wireweave 0.3.16 → 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 +303 -25
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) {
|
|
@@ -477,7 +556,8 @@ export class VoiceSession extends EventTarget {
|
|
|
477
556
|
}
|
|
478
557
|
if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
|
|
479
558
|
if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
480
|
-
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();
|
|
481
561
|
};
|
|
482
562
|
this._ensureDataChannel(peer, peerPubkey, isOfferer);
|
|
483
563
|
if (isOfferer) {
|
|
@@ -588,38 +668,197 @@ export class VoiceSession extends EventTarget {
|
|
|
588
668
|
this.retrySchedule[pk] = { attempt: a, timer };
|
|
589
669
|
}
|
|
590
670
|
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
}
|
|
593
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
|
+
|
|
594
707
|
async _sfuPoll() {
|
|
595
|
-
const
|
|
708
|
+
const rttScores = {};
|
|
709
|
+
const tasks = [];
|
|
596
710
|
for (const [pk, peer] of this.peers) {
|
|
597
711
|
if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
|
|
598
|
-
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(() => {}));
|
|
599
741
|
}
|
|
600
742
|
await Promise.all(tasks);
|
|
601
|
-
this.sfu.rttMatrix.set(this.auth.pubkey,
|
|
743
|
+
this.sfu.rttMatrix.set(this.auth.pubkey, rttScores);
|
|
602
744
|
this._sfuMaybeElect();
|
|
603
745
|
}
|
|
746
|
+
|
|
604
747
|
_sfuMaybeElect() {
|
|
605
|
-
if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
|
|
606
748
|
if (this.sfu.electionTimer) return;
|
|
607
|
-
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;
|
|
608
791
|
}
|
|
792
|
+
|
|
609
793
|
_sfuElect() {
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
}
|
|
617
815
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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);
|
|
621
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;
|
|
847
|
+
}
|
|
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);
|
|
856
|
+
}
|
|
857
|
+
|
|
622
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.
|
|
623
862
|
for (const [srcPk, srcPeer] of this.peers) {
|
|
624
863
|
if (!srcPeer.pc?.getReceivers) continue;
|
|
625
864
|
srcPeer.pc.getReceivers().forEach(recv => {
|
|
@@ -632,10 +871,49 @@ export class VoiceSession extends EventTarget {
|
|
|
632
871
|
});
|
|
633
872
|
}
|
|
634
873
|
}
|
|
635
|
-
|
|
636
|
-
|
|
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) {
|
|
637
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
|
+
}
|
|
638
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
|
+
|
|
639
917
|
_sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
|
|
640
918
|
|
|
641
919
|
_rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }
|