ulai-js-sdk 0.1.0 → 0.1.2
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 +557 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +149 -42
- package/dist/room.d.ts +29 -1
- package/dist/room.js +239 -52
- package/dist/signaling.d.ts +11 -0
- package/dist/signaling.js +139 -24
- package/dist/types.d.ts +6 -4
- package/package.json +14 -4
- package/src/client.ts +0 -59
- package/src/events.ts +0 -64
- package/src/index.ts +0 -5
- package/src/room.ts +0 -163
- package/src/signaling.ts +0 -53
- package/src/types.ts +0 -44
- package/tsconfig.json +0 -15
package/dist/events.js
CHANGED
|
@@ -8,63 +8,170 @@ const eventemitter3_1 = __importDefault(require("eventemitter3"));
|
|
|
8
8
|
class EventsClient extends eventemitter3_1.default {
|
|
9
9
|
ws = null;
|
|
10
10
|
url;
|
|
11
|
+
sessionId = null;
|
|
12
|
+
eventsToken = null;
|
|
13
|
+
lastEventId = null;
|
|
14
|
+
isClosedManually = false;
|
|
15
|
+
retryAttempts = 0;
|
|
16
|
+
maxRetries = 5;
|
|
17
|
+
reconnectTimer = null;
|
|
18
|
+
heartbeatTimer = null;
|
|
11
19
|
constructor(cpBase) {
|
|
12
20
|
super();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
this.url = this.buildUrl(cpBase);
|
|
22
|
+
}
|
|
23
|
+
buildUrl(cpBase) {
|
|
24
|
+
const cleanHost = cpBase
|
|
25
|
+
.replace(/^https?:\/\//, '')
|
|
26
|
+
.replace(/^wss?:\/\//, '')
|
|
27
|
+
.replace(/\/$/, '');
|
|
28
|
+
const isLocal = cleanHost.startsWith('localhost') ||
|
|
29
|
+
cleanHost.startsWith('127.0.0.1');
|
|
30
|
+
let scheme = 'wss';
|
|
31
|
+
if (isLocal) {
|
|
32
|
+
scheme = 'ws';
|
|
33
|
+
}
|
|
34
|
+
else if (typeof window !== 'undefined' && window.location.protocol === 'http:') {
|
|
35
|
+
scheme = 'ws';
|
|
36
|
+
}
|
|
37
|
+
else if (cpBase.startsWith('http://')) {
|
|
38
|
+
scheme = 'ws';
|
|
39
|
+
}
|
|
40
|
+
return `${scheme}://${cleanHost}/api/v1/events`;
|
|
16
41
|
}
|
|
17
42
|
connect(sessionId, eventsToken) {
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
this.sessionId = sessionId;
|
|
44
|
+
this.eventsToken = eventsToken;
|
|
45
|
+
this.isClosedManually = false;
|
|
46
|
+
this.cleanupSocket();
|
|
47
|
+
try {
|
|
48
|
+
this.ws = new WebSocket(this.url);
|
|
49
|
+
this.ws.onopen = () => {
|
|
50
|
+
this.retryAttempts = 0;
|
|
51
|
+
this.sendSubscribe();
|
|
52
|
+
this.startHeartbeat();
|
|
53
|
+
};
|
|
54
|
+
this.ws.onmessage = (event) => {
|
|
55
|
+
try {
|
|
56
|
+
const msg = JSON.parse(event.data);
|
|
57
|
+
if (msg.id) {
|
|
58
|
+
this.lastEventId = msg.id;
|
|
59
|
+
}
|
|
60
|
+
if (msg.type === 'subscribed') {
|
|
61
|
+
this.emit('connected', msg);
|
|
62
|
+
}
|
|
63
|
+
else if (msg.type === 'transcript_delta') {
|
|
64
|
+
this.emit('transcript_delta', msg);
|
|
65
|
+
}
|
|
66
|
+
else if (msg.type === 'roster') {
|
|
67
|
+
this.emit('roster', msg);
|
|
68
|
+
}
|
|
69
|
+
else if (msg.type === 'session_terminated') {
|
|
70
|
+
this.emit('session_terminated', msg);
|
|
71
|
+
}
|
|
72
|
+
else if (msg.type === 'participant_joined') {
|
|
73
|
+
this.emit('participant_joined', msg);
|
|
74
|
+
}
|
|
75
|
+
else if (msg.type === 'participant_left') {
|
|
76
|
+
this.emit('participant_left', msg);
|
|
77
|
+
}
|
|
78
|
+
else if (msg.type === 'error') {
|
|
79
|
+
this.emit('error', msg);
|
|
80
|
+
}
|
|
35
81
|
}
|
|
36
|
-
|
|
37
|
-
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error('[Events] Failed to parse message', err);
|
|
38
84
|
}
|
|
39
|
-
|
|
40
|
-
|
|
85
|
+
};
|
|
86
|
+
this.ws.onclose = (event) => {
|
|
87
|
+
this.stopHeartbeat();
|
|
88
|
+
if (this.isClosedManually) {
|
|
89
|
+
this.emit('disconnected');
|
|
90
|
+
return;
|
|
41
91
|
}
|
|
42
|
-
|
|
43
|
-
this.
|
|
92
|
+
if (this.retryAttempts < this.maxRetries) {
|
|
93
|
+
this.retryAttempts++;
|
|
94
|
+
const delay = Math.min(1000 * Math.pow(1.5, this.retryAttempts), 10000);
|
|
95
|
+
console.warn(`[Events] Event socket closed. Reconnecting in ${Math.round(delay)}ms (attempt ${this.retryAttempts}/${this.maxRetries})...`);
|
|
96
|
+
this.emit('reconnecting', { attempt: this.retryAttempts });
|
|
97
|
+
this.reconnectTimer = setTimeout(() => {
|
|
98
|
+
if (!this.isClosedManually && this.sessionId && this.eventsToken) {
|
|
99
|
+
this.connect(this.sessionId, this.eventsToken);
|
|
100
|
+
}
|
|
101
|
+
}, delay);
|
|
44
102
|
}
|
|
45
|
-
else
|
|
46
|
-
|
|
103
|
+
else {
|
|
104
|
+
console.error('[Events] Max reconnect attempts exceeded for events socket');
|
|
105
|
+
this.emit('disconnected', event);
|
|
47
106
|
}
|
|
48
|
-
|
|
49
|
-
|
|
107
|
+
};
|
|
108
|
+
this.ws.onerror = (error) => {
|
|
109
|
+
this.emit('error', error);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
this.emit('error', err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
sendSubscribe() {
|
|
117
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.sessionId || !this.eventsToken) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const payload = {
|
|
121
|
+
action: 'subscribe',
|
|
122
|
+
scope: 'session',
|
|
123
|
+
session_id: this.sessionId,
|
|
124
|
+
token: this.eventsToken,
|
|
125
|
+
};
|
|
126
|
+
if (this.lastEventId) {
|
|
127
|
+
payload.last_event_id = this.lastEventId;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
this.ws.send(JSON.stringify(payload));
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.error('[Events] Failed to send subscription', err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
startHeartbeat() {
|
|
137
|
+
this.stopHeartbeat();
|
|
138
|
+
this.heartbeatTimer = setInterval(() => {
|
|
139
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
140
|
+
try {
|
|
141
|
+
this.ws.send(JSON.stringify({ action: 'ping' }));
|
|
50
142
|
}
|
|
143
|
+
catch (_) { }
|
|
51
144
|
}
|
|
52
|
-
|
|
53
|
-
console.error('Failed to parse events message', err);
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
this.ws.onclose = () => {
|
|
57
|
-
this.emit('disconnected');
|
|
58
|
-
};
|
|
59
|
-
this.ws.onerror = (error) => {
|
|
60
|
-
this.emit('error', error);
|
|
61
|
-
};
|
|
145
|
+
}, 25000);
|
|
62
146
|
}
|
|
63
|
-
|
|
147
|
+
stopHeartbeat() {
|
|
148
|
+
if (this.heartbeatTimer) {
|
|
149
|
+
clearInterval(this.heartbeatTimer);
|
|
150
|
+
this.heartbeatTimer = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
cleanupSocket() {
|
|
154
|
+
this.stopHeartbeat();
|
|
155
|
+
if (this.reconnectTimer) {
|
|
156
|
+
clearTimeout(this.reconnectTimer);
|
|
157
|
+
this.reconnectTimer = null;
|
|
158
|
+
}
|
|
64
159
|
if (this.ws) {
|
|
65
|
-
this.ws.
|
|
160
|
+
this.ws.onopen = null;
|
|
161
|
+
this.ws.onmessage = null;
|
|
162
|
+
this.ws.onclose = null;
|
|
163
|
+
this.ws.onerror = null;
|
|
164
|
+
try {
|
|
165
|
+
this.ws.close();
|
|
166
|
+
}
|
|
167
|
+
catch (_) { }
|
|
66
168
|
this.ws = null;
|
|
67
169
|
}
|
|
68
170
|
}
|
|
171
|
+
close() {
|
|
172
|
+
this.isClosedManually = true;
|
|
173
|
+
this.cleanupSocket();
|
|
174
|
+
this.emit('disconnected');
|
|
175
|
+
}
|
|
69
176
|
}
|
|
70
177
|
exports.EventsClient = EventsClient;
|
package/dist/room.d.ts
CHANGED
|
@@ -5,14 +5,42 @@ export declare class Room extends EventEmitter<RoomEvent> {
|
|
|
5
5
|
private signaling;
|
|
6
6
|
private eventsClient;
|
|
7
7
|
private peerConnection;
|
|
8
|
+
private audioTransceiver;
|
|
8
9
|
private localStream;
|
|
9
10
|
private remoteStreams;
|
|
10
11
|
roomId: string | null;
|
|
11
12
|
participantId: string | null;
|
|
13
|
+
private readonly isPolite;
|
|
14
|
+
private makingOffer;
|
|
15
|
+
private candidateQueue;
|
|
16
|
+
private signalQueue;
|
|
12
17
|
constructor(cpBase: string);
|
|
13
18
|
connect(options: JoinRoomOptions): Promise<void>;
|
|
14
19
|
private setupEventsClient;
|
|
15
20
|
private setupSignaling;
|
|
16
|
-
|
|
21
|
+
private handleSignal;
|
|
22
|
+
private safeNegotiate;
|
|
23
|
+
private handleIncomingCandidate;
|
|
24
|
+
private drainCandidateQueue;
|
|
25
|
+
/**
|
|
26
|
+
* Publishes local microphone audio to the room.
|
|
27
|
+
*/
|
|
28
|
+
publishAudio(): Promise<MediaStream>;
|
|
29
|
+
/**
|
|
30
|
+
* Sets microphone mute state.
|
|
31
|
+
*/
|
|
32
|
+
setMuted(muted: boolean): void;
|
|
33
|
+
/**
|
|
34
|
+
* Returns whether the microphone is currently muted.
|
|
35
|
+
*/
|
|
36
|
+
isMuted(): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Gets the active local media stream.
|
|
39
|
+
*/
|
|
40
|
+
getLocalStream(): MediaStream | null;
|
|
41
|
+
/**
|
|
42
|
+
* Gets all subscribed remote audio streams.
|
|
43
|
+
*/
|
|
44
|
+
getRemoteStreams(): MediaStream[];
|
|
17
45
|
disconnect(): void;
|
|
18
46
|
}
|
package/dist/room.js
CHANGED
|
@@ -12,10 +12,18 @@ class Room extends eventemitter3_1.default {
|
|
|
12
12
|
signaling = null;
|
|
13
13
|
eventsClient = null;
|
|
14
14
|
peerConnection = null;
|
|
15
|
+
audioTransceiver = null;
|
|
15
16
|
localStream = null;
|
|
16
17
|
remoteStreams = new Map();
|
|
17
18
|
roomId = null;
|
|
18
19
|
participantId = null;
|
|
20
|
+
// Perfect Negotiation State (Client is the polite peer)
|
|
21
|
+
isPolite = true;
|
|
22
|
+
makingOffer = false;
|
|
23
|
+
candidateQueue = [];
|
|
24
|
+
// Signaling messages are handled one at a time, in arrival order. Handled concurrently, a bridge
|
|
25
|
+
// offer could be processed in the middle of our own offer and dropped.
|
|
26
|
+
signalQueue = Promise.resolve();
|
|
19
27
|
constructor(cpBase) {
|
|
20
28
|
super();
|
|
21
29
|
this.cpBase = cpBase;
|
|
@@ -23,43 +31,75 @@ class Room extends eventemitter3_1.default {
|
|
|
23
31
|
async connect(options) {
|
|
24
32
|
this.roomId = options.roomId;
|
|
25
33
|
this.participantId = options.participantId || '';
|
|
26
|
-
// 1. Setup Control Plane Events
|
|
34
|
+
// 1. Setup Control Plane Events (Transcripts, Roster, Session Lifecycle)
|
|
27
35
|
if (options.eventsToken) {
|
|
28
36
|
this.eventsClient = new events_1.EventsClient(this.cpBase);
|
|
29
37
|
this.setupEventsClient();
|
|
30
38
|
this.eventsClient.connect(this.roomId, options.eventsToken);
|
|
31
39
|
}
|
|
32
|
-
// 2. Setup WebRTC Signaling
|
|
40
|
+
// 2. Setup WebRTC Signaling & Media
|
|
33
41
|
if (options.nodeAddress && options.ticket) {
|
|
34
42
|
this.signaling = new signaling_1.SignalingClient(options.nodeAddress, options.ticket);
|
|
35
43
|
this.setupSignaling();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
const defaultIceServers = [
|
|
45
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
46
|
+
];
|
|
47
|
+
const iceServers = options.iceServers && options.iceServers.length > 0
|
|
48
|
+
? options.iceServers
|
|
49
|
+
: defaultIceServers;
|
|
50
|
+
this.peerConnection = new RTCPeerConnection({ iceServers });
|
|
51
|
+
// Every local change that needs an offer (the initial transceiver, publishing the mic, an
|
|
52
|
+
// offer rolled back by a collision) fires this. Offers sent before signaling opens wait in
|
|
53
|
+
// its outbox.
|
|
54
|
+
this.peerConnection.onnegotiationneeded = () => {
|
|
55
|
+
void this.safeNegotiate();
|
|
56
|
+
};
|
|
57
|
+
// Pre-allocate audio transceiver as recvonly so the initial offer immediately
|
|
58
|
+
// negotiates audio codec capabilities (m=audio) and is ready for agent downlink.
|
|
59
|
+
try {
|
|
60
|
+
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
|
|
61
|
+
direction: 'recvonly',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.warn('[Room] Error adding initial audio transceiver:', err);
|
|
66
|
+
}
|
|
42
67
|
this.peerConnection.onicecandidate = (event) => {
|
|
43
68
|
if (event.candidate && this.signaling) {
|
|
44
|
-
this.signaling.send({
|
|
69
|
+
this.signaling.send({
|
|
70
|
+
type: 'candidate',
|
|
71
|
+
candidate: JSON.stringify(event.candidate.toJSON()),
|
|
72
|
+
});
|
|
45
73
|
}
|
|
46
74
|
};
|
|
75
|
+
// Resilient ontrack handler: works with stream-grouped tracks and bare Pion SFU tracks
|
|
47
76
|
this.peerConnection.ontrack = (event) => {
|
|
48
|
-
const stream = event.streams[0]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
77
|
+
const stream = event.streams && event.streams[0]
|
|
78
|
+
? event.streams[0]
|
|
79
|
+
: new MediaStream([event.track]);
|
|
80
|
+
this.remoteStreams.set(stream.id, stream);
|
|
81
|
+
this.emit('trackSubscribed', event.track, stream);
|
|
82
|
+
event.track.onended = () => {
|
|
83
|
+
this.remoteStreams.delete(stream.id);
|
|
84
|
+
this.emit('trackUnsubscribed', event.track, stream);
|
|
85
|
+
};
|
|
53
86
|
};
|
|
54
87
|
this.peerConnection.onconnectionstatechange = () => {
|
|
55
|
-
|
|
88
|
+
const state = this.peerConnection?.connectionState;
|
|
89
|
+
switch (state) {
|
|
56
90
|
case 'connected':
|
|
57
91
|
this.emit('connected');
|
|
58
92
|
break;
|
|
93
|
+
case 'connecting':
|
|
94
|
+
this.emit('reconnecting');
|
|
95
|
+
break;
|
|
59
96
|
case 'disconnected':
|
|
60
97
|
case 'failed':
|
|
61
98
|
this.emit('disconnected');
|
|
62
99
|
break;
|
|
100
|
+
case 'closed':
|
|
101
|
+
this.emit('disconnected');
|
|
102
|
+
break;
|
|
63
103
|
}
|
|
64
104
|
};
|
|
65
105
|
this.signaling.connect();
|
|
@@ -81,65 +121,212 @@ class Room extends eventemitter3_1.default {
|
|
|
81
121
|
setupSignaling() {
|
|
82
122
|
if (!this.signaling)
|
|
83
123
|
return;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await this.peerConnection.setLocalDescription(offer);
|
|
89
|
-
this.signaling.send({ type: 'offer', sdp: this.peerConnection.localDescription?.sdp });
|
|
90
|
-
}
|
|
124
|
+
// The initial offer comes from onnegotiationneeded and is flushed from the outbox on connect.
|
|
125
|
+
this.signaling.on('reconnected', async () => {
|
|
126
|
+
this.emit('reconnected');
|
|
127
|
+
await this.safeNegotiate();
|
|
91
128
|
});
|
|
92
|
-
this.signaling.on('message',
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
this.signaling.on('message', (msg) => {
|
|
130
|
+
this.signalQueue = this.signalQueue.then(() => this.handleSignal(msg));
|
|
131
|
+
});
|
|
132
|
+
this.signaling.on('disconnected', () => {
|
|
133
|
+
this.emit('disconnected');
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async handleSignal(msg) {
|
|
137
|
+
const pc = this.peerConnection;
|
|
138
|
+
if (!pc)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
if (msg.type === 'offer' || msg.type === 'restart-offer') {
|
|
142
|
+
if (!msg.sdp)
|
|
143
|
+
return;
|
|
144
|
+
// Perfect negotiation: detect collision (SDP Glare)
|
|
145
|
+
const offerCollision = this.makingOffer || pc.signalingState !== 'stable';
|
|
146
|
+
if (offerCollision && !this.isPolite) {
|
|
147
|
+
console.warn('[Room] Impolite collision: ignoring incoming offer');
|
|
148
|
+
return;
|
|
101
149
|
}
|
|
102
|
-
|
|
103
|
-
|
|
150
|
+
if (offerCollision) {
|
|
151
|
+
console.log('[Room] Polite collision: rolling back local offer to accept server offer');
|
|
104
152
|
}
|
|
105
|
-
|
|
106
|
-
|
|
153
|
+
// On a collision this implicitly rolls back our own offer, even one still being made. An
|
|
154
|
+
// explicit rollback throws if we're stable by the time it runs, which is what happens when
|
|
155
|
+
// the bridge sends its answer and then its own offer right away (someone already talking),
|
|
156
|
+
// and the bridge's offer was dropped. Whatever our offer carried is offered again via
|
|
157
|
+
// negotiationneeded once we're back to stable.
|
|
158
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
|
|
159
|
+
await this.drainCandidateQueue();
|
|
160
|
+
await pc.setLocalDescription();
|
|
161
|
+
this.signaling?.send({
|
|
162
|
+
type: 'answer',
|
|
163
|
+
sdp: pc.localDescription?.sdp,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else if (msg.type === 'answer') {
|
|
167
|
+
if (!msg.sdp)
|
|
168
|
+
return;
|
|
169
|
+
// An answer to an offer we've since rolled back has nothing to apply to.
|
|
170
|
+
if (pc.signalingState !== 'have-local-offer') {
|
|
171
|
+
console.warn('[Room] Ignoring answer in signalingState', pc.signalingState);
|
|
172
|
+
return;
|
|
107
173
|
}
|
|
174
|
+
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
|
|
175
|
+
await this.drainCandidateQueue();
|
|
108
176
|
}
|
|
109
|
-
|
|
110
|
-
|
|
177
|
+
else if (msg.type === 'candidate' && msg.candidate) {
|
|
178
|
+
const candidateInit = JSON.parse(msg.candidate);
|
|
179
|
+
await this.handleIncomingCandidate(candidateInit);
|
|
111
180
|
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error('[Room] Error handling signaling message:', err);
|
|
184
|
+
this.emit('error', err);
|
|
185
|
+
}
|
|
116
186
|
}
|
|
187
|
+
async safeNegotiate() {
|
|
188
|
+
if (!this.peerConnection || this.makingOffer)
|
|
189
|
+
return;
|
|
190
|
+
// Mid-negotiation, setLocalDescription() would make an answer, not an offer. negotiationneeded
|
|
191
|
+
// fires again once the current exchange completes.
|
|
192
|
+
if (this.peerConnection.signalingState !== 'stable')
|
|
193
|
+
return;
|
|
194
|
+
try {
|
|
195
|
+
this.makingOffer = true;
|
|
196
|
+
// Without an argument the offer is created when it's applied, so it can't go stale against
|
|
197
|
+
// an offer from the bridge that arrives meanwhile.
|
|
198
|
+
await this.peerConnection.setLocalDescription();
|
|
199
|
+
if (this.peerConnection.pendingLocalDescription?.type !== 'offer')
|
|
200
|
+
return; // rolled back
|
|
201
|
+
this.signaling?.send({
|
|
202
|
+
type: 'offer',
|
|
203
|
+
sdp: this.peerConnection.localDescription?.sdp,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.error('[Room] safeNegotiate error:', err);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.makingOffer = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async handleIncomingCandidate(candidate) {
|
|
214
|
+
if (!this.peerConnection?.remoteDescription || !this.peerConnection.remoteDescription.type) {
|
|
215
|
+
// Buffer candidates if remote description is not set yet
|
|
216
|
+
this.candidateQueue.push(candidate);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await this.peerConnection.addIceCandidate(candidate);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.warn('[Room] Failed to add ICE candidate:', err);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async drainCandidateQueue() {
|
|
227
|
+
while (this.candidateQueue.length > 0) {
|
|
228
|
+
const candidate = this.candidateQueue.shift();
|
|
229
|
+
if (candidate && this.peerConnection?.remoteDescription) {
|
|
230
|
+
try {
|
|
231
|
+
await this.peerConnection.addIceCandidate(candidate);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.warn('[Room] Error draining candidate:', err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Publishes local microphone audio to the room.
|
|
241
|
+
*/
|
|
117
242
|
async publishAudio() {
|
|
118
243
|
try {
|
|
119
|
-
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
244
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
245
|
+
audio: {
|
|
246
|
+
echoCancellation: true,
|
|
247
|
+
noiseSuppression: true,
|
|
248
|
+
autoGainControl: true,
|
|
249
|
+
},
|
|
250
|
+
video: false,
|
|
251
|
+
});
|
|
252
|
+
const audioTrack = this.localStream.getAudioTracks()[0];
|
|
253
|
+
if (this.peerConnection && audioTrack) {
|
|
254
|
+
if (this.audioTransceiver) {
|
|
255
|
+
this.audioTransceiver.direction = 'sendrecv';
|
|
256
|
+
if (this.audioTransceiver.sender) {
|
|
257
|
+
await this.audioTransceiver.sender.replaceTrack(audioTrack);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const transceiver = this.peerConnection
|
|
262
|
+
.getTransceivers()
|
|
263
|
+
.find((t) => t.receiver.track.kind === 'audio');
|
|
264
|
+
if (transceiver) {
|
|
265
|
+
this.audioTransceiver = transceiver;
|
|
266
|
+
this.audioTransceiver.direction = 'sendrecv';
|
|
267
|
+
await this.audioTransceiver.sender.replaceTrack(audioTrack);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
this.peerConnection.addTrack(audioTrack, this.localStream);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// The direction change (or addTrack) fires negotiationneeded, which sends the offer.
|
|
128
274
|
}
|
|
275
|
+
return this.localStream;
|
|
129
276
|
}
|
|
130
277
|
catch (err) {
|
|
131
|
-
console.error('Failed to publish audio:', err);
|
|
278
|
+
console.error('[Room] Failed to publish audio:', err);
|
|
132
279
|
this.emit('error', err);
|
|
280
|
+
throw err;
|
|
133
281
|
}
|
|
134
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Sets microphone mute state.
|
|
285
|
+
*/
|
|
286
|
+
setMuted(muted) {
|
|
287
|
+
if (this.localStream) {
|
|
288
|
+
this.localStream.getAudioTracks().forEach((track) => {
|
|
289
|
+
track.enabled = !muted;
|
|
290
|
+
});
|
|
291
|
+
this.emit('muteChanged', muted);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Returns whether the microphone is currently muted.
|
|
296
|
+
*/
|
|
297
|
+
isMuted() {
|
|
298
|
+
if (!this.localStream)
|
|
299
|
+
return true;
|
|
300
|
+
const tracks = this.localStream.getAudioTracks();
|
|
301
|
+
return tracks.length === 0 || !tracks[0].enabled;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Gets the active local media stream.
|
|
305
|
+
*/
|
|
306
|
+
getLocalStream() {
|
|
307
|
+
return this.localStream;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Gets all subscribed remote audio streams.
|
|
311
|
+
*/
|
|
312
|
+
getRemoteStreams() {
|
|
313
|
+
return Array.from(this.remoteStreams.values());
|
|
314
|
+
}
|
|
135
315
|
disconnect() {
|
|
136
316
|
if (this.localStream) {
|
|
137
|
-
this.localStream.getTracks().forEach(track => track.stop());
|
|
317
|
+
this.localStream.getTracks().forEach((track) => track.stop());
|
|
318
|
+
this.localStream = null;
|
|
138
319
|
}
|
|
139
320
|
if (this.peerConnection) {
|
|
321
|
+
this.peerConnection.onicecandidate = null;
|
|
322
|
+
this.peerConnection.ontrack = null;
|
|
323
|
+
this.peerConnection.onconnectionstatechange = null;
|
|
140
324
|
this.peerConnection.close();
|
|
141
325
|
this.peerConnection = null;
|
|
142
326
|
}
|
|
327
|
+
this.audioTransceiver = null;
|
|
328
|
+
this.candidateQueue = [];
|
|
329
|
+
this.remoteStreams.clear();
|
|
143
330
|
if (this.signaling) {
|
|
144
331
|
this.signaling.close();
|
|
145
332
|
this.signaling = null;
|
package/dist/signaling.d.ts
CHANGED
|
@@ -3,8 +3,19 @@ import { SignalMessage } from './types';
|
|
|
3
3
|
export declare class SignalingClient extends EventEmitter {
|
|
4
4
|
private ws;
|
|
5
5
|
private url;
|
|
6
|
+
private outbox;
|
|
7
|
+
private isClosedManually;
|
|
8
|
+
private retryAttempts;
|
|
9
|
+
private readonly maxRetries;
|
|
10
|
+
private reconnectTimer;
|
|
11
|
+
private heartbeatTimer;
|
|
6
12
|
constructor(nodeAddress: string, ticket: string);
|
|
13
|
+
private buildUrl;
|
|
7
14
|
connect(): void;
|
|
8
15
|
send(message: SignalMessage): void;
|
|
16
|
+
private flushOutbox;
|
|
17
|
+
private startHeartbeat;
|
|
18
|
+
private stopHeartbeat;
|
|
19
|
+
private cleanupSocket;
|
|
9
20
|
close(): void;
|
|
10
21
|
}
|