wireweave 0.1.6 → 0.2.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.
Files changed (2) hide show
  1. package/package.json +2 -3
  2. package/src/voice.js +276 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.1.6",
3
+ "version": "0.2.2",
4
4
  "description": "nostr + webrtc voice SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node test.js",
33
- "site:build": "node site/build.js"
33
+ "site:build": "npx --yes flatspace@latest build"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
@@ -54,7 +54,6 @@
54
54
  "nostr-tools": "^2.7.0"
55
55
  },
56
56
  "devDependencies": {
57
- "flatspace": "^1.0.13",
58
57
  "nostr-tools": "^2.7.0",
59
58
  "ws": "^8.18.0"
60
59
  },
package/src/voice.js CHANGED
@@ -14,6 +14,16 @@ const HEARTBEAT = 30000;
14
14
  const STALL_CHECK = 5000;
15
15
  const DISCONNECT_GRACE = 8000;
16
16
 
17
+ // Speaker-activity / queue / anti-overtalk tunables
18
+ const SPEAKER_ACTIVE_RMS = 0.045; // RMS threshold above which a stream counts as "speaking"
19
+ const SPEAKER_HOLD_MS = 350; // tail: stay marked speaking this long after last frame > threshold
20
+ const SPEAKER_POLL_MS = 80; // analyzer poll cadence
21
+ const QUEUE_MAX_SEGMENT_MS = 30000; // hard cap per segment
22
+ const QUEUE_MIME_PREFS = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
23
+ const DC_LABEL = 'wireweave-queue';
24
+ const DC_CHUNK_MAX = 14000; // ~14 KB SCTP-friendly chunks
25
+ const DC_HEADER = 'WW1'; // protocol marker
26
+
17
27
  const deriveRoomId = async (serverId, channel) => {
18
28
  const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
19
29
  return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
@@ -52,8 +62,12 @@ export class VoiceSession extends EventTarget {
52
62
  try {
53
63
  this.roomId = await deriveRoomId(this.serverId, channelName);
54
64
  this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
65
+ // PTT default: gate closed at join. Apps that want always-on call setMuted(false).
66
+ this.muted = true;
67
+ this.localStream.getAudioTracks().forEach(t => t.enabled = false);
55
68
  this.participants.clear();
56
- this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: false, isLocal: true, hasVideo: false, connectionQuality: 'good' });
69
+ this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: true, isLocal: true, hasVideo: false, connectionQuality: 'good' });
70
+ this._attachAnalyzer('local', this.localStream);
57
71
  this.actor.send({ type: 'connected' });
58
72
  this._subscribeSignals();
59
73
  this._subscribePresence();
@@ -77,6 +91,10 @@ export class VoiceSession extends EventTarget {
77
91
  this._sfuStop();
78
92
  for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
79
93
  this.peers.clear();
94
+ if (this._activityTimer) { clearInterval(this._activityTimer); this._activityTimer = null; }
95
+ if (this._activeAnalyzers) { for (const k of Array.from(this._activeAnalyzers.keys())) this._detachAnalyzer(k); }
96
+ if (this._outboundRec) { try { this._outboundRec.rec.stop(); } catch {} this._outboundRec = null; }
97
+ if (this._actx && this._actx.state !== 'closed') { try { this._actx.close(); } catch {} this._actx = null; }
80
98
  if (this.cameraStream) { this.cameraStream.getTracks().forEach(t => t.stop()); this.cameraStream = null; }
81
99
  if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; }
82
100
  if (this.roomId) { this.pool.unsubscribe('voice-presence-' + this.roomId); this.pool.unsubscribe('voice-signals-' + this.roomId); }
@@ -87,14 +105,265 @@ export class VoiceSession extends EventTarget {
87
105
  this._emit('disconnected', {});
88
106
  }
89
107
 
90
- toggleMic() {
91
- this.muted = !this.muted;
108
+ toggleMic() { return this.setMuted(!this.muted); }
109
+
110
+ // setMuted is the canonical API. PTT layers should call setMuted(false) on
111
+ // hold-start and setMuted(true) on hold-end. Anti-overtalk lives below as
112
+ // requestTransmit / releaseTransmit which gate the unmute on remote silence.
113
+ setMuted(want) {
114
+ const next = !!want;
115
+ if (this.muted === next) return;
116
+ this.muted = next;
92
117
  if (this.localStream) this.localStream.getAudioTracks().forEach(t => t.enabled = !this.muted);
93
118
  const local = this.participants.get('local'); if (local) local.isMuted = this.muted;
119
+ if (this.muted) this._localActivityClear();
94
120
  this._emit('mic', { muted: this.muted });
95
121
  this._emit('participants', { list: this.getParticipants() });
96
122
  }
97
123
 
124
+ // ────────────────────────────────────────────────────────────────────────
125
+ // Speaker-activity detection
126
+ // Each peer stream + the local stream gets a Web Audio analyser. We poll
127
+ // the rms and flip participant.isSpeaking with hysteresis so the rest of
128
+ // the app (including the queue layer) can react.
129
+ // ────────────────────────────────────────────────────────────────────────
130
+ _ensureAudioCtx() {
131
+ if (this._actx && this._actx.state !== 'closed') return this._actx;
132
+ const Ctx = (typeof AudioContext !== 'undefined') ? AudioContext : (typeof webkitAudioContext !== 'undefined') ? webkitAudioContext : null;
133
+ if (!Ctx) return null;
134
+ this._actx = new Ctx();
135
+ this._activeAnalyzers = new Map(); // key → { node, source, lastActive, gainNode? }
136
+ this._mixedDest = this._actx.createMediaStreamDestination();
137
+ this._mixedGain = this._actx.createGain();
138
+ this._mixedGain.gain.value = 1.0;
139
+ this._mixedGain.connect(this._mixedDest);
140
+ if (!this._activityTimer) this._activityTimer = setInterval(() => this._pollActivity(), SPEAKER_POLL_MS);
141
+ return this._actx;
142
+ }
143
+
144
+ _attachAnalyzer(key, stream, { tap = false } = {}) {
145
+ const ctx = this._ensureAudioCtx(); if (!ctx || !stream) return;
146
+ if (this._activeAnalyzers.has(key)) return;
147
+ try {
148
+ const src = ctx.createMediaStreamSource(stream);
149
+ const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.4;
150
+ src.connect(an);
151
+ let tapNode = null;
152
+ if (tap) { tapNode = ctx.createGain(); tapNode.gain.value = 1.0; src.connect(tapNode); tapNode.connect(this._mixedGain); }
153
+ this._activeAnalyzers.set(key, { src, an, tapNode, lastActive: 0, speaking: false });
154
+ } catch {}
155
+ }
156
+
157
+ _detachAnalyzer(key) {
158
+ const a = this._activeAnalyzers?.get(key); if (!a) return;
159
+ try { a.tapNode?.disconnect(); } catch {}
160
+ try { a.an?.disconnect(); } catch {}
161
+ try { a.src?.disconnect(); } catch {}
162
+ this._activeAnalyzers.delete(key);
163
+ this._setSpeaking(key, false);
164
+ }
165
+
166
+ _pollActivity() {
167
+ if (!this._activeAnalyzers || !this._activeAnalyzers.size) return;
168
+ const buf = new Uint8Array(256);
169
+ const now = Date.now();
170
+ for (const [key, a] of this._activeAnalyzers) {
171
+ a.an.getByteTimeDomainData(buf);
172
+ let sum = 0; for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; }
173
+ const rms = Math.sqrt(sum / buf.length);
174
+ const active = rms > SPEAKER_ACTIVE_RMS;
175
+ if (active) a.lastActive = now;
176
+ const stillSpeaking = active || (now - a.lastActive) < SPEAKER_HOLD_MS;
177
+ if (stillSpeaking !== a.speaking) { a.speaking = stillSpeaking; this._setSpeaking(key, stillSpeaking); }
178
+ }
179
+ this._maybeAutoTransmit();
180
+ }
181
+
182
+ _setSpeaking(key, val) {
183
+ const isLocal = key === 'local';
184
+ if (isLocal) {
185
+ const p = this.participants.get('local'); if (p && p.isSpeaking !== val) { p.isSpeaking = val; this._emit('participants', { list: this.getParticipants() }); this._emit('speaker', { key: 'local', isLocal: true, speaking: val }); }
186
+ } else {
187
+ const shortId = 'nostr-' + key.slice(0, 12);
188
+ const p = this.participants.get(shortId);
189
+ if (p && p.isSpeaking !== val) { p.isSpeaking = val; this._emit('participants', { list: this.getParticipants() }); this._emit('speaker', { key, isLocal: false, speaking: val }); }
190
+ }
191
+ }
192
+
193
+ _localActivityClear() {
194
+ const a = this._activeAnalyzers?.get('local'); if (a) { a.lastActive = 0; if (a.speaking) { a.speaking = false; this._setSpeaking('local', false); } }
195
+ }
196
+
197
+ anyRemoteSpeaking() {
198
+ if (!this._activeAnalyzers) return false;
199
+ for (const [k, a] of this._activeAnalyzers) if (k !== 'local' && a.speaking) return true;
200
+ return false;
201
+ }
202
+
203
+ // ────────────────────────────────────────────────────────────────────────
204
+ // Anti-overtalk transmit gate
205
+ // requestTransmit() — opens the mic if the channel is clear; otherwise starts
206
+ // buffering into a local segment. releaseTransmit() finalizes / closes.
207
+ // The buffered segment is then published via per-peer data channel as the
208
+ // outbound queue to peers (they playback when their inbound channel drains).
209
+ // ────────────────────────────────────────────────────────────────────────
210
+ requestTransmit() {
211
+ if (!this.localStream) return false;
212
+ this._wantsTransmit = true;
213
+ if (!this.anyRemoteSpeaking()) { this.setMuted(false); this._emit('transmit', { mode: 'live' }); return true; }
214
+ // remote busy → buffer locally
215
+ this._beginOutboundCapture();
216
+ this._emit('transmit', { mode: 'queued' });
217
+ return false;
218
+ }
219
+
220
+ releaseTransmit() {
221
+ this._wantsTransmit = false;
222
+ if (this._outboundRec) this._finalizeOutboundCapture();
223
+ if (!this.muted) this.setMuted(true);
224
+ this._emit('transmit', { mode: 'idle' });
225
+ }
226
+
227
+ _maybeAutoTransmit() {
228
+ if (!this._wantsTransmit) return;
229
+ // If we're queued AND remote is now silent, flip to live and finalize the held segment
230
+ if (this._outboundRec && !this.anyRemoteSpeaking()) {
231
+ this._finalizeOutboundCapture();
232
+ this.setMuted(false);
233
+ this._emit('transmit', { mode: 'live' });
234
+ }
235
+ // If we're live AND a remote starts speaking, fall back to queued mode
236
+ else if (!this.muted && this.anyRemoteSpeaking()) {
237
+ this.setMuted(true);
238
+ this._beginOutboundCapture();
239
+ this._emit('transmit', { mode: 'queued' });
240
+ }
241
+ }
242
+
243
+ _pickMime() {
244
+ if (typeof MediaRecorder === 'undefined') return null;
245
+ for (const m of QUEUE_MIME_PREFS) if (MediaRecorder.isTypeSupported(m)) return m;
246
+ return '';
247
+ }
248
+
249
+ _beginOutboundCapture() {
250
+ if (this._outboundRec || !this.localStream) return;
251
+ const mime = this._pickMime(); if (mime === null) return;
252
+ const segId = (this.auth.pubkey?.slice(0, 8) || 'me') + '-' + Date.now();
253
+ const chunks = [];
254
+ let rec;
255
+ try { rec = new MediaRecorder(this.localStream, mime ? { mimeType: mime } : undefined); } catch { return; }
256
+ rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
257
+ rec.start(250);
258
+ this._outboundRec = { rec, chunks, segId, mime: mime || rec.mimeType, ts: Date.now(), capLimit: setTimeout(() => this._finalizeOutboundCapture(), QUEUE_MAX_SEGMENT_MS) };
259
+ this._emit('segment-start', { kind: 'outbound', segId });
260
+ }
261
+
262
+ async _finalizeOutboundCapture() {
263
+ const o = this._outboundRec; if (!o) return;
264
+ this._outboundRec = null;
265
+ clearTimeout(o.capLimit);
266
+ if (o.rec.state !== 'inactive') {
267
+ const stopped = new Promise(r => o.rec.onstop = r);
268
+ try { o.rec.stop(); } catch {}
269
+ await stopped;
270
+ }
271
+ if (!o.chunks.length) return;
272
+ const blob = new Blob(o.chunks, { type: o.mime });
273
+ const buf = new Uint8Array(await blob.arrayBuffer());
274
+ const segment = { id: o.segId, from: this.auth.pubkey, name: this.displayName || 'Me', mime: o.mime, ts: o.ts, dur: Date.now() - o.ts, bytes: buf };
275
+ this._emit('segment-finalized', { kind: 'outbound', segment });
276
+ this._broadcastSegment(segment);
277
+ }
278
+
279
+ // ────────────────────────────────────────────────────────────────────────
280
+ // Per-peer data channel — segment broadcast
281
+ // ────────────────────────────────────────────────────────────────────────
282
+ _ensureDataChannel(peer, peerPubkey, isOfferer) {
283
+ if (peer.dc) return;
284
+ if (isOfferer) {
285
+ try {
286
+ const dc = peer.pc.createDataChannel(DC_LABEL, { ordered: true });
287
+ peer.dc = dc; this._wireDataChannel(dc, peer, peerPubkey);
288
+ } catch {}
289
+ } else {
290
+ peer.pc.ondatachannel = (ev) => { if (ev.channel.label === DC_LABEL) { peer.dc = ev.channel; this._wireDataChannel(peer.dc, peer, peerPubkey); } };
291
+ }
292
+ }
293
+
294
+ _wireDataChannel(dc, peer, peerPubkey) {
295
+ dc.binaryType = 'arraybuffer';
296
+ peer._dcInbox = new Map(); // segId → { meta, parts:[], received:0, total:0 }
297
+ dc.onmessage = (e) => this._dcOnMessage(e.data, peer, peerPubkey);
298
+ dc.onopen = () => this._emit('dc-open', { peerPubkey });
299
+ dc.onclose = () => this._emit('dc-close', { peerPubkey });
300
+ }
301
+
302
+ _dcSendJSON(peer, obj) {
303
+ if (!peer.dc || peer.dc.readyState !== 'open') return false;
304
+ try { peer.dc.send(DC_HEADER + 'J' + JSON.stringify(obj)); return true; } catch { return false; }
305
+ }
306
+ _dcSendBytes(peer, segId, idx, total, bytes) {
307
+ if (!peer.dc || peer.dc.readyState !== 'open') return false;
308
+ const head = new TextEncoder().encode(DC_HEADER + 'B' + segId + ':' + idx + '/' + total + ':');
309
+ const buf = new Uint8Array(head.length + bytes.length);
310
+ buf.set(head, 0); buf.set(bytes, head.length);
311
+ try { peer.dc.send(buf.buffer); return true; } catch { return false; }
312
+ }
313
+
314
+ async _broadcastSegment(seg) {
315
+ const open = [];
316
+ for (const [pk, peer] of this.peers) if (peer.dc?.readyState === 'open') open.push([pk, peer]);
317
+ if (!open.length) return;
318
+ const meta = { type: 'seg-meta', segId: seg.id, from: seg.from, name: seg.name, mime: seg.mime, ts: seg.ts, dur: seg.dur, total: Math.ceil(seg.bytes.length / DC_CHUNK_MAX), bytes: seg.bytes.length };
319
+ for (const [, peer] of open) this._dcSendJSON(peer, meta);
320
+ let idx = 0;
321
+ for (let off = 0; off < seg.bytes.length; off += DC_CHUNK_MAX, idx++) {
322
+ const slice = seg.bytes.subarray(off, Math.min(off + DC_CHUNK_MAX, seg.bytes.length));
323
+ for (const [, peer] of open) this._dcSendBytes(peer, seg.id, idx, meta.total, slice);
324
+ }
325
+ for (const [, peer] of open) this._dcSendJSON(peer, { type: 'seg-end', segId: seg.id });
326
+ }
327
+
328
+ _dcOnMessage(data, peer, peerPubkey) {
329
+ if (typeof data === 'string') {
330
+ if (!data.startsWith(DC_HEADER)) return;
331
+ const tag = data[3]; const body = data.slice(4);
332
+ if (tag !== 'J') return;
333
+ let obj; try { obj = JSON.parse(body); } catch { return; }
334
+ if (obj.type === 'seg-meta') {
335
+ peer._dcInbox.set(obj.segId, { meta: obj, parts: new Array(obj.total), received: 0 });
336
+ } else if (obj.type === 'seg-end') {
337
+ const inbox = peer._dcInbox.get(obj.segId); if (!inbox) return;
338
+ peer._dcInbox.delete(obj.segId);
339
+ if (inbox.received < inbox.meta.total) return; // dropped
340
+ const total = inbox.parts.reduce((n, p) => n + (p?.length || 0), 0);
341
+ const buf = new Uint8Array(total); let off = 0;
342
+ for (const p of inbox.parts) { buf.set(p, off); off += p.length; }
343
+ const segment = { id: inbox.meta.segId, from: inbox.meta.from || peerPubkey, name: inbox.meta.name, mime: inbox.meta.mime, ts: inbox.meta.ts, dur: inbox.meta.dur, bytes: buf };
344
+ this._emit('segment-received', { kind: 'inbound', from: peerPubkey, segment });
345
+ }
346
+ return;
347
+ }
348
+ // binary chunk
349
+ const view = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer);
350
+ if (view.length < 6) return;
351
+ if (view[0] !== 0x57 || view[1] !== 0x57 || view[2] !== 0x31) return; // 'WW1'
352
+ if (view[3] !== 0x42) return; // 'B'
353
+ let h = 4; let colons = 0; let metaEnd = -1;
354
+ for (; h < view.length && h < 80; h++) {
355
+ if (view[h] === 0x3A) { colons++; if (colons === 2) { metaEnd = h; break; } }
356
+ }
357
+ if (metaEnd < 0) return;
358
+ const headerStr = new TextDecoder().decode(view.subarray(4, metaEnd));
359
+ // segId:idx/total
360
+ const [segId, rest] = headerStr.split(':');
361
+ const [idxStr, totalStr] = rest.split('/');
362
+ const idx = +idxStr, total = +totalStr;
363
+ const inbox = peer._dcInbox.get(segId); if (!inbox) return;
364
+ if (!inbox.parts[idx]) { inbox.parts[idx] = view.subarray(metaEnd + 1); inbox.received++; }
365
+ }
366
+
98
367
  toggleDeafen() {
99
368
  this.deafened = !this.deafened;
100
369
  for (const [, peer] of this.peers) if (peer.audioEl) peer.audioEl.muted = this.deafened;
@@ -167,6 +436,7 @@ export class VoiceSession extends EventTarget {
167
436
  pc.ontrack = (ev) => {
168
437
  if (ev.track.kind === 'video') { this.onVideoTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0] }); return; }
169
438
  this.onAudioTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0], peer });
439
+ this._attachAnalyzer(peerPubkey, ev.streams[0]);
170
440
  try { ev.receiver.playoutDelayHint = 0.02; } catch {}
171
441
  ev.track.onended = () => { if (peer.fsm.getSnapshot().matches('connected')) this._doIceRestart(peer, peerPubkey, fsmActor); };
172
442
  if (!peer._stallInterval) peer._stallInterval = setInterval(() => this._checkStall(peer, peerPubkey, fsmActor), STALL_CHECK);
@@ -189,6 +459,7 @@ export class VoiceSession extends EventTarget {
189
459
  if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
190
460
  if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
191
461
  };
462
+ this._ensureDataChannel(peer, peerPubkey, isOfferer);
192
463
  if (isOfferer) {
193
464
  fsmActor.send({ type: 'offer' });
194
465
  pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
@@ -263,8 +534,10 @@ export class VoiceSession extends EventTarget {
263
534
  if (peer.iceTimer) clearTimeout(peer.iceTimer);
264
535
  if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
265
536
  if (peer._stallInterval) clearInterval(peer._stallInterval);
537
+ try { peer.dc?.close(); } catch {}
266
538
  try { peer.pc?.close(); } catch {}
267
539
  if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
540
+ this._detachAnalyzer(peerPubkey);
268
541
  this.peers.delete(peerPubkey);
269
542
  this._emit('peer-closed', { peerPubkey });
270
543
  }