wireweave 0.1.6 → 0.2.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/package.json +1 -1
- package/src/voice.js +276 -3
package/package.json
CHANGED
package/src/voice.js
CHANGED
|
@@ -14,6 +14,16 @@ const HEARTBEAT = 30000;
|
|
|
14
14
|
const STALL_CHECK = 5000;
|
|
15
15
|
const DISCONNECT_GRACE = 8000;
|
|
16
16
|
|
|
17
|
+
// Speaker-activity / queue / anti-overtalk tunables
|
|
18
|
+
const SPEAKER_ACTIVE_RMS = 0.045; // RMS threshold above which a stream counts as "speaking"
|
|
19
|
+
const SPEAKER_HOLD_MS = 350; // tail: stay marked speaking this long after last frame > threshold
|
|
20
|
+
const SPEAKER_POLL_MS = 80; // analyzer poll cadence
|
|
21
|
+
const QUEUE_MAX_SEGMENT_MS = 30000; // hard cap per segment
|
|
22
|
+
const QUEUE_MIME_PREFS = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
|
|
23
|
+
const DC_LABEL = 'wireweave-queue';
|
|
24
|
+
const DC_CHUNK_MAX = 14000; // ~14 KB SCTP-friendly chunks
|
|
25
|
+
const DC_HEADER = 'WW1'; // protocol marker
|
|
26
|
+
|
|
17
27
|
const deriveRoomId = async (serverId, channel) => {
|
|
18
28
|
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
|
|
19
29
|
return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
@@ -52,8 +62,12 @@ export class VoiceSession extends EventTarget {
|
|
|
52
62
|
try {
|
|
53
63
|
this.roomId = await deriveRoomId(this.serverId, channelName);
|
|
54
64
|
this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
|
65
|
+
// PTT default: gate closed at join. Apps that want always-on call setMuted(false).
|
|
66
|
+
this.muted = true;
|
|
67
|
+
this.localStream.getAudioTracks().forEach(t => t.enabled = false);
|
|
55
68
|
this.participants.clear();
|
|
56
|
-
this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted:
|
|
69
|
+
this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: true, isLocal: true, hasVideo: false, connectionQuality: 'good' });
|
|
70
|
+
this._attachAnalyzer('local', this.localStream);
|
|
57
71
|
this.actor.send({ type: 'connected' });
|
|
58
72
|
this._subscribeSignals();
|
|
59
73
|
this._subscribePresence();
|
|
@@ -77,6 +91,10 @@ export class VoiceSession extends EventTarget {
|
|
|
77
91
|
this._sfuStop();
|
|
78
92
|
for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
|
|
79
93
|
this.peers.clear();
|
|
94
|
+
if (this._activityTimer) { clearInterval(this._activityTimer); this._activityTimer = null; }
|
|
95
|
+
if (this._activeAnalyzers) { for (const k of Array.from(this._activeAnalyzers.keys())) this._detachAnalyzer(k); }
|
|
96
|
+
if (this._outboundRec) { try { this._outboundRec.rec.stop(); } catch {} this._outboundRec = null; }
|
|
97
|
+
if (this._actx && this._actx.state !== 'closed') { try { this._actx.close(); } catch {} this._actx = null; }
|
|
80
98
|
if (this.cameraStream) { this.cameraStream.getTracks().forEach(t => t.stop()); this.cameraStream = null; }
|
|
81
99
|
if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; }
|
|
82
100
|
if (this.roomId) { this.pool.unsubscribe('voice-presence-' + this.roomId); this.pool.unsubscribe('voice-signals-' + this.roomId); }
|
|
@@ -87,14 +105,265 @@ export class VoiceSession extends EventTarget {
|
|
|
87
105
|
this._emit('disconnected', {});
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
toggleMic() {
|
|
91
|
-
|
|
108
|
+
toggleMic() { return this.setMuted(!this.muted); }
|
|
109
|
+
|
|
110
|
+
// setMuted is the canonical API. PTT layers should call setMuted(false) on
|
|
111
|
+
// hold-start and setMuted(true) on hold-end. Anti-overtalk lives below as
|
|
112
|
+
// requestTransmit / releaseTransmit which gate the unmute on remote silence.
|
|
113
|
+
setMuted(want) {
|
|
114
|
+
const next = !!want;
|
|
115
|
+
if (this.muted === next) return;
|
|
116
|
+
this.muted = next;
|
|
92
117
|
if (this.localStream) this.localStream.getAudioTracks().forEach(t => t.enabled = !this.muted);
|
|
93
118
|
const local = this.participants.get('local'); if (local) local.isMuted = this.muted;
|
|
119
|
+
if (this.muted) this._localActivityClear();
|
|
94
120
|
this._emit('mic', { muted: this.muted });
|
|
95
121
|
this._emit('participants', { list: this.getParticipants() });
|
|
96
122
|
}
|
|
97
123
|
|
|
124
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Speaker-activity detection
|
|
126
|
+
// Each peer stream + the local stream gets a Web Audio analyser. We poll
|
|
127
|
+
// the rms and flip participant.isSpeaking with hysteresis so the rest of
|
|
128
|
+
// the app (including the queue layer) can react.
|
|
129
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
130
|
+
_ensureAudioCtx() {
|
|
131
|
+
if (this._actx && this._actx.state !== 'closed') return this._actx;
|
|
132
|
+
const Ctx = (typeof AudioContext !== 'undefined') ? AudioContext : (typeof webkitAudioContext !== 'undefined') ? webkitAudioContext : null;
|
|
133
|
+
if (!Ctx) return null;
|
|
134
|
+
this._actx = new Ctx();
|
|
135
|
+
this._activeAnalyzers = new Map(); // key → { node, source, lastActive, gainNode? }
|
|
136
|
+
this._mixedDest = this._actx.createMediaStreamDestination();
|
|
137
|
+
this._mixedGain = this._actx.createGain();
|
|
138
|
+
this._mixedGain.gain.value = 1.0;
|
|
139
|
+
this._mixedGain.connect(this._mixedDest);
|
|
140
|
+
if (!this._activityTimer) this._activityTimer = setInterval(() => this._pollActivity(), SPEAKER_POLL_MS);
|
|
141
|
+
return this._actx;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_attachAnalyzer(key, stream, { tap = false } = {}) {
|
|
145
|
+
const ctx = this._ensureAudioCtx(); if (!ctx || !stream) return;
|
|
146
|
+
if (this._activeAnalyzers.has(key)) return;
|
|
147
|
+
try {
|
|
148
|
+
const src = ctx.createMediaStreamSource(stream);
|
|
149
|
+
const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.4;
|
|
150
|
+
src.connect(an);
|
|
151
|
+
let tapNode = null;
|
|
152
|
+
if (tap) { tapNode = ctx.createGain(); tapNode.gain.value = 1.0; src.connect(tapNode); tapNode.connect(this._mixedGain); }
|
|
153
|
+
this._activeAnalyzers.set(key, { src, an, tapNode, lastActive: 0, speaking: false });
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_detachAnalyzer(key) {
|
|
158
|
+
const a = this._activeAnalyzers?.get(key); if (!a) return;
|
|
159
|
+
try { a.tapNode?.disconnect(); } catch {}
|
|
160
|
+
try { a.an?.disconnect(); } catch {}
|
|
161
|
+
try { a.src?.disconnect(); } catch {}
|
|
162
|
+
this._activeAnalyzers.delete(key);
|
|
163
|
+
this._setSpeaking(key, false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_pollActivity() {
|
|
167
|
+
if (!this._activeAnalyzers || !this._activeAnalyzers.size) return;
|
|
168
|
+
const buf = new Uint8Array(256);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
for (const [key, a] of this._activeAnalyzers) {
|
|
171
|
+
a.an.getByteTimeDomainData(buf);
|
|
172
|
+
let sum = 0; for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; }
|
|
173
|
+
const rms = Math.sqrt(sum / buf.length);
|
|
174
|
+
const active = rms > SPEAKER_ACTIVE_RMS;
|
|
175
|
+
if (active) a.lastActive = now;
|
|
176
|
+
const stillSpeaking = active || (now - a.lastActive) < SPEAKER_HOLD_MS;
|
|
177
|
+
if (stillSpeaking !== a.speaking) { a.speaking = stillSpeaking; this._setSpeaking(key, stillSpeaking); }
|
|
178
|
+
}
|
|
179
|
+
this._maybeAutoTransmit();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_setSpeaking(key, val) {
|
|
183
|
+
const isLocal = key === 'local';
|
|
184
|
+
if (isLocal) {
|
|
185
|
+
const p = this.participants.get('local'); if (p && p.isSpeaking !== val) { p.isSpeaking = val; this._emit('participants', { list: this.getParticipants() }); this._emit('speaker', { key: 'local', isLocal: true, speaking: val }); }
|
|
186
|
+
} else {
|
|
187
|
+
const shortId = 'nostr-' + key.slice(0, 12);
|
|
188
|
+
const p = this.participants.get(shortId);
|
|
189
|
+
if (p && p.isSpeaking !== val) { p.isSpeaking = val; this._emit('participants', { list: this.getParticipants() }); this._emit('speaker', { key, isLocal: false, speaking: val }); }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_localActivityClear() {
|
|
194
|
+
const a = this._activeAnalyzers?.get('local'); if (a) { a.lastActive = 0; if (a.speaking) { a.speaking = false; this._setSpeaking('local', false); } }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
anyRemoteSpeaking() {
|
|
198
|
+
if (!this._activeAnalyzers) return false;
|
|
199
|
+
for (const [k, a] of this._activeAnalyzers) if (k !== 'local' && a.speaking) return true;
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
204
|
+
// Anti-overtalk transmit gate
|
|
205
|
+
// requestTransmit() — opens the mic if the channel is clear; otherwise starts
|
|
206
|
+
// buffering into a local segment. releaseTransmit() finalizes / closes.
|
|
207
|
+
// The buffered segment is then published via per-peer data channel as the
|
|
208
|
+
// outbound queue to peers (they playback when their inbound channel drains).
|
|
209
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
210
|
+
requestTransmit() {
|
|
211
|
+
if (!this.localStream) return false;
|
|
212
|
+
this._wantsTransmit = true;
|
|
213
|
+
if (!this.anyRemoteSpeaking()) { this.setMuted(false); this._emit('transmit', { mode: 'live' }); return true; }
|
|
214
|
+
// remote busy → buffer locally
|
|
215
|
+
this._beginOutboundCapture();
|
|
216
|
+
this._emit('transmit', { mode: 'queued' });
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
releaseTransmit() {
|
|
221
|
+
this._wantsTransmit = false;
|
|
222
|
+
if (this._outboundRec) this._finalizeOutboundCapture();
|
|
223
|
+
if (!this.muted) this.setMuted(true);
|
|
224
|
+
this._emit('transmit', { mode: 'idle' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_maybeAutoTransmit() {
|
|
228
|
+
if (!this._wantsTransmit) return;
|
|
229
|
+
// If we're queued AND remote is now silent, flip to live and finalize the held segment
|
|
230
|
+
if (this._outboundRec && !this.anyRemoteSpeaking()) {
|
|
231
|
+
this._finalizeOutboundCapture();
|
|
232
|
+
this.setMuted(false);
|
|
233
|
+
this._emit('transmit', { mode: 'live' });
|
|
234
|
+
}
|
|
235
|
+
// If we're live AND a remote starts speaking, fall back to queued mode
|
|
236
|
+
else if (!this.muted && this.anyRemoteSpeaking()) {
|
|
237
|
+
this.setMuted(true);
|
|
238
|
+
this._beginOutboundCapture();
|
|
239
|
+
this._emit('transmit', { mode: 'queued' });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_pickMime() {
|
|
244
|
+
if (typeof MediaRecorder === 'undefined') return null;
|
|
245
|
+
for (const m of QUEUE_MIME_PREFS) if (MediaRecorder.isTypeSupported(m)) return m;
|
|
246
|
+
return '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_beginOutboundCapture() {
|
|
250
|
+
if (this._outboundRec || !this.localStream) return;
|
|
251
|
+
const mime = this._pickMime(); if (mime === null) return;
|
|
252
|
+
const segId = (this.auth.pubkey?.slice(0, 8) || 'me') + '-' + Date.now();
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let rec;
|
|
255
|
+
try { rec = new MediaRecorder(this.localStream, mime ? { mimeType: mime } : undefined); } catch { return; }
|
|
256
|
+
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
|
|
257
|
+
rec.start(250);
|
|
258
|
+
this._outboundRec = { rec, chunks, segId, mime: mime || rec.mimeType, ts: Date.now(), capLimit: setTimeout(() => this._finalizeOutboundCapture(), QUEUE_MAX_SEGMENT_MS) };
|
|
259
|
+
this._emit('segment-start', { kind: 'outbound', segId });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _finalizeOutboundCapture() {
|
|
263
|
+
const o = this._outboundRec; if (!o) return;
|
|
264
|
+
this._outboundRec = null;
|
|
265
|
+
clearTimeout(o.capLimit);
|
|
266
|
+
if (o.rec.state !== 'inactive') {
|
|
267
|
+
const stopped = new Promise(r => o.rec.onstop = r);
|
|
268
|
+
try { o.rec.stop(); } catch {}
|
|
269
|
+
await stopped;
|
|
270
|
+
}
|
|
271
|
+
if (!o.chunks.length) return;
|
|
272
|
+
const blob = new Blob(o.chunks, { type: o.mime });
|
|
273
|
+
const buf = new Uint8Array(await blob.arrayBuffer());
|
|
274
|
+
const segment = { id: o.segId, from: this.auth.pubkey, name: this.displayName || 'Me', mime: o.mime, ts: o.ts, dur: Date.now() - o.ts, bytes: buf };
|
|
275
|
+
this._emit('segment-finalized', { kind: 'outbound', segment });
|
|
276
|
+
this._broadcastSegment(segment);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
280
|
+
// Per-peer data channel — segment broadcast
|
|
281
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
282
|
+
_ensureDataChannel(peer, peerPubkey, isOfferer) {
|
|
283
|
+
if (peer.dc) return;
|
|
284
|
+
if (isOfferer) {
|
|
285
|
+
try {
|
|
286
|
+
const dc = peer.pc.createDataChannel(DC_LABEL, { ordered: true });
|
|
287
|
+
peer.dc = dc; this._wireDataChannel(dc, peer, peerPubkey);
|
|
288
|
+
} catch {}
|
|
289
|
+
} else {
|
|
290
|
+
peer.pc.ondatachannel = (ev) => { if (ev.channel.label === DC_LABEL) { peer.dc = ev.channel; this._wireDataChannel(peer.dc, peer, peerPubkey); } };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_wireDataChannel(dc, peer, peerPubkey) {
|
|
295
|
+
dc.binaryType = 'arraybuffer';
|
|
296
|
+
peer._dcInbox = new Map(); // segId → { meta, parts:[], received:0, total:0 }
|
|
297
|
+
dc.onmessage = (e) => this._dcOnMessage(e.data, peer, peerPubkey);
|
|
298
|
+
dc.onopen = () => this._emit('dc-open', { peerPubkey });
|
|
299
|
+
dc.onclose = () => this._emit('dc-close', { peerPubkey });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_dcSendJSON(peer, obj) {
|
|
303
|
+
if (!peer.dc || peer.dc.readyState !== 'open') return false;
|
|
304
|
+
try { peer.dc.send(DC_HEADER + 'J' + JSON.stringify(obj)); return true; } catch { return false; }
|
|
305
|
+
}
|
|
306
|
+
_dcSendBytes(peer, segId, idx, total, bytes) {
|
|
307
|
+
if (!peer.dc || peer.dc.readyState !== 'open') return false;
|
|
308
|
+
const head = new TextEncoder().encode(DC_HEADER + 'B' + segId + ':' + idx + '/' + total + ':');
|
|
309
|
+
const buf = new Uint8Array(head.length + bytes.length);
|
|
310
|
+
buf.set(head, 0); buf.set(bytes, head.length);
|
|
311
|
+
try { peer.dc.send(buf.buffer); return true; } catch { return false; }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async _broadcastSegment(seg) {
|
|
315
|
+
const open = [];
|
|
316
|
+
for (const [pk, peer] of this.peers) if (peer.dc?.readyState === 'open') open.push([pk, peer]);
|
|
317
|
+
if (!open.length) return;
|
|
318
|
+
const meta = { type: 'seg-meta', segId: seg.id, from: seg.from, name: seg.name, mime: seg.mime, ts: seg.ts, dur: seg.dur, total: Math.ceil(seg.bytes.length / DC_CHUNK_MAX), bytes: seg.bytes.length };
|
|
319
|
+
for (const [, peer] of open) this._dcSendJSON(peer, meta);
|
|
320
|
+
let idx = 0;
|
|
321
|
+
for (let off = 0; off < seg.bytes.length; off += DC_CHUNK_MAX, idx++) {
|
|
322
|
+
const slice = seg.bytes.subarray(off, Math.min(off + DC_CHUNK_MAX, seg.bytes.length));
|
|
323
|
+
for (const [, peer] of open) this._dcSendBytes(peer, seg.id, idx, meta.total, slice);
|
|
324
|
+
}
|
|
325
|
+
for (const [, peer] of open) this._dcSendJSON(peer, { type: 'seg-end', segId: seg.id });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_dcOnMessage(data, peer, peerPubkey) {
|
|
329
|
+
if (typeof data === 'string') {
|
|
330
|
+
if (!data.startsWith(DC_HEADER)) return;
|
|
331
|
+
const tag = data[3]; const body = data.slice(4);
|
|
332
|
+
if (tag !== 'J') return;
|
|
333
|
+
let obj; try { obj = JSON.parse(body); } catch { return; }
|
|
334
|
+
if (obj.type === 'seg-meta') {
|
|
335
|
+
peer._dcInbox.set(obj.segId, { meta: obj, parts: new Array(obj.total), received: 0 });
|
|
336
|
+
} else if (obj.type === 'seg-end') {
|
|
337
|
+
const inbox = peer._dcInbox.get(obj.segId); if (!inbox) return;
|
|
338
|
+
peer._dcInbox.delete(obj.segId);
|
|
339
|
+
if (inbox.received < inbox.meta.total) return; // dropped
|
|
340
|
+
const total = inbox.parts.reduce((n, p) => n + (p?.length || 0), 0);
|
|
341
|
+
const buf = new Uint8Array(total); let off = 0;
|
|
342
|
+
for (const p of inbox.parts) { buf.set(p, off); off += p.length; }
|
|
343
|
+
const segment = { id: inbox.meta.segId, from: inbox.meta.from || peerPubkey, name: inbox.meta.name, mime: inbox.meta.mime, ts: inbox.meta.ts, dur: inbox.meta.dur, bytes: buf };
|
|
344
|
+
this._emit('segment-received', { kind: 'inbound', from: peerPubkey, segment });
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// binary chunk
|
|
349
|
+
const view = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer);
|
|
350
|
+
if (view.length < 6) return;
|
|
351
|
+
if (view[0] !== 0x57 || view[1] !== 0x57 || view[2] !== 0x31) return; // 'WW1'
|
|
352
|
+
if (view[3] !== 0x42) return; // 'B'
|
|
353
|
+
let h = 4; let colons = 0; let metaEnd = -1;
|
|
354
|
+
for (; h < view.length && h < 80; h++) {
|
|
355
|
+
if (view[h] === 0x3A) { colons++; if (colons === 2) { metaEnd = h; break; } }
|
|
356
|
+
}
|
|
357
|
+
if (metaEnd < 0) return;
|
|
358
|
+
const headerStr = new TextDecoder().decode(view.subarray(4, metaEnd));
|
|
359
|
+
// segId:idx/total
|
|
360
|
+
const [segId, rest] = headerStr.split(':');
|
|
361
|
+
const [idxStr, totalStr] = rest.split('/');
|
|
362
|
+
const idx = +idxStr, total = +totalStr;
|
|
363
|
+
const inbox = peer._dcInbox.get(segId); if (!inbox) return;
|
|
364
|
+
if (!inbox.parts[idx]) { inbox.parts[idx] = view.subarray(metaEnd + 1); inbox.received++; }
|
|
365
|
+
}
|
|
366
|
+
|
|
98
367
|
toggleDeafen() {
|
|
99
368
|
this.deafened = !this.deafened;
|
|
100
369
|
for (const [, peer] of this.peers) if (peer.audioEl) peer.audioEl.muted = this.deafened;
|
|
@@ -167,6 +436,7 @@ export class VoiceSession extends EventTarget {
|
|
|
167
436
|
pc.ontrack = (ev) => {
|
|
168
437
|
if (ev.track.kind === 'video') { this.onVideoTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0] }); return; }
|
|
169
438
|
this.onAudioTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0], peer });
|
|
439
|
+
this._attachAnalyzer(peerPubkey, ev.streams[0]);
|
|
170
440
|
try { ev.receiver.playoutDelayHint = 0.02; } catch {}
|
|
171
441
|
ev.track.onended = () => { if (peer.fsm.getSnapshot().matches('connected')) this._doIceRestart(peer, peerPubkey, fsmActor); };
|
|
172
442
|
if (!peer._stallInterval) peer._stallInterval = setInterval(() => this._checkStall(peer, peerPubkey, fsmActor), STALL_CHECK);
|
|
@@ -189,6 +459,7 @@ export class VoiceSession extends EventTarget {
|
|
|
189
459
|
if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
190
460
|
if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
|
|
191
461
|
};
|
|
462
|
+
this._ensureDataChannel(peer, peerPubkey, isOfferer);
|
|
192
463
|
if (isOfferer) {
|
|
193
464
|
fsmActor.send({ type: 'offer' });
|
|
194
465
|
pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
|
|
@@ -263,8 +534,10 @@ export class VoiceSession extends EventTarget {
|
|
|
263
534
|
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
264
535
|
if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
|
|
265
536
|
if (peer._stallInterval) clearInterval(peer._stallInterval);
|
|
537
|
+
try { peer.dc?.close(); } catch {}
|
|
266
538
|
try { peer.pc?.close(); } catch {}
|
|
267
539
|
if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
|
|
540
|
+
this._detachAnalyzer(peerPubkey);
|
|
268
541
|
this.peers.delete(peerPubkey);
|
|
269
542
|
this._emit('peer-closed', { peerPubkey });
|
|
270
543
|
}
|