wireweave 0.3.41 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +1 -1
- package/src/relay-pool.js +227 -4
- package/src/voice.js +119 -14
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RelayPool, createRelayPool } from './relay-pool.js';
|
|
1
|
+
export { RelayPool, createRelayPool, RelayHealth } from './relay-pool.js';
|
|
2
2
|
export { NostrAuth, createAuth } from './auth.js';
|
|
3
3
|
export { createFSM } from './fsm.js';
|
|
4
4
|
export { VoiceSession, createVoiceSession } from './voice.js';
|
package/src/relay-pool.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { safeSetItem } from './safe-storage.js';
|
|
2
|
+
import * as debug from './debug.js';
|
|
3
|
+
|
|
1
4
|
// Ordered fastest-first by measured connect latency; dead relays removed so
|
|
2
5
|
// signaling reaches a live relay immediately instead of racing dead hosts.
|
|
3
6
|
// (relay.nostr.band / nostr.wine / relay.current.fyi / relay.0xchat.com were
|
|
@@ -12,12 +15,94 @@ const DEFAULT_RELAYS = [
|
|
|
12
15
|
'wss://relay.damus.io'
|
|
13
16
|
];
|
|
14
17
|
|
|
18
|
+
// Backup pool candidates auto-rotation can promote in when a connected relay's
|
|
19
|
+
// health score falls below a healthy alternative's — never DEFAULT_RELAYS
|
|
20
|
+
// members already in play, so rotation always trades toward strictly-better.
|
|
21
|
+
const FALLBACK_RELAYS = [
|
|
22
|
+
'wss://relay.nostr.band',
|
|
23
|
+
'wss://nostr.wine',
|
|
24
|
+
'wss://relay.current.fyi',
|
|
25
|
+
'wss://relay.0xchat.com'
|
|
26
|
+
];
|
|
27
|
+
|
|
15
28
|
const SEEN_MAX = 10000;
|
|
16
29
|
const PENDING_MAX = 500;
|
|
17
30
|
const PENDING_TTL_MS = 120000;
|
|
18
31
|
const CONNECT_TIMEOUT_MS = 10000;
|
|
19
32
|
const jitter = (ms) => Math.round(ms * (0.75 + Math.random() * 0.5));
|
|
20
33
|
|
|
34
|
+
// EWMA smoothing for latency/EOSE-speed samples — recent samples dominate but
|
|
35
|
+
// a single slow sample doesn't crater a relay's score outright.
|
|
36
|
+
const EWMA_ALPHA = 0.3;
|
|
37
|
+
const ewma = (prev, sample) => prev === null ? sample : prev + EWMA_ALPHA * (sample - prev);
|
|
38
|
+
|
|
39
|
+
const HEALTH_STORAGE_KEY = 'ww_relay_health';
|
|
40
|
+
const HEALTH_SAVE_DEBOUNCE_MS = 2000;
|
|
41
|
+
|
|
42
|
+
// health.rank: 0..100, higher is better. Weighted blend of connect latency,
|
|
43
|
+
// EOSE response speed, and uptime ratio (successful sustained connections vs
|
|
44
|
+
// total attempts). Missing samples (relay never connected / no EOSE seen yet)
|
|
45
|
+
// score neutral (50) for that component rather than zero, so a fresh relay
|
|
46
|
+
// isn't unfairly punished before it has a chance to report real numbers.
|
|
47
|
+
const scoreLatency = (ms) => ms === null ? 50 : Math.max(0, 100 - Math.min(ms, 3000) / 30);
|
|
48
|
+
const scoreEose = (ms) => ms === null ? 50 : Math.max(0, 100 - Math.min(ms, 5000) / 50);
|
|
49
|
+
const scoreUptime = (attempts, successes) => attempts === 0 ? 50 : Math.round((successes / attempts) * 100);
|
|
50
|
+
|
|
51
|
+
const computeRank = (health) => Math.round(
|
|
52
|
+
0.35 * scoreLatency(health.connectLatencyMs) +
|
|
53
|
+
0.35 * scoreEose(health.eoseLatencyMs) +
|
|
54
|
+
0.30 * scoreUptime(health.attempts, health.successes)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
class RelayHealth {
|
|
58
|
+
constructor(url) {
|
|
59
|
+
this.url = url;
|
|
60
|
+
this.connectLatencyMs = null;
|
|
61
|
+
this.eoseLatencyMs = null;
|
|
62
|
+
this.attempts = 0;
|
|
63
|
+
this.successes = 0;
|
|
64
|
+
this.rank = 50;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
recordConnectAttempt() { this.attempts++; }
|
|
68
|
+
|
|
69
|
+
recordConnectLatency(ms) {
|
|
70
|
+
this.connectLatencyMs = ewma(this.connectLatencyMs, ms);
|
|
71
|
+
this.rank = computeRank(this);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordSustainedConnection() {
|
|
75
|
+
this.successes++;
|
|
76
|
+
this.rank = computeRank(this);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordEoseLatency(ms) {
|
|
80
|
+
this.eoseLatencyMs = ewma(this.eoseLatencyMs, ms);
|
|
81
|
+
this.rank = computeRank(this);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toJSON() {
|
|
85
|
+
return {
|
|
86
|
+
url: this.url,
|
|
87
|
+
connectLatencyMs: this.connectLatencyMs,
|
|
88
|
+
eoseLatencyMs: this.eoseLatencyMs,
|
|
89
|
+
attempts: this.attempts,
|
|
90
|
+
successes: this.successes,
|
|
91
|
+
rank: this.rank
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static fromJSON(obj) {
|
|
96
|
+
const h = new RelayHealth(obj.url);
|
|
97
|
+
h.connectLatencyMs = obj.connectLatencyMs ?? null;
|
|
98
|
+
h.eoseLatencyMs = obj.eoseLatencyMs ?? null;
|
|
99
|
+
h.attempts = obj.attempts ?? 0;
|
|
100
|
+
h.successes = obj.successes ?? 0;
|
|
101
|
+
h.rank = obj.rank ?? computeRank(h);
|
|
102
|
+
return h;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
21
106
|
const lruTouch = (map, key) => {
|
|
22
107
|
if (map.has(key)) { map.delete(key); map.set(key, 1); return false; }
|
|
23
108
|
map.set(key, 1);
|
|
@@ -34,7 +119,7 @@ const fnv1a = (s) => {
|
|
|
34
119
|
const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
|
|
35
120
|
|
|
36
121
|
export class RelayPool extends EventTarget {
|
|
37
|
-
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null } = {}) {
|
|
122
|
+
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true } = {}) {
|
|
38
123
|
super();
|
|
39
124
|
this.urls = [...relays];
|
|
40
125
|
this.relays = new Map();
|
|
@@ -48,6 +133,116 @@ export class RelayPool extends EventTarget {
|
|
|
48
133
|
this.verifyEvent = verifyEvent;
|
|
49
134
|
this.WS = WebSocketImpl || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
50
135
|
if (!this.WS) throw new Error('No WebSocket implementation available');
|
|
136
|
+
|
|
137
|
+
this.storage = storage;
|
|
138
|
+
this.autoRotate = autoRotate;
|
|
139
|
+
this.fallbackRelays = [...fallbackRelays];
|
|
140
|
+
this.health = new Map();
|
|
141
|
+
for (const url of this.urls) this.health.set(url, new RelayHealth(url));
|
|
142
|
+
this._loadHealth();
|
|
143
|
+
this._saveHealthTimer = null;
|
|
144
|
+
|
|
145
|
+
// Debug-panel integration: window.__wireweave.relayPool (or relayPool2,
|
|
146
|
+
// relayPool3... for additional instances) exposes healthReport() live.
|
|
147
|
+
this._debugKey = 'relayPool';
|
|
148
|
+
let n = 2;
|
|
149
|
+
while (debug.get(this._debugKey)) this._debugKey = 'relayPool' + n++;
|
|
150
|
+
debug.register(this._debugKey, this);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Health scoring / persistence -------------------------------------
|
|
154
|
+
|
|
155
|
+
_getHealth(url) {
|
|
156
|
+
let h = this.health.get(url);
|
|
157
|
+
if (!h) { h = new RelayHealth(url); this.health.set(url, h); }
|
|
158
|
+
return h;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_loadHealth() {
|
|
162
|
+
if (!this.storage) return;
|
|
163
|
+
try {
|
|
164
|
+
const raw = this.storage.getItem(HEALTH_STORAGE_KEY);
|
|
165
|
+
if (!raw) return;
|
|
166
|
+
const parsed = JSON.parse(raw);
|
|
167
|
+
if (!Array.isArray(parsed)) return;
|
|
168
|
+
for (const entry of parsed) {
|
|
169
|
+
if (!entry?.url) continue;
|
|
170
|
+
this.health.set(entry.url, RelayHealth.fromJSON(entry));
|
|
171
|
+
}
|
|
172
|
+
} catch { /* corrupt/absent persisted health is non-fatal — scores rebuild live */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_scheduleSaveHealth() {
|
|
176
|
+
if (!this.storage) return;
|
|
177
|
+
if (this._saveHealthTimer) return;
|
|
178
|
+
this._saveHealthTimer = setTimeout(() => {
|
|
179
|
+
this._saveHealthTimer = null;
|
|
180
|
+
this._saveHealthNow();
|
|
181
|
+
}, HEALTH_SAVE_DEBOUNCE_MS);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_saveHealthNow() {
|
|
185
|
+
if (!this.storage) return;
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const [, h] of this.health) out.push(h.toJSON());
|
|
188
|
+
safeSetItem(this.storage, this, HEALTH_STORAGE_KEY, JSON.stringify(out));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Sorted best-first snapshot for a debug panel / inspection.
|
|
192
|
+
healthReport() {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const [, h] of this.health) out.push(h.toJSON());
|
|
195
|
+
out.sort((a, b) => b.rank - a.rank);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Swap the worst currently-active relay for the best-ranked unused
|
|
200
|
+
// candidate (fallback pool, or a previously-tried relay we disconnected
|
|
201
|
+
// from) when the gap is large enough to be worth the churn of a new
|
|
202
|
+
// connection. Never rotates below MIN_ACTIVE_RELAYS active URLs.
|
|
203
|
+
_maybeRotate() {
|
|
204
|
+
if (!this.autoRotate || this._closed) return;
|
|
205
|
+
const MIN_ACTIVE_RELAYS = 2;
|
|
206
|
+
const ROTATE_GAP = 20;
|
|
207
|
+
if (this.urls.length <= MIN_ACTIVE_RELAYS) return;
|
|
208
|
+
|
|
209
|
+
const active = this.urls
|
|
210
|
+
.map((url) => this._getHealth(url))
|
|
211
|
+
.filter((h) => h.attempts >= 2); // need a couple of samples before judging
|
|
212
|
+
if (active.length === 0) return;
|
|
213
|
+
const worst = active.reduce((a, b) => (a.rank <= b.rank ? a : b));
|
|
214
|
+
|
|
215
|
+
const candidates = this.fallbackRelays.filter((u) => !this.urls.includes(u));
|
|
216
|
+
if (candidates.length === 0) return;
|
|
217
|
+
const candidateHealth = candidates.map((u) => this._getHealth(u));
|
|
218
|
+
const best = candidateHealth.reduce((a, b) => (a.rank >= b.rank ? a : b));
|
|
219
|
+
// Only rotate toward a candidate with real observed history beating the
|
|
220
|
+
// worst active relay by a wide margin — an untested candidate (rank 50
|
|
221
|
+
// neutral default) never displaces a relay with a real track record.
|
|
222
|
+
if (best.attempts === 0) return;
|
|
223
|
+
if (best.rank - worst.rank < ROTATE_GAP) return;
|
|
224
|
+
|
|
225
|
+
this._rotate(worst.url, best.url);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_rotate(outUrl, inUrl) {
|
|
229
|
+
const idx = this.urls.indexOf(outUrl);
|
|
230
|
+
if (idx === -1) return;
|
|
231
|
+
this.urls[idx] = inUrl;
|
|
232
|
+
this.fallbackRelays = this.fallbackRelays.filter((u) => u !== inUrl);
|
|
233
|
+
this.fallbackRelays.push(outUrl);
|
|
234
|
+
|
|
235
|
+
const old = this.relays.get(outUrl);
|
|
236
|
+
if (old) {
|
|
237
|
+
if (old._connectTimer) clearTimeout(old._connectTimer);
|
|
238
|
+
if (old.ws) { old.ws.onclose = null; old.ws.onerror = null; old.ws.onopen = null; old.ws.onmessage = null; try { old.ws.close(); } catch {} }
|
|
239
|
+
this.relays.delete(outUrl);
|
|
240
|
+
}
|
|
241
|
+
const t = this._reconnectTimers.get(outUrl);
|
|
242
|
+
if (t) { clearTimeout(t); this._reconnectTimers.delete(outUrl); }
|
|
243
|
+
|
|
244
|
+
this._emit('relay-rotated', { out: outUrl, in: inUrl, outRank: this._getHealth(outUrl).rank, inRank: this._getHealth(inUrl).rank });
|
|
245
|
+
if (!this._closed) this._open(inUrl);
|
|
51
246
|
}
|
|
52
247
|
|
|
53
248
|
connect() {
|
|
@@ -71,6 +266,9 @@ export class RelayPool extends EventTarget {
|
|
|
71
266
|
}
|
|
72
267
|
}
|
|
73
268
|
this.relays.clear();
|
|
269
|
+
if (this._saveHealthTimer) { clearTimeout(this._saveHealthTimer); this._saveHealthTimer = null; }
|
|
270
|
+
this._saveHealthNow();
|
|
271
|
+
debug.deregister(this._debugKey);
|
|
74
272
|
}
|
|
75
273
|
|
|
76
274
|
_open(url) {
|
|
@@ -78,10 +276,14 @@ export class RelayPool extends EventTarget {
|
|
|
78
276
|
this._reconnectTimers.delete(url);
|
|
79
277
|
const existing = this.relays.get(url);
|
|
80
278
|
if (existing?.ws && (existing.ws.readyState === 0 || existing.ws.readyState === 1)) return;
|
|
81
|
-
const relay = existing || { ws: null, status: 'connecting', subIds: new Set(), latencyMs: null, failCount: 0, reconnectDelay: 1000, _reqSentAt: null, _openedAt: null };
|
|
279
|
+
const relay = existing || { ws: null, status: 'connecting', subIds: new Set(), latencyMs: null, failCount: 0, reconnectDelay: 1000, _reqSentAt: null, _openedAt: null, _eoseReqSentAt: new Map() };
|
|
280
|
+
if (!relay._eoseReqSentAt) relay._eoseReqSentAt = new Map();
|
|
82
281
|
relay.status = 'connecting';
|
|
83
282
|
relay.latencyMs = null;
|
|
84
283
|
this.relays.set(url, relay);
|
|
284
|
+
const health = this._getHealth(url);
|
|
285
|
+
health.recordConnectAttempt();
|
|
286
|
+
const connectStartedAt = Date.now();
|
|
85
287
|
let ws;
|
|
86
288
|
try { ws = new this.WS(url); }
|
|
87
289
|
catch (e) { relay.status = 'error'; this._emit('relay-status', { url, status: 'error' }); return; }
|
|
@@ -95,11 +297,14 @@ export class RelayPool extends EventTarget {
|
|
|
95
297
|
clearTimeout(connectTimer);
|
|
96
298
|
relay.status = 'connected';
|
|
97
299
|
relay._openedAt = Date.now();
|
|
300
|
+
health.recordConnectLatency(relay._openedAt - connectStartedAt);
|
|
301
|
+
this._scheduleSaveHealth();
|
|
98
302
|
this._emit('relay-status', { url, status: 'connected' });
|
|
99
303
|
for (const [subId, sub] of this.subs) {
|
|
100
304
|
ws.send(JSON.stringify(['REQ', subId, ...sub.filters]));
|
|
101
305
|
relay.subIds.add(subId);
|
|
102
306
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
307
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
103
308
|
}
|
|
104
309
|
this._drainPending(url, ws);
|
|
105
310
|
};
|
|
@@ -116,8 +321,16 @@ export class RelayPool extends EventTarget {
|
|
|
116
321
|
relay.status = 'closed';
|
|
117
322
|
this._emit('relay-status', { url, status: 'closed' });
|
|
118
323
|
const sustained = relay._openedAt && Date.now() - relay._openedAt > 5000;
|
|
119
|
-
if (sustained) {
|
|
120
|
-
|
|
324
|
+
if (sustained) {
|
|
325
|
+
relay.failCount = 0;
|
|
326
|
+
relay.reconnectDelay = 1000;
|
|
327
|
+
health.recordSustainedConnection();
|
|
328
|
+
this._scheduleSaveHealth();
|
|
329
|
+
this._maybeRotate();
|
|
330
|
+
} else {
|
|
331
|
+
relay.failCount++;
|
|
332
|
+
relay.reconnectDelay = Math.min(relay.reconnectDelay * 2, 30000);
|
|
333
|
+
}
|
|
121
334
|
relay._openedAt = null;
|
|
122
335
|
if (this._closed) return;
|
|
123
336
|
const t = setTimeout(() => this._open(url), jitter(relay.reconnectDelay));
|
|
@@ -142,6 +355,13 @@ export class RelayPool extends EventTarget {
|
|
|
142
355
|
sub?.onEvent?.(event);
|
|
143
356
|
this._emit('event', { subId, event });
|
|
144
357
|
} else if (type === 'EOSE') {
|
|
358
|
+
const relay = this.relays.get(url);
|
|
359
|
+
const sentAt = relay?._eoseReqSentAt?.get(subId);
|
|
360
|
+
if (sentAt) {
|
|
361
|
+
relay._eoseReqSentAt.delete(subId);
|
|
362
|
+
this._getHealth(url).recordEoseLatency(Date.now() - sentAt);
|
|
363
|
+
this._scheduleSaveHealth();
|
|
364
|
+
}
|
|
145
365
|
this.subs.get(subId)?.onEose?.();
|
|
146
366
|
this._emit('eose', { subId });
|
|
147
367
|
} else if (type === 'NOTICE') {
|
|
@@ -163,6 +383,8 @@ export class RelayPool extends EventTarget {
|
|
|
163
383
|
relay.ws.send(JSON.stringify(['REQ', subId, ...filters]));
|
|
164
384
|
relay.subIds.add(subId);
|
|
165
385
|
if (!relay._reqSentAt) relay._reqSentAt = Date.now();
|
|
386
|
+
if (!relay._eoseReqSentAt) relay._eoseReqSentAt = new Map();
|
|
387
|
+
relay._eoseReqSentAt.set(subId, Date.now());
|
|
166
388
|
}
|
|
167
389
|
}
|
|
168
390
|
return subId;
|
|
@@ -290,3 +512,4 @@ export class RelayPool extends EventTarget {
|
|
|
290
512
|
}
|
|
291
513
|
|
|
292
514
|
export const createRelayPool = (opts) => new RelayPool(opts);
|
|
515
|
+
export { RelayHealth };
|
package/src/voice.js
CHANGED
|
@@ -36,6 +36,18 @@ const DC_LABEL = 'wireweave-queue';
|
|
|
36
36
|
const DC_CHUNK_MAX = 14000; // ~14 KB SCTP-friendly chunks
|
|
37
37
|
const DC_HEADER = 'WW1'; // protocol marker
|
|
38
38
|
|
|
39
|
+
// Opus bitrate ladder: named quality tiers applied to the outbound audio
|
|
40
|
+
// RTCRtpSender's encoding via setParameters(). This is the real mechanism
|
|
41
|
+
// available for a single (non-simulcast) Opus sender — see the simulcast
|
|
42
|
+
// note on VoiceSession below for why per-layer simulcast is out of scope.
|
|
43
|
+
const OPUS_BITRATE_LADDER = {
|
|
44
|
+
low: 16000,
|
|
45
|
+
medium: 32000,
|
|
46
|
+
high: 48000,
|
|
47
|
+
max: 64000
|
|
48
|
+
};
|
|
49
|
+
const DEFAULT_AUDIO_QUALITY = 'high';
|
|
50
|
+
|
|
39
51
|
const deriveRoomId = async (serverId, channel) => {
|
|
40
52
|
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
|
|
41
53
|
return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
@@ -46,8 +58,26 @@ const deriveRoomId = async (serverId, channel) => {
|
|
|
46
58
|
// proxy passthrough) without wireweave depending on any Node WebRTC binding.
|
|
47
59
|
const defaultCreatePeerConnection = (config) => new RTCPeerConnection(config);
|
|
48
60
|
|
|
61
|
+
// SIMULCAST-LITE — scope verified against the real API surface used in this
|
|
62
|
+
// file before promising it. Real browser simulcast (multiple RTCRtpEncodingParameters
|
|
63
|
+
// with distinct `rid`/`scaleResolutionDownBy` on one RTCRtpSender) is a VIDEO-only
|
|
64
|
+
// technique: Opus is not encoded per-layer, and every addTransceiver/addTrack call
|
|
65
|
+
// site in this file (see `_maybeConnect`, `_handleSignal`'s doAnswer) sends audio
|
|
66
|
+
// only — `cameraStream` is a field with no producer, there is no video sendrecv
|
|
67
|
+
// transceiver anywhere in this module. Promising `sendEncodings` simulcast here
|
|
68
|
+
// would be fabricated. The honest, reachable analog for a single-encoding Opus
|
|
69
|
+
// sender is *adaptive bitrate switching* driven by live getStats() (already
|
|
70
|
+
// polled in `_sfuPoll`) — `setAudioQuality`/the bitrate ladder below is that
|
|
71
|
+
// real, verifiable mechanism, not simulcast. If/when this module gains a real
|
|
72
|
+
// video sender, true simulcast (sendEncodings on addTransceiver) becomes
|
|
73
|
+
// reachable and should replace this note.
|
|
49
74
|
export class VoiceSession extends EventTarget {
|
|
50
|
-
constructor({
|
|
75
|
+
constructor({
|
|
76
|
+
fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '',
|
|
77
|
+
onAudioTrack = null, onVideoTrack = null, createPeerConnection = defaultCreatePeerConnection,
|
|
78
|
+
pttMode = true, micSensitivity = SPEAKER_ACTIVE_RMS, noiseSuppression = true,
|
|
79
|
+
echoCancellation = true, autoGainControl = true, audioQuality = DEFAULT_AUDIO_QUALITY, dtx = true
|
|
80
|
+
}) {
|
|
51
81
|
super();
|
|
52
82
|
if (!fsm || !xstate || !relayPool || !auth || !mediaDevices) throw new Error('VoiceSession: missing deps');
|
|
53
83
|
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth; this.md = mediaDevices; this.bans = bans;
|
|
@@ -63,8 +93,55 @@ export class VoiceSession extends EventTarget {
|
|
|
63
93
|
this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
|
|
64
94
|
this.retrySchedule = {};
|
|
65
95
|
this._epoch = 0;
|
|
96
|
+
// Push-to-talk mode: when true (default, matches prior hardcoded behavior),
|
|
97
|
+
// connect() starts muted and the caller must setMuted(false)/requestTransmit()
|
|
98
|
+
// to speak. When false, connect() starts unmuted (open-mic / voice-activity mode).
|
|
99
|
+
this.pttMode = !!pttMode;
|
|
100
|
+
// Mic-sensitivity threshold: RMS level above which the speaker-activity
|
|
101
|
+
// detector (_pollActivity) counts a stream as "speaking". Was a hardcoded
|
|
102
|
+
// module constant (SPEAKER_ACTIVE_RMS); now a real per-instance, live-settable value.
|
|
103
|
+
this.micSensitivity = typeof micSensitivity === 'number' && micSensitivity > 0 ? micSensitivity : SPEAKER_ACTIVE_RMS;
|
|
104
|
+
// getUserMedia audio constraints — were hardcoded `true` at the single
|
|
105
|
+
// getUserMedia call site in connect(); now real constructor-configurable
|
|
106
|
+
// fields actually threaded into that call.
|
|
107
|
+
this.noiseSuppression = !!noiseSuppression;
|
|
108
|
+
this.echoCancellation = !!echoCancellation;
|
|
109
|
+
this.autoGainControl = !!autoGainControl;
|
|
110
|
+
// Opus bitrate ladder tier + DTX (discontinuous transmission / silence
|
|
111
|
+
// suppression). Applied for real in _applyAudioHints (bitrate, via the
|
|
112
|
+
// existing RTCRtpSender.setParameters call) and _mungeDtx (DTX, via
|
|
113
|
+
// real SDP fmtp munging — DTX is not an RTCRtpEncodingParameters field in
|
|
114
|
+
// the actual spec, it is negotiated in the Opus fmtp line).
|
|
115
|
+
this.setAudioQuality(audioQuality);
|
|
116
|
+
this.dtx = !!dtx;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Live-settable: mic-sensitivity threshold used by the speaker-activity poller.
|
|
120
|
+
setMicSensitivity(rms) {
|
|
121
|
+
if (typeof rms !== 'number' || !(rms > 0)) return;
|
|
122
|
+
this.micSensitivity = rms;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Live-settable: push-to-talk vs open-mic mode. Does not itself mute/unmute —
|
|
126
|
+
// it changes what connect() defaults to and what releaseTransmit() restores to.
|
|
127
|
+
setPttMode(on) { this.pttMode = !!on; }
|
|
128
|
+
|
|
129
|
+
// Live-settable: Opus target bitrate tier. Re-applies immediately to every
|
|
130
|
+
// connected peer's audio sender via the same setParameters() path _applyAudioHints
|
|
131
|
+
// uses, so a mid-call quality change actually reaches the wire.
|
|
132
|
+
setAudioQuality(tier) {
|
|
133
|
+
const kbps = OPUS_BITRATE_LADDER[tier];
|
|
134
|
+
this.audioQuality = kbps ? tier : DEFAULT_AUDIO_QUALITY;
|
|
135
|
+
this._targetBitrate = kbps || OPUS_BITRATE_LADDER[DEFAULT_AUDIO_QUALITY];
|
|
136
|
+
for (const [, peer] of this.peers) if (peer.pc) this._applyAudioHints(peer.pc);
|
|
66
137
|
}
|
|
67
138
|
|
|
139
|
+
// Live-settable: DTX (silence suppression) toggle. Re-negotiation of an
|
|
140
|
+
// already-open connection isn't forced (that would require a fresh offer/answer);
|
|
141
|
+
// it takes effect on the next SDP exchange (offer, answer, or ICE restart) for
|
|
142
|
+
// open peers, and immediately for any new connection.
|
|
143
|
+
setDtx(on) { this.dtx = !!on; }
|
|
144
|
+
|
|
68
145
|
_initActor() {
|
|
69
146
|
this.actor = this.xstate.createActor(this.fsm.voiceMachine);
|
|
70
147
|
this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
|
|
@@ -92,15 +169,16 @@ export class VoiceSession extends EventTarget {
|
|
|
92
169
|
try {
|
|
93
170
|
const roomId = await deriveRoomId(this.serverId, channelName);
|
|
94
171
|
if (epoch !== this._epoch) return;
|
|
95
|
-
const stream = await this.md.getUserMedia({ audio: { echoCancellation:
|
|
172
|
+
const stream = await this.md.getUserMedia({ audio: { echoCancellation: this.echoCancellation, noiseSuppression: this.noiseSuppression, autoGainControl: this.autoGainControl } });
|
|
96
173
|
if (epoch !== this._epoch) { stream.getTracks().forEach(t => t.stop()); return; }
|
|
97
174
|
this.roomId = roomId;
|
|
98
175
|
this.localStream = stream;
|
|
99
|
-
// PTT
|
|
100
|
-
|
|
101
|
-
this.
|
|
176
|
+
// PTT mode: gate closed at join, caller opens it via setMuted(false)/requestTransmit().
|
|
177
|
+
// Open-mic mode (pttMode=false): start unmuted.
|
|
178
|
+
this.muted = this.pttMode;
|
|
179
|
+
this.localStream.getAudioTracks().forEach(t => t.enabled = !this.pttMode);
|
|
102
180
|
this.participants.clear();
|
|
103
|
-
this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted:
|
|
181
|
+
this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: this.pttMode, isLocal: true, hasVideo: false, connectionQuality: 'good' });
|
|
104
182
|
this._attachAnalyzer('local', this.localStream);
|
|
105
183
|
this.actor.send({ type: 'connected' });
|
|
106
184
|
this._subscribeSignals();
|
|
@@ -209,7 +287,7 @@ export class VoiceSession extends EventTarget {
|
|
|
209
287
|
a.an.getByteTimeDomainData(buf);
|
|
210
288
|
let sum = 0; for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; }
|
|
211
289
|
const rms = Math.sqrt(sum / buf.length);
|
|
212
|
-
const active = rms >
|
|
290
|
+
const active = rms > this.micSensitivity;
|
|
213
291
|
if (active) a.lastActive = now;
|
|
214
292
|
const stillSpeaking = active || (now - a.lastActive) < SPEAKER_HOLD_MS;
|
|
215
293
|
if (stillSpeaking !== a.speaking) { a.speaking = stillSpeaking; this._setSpeaking(key, stillSpeaking); }
|
|
@@ -258,7 +336,9 @@ export class VoiceSession extends EventTarget {
|
|
|
258
336
|
releaseTransmit() {
|
|
259
337
|
this._wantsTransmit = false;
|
|
260
338
|
if (this._outboundRec) this._finalizeOutboundCapture();
|
|
261
|
-
|
|
339
|
+
// Only re-close the gate in PTT mode. In open-mic mode there is no "release"
|
|
340
|
+
// to fall back to — the mic stays live per pttMode's own semantics.
|
|
341
|
+
if (this.pttMode && !this.muted) this.setMuted(true);
|
|
262
342
|
this._emit('transmit', { mode: 'idle' });
|
|
263
343
|
}
|
|
264
344
|
|
|
@@ -591,19 +671,44 @@ export class VoiceSession extends EventTarget {
|
|
|
591
671
|
this._ensureDataChannel(peer, peerPubkey, isOfferer);
|
|
592
672
|
if (isOfferer) {
|
|
593
673
|
fsmActor.send({ type: 'offer' });
|
|
594
|
-
pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
|
|
674
|
+
pc.createOffer().then(o => { o.sdp = this._mungeDtx(o.sdp); return pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o)); }).catch(() => {});
|
|
595
675
|
}
|
|
596
676
|
}
|
|
597
677
|
|
|
678
|
+
// Real DTX (discontinuous transmission / silence suppression) toggle.
|
|
679
|
+
// DTX is not an RTCRtpEncodingParameters field in the actual WebRTC spec —
|
|
680
|
+
// it's negotiated per the Opus fmtp SDP line (`usedtx=1`). This mutates the
|
|
681
|
+
// outgoing SDP's audio m-section fmtp lines for the Opus payload type(s)
|
|
682
|
+
// found via the SDP itself (matches "opus" case-insensitively, same as the
|
|
683
|
+
// codec-preference filter in _applyAudioHints), adding/removing usedtx=1.
|
|
684
|
+
_mungeDtx(sdp) {
|
|
685
|
+
if (!sdp) return sdp;
|
|
686
|
+
const lines = sdp.split('\r\n');
|
|
687
|
+
const opusPts = new Set();
|
|
688
|
+
for (const line of lines) {
|
|
689
|
+
const m = /^a=rtpmap:(\d+)\s+opus\//i.exec(line);
|
|
690
|
+
if (m) opusPts.add(m[1]);
|
|
691
|
+
}
|
|
692
|
+
if (!opusPts.size) return sdp;
|
|
693
|
+
const out = lines.map(line => {
|
|
694
|
+
const m = /^a=fmtp:(\d+)\s+(.*)$/.exec(line);
|
|
695
|
+
if (!m || !opusPts.has(m[1])) return line;
|
|
696
|
+
const params = m[2].split(';').map(p => p.trim()).filter(p => p && !/^usedtx=/i.test(p));
|
|
697
|
+
if (this.dtx) params.push('usedtx=1');
|
|
698
|
+
return 'a=fmtp:' + m[1] + ' ' + params.join(';');
|
|
699
|
+
});
|
|
700
|
+
return out.join('\r\n');
|
|
701
|
+
}
|
|
702
|
+
|
|
598
703
|
_applyAudioHints(pc) {
|
|
599
704
|
try {
|
|
600
705
|
pc.getSenders().forEach(s => {
|
|
601
706
|
if (!s.track || s.track.kind !== 'audio') return;
|
|
602
707
|
const p = s.getParameters(); if (!p.encodings?.length) return;
|
|
603
708
|
p.encodings[0].networkPriority = 'high';
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
|
|
709
|
+
// Opus bitrate ladder: real per-tier target set via setAudioQuality(),
|
|
710
|
+
// applied here through the actual RTCRtpSender.setParameters() call.
|
|
711
|
+
p.encodings[0].maxBitrate = this._targetBitrate || OPUS_BITRATE_LADDER[DEFAULT_AUDIO_QUALITY];
|
|
607
712
|
p.encodings[0].priority = 'high';
|
|
608
713
|
s.setParameters(p).catch(() => {});
|
|
609
714
|
});
|
|
@@ -634,7 +739,7 @@ export class VoiceSession extends EventTarget {
|
|
|
634
739
|
peer.failCount++;
|
|
635
740
|
if (peer.failCount <= 1 && this.auth.pubkey > peerPubkey) {
|
|
636
741
|
fsmActor.send({ type: 'restart' }); pc.restartIce();
|
|
637
|
-
pc.createOffer({ iceRestart: true }).then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => this._closePeer(peerPubkey));
|
|
742
|
+
pc.createOffer({ iceRestart: true }).then(o => { o.sdp = this._mungeDtx(o.sdp); return pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o)); }).catch(() => this._closePeer(peerPubkey));
|
|
638
743
|
} else { this._closePeer(peerPubkey); this._scheduleReconnect(peerPubkey, peer.failCount); }
|
|
639
744
|
}
|
|
640
745
|
|
|
@@ -654,7 +759,7 @@ export class VoiceSession extends EventTarget {
|
|
|
654
759
|
const hasAudioTx = pc.getTransceivers().some(t => t.receiver.track?.kind === 'audio');
|
|
655
760
|
if (!hasAudioTx) pc.addTransceiver('audio', { direction: this.localStream ? 'sendrecv' : 'recvonly' });
|
|
656
761
|
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)); }
|
|
657
|
-
const a = await pc.createAnswer(); await pc.setLocalDescription(a); fsmActor.send({ type: 'sent_answer' }); this._publishSignal(from, 'answer', a);
|
|
762
|
+
const a = await pc.createAnswer(); a.sdp = this._mungeDtx(a.sdp); await pc.setLocalDescription(a); fsmActor.send({ type: 'sent_answer' }); this._publishSignal(from, 'answer', a);
|
|
658
763
|
};
|
|
659
764
|
if (data.type === 'offer') {
|
|
660
765
|
const polite = this.auth.pubkey < from; const collision = pc.signalingState !== 'stable';
|