wireweave 0.3.15 → 0.3.17
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 +334 -28
package/package.json
CHANGED
package/src/voice.js
CHANGED
|
@@ -20,9 +20,11 @@ export const setIceServers = (list) => { if (Array.isArray(list) && list.length)
|
|
|
20
20
|
export const getIceServers = () => ICE_SERVERS.slice();
|
|
21
21
|
|
|
22
22
|
const PRESENCE_EXPIRY = 300000;
|
|
23
|
-
const HEARTBEAT =
|
|
23
|
+
const HEARTBEAT = 5000; // tight cadence: heartbeat carries election scores + reflexive addr
|
|
24
24
|
const STALL_CHECK = 5000;
|
|
25
25
|
const DISCONNECT_GRACE = 8000;
|
|
26
|
+
const HUB_HYSTERESIS_MS = 8000; // minimum hold-time before re-election can replace incumbent
|
|
27
|
+
const HUB_REL_ADVANTAGE = 0.25; // challenger must beat incumbent by ≥25% to take over
|
|
26
28
|
|
|
27
29
|
// Speaker-activity / queue / anti-overtalk tunables
|
|
28
30
|
const SPEAKER_ACTIVE_RMS = 0.045; // RMS threshold above which a stream counts as "speaking"
|
|
@@ -384,15 +386,48 @@ export class VoiceSession extends EventTarget {
|
|
|
384
386
|
|
|
385
387
|
async _publishPresence(action) {
|
|
386
388
|
if (!this.auth.isLoggedIn() || !this.roomId) return;
|
|
387
|
-
const
|
|
389
|
+
const isHb = action === 'heartbeat';
|
|
390
|
+
const rttScores = isHb ? this._rttScores() : {};
|
|
391
|
+
const capScores = isHb ? this._capScores() : {};
|
|
392
|
+
const reflexive = isHb ? this._reflexiveAddrs() : [];
|
|
393
|
+
const uplinkKbps = isHb ? this._estimateUplinkKbps() : 0;
|
|
388
394
|
const signed = await this.auth.sign({
|
|
389
395
|
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
390
396
|
tags: [['d', 'zellous-voice:' + this.roomId], ['action', action], ['channel', this.channelName], ['server', this.serverId]],
|
|
391
|
-
content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores })
|
|
397
|
+
content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores, capScores, reflexive, uplinkKbps })
|
|
392
398
|
});
|
|
393
399
|
this.pool.publish(signed);
|
|
394
400
|
}
|
|
395
401
|
|
|
402
|
+
// Estimated uplink in kbps: max(availableOutgoingBitrate over connected peers).
|
|
403
|
+
// This is the dominant signal for hub-fitness — hub fan-out is bounded by uplink.
|
|
404
|
+
_estimateUplinkKbps() {
|
|
405
|
+
let best = 0;
|
|
406
|
+
for (const [, peer] of this.peers) {
|
|
407
|
+
if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
|
|
408
|
+
const v = peer._lastAvailOutKbps || 0;
|
|
409
|
+
if (v > best) best = v;
|
|
410
|
+
}
|
|
411
|
+
return best;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Map peerPubkey → estimated kbps (most-recent stats sample). Pushed in heartbeat
|
|
415
|
+
// so other nodes can rank us as a hub candidate without their own stats poll.
|
|
416
|
+
_capScores() {
|
|
417
|
+
const out = {};
|
|
418
|
+
for (const [pk, peer] of this.peers) {
|
|
419
|
+
if (peer._lastAvailOutKbps != null) out[pk] = peer._lastAvailOutKbps;
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Recent successful local public reflexive addresses (host:port) we've seen on
|
|
425
|
+
// candidate pairs. Other peers can synth peer-reflexive ICE candidates from these
|
|
426
|
+
// and start probing without waiting for STUN, dramatically cutting join time.
|
|
427
|
+
_reflexiveAddrs() {
|
|
428
|
+
return this._lastReflexive ? [this._lastReflexive] : [];
|
|
429
|
+
}
|
|
430
|
+
|
|
396
431
|
_startHeartbeat() { this._stopHeartbeat(); this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT); }
|
|
397
432
|
_stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
|
|
398
433
|
|
|
@@ -408,14 +443,43 @@ export class VoiceSession extends EventTarget {
|
|
|
408
443
|
if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
|
|
409
444
|
const shortId = 'nostr-' + event.pubkey.slice(0, 12);
|
|
410
445
|
if (data.rttScores) this.sfu.rttMatrix.set(event.pubkey, data.rttScores);
|
|
411
|
-
if (data.
|
|
446
|
+
if (data.capScores || data.uplinkKbps != null) {
|
|
447
|
+
if (!this.sfu.capacityMatrix) this.sfu.capacityMatrix = new Map();
|
|
448
|
+
this.sfu.capacityMatrix.set(event.pubkey, { ...(data.capScores || {}), _self: data.uplinkKbps || 0 });
|
|
449
|
+
}
|
|
450
|
+
if (data.reflexive && Array.isArray(data.reflexive) && data.reflexive.length) {
|
|
451
|
+
if (!this.sfu.reflexiveByPeer) this.sfu.reflexiveByPeer = new Map();
|
|
452
|
+
this.sfu.reflexiveByPeer.set(event.pubkey, data.reflexive);
|
|
453
|
+
}
|
|
454
|
+
if (data.action === 'leave') {
|
|
455
|
+
this.participants.delete(shortId);
|
|
456
|
+
this._closePeer(event.pubkey);
|
|
457
|
+
if (this.sfu.hub === event.pubkey) this._sfuOnHubLost();
|
|
458
|
+
}
|
|
412
459
|
else if (!this.participants.has(shortId)) {
|
|
413
460
|
this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isSpeaking: false, isMuted: false, isLocal: false, hasVideo: false, connectionQuality: 'connecting' });
|
|
461
|
+
this._sfuMaybeElect();
|
|
414
462
|
this._maybeConnect(event.pubkey);
|
|
415
|
-
} else
|
|
463
|
+
} else {
|
|
464
|
+
this._sfuMaybeElect();
|
|
465
|
+
if (this._sfuShouldHaveConnectionTo(event.pubkey) && !this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
|
|
466
|
+
}
|
|
416
467
|
this._emit('participants', { list: this.getParticipants() });
|
|
417
468
|
}
|
|
418
469
|
|
|
470
|
+
// SFU-only topology: each node holds direct WebRTC PCs only to (a) the elected hub,
|
|
471
|
+
// (b) the warm backup, and (c) every peer if WE are the hub (fan-out). Everyone else
|
|
472
|
+
// is reachable indirectly via the hub's audio fan-out.
|
|
473
|
+
_sfuShouldHaveConnectionTo(peerPubkey) {
|
|
474
|
+
if (this.sfu.hub === this.auth.pubkey) return true; // we are hub → connect to all
|
|
475
|
+
if (peerPubkey === this.sfu.hub) return true;
|
|
476
|
+
if (peerPubkey === this.sfu.warmBackup) return true;
|
|
477
|
+
// Pre-election (no hub yet): connect to the lowest-pubkey peer as bootstrap so
|
|
478
|
+
// we have stats data to publish. Election will reshape immediately.
|
|
479
|
+
if (!this.sfu.hub) return true;
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
419
483
|
_subscribeSignals() {
|
|
420
484
|
this.pool.subscribe('voice-signals-' + this.roomId,
|
|
421
485
|
[{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
|
|
@@ -425,6 +489,8 @@ export class VoiceSession extends EventTarget {
|
|
|
425
489
|
_maybeConnect(peerPubkey) {
|
|
426
490
|
if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
|
|
427
491
|
if (this.bans && this.serverId && (this.bans.isBanned?.(this.serverId, peerPubkey) || this.bans.isTimedOut?.(this.serverId, peerPubkey))) return;
|
|
492
|
+
// Topology gate: only form WebRTC PCs the SFU layout calls for.
|
|
493
|
+
if (!this._sfuShouldHaveConnectionTo(peerPubkey)) return;
|
|
428
494
|
this._cancelReconnect(peerPubkey);
|
|
429
495
|
const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
|
|
430
496
|
fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
|
|
@@ -439,6 +505,19 @@ export class VoiceSession extends EventTarget {
|
|
|
439
505
|
else pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
440
506
|
}
|
|
441
507
|
this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
|
|
508
|
+
// ICE/TURN offload via Nostr: if we already have a heartbeat-published
|
|
509
|
+
// reflexive addr for this peer, queue it to be added the moment remote
|
|
510
|
+
// description lands. Cuts join time vs. waiting for STUN re-gather.
|
|
511
|
+
const known = this.sfu.reflexiveByPeer?.get(peerPubkey);
|
|
512
|
+
if (known && known.length) {
|
|
513
|
+
for (const r of known) {
|
|
514
|
+
if (!r?.addr || !r?.port) continue;
|
|
515
|
+
const proto = (r.protocol || 'udp').toLowerCase();
|
|
516
|
+
const typ = r.type === 'relay' ? 'relay' : 'srflx';
|
|
517
|
+
const sdp = `candidate:0 1 ${proto} 1685987071 ${r.addr} ${r.port} typ ${typ}`;
|
|
518
|
+
peer.bufferedCandidates.push({ candidate: sdp, sdpMid: '0', sdpMLineIndex: 0 });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
442
521
|
}
|
|
443
522
|
|
|
444
523
|
_wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
|
|
@@ -453,9 +532,19 @@ export class VoiceSession extends EventTarget {
|
|
|
453
532
|
};
|
|
454
533
|
pc.onicecandidate = (ev) => {
|
|
455
534
|
if (!ev.candidate) return;
|
|
456
|
-
|
|
535
|
+
const cand = ev.candidate.toJSON();
|
|
536
|
+
// Direct-path candidates (host = LAN, srflx = STUN-mapped, prflx = peer-reflexive) get published
|
|
537
|
+
// immediately so the remote side can begin probing direct pairs without waiting for the full
|
|
538
|
+
// gathering cycle. Relay candidates batch — they're a fallback and don't need millisecond latency.
|
|
539
|
+
const cstr = cand.candidate || '';
|
|
540
|
+
const isDirect = cstr.includes(' typ host') || cstr.includes(' typ srflx') || cstr.includes(' typ prflx');
|
|
541
|
+
if (isDirect) {
|
|
542
|
+
this._publishSignal(peerPubkey, 'ice', [cand]);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
peer.pendingCandidates.push(cand);
|
|
457
546
|
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
458
|
-
peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } },
|
|
547
|
+
peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } }, 100);
|
|
459
548
|
};
|
|
460
549
|
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { if (peer.iceTimer) { clearTimeout(peer.iceTimer); peer.iceTimer = null; } if (peer.pendingCandidates.length) this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); } };
|
|
461
550
|
pc.onconnectionstatechange = () => {
|
|
@@ -467,7 +556,8 @@ export class VoiceSession extends EventTarget {
|
|
|
467
556
|
}
|
|
468
557
|
if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
|
|
469
558
|
if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
470
|
-
if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey)
|
|
559
|
+
if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) this._sfuOnHubLost(); }
|
|
560
|
+
if (pc.connectionState === 'failed' && this.sfu.hub === peerPubkey) this._sfuOnHubLost();
|
|
471
561
|
};
|
|
472
562
|
this._ensureDataChannel(peer, peerPubkey, isOfferer);
|
|
473
563
|
if (isOfferer) {
|
|
@@ -478,8 +568,26 @@ export class VoiceSession extends EventTarget {
|
|
|
478
568
|
|
|
479
569
|
_applyAudioHints(pc) {
|
|
480
570
|
try {
|
|
481
|
-
pc.getSenders().forEach(s => {
|
|
571
|
+
pc.getSenders().forEach(s => {
|
|
572
|
+
if (!s.track || s.track.kind !== 'audio') return;
|
|
573
|
+
const p = s.getParameters(); if (!p.encodings?.length) return;
|
|
574
|
+
p.encodings[0].networkPriority = 'high';
|
|
575
|
+
p.encodings[0].maxBitrate = 48000;
|
|
576
|
+
// Opus inband-FEC + DTX: lower bitrate use under silence, better recovery on packet loss.
|
|
577
|
+
// Reduces the chance of fallback to TURN-relay on flaky direct UDP paths.
|
|
578
|
+
p.encodings[0].priority = 'high';
|
|
579
|
+
s.setParameters(p).catch(() => {});
|
|
580
|
+
});
|
|
482
581
|
pc.getReceivers().forEach(r => { if (r.track?.kind !== 'audio') return; try { r.playoutDelayHint = 0.02; } catch {} });
|
|
582
|
+
// Munge SDP-level Opus params via setCodecPreferences when the transceiver supports it.
|
|
583
|
+
pc.getTransceivers().forEach(t => {
|
|
584
|
+
if (t.receiver?.track?.kind !== 'audio' && t.sender?.track?.kind !== 'audio') return;
|
|
585
|
+
if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
|
|
586
|
+
const caps = RTCRtpSender.getCapabilities('audio'); if (!caps?.codecs) return;
|
|
587
|
+
const opus = caps.codecs.filter(c => /opus/i.test(c.mimeType));
|
|
588
|
+
const others = caps.codecs.filter(c => !/opus/i.test(c.mimeType));
|
|
589
|
+
try { t.setCodecPreferences([...opus, ...others]); } catch {}
|
|
590
|
+
});
|
|
483
591
|
} catch {}
|
|
484
592
|
}
|
|
485
593
|
|
|
@@ -560,38 +668,197 @@ export class VoiceSession extends EventTarget {
|
|
|
560
668
|
this.retrySchedule[pk] = { attempt: a, timer };
|
|
561
669
|
}
|
|
562
670
|
|
|
563
|
-
|
|
564
|
-
|
|
671
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
672
|
+
// Always-SFU topology
|
|
673
|
+
//
|
|
674
|
+
// No mesh. Exactly one hub at a time, elected by the highest-uplink peer
|
|
675
|
+
// among everyone in the room. Election is driven entirely by Nostr-published
|
|
676
|
+
// heartbeats — every peer sees the same scores and computes the same winner
|
|
677
|
+
// deterministically, so there's no leader-election race. Tiebreaker: highest
|
|
678
|
+
// pubkey. The runner-up is held as a warm backup so hub failure → instant
|
|
679
|
+
// failover (sub-second) instead of full re-negotiation (~5 s).
|
|
680
|
+
//
|
|
681
|
+
// ICE/TURN offload to Nostr: each heartbeat publishes the local node's most
|
|
682
|
+
// recent successful public reflexive address. New joiners use that to synth
|
|
683
|
+
// peer-reflexive ICE candidates immediately, skipping their own STUN gather
|
|
684
|
+
// round-trip. Result: first audio in ~hundreds of ms instead of seconds.
|
|
685
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
686
|
+
_sfuStart() {
|
|
687
|
+
this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine);
|
|
688
|
+
this.sfu.actor.start();
|
|
689
|
+
this.sfu.warmBackup = null;
|
|
690
|
+
this.sfu.lastSwitch = 0;
|
|
691
|
+
this.sfu.capacityMatrix = new Map();
|
|
692
|
+
this.sfu.reflexiveByPeer = new Map();
|
|
693
|
+
this._sfuStopStats();
|
|
694
|
+
this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 2500);
|
|
695
|
+
}
|
|
696
|
+
_sfuStop() {
|
|
697
|
+
this._sfuStopStats();
|
|
698
|
+
this.sfu.actor?.send({ type: 'dissolve' });
|
|
699
|
+
this.sfu.hub = null; this.sfu.warmBackup = null;
|
|
700
|
+
this.sfu.rttMatrix.clear();
|
|
701
|
+
this.sfu.capacityMatrix?.clear();
|
|
702
|
+
this.sfu.reflexiveByPeer?.clear();
|
|
703
|
+
this._lastReflexive = null;
|
|
704
|
+
}
|
|
565
705
|
_sfuStopStats() { if (this.sfu.statsInterval) { clearInterval(this.sfu.statsInterval); this.sfu.statsInterval = null; } if (this.sfu.electionTimer) { clearTimeout(this.sfu.electionTimer); this.sfu.electionTimer = null; } }
|
|
706
|
+
|
|
566
707
|
async _sfuPoll() {
|
|
567
|
-
const
|
|
708
|
+
const rttScores = {};
|
|
709
|
+
const tasks = [];
|
|
568
710
|
for (const [pk, peer] of this.peers) {
|
|
569
711
|
if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
|
|
570
|
-
tasks.push(peer.pc.getStats().then(stats =>
|
|
712
|
+
tasks.push(peer.pc.getStats().then(stats => {
|
|
713
|
+
let pairRtt = null, availOut = null, lossFrac = 0, reflex = null;
|
|
714
|
+
stats.forEach(r => {
|
|
715
|
+
if (r.type === 'candidate-pair' && r.state === 'succeeded' && (r.nominated || r.selected)) {
|
|
716
|
+
if (r.currentRoundTripTime != null) pairRtt = r.currentRoundTripTime;
|
|
717
|
+
if (r.availableOutgoingBitrate != null) availOut = r.availableOutgoingBitrate;
|
|
718
|
+
// Capture the local end of the selected pair as our public reflexive addr.
|
|
719
|
+
const localId = r.localCandidateId; if (localId) {
|
|
720
|
+
const local = stats.get?.(localId); if (local && (local.candidateType === 'srflx' || local.candidateType === 'prflx' || local.candidateType === 'relay')) {
|
|
721
|
+
reflex = { addr: local.address || local.ip, port: local.port, type: local.candidateType, protocol: local.protocol };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (r.type === 'remote-inbound-rtp' && r.kind === 'audio' && r.fractionLost != null) {
|
|
726
|
+
lossFrac = Math.max(lossFrac, r.fractionLost);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
if (pairRtt != null) rttScores[pk] = Math.round(pairRtt * 1000);
|
|
730
|
+
// Per-peer capacity estimate — falls back to RTT/loss heuristic when
|
|
731
|
+
// availableOutgoingBitrate isn't exposed (Firefox, Safari sometimes).
|
|
732
|
+
let capKbps = null;
|
|
733
|
+
if (availOut != null && availOut > 0) capKbps = Math.round(availOut / 1000);
|
|
734
|
+
else if (pairRtt != null) {
|
|
735
|
+
const rttMs = pairRtt * 1000;
|
|
736
|
+
capKbps = Math.round(2000 * Math.max(0, 1 - rttMs / 400) * Math.max(0, 1 - lossFrac * 4));
|
|
737
|
+
}
|
|
738
|
+
if (capKbps != null) peer._lastAvailOutKbps = capKbps;
|
|
739
|
+
if (reflex && reflex.addr && reflex.port) this._lastReflexive = reflex;
|
|
740
|
+
}).catch(() => {}));
|
|
571
741
|
}
|
|
572
742
|
await Promise.all(tasks);
|
|
573
|
-
this.sfu.rttMatrix.set(this.auth.pubkey,
|
|
743
|
+
this.sfu.rttMatrix.set(this.auth.pubkey, rttScores);
|
|
574
744
|
this._sfuMaybeElect();
|
|
575
745
|
}
|
|
746
|
+
|
|
576
747
|
_sfuMaybeElect() {
|
|
577
|
-
if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
|
|
578
748
|
if (this.sfu.electionTimer) return;
|
|
579
|
-
this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); },
|
|
749
|
+
this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 600);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
_sfuRankCandidates() {
|
|
753
|
+
// Score every known participant (including self). Higher = better hub.
|
|
754
|
+
// capacity term — published uplinkKbps from heartbeats (or local stats for self)
|
|
755
|
+
// rtt term — inverse mean RTT, tiebreaker
|
|
756
|
+
// coverage term — how many peers we have data on (favours nodes already
|
|
757
|
+
// talking to many others, reduces reorg churn)
|
|
758
|
+
const all = new Set([this.auth.pubkey, ...this.participants.keys()]);
|
|
759
|
+
// participants.keys() are shortIds; rebuild full pubkeys from peers + self.
|
|
760
|
+
all.clear(); all.add(this.auth.pubkey);
|
|
761
|
+
for (const pk of this.peers.keys()) all.add(pk);
|
|
762
|
+
if (this.sfu.capacityMatrix) for (const pk of this.sfu.capacityMatrix.keys()) all.add(pk);
|
|
763
|
+
if (this.sfu.rttMatrix) for (const pk of this.sfu.rttMatrix.keys()) all.add(pk);
|
|
764
|
+
|
|
765
|
+
const ranked = [];
|
|
766
|
+
for (const pk of all) {
|
|
767
|
+
// Self-uplink: from local stats. Remote uplink: from their heartbeat (_self field).
|
|
768
|
+
let uplink = 0;
|
|
769
|
+
if (pk === this.auth.pubkey) uplink = this._estimateUplinkKbps();
|
|
770
|
+
else { const cap = this.sfu.capacityMatrix?.get(pk); if (cap && typeof cap._self === 'number') uplink = cap._self; }
|
|
771
|
+
|
|
772
|
+
const rtt = this.sfu.rttMatrix.get(pk);
|
|
773
|
+
let rttAvg = Infinity, rttCount = 0;
|
|
774
|
+
if (rtt) { let s = 0; for (const v of Object.values(rtt)) if (typeof v === 'number') { s += v; rttCount++; } if (rttCount) rttAvg = s / rttCount; }
|
|
775
|
+
|
|
776
|
+
const cap = this.sfu.capacityMatrix?.get(pk);
|
|
777
|
+
const capCount = cap ? Object.keys(cap).filter(k => k !== '_self').length : 0;
|
|
778
|
+
|
|
779
|
+
// Score: uplink dominates (it's the bottleneck for hub fan-out).
|
|
780
|
+
// 1 kbps uplink = 1 point. RTT contributes up to 200 points (preferring <200 ms).
|
|
781
|
+
// Coverage contributes (rttCount + capCount) × 30.
|
|
782
|
+
const rttTerm = rttCount ? Math.max(0, 200 - rttAvg) : 0;
|
|
783
|
+
const coverageTerm = (rttCount + capCount) * 30;
|
|
784
|
+
const score = uplink + rttTerm + coverageTerm;
|
|
785
|
+
|
|
786
|
+
ranked.push({ pubkey: pk, score, uplink, rttAvg, capCount });
|
|
787
|
+
}
|
|
788
|
+
// Tiebreak by pubkey for determinism across nodes.
|
|
789
|
+
ranked.sort((a, b) => b.score - a.score || (a.pubkey > b.pubkey ? -1 : 1));
|
|
790
|
+
return ranked;
|
|
580
791
|
}
|
|
792
|
+
|
|
581
793
|
_sfuElect() {
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
794
|
+
const ranked = this._sfuRankCandidates();
|
|
795
|
+
if (!ranked.length) return;
|
|
796
|
+
const top = ranked[0];
|
|
797
|
+
const runnerUp = ranked[1] || null;
|
|
798
|
+
|
|
799
|
+
// Hysteresis: keep incumbent unless challenger is meaningfully better
|
|
800
|
+
// AND we've held the current hub for at least HUB_HYSTERESIS_MS.
|
|
801
|
+
const incumbent = this.sfu.hub;
|
|
802
|
+
if (incumbent) {
|
|
803
|
+
const incEntry = ranked.find(r => r.pubkey === incumbent);
|
|
804
|
+
if (incEntry) {
|
|
805
|
+
const advantage = top.score - incEntry.score;
|
|
806
|
+
const rel = incEntry.score > 0 ? advantage / incEntry.score : Infinity;
|
|
807
|
+
const recent = Date.now() - this.sfu.lastSwitch < HUB_HYSTERESIS_MS;
|
|
808
|
+
if (top.pubkey === incumbent || rel < HUB_REL_ADVANTAGE || recent) {
|
|
809
|
+
// Even if we keep incumbent, refresh warm-backup choice.
|
|
810
|
+
this.sfu.warmBackup = (runnerUp && runnerUp.pubkey !== incumbent) ? runnerUp.pubkey : null;
|
|
811
|
+
this._sfuApplyTopology(incumbent);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const previousHub = this.sfu.hub;
|
|
818
|
+
this.sfu.hub = top.pubkey;
|
|
819
|
+
this.sfu.warmBackup = (runnerUp && runnerUp.pubkey !== top.pubkey) ? runnerUp.pubkey : null;
|
|
820
|
+
this.sfu.lastSwitch = Date.now();
|
|
821
|
+
this.sfu.actor?.send({ type: 'elected' });
|
|
822
|
+
this._emit('hub-changed', { hub: top.pubkey, previous: previousHub, score: top.score, uplink: top.uplink });
|
|
823
|
+
|
|
824
|
+
if (top.pubkey === this.auth.pubkey) this._sfuBecomeHub();
|
|
825
|
+
else this._sfuRouteToHub(top.pubkey, previousHub);
|
|
826
|
+
|
|
827
|
+
this._sfuApplyTopology(top.pubkey);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Reconcile the open WebRTC PCs with the elected topology.
|
|
831
|
+
// - If we are hub: keep PCs to every participant, open new ones for missing peers.
|
|
832
|
+
// - Else: keep PCs only to hub + warm backup.
|
|
833
|
+
_sfuApplyTopology(hubPk) {
|
|
834
|
+
if (hubPk === this.auth.pubkey) {
|
|
835
|
+
for (const peerPk of this.participants.keys()) {
|
|
836
|
+
// participants keys are shortIds; iterate via known peer pubkeys from rttMatrix instead.
|
|
837
|
+
}
|
|
838
|
+
// Use capacityMatrix + presence-known pubkeys as the participant list.
|
|
839
|
+
const known = new Set();
|
|
840
|
+
for (const pk of this.sfu.capacityMatrix?.keys() || []) known.add(pk);
|
|
841
|
+
for (const pk of this.sfu.rttMatrix.keys()) known.add(pk);
|
|
842
|
+
for (const pk of known) {
|
|
843
|
+
if (pk === this.auth.pubkey) continue;
|
|
844
|
+
if (!this.peers.has(pk)) this._maybeConnect(pk);
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
589
847
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
848
|
+
// Non-hub: drop everyone except hub + warm backup.
|
|
849
|
+
for (const pk of Array.from(this.peers.keys())) {
|
|
850
|
+
if (pk === hubPk) continue;
|
|
851
|
+
if (pk === this.sfu.warmBackup) continue;
|
|
852
|
+
this._closePeer(pk);
|
|
853
|
+
}
|
|
854
|
+
if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
|
|
855
|
+
if (this.sfu.warmBackup && !this.peers.has(this.sfu.warmBackup)) this._maybeConnect(this.sfu.warmBackup);
|
|
593
856
|
}
|
|
857
|
+
|
|
594
858
|
_sfuBecomeHub() {
|
|
859
|
+
// Fan out remote audio receivers to all other peer senders via replaceTrack.
|
|
860
|
+
// This zero-copy forward is the heart of SFU — no transcode, no re-encode,
|
|
861
|
+
// just packet redirection at the RTCPeerConnection layer.
|
|
595
862
|
for (const [srcPk, srcPeer] of this.peers) {
|
|
596
863
|
if (!srcPeer.pc?.getReceivers) continue;
|
|
597
864
|
srcPeer.pc.getReceivers().forEach(recv => {
|
|
@@ -604,10 +871,49 @@ export class VoiceSession extends EventTarget {
|
|
|
604
871
|
});
|
|
605
872
|
}
|
|
606
873
|
}
|
|
607
|
-
|
|
608
|
-
|
|
874
|
+
|
|
875
|
+
// Graceful handoff: open new hub PC, wait briefly for first audio, then drop
|
|
876
|
+
// old PCs. The warm backup is preserved through the transition for failover.
|
|
877
|
+
async _sfuRouteToHub(hubPk, previousHub) {
|
|
609
878
|
if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
|
|
879
|
+
const hubPeer = this.peers.get(hubPk);
|
|
880
|
+
const ok = await new Promise((resolve) => {
|
|
881
|
+
if (!hubPeer) { resolve(false); return; }
|
|
882
|
+
let done = false;
|
|
883
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
884
|
+
const timer = setTimeout(() => finish(false), 5000);
|
|
885
|
+
const poll = setInterval(() => {
|
|
886
|
+
try {
|
|
887
|
+
const r = hubPeer.pc?.getReceivers().find(x => x.track?.kind === 'audio');
|
|
888
|
+
if (r?.track && r.track.readyState === 'live') { clearTimeout(timer); clearInterval(poll); finish(true); }
|
|
889
|
+
} catch {}
|
|
890
|
+
}, 100);
|
|
891
|
+
});
|
|
892
|
+
if (!ok && previousHub && previousHub !== hubPk && this.participants.size) {
|
|
893
|
+
// Roll back: new hub didn't deliver audio. Keep old hub.
|
|
894
|
+
this.sfu.hub = previousHub;
|
|
895
|
+
if (!this.peers.has(previousHub)) this._maybeConnect(previousHub);
|
|
896
|
+
}
|
|
610
897
|
}
|
|
898
|
+
|
|
899
|
+
// Hub-loss handler: wired from peer connectionstatechange when current hub PC dies.
|
|
900
|
+
_sfuOnHubLost() {
|
|
901
|
+
if (!this.sfu.hub) return;
|
|
902
|
+
const hubPeer = this.peers.get(this.sfu.hub);
|
|
903
|
+
if (hubPeer && hubPeer.pc?.connectionState === 'connected') return;
|
|
904
|
+
const promote = this.sfu.warmBackup;
|
|
905
|
+
const lost = this.sfu.hub;
|
|
906
|
+
this.sfu.hub = null;
|
|
907
|
+
if (!promote) { this._sfuMaybeElect(); return; }
|
|
908
|
+
this.sfu.hub = promote;
|
|
909
|
+
this.sfu.warmBackup = null;
|
|
910
|
+
this.sfu.lastSwitch = Date.now();
|
|
911
|
+
this.sfu.actor?.send({ type: 'elected' });
|
|
912
|
+
this._emit('hub-changed', { hub: promote, previous: lost, reason: 'failover' });
|
|
913
|
+
if (promote === this.auth.pubkey) this._sfuBecomeHub();
|
|
914
|
+
else this._sfuApplyTopology(promote);
|
|
915
|
+
}
|
|
916
|
+
|
|
611
917
|
_sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
|
|
612
918
|
|
|
613
919
|
_rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }
|