ulai-js-sdk 0.1.0 → 0.1.1
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 +27 -1
- package/dist/room.js +208 -39
- 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,40 @@ 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;
|
|
12
16
|
constructor(cpBase: string);
|
|
13
17
|
connect(options: JoinRoomOptions): Promise<void>;
|
|
14
18
|
private setupEventsClient;
|
|
15
19
|
private setupSignaling;
|
|
16
|
-
|
|
20
|
+
private safeNegotiate;
|
|
21
|
+
private handleIncomingCandidate;
|
|
22
|
+
private drainCandidateQueue;
|
|
23
|
+
/**
|
|
24
|
+
* Publishes local microphone audio to the room.
|
|
25
|
+
*/
|
|
26
|
+
publishAudio(): Promise<MediaStream>;
|
|
27
|
+
/**
|
|
28
|
+
* Sets microphone mute state.
|
|
29
|
+
*/
|
|
30
|
+
setMuted(muted: boolean): void;
|
|
31
|
+
/**
|
|
32
|
+
* Returns whether the microphone is currently muted.
|
|
33
|
+
*/
|
|
34
|
+
isMuted(): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Gets the active local media stream.
|
|
37
|
+
*/
|
|
38
|
+
getLocalStream(): MediaStream | null;
|
|
39
|
+
/**
|
|
40
|
+
* Gets all subscribed remote audio streams.
|
|
41
|
+
*/
|
|
42
|
+
getRemoteStreams(): MediaStream[];
|
|
17
43
|
disconnect(): void;
|
|
18
44
|
}
|
package/dist/room.js
CHANGED
|
@@ -12,10 +12,15 @@ 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 = [];
|
|
19
24
|
constructor(cpBase) {
|
|
20
25
|
super();
|
|
21
26
|
this.cpBase = cpBase;
|
|
@@ -23,43 +28,69 @@ class Room extends eventemitter3_1.default {
|
|
|
23
28
|
async connect(options) {
|
|
24
29
|
this.roomId = options.roomId;
|
|
25
30
|
this.participantId = options.participantId || '';
|
|
26
|
-
// 1. Setup Control Plane Events
|
|
31
|
+
// 1. Setup Control Plane Events (Transcripts, Roster, Session Lifecycle)
|
|
27
32
|
if (options.eventsToken) {
|
|
28
33
|
this.eventsClient = new events_1.EventsClient(this.cpBase);
|
|
29
34
|
this.setupEventsClient();
|
|
30
35
|
this.eventsClient.connect(this.roomId, options.eventsToken);
|
|
31
36
|
}
|
|
32
|
-
// 2. Setup WebRTC Signaling
|
|
37
|
+
// 2. Setup WebRTC Signaling & Media
|
|
33
38
|
if (options.nodeAddress && options.ticket) {
|
|
34
39
|
this.signaling = new signaling_1.SignalingClient(options.nodeAddress, options.ticket);
|
|
35
40
|
this.setupSignaling();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
const defaultIceServers = [
|
|
42
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
43
|
+
];
|
|
44
|
+
const iceServers = options.iceServers && options.iceServers.length > 0
|
|
45
|
+
? options.iceServers
|
|
46
|
+
: defaultIceServers;
|
|
47
|
+
this.peerConnection = new RTCPeerConnection({ iceServers });
|
|
48
|
+
// Pre-allocate audio transceiver as recvonly so the initial offer immediately
|
|
49
|
+
// negotiates audio codec capabilities (m=audio) and is ready for agent downlink.
|
|
50
|
+
try {
|
|
51
|
+
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
|
|
52
|
+
direction: 'recvonly',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.warn('[Room] Error adding initial audio transceiver:', err);
|
|
57
|
+
}
|
|
42
58
|
this.peerConnection.onicecandidate = (event) => {
|
|
43
59
|
if (event.candidate && this.signaling) {
|
|
44
|
-
this.signaling.send({
|
|
60
|
+
this.signaling.send({
|
|
61
|
+
type: 'candidate',
|
|
62
|
+
candidate: JSON.stringify(event.candidate.toJSON()),
|
|
63
|
+
});
|
|
45
64
|
}
|
|
46
65
|
};
|
|
66
|
+
// Resilient ontrack handler: works with stream-grouped tracks and bare Pion SFU tracks
|
|
47
67
|
this.peerConnection.ontrack = (event) => {
|
|
48
|
-
const stream = event.streams[0]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
const stream = event.streams && event.streams[0]
|
|
69
|
+
? event.streams[0]
|
|
70
|
+
: new MediaStream([event.track]);
|
|
71
|
+
this.remoteStreams.set(stream.id, stream);
|
|
72
|
+
this.emit('trackSubscribed', event.track, stream);
|
|
73
|
+
event.track.onended = () => {
|
|
74
|
+
this.remoteStreams.delete(stream.id);
|
|
75
|
+
this.emit('trackUnsubscribed', event.track, stream);
|
|
76
|
+
};
|
|
53
77
|
};
|
|
54
78
|
this.peerConnection.onconnectionstatechange = () => {
|
|
55
|
-
|
|
79
|
+
const state = this.peerConnection?.connectionState;
|
|
80
|
+
switch (state) {
|
|
56
81
|
case 'connected':
|
|
57
82
|
this.emit('connected');
|
|
58
83
|
break;
|
|
84
|
+
case 'connecting':
|
|
85
|
+
this.emit('reconnecting');
|
|
86
|
+
break;
|
|
59
87
|
case 'disconnected':
|
|
60
88
|
case 'failed':
|
|
61
89
|
this.emit('disconnected');
|
|
62
90
|
break;
|
|
91
|
+
case 'closed':
|
|
92
|
+
this.emit('disconnected');
|
|
93
|
+
break;
|
|
63
94
|
}
|
|
64
95
|
};
|
|
65
96
|
this.signaling.connect();
|
|
@@ -82,64 +113,202 @@ class Room extends eventemitter3_1.default {
|
|
|
82
113
|
if (!this.signaling)
|
|
83
114
|
return;
|
|
84
115
|
this.signaling.on('connected', async () => {
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
// Initiate negotiation when signaling is ready
|
|
117
|
+
await this.safeNegotiate();
|
|
118
|
+
});
|
|
119
|
+
this.signaling.on('reconnected', async () => {
|
|
120
|
+
this.emit('reconnected');
|
|
121
|
+
await this.safeNegotiate();
|
|
91
122
|
});
|
|
92
123
|
this.signaling.on('message', async (msg) => {
|
|
93
124
|
if (!this.peerConnection)
|
|
94
125
|
return;
|
|
95
126
|
try {
|
|
96
127
|
if (msg.type === 'offer' || msg.type === 'restart-offer') {
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
128
|
+
// Perfect negotiation: detect collision (SDP Glare)
|
|
129
|
+
const offerCollision = this.makingOffer || this.peerConnection.signalingState !== 'stable';
|
|
130
|
+
if (offerCollision && !this.isPolite) {
|
|
131
|
+
console.warn('[Room] Impolite collision: ignoring incoming offer');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (offerCollision && this.isPolite) {
|
|
135
|
+
// Polite peer: rollback local offer to accept the incoming server offer
|
|
136
|
+
console.log('[Room] Polite collision: rolling back local offer to accept server offer');
|
|
137
|
+
await this.peerConnection.setLocalDescription({ type: 'rollback' });
|
|
138
|
+
}
|
|
139
|
+
if (msg.sdp) {
|
|
140
|
+
await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: msg.sdp }));
|
|
141
|
+
await this.drainCandidateQueue();
|
|
142
|
+
const answer = await this.peerConnection.createAnswer();
|
|
143
|
+
await this.peerConnection.setLocalDescription(answer);
|
|
144
|
+
this.signaling.send({
|
|
145
|
+
type: 'answer',
|
|
146
|
+
sdp: this.peerConnection.localDescription?.sdp,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
101
149
|
}
|
|
102
150
|
else if (msg.type === 'answer') {
|
|
103
|
-
|
|
151
|
+
if (msg.sdp) {
|
|
152
|
+
await this.peerConnection.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: msg.sdp }));
|
|
153
|
+
await this.drainCandidateQueue();
|
|
154
|
+
}
|
|
104
155
|
}
|
|
105
156
|
else if (msg.type === 'candidate' && msg.candidate) {
|
|
106
|
-
|
|
157
|
+
const candidateInit = JSON.parse(msg.candidate);
|
|
158
|
+
await this.handleIncomingCandidate(candidateInit);
|
|
107
159
|
}
|
|
108
160
|
}
|
|
109
161
|
catch (err) {
|
|
110
|
-
console.error('Error handling signaling message', err);
|
|
162
|
+
console.error('[Room] Error handling signaling message:', err);
|
|
163
|
+
this.emit('error', err);
|
|
111
164
|
}
|
|
112
165
|
});
|
|
113
166
|
this.signaling.on('disconnected', () => {
|
|
114
167
|
this.emit('disconnected');
|
|
115
168
|
});
|
|
116
169
|
}
|
|
170
|
+
async safeNegotiate() {
|
|
171
|
+
if (!this.peerConnection || this.makingOffer)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
this.makingOffer = true;
|
|
175
|
+
const offer = await this.peerConnection.createOffer();
|
|
176
|
+
// Only set local description if signalingState is stable
|
|
177
|
+
if (this.peerConnection.signalingState !== 'stable') {
|
|
178
|
+
console.warn('[Room] Skip makeOffer: signalingState is', this.peerConnection.signalingState);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await this.peerConnection.setLocalDescription(offer);
|
|
182
|
+
this.signaling?.send({
|
|
183
|
+
type: 'offer',
|
|
184
|
+
sdp: this.peerConnection.localDescription?.sdp,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error('[Room] safeNegotiate error:', err);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
this.makingOffer = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async handleIncomingCandidate(candidate) {
|
|
195
|
+
if (!this.peerConnection?.remoteDescription || !this.peerConnection.remoteDescription.type) {
|
|
196
|
+
// Buffer candidates if remote description is not set yet
|
|
197
|
+
this.candidateQueue.push(candidate);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await this.peerConnection.addIceCandidate(candidate);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.warn('[Room] Failed to add ICE candidate:', err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async drainCandidateQueue() {
|
|
208
|
+
while (this.candidateQueue.length > 0) {
|
|
209
|
+
const candidate = this.candidateQueue.shift();
|
|
210
|
+
if (candidate && this.peerConnection?.remoteDescription) {
|
|
211
|
+
try {
|
|
212
|
+
await this.peerConnection.addIceCandidate(candidate);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
console.warn('[Room] Error draining candidate:', err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Publishes local microphone audio to the room.
|
|
222
|
+
*/
|
|
117
223
|
async publishAudio() {
|
|
118
224
|
try {
|
|
119
|
-
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
225
|
+
this.localStream = await navigator.mediaDevices.getUserMedia({
|
|
226
|
+
audio: {
|
|
227
|
+
echoCancellation: true,
|
|
228
|
+
noiseSuppression: true,
|
|
229
|
+
autoGainControl: true,
|
|
230
|
+
},
|
|
231
|
+
video: false,
|
|
232
|
+
});
|
|
233
|
+
const audioTrack = this.localStream.getAudioTracks()[0];
|
|
234
|
+
if (this.peerConnection && audioTrack) {
|
|
235
|
+
if (this.audioTransceiver) {
|
|
236
|
+
this.audioTransceiver.direction = 'sendrecv';
|
|
237
|
+
if (this.audioTransceiver.sender) {
|
|
238
|
+
await this.audioTransceiver.sender.replaceTrack(audioTrack);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const transceiver = this.peerConnection
|
|
243
|
+
.getTransceivers()
|
|
244
|
+
.find((t) => t.receiver.track.kind === 'audio');
|
|
245
|
+
if (transceiver) {
|
|
246
|
+
this.audioTransceiver = transceiver;
|
|
247
|
+
this.audioTransceiver.direction = 'sendrecv';
|
|
248
|
+
await this.audioTransceiver.sender.replaceTrack(audioTrack);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
this.peerConnection.addTrack(audioTrack, this.localStream);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Trigger safe renegotiation
|
|
255
|
+
await this.safeNegotiate();
|
|
128
256
|
}
|
|
257
|
+
return this.localStream;
|
|
129
258
|
}
|
|
130
259
|
catch (err) {
|
|
131
|
-
console.error('Failed to publish audio:', err);
|
|
260
|
+
console.error('[Room] Failed to publish audio:', err);
|
|
132
261
|
this.emit('error', err);
|
|
262
|
+
throw err;
|
|
133
263
|
}
|
|
134
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Sets microphone mute state.
|
|
267
|
+
*/
|
|
268
|
+
setMuted(muted) {
|
|
269
|
+
if (this.localStream) {
|
|
270
|
+
this.localStream.getAudioTracks().forEach((track) => {
|
|
271
|
+
track.enabled = !muted;
|
|
272
|
+
});
|
|
273
|
+
this.emit('muteChanged', muted);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Returns whether the microphone is currently muted.
|
|
278
|
+
*/
|
|
279
|
+
isMuted() {
|
|
280
|
+
if (!this.localStream)
|
|
281
|
+
return true;
|
|
282
|
+
const tracks = this.localStream.getAudioTracks();
|
|
283
|
+
return tracks.length === 0 || !tracks[0].enabled;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Gets the active local media stream.
|
|
287
|
+
*/
|
|
288
|
+
getLocalStream() {
|
|
289
|
+
return this.localStream;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Gets all subscribed remote audio streams.
|
|
293
|
+
*/
|
|
294
|
+
getRemoteStreams() {
|
|
295
|
+
return Array.from(this.remoteStreams.values());
|
|
296
|
+
}
|
|
135
297
|
disconnect() {
|
|
136
298
|
if (this.localStream) {
|
|
137
|
-
this.localStream.getTracks().forEach(track => track.stop());
|
|
299
|
+
this.localStream.getTracks().forEach((track) => track.stop());
|
|
300
|
+
this.localStream = null;
|
|
138
301
|
}
|
|
139
302
|
if (this.peerConnection) {
|
|
303
|
+
this.peerConnection.onicecandidate = null;
|
|
304
|
+
this.peerConnection.ontrack = null;
|
|
305
|
+
this.peerConnection.onconnectionstatechange = null;
|
|
140
306
|
this.peerConnection.close();
|
|
141
307
|
this.peerConnection = null;
|
|
142
308
|
}
|
|
309
|
+
this.audioTransceiver = null;
|
|
310
|
+
this.candidateQueue = [];
|
|
311
|
+
this.remoteStreams.clear();
|
|
143
312
|
if (this.signaling) {
|
|
144
313
|
this.signaling.close();
|
|
145
314
|
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
|
}
|