@rapidmx/meet-plugin 0.14.0 → 0.15.0

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 (46) hide show
  1. package/apps/meet/_CallControls.tsx +119 -2
  2. package/apps/meet/_CallView.tsx +59 -122
  3. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  4. package/apps/meet/_ParticipantTile.tsx +82 -9
  5. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  6. package/apps/meet/_icons.tsx +10 -0
  7. package/apps/shared/relay/AudioPlayer.ts +29 -0
  8. package/apps/shared/relay/AudioSender.ts +23 -0
  9. package/apps/shared/relay/RelayClient.ts +52 -1
  10. package/apps/shared/relay/RelayReceiver.ts +43 -2
  11. package/apps/shared/relay/RelaySender.ts +23 -1
  12. package/apps/shared/relay/RelayTransport.ts +19 -0
  13. package/apps/shared/relay/SilenceMeter.ts +59 -0
  14. package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
  15. package/apps/shared/webrtc/types.ts +71 -2
  16. package/dist/apps/meet/_CallControls.d.ts +24 -2
  17. package/dist/apps/meet/_CallControls.js +34 -3
  18. package/dist/apps/meet/_CallView.d.ts +28 -15
  19. package/dist/apps/meet/_CallView.js +50 -64
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  21. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  22. package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
  23. package/dist/apps/meet/_ParticipantTile.js +36 -5
  24. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  25. package/dist/apps/meet/_icons.d.ts +4 -0
  26. package/dist/apps/meet/_icons.js +4 -0
  27. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  28. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  29. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  30. package/dist/apps/shared/relay/AudioSender.js +20 -4
  31. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  32. package/dist/apps/shared/relay/RelayClient.js +44 -1
  33. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  34. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  35. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  36. package/dist/apps/shared/relay/RelaySender.js +20 -5
  37. package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
  38. package/dist/apps/shared/relay/RelayTransport.js +14 -0
  39. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  40. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  41. package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
  42. package/dist/apps/shared/webrtc/types.d.ts +66 -2
  43. package/dist/lib/util/RelayHub.js +15 -2
  44. package/dist/lib/util/RelayHub.js.map +1 -1
  45. package/dist/types/util/RelayHub.d.ts +5 -2
  46. package/package.json +1 -1
@@ -23,11 +23,16 @@ export declare const RELAY_BACKOFF_MAX_MS = 15000;
23
23
  /** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
24
24
  * upgrade but never answers the hello would otherwise hang the tier forever). */
25
25
  export declare const RELAY_HANDSHAKE_TIMEOUT_MS = 10000;
26
+ /** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
27
+ * polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
28
+ export declare const RELAY_PING_INTERVAL_MS = 2000;
26
29
  export interface RelayClientOptions {
27
30
  url: string;
28
31
  peerId: string;
29
32
  createSocket: (url: string) => RelaySocket;
30
33
  random: () => number;
34
+ /** A monotonic millisecond clock for timing pings. Defaults to `Date.now`. */
35
+ now?: () => number;
31
36
  /** Called with the sender's peer id (as stamped by the server) and the original payload. */
32
37
  onMedia: (peerId: string, payload: Uint8Array) => void;
33
38
  }
@@ -42,10 +47,15 @@ export declare class RelayClient {
42
47
  private wanted;
43
48
  private reconnectTimer;
44
49
  private handshakeTimer;
50
+ private pingTimer;
51
+ private lastRoundTripMs;
45
52
  private readonly decoder;
46
53
  constructor(options: RelayClientOptions);
47
54
  /** True once the server has answered the hello on the current socket, i.e. media may be sent. */
48
55
  get ready(): boolean;
56
+ /** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
57
+ * current socket (and for good on a server too old to answer pings). */
58
+ get roundTripMs(): number | undefined;
49
59
  /** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
50
60
  * `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
51
61
  * frame fragmenter sizes its fragments from this. */
@@ -62,6 +72,12 @@ export declare class RelayClient {
62
72
  private openSocket;
63
73
  private handleMessage;
64
74
  private handleText;
75
+ /** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
76
+ * or one from the future (nothing this client sent). */
77
+ private handlePong;
78
+ private now;
79
+ /** Stops pinging and forgets the last round trip - it described a socket that is gone. */
80
+ private stopPinging;
65
81
  private handleClose;
66
82
  /** Detaches and closes the current socket, if any, so its late events can't reach this client. */
67
83
  private detach;
@@ -13,6 +13,10 @@
13
13
  * older one, which accepts `MAX_MESSAGE_BYTES`). Nothing else may be sent before `ready`.
14
14
  * - `{"op":"want","peers":[...]}` REPLACES the set of senders this socket receives from (the server accepts at most
15
15
  * `MAX_WANTED_PEERS`). It has to be re-sent after every reconnect, because the server's set dies with the socket.
16
+ * - `{"op":"ping","t":<this client's clock>}`, every `RELAY_PING_INTERVAL_MS` once ready, is answered with
17
+ * `{"op":"pong","t":<the same>}`, which times the round trip to the server (`roundTripMs`) - over the same socket as
18
+ * the media, so it includes whatever the media has queued ahead of it. An older server ignores the ping and the
19
+ * round trip stays unknown.
16
20
  * - A binary message from the client is an opaque payload of at most `maxMessageBytes`. The server forwards it to
17
21
  * every socket that wants this sender as `[1 byte N][N bytes UTF-8 sender peer id][payload]`, stamping the sender
18
22
  * itself so a peer cannot speak as another.
@@ -46,6 +50,9 @@ export const RELAY_BACKOFF_MAX_MS = 15000;
46
50
  /** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
47
51
  * upgrade but never answers the hello would otherwise hang the tier forever). */
48
52
  export const RELAY_HANDSHAKE_TIMEOUT_MS = 10000;
53
+ /** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
54
+ * polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
55
+ export const RELAY_PING_INTERVAL_MS = 2000;
49
56
  const SOCKET_OPEN = 1;
50
57
  /** Views `data` as bytes when it is binary (an `ArrayBuffer` or a typed array), else `undefined`. Uses
51
58
  * `Object.prototype.toString` rather than `instanceof` so it works for a buffer from another realm too. */
@@ -73,6 +80,11 @@ export class RelayClient {
73
80
  get ready() {
74
81
  return this.isReady;
75
82
  }
83
+ /** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
84
+ * current socket (and for good on a server too old to answer pings). */
85
+ get roundTripMs() {
86
+ return this.lastRoundTripMs;
87
+ }
76
88
  /** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
77
89
  * `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
78
90
  * frame fragmenter sizes its fragments from this. */
@@ -125,6 +137,7 @@ export class RelayClient {
125
137
  clearTimeout(this.reconnectTimer);
126
138
  clearTimeout(this.handshakeTimer);
127
139
  this.reconnectTimer = this.handshakeTimer = undefined;
140
+ this.stopPinging();
128
141
  this.detach();
129
142
  }
130
143
  openSocket() {
@@ -176,7 +189,15 @@ export class RelayClient {
176
189
  catch {
177
190
  return;
178
191
  }
179
- if (!message || typeof message !== "object" || message.op !== "ready" || this.isReady) {
192
+ if (!message || typeof message !== "object") {
193
+ return;
194
+ }
195
+ const op = message.op;
196
+ if (op === "pong") {
197
+ this.handlePong(message.t);
198
+ return;
199
+ }
200
+ if (op !== "ready" || this.isReady) {
180
201
  return;
181
202
  }
182
203
  this.isReady = true;
@@ -189,12 +210,34 @@ export class RelayClient {
189
210
  this.handshakeTimer = undefined;
190
211
  // The server's wanted set is per socket, so it starts empty on every (re)connect.
191
212
  this.sendText({ op: "want", peers: this.wanted });
213
+ this.pingTimer = setInterval(() => this.sendText({ op: "ping", t: this.now() }), RELAY_PING_INTERVAL_MS);
214
+ }
215
+ /** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
216
+ * or one from the future (nothing this client sent). */
217
+ handlePong(t) {
218
+ if (!this.isReady || typeof t !== "number") {
219
+ return;
220
+ }
221
+ const roundTrip = this.now() - t;
222
+ if (roundTrip >= 0) {
223
+ this.lastRoundTripMs = roundTrip;
224
+ }
225
+ }
226
+ now() {
227
+ return (this.options.now ?? Date.now)();
228
+ }
229
+ /** Stops pinging and forgets the last round trip - it described a socket that is gone. */
230
+ stopPinging() {
231
+ clearInterval(this.pingTimer);
232
+ this.pingTimer = undefined;
233
+ this.lastRoundTripMs = undefined;
192
234
  }
193
235
  handleClose(socket) {
194
236
  if (socket !== this.socket) {
195
237
  return;
196
238
  }
197
239
  this.isReady = false;
240
+ this.stopPinging();
198
241
  // The next server might be a different one (a rolling upgrade), so the limit is learned afresh.
199
242
  this.messageLimit = MAX_MESSAGE_BYTES;
200
243
  clearTimeout(this.handshakeTimer);
@@ -1,3 +1,4 @@
1
+ import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
1
2
  import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
2
3
  /**
3
4
  * One remote participant as seen through the relay: reassembles their fragments, decodes them, and exposes the result
@@ -11,11 +12,15 @@ export declare class RelayReceiver {
11
12
  readonly stream: MediaStream;
12
13
  private readonly audioFrames;
13
14
  private readonly videoFrames;
15
+ private readonly audioCounter;
16
+ private readonly videoCounter;
14
17
  private readonly audio;
15
18
  private readonly video;
16
19
  constructor(env: RelayEnv, playbackContext: AudioContextLike);
17
20
  /** Handles one relayed payload from this sender (a media fragment). Anything malformed is ignored. */
18
21
  handleMedia(payload: Uint8Array): void;
22
+ /** A snapshot of what has been received from this peer - see `RelayReceiveDiagnostics`. */
23
+ diagnostics(): RelayReceiveDiagnostics;
19
24
  /** Releases the decoders and ends the stream's tracks. */
20
25
  close(): void;
21
26
  }
@@ -1,10 +1,29 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
1
  import { AudioPlayer } from "./AudioPlayer.js";
6
- import { decodeFragment, FrameReassembler, KIND_AUDIO } from "./frames.js";
2
+ import { decodeFragment, FrameReassembler, KIND_AUDIO, seqIsNewer } from "./frames.js";
7
3
  import { VideoPlayer } from "./VideoPlayer.js";
4
+ const SEQ_MODULUS = 0x10000;
5
+ /** A jump in sequence numbers bigger than this is taken for the sender restarting its counter, not for that many
6
+ * lost frames. Ten seconds of 20 ms audio packets - far more than any real outage the stream would survive. */
7
+ const MAX_COUNTED_SEQ_GAP = 500;
8
+ /** Counts one kind's frames as they complete, and the ones the sequence numbers say never did. */
9
+ class ReceiveCounter {
10
+ constructor() {
11
+ this.stats = { framesReceived: 0, framesLost: 0, bytesReceived: 0 };
12
+ }
13
+ fragment(bytes) {
14
+ this.stats.bytesReceived += bytes;
15
+ }
16
+ frame(frame) {
17
+ this.stats.framesReceived += 1;
18
+ if (this.lastSeq !== undefined && seqIsNewer(frame.seq, this.lastSeq)) {
19
+ const missing = ((frame.seq - this.lastSeq + SEQ_MODULUS) % SEQ_MODULUS) - 1;
20
+ if (missing <= MAX_COUNTED_SEQ_GAP) {
21
+ this.stats.framesLost += missing;
22
+ }
23
+ }
24
+ this.lastSeq = frame.seq;
25
+ }
26
+ }
8
27
  /** The canvas is sized to the first decoded picture; until then it is this size. */
9
28
  const INITIAL_CANVAS_WIDTH = 320;
10
29
  const INITIAL_CANVAS_HEIGHT = 240;
@@ -22,6 +41,8 @@ export class RelayReceiver {
22
41
  constructor(env, playbackContext) {
23
42
  this.audioFrames = new FrameReassembler();
24
43
  this.videoFrames = new FrameReassembler();
44
+ this.audioCounter = new ReceiveCounter();
45
+ this.videoCounter = new ReceiveCounter();
25
46
  const destination = playbackContext.createMediaStreamDestination();
26
47
  this.audio = new AudioPlayer(env, playbackContext, destination);
27
48
  const canvas = env.createCanvas();
@@ -37,10 +58,14 @@ export class RelayReceiver {
37
58
  if (!fragment) {
38
59
  return;
39
60
  }
40
- const frame = (fragment.kind === KIND_AUDIO ? this.audioFrames : this.videoFrames).push(fragment);
61
+ const isAudio = fragment.kind === KIND_AUDIO;
62
+ const counter = isAudio ? this.audioCounter : this.videoCounter;
63
+ counter.fragment(payload.length);
64
+ const frame = (isAudio ? this.audioFrames : this.videoFrames).push(fragment);
41
65
  if (!frame) {
42
66
  return;
43
67
  }
68
+ counter.frame(frame);
44
69
  if (frame.kind === KIND_AUDIO) {
45
70
  this.audio.push(frame);
46
71
  }
@@ -48,6 +73,13 @@ export class RelayReceiver {
48
73
  this.video?.push(frame);
49
74
  }
50
75
  }
76
+ /** A snapshot of what has been received from this peer - see `RelayReceiveDiagnostics`. */
77
+ diagnostics() {
78
+ return {
79
+ audio: { ...this.audioCounter.stats, ...this.audio.stats() },
80
+ video: { ...this.videoCounter.stats },
81
+ };
82
+ }
51
83
  /** Releases the decoders and ends the stream's tracks. */
52
84
  close() {
53
85
  this.audio.close();
@@ -1,3 +1,5 @@
1
+ import type { RelaySendDiagnostics, RelaySendStats } from "../webrtc/types.js";
2
+ import { type FrameKind } from "./frames.js";
1
3
  import type { RelayEnv } from "./relayEnv.js";
2
4
  import type { PublishFrame } from "./senderCommon.js";
3
5
  /** Numbers and fragments the frames of one sender. The counters belong to the transport rather than to a capture
@@ -7,6 +9,8 @@ export declare class FramePublisher {
7
9
  private readonly send;
8
10
  private readonly maxMessageBytes;
9
11
  private readonly seqs;
12
+ /** What happened to each kind's frames - for the diagnostics panel. */
13
+ readonly stats: Record<FrameKind, RelaySendStats>;
10
14
  /** `send` returns whether one message was accepted by the socket. */
11
15
  /** `maxMessageBytes` is the largest message the socket may send right now (it changes when the server's `ready`
12
16
  * announces a limit), so it is read for every frame rather than fixed at construction. */
@@ -24,9 +28,12 @@ export interface RelaySenderOptions {
24
28
  }
25
29
  /** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
26
30
  export declare class RelaySender {
31
+ private readonly publisher;
27
32
  private readonly audio;
28
33
  private readonly video;
29
34
  constructor(options: RelaySenderOptions);
35
+ /** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
36
+ diagnostics(): RelaySendDiagnostics;
30
37
  /** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
31
38
  setActive(active: boolean): void;
32
39
  /** `null` sends nothing for that kind. Safe before or after `setActive`. */
@@ -1,7 +1,3 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
1
  import { AudioSender } from "./AudioSender.js";
6
2
  import { DEFAULT_MESSAGE_BYTES, fragmentFrame, KIND_AUDIO, KIND_VIDEO } from "./frames.js";
7
3
  import { VideoSender } from "./VideoSender.js";
@@ -17,20 +13,30 @@ export class FramePublisher {
17
13
  this.send = send;
18
14
  this.maxMessageBytes = maxMessageBytes;
19
15
  this.seqs = { [KIND_AUDIO]: 0, [KIND_VIDEO]: 0 };
16
+ /** What happened to each kind's frames - for the diagnostics panel. */
17
+ this.stats = {
18
+ [KIND_AUDIO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
19
+ [KIND_VIDEO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
20
+ };
20
21
  /** Fragments and sends one frame, stopping at the first fragment the socket refuses (the rest could not be
21
22
  * used without it). Returns whether the whole frame went out. */
22
23
  this.publish = (kind, keyFrame, data, timestampMs) => {
23
24
  const seq = this.seqs[kind];
24
25
  this.seqs[kind] = (seq + 1) % SEQ_MODULUS;
26
+ const stats = this.stats[kind];
25
27
  const fragments = fragmentFrame(kind, keyFrame, seq, timestampMs, data, this.maxMessageBytes());
26
28
  if (fragments.length === 0) {
29
+ stats.framesDropped += 1;
27
30
  return false;
28
31
  }
29
32
  for (const fragment of fragments) {
30
33
  if (!this.send(fragment)) {
34
+ stats.framesDropped += 1;
31
35
  return false;
32
36
  }
37
+ stats.bytesSent += fragment.length;
33
38
  }
39
+ stats.framesSent += 1;
34
40
  return true;
35
41
  };
36
42
  }
@@ -38,10 +44,19 @@ export class FramePublisher {
38
44
  /** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
39
45
  export class RelaySender {
40
46
  constructor(options) {
41
- const deps = { env: options.env, publish: new FramePublisher(options.send, options.maxMessageBytes).publish, canSend: options.canSend };
47
+ this.publisher = new FramePublisher(options.send, options.maxMessageBytes);
48
+ const deps = { env: options.env, publish: this.publisher.publish, canSend: options.canSend };
42
49
  this.audio = new AudioSender(deps);
43
50
  this.video = new VideoSender(deps);
44
51
  }
52
+ /** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
53
+ diagnostics() {
54
+ return {
55
+ ...this.audio.captureStats(),
56
+ audio: { ...this.publisher.stats[KIND_AUDIO] },
57
+ video: { ...this.publisher.stats[KIND_VIDEO] },
58
+ };
59
+ }
45
60
  /** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
46
61
  setActive(active) {
47
62
  this.audio.setActive(active);
@@ -1,3 +1,4 @@
1
+ import type { RelayDiagnostics } from "../webrtc/types.js";
1
2
  import { type RelayEnv } from "./relayEnv.js";
2
3
  export interface RelayTransport {
3
4
  /** false when this browser lacks WebSocket, AudioEncoder/AudioDecoder/VideoEncoder/VideoDecoder, VideoFrame,
@@ -14,6 +15,9 @@ export interface RelayTransport {
14
15
  /** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
15
16
  * before or after `setSending`. */
16
17
  setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
18
+ /** The sending counters (shared by every peer) plus what has been received from `peerId`, for the diagnostics
19
+ * panel - cumulative, so a caller wanting a rate keeps the previous sample. */
20
+ diagnostics(peerId: string): RelayDiagnostics;
17
21
  /** Closes the socket, all coders, streams and timers. Idempotent. */
18
22
  close(): void;
19
23
  }
@@ -37,6 +37,16 @@ const UNSUPPORTED_TRANSPORT = {
37
37
  stopReceivingFrom: () => undefined,
38
38
  setSending: () => undefined,
39
39
  setLocalTrack: () => undefined,
40
+ diagnostics: () => ({
41
+ send: {
42
+ audioCapturedMs: 0,
43
+ audioCaptureWallMs: 0,
44
+ audioSilentMs: 0,
45
+ audioEncoderSkippedMs: 0,
46
+ audio: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
47
+ video: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
48
+ },
49
+ }),
40
50
  close: () => undefined,
41
51
  };
42
52
  class WebSocketRelayTransport {
@@ -50,6 +60,7 @@ class WebSocketRelayTransport {
50
60
  peerId,
51
61
  createSocket: (target) => env.createSocket(target),
52
62
  random: () => env.random(),
63
+ now: () => env.now(),
53
64
  onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
54
65
  });
55
66
  this.sender = new RelaySender({
@@ -95,6 +106,9 @@ class WebSocketRelayTransport {
95
106
  this.sender.setTrack(kind, track);
96
107
  }
97
108
  }
109
+ diagnostics(peerId) {
110
+ return { roundTripMs: this.client.roundTripMs, send: this.sender.diagnostics(), receive: this.receivers.get(peerId)?.diagnostics() };
111
+ }
98
112
  close() {
99
113
  if (this.closed) {
100
114
  return;
@@ -0,0 +1,25 @@
1
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
2
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
3
+ export declare const SILENCE_THRESHOLD = 0.0001;
4
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
5
+ export declare const MIN_SILENT_RUN_SECONDS = 0.005;
6
+ /**
7
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
8
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
9
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
10
+ * full.
11
+ */
12
+ export declare class SilenceMeter {
13
+ private totalSeconds;
14
+ private silentSeconds;
15
+ private run;
16
+ private runRate;
17
+ /** Adds one block of `sampleRate` Hz samples. */
18
+ feed(samples: Float32Array, sampleRate: number): void;
19
+ /** Milliseconds of audio fed so far. */
20
+ get totalMs(): number;
21
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
22
+ get silentMs(): number;
23
+ private runSeconds;
24
+ private endRun;
25
+ }
@@ -0,0 +1,55 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
6
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
7
+ export const SILENCE_THRESHOLD = 1e-4;
8
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
9
+ export const MIN_SILENT_RUN_SECONDS = 0.005;
10
+ /**
11
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
12
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
13
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
14
+ * full.
15
+ */
16
+ export class SilenceMeter {
17
+ constructor() {
18
+ this.totalSeconds = 0;
19
+ this.silentSeconds = 0;
20
+ this.run = 0;
21
+ this.runRate = 0;
22
+ }
23
+ /** Adds one block of `sampleRate` Hz samples. */
24
+ feed(samples, sampleRate) {
25
+ if (sampleRate !== this.runRate) {
26
+ this.endRun();
27
+ this.runRate = sampleRate;
28
+ }
29
+ for (const sample of samples) {
30
+ if (Math.abs(sample) < SILENCE_THRESHOLD) {
31
+ this.run += 1;
32
+ }
33
+ else if (this.run > 0) {
34
+ this.endRun();
35
+ }
36
+ }
37
+ this.totalSeconds += samples.length / sampleRate;
38
+ }
39
+ /** Milliseconds of audio fed so far. */
40
+ get totalMs() {
41
+ return this.totalSeconds * 1000;
42
+ }
43
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
44
+ get silentMs() {
45
+ return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
46
+ }
47
+ runSeconds(run) {
48
+ const seconds = this.runRate > 0 ? run / this.runRate : 0;
49
+ return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
50
+ }
51
+ endRun() {
52
+ this.silentSeconds += this.runSeconds(this.run);
53
+ this.run = 0;
54
+ }
55
+ }
@@ -558,7 +558,11 @@ export class MeshConnectionManager {
558
558
  async pollDiagnostics(peer) {
559
559
  let diagnostics;
560
560
  try {
561
- diagnostics = await peer.pc.collectDiagnostics();
561
+ const relay = this.options.relay;
562
+ diagnostics =
563
+ peer.transport === "websocket" && relay?.diagnostics
564
+ ? fromRelayDiagnostics(relay.diagnostics(peer.uid))
565
+ : await peer.pc.collectDiagnostics();
562
566
  }
563
567
  catch {
564
568
  // Leaves whatever was last polled in place rather than blanking a momentary failure.
@@ -636,6 +640,9 @@ export class MeshConnectionManager {
636
640
  });
637
641
  this.syncRelaySending();
638
642
  this.emit({ type: "participant-updated", participant: toParticipant(peer) });
643
+ if (relay.diagnostics) {
644
+ this.startDiagnosticsPolling(peer);
645
+ }
639
646
  if (notify) {
640
647
  this.send({ kind: "relay-fallback", to: peer.uid });
641
648
  }
@@ -806,6 +813,18 @@ function toParticipant(peer) {
806
813
  diagnostics: peer.diagnostics,
807
814
  };
808
815
  }
816
+ /** A relayed pair's diagnostics: the relay's own counters, with the part that maps onto RTP stats (round trip, frames
817
+ * lost, bytes each way) also filled into the ordinary fields, so the panel's ordinary rows show it too. The round trip
818
+ * is to the relay server, not to the peer - see `RelayDiagnostics.roundTripMs`. */
819
+ function fromRelayDiagnostics(relay) {
820
+ const { send, receive } = relay;
821
+ return {
822
+ roundTripTimeSeconds: relay.roundTripMs === undefined ? undefined : relay.roundTripMs / 1000,
823
+ audio: { packetsLost: receive?.audio.framesLost, bytesSent: send.audio.bytesSent, bytesReceived: receive?.audio.bytesReceived },
824
+ video: { packetsLost: receive?.video.framesLost, bytesSent: send.video.bytesSent, bytesReceived: receive?.video.bytesReceived },
825
+ relay,
826
+ };
827
+ }
809
828
  function sameState(a, b) {
810
829
  return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
811
830
  }
@@ -114,6 +114,66 @@ export interface ConnectionDiagnostics {
114
114
  roundTripTimeSeconds?: number;
115
115
  audio: RtpStreamDiagnostics;
116
116
  video: RtpStreamDiagnostics;
117
+ /** The WebSocket relay's own counters, present only for a `"websocket"` pair - `audio`/`video` then carry the
118
+ * subset of them that maps onto RTP stats (frames lost, bytes each way). */
119
+ relay?: RelayDiagnostics;
120
+ }
121
+ /** What the relay's sending half did with one kind of frame, cumulative since the relay transport was created. */
122
+ export interface RelaySendStats {
123
+ framesSent: number;
124
+ /** Frames encoded but not sent: the socket was not ready, backed up, or refused a fragment. */
125
+ framesDropped: number;
126
+ bytesSent: number;
127
+ }
128
+ /** What the relay's sending half has done, cumulative since the relay transport was created. It is per browser, not
129
+ * per peer: every relayed peer receives the same stream. */
130
+ export interface RelaySendDiagnostics {
131
+ /** Milliseconds of microphone audio the capture path delivered, against `audioCaptureWallMs` - the wall-clock time
132
+ * it was capturing for. Well short of it means capture is losing whole blocks. */
133
+ audioCapturedMs: number;
134
+ audioCaptureWallMs: number;
135
+ /** How much of `audioCapturedMs` was digital silence (see `SilenceMeter`) - a live microphone always has some
136
+ * noise, so a large share means the capture path is handing the encoder zero-filled blocks. */
137
+ audioSilentMs: number;
138
+ /** Milliseconds of captured audio not encoded because the encoder was too far behind. */
139
+ audioEncoderSkippedMs: number;
140
+ audio: RelaySendStats;
141
+ video: RelaySendStats;
142
+ }
143
+ /** What the relay's receiving half got of one kind of frame from one peer, cumulative. */
144
+ export interface RelayReceiveStats {
145
+ framesReceived: number;
146
+ /** Frames the sequence numbers say never arrived complete - dropped by the sender, the server or in transit. */
147
+ framesLost: number;
148
+ bytesReceived: number;
149
+ }
150
+ /** What the relay's receiving half has done with one peer's media, cumulative since it started receiving from them. */
151
+ export interface RelayReceiveDiagnostics {
152
+ audio: RelayReceiveStats & {
153
+ /** Milliseconds of decoded audio scheduled for playback. */
154
+ playedMs: number;
155
+ /** How much of `playedMs` was digital silence - silence that arrived inside the packets, i.e. the sender sent
156
+ * it, as opposed to `gapMs`. */
157
+ silentMs: number;
158
+ /** Times playback ran dry and restarted, and the total silence that inserted. */
159
+ gaps: number;
160
+ gapMs: number;
161
+ /** Packets dropped on arrival because the decoder was backed up or playback was already too far ahead. */
162
+ droppedLate: number;
163
+ /** How far ahead of the clock audio is scheduled right now - the receive buffer's current depth. */
164
+ bufferedMs: number;
165
+ };
166
+ video: RelayReceiveStats;
167
+ }
168
+ /** One sample of the relay's counters for one peer. */
169
+ export interface RelayDiagnostics {
170
+ /** The latest round trip from this browser to the relay server, in milliseconds - not to the peer: the relay has
171
+ * no direct path between the two, and each end only knows its own leg. `undefined` before the first pong, or for
172
+ * a server too old to answer pings. */
173
+ roundTripMs?: number;
174
+ send: RelaySendDiagnostics;
175
+ /** `undefined` when nothing is being received from this peer. */
176
+ receive?: RelayReceiveDiagnostics;
117
177
  }
118
178
  /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
119
179
  * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
@@ -183,6 +243,9 @@ export interface RelayTransportLike {
183
243
  setSending(active: boolean): void;
184
244
  /** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
185
245
  setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
246
+ /** The relay's counters as they stand for `peerId`, for the diagnostics panel. Optional so a test's fake relay
247
+ * needn't provide it; the mesh then shows nothing for a relayed pair. */
248
+ diagnostics?(peerId: string): RelayDiagnostics;
186
249
  close(): void;
187
250
  }
188
251
  /** `iceTransportPolicy` mirrors the real `RTCConfiguration` field of the same name - `"relay"` forces ICE to use
@@ -200,8 +263,9 @@ export interface MeshParticipant extends ParticipantState {
200
263
  /** How this participant's media currently reaches the local one - see `MediaTransport`. */
201
264
  transport: MediaTransport;
202
265
  /** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
203
- * first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
204
- * a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
266
+ * first poll completes (shortly after `transport` first leaves `"connecting"`, or the pair moves to the relay).
267
+ * For a `"websocket"`-relayed participant they come from the relay's own counters (`relay`) rather than an
268
+ * `RTCPeerConnection`. */
205
269
  diagnostics?: ConnectionDiagnostics;
206
270
  }
207
271
  export type MeshEvent = {
@@ -211,6 +211,16 @@ class Connection {
211
211
  else if (message.op === "want") {
212
212
  this.want(message);
213
213
  }
214
+ else if (message.op === "ping") {
215
+ this.ping(message);
216
+ }
217
+ }
218
+ /** Echoes a `ping`'s `t` straight back as a `pong`, so the client can time the round trip on its own clock. A
219
+ * ping without a finite number `t` is ignored. */
220
+ ping(message) {
221
+ if (typeof message.t === "number" && Number.isFinite(message.t)) {
222
+ this.sendText(JSON.stringify({ op: "pong", t: message.t }));
223
+ }
214
224
  }
215
225
  hello(message) {
216
226
  if (message.v !== RELAY_PROTOCOL_VERSION) {
@@ -287,11 +297,14 @@ class Connection {
287
297
  * same room replaces the older one, which is closed with 1008 "Replaced.".
288
298
  *
289
299
  * After that, `{"op":"want","peers":["<peerId>", ...]}` REPLACES the socket's interest set with the valid entries, at
290
- * most `RELAY_MAX_WANT_PEERS` of them; every other or malformed text message is ignored. Binary messages of 1 to
300
+ * most `RELAY_MAX_WANT_PEERS` of them, and `{"op":"ping","t":<number>}` is answered at once with
301
+ * `{"op":"pong","t":<the same number>}` (the client's own clock, never interpreted here - it lets the client time its
302
+ * round trip to the server, on the same socket and so behind the same queues as its media); every other or malformed
303
+ * text message, including a ping without a finite numeric `t`, is ignored. Binary messages of 1 to
291
304
  * `RelayHubOptions.maxPayloadBytes` bytes (`RELAY_MAX_PAYLOAD_BYTES` unless the route asked for more; opaque to the hub) are forwarded to every other socket of the room whose interest
292
305
  * set contains the sender's peer id; binary before `hello`, empty and oversized messages are dropped.
293
306
  *
294
- * Server to client: `{"op":"ready","v":1,"maxMessageBytes":N}` once, then binary messages `[1 byte: UTF-8 length N of the sender's peer
307
+ * Server to client: `{"op":"ready","v":1,"maxMessageBytes":N}` once, a `pong` for each ping, then binary messages `[1 byte: UTF-8 length N of the sender's peer
295
308
  * id][N bytes: the sender's peer id][the original payload]`. The sender is stamped by the hub from the `hello`,
296
309
  * never trusted from the payload.
297
310
  *