wireweave 0.1.1 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "nostr + webrtc voice SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,6 +9,9 @@
9
9
  ".": "./src/index.js",
10
10
  "./relay-pool": "./src/relay-pool.js",
11
11
  "./auth": "./src/auth.js",
12
+ "./fsm": "./src/fsm.js",
13
+ "./voice": "./src/voice.js",
14
+ "./chat": "./src/chat.js",
12
15
  "./debug": "./src/debug.js",
13
16
  "./legacy": "./src/legacy/index.js",
14
17
  "./legacy/*": "./src/legacy/*"
package/src/chat.js ADDED
@@ -0,0 +1,95 @@
1
+ const hexChannelId = async (channelId, serverId) => {
2
+ const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':' + channelId));
3
+ return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('');
4
+ };
5
+
6
+ export class Chat extends EventTarget {
7
+ constructor({ relayPool, auth, getChannelContext = () => ({ channelId: null, serverId: '' }), isAdmin = () => false }) {
8
+ super();
9
+ if (!relayPool || !auth) throw new Error('Chat: relayPool + auth required');
10
+ this.pool = relayPool; this.auth = auth;
11
+ this.getChannelContext = getChannelContext; this.isAdmin = isAdmin;
12
+ this.activeChannelId = null;
13
+ this.messages = [];
14
+ this.profiles = new Map(); this.fetching = new Set();
15
+ }
16
+
17
+ async send(content, { announcement = false } = {}) {
18
+ const { channelId, serverId } = this.getChannelContext();
19
+ if (!this.auth.isLoggedIn() || !channelId) return;
20
+ if (announcement && !this.isAdmin(serverId)) return;
21
+ const trimmed = content.trim(); if (!trimmed) return;
22
+ const chanHex = await hexChannelId(channelId, serverId);
23
+ const tags = [['e', chanHex, '', 'root']];
24
+ if (announcement) tags.push(['t', 'announcement']);
25
+ const signed = await this.auth.sign({ kind: 42, created_at: Math.floor(Date.now() / 1000), tags, content: trimmed });
26
+ this.pool.publish(signed);
27
+ this._addMessage(this._eventToMsg(signed));
28
+ }
29
+
30
+ async loadHistory(channelId) {
31
+ const { serverId } = this.getChannelContext();
32
+ if (this.activeChannelId) {
33
+ this.pool.unsubscribe('chat-' + this.activeChannelId);
34
+ this.pool.unsubscribe('chat-live-' + this.activeChannelId);
35
+ }
36
+ this.activeChannelId = channelId;
37
+ this.messages = [];
38
+ this._emit('messages', { list: [] });
39
+ const chanHex = await hexChannelId(channelId, serverId);
40
+ const collected = [];
41
+ this.pool.subscribe('chat-' + channelId,
42
+ [{ kinds: [42], '#e': [chanHex], limit: 50 }],
43
+ (ev) => collected.push(this._eventToMsg(ev)),
44
+ () => {
45
+ collected.sort((a, b) => a.timestamp - b.timestamp);
46
+ this.messages = collected;
47
+ this._emit('messages', { list: collected });
48
+ });
49
+ this.pool.subscribe('chat-live-' + channelId,
50
+ [{ kinds: [42], '#e': [chanHex], since: Math.floor(Date.now() / 1000) }],
51
+ (ev) => this._addMessage(this._eventToMsg(ev)));
52
+ }
53
+
54
+ async deleteMessage(id) {
55
+ const signed = await this.auth.sign({ kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [['e', id]], content: 'deleted' });
56
+ this.pool.publish(signed);
57
+ this.messages = this.messages.filter(m => m.id !== id);
58
+ this._emit('messages', { list: this.messages });
59
+ }
60
+
61
+ _eventToMsg(event) {
62
+ const tTags = (event.tags || []).filter(t => t[0] === 't').map(t => t[1]);
63
+ this._fetchProfile(event.pubkey);
64
+ return { id: event.id, type: 'text', userId: event.pubkey, content: event.content, timestamp: event.created_at * 1000, tags: tTags };
65
+ }
66
+
67
+ _addMessage(msg) {
68
+ if (this.messages.find(m => m.id === msg.id)) return;
69
+ this.messages = [...this.messages, msg];
70
+ this._emit('message', { message: msg });
71
+ this._emit('messages', { list: this.messages });
72
+ }
73
+
74
+ resolveProfile(pubkey) {
75
+ const p = this.profiles.get(pubkey);
76
+ if (p) return p.name || this.auth.npubShort(pubkey);
77
+ this._fetchProfile(pubkey);
78
+ return this.auth.npubShort(pubkey);
79
+ }
80
+
81
+ _fetchProfile(pubkey) {
82
+ if (this.fetching.has(pubkey)) return;
83
+ this.fetching.add(pubkey);
84
+ this.pool.subscribe('profile-' + pubkey,
85
+ [{ kinds: [0], authors: [pubkey], limit: 1 }],
86
+ (event) => { try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {} },
87
+ () => { this.pool.unsubscribe('profile-' + pubkey); this.fetching.delete(pubkey); });
88
+ }
89
+
90
+ updateProfile(pubkey, profile) { this.profiles.set(pubkey, profile); this._emit('profile', { pubkey, profile }); }
91
+
92
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
93
+ }
94
+
95
+ export const createChat = (opts) => new Chat(opts);
package/src/fsm.js ADDED
@@ -0,0 +1,51 @@
1
+ export const createFSM = (xstate) => {
2
+ if (!xstate?.createMachine) throw new Error('xstate required');
3
+ return {
4
+ voiceMachine: xstate.createMachine({
5
+ initial: 'idle',
6
+ states: {
7
+ idle: { on: { connect: 'connecting' } },
8
+ connecting: { on: { connected: 'connected', fail: 'idle' } },
9
+ connected: { on: { disconnect: 'disconnecting' } },
10
+ disconnecting: { on: { done: 'idle' } }
11
+ }
12
+ }),
13
+ peerMachine: xstate.createMachine({
14
+ initial: 'new',
15
+ states: {
16
+ new: { on: { offer: 'offering', recv_offer: 'answering' } },
17
+ offering: { on: { recv_answer: 'connected', fail: 'new', restart: 'offering' } },
18
+ answering: { on: { sent_answer: 'connected', recv_answer: 'connected', fail: 'new' } },
19
+ connected: { on: { disconnect: 'reconnecting', close: 'closed' } },
20
+ reconnecting: { on: { offer: 'offering', recv_answer: 'connected', close: 'closed' } },
21
+ closed: {}
22
+ }
23
+ }),
24
+ cameraMachine: xstate.createMachine({
25
+ initial: 'idle',
26
+ states: {
27
+ idle: { on: { enable: 'requesting' } },
28
+ requesting: { on: { enabled: 'active', denied: 'idle' } },
29
+ active: { on: { disable: 'idle', error: 'idle' } },
30
+ error: { on: { enable: 'requesting' } }
31
+ }
32
+ }),
33
+ relayMachine: xstate.createMachine({
34
+ initial: 'connecting',
35
+ states: {
36
+ connecting: { on: { connected: 'connected', fail: 'error' } },
37
+ connected: { on: { fail: 'error', disconnect: 'disconnected' } },
38
+ disconnected: { on: { reconnect: 'connecting' } },
39
+ error: { on: { reconnect: 'connecting' } }
40
+ }
41
+ }),
42
+ sfuMachine: xstate.createMachine({
43
+ initial: 'mesh',
44
+ states: {
45
+ mesh: { on: { elect: 'electing' } },
46
+ electing: { on: { elected: 'star', dissolve: 'mesh' } },
47
+ star: { on: { dissolve: 'mesh' } }
48
+ }
49
+ })
50
+ };
51
+ };
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export { RelayPool, createRelayPool } from './relay-pool.js';
2
2
  export { NostrAuth, createAuth } from './auth.js';
3
+ export { createFSM } from './fsm.js';
4
+ export { VoiceSession, createVoiceSession } from './voice.js';
5
+ export { Chat, createChat } from './chat.js';
3
6
  export * as debug from './debug.js';
package/src/voice.js ADDED
@@ -0,0 +1,348 @@
1
+ const ICE_SERVERS = [
2
+ { urls: 'stun:stun.l.google.com:19302' },
3
+ { urls: 'stun:stun1.l.google.com:19302' },
4
+ { urls: 'stun:stun.cloudflare.com:3478' },
5
+ { urls: 'stun:stun.nextcloud.com:443' },
6
+ { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
7
+ { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
8
+ { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' },
9
+ { urls: 'turns:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' }
10
+ ];
11
+
12
+ const PRESENCE_EXPIRY = 300000;
13
+ const HEARTBEAT = 30000;
14
+ const STALL_CHECK = 5000;
15
+ const DISCONNECT_GRACE = 8000;
16
+
17
+ const deriveRoomId = async (serverId, channel) => {
18
+ const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
19
+ return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
20
+ };
21
+
22
+ export class VoiceSession extends EventTarget {
23
+ constructor({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null }) {
24
+ super();
25
+ if (!fsm || !xstate || !relayPool || !auth || !mediaDevices) throw new Error('VoiceSession: missing deps');
26
+ this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth; this.md = mediaDevices; this.bans = bans;
27
+ this.serverId = serverId;
28
+ this.onAudioTrack = onAudioTrack; this.onVideoTrack = onVideoTrack;
29
+ this.actor = null;
30
+ this.channelName = ''; this.roomId = '';
31
+ this.peers = new Map(); this.participants = new Map();
32
+ this.localStream = null; this.cameraStream = null;
33
+ this.heartbeat = null; this.joinTs = 0;
34
+ this.muted = false; this.deafened = false;
35
+ this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
36
+ this.retrySchedule = {};
37
+ }
38
+
39
+ _initActor() {
40
+ this.actor = this.xstate.createActor(this.fsm.voiceMachine);
41
+ this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
42
+ this.actor.start();
43
+ }
44
+
45
+ async connect(channelName, { displayName = 'Guest' } = {}) {
46
+ if (!this.actor) this._initActor();
47
+ if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
48
+ this.actor.send({ type: 'connect' });
49
+ this.channelName = channelName;
50
+ this.joinTs = Math.floor(Date.now() / 1000);
51
+ this.displayName = displayName;
52
+ try {
53
+ this.roomId = await deriveRoomId(this.serverId, channelName);
54
+ this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
55
+ this.participants.clear();
56
+ this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: false, isLocal: true, hasVideo: false, connectionQuality: 'good' });
57
+ this.actor.send({ type: 'connected' });
58
+ this._subscribeSignals();
59
+ this._subscribePresence();
60
+ await this._publishPresence('join');
61
+ this._startHeartbeat();
62
+ this._sfuStart();
63
+ this._emit('connected', { roomId: this.roomId, channelName });
64
+ } catch (e) {
65
+ this.actor.send({ type: 'fail' });
66
+ this._emit('error', { message: 'connect failed: ' + e.message });
67
+ throw e;
68
+ }
69
+ }
70
+
71
+ async disconnect() {
72
+ if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
73
+ if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
74
+ this.actor.send({ type: 'disconnect' });
75
+ await this._publishPresence('leave');
76
+ this._stopHeartbeat();
77
+ this._sfuStop();
78
+ for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
79
+ this.peers.clear();
80
+ if (this.cameraStream) { this.cameraStream.getTracks().forEach(t => t.stop()); this.cameraStream = null; }
81
+ if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; }
82
+ if (this.roomId) { this.pool.unsubscribe('voice-presence-' + this.roomId); this.pool.unsubscribe('voice-signals-' + this.roomId); }
83
+ this.participants.clear();
84
+ this.roomId = ''; this.channelName = '';
85
+ this.muted = false; this.deafened = false;
86
+ this.actor.send({ type: 'done' });
87
+ this._emit('disconnected', {});
88
+ }
89
+
90
+ toggleMic() {
91
+ this.muted = !this.muted;
92
+ if (this.localStream) this.localStream.getAudioTracks().forEach(t => t.enabled = !this.muted);
93
+ const local = this.participants.get('local'); if (local) local.isMuted = this.muted;
94
+ this._emit('mic', { muted: this.muted });
95
+ this._emit('participants', { list: this.getParticipants() });
96
+ }
97
+
98
+ toggleDeafen() {
99
+ this.deafened = !this.deafened;
100
+ for (const [, peer] of this.peers) if (peer.audioEl) peer.audioEl.muted = this.deafened;
101
+ this._emit('deafen', { deafened: this.deafened });
102
+ }
103
+
104
+ getParticipants() { return Array.from(this.participants.values()); }
105
+
106
+ async _publishPresence(action) {
107
+ if (!this.auth.isLoggedIn() || !this.roomId) return;
108
+ const rttScores = action === 'heartbeat' ? this._rttScores() : {};
109
+ const signed = await this.auth.sign({
110
+ kind: 30078, created_at: Math.floor(Date.now() / 1000),
111
+ tags: [['d', 'zellous-voice:' + this.roomId], ['action', action], ['channel', this.channelName], ['server', this.serverId]],
112
+ content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores })
113
+ });
114
+ this.pool.publish(signed);
115
+ }
116
+
117
+ _startHeartbeat() { this._stopHeartbeat(); this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT); }
118
+ _stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
119
+
120
+ _subscribePresence() {
121
+ this.pool.subscribe('voice-presence-' + this.roomId,
122
+ [{ kinds: [30078], '#d': ['zellous-voice:' + this.roomId] }],
123
+ (event) => this._onPresence(event));
124
+ }
125
+
126
+ _onPresence(event) {
127
+ if (event.pubkey === this.auth.pubkey) return;
128
+ let data; try { data = JSON.parse(event.content); } catch { return; }
129
+ if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
130
+ const shortId = 'nostr-' + event.pubkey.slice(0, 12);
131
+ if (data.rttScores) this.sfu.rttMatrix.set(event.pubkey, data.rttScores);
132
+ if (data.action === 'leave') { this.participants.delete(shortId); this._closePeer(event.pubkey); }
133
+ else if (!this.participants.has(shortId)) {
134
+ this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isSpeaking: false, isMuted: false, isLocal: false, hasVideo: false, connectionQuality: 'connecting' });
135
+ this._maybeConnect(event.pubkey);
136
+ } else if (!this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
137
+ this._emit('participants', { list: this.getParticipants() });
138
+ }
139
+
140
+ _subscribeSignals() {
141
+ this.pool.subscribe('voice-signals-' + this.roomId,
142
+ [{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
143
+ (event) => this._handleSignal(event));
144
+ }
145
+
146
+ _maybeConnect(peerPubkey) {
147
+ if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
148
+ if (this.bans && this.serverId && (this.bans.isBanned?.(this.serverId, peerPubkey) || this.bans.isTimedOut?.(this.serverId, peerPubkey))) return;
149
+ this._cancelReconnect(peerPubkey);
150
+ const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
151
+ fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
152
+ fsmActor.start();
153
+ const peer = { pc: null, audioEl: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, _stallInterval: null, remoteDescSet: false, trackEndedRestart: false };
154
+ this.peers.set(peerPubkey, peer);
155
+ const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS, bundlePolicy: 'max-bundle' });
156
+ peer.pc = pc;
157
+ const isOfferer = this.auth.pubkey > peerPubkey;
158
+ if (isOfferer) {
159
+ if (this.localStream) this.localStream.getTracks().forEach(t => pc.addTransceiver(t, { direction: 'sendrecv', streams: [this.localStream] }));
160
+ else pc.addTransceiver('audio', { direction: 'recvonly' });
161
+ }
162
+ this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
163
+ }
164
+
165
+ _wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
166
+ const pc = peer.pc;
167
+ pc.ontrack = (ev) => {
168
+ if (ev.track.kind === 'video') { this.onVideoTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0] }); return; }
169
+ this.onAudioTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0], peer });
170
+ try { ev.receiver.playoutDelayHint = 0.02; } catch {}
171
+ ev.track.onended = () => { if (peer.fsm.getSnapshot().matches('connected')) this._doIceRestart(peer, peerPubkey, fsmActor); };
172
+ if (!peer._stallInterval) peer._stallInterval = setInterval(() => this._checkStall(peer, peerPubkey, fsmActor), STALL_CHECK);
173
+ };
174
+ pc.onicecandidate = (ev) => {
175
+ if (!ev.candidate) return;
176
+ peer.pendingCandidates.push(ev.candidate.toJSON());
177
+ if (peer.iceTimer) clearTimeout(peer.iceTimer);
178
+ peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } }, 500);
179
+ };
180
+ 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)); } };
181
+ pc.onconnectionstatechange = () => {
182
+ if (pc.connectionState === 'connected') {
183
+ peer.failCount = 0; this._cancelReconnect(peerPubkey);
184
+ if (fsmActor.getSnapshot().can({ type: 'recv_answer' })) fsmActor.send({ type: 'recv_answer' });
185
+ if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
186
+ this._applyAudioHints(pc);
187
+ }
188
+ if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
189
+ if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
190
+ if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
191
+ };
192
+ if (isOfferer) {
193
+ fsmActor.send({ type: 'offer' });
194
+ pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
195
+ }
196
+ }
197
+
198
+ _applyAudioHints(pc) {
199
+ try {
200
+ pc.getSenders().forEach(s => { if (!s.track || s.track.kind !== 'audio') return; const p = s.getParameters(); if (!p.encodings?.length) return; p.encodings[0].networkPriority = 'high'; p.encodings[0].maxBitrate = 48000; s.setParameters(p).catch(() => {}); });
201
+ pc.getReceivers().forEach(r => { if (r.track?.kind !== 'audio') return; try { r.playoutDelayHint = 0.02; } catch {} });
202
+ } catch {}
203
+ }
204
+
205
+ _checkStall(peer, peerPubkey, fsmActor) {
206
+ if (!peer.audioEl?.srcObject || !peer.fsm.getSnapshot().matches('connected') || peer.trackEndedRestart) return;
207
+ const allEnded = peer.audioEl.srcObject.getTracks().every(t => t.readyState === 'ended');
208
+ if (!allEnded) return;
209
+ peer.trackEndedRestart = true;
210
+ this._doIceRestart(peer, peerPubkey, fsmActor);
211
+ }
212
+
213
+ _doIceRestart(peer, peerPubkey, fsmActor) {
214
+ const pc = peer.pc;
215
+ if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
216
+ peer.failCount++;
217
+ if (peer.failCount <= 1 && this.auth.pubkey > peerPubkey) {
218
+ fsmActor.send({ type: 'restart' }); pc.restartIce();
219
+ pc.createOffer({ iceRestart: true }).then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => this._closePeer(peerPubkey));
220
+ } else { this._closePeer(peerPubkey); this._scheduleReconnect(peerPubkey, peer.failCount); }
221
+ }
222
+
223
+ _handleSignal(event) {
224
+ const from = event.pubkey; if (from === this.auth.pubkey) return;
225
+ let data; try { data = JSON.parse(event.content); } catch { return; }
226
+ if (!data?.type) return;
227
+ if (!this.peers.has(from)) this._maybeConnect(from);
228
+ const peer = this.peers.get(from); if (!peer) return;
229
+ const pc = peer.pc; const fsmActor = peer.fsm;
230
+ const addCands = (cands) => cands.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {}));
231
+ const drainBuf = () => { addCands(peer.bufferedCandidates); peer.bufferedCandidates = []; };
232
+ const doAnswer = async () => {
233
+ if (fsmActor.getSnapshot().can({ type: 'recv_offer' })) fsmActor.send({ type: 'recv_offer' });
234
+ await pc.setRemoteDescription(new RTCSessionDescription(data.data));
235
+ peer.remoteDescSet = true; drainBuf();
236
+ const hasAudioTx = pc.getTransceivers().some(t => t.receiver.track?.kind === 'audio');
237
+ if (!hasAudioTx) pc.addTransceiver('audio', { direction: this.localStream ? 'sendrecv' : 'recvonly' });
238
+ if (this.localStream) { const hasSender = pc.getSenders().some(s => s.track?.kind === 'audio'); if (!hasSender) this.localStream.getTracks().forEach(t => pc.addTrack(t, this.localStream)); }
239
+ const a = await pc.createAnswer(); await pc.setLocalDescription(a); fsmActor.send({ type: 'sent_answer' }); this._publishSignal(from, 'answer', a);
240
+ };
241
+ if (data.type === 'offer') {
242
+ const polite = this.auth.pubkey < from; const collision = pc.signalingState !== 'stable';
243
+ if (collision && !polite) return;
244
+ if (collision && polite) { pc.setLocalDescription({ type: 'rollback' }).then(doAnswer).catch(() => {}); return; }
245
+ doAnswer().catch(() => {});
246
+ } else if (data.type === 'answer' && pc.signalingState === 'have-local-offer') {
247
+ pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(() => { peer.remoteDescSet = true; drainBuf(); }).catch(() => {});
248
+ } else if (data.type === 'ice') {
249
+ const cands = Array.isArray(data.data) ? data.data : [data.data];
250
+ if (peer.remoteDescSet) addCands(cands); else peer.bufferedCandidates.push(...cands);
251
+ }
252
+ }
253
+
254
+ async _publishSignal(toPubkey, type, data) {
255
+ if (!this.auth.pubkey || !this.roomId) return;
256
+ const d = 'zellous-rtc:' + this.roomId + ':' + this.auth.pubkey + ':' + toPubkey + ':' + type + ':' + (type === 'ice' ? Date.now() : 'sdp');
257
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', d], ['p', toPubkey], ['r', this.roomId]], content: JSON.stringify({ type, data }) });
258
+ this.pool.publish(signed);
259
+ }
260
+
261
+ _closePeer(peerPubkey) {
262
+ const peer = this.peers.get(peerPubkey); if (!peer) return;
263
+ if (peer.iceTimer) clearTimeout(peer.iceTimer);
264
+ if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
265
+ if (peer._stallInterval) clearInterval(peer._stallInterval);
266
+ try { peer.pc?.close(); } catch {}
267
+ if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
268
+ this.peers.delete(peerPubkey);
269
+ this._emit('peer-closed', { peerPubkey });
270
+ }
271
+
272
+ _cancelReconnect(pk) { const e = this.retrySchedule[pk]; if (e) { clearTimeout(e.timer); delete this.retrySchedule[pk]; } }
273
+ _scheduleReconnect(pk, attempt) {
274
+ const a = attempt || 0; if (a >= 6) return;
275
+ this._cancelReconnect(pk);
276
+ const timer = setTimeout(() => { delete this.retrySchedule[pk]; if (!this.peers.has(pk) && this.roomId) this._maybeConnect(pk); }, Math.min(2 ** a * 2000, 30000));
277
+ this.retrySchedule[pk] = { attempt: a, timer };
278
+ }
279
+
280
+ _sfuStart() { this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine); this.sfu.actor.start(); this._sfuStopStats(); this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 5000); }
281
+ _sfuStop() { this._sfuStopStats(); this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hub = null; this.sfu.rttMatrix.clear(); }
282
+ _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; } }
283
+ async _sfuPoll() {
284
+ const scores = {}; const tasks = [];
285
+ for (const [pk, peer] of this.peers) {
286
+ if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
287
+ tasks.push(peer.pc.getStats().then(stats => stats.forEach(r => { if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime != null) scores[pk] = Math.round(r.currentRoundTripTime * 1000); })).catch(() => {}));
288
+ }
289
+ await Promise.all(tasks);
290
+ this.sfu.rttMatrix.set(this.auth.pubkey, scores);
291
+ this._sfuMaybeElect();
292
+ }
293
+ _sfuMaybeElect() {
294
+ if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
295
+ if (this.sfu.electionTimer) return;
296
+ this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 2000);
297
+ }
298
+ _sfuElect() {
299
+ const all = [this.auth.pubkey]; for (const pk of this.peers.keys()) all.push(pk);
300
+ let best = null, bestAvg = Infinity;
301
+ for (const pk of all) {
302
+ const s = this.sfu.rttMatrix.get(pk); if (!s) continue;
303
+ const vals = Object.values(s).filter(v => typeof v === 'number'); if (!vals.length) continue;
304
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
305
+ if (avg < bestAvg || (avg === bestAvg && pk > best)) { bestAvg = avg; best = pk; }
306
+ }
307
+ if (!best || best === this.sfu.hub) return;
308
+ this.sfu.hub = best; this.sfu.actor?.send({ type: 'elected' });
309
+ if (best === this.auth.pubkey) this._sfuBecomeHub(); else this._sfuRouteToHub(best);
310
+ }
311
+ _sfuBecomeHub() {
312
+ for (const [srcPk, srcPeer] of this.peers) {
313
+ if (!srcPeer.pc?.getReceivers) continue;
314
+ srcPeer.pc.getReceivers().forEach(recv => {
315
+ if (recv.track?.kind !== 'audio') return;
316
+ for (const [dstPk, dstPeer] of this.peers) {
317
+ if (dstPk === srcPk) continue;
318
+ const sender = dstPeer.pc?.getSenders().find(s => s.track?.kind === 'audio');
319
+ sender?.replaceTrack(recv.track).catch(() => {});
320
+ }
321
+ });
322
+ }
323
+ }
324
+ _sfuRouteToHub(hubPk) {
325
+ for (const pk of Array.from(this.peers.keys())) { if (pk === hubPk) continue; this._closePeer(pk); }
326
+ if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
327
+ }
328
+ _sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
329
+
330
+ _rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }
331
+
332
+ debug() {
333
+ const peers = [];
334
+ for (const [pk, peer] of this.peers) peers.push({ pubkey: pk.slice(0, 12), fsmState: peer.fsm?.getSnapshot().value, iceState: peer.pc?.iceConnectionState, connState: peer.pc?.connectionState, candidates: peer.pendingCandidates.length, buffered: peer.bufferedCandidates.length });
335
+ const rttMatrix = {}; for (const [k, v] of this.sfu.rttMatrix) rttMatrix[k.slice(0, 12)] = v;
336
+ return { fsm: this.actor?.getSnapshot().value, peers, participants: this.getParticipants(), sfu: { mode: this.sfu.actor?.getSnapshot().value || 'mesh', hub: this.sfu.hub?.slice(0, 12) || null, rttMatrix }, retrySchedule: this.retrySchedule };
337
+ }
338
+
339
+ heal() {
340
+ if (!this.roomId) return;
341
+ const bad = { disconnected: 1, failed: 1, closed: 1 };
342
+ for (const [pk, peer] of this.peers) if (bad[peer.pc?.connectionState]) { this._closePeer(pk); this._scheduleReconnect(pk, 0); }
343
+ }
344
+
345
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
346
+ }
347
+
348
+ export const createVoiceSession = (opts) => new VoiceSession(opts);