wireweave 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/LICENSE +21 -0
- package/README.md +72 -0
- package/package.json +52 -0
- package/src/auth.js +97 -0
- package/src/debug.js +20 -0
- package/src/index.js +3 -0
- package/src/legacy/README.md +71 -0
- package/src/legacy/index.js +49 -0
- package/src/legacy/nostr-auth.js +217 -0
- package/src/legacy/nostr-bans.js +59 -0
- package/src/legacy/nostr-channels.js +145 -0
- package/src/legacy/nostr-chat.js +185 -0
- package/src/legacy/nostr-fsm.js +5 -0
- package/src/legacy/nostr-media.js +97 -0
- package/src/legacy/nostr-message.js +12 -0
- package/src/legacy/nostr-network.js +182 -0
- package/src/legacy/nostr-pages.js +152 -0
- package/src/legacy/nostr-roles.js +74 -0
- package/src/legacy/nostr-servers.js +223 -0
- package/src/legacy/nostr-settings.js +97 -0
- package/src/legacy/nostr-state-patch.js +19 -0
- package/src/legacy/nostr-voice-camera.js +63 -0
- package/src/legacy/nostr-voice-rtc.js +199 -0
- package/src/legacy/nostr-voice-sfu.js +128 -0
- package/src/legacy/nostr-voice.js +257 -0
- package/src/relay-pool.js +170 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
window.__voiceRetrySchedule = window.__voiceRetrySchedule || {};
|
|
2
|
+
var nostrVoiceRtc = {
|
|
3
|
+
_iceServers:[
|
|
4
|
+
{urls:'stun:stun.l.google.com:19302'},
|
|
5
|
+
{urls:'stun:stun1.l.google.com:19302'},
|
|
6
|
+
{urls:'stun:stun.cloudflare.com:3478'},
|
|
7
|
+
{urls:'stun:stun.nextcloud.com:443'},
|
|
8
|
+
{url:'turn:openrelay.metered.ca:80',urls:'turn:openrelay.metered.ca:80',username:'openrelayproject',credential:'openrelayproject'},
|
|
9
|
+
{url:'turn:openrelay.metered.ca:443',urls:'turn:openrelay.metered.ca:443',username:'openrelayproject',credential:'openrelayproject'},
|
|
10
|
+
{url:'turn:openrelay.metered.ca:443?transport=tcp',urls:'turn:openrelay.metered.ca:443?transport=tcp',username:'openrelayproject',credential:'openrelayproject'},
|
|
11
|
+
{url:'turns:openrelay.metered.ca:443',urls:'turns:openrelay.metered.ca:443',username:'openrelayproject',credential:'openrelayproject'},
|
|
12
|
+
],
|
|
13
|
+
|
|
14
|
+
_applyAudioHints(pc) {
|
|
15
|
+
try {
|
|
16
|
+
pc.getSenders().forEach(function(sender) {
|
|
17
|
+
if(!sender.track||sender.track.kind!=='audio') return;
|
|
18
|
+
var params=sender.getParameters();
|
|
19
|
+
if(!params.encodings||!params.encodings.length) return;
|
|
20
|
+
params.encodings[0].networkPriority='high'; params.encodings[0].priority='high';
|
|
21
|
+
params.encodings[0].maxBitrate=48000;
|
|
22
|
+
sender.setParameters(params).catch(function(){});
|
|
23
|
+
});
|
|
24
|
+
pc.getReceivers().forEach(function(recv) {
|
|
25
|
+
if(!recv.track||recv.track.kind!=='audio') return;
|
|
26
|
+
try { recv.playoutDelayHint=0.02; } catch(e) {}
|
|
27
|
+
});
|
|
28
|
+
} catch(e) {}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
maybeConnect(peerPubkey) {
|
|
32
|
+
var nv=nostrVoice;
|
|
33
|
+
if(!peerPubkey||peerPubkey===state.nostrPubkey||nv._peers.has(peerPubkey)) return;
|
|
34
|
+
if(window.nostrBans&&state.currentServerId&&(nostrBans.isBanned(state.currentServerId,peerPubkey)||nostrBans.isTimedOut(state.currentServerId,peerPubkey))) return;
|
|
35
|
+
nostrVoiceRtc.cancelReconnect(peerPubkey);
|
|
36
|
+
var fsm=XState.createActor(nostrFsm.peerMachine);
|
|
37
|
+
fsm.subscribe(function(snap){var p=nv._peers.get(peerPubkey);if(p)p.state=snap.value;});
|
|
38
|
+
fsm.start();
|
|
39
|
+
var peer={pc:null,audioEl:null,pendingCandidates:[],bufferedCandidates:[],iceTimer:null,disconnectTimer:null,failCount:0,state:'new',fsm:fsm};
|
|
40
|
+
nv._peers.set(peerPubkey,peer);
|
|
41
|
+
var pc=new RTCPeerConnection({iceServers:nostrVoiceRtc._iceServers,bundlePolicy:'max-bundle'}); peer.pc=pc;
|
|
42
|
+
var isOfferer=state.nostrPubkey>peerPubkey;
|
|
43
|
+
if(isOfferer){
|
|
44
|
+
if(nv._localStream)
|
|
45
|
+
nv._localStream.getTracks().forEach(t=>pc.addTransceiver(t,{direction:'sendrecv',streams:[nv._localStream]}));
|
|
46
|
+
else
|
|
47
|
+
pc.addTransceiver('audio',{direction:'recvonly'});
|
|
48
|
+
}
|
|
49
|
+
pc.ontrack=function(ev){
|
|
50
|
+
if(ev.track.kind==='video'){
|
|
51
|
+
var pKey='nostr-'+peerPubkey.slice(0,12);
|
|
52
|
+
var p=nv._participants.get(pKey);
|
|
53
|
+
if(p){p.hasVideo=true;p._videoStream=ev.streams[0];}
|
|
54
|
+
var el=document.getElementById('vtile-video-'+peerPubkey.slice(0,8));
|
|
55
|
+
if(!el){el=document.createElement('video');el.id='vtile-video-'+peerPubkey.slice(0,8);el.autoplay=true;el.playsinline=true;el.style.cssText='width:100%;height:100%;object-fit:cover;border-radius:8px';var wrap=document.getElementById('vtile-wrap-'+peerPubkey.slice(0,8));if(wrap){wrap.appendChild(el);}}
|
|
56
|
+
el.srcObject=ev.streams[0];
|
|
57
|
+
nv.updateParticipants();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if(!peer.audioEl){peer.audioEl=new Audio();peer.audioEl.autoplay=true;peer.audioEl.muted=state.voiceDeafened;document.body.appendChild(peer.audioEl);}
|
|
61
|
+
peer.audioEl.srcObject=ev.streams[0];
|
|
62
|
+
try { ev.receiver.playoutDelayHint=0.02; } catch(e) {}
|
|
63
|
+
ev.track.onended=function(){
|
|
64
|
+
if(!peer.fsm.getSnapshot().matches('connected')) return;
|
|
65
|
+
if(window.__debug) window.__debug['rtc-track-ended-'+peerPubkey.slice(0,8)]=Date.now();
|
|
66
|
+
doIceRestart();
|
|
67
|
+
};
|
|
68
|
+
if(!peer._stallInterval) peer._stallInterval=setInterval(function(){
|
|
69
|
+
if(!peer.audioEl||!peer.audioEl.srcObject) return;
|
|
70
|
+
if(!peer.fsm.getSnapshot().matches('connected')) return;
|
|
71
|
+
if(peer.trackEndedRestart) return;
|
|
72
|
+
var allEnded=peer.audioEl.srcObject.getTracks().every(function(t){return t.readyState==='ended';});
|
|
73
|
+
if(!allEnded) return;
|
|
74
|
+
peer.trackEndedRestart=true;
|
|
75
|
+
if(window.__debug) window.__debug['rtc-stall-'+peerPubkey.slice(0,8)]=Date.now();
|
|
76
|
+
doIceRestart();
|
|
77
|
+
},5000);
|
|
78
|
+
};
|
|
79
|
+
pc.onicecandidate=function(ev){
|
|
80
|
+
if(!ev.candidate) return;
|
|
81
|
+
peer.pendingCandidates.push(ev.candidate.toJSON());
|
|
82
|
+
if(peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
83
|
+
peer.iceTimer=setTimeout(function(){
|
|
84
|
+
if(peer.pendingCandidates.length){nv._publishSignal(peerPubkey,'ice',peer.pendingCandidates.splice(0));peer.iceTimer=null;}
|
|
85
|
+
},500);
|
|
86
|
+
};
|
|
87
|
+
pc.onicegatheringstatechange=function(){
|
|
88
|
+
if(pc.iceGatheringState==='complete'){
|
|
89
|
+
if(peer.iceTimer){clearTimeout(peer.iceTimer);peer.iceTimer=null;}
|
|
90
|
+
if(peer.pendingCandidates.length){nv._publishSignal(peerPubkey,'ice',peer.pendingCandidates.splice(0));}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var doIceRestart=function(){
|
|
94
|
+
if(peer.disconnectTimer){clearTimeout(peer.disconnectTimer);peer.disconnectTimer=null;}
|
|
95
|
+
peer.failCount++;
|
|
96
|
+
if(peer.failCount<=1&&state.nostrPubkey>peerPubkey){
|
|
97
|
+
fsm.send({type:'restart'}); pc.restartIce();
|
|
98
|
+
pc.createOffer({iceRestart:true}).then(o=>pc.setLocalDescription(o).then(()=>nv._publishSignal(peerPubkey,'offer',o))).catch(()=>nv._closePeer(peerPubkey));
|
|
99
|
+
} else { nv._closePeer(peerPubkey); nostrVoiceRtc.scheduleReconnect(peerPubkey, peer.failCount); }
|
|
100
|
+
};
|
|
101
|
+
pc.onconnectionstatechange=function(){
|
|
102
|
+
if(pc.connectionState==='connected'){
|
|
103
|
+
peer.failCount=0; nostrVoiceRtc.cancelReconnect(peerPubkey);
|
|
104
|
+
if(fsm.getSnapshot().can({type:'recv_answer'})) fsm.send({type:'recv_answer'});
|
|
105
|
+
if(peer.disconnectTimer){clearTimeout(peer.disconnectTimer);peer.disconnectTimer=null;}
|
|
106
|
+
nostrVoiceRtc._applyAudioHints(pc);
|
|
107
|
+
var pKey='nostr-'+peerPubkey.slice(0,12);
|
|
108
|
+
var p=nv._participants.get(pKey);
|
|
109
|
+
if(!p){nv._participants.set(pKey,{identity:pKey,isSpeaking:false,isMuted:false,isLocal:false,hasVideo:false,connectionQuality:'good',_peerPubkey:peerPubkey});}
|
|
110
|
+
else{p.connectionQuality='good';p._peerPubkey=peerPubkey;}
|
|
111
|
+
nv.updateParticipants();
|
|
112
|
+
}
|
|
113
|
+
if(pc.connectionState==='disconnected'){
|
|
114
|
+
fsm.send({type:'disconnect'}); peer.disconnectTimer=setTimeout(doIceRestart,8000);
|
|
115
|
+
}
|
|
116
|
+
if(pc.connectionState==='failed') doIceRestart();
|
|
117
|
+
if(pc.connectionState==='closed'){nv._closePeer(peerPubkey);if(window.nostrVoiceSfu&&nostrVoiceSfu._hub===peerPubkey){nostrVoiceSfu._dissolve();setTimeout(nostrVoiceSfu._maybeElect,500);}}
|
|
118
|
+
};
|
|
119
|
+
if(isOfferer){
|
|
120
|
+
fsm.send({type:'offer'});
|
|
121
|
+
pc.createOffer().then(o=>pc.setLocalDescription(o).then(()=>nv._publishSignal(peerPubkey,'offer',o)))
|
|
122
|
+
.catch(e=>console.warn('[nostr-voice] offer:',e));
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
handleSignal(event) {
|
|
127
|
+
var nv=nostrVoice;
|
|
128
|
+
var from=event.pubkey; if(from===state.nostrPubkey) return;
|
|
129
|
+
var data; try{data=JSON.parse(event.content);}catch(e){return;} if(!data?.type) return;
|
|
130
|
+
if(!nv._peers.has(from)) nostrVoiceRtc.maybeConnect(from);
|
|
131
|
+
var peer=nv._peers.get(from); if(!peer) return;
|
|
132
|
+
var pc=peer.pc; var fsm=peer.fsm;
|
|
133
|
+
var addCands=function(cands){cands.forEach(c=>pc.addIceCandidate(new RTCIceCandidate(c)).catch(e=>console.error('[nostr-voice] ice:',e)));};
|
|
134
|
+
var drainBuf=function(){addCands(peer.bufferedCandidates);peer.bufferedCandidates=[];};
|
|
135
|
+
var doAnswer=function(){if(fsm.getSnapshot().can({type:'recv_offer'}))fsm.send({type:'recv_offer'});return pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(function(){peer.remoteDescSet=true;drainBuf();var hasAudioTx=pc.getTransceivers().some(function(t){return t.receiver.track&&t.receiver.track.kind==='audio';});if(!hasAudioTx)pc.addTransceiver('audio',{direction:nv._localStream?'sendrecv':'recvonly'});if(nv._localStream){var hasSender=pc.getSenders().some(function(s){return s.track&&s.track.kind==='audio';});if(!hasSender)nv._localStream.getTracks().forEach(function(t){pc.addTrack(t,nv._localStream);});}return pc.createAnswer();}).then(function(a){return pc.setLocalDescription(a).then(function(){fsm.send({type:'sent_answer'});nv._publishSignal(from,'answer',a);});}).catch(function(e){console.warn('[nostr-voice] answer:',e);});};
|
|
136
|
+
if(data.type==='offer'){
|
|
137
|
+
var polite=state.nostrPubkey<from;
|
|
138
|
+
var collision=pc.signalingState!=='stable';
|
|
139
|
+
if(collision&&!polite) return;
|
|
140
|
+
if(collision&&polite){pc.setLocalDescription({type:'rollback'}).then(doAnswer).catch(function(e){console.warn('[nostr-voice] rollback:',e);});return;}
|
|
141
|
+
doAnswer();
|
|
142
|
+
} else if(data.type==='answer'&&pc.signalingState==='have-local-offer'){
|
|
143
|
+
pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(()=>{peer.remoteDescSet=true;drainBuf();}).catch(e=>console.warn('[nostr-voice] set-answer:',e));
|
|
144
|
+
} else if(data.type==='ice'){
|
|
145
|
+
var cands=Array.isArray(data.data)?data.data:[data.data];
|
|
146
|
+
if(peer.remoteDescSet) addCands(cands);
|
|
147
|
+
else peer.bufferedCandidates.push(...cands);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
subscribe(roomId,pubkey) {
|
|
152
|
+
nostrNet.subscribe('voice-signals-'+roomId,
|
|
153
|
+
[{kinds:[30078],'#p':[pubkey],'#r':[roomId]}],
|
|
154
|
+
nostrVoiceRtc.handleSignal,()=>{});
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async publish(toPubkey,type,data,roomId) {
|
|
158
|
+
if(!state.nostrPubkey||!roomId) return;
|
|
159
|
+
var d='zellous-rtc:'+roomId+':'+state.nostrPubkey+':'+toPubkey+':'+type+':'+(type==='ice'?Date.now():'sdp');
|
|
160
|
+
nostrNet.publish(await auth.sign({kind:30078,created_at:Math.floor(Date.now()/1000),
|
|
161
|
+
tags:[['d',d],['p',toPubkey],['r',roomId]],
|
|
162
|
+
content:JSON.stringify({type,data})}));
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
window.__zellous.voiceRtc = nostrVoiceRtc;
|
|
166
|
+
window.nostrVoiceRtc = nostrVoiceRtc;
|
|
167
|
+
|
|
168
|
+
nostrVoiceRtc.cancelReconnect = function(pk) {
|
|
169
|
+
var e=window.__voiceRetrySchedule[pk]; if(e){clearTimeout(e.timer);delete window.__voiceRetrySchedule[pk];}
|
|
170
|
+
};
|
|
171
|
+
nostrVoiceRtc.scheduleReconnect = function(pk, attempt) {
|
|
172
|
+
var a=attempt||0; if(a>=6) return;
|
|
173
|
+
nostrVoiceRtc.cancelReconnect(pk);
|
|
174
|
+
var timer=setTimeout(function(){
|
|
175
|
+
delete window.__voiceRetrySchedule[pk];
|
|
176
|
+
var nv=nostrVoice; if(!nv._peers.has(pk)&&nv._roomId) nostrVoiceRtc.maybeConnect(pk);
|
|
177
|
+
}, Math.min(Math.pow(2,a)*2000,30000));
|
|
178
|
+
window.__voiceRetrySchedule[pk]={attempt:a,timer:timer};
|
|
179
|
+
if(window.__debug&&window.__debug.voice) window.__debug.voice.retrySchedule=window.__voiceRetrySchedule;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
var _healDebounce=null,_pagehidden=false,_BAD={disconnected:1,failed:1,closed:1};
|
|
183
|
+
function _healPeers(){
|
|
184
|
+
if(_healDebounce){clearTimeout(_healDebounce);_healDebounce=null;}
|
|
185
|
+
_healDebounce=setTimeout(function(){
|
|
186
|
+
_healDebounce=null; var nv=nostrVoice; if(!nv._roomId) return;
|
|
187
|
+
nv._peers.forEach(function(peer,pk){if(_BAD[peer.pc&&peer.pc.connectionState]){nv._closePeer(pk);nostrVoiceRtc.scheduleReconnect(pk,0);}});
|
|
188
|
+
},500);
|
|
189
|
+
}
|
|
190
|
+
document.addEventListener('visibilitychange',function(){
|
|
191
|
+
if(document.visibilityState==='hidden'){_pagehidden=true;return;}
|
|
192
|
+
if(_pagehidden){_pagehidden=false;_healPeers();}
|
|
193
|
+
});
|
|
194
|
+
window.addEventListener('online',function(){
|
|
195
|
+
var nv=nostrVoice; if(!nv._roomId) return;
|
|
196
|
+
if(window.nostrNet&&nostrNet.reconnectAll) nostrNet.reconnectAll();
|
|
197
|
+
_healPeers();
|
|
198
|
+
});
|
|
199
|
+
window.addEventListener('pageshow',function(ev){if(ev.persisted||_pagehidden){_pagehidden=false;_healPeers();}});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
var nostrVoiceSfu = {
|
|
2
|
+
_actor: null,
|
|
3
|
+
_hub: null,
|
|
4
|
+
_hubLostAt: null,
|
|
5
|
+
_rttMatrix: new Map(),
|
|
6
|
+
_electionTimer: null,
|
|
7
|
+
_statsInterval: null,
|
|
8
|
+
|
|
9
|
+
_initActor() {
|
|
10
|
+
var machine = XState.createMachine({initial:'mesh',states:{mesh:{on:{elect:'electing'}},electing:{on:{elected:'star',dissolve:'mesh'}},star:{on:{dissolve:'mesh'}}}});
|
|
11
|
+
nostrVoiceSfu._actor = XState.createActor(machine);
|
|
12
|
+
nostrVoiceSfu._actor.start();
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
start() {
|
|
16
|
+
nostrVoiceSfu._initActor();
|
|
17
|
+
nostrVoiceSfu._stopStats();
|
|
18
|
+
nostrVoiceSfu._statsInterval = setInterval(nostrVoiceSfu._poll, 5000);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
stop() {
|
|
22
|
+
nostrVoiceSfu._stopStats();
|
|
23
|
+
if(nostrVoiceSfu._actor) nostrVoiceSfu._actor.send({type:'dissolve'});
|
|
24
|
+
nostrVoiceSfu._hub = null;
|
|
25
|
+
nostrVoiceSfu._rttMatrix.clear();
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
_stopStats() {
|
|
29
|
+
if(nostrVoiceSfu._statsInterval) { clearInterval(nostrVoiceSfu._statsInterval); nostrVoiceSfu._statsInterval = null; }
|
|
30
|
+
if(nostrVoiceSfu._electionTimer) { clearTimeout(nostrVoiceSfu._electionTimer); nostrVoiceSfu._electionTimer = null; }
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async _poll() {
|
|
34
|
+
var nv = nostrVoice;
|
|
35
|
+
var scores = {};
|
|
36
|
+
var tasks = [];
|
|
37
|
+
nv._peers.forEach(function(peer, pk) {
|
|
38
|
+
if(!peer.pc || peer.pc.connectionState !== 'connected') return;
|
|
39
|
+
tasks.push(peer.pc.getStats().then(function(stats) {
|
|
40
|
+
stats.forEach(function(r) {
|
|
41
|
+
if(r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime != null) {
|
|
42
|
+
scores[pk] = Math.round(r.currentRoundTripTime * 1000);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}).catch(function(){}));
|
|
46
|
+
});
|
|
47
|
+
await Promise.all(tasks);
|
|
48
|
+
nostrVoiceSfu._rttMatrix.set(state.nostrPubkey, scores);
|
|
49
|
+
nostrVoiceSfu._maybeElect();
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
onPresenceRtt(pubkey, rttScores) {
|
|
53
|
+
if(rttScores && typeof rttScores === 'object') nostrVoiceSfu._rttMatrix.set(pubkey, rttScores);
|
|
54
|
+
nostrVoiceSfu._maybeElect();
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
_maybeElect() {
|
|
58
|
+
var nv = nostrVoice;
|
|
59
|
+
var peerCount = nv._peers.size;
|
|
60
|
+
if(peerCount < 3) { if(nostrVoiceSfu._actor && nostrVoiceSfu._actor.getSnapshot().value !== 'mesh') nostrVoiceSfu._dissolve(); return; }
|
|
61
|
+
if(nostrVoiceSfu._electionTimer) return;
|
|
62
|
+
nostrVoiceSfu._electionTimer = setTimeout(function() {
|
|
63
|
+
nostrVoiceSfu._electionTimer = null;
|
|
64
|
+
nostrVoiceSfu._elect();
|
|
65
|
+
}, 2000);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
_elect() {
|
|
69
|
+
var nv = nostrVoice;
|
|
70
|
+
var allPeers = [state.nostrPubkey];
|
|
71
|
+
nv._peers.forEach(function(_, pk) { allPeers.push(pk); });
|
|
72
|
+
var best = null, bestAvg = Infinity;
|
|
73
|
+
allPeers.forEach(function(pk) {
|
|
74
|
+
var scores = nostrVoiceSfu._rttMatrix.get(pk);
|
|
75
|
+
if(!scores) return;
|
|
76
|
+
var vals = Object.values(scores).filter(function(v) { return typeof v === 'number'; });
|
|
77
|
+
if(!vals.length) return;
|
|
78
|
+
var avg = vals.reduce(function(a, b) { return a + b; }, 0) / vals.length;
|
|
79
|
+
if(avg < bestAvg || (avg === bestAvg && pk > best)) { bestAvg = avg; best = pk; }
|
|
80
|
+
});
|
|
81
|
+
if(!best) return;
|
|
82
|
+
if(best !== nostrVoiceSfu._hub) {
|
|
83
|
+
nostrVoiceSfu._hub = best;
|
|
84
|
+
if(nostrVoiceSfu._actor) nostrVoiceSfu._actor.send({type:'elected'});
|
|
85
|
+
if(best === state.nostrPubkey) nostrVoiceSfu._becomeHub();
|
|
86
|
+
else nostrVoiceSfu._routeToHub(best);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
_becomeHub() {
|
|
91
|
+
var nv = nostrVoice;
|
|
92
|
+
nv._peers.forEach(function(srcPeer, srcPk) {
|
|
93
|
+
if(!srcPeer.pc || !srcPeer.pc.getReceivers) return;
|
|
94
|
+
srcPeer.pc.getReceivers().forEach(function(recv) {
|
|
95
|
+
if(!recv.track || recv.track.kind !== 'audio') return;
|
|
96
|
+
nv._peers.forEach(function(dstPeer, dstPk) {
|
|
97
|
+
if(dstPk === srcPk) return;
|
|
98
|
+
var sender = dstPeer.pc && dstPeer.pc.getSenders().find(function(s) { return s.track && s.track.kind === 'audio'; });
|
|
99
|
+
if(sender) sender.replaceTrack(recv.track).catch(function(){});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
_routeToHub(hubPk) {
|
|
106
|
+
var nv = nostrVoice;
|
|
107
|
+
nv._peers.forEach(function(peer, pk) {
|
|
108
|
+
if(pk === hubPk) return;
|
|
109
|
+
nostrVoice._closePeer(pk);
|
|
110
|
+
nv._peers.delete(pk);
|
|
111
|
+
});
|
|
112
|
+
if(!nv._peers.has(hubPk)) nostrVoiceRtc.maybeConnect(hubPk);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
_dissolve() {
|
|
116
|
+
if(nostrVoiceSfu._actor) nostrVoiceSfu._actor.send({type:'dissolve'});
|
|
117
|
+
nostrVoiceSfu._hubLostAt = Date.now();
|
|
118
|
+
nostrVoiceSfu._hub = null;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
get __debug() {
|
|
122
|
+
var scores = {};
|
|
123
|
+
nostrVoiceSfu._rttMatrix.forEach(function(v, k) { scores[k.slice(0,12)] = v; });
|
|
124
|
+
return {mode: nostrVoiceSfu._actor ? nostrVoiceSfu._actor.getSnapshot().value : 'mesh', hub: nostrVoiceSfu._hub ? nostrVoiceSfu._hub.slice(0,12) : null, hubLostAt: nostrVoiceSfu._hubLostAt, rttMatrix: scores};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
window.__zellous.voiceSfu = nostrVoiceSfu;
|
|
128
|
+
window.nostrVoiceSfu = nostrVoiceSfu;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
var nostrVoice = {
|
|
2
|
+
_channelName: '', _roomId: '', _presenceInterval: null,
|
|
3
|
+
_participants: new Map(), _localStream: null, _peers: new Map(), _joinTs: 0,
|
|
4
|
+
_fsm: null,
|
|
5
|
+
|
|
6
|
+
_initFSM: function() {
|
|
7
|
+
var actor = XState.createActor(nostrFsm.voiceMachine);
|
|
8
|
+
actor.subscribe(function(snap) {
|
|
9
|
+
var next = snap.value;
|
|
10
|
+
state.voiceConnectionState = next === 'connected' ? 'connected' : next === 'idle' ? 'disconnected' : next;
|
|
11
|
+
state.voiceConnected = next === 'connected';
|
|
12
|
+
});
|
|
13
|
+
actor.start();
|
|
14
|
+
nostrVoice._fsm = actor;
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
_peerFSM: function(pubkey) {
|
|
18
|
+
var actor = XState.createActor(nostrFsm.peerMachine);
|
|
19
|
+
actor.subscribe(function(snap) {
|
|
20
|
+
var peer = nostrVoice._peers.get(pubkey);
|
|
21
|
+
if (peer) peer.state = snap.value;
|
|
22
|
+
});
|
|
23
|
+
actor.start();
|
|
24
|
+
return actor;
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async _deriveRoomId(ch) {
|
|
28
|
+
var h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((state.currentServerId||'default')+':voice:'+ch));
|
|
29
|
+
return 'zellous'+Array.from(new Uint8Array(h)).map(b=>b.toString(16).padStart(2,'0')).join('').slice(0,16);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
_displayName() { return state.nostrProfile?.name||(state.nostrPubkey?auth.npubShort(state.nostrPubkey):'Guest'); },
|
|
33
|
+
|
|
34
|
+
async _applyRnnoise(rawStream) {
|
|
35
|
+
try {
|
|
36
|
+
const worketUrl = new URL('../../vendor/rnnoise-worklet.js', document.baseURI).href;
|
|
37
|
+
if (!nostrVoice._rnnoiseCtx) {
|
|
38
|
+
nostrVoice._rnnoiseCtx = new AudioContext({ sampleRate: 48000 });
|
|
39
|
+
await nostrVoice._rnnoiseCtx.audioWorklet.addModule(worketUrl);
|
|
40
|
+
}
|
|
41
|
+
const ctx = nostrVoice._rnnoiseCtx;
|
|
42
|
+
if (ctx.state === 'suspended') await ctx.resume();
|
|
43
|
+
// tear down previous graph nodes if reconnecting
|
|
44
|
+
if (nostrVoice._rnnoiseSource) { try { nostrVoice._rnnoiseSource.disconnect(); } catch(e){} }
|
|
45
|
+
if (nostrVoice._rnnoiseNode) { try { nostrVoice._rnnoiseNode.disconnect(); } catch(e){} }
|
|
46
|
+
const source = ctx.createMediaStreamSource(rawStream);
|
|
47
|
+
const denoiseNode = new AudioWorkletNode(ctx, 'NoiseSuppressorWorklet');
|
|
48
|
+
const dest = ctx.createMediaStreamDestination();
|
|
49
|
+
source.connect(denoiseNode);
|
|
50
|
+
denoiseNode.connect(dest);
|
|
51
|
+
nostrVoice._rnnoiseSource = source;
|
|
52
|
+
nostrVoice._rnnoiseNode = denoiseNode;
|
|
53
|
+
nostrVoice._rnnoiseDest = dest;
|
|
54
|
+
return dest.stream;
|
|
55
|
+
} catch(e) {
|
|
56
|
+
console.warn('[nostr-voice] rnnoise unavailable, using raw stream:', e.message);
|
|
57
|
+
return rawStream;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
_rnnoiseEnabled() {
|
|
62
|
+
const stored = localStorage.getItem('zellous_rnnoise');
|
|
63
|
+
return stored === null ? true : stored === 'true';
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async connect(channelName) {
|
|
67
|
+
if (!nostrVoice._fsm) nostrVoice._initFSM();
|
|
68
|
+
if (!nostrVoice._fsm.getSnapshot().can({type:'connect'})) { await nostrVoice.disconnect(); }
|
|
69
|
+
nostrVoice._fsm.send({type:'connect'});
|
|
70
|
+
nostrVoice._channelName = channelName;
|
|
71
|
+
nostrVoice._joinTs = Math.floor(Date.now()/1000);
|
|
72
|
+
try {
|
|
73
|
+
nostrVoice._roomId = await nostrVoice._deriveRoomId(channelName);
|
|
74
|
+
let rawStream;
|
|
75
|
+
if (state.mediaStream && state.mediaStream.getAudioTracks().some(t=>t.readyState==='live')) {
|
|
76
|
+
rawStream = state.mediaStream;
|
|
77
|
+
} else {
|
|
78
|
+
rawStream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});
|
|
79
|
+
state.mediaStream = rawStream;
|
|
80
|
+
}
|
|
81
|
+
if (nostrVoice._rnnoiseEnabled()) {
|
|
82
|
+
nostrVoice._localStream = await nostrVoice._applyRnnoise(rawStream);
|
|
83
|
+
} else {
|
|
84
|
+
nostrVoice._localStream = rawStream;
|
|
85
|
+
}
|
|
86
|
+
if (state.cameraEnabled && !nostrVoice._cameraStream) {
|
|
87
|
+
try {
|
|
88
|
+
const [w, h] = state.webcamResolution.split('x').map(Number);
|
|
89
|
+
nostrVoice._cameraStream = await navigator.mediaDevices.getUserMedia({video:{width:w,height:h,frameRate:state.webcamFps,facingMode:'user'}});
|
|
90
|
+
} catch(e) {
|
|
91
|
+
console.warn('[nostr-voice] camera access denied:', e.message);
|
|
92
|
+
state.cameraEnabled = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
nostrVoice._participants.clear();
|
|
96
|
+
nostrVoice._participants.set('local',{identity:nostrVoice._displayName(),isSpeaking:false,isMuted:false,isLocal:true,hasVideo:!!nostrVoice._cameraStream,connectionQuality:'good'});
|
|
97
|
+
nostrVoice._fsm.send({type:'connected'});
|
|
98
|
+
state.voiceConnectionQuality='good'; state.voiceChannelName=channelName;
|
|
99
|
+
state.voiceReconnectAttempts=0; state.dataChannelAvailable=false;
|
|
100
|
+
nostrVoiceRtc.subscribe(nostrVoice._roomId,state.nostrPubkey);
|
|
101
|
+
if(window.nostrBans) nostrBans.subscribe(state.currentServerId||'');
|
|
102
|
+
nostrVoice._subscribePresence();
|
|
103
|
+
nostrVoice._publishPresence('join'); nostrVoice._startHeartbeat(); nostrVoice.updateParticipants();
|
|
104
|
+
if(window.serverSettings) serverSettings.applyToEncoder();
|
|
105
|
+
if(window.nostrVoiceSfu) nostrVoiceSfu.start();
|
|
106
|
+
if(ui.voicePanel) ui.voicePanel.classList.add('visible');
|
|
107
|
+
if(ui.voicePanelChannel) ui.voicePanelChannel.textContent=channelName;
|
|
108
|
+
message.add('Voice connected');
|
|
109
|
+
} catch(e) {
|
|
110
|
+
console.warn('[nostr-voice] connect failed:',e);
|
|
111
|
+
nostrVoice._fsm.send({type:'fail'});
|
|
112
|
+
message.add('Voice connection failed: '+e.message);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async disconnect() {
|
|
117
|
+
if (!nostrVoice._fsm || nostrVoice._fsm.getSnapshot().matches('idle')) return;
|
|
118
|
+
if (!nostrVoice._fsm.getSnapshot().can({type:'disconnect'})) return;
|
|
119
|
+
nostrVoice._fsm.send({type:'disconnect'});
|
|
120
|
+
nostrVoice._publishPresence('leave'); nostrVoice._stopHeartbeat();
|
|
121
|
+
if(window.nostrVoiceSfu) nostrVoiceSfu.stop();
|
|
122
|
+
nostrVoice._peers.forEach((_,pk)=>nostrVoice._closePeer(pk)); nostrVoice._peers.clear();
|
|
123
|
+
if(nostrVoice._cameraStream){nostrVoice._cameraStream.getTracks().forEach(function(t){t.stop();});nostrVoice._cameraStream=null;}
|
|
124
|
+
state.cameraEnabled=false;
|
|
125
|
+
if(nostrVoice._localStream&&nostrVoice._localStream!==state.mediaStream){nostrVoice._localStream.getTracks().forEach(t=>t.stop());}
|
|
126
|
+
nostrVoice._localStream=null;
|
|
127
|
+
// tear down rnnoise graph (keep ctx alive for reuse, just disconnect nodes)
|
|
128
|
+
if(nostrVoice._rnnoiseSource){try{nostrVoice._rnnoiseSource.disconnect();}catch(e){} nostrVoice._rnnoiseSource=null;}
|
|
129
|
+
if(nostrVoice._rnnoiseNode){try{nostrVoice._rnnoiseNode.disconnect();}catch(e){} nostrVoice._rnnoiseNode=null;}
|
|
130
|
+
if(nostrVoice._roomId){nostrNet.unsubscribe('voice-presence-'+nostrVoice._roomId);nostrNet.unsubscribe('voice-signals-'+nostrVoice._roomId);}
|
|
131
|
+
nostrVoice._participants.clear(); nostrVoice._roomId=''; nostrVoice._channelName='';
|
|
132
|
+
state.voiceConnectionQuality='unknown'; state.voiceChannelName='';
|
|
133
|
+
state.voiceParticipants=[]; state.voiceReconnectAttempts=0;
|
|
134
|
+
state.micMuted=false; state.voiceDeafened=false; state.activeSpeakers=new Set();
|
|
135
|
+
if(ui.voicePanel) ui.voicePanel.classList.remove('visible');
|
|
136
|
+
nostrVoice._fsm.send({type:'done'});
|
|
137
|
+
nostrVoice.updateParticipants();
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
_closePeer(pubkey) {
|
|
141
|
+
var peer=nostrVoice._peers.get(pubkey); if(!peer) return;
|
|
142
|
+
if(peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
143
|
+
if(peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
|
|
144
|
+
if(peer._stallInterval) clearInterval(peer._stallInterval);
|
|
145
|
+
try{peer.pc?.close();}catch(e){}
|
|
146
|
+
if(peer.audioEl){peer.audioEl.srcObject=null;peer.audioEl.remove();}
|
|
147
|
+
nostrVoice._peers.delete(pubkey);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
toggleMic() {
|
|
151
|
+
state.micMuted=!state.micMuted;
|
|
152
|
+
// mute the raw mic stream (affects both direct and rnnoise-processed path)
|
|
153
|
+
const rawStream = state.mediaStream;
|
|
154
|
+
if(rawStream) rawStream.getAudioTracks().forEach(t=>{t.enabled=!state.micMuted;});
|
|
155
|
+
// also mute processed stream output tracks if rnnoise is active
|
|
156
|
+
if(nostrVoice._localStream && nostrVoice._localStream !== rawStream) nostrVoice._localStream.getAudioTracks().forEach(t=>{t.enabled=!state.micMuted;});
|
|
157
|
+
var local=nostrVoice._participants.get('local'); if(local) local.isMuted=state.micMuted;
|
|
158
|
+
nostrVoice.updateParticipants();
|
|
159
|
+
document.getElementById('micToggleBtn')?.classList.toggle('muted',state.micMuted);
|
|
160
|
+
document.getElementById('voiceMicBtn')?.classList.toggle('active',!state.micMuted);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
toggleDeafen() {
|
|
164
|
+
state.voiceDeafened=!state.voiceDeafened;
|
|
165
|
+
nostrVoice._peers.forEach(peer=>{if(peer.audioEl) peer.audioEl.muted=state.voiceDeafened;});
|
|
166
|
+
document.getElementById('deafenToggleBtn')?.classList.toggle('muted',state.voiceDeafened);
|
|
167
|
+
document.getElementById('voiceDeafenBtn')?.classList.toggle('active',state.voiceDeafened);
|
|
168
|
+
nostrVoice.updateParticipants();
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
toggleCamera() { return nostrVoiceCamera.toggle(); },
|
|
172
|
+
|
|
173
|
+
_publishSignal(toPubkey,type,data) { return nostrVoiceRtc.publish(toPubkey,type,data,nostrVoice._roomId); },
|
|
174
|
+
_maybeConnect(pk) { nostrVoiceRtc.maybeConnect(pk); },
|
|
175
|
+
_subscribeSignals() { nostrVoiceRtc.subscribe(nostrVoice._roomId,state.nostrPubkey); },
|
|
176
|
+
|
|
177
|
+
updateParticipants() {
|
|
178
|
+
var list=[]; nostrVoice._participants.forEach(p=>list.push(p)); state.voiceParticipants=list;
|
|
179
|
+
if(window.uiVoice){uiVoice.renderGrid();uiVoice.renderPanel();}
|
|
180
|
+
if(window.uiChannels) uiChannels.render();
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
_rttScores() {
|
|
184
|
+
var scores = {};
|
|
185
|
+
if(window.nostrVoiceSfu) {
|
|
186
|
+
var myScores = nostrVoiceSfu._rttMatrix.get(state.nostrPubkey);
|
|
187
|
+
if(myScores) Object.assign(scores, myScores);
|
|
188
|
+
}
|
|
189
|
+
return scores;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async _publishPresence(action) {
|
|
193
|
+
if(!auth.isLoggedIn()||!nostrVoice._roomId) return;
|
|
194
|
+
var rttScores = action === 'heartbeat' ? nostrVoice._rttScores() : {};
|
|
195
|
+
nostrNet.publish(await auth.sign({kind:30078,created_at:Math.floor(Date.now()/1000),
|
|
196
|
+
tags:[['d','zellous-voice:'+nostrVoice._roomId],['action',action],['channel',nostrVoice._channelName],['server',state.currentServerId||'']],
|
|
197
|
+
content:JSON.stringify({action,name:nostrVoice._displayName(),channel:nostrVoice._channelName,ts:Date.now(),rttScores})}));
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
_startHeartbeat() {
|
|
201
|
+
nostrVoice._stopHeartbeat();
|
|
202
|
+
nostrVoice._presenceInterval=setInterval(()=>{if(nostrVoice._fsm?.getSnapshot().matches('connected')) nostrVoice._publishPresence('heartbeat');},30000);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
_stopHeartbeat() { if(nostrVoice._presenceInterval){clearInterval(nostrVoice._presenceInterval);nostrVoice._presenceInterval=null;} },
|
|
206
|
+
|
|
207
|
+
_subscribePresence() {
|
|
208
|
+
if(!nostrVoice._roomId) return;
|
|
209
|
+
nostrNet.subscribe('voice-presence-'+nostrVoice._roomId,
|
|
210
|
+
[{kinds:[30078],'#d':['zellous-voice:'+nostrVoice._roomId]}],
|
|
211
|
+
function(event) {
|
|
212
|
+
if(event.pubkey===state.nostrPubkey) return;
|
|
213
|
+
try {
|
|
214
|
+
var data=JSON.parse(event.content);
|
|
215
|
+
if(Date.now()-(data.ts||0)>300000) return;
|
|
216
|
+
var shortId='nostr-'+event.pubkey.slice(0,12);
|
|
217
|
+
if(data.rttScores && window.nostrVoiceSfu) nostrVoiceSfu.onPresenceRtt(event.pubkey, data.rttScores);
|
|
218
|
+
if(data.action==='leave'){nostrVoice._participants.delete(shortId);nostrVoice._closePeer(event.pubkey);}
|
|
219
|
+
else if(!nostrVoice._participants.has(shortId)){
|
|
220
|
+
nostrVoice._participants.set(shortId,{identity:data.name||auth.npubShort(event.pubkey),isSpeaking:false,isMuted:false,isLocal:false,hasVideo:false,connectionQuality:'connecting'});
|
|
221
|
+
nostrVoiceRtc.maybeConnect(event.pubkey);
|
|
222
|
+
} else if(!nostrVoice._peers.has(event.pubkey)){
|
|
223
|
+
nostrVoiceRtc.maybeConnect(event.pubkey);
|
|
224
|
+
} else {
|
|
225
|
+
var existingPeer=nostrVoice._peers.get(event.pubkey);
|
|
226
|
+
if(existingPeer&&existingPeer.pc&&existingPeer.pc.connectionState==='new'&&state.nostrPubkey>event.pubkey){
|
|
227
|
+
existingPeer.pc.createOffer().then(o=>existingPeer.pc.setLocalDescription(o).then(()=>nostrVoice._publishSignal(event.pubkey,'offer',o))).catch(()=>{});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
nostrVoice.updateParticipants();
|
|
231
|
+
} catch(e){}
|
|
232
|
+
},()=>{});
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
isDataChannelReady:()=>false,
|
|
236
|
+
updateVoiceGrid(){nostrVoice.updateParticipants();},
|
|
237
|
+
|
|
238
|
+
get __debug() {
|
|
239
|
+
var peers=[];
|
|
240
|
+
nostrVoice._peers.forEach(function(peer,pk){
|
|
241
|
+
var audioState=null;
|
|
242
|
+
if(peer.audioEl){audioState=peer.audioEl.ended?'ended':peer.audioEl.paused?'paused':'playing';}
|
|
243
|
+
var trackState=null;
|
|
244
|
+
if(peer.audioEl&&peer.audioEl.srcObject){var tracks=peer.audioEl.srcObject.getAudioTracks();trackState=tracks.length?tracks[0].readyState:'none';}
|
|
245
|
+
peers.push({pubkey:pk.slice(0,12),fsmState:peer.fsm?.getSnapshot().value,iceState:peer.pc?.iceConnectionState,connState:peer.pc?.connectionState,audioState:audioState,trackState:trackState,retryAttempt:peer.retryAttempt||0,retryAt:peer.retryAt||null,candidates:peer.pendingCandidates?.length??0,buffered:peer.bufferedCandidates?.length??0});
|
|
246
|
+
});
|
|
247
|
+
var sfu=window.nostrVoiceSfu?nostrVoiceSfu.__debug:null;
|
|
248
|
+
var camera=window.nostrVoiceCamera?{fsm:nostrVoiceCamera._fsm?.getSnapshot().value,stream:!!nostrVoice._cameraStream,enabled:state.cameraEnabled}:null;
|
|
249
|
+
return {fsm:nostrVoice._fsm?.getSnapshot().value,camera:camera,peers:peers,participants:state.voiceParticipants,sfu:sfu,retrySchedule:window.__voiceRetrySchedule||{}};
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
window.__zellous.voice=nostrVoice;
|
|
254
|
+
window.lk=nostrVoice;
|
|
255
|
+
window.nostrVoice=nostrVoice;
|
|
256
|
+
if(!window.__debug) window.__debug={};
|
|
257
|
+
Object.defineProperty(window.__debug,'voice',{get:function(){return nostrVoice.__debug;},configurable:true});
|