wireweave 0.3.42 → 0.3.44

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/data.js +30 -1
  3. package/src/voice.js +119 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
4
4
  "description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/data.js CHANGED
@@ -16,9 +16,37 @@ const DEFAULT_ICE_SERVERS = [
16
16
  { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' }
17
17
  ];
18
18
  let ICE_SERVERS = DEFAULT_ICE_SERVERS;
19
- export const setIceServers = (list) => { if (Array.isArray(list) && list.length) ICE_SERVERS = list; };
19
+ let iceServersOverridden = false;
20
+ export const setIceServers = (list) => { if (Array.isArray(list) && list.length) { ICE_SERVERS = list; iceServersOverridden = true; } };
20
21
  export const getIceServers = () => ICE_SERVERS.slice();
21
22
 
23
+ // The bundled TURN entries use the openrelayproject public demo credentials
24
+ // (metered.ca's shared, rate-limited, no-SLA relay). Fine for local dev/testing,
25
+ // but a real hosted deployment that never calls setIceServers() is silently
26
+ // depending on a third party's free-tier relay for every NAT-restricted peer —
27
+ // warn once, at first connect, so that's discoverable instead of a mystery
28
+ // "some players can never connect" bug report. Never fires on localhost/loopback
29
+ // dev servers, and never fires once the host has provided its own iceServers.
30
+ let defaultTurnWarned = false;
31
+ const isHostedDeployment = () => {
32
+ try {
33
+ const host = typeof location !== 'undefined' && location.hostname;
34
+ if (!host) return false;
35
+ if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '') return false;
36
+ if (host.endsWith('.local')) return false;
37
+ return true;
38
+ } catch { return false; }
39
+ };
40
+ const warnDefaultTurnCredentialsOnce = () => {
41
+ if (defaultTurnWarned || iceServersOverridden) return;
42
+ if (!isHostedDeployment()) return;
43
+ defaultTurnWarned = true;
44
+ console.warn('[wireweave] Using the bundled default TURN servers (openrelayproject shared public credentials) ' +
45
+ 'on a non-localhost deployment. These are a free, rate-limited, no-SLA relay meant for local dev/testing — ' +
46
+ 'NAT-restricted peers on this deployment may fail to connect or get throttled. Call setIceServers() with your ' +
47
+ 'own TURN credentials before connecting.');
48
+ };
49
+
22
50
  const PRESENCE_EXPIRY = 300000;
23
51
  const HEARTBEAT = 30000;
24
52
  const DISCONNECT_GRACE = 8000;
@@ -64,6 +92,7 @@ export class DataSession extends EventTarget {
64
92
  }
65
93
 
66
94
  async connect(room, { displayName = 'Guest' } = {}) {
95
+ warnDefaultTurnCredentialsOnce();
67
96
  if (!this.actor) this._initActor();
68
97
  if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
69
98
  this.actor.send({ type: 'connect' });
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({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null, createPeerConnection = defaultCreatePeerConnection }) {
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: true, noiseSuppression: true, autoGainControl: true } });
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 default: gate closed at join. Apps that want always-on call setMuted(false).
100
- this.muted = true;
101
- this.localStream.getAudioTracks().forEach(t => t.enabled = false);
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: true, isLocal: true, hasVideo: false, connectionQuality: 'good' });
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 > SPEAKER_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
- if (!this.muted) this.setMuted(true);
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
- p.encodings[0].maxBitrate = 48000;
605
- // Opus inband-FEC + DTX: lower bitrate use under silence, better recovery on packet loss.
606
- // Reduces the chance of fallback to TURN-relay on flaky direct UDP paths.
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';