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.
@@ -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});