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 +4 -1
- package/src/chat.js +95 -0
- package/src/fsm.js +51 -0
- package/src/index.js +3 -0
- package/src/voice.js +348 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wireweave",
|
|
3
|
-
"version": "0.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);
|