@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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -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 `
|
|
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"
|
|
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
|
|
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();
|