@rapidmx/meet-plugin 0.14.1 → 0.16.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 (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -0,0 +1,268 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Draws the whiteboard's elements on a 2D canvas, in board units (`protocol.ts`'s `BOARD_WIDTH` x `BOARD_HEIGHT`)
7
+ * scaled to the canvas. Each pen has its own look:
8
+ *
9
+ * - **ballpoint** - a thin, even, opaque line.
10
+ * - **sharpie** - a bolder opaque line with round ends.
11
+ * - **marker** - a broad, square-tipped, translucent stroke laid down in `multiply`, so it highlights what is under
12
+ * it instead of covering it, and doesn't darken where it overlaps itself (the stroke is one path).
13
+ * - **crayon** - a waxy translucent base speckled with grain, like wax catching on paper.
14
+ * - **pencil** - a coloring pencil: several fine, slightly offset, faint passes that build up a grainy line.
15
+ *
16
+ * The grain of a crayon or pencil comes from a random generator seeded by the element's id, so every participant's
17
+ * board draws the very same texture for the same stroke - it is not re-randomized per screen or per redraw.
18
+ */
19
+ import { BOARD_HEIGHT, BOARD_WIDTH } from "./protocol.js";
20
+ /** The board's paper color. */
21
+ export const BOARD_BACKGROUND = "#ffffff";
22
+ /** The font stack stamps are drawn in - each platform's color emoji font first. */
23
+ export const STAMP_FONT_FAMILY = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';
24
+ /** A small, fast, seedable PRNG (mulberry32) - returns numbers in [0, 1). */
25
+ export function seededRandom(seed) {
26
+ let state = 2166136261;
27
+ for (let i = 0; i < seed.length; i++) {
28
+ state = Math.imul(state ^ seed.charCodeAt(i), 16777619);
29
+ }
30
+ return () => {
31
+ state = (state + 0x6d2b79f5) | 0;
32
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
33
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ };
36
+ }
37
+ /** Clears the canvas to paper and draws `elements` in order, `scale` canvas pixels per board unit. */
38
+ export function renderBoard(ctx, elements, scale) {
39
+ ctx.save();
40
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
41
+ ctx.globalAlpha = 1;
42
+ ctx.globalCompositeOperation = "source-over";
43
+ ctx.fillStyle = BOARD_BACKGROUND;
44
+ ctx.fillRect(0, 0, BOARD_WIDTH * scale, BOARD_HEIGHT * scale);
45
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
46
+ for (const element of elements) {
47
+ drawElement(ctx, element);
48
+ }
49
+ ctx.restore();
50
+ }
51
+ export function drawElement(ctx, element) {
52
+ ctx.save();
53
+ switch (element.kind) {
54
+ case "stroke":
55
+ drawStroke(ctx, element);
56
+ break;
57
+ case "shape":
58
+ drawShape(ctx, element);
59
+ break;
60
+ case "stamp":
61
+ drawStamp(ctx, element);
62
+ break;
63
+ }
64
+ ctx.restore();
65
+ }
66
+ /** Traces a smooth path through `points` (curves through the midpoints between them), offset by (`dx`, `dy`). */
67
+ function tracePath(ctx, points, dx = 0, dy = 0) {
68
+ ctx.beginPath();
69
+ ctx.moveTo(points[0] + dx, points[1] + dy);
70
+ if (points.length === 2) {
71
+ // A single tap still leaves a dot.
72
+ ctx.lineTo(points[0] + dx + 0.01, points[1] + dy);
73
+ return;
74
+ }
75
+ for (let i = 2; i < points.length - 2; i += 2) {
76
+ const midX = (points[i] + points[i + 2]) / 2;
77
+ const midY = (points[i + 1] + points[i + 3]) / 2;
78
+ ctx.quadraticCurveTo(points[i] + dx, points[i + 1] + dy, midX + dx, midY + dy);
79
+ }
80
+ ctx.lineTo(points[points.length - 2] + dx, points[points.length - 1] + dy);
81
+ }
82
+ function drawStroke(ctx, stroke) {
83
+ const { points, size, color } = stroke;
84
+ ctx.strokeStyle = color;
85
+ ctx.fillStyle = color;
86
+ ctx.lineJoin = "round";
87
+ ctx.lineCap = "round";
88
+ switch (stroke.pen) {
89
+ case "ballpoint":
90
+ ctx.lineWidth = Math.max(1.2, size * 0.6);
91
+ tracePath(ctx, points);
92
+ ctx.stroke();
93
+ return;
94
+ case "sharpie":
95
+ ctx.lineWidth = size * 1.4;
96
+ tracePath(ctx, points);
97
+ ctx.stroke();
98
+ return;
99
+ case "marker":
100
+ ctx.globalCompositeOperation = "multiply";
101
+ ctx.globalAlpha = 0.4;
102
+ ctx.lineCap = "square";
103
+ ctx.lineWidth = size * 3;
104
+ tracePath(ctx, points);
105
+ ctx.stroke();
106
+ return;
107
+ case "crayon":
108
+ drawCrayon(ctx, stroke);
109
+ return;
110
+ case "pencil":
111
+ drawPencil(ctx, stroke);
112
+ return;
113
+ }
114
+ }
115
+ function drawCrayon(ctx, stroke) {
116
+ const random = seededRandom(stroke.id);
117
+ const width = stroke.size * 1.8;
118
+ ctx.globalAlpha = 0.55;
119
+ ctx.lineWidth = width;
120
+ tracePath(ctx, stroke.points);
121
+ ctx.stroke();
122
+ // Grain: specks scattered across the stroke's width along every segment, more for a longer segment.
123
+ ctx.globalAlpha = 0.7;
124
+ const speck = Math.max(1, width / 6);
125
+ eachSegment(stroke.points, (x0, y0, x1, y1, length) => {
126
+ const count = Math.ceil((length * width) / 18);
127
+ for (let i = 0; i < count; i++) {
128
+ const t = random();
129
+ const spread = (random() - 0.5) * width;
130
+ const along = (random() - 0.5) * speck;
131
+ ctx.fillRect(x0 + (x1 - x0) * t + spread * 0.7 + along, y0 + (y1 - y0) * t + spread * 0.7 - along, speck, speck);
132
+ }
133
+ });
134
+ }
135
+ function drawPencil(ctx, stroke) {
136
+ const random = seededRandom(stroke.id);
137
+ ctx.lineWidth = Math.max(0.8, stroke.size * 0.45);
138
+ ctx.globalAlpha = 0.35;
139
+ const passes = 3;
140
+ const jitter = Math.max(0.6, stroke.size * 0.35);
141
+ for (let pass = 0; pass < passes; pass++) {
142
+ tracePath(ctx, stroke.points, (random() - 0.5) * jitter, (random() - 0.5) * jitter);
143
+ ctx.stroke();
144
+ }
145
+ }
146
+ /** Calls `visit` with each segment of `points` and its length; a single point is a zero-length segment. */
147
+ function eachSegment(points, visit) {
148
+ if (points.length === 2) {
149
+ visit(points[0], points[1], points[0], points[1], 1);
150
+ return;
151
+ }
152
+ for (let i = 0; i < points.length - 2; i += 2) {
153
+ const [x0, y0, x1, y1] = [points[i], points[i + 1], points[i + 2], points[i + 3]];
154
+ visit(x0, y0, x1, y1, Math.hypot(x1 - x0, y1 - y0));
155
+ }
156
+ }
157
+ function drawShape(ctx, shape) {
158
+ const { x1, y1, x2, y2 } = shape;
159
+ ctx.strokeStyle = shape.color;
160
+ ctx.lineWidth = shape.size;
161
+ ctx.lineJoin = "round";
162
+ ctx.lineCap = "round";
163
+ ctx.beginPath();
164
+ const left = Math.min(x1, x2);
165
+ const top = Math.min(y1, y2);
166
+ const width = Math.abs(x2 - x1);
167
+ const height = Math.abs(y2 - y1);
168
+ switch (shape.shape) {
169
+ case "line":
170
+ ctx.moveTo(x1, y1);
171
+ ctx.lineTo(x2, y2);
172
+ break;
173
+ case "arrow": {
174
+ ctx.moveTo(x1, y1);
175
+ ctx.lineTo(x2, y2);
176
+ const angle = Math.atan2(y2 - y1, x2 - x1);
177
+ const head = Math.max(12, shape.size * 4);
178
+ for (const side of [-1, 1]) {
179
+ ctx.moveTo(x2, y2);
180
+ ctx.lineTo(x2 - head * Math.cos(angle + side * (Math.PI / 7)), y2 - head * Math.sin(angle + side * (Math.PI / 7)));
181
+ }
182
+ break;
183
+ }
184
+ case "rect":
185
+ ctx.moveTo(left, top);
186
+ ctx.lineTo(left + width, top);
187
+ ctx.lineTo(left + width, top + height);
188
+ ctx.lineTo(left, top + height);
189
+ ctx.closePath();
190
+ break;
191
+ case "ellipse":
192
+ ctx.ellipse(left + width / 2, top + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
193
+ break;
194
+ case "triangle":
195
+ ctx.moveTo(left + width / 2, top);
196
+ ctx.lineTo(left + width, top + height);
197
+ ctx.lineTo(left, top + height);
198
+ ctx.closePath();
199
+ break;
200
+ case "star":
201
+ for (const [i, [px, py]] of starPoints(left, top, width, height).entries()) {
202
+ if (i === 0) {
203
+ ctx.moveTo(px, py);
204
+ }
205
+ else {
206
+ ctx.lineTo(px, py);
207
+ }
208
+ }
209
+ ctx.closePath();
210
+ break;
211
+ }
212
+ ctx.stroke();
213
+ }
214
+ /** The ten corners of a five-pointed star filling the given box, starting at its top point. */
215
+ export function starPoints(left, top, width, height) {
216
+ const cx = left + width / 2;
217
+ const cy = top + height / 2;
218
+ const corners = [];
219
+ for (let i = 0; i < 10; i++) {
220
+ const radius = i % 2 === 0 ? 1 : 0.4;
221
+ const angle = -Math.PI / 2 + (i * Math.PI) / 5;
222
+ corners.push([cx + (Math.cos(angle) * radius * width) / 2, cy + (Math.sin(angle) * radius * height) / 2]);
223
+ }
224
+ return corners;
225
+ }
226
+ function drawStamp(ctx, stamp) {
227
+ ctx.font = `${stamp.size}px ${STAMP_FONT_FAMILY}`;
228
+ ctx.textAlign = "center";
229
+ ctx.textBaseline = "middle";
230
+ ctx.fillStyle = "#000000";
231
+ ctx.fillText(stamp.stamp, stamp.x, stamp.y);
232
+ }
233
+ function distanceToSegment(px, py, x0, y0, x1, y1) {
234
+ const dx = x1 - x0;
235
+ const dy = y1 - y0;
236
+ const lengthSquared = dx * dx + dy * dy;
237
+ const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((px - x0) * dx + (py - y0) * dy) / lengthSquared));
238
+ return Math.hypot(px - (x0 + t * dx), py - (y0 + t * dy));
239
+ }
240
+ /** Whether the eraser at (`x`, `y`) with `radius`, in board units, touches `element`. A stroke or line counts its own
241
+ * width; a closed shape is touched on its outline's bounding box, a stamp anywhere on its square. */
242
+ export function hitTest(element, x, y, radius) {
243
+ switch (element.kind) {
244
+ case "stroke": {
245
+ const reach = radius + element.size;
246
+ let hit = false;
247
+ eachSegment(element.points, (x0, y0, x1, y1) => {
248
+ hit = hit || distanceToSegment(x, y, x0, y0, x1, y1) <= reach;
249
+ });
250
+ return hit;
251
+ }
252
+ case "shape": {
253
+ const reach = radius + element.size;
254
+ if (element.shape === "line" || element.shape === "arrow") {
255
+ return distanceToSegment(x, y, element.x1, element.y1, element.x2, element.y2) <= reach;
256
+ }
257
+ const left = Math.min(element.x1, element.x2) - reach;
258
+ const right = Math.max(element.x1, element.x2) + reach;
259
+ const top = Math.min(element.y1, element.y2) - reach;
260
+ const bottom = Math.max(element.y1, element.y2) + reach;
261
+ return x >= left && x <= right && y >= top && y <= bottom;
262
+ }
263
+ case "stamp": {
264
+ const half = element.size / 2 + radius;
265
+ return Math.abs(x - element.x) <= half && Math.abs(y - element.y) <= half;
266
+ }
267
+ }
268
+ }
@@ -1,5 +1,8 @@
1
+ import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
1
2
  import { type Frame } from "./frames.js";
2
3
  import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
4
+ /** The playback half of `RelayReceiveDiagnostics.audio`. */
5
+ export type AudioPlaybackStats = Pick<RelayReceiveDiagnostics["audio"], "playedMs" | "silentMs" | "gaps" | "gapMs" | "droppedLate" | "bufferedMs">;
3
6
  export declare const PLAYBACK_SAMPLE_RATE = 48000;
4
7
  /** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
5
8
  * packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
@@ -38,8 +41,14 @@ export declare class AudioPlayer {
38
41
  private decoder;
39
42
  private nextTime;
40
43
  private readonly timestamps;
44
+ private readonly played;
45
+ private gaps;
46
+ private gapSeconds;
47
+ private droppedLate;
41
48
  /** `destination` is the node whose stream the peer's `MediaStream` carries. */
42
49
  constructor(env: RelayEnv, ctx: AudioContextLike, destination: unknown);
50
+ /** What playback has done so far - see `RelayReceiveDiagnostics`. */
51
+ stats(): AudioPlaybackStats;
43
52
  /** Feeds one reassembled audio frame (one Opus packet). */
44
53
  push(frame: Frame): void;
45
54
  close(): void;
@@ -1,9 +1,6 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
1
  import { TimestampUnwrapper } from "./frames.js";
6
2
  import { safeClose } from "./senderCommon.js";
3
+ import { SilenceMeter } from "./SilenceMeter.js";
7
4
  export const PLAYBACK_SAMPLE_RATE = 48000;
8
5
  /** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
9
6
  * packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
@@ -43,11 +40,27 @@ export class AudioPlayer {
43
40
  this.destination = destination;
44
41
  this.nextTime = 0;
45
42
  this.timestamps = new TimestampUnwrapper();
43
+ this.played = new SilenceMeter();
44
+ this.gaps = 0;
45
+ this.gapSeconds = 0;
46
+ this.droppedLate = 0;
47
+ }
48
+ /** What playback has done so far - see `RelayReceiveDiagnostics`. */
49
+ stats() {
50
+ return {
51
+ playedMs: this.played.totalMs,
52
+ silentMs: this.played.silentMs,
53
+ gaps: this.gaps,
54
+ gapMs: this.gapSeconds * 1000,
55
+ droppedLate: this.droppedLate,
56
+ bufferedMs: Math.max(0, this.nextTime - this.ctx.currentTime) * 1000,
57
+ };
46
58
  }
47
59
  /** Feeds one reassembled audio frame (one Opus packet). */
48
60
  push(frame) {
49
61
  const decoder = this.decoder ?? this.createDecoder();
50
62
  if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
63
+ this.droppedLate += 1;
51
64
  return;
52
65
  }
53
66
  try {
@@ -91,6 +104,7 @@ export class AudioPlayer {
91
104
  try {
92
105
  const now = this.ctx.currentTime;
93
106
  if (this.nextTime - now > MAX_AHEAD_SECONDS) {
107
+ this.droppedLate += 1;
94
108
  return;
95
109
  }
96
110
  const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
@@ -118,6 +132,12 @@ export class AudioPlayer {
118
132
  source.onended = () => source.disconnect();
119
133
  }
120
134
  source.start(start);
135
+ this.played.feed(pcm, data.sampleRate);
136
+ if (start > this.nextTime && this.nextTime > 0) {
137
+ // Playback ran dry: everything between the end of the last block and this one is silence.
138
+ this.gaps += 1;
139
+ this.gapSeconds += start - this.nextTime;
140
+ }
121
141
  this.nextTime = start + buffer.duration;
122
142
  }
123
143
  catch {
@@ -1,3 +1,4 @@
1
+ import type { RelaySendDiagnostics } from "../webrtc/types.js";
1
2
  import { type SenderDeps } from "./senderCommon.js";
2
3
  export declare const AUDIO_SAMPLE_RATE = 48000;
3
4
  /** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
@@ -35,7 +36,14 @@ export declare class AudioSender {
35
36
  private encoder;
36
37
  private baseMs;
37
38
  private samples;
39
+ /** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
40
+ * silent), the wall-clock time it was attached for, and what the encoder had to skip. */
41
+ private readonly captured;
42
+ private wallMs;
43
+ private encoderSkippedSamples;
38
44
  constructor(deps: SenderDeps);
45
+ /** The capture half of `RelaySendDiagnostics`. */
46
+ captureStats(): Pick<RelaySendDiagnostics, "audioCapturedMs" | "audioCaptureWallMs" | "audioSilentMs" | "audioEncoderSkippedMs">;
39
47
  setActive(active: boolean): void;
40
48
  setTrack(track: MediaStreamTrack | null): void;
41
49
  /** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
@@ -1,10 +1,7 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
1
  import { resumeAudioContext } from "./audioResume.js";
6
2
  import { KIND_AUDIO } from "./frames.js";
7
3
  import { safeClose } from "./senderCommon.js";
4
+ import { SilenceMeter } from "./SilenceMeter.js";
8
5
  export const AUDIO_SAMPLE_RATE = 48000;
9
6
  /** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
10
7
  * thread is interrupted. */
@@ -36,6 +33,20 @@ export class AudioSender {
36
33
  this.attachedTrack = null;
37
34
  this.baseMs = 0;
38
35
  this.samples = 0;
36
+ /** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
37
+ * silent), the wall-clock time it was attached for, and what the encoder had to skip. */
38
+ this.captured = new SilenceMeter();
39
+ this.wallMs = 0;
40
+ this.encoderSkippedSamples = 0;
41
+ }
42
+ /** The capture half of `RelaySendDiagnostics`. */
43
+ captureStats() {
44
+ return {
45
+ audioCapturedMs: this.captured.totalMs,
46
+ audioCaptureWallMs: this.wallMs + (this.attachedTrack ? this.deps.env.now() - this.baseMs : 0),
47
+ audioSilentMs: this.captured.silentMs,
48
+ audioEncoderSkippedMs: (this.encoderSkippedSamples * 1000) / AUDIO_SAMPLE_RATE,
49
+ };
39
50
  }
40
51
  setActive(active) {
41
52
  this.active = active;
@@ -84,6 +95,9 @@ export class AudioSender {
84
95
  }
85
96
  }
86
97
  detach() {
98
+ if (this.attachedTrack) {
99
+ this.wallMs += this.deps.env.now() - this.baseMs;
100
+ }
87
101
  this.attachedTrack = null;
88
102
  this.stopResume?.();
89
103
  this.stopResume = undefined;
@@ -109,11 +123,13 @@ export class AudioSender {
109
123
  const samples = input.length;
110
124
  const timestampUs = Math.round((this.samples * 1000000) / AUDIO_SAMPLE_RATE);
111
125
  this.samples += samples;
126
+ this.captured.feed(input, AUDIO_SAMPLE_RATE);
112
127
  if (!this.deps.canSend()) {
113
128
  return;
114
129
  }
115
130
  const encoder = this.encoder ?? this.createEncoder();
116
131
  if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
132
+ this.encoderSkippedSamples += samples;
117
133
  return;
118
134
  }
119
135
  let audio;
@@ -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,13 +13,18 @@
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.
19
23
  * - Binary messages may be dropped under backpressure and the socket may close at any moment, so this client
20
24
  * reconnects with exponential backoff and treats every media send as best effort.
21
25
  *
22
- * Authentication is the `jwt` cookie already in place (see `GuestSignalingClient`), so this client sets no cookie.
26
+ * Authentication is the `jwt` cookie already in place (see `../signaling/SignalingClient.ts`), so this client sets no
27
+ * cookie.
23
28
  */
24
29
  import { DEFAULT_MESSAGE_BYTES } from "./frames.js";
25
30
  /** The largest binary message an older server accepts - it closes the socket on a larger one (uWS's default limit).
@@ -46,6 +51,9 @@ export const RELAY_BACKOFF_MAX_MS = 15000;
46
51
  /** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
47
52
  * upgrade but never answers the hello would otherwise hang the tier forever). */
48
53
  export const RELAY_HANDSHAKE_TIMEOUT_MS = 10000;
54
+ /** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
55
+ * polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
56
+ export const RELAY_PING_INTERVAL_MS = 2000;
49
57
  const SOCKET_OPEN = 1;
50
58
  /** Views `data` as bytes when it is binary (an `ArrayBuffer` or a typed array), else `undefined`. Uses
51
59
  * `Object.prototype.toString` rather than `instanceof` so it works for a buffer from another realm too. */
@@ -73,6 +81,11 @@ export class RelayClient {
73
81
  get ready() {
74
82
  return this.isReady;
75
83
  }
84
+ /** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
85
+ * current socket (and for good on a server too old to answer pings). */
86
+ get roundTripMs() {
87
+ return this.lastRoundTripMs;
88
+ }
76
89
  /** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
77
90
  * `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
78
91
  * frame fragmenter sizes its fragments from this. */
@@ -125,6 +138,7 @@ export class RelayClient {
125
138
  clearTimeout(this.reconnectTimer);
126
139
  clearTimeout(this.handshakeTimer);
127
140
  this.reconnectTimer = this.handshakeTimer = undefined;
141
+ this.stopPinging();
128
142
  this.detach();
129
143
  }
130
144
  openSocket() {
@@ -176,7 +190,15 @@ export class RelayClient {
176
190
  catch {
177
191
  return;
178
192
  }
179
- if (!message || typeof message !== "object" || message.op !== "ready" || this.isReady) {
193
+ if (!message || typeof message !== "object") {
194
+ return;
195
+ }
196
+ const op = message.op;
197
+ if (op === "pong") {
198
+ this.handlePong(message.t);
199
+ return;
200
+ }
201
+ if (op !== "ready" || this.isReady) {
180
202
  return;
181
203
  }
182
204
  this.isReady = true;
@@ -189,12 +211,34 @@ export class RelayClient {
189
211
  this.handshakeTimer = undefined;
190
212
  // The server's wanted set is per socket, so it starts empty on every (re)connect.
191
213
  this.sendText({ op: "want", peers: this.wanted });
214
+ this.pingTimer = setInterval(() => this.sendText({ op: "ping", t: this.now() }), RELAY_PING_INTERVAL_MS);
215
+ }
216
+ /** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
217
+ * or one from the future (nothing this client sent). */
218
+ handlePong(t) {
219
+ if (!this.isReady || typeof t !== "number") {
220
+ return;
221
+ }
222
+ const roundTrip = this.now() - t;
223
+ if (roundTrip >= 0) {
224
+ this.lastRoundTripMs = roundTrip;
225
+ }
226
+ }
227
+ now() {
228
+ return (this.options.now ?? Date.now)();
229
+ }
230
+ /** Stops pinging and forgets the last round trip - it described a socket that is gone. */
231
+ stopPinging() {
232
+ clearInterval(this.pingTimer);
233
+ this.pingTimer = undefined;
234
+ this.lastRoundTripMs = undefined;
192
235
  }
193
236
  handleClose(socket) {
194
237
  if (socket !== this.socket) {
195
238
  return;
196
239
  }
197
240
  this.isReady = false;
241
+ this.stopPinging();
198
242
  // The next server might be a different one (a rolling upgrade), so the limit is learned afresh.
199
243
  this.messageLimit = MAX_MESSAGE_BYTES;
200
244
  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();