@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
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
/**
|
|
6
|
-
* The two machine-learning models behind the filters, run in the browser with MediaPipe Tasks:
|
|
7
|
-
*
|
|
8
|
-
* - **Person segmentation** (`createPersonSegmenter`) - which pixels are the participant: what the background
|
|
9
|
-
* blur and the custom background need.
|
|
10
|
-
* - **Face landmarks** (`createFaceTracker`) - where the participant's face is: what the accessories need.
|
|
11
|
-
*
|
|
12
|
-
* Nothing here is loaded until a participant turns a filter on that needs it, and each model is loaded once and
|
|
13
|
-
* shared. The MediaPipe library itself is `import()`ed on demand for the same reason - a participant who never uses a
|
|
14
|
-
* filter never downloads it.
|
|
15
|
-
*
|
|
16
|
-
* **Where the assets come from**: by default the WebAssembly runtime is fetched from jsDelivr and the models from
|
|
17
|
-
* Google's public MediaPipe model bucket. That is a request to a third party, so an administrator can host them
|
|
18
|
-
* instead (the `mail:videoconf:effects:assets_url` setting, delivered by the join response): the runtime's `wasm/`
|
|
19
|
-
* directory from the `@mediapipe/tasks-vision` package, plus `selfie_segmenter.tflite` and `face_landmarker.task`,
|
|
20
|
-
* all under one base URL.
|
|
21
|
-
*
|
|
22
|
-
* Everything the rest of the pipeline needs from MediaPipe is behind the small `PersonSegmenter`/`FaceTracker`
|
|
23
|
-
* interfaces, so `VideoFilterProcessor` never touches the library and its tests use fakes.
|
|
24
|
-
*/
|
|
25
|
-
import type { FaceLandmark } from "./accessories.js";
|
|
26
|
-
|
|
27
|
-
/** Must match the installed `@mediapipe/tasks-vision` (a test checks): the `.wasm` fetched at runtime has to be the
|
|
28
|
-
* version the JavaScript was built against. */
|
|
29
|
-
export const MEDIAPIPE_VERSION = "1.0.1";
|
|
30
|
-
|
|
31
|
-
const CDN_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`;
|
|
32
|
-
const CDN_SEGMENTER_MODEL =
|
|
33
|
-
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
|
|
34
|
-
const CDN_FACE_MODEL =
|
|
35
|
-
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
|
36
|
-
|
|
37
|
-
export interface ModelUrls {
|
|
38
|
-
wasm: string;
|
|
39
|
-
segmenter: string;
|
|
40
|
-
face: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Where to fetch the runtime and models from - the CDN when `assetsUrl` is empty, else under `assetsUrl`. */
|
|
44
|
-
export function modelUrls(assetsUrl?: string): ModelUrls {
|
|
45
|
-
if (!assetsUrl) {
|
|
46
|
-
return { wasm: CDN_WASM, segmenter: CDN_SEGMENTER_MODEL, face: CDN_FACE_MODEL };
|
|
47
|
-
}
|
|
48
|
-
const base = assetsUrl.replace(/\/+$/, "");
|
|
49
|
-
return { wasm: `${base}/wasm`, segmenter: `${base}/selfie_segmenter.tflite`, face: `${base}/face_landmarker.task` };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** How likely each pixel of a frame is to be the participant, 0-1. */
|
|
53
|
-
export interface PersonMask {
|
|
54
|
-
width: number;
|
|
55
|
-
height: number;
|
|
56
|
-
confidence: Float32Array;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface PersonSegmenter {
|
|
60
|
-
/** Segments `frame`, or `null` when the model produced nothing for it. */
|
|
61
|
-
segment(frame: HTMLVideoElement, timestampMs: number): PersonMask | null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface FaceTracker {
|
|
65
|
-
/** The landmarks of the first face in `frame`, or `null` when there is none. */
|
|
66
|
-
detect(frame: HTMLVideoElement, timestampMs: number): FaceLandmark[] | null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Which of a segmentation model's masks is the person: the one labelled as such (the selfie model's only label is
|
|
70
|
-
* "selfie"), else the last (models list the background first). */
|
|
71
|
-
export function personMaskIndex(labels: string[], maskCount: number): number {
|
|
72
|
-
const index = labels.findIndex((label) => /person|selfie|foreground/i.test(label));
|
|
73
|
-
return index >= 0 && index < maskCount ? index : maskCount - 1;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** MediaPipe rejects a frame whose timestamp isn't later than the last one it saw. */
|
|
77
|
-
function monotonic(): (timestampMs: number) => number {
|
|
78
|
-
let last = -1;
|
|
79
|
-
return (timestampMs) => {
|
|
80
|
-
last = Math.max(timestampMs, last + 1);
|
|
81
|
-
return last;
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
type Vision = typeof import("@mediapipe/tasks-vision");
|
|
86
|
-
|
|
87
|
-
/** Creates a task on the GPU, or on the CPU when the GPU can't run it (no WebGL, or a driver that refuses). */
|
|
88
|
-
async function withFallback<T>(create: (delegate: "GPU" | "CPU") => Promise<T>): Promise<T> {
|
|
89
|
-
try {
|
|
90
|
-
return await create("GPU");
|
|
91
|
-
} catch {
|
|
92
|
-
return create("CPU");
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function loadSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
|
|
97
|
-
const urls = modelUrls(assetsUrl);
|
|
98
|
-
const vision: Vision = await import("@mediapipe/tasks-vision");
|
|
99
|
-
const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
|
|
100
|
-
const segmenter = await withFallback((delegate) =>
|
|
101
|
-
vision.ImageSegmenter.createFromOptions(fileset, {
|
|
102
|
-
baseOptions: { modelAssetPath: urls.segmenter, delegate },
|
|
103
|
-
runningMode: "VIDEO",
|
|
104
|
-
outputConfidenceMasks: true,
|
|
105
|
-
outputCategoryMask: false,
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
const labels = segmenter.getLabels();
|
|
109
|
-
const stamp = monotonic();
|
|
110
|
-
return {
|
|
111
|
-
segment(frame, timestampMs) {
|
|
112
|
-
let mask: PersonMask | null = null;
|
|
113
|
-
segmenter.segmentForVideo(frame, stamp(timestampMs), (result) => {
|
|
114
|
-
const masks = result.confidenceMasks;
|
|
115
|
-
if (!masks?.length) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const person = masks[personMaskIndex(labels, masks.length)];
|
|
119
|
-
// The mask is only valid inside this callback, so it is copied out.
|
|
120
|
-
mask = { width: person.width, height: person.height, confidence: new Float32Array(person.getAsFloat32Array()) };
|
|
121
|
-
});
|
|
122
|
-
return mask;
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function loadFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
|
|
128
|
-
const urls = modelUrls(assetsUrl);
|
|
129
|
-
const vision: Vision = await import("@mediapipe/tasks-vision");
|
|
130
|
-
const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
|
|
131
|
-
const landmarker = await withFallback((delegate) =>
|
|
132
|
-
vision.FaceLandmarker.createFromOptions(fileset, {
|
|
133
|
-
baseOptions: { modelAssetPath: urls.face, delegate },
|
|
134
|
-
runningMode: "VIDEO",
|
|
135
|
-
numFaces: 1,
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
const stamp = monotonic();
|
|
139
|
-
return {
|
|
140
|
-
detect(frame, timestampMs) {
|
|
141
|
-
return landmarker.detectForVideo(frame, stamp(timestampMs)).faceLandmarks[0] ?? null;
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const segmenters = new Map<string, Promise<PersonSegmenter>>();
|
|
147
|
-
const faceTrackers = new Map<string, Promise<FaceTracker>>();
|
|
148
|
-
|
|
149
|
-
/** Loads `load` once per key, and forgets a failure so the next attempt (a retry) tries again. */
|
|
150
|
-
function shared<T>(cache: Map<string, Promise<T>>, key: string, load: () => Promise<T>): Promise<T> {
|
|
151
|
-
let pending = cache.get(key);
|
|
152
|
-
if (!pending) {
|
|
153
|
-
pending = load();
|
|
154
|
-
cache.set(key, pending);
|
|
155
|
-
pending.catch(() => cache.delete(key));
|
|
156
|
-
}
|
|
157
|
-
return pending;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The person-segmentation model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
|
|
161
|
-
export function createPersonSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
|
|
162
|
-
return shared(segmenters, assetsUrl ?? "", () => loadSegmenter(assetsUrl));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** The face-landmark model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
|
|
166
|
-
export function createFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
|
|
167
|
-
return shared(faceTrackers, assetsUrl ?? "", () => loadFaceTracker(assetsUrl));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Forgets every loaded model - for tests. */
|
|
171
|
-
export function resetModelCache(): void {
|
|
172
|
-
segmenters.clear();
|
|
173
|
-
faceTrackers.clear();
|
|
174
|
-
}
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The two machine-learning models behind the filters, run in the browser with MediaPipe Tasks:
|
|
7
|
+
*
|
|
8
|
+
* - **Person segmentation** (`createPersonSegmenter`) - which pixels are the participant: what the background
|
|
9
|
+
* blur and the custom background need.
|
|
10
|
+
* - **Face landmarks** (`createFaceTracker`) - where the participant's face is: what the accessories need.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here is loaded until a participant turns a filter on that needs it, and each model is loaded once and
|
|
13
|
+
* shared. The MediaPipe library itself is `import()`ed on demand for the same reason - a participant who never uses a
|
|
14
|
+
* filter never downloads it.
|
|
15
|
+
*
|
|
16
|
+
* **Where the assets come from**: by default the WebAssembly runtime is fetched from jsDelivr and the models from
|
|
17
|
+
* Google's public MediaPipe model bucket. That is a request to a third party, so an administrator can host them
|
|
18
|
+
* instead (the `mail:videoconf:effects:assets_url` setting, delivered by the join response): the runtime's `wasm/`
|
|
19
|
+
* directory from the `@mediapipe/tasks-vision` package, plus `selfie_segmenter.tflite` and `face_landmarker.task`,
|
|
20
|
+
* all under one base URL.
|
|
21
|
+
*
|
|
22
|
+
* Everything the rest of the pipeline needs from MediaPipe is behind the small `PersonSegmenter`/`FaceTracker`
|
|
23
|
+
* interfaces, so `VideoFilterProcessor` never touches the library and its tests use fakes.
|
|
24
|
+
*/
|
|
25
|
+
import type { FaceLandmark } from "./accessories.js";
|
|
26
|
+
|
|
27
|
+
/** Must match the installed `@mediapipe/tasks-vision` (a test checks): the `.wasm` fetched at runtime has to be the
|
|
28
|
+
* version the JavaScript was built against. */
|
|
29
|
+
export const MEDIAPIPE_VERSION = "1.0.1";
|
|
30
|
+
|
|
31
|
+
const CDN_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`;
|
|
32
|
+
const CDN_SEGMENTER_MODEL =
|
|
33
|
+
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
|
|
34
|
+
const CDN_FACE_MODEL =
|
|
35
|
+
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
|
36
|
+
|
|
37
|
+
export interface ModelUrls {
|
|
38
|
+
wasm: string;
|
|
39
|
+
segmenter: string;
|
|
40
|
+
face: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Where to fetch the runtime and models from - the CDN when `assetsUrl` is empty, else under `assetsUrl`. */
|
|
44
|
+
export function modelUrls(assetsUrl?: string): ModelUrls {
|
|
45
|
+
if (!assetsUrl) {
|
|
46
|
+
return { wasm: CDN_WASM, segmenter: CDN_SEGMENTER_MODEL, face: CDN_FACE_MODEL };
|
|
47
|
+
}
|
|
48
|
+
const base = assetsUrl.replace(/\/+$/, "");
|
|
49
|
+
return { wasm: `${base}/wasm`, segmenter: `${base}/selfie_segmenter.tflite`, face: `${base}/face_landmarker.task` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How likely each pixel of a frame is to be the participant, 0-1. */
|
|
53
|
+
export interface PersonMask {
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
confidence: Float32Array;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PersonSegmenter {
|
|
60
|
+
/** Segments `frame`, or `null` when the model produced nothing for it. */
|
|
61
|
+
segment(frame: HTMLVideoElement, timestampMs: number): PersonMask | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FaceTracker {
|
|
65
|
+
/** The landmarks of the first face in `frame`, or `null` when there is none. */
|
|
66
|
+
detect(frame: HTMLVideoElement, timestampMs: number): FaceLandmark[] | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Which of a segmentation model's masks is the person: the one labelled as such (the selfie model's only label is
|
|
70
|
+
* "selfie"), else the last (models list the background first). */
|
|
71
|
+
export function personMaskIndex(labels: string[], maskCount: number): number {
|
|
72
|
+
const index = labels.findIndex((label) => /person|selfie|foreground/i.test(label));
|
|
73
|
+
return index >= 0 && index < maskCount ? index : maskCount - 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** MediaPipe rejects a frame whose timestamp isn't later than the last one it saw. */
|
|
77
|
+
function monotonic(): (timestampMs: number) => number {
|
|
78
|
+
let last = -1;
|
|
79
|
+
return (timestampMs) => {
|
|
80
|
+
last = Math.max(timestampMs, last + 1);
|
|
81
|
+
return last;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Vision = typeof import("@mediapipe/tasks-vision");
|
|
86
|
+
|
|
87
|
+
/** Creates a task on the GPU, or on the CPU when the GPU can't run it (no WebGL, or a driver that refuses). */
|
|
88
|
+
async function withFallback<T>(create: (delegate: "GPU" | "CPU") => Promise<T>): Promise<T> {
|
|
89
|
+
try {
|
|
90
|
+
return await create("GPU");
|
|
91
|
+
} catch {
|
|
92
|
+
return create("CPU");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
|
|
97
|
+
const urls = modelUrls(assetsUrl);
|
|
98
|
+
const vision: Vision = await import("@mediapipe/tasks-vision");
|
|
99
|
+
const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
|
|
100
|
+
const segmenter = await withFallback((delegate) =>
|
|
101
|
+
vision.ImageSegmenter.createFromOptions(fileset, {
|
|
102
|
+
baseOptions: { modelAssetPath: urls.segmenter, delegate },
|
|
103
|
+
runningMode: "VIDEO",
|
|
104
|
+
outputConfidenceMasks: true,
|
|
105
|
+
outputCategoryMask: false,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const labels = segmenter.getLabels();
|
|
109
|
+
const stamp = monotonic();
|
|
110
|
+
return {
|
|
111
|
+
segment(frame, timestampMs) {
|
|
112
|
+
let mask: PersonMask | null = null;
|
|
113
|
+
segmenter.segmentForVideo(frame, stamp(timestampMs), (result) => {
|
|
114
|
+
const masks = result.confidenceMasks;
|
|
115
|
+
if (!masks?.length) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const person = masks[personMaskIndex(labels, masks.length)];
|
|
119
|
+
// The mask is only valid inside this callback, so it is copied out.
|
|
120
|
+
mask = { width: person.width, height: person.height, confidence: new Float32Array(person.getAsFloat32Array()) };
|
|
121
|
+
});
|
|
122
|
+
return mask;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function loadFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
|
|
128
|
+
const urls = modelUrls(assetsUrl);
|
|
129
|
+
const vision: Vision = await import("@mediapipe/tasks-vision");
|
|
130
|
+
const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
|
|
131
|
+
const landmarker = await withFallback((delegate) =>
|
|
132
|
+
vision.FaceLandmarker.createFromOptions(fileset, {
|
|
133
|
+
baseOptions: { modelAssetPath: urls.face, delegate },
|
|
134
|
+
runningMode: "VIDEO",
|
|
135
|
+
numFaces: 1,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
const stamp = monotonic();
|
|
139
|
+
return {
|
|
140
|
+
detect(frame, timestampMs) {
|
|
141
|
+
return landmarker.detectForVideo(frame, stamp(timestampMs)).faceLandmarks[0] ?? null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const segmenters = new Map<string, Promise<PersonSegmenter>>();
|
|
147
|
+
const faceTrackers = new Map<string, Promise<FaceTracker>>();
|
|
148
|
+
|
|
149
|
+
/** Loads `load` once per key, and forgets a failure so the next attempt (a retry) tries again. */
|
|
150
|
+
function shared<T>(cache: Map<string, Promise<T>>, key: string, load: () => Promise<T>): Promise<T> {
|
|
151
|
+
let pending = cache.get(key);
|
|
152
|
+
if (!pending) {
|
|
153
|
+
pending = load();
|
|
154
|
+
cache.set(key, pending);
|
|
155
|
+
pending.catch(() => cache.delete(key));
|
|
156
|
+
}
|
|
157
|
+
return pending;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The person-segmentation model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
|
|
161
|
+
export function createPersonSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
|
|
162
|
+
return shared(segmenters, assetsUrl ?? "", () => loadSegmenter(assetsUrl));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The face-landmark model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
|
|
166
|
+
export function createFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
|
|
167
|
+
return shared(faceTrackers, assetsUrl ?? "", () => loadFaceTracker(assetsUrl));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Forgets every loaded model - for tests. */
|
|
171
|
+
export function resetModelCache(): void {
|
|
172
|
+
segmenters.clear();
|
|
173
|
+
faceTrackers.clear();
|
|
174
|
+
}
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
|
|
5
6
|
import { TimestampUnwrapper, type Frame } from "./frames.js";
|
|
6
7
|
import type { AudioContextLike, AudioDataLike, DecoderLike, RelayEnv } from "./relayEnv.js";
|
|
7
8
|
import { safeClose } from "./senderCommon.js";
|
|
9
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
10
|
+
|
|
11
|
+
/** The playback half of `RelayReceiveDiagnostics.audio`. */
|
|
12
|
+
export type AudioPlaybackStats = Pick<RelayReceiveDiagnostics["audio"], "playedMs" | "silentMs" | "gaps" | "gapMs" | "droppedLate" | "bufferedMs">;
|
|
8
13
|
|
|
9
14
|
export const PLAYBACK_SAMPLE_RATE = 48_000;
|
|
10
15
|
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
@@ -42,6 +47,10 @@ export class AudioPlayer {
|
|
|
42
47
|
private decoder: DecoderLike | undefined;
|
|
43
48
|
private nextTime = 0;
|
|
44
49
|
private readonly timestamps = new TimestampUnwrapper();
|
|
50
|
+
private readonly played = new SilenceMeter();
|
|
51
|
+
private gaps = 0;
|
|
52
|
+
private gapSeconds = 0;
|
|
53
|
+
private droppedLate = 0;
|
|
45
54
|
|
|
46
55
|
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
47
56
|
constructor(
|
|
@@ -50,10 +59,23 @@ export class AudioPlayer {
|
|
|
50
59
|
private readonly destination: unknown,
|
|
51
60
|
) {}
|
|
52
61
|
|
|
62
|
+
/** What playback has done so far - see `RelayReceiveDiagnostics`. */
|
|
63
|
+
stats(): AudioPlaybackStats {
|
|
64
|
+
return {
|
|
65
|
+
playedMs: this.played.totalMs,
|
|
66
|
+
silentMs: this.played.silentMs,
|
|
67
|
+
gaps: this.gaps,
|
|
68
|
+
gapMs: this.gapSeconds * 1000,
|
|
69
|
+
droppedLate: this.droppedLate,
|
|
70
|
+
bufferedMs: Math.max(0, this.nextTime - this.ctx.currentTime) * 1000,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
54
75
|
push(frame: Frame): void {
|
|
55
76
|
const decoder = this.decoder ?? this.createDecoder();
|
|
56
77
|
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
78
|
+
this.droppedLate += 1;
|
|
57
79
|
return;
|
|
58
80
|
}
|
|
59
81
|
try {
|
|
@@ -101,6 +123,7 @@ export class AudioPlayer {
|
|
|
101
123
|
try {
|
|
102
124
|
const now = this.ctx.currentTime;
|
|
103
125
|
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
126
|
+
this.droppedLate += 1;
|
|
104
127
|
return;
|
|
105
128
|
}
|
|
106
129
|
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
@@ -127,6 +150,12 @@ export class AudioPlayer {
|
|
|
127
150
|
source.onended = () => source.disconnect();
|
|
128
151
|
}
|
|
129
152
|
source.start(start);
|
|
153
|
+
this.played.feed(pcm, data.sampleRate);
|
|
154
|
+
if (start > this.nextTime && this.nextTime > 0) {
|
|
155
|
+
// Playback ran dry: everything between the end of the last block and this one is silence.
|
|
156
|
+
this.gaps += 1;
|
|
157
|
+
this.gapSeconds += start - this.nextTime;
|
|
158
|
+
}
|
|
130
159
|
this.nextTime = start + buffer.duration;
|
|
131
160
|
} catch {
|
|
132
161
|
// A block the browser will not take (an odd sample rate, a closed context) is dropped like a lost packet.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { RelaySendDiagnostics } from "../webrtc/types.js";
|
|
5
6
|
import { resumeAudioContext } from "./audioResume.js";
|
|
6
7
|
import { KIND_AUDIO } from "./frames.js";
|
|
7
8
|
import type {
|
|
@@ -14,6 +15,7 @@ import type {
|
|
|
14
15
|
ScriptProcessorLike,
|
|
15
16
|
} from "./relayEnv.js";
|
|
16
17
|
import { safeClose, type SenderDeps } from "./senderCommon.js";
|
|
18
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
17
19
|
|
|
18
20
|
export const AUDIO_SAMPLE_RATE = 48_000;
|
|
19
21
|
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
@@ -53,8 +55,24 @@ export class AudioSender {
|
|
|
53
55
|
private baseMs = 0;
|
|
54
56
|
private samples = 0;
|
|
55
57
|
|
|
58
|
+
/** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
|
|
59
|
+
* silent), the wall-clock time it was attached for, and what the encoder had to skip. */
|
|
60
|
+
private readonly captured = new SilenceMeter();
|
|
61
|
+
private wallMs = 0;
|
|
62
|
+
private encoderSkippedSamples = 0;
|
|
63
|
+
|
|
56
64
|
constructor(private readonly deps: SenderDeps) {}
|
|
57
65
|
|
|
66
|
+
/** The capture half of `RelaySendDiagnostics`. */
|
|
67
|
+
captureStats(): Pick<RelaySendDiagnostics, "audioCapturedMs" | "audioCaptureWallMs" | "audioSilentMs" | "audioEncoderSkippedMs"> {
|
|
68
|
+
return {
|
|
69
|
+
audioCapturedMs: this.captured.totalMs,
|
|
70
|
+
audioCaptureWallMs: this.wallMs + (this.attachedTrack ? this.deps.env.now() - this.baseMs : 0),
|
|
71
|
+
audioSilentMs: this.captured.silentMs,
|
|
72
|
+
audioEncoderSkippedMs: (this.encoderSkippedSamples * 1000) / AUDIO_SAMPLE_RATE,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
setActive(active: boolean): void {
|
|
59
77
|
this.active = active;
|
|
60
78
|
this.sync();
|
|
@@ -105,6 +123,9 @@ export class AudioSender {
|
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
private detach(): void {
|
|
126
|
+
if (this.attachedTrack) {
|
|
127
|
+
this.wallMs += this.deps.env.now() - this.baseMs;
|
|
128
|
+
}
|
|
108
129
|
this.attachedTrack = null;
|
|
109
130
|
this.stopResume?.();
|
|
110
131
|
this.stopResume = undefined;
|
|
@@ -132,11 +153,13 @@ export class AudioSender {
|
|
|
132
153
|
const samples = input.length;
|
|
133
154
|
const timestampUs = Math.round((this.samples * 1_000_000) / AUDIO_SAMPLE_RATE);
|
|
134
155
|
this.samples += samples;
|
|
156
|
+
this.captured.feed(input, AUDIO_SAMPLE_RATE);
|
|
135
157
|
if (!this.deps.canSend()) {
|
|
136
158
|
return;
|
|
137
159
|
}
|
|
138
160
|
const encoder = this.encoder ?? this.createEncoder();
|
|
139
161
|
if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
|
|
162
|
+
this.encoderSkippedSamples += samples;
|
|
140
163
|
return;
|
|
141
164
|
}
|
|
142
165
|
let audio: AudioDataLike | undefined;
|
|
@@ -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
|
import type { RelaySocket } from "./relayEnv.js";
|
|
@@ -48,6 +53,9 @@ export const RELAY_BACKOFF_MAX_MS = 15_000;
|
|
|
48
53
|
/** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
|
|
49
54
|
* upgrade but never answers the hello would otherwise hang the tier forever). */
|
|
50
55
|
export const RELAY_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
56
|
+
/** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
|
|
57
|
+
* polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
|
|
58
|
+
export const RELAY_PING_INTERVAL_MS = 2_000;
|
|
51
59
|
|
|
52
60
|
const SOCKET_OPEN = 1;
|
|
53
61
|
|
|
@@ -56,6 +64,8 @@ export interface RelayClientOptions {
|
|
|
56
64
|
peerId: string;
|
|
57
65
|
createSocket: (url: string) => RelaySocket;
|
|
58
66
|
random: () => number;
|
|
67
|
+
/** A monotonic millisecond clock for timing pings. Defaults to `Date.now`. */
|
|
68
|
+
now?: () => number;
|
|
59
69
|
/** Called with the sender's peer id (as stamped by the server) and the original payload. */
|
|
60
70
|
onMedia: (peerId: string, payload: Uint8Array) => void;
|
|
61
71
|
}
|
|
@@ -82,6 +92,8 @@ export class RelayClient {
|
|
|
82
92
|
private wanted: string[] = [];
|
|
83
93
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
84
94
|
private handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
95
|
+
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
96
|
+
private lastRoundTripMs: number | undefined;
|
|
85
97
|
private readonly decoder = new TextDecoder();
|
|
86
98
|
|
|
87
99
|
constructor(private readonly options: RelayClientOptions) {}
|
|
@@ -91,6 +103,12 @@ export class RelayClient {
|
|
|
91
103
|
return this.isReady;
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
/** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
|
|
107
|
+
* current socket (and for good on a server too old to answer pings). */
|
|
108
|
+
get roundTripMs(): number | undefined {
|
|
109
|
+
return this.lastRoundTripMs;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
/** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
|
|
95
113
|
* `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
|
|
96
114
|
* frame fragmenter sizes its fragments from this. */
|
|
@@ -148,6 +166,7 @@ export class RelayClient {
|
|
|
148
166
|
clearTimeout(this.reconnectTimer);
|
|
149
167
|
clearTimeout(this.handshakeTimer);
|
|
150
168
|
this.reconnectTimer = this.handshakeTimer = undefined;
|
|
169
|
+
this.stopPinging();
|
|
151
170
|
this.detach();
|
|
152
171
|
}
|
|
153
172
|
|
|
@@ -200,7 +219,15 @@ export class RelayClient {
|
|
|
200
219
|
} catch {
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
|
-
if (!message || typeof message !== "object"
|
|
222
|
+
if (!message || typeof message !== "object") {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const op = (message as { op?: unknown }).op;
|
|
226
|
+
if (op === "pong") {
|
|
227
|
+
this.handlePong((message as { t?: unknown }).t);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (op !== "ready" || this.isReady) {
|
|
204
231
|
return;
|
|
205
232
|
}
|
|
206
233
|
this.isReady = true;
|
|
@@ -213,6 +240,30 @@ export class RelayClient {
|
|
|
213
240
|
this.handshakeTimer = undefined;
|
|
214
241
|
// The server's wanted set is per socket, so it starts empty on every (re)connect.
|
|
215
242
|
this.sendText({ op: "want", peers: this.wanted });
|
|
243
|
+
this.pingTimer = setInterval(() => this.sendText({ op: "ping", t: this.now() }), RELAY_PING_INTERVAL_MS);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
|
|
247
|
+
* or one from the future (nothing this client sent). */
|
|
248
|
+
private handlePong(t: unknown): void {
|
|
249
|
+
if (!this.isReady || typeof t !== "number") {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const roundTrip = this.now() - t;
|
|
253
|
+
if (roundTrip >= 0) {
|
|
254
|
+
this.lastRoundTripMs = roundTrip;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private now(): number {
|
|
259
|
+
return (this.options.now ?? Date.now)();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Stops pinging and forgets the last round trip - it described a socket that is gone. */
|
|
263
|
+
private stopPinging(): void {
|
|
264
|
+
clearInterval(this.pingTimer);
|
|
265
|
+
this.pingTimer = undefined;
|
|
266
|
+
this.lastRoundTripMs = undefined;
|
|
216
267
|
}
|
|
217
268
|
|
|
218
269
|
private handleClose(socket: RelaySocket): void {
|
|
@@ -220,6 +271,7 @@ export class RelayClient {
|
|
|
220
271
|
return;
|
|
221
272
|
}
|
|
222
273
|
this.isReady = false;
|
|
274
|
+
this.stopPinging();
|
|
223
275
|
// The next server might be a different one (a rolling upgrade), so the limit is learned afresh.
|
|
224
276
|
this.messageLimit = MAX_MESSAGE_BYTES;
|
|
225
277
|
clearTimeout(this.handshakeTimer);
|