@rapidmx/meet-plugin 0.5.0 → 0.8.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 +15 -2
- package/apps/meet/[token].tsx +3 -2
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +23 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetLobby.tsx +22 -0
- package/apps/meet/_ParticipantTile.tsx +62 -9
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_meetApi.ts +6 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
- package/apps/shared/media/filters/accessories.ts +262 -0
- package/apps/shared/media/filters/backgroundImage.ts +69 -0
- package/apps/shared/media/filters/filterTypes.ts +89 -0
- package/apps/shared/media/filters/mlModels.ts +174 -0
- package/apps/shared/media/filters/pixelEffects.ts +61 -0
- package/apps/shared/media/mediaPreferences.ts +105 -0
- package/apps/shared/media/useLocalMedia.ts +198 -14
- package/apps/shared/relay/AudioPlayer.ts +111 -0
- package/apps/shared/relay/AudioSender.ts +194 -0
- package/apps/shared/relay/RelayClient.ts +262 -0
- package/apps/shared/relay/RelayReceiver.ts +72 -0
- package/apps/shared/relay/RelaySender.ts +74 -0
- package/apps/shared/relay/RelayTransport.ts +188 -0
- package/apps/shared/relay/VideoPlayer.ts +110 -0
- package/apps/shared/relay/VideoSender.ts +216 -0
- package/apps/shared/relay/audioResume.ts +55 -0
- package/apps/shared/relay/frames.ts +275 -0
- package/apps/shared/relay/relayEnv.ts +230 -0
- package/apps/shared/relay/senderCommon.ts +30 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].js +3 -3
- package/dist/apps/meet/_CallControls.d.ts +2 -1
- package/dist/apps/meet/_CallControls.js +6 -3
- package/dist/apps/meet/_CallView.d.ts +4 -1
- package/dist/apps/meet/_CallView.js +17 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetLobby.d.ts +4 -0
- package/dist/apps/meet/_MeetLobby.js +8 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
- package/dist/apps/meet/_ParticipantTile.js +20 -3
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
- package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
- package/dist/apps/shared/media/filters/accessories.js +211 -0
- package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
- package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
- package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
- package/dist/apps/shared/media/filters/filterTypes.js +63 -0
- package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
- package/dist/apps/shared/media/filters/mlModels.js +106 -0
- package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
- package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
- package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
- package/dist/apps/shared/media/mediaPreferences.js +93 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
- package/dist/apps/shared/media/useLocalMedia.js +160 -14
- package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
- package/dist/apps/shared/relay/AudioPlayer.js +103 -0
- package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
- package/dist/apps/shared/relay/AudioSender.js +172 -0
- package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
- package/dist/apps/shared/relay/RelayClient.js +236 -0
- package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
- package/dist/apps/shared/relay/RelayReceiver.js +59 -0
- package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
- package/dist/apps/shared/relay/RelaySender.js +54 -0
- package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
- package/dist/apps/shared/relay/RelayTransport.js +142 -0
- package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
- package/dist/apps/shared/relay/VideoPlayer.js +102 -0
- package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
- package/dist/apps/shared/relay/VideoSender.js +204 -0
- package/dist/apps/shared/relay/audioResume.d.ts +12 -0
- package/dist/apps/shared/relay/audioResume.js +45 -0
- package/dist/apps/shared/relay/frames.d.ts +101 -0
- package/dist/apps/shared/relay/frames.js +221 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
- package/dist/apps/shared/relay/relayEnv.js +69 -0
- package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
- package/dist/apps/shared/relay/senderCommon.js +10 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
- package/dist/apps/shared/webrtc/types.d.ts +29 -2
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/EffectsUtils.js +21 -0
- package/dist/lib/util/EffectsUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +25 -4
- package/dist/lib/util/IceServerUtils.js.map +1 -1
- package/dist/lib/util/RedisRelayBus.js +241 -0
- package/dist/lib/util/RedisRelayBus.js.map +1 -0
- package/dist/lib/util/RelayHub.js +405 -0
- package/dist/lib/util/RelayHub.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
- package/dist/types/util/EffectsUtils.d.ts +10 -0
- package/dist/types/util/IceServerUtils.d.ts +12 -1
- package/dist/types/util/RedisRelayBus.d.ts +131 -0
- package/dist/types/util/RelayHub.d.ts +188 -0
- package/package.json +24 -7
|
@@ -20,17 +20,30 @@
|
|
|
20
20
|
* - **Devices**: `selectDevice()` swaps in another camera or microphone; `devicechange` refreshes the lists.
|
|
21
21
|
* - **Level**: `audioLevel` (0-5) follows the microphone while it is unmuted, for the "your audio is being sent"
|
|
22
22
|
* indicator.
|
|
23
|
+
* - **Filters**: while any video filter is on (`setFilters()`), `videoTrack`/`videoStream` are the *filtered*
|
|
24
|
+
* picture (`filters/VideoFilterProcessor.ts`) rather than the camera's own track - so everything downstream, the
|
|
25
|
+
* lobby preview and every path the call sends over, gets it without knowing. The camera's track stays owned here; the
|
|
26
|
+
* processor only reads it. If the filtered picture can't be made there is no video at all rather than an unfiltered
|
|
27
|
+
* one, since the filter may be hiding the participant's room.
|
|
28
|
+
* - **Remembered settings**: the devices picked, whether the microphone and camera were on, and the filters are
|
|
29
|
+
* saved on this device (`mediaPreferences.ts`) and applied the next time - a saved device is asked for as a
|
|
30
|
+
* preference (`ideal`), so one that has since been unplugged falls back to the default instead of failing.
|
|
23
31
|
*
|
|
24
32
|
* Nothing here runs during SSR: browser APIs are only touched from effects and event handlers.
|
|
25
33
|
*/
|
|
26
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
34
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
27
35
|
import { defaultMediaDevices, isMediaDevicesSupported, listDevices, requestUserMedia, } from "./deviceMedia.js";
|
|
28
36
|
import { startLevelMeter } from "./levelMeter.js";
|
|
37
|
+
import { loadPicture, prepareBackgroundImage } from "./filters/backgroundImage.js";
|
|
38
|
+
import { NO_FILTERS, filtersActive } from "./filters/filterTypes.js";
|
|
39
|
+
import { VideoFilterProcessor } from "./filters/VideoFilterProcessor.js";
|
|
40
|
+
import { loadPreferences, savePreferences } from "./mediaPreferences.js";
|
|
29
41
|
const AUDIO_CONSTRAINTS = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
|
30
42
|
const VIDEO_CONSTRAINTS = { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } };
|
|
31
43
|
/** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
|
|
32
44
|
* below full scale. */
|
|
33
45
|
const LEVEL_FULL_SCALE = 20;
|
|
46
|
+
const FILTERS_UNSUPPORTED = "Video effects aren't supported in this browser, so your camera is off. Turn the effects off to show your camera.";
|
|
34
47
|
export const AUDIO_LEVEL_STEPS = 5;
|
|
35
48
|
function statusFor(error) {
|
|
36
49
|
switch (error.kind) {
|
|
@@ -42,7 +55,11 @@ function statusFor(error) {
|
|
|
42
55
|
return "error";
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
|
-
|
|
58
|
+
/** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
|
|
59
|
+
function preferring(base, preferredId) {
|
|
60
|
+
return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
|
|
61
|
+
}
|
|
62
|
+
export function useLocalMedia({ effectsAssetsUrl } = {}) {
|
|
46
63
|
const [supported, setSupported] = useState(true);
|
|
47
64
|
const [requesting, setRequesting] = useState(false);
|
|
48
65
|
const [error, setError] = useState(null);
|
|
@@ -51,9 +68,55 @@ export function useLocalMedia() {
|
|
|
51
68
|
const [micEnabled, setMicEnabled] = useState(true);
|
|
52
69
|
const [devices, setDevices] = useState({ cameras: [], microphones: [] });
|
|
53
70
|
const [audioLevel, setAudioLevel] = useState(0);
|
|
71
|
+
const [filters, setFiltersState] = useState(NO_FILTERS);
|
|
72
|
+
const [background, setBackground] = useState(null);
|
|
73
|
+
const [filterStatus, setFilterStatus] = useState({ loading: false, error: null });
|
|
74
|
+
const [processed, setProcessed] = useState(null);
|
|
54
75
|
const tracksRef = useRef({ audio: null, video: null });
|
|
55
76
|
const micEnabledRef = useRef(true);
|
|
56
77
|
const mountedRef = useRef(true);
|
|
78
|
+
const prefsRef = useRef(null);
|
|
79
|
+
const filtersRef = useRef(NO_FILTERS);
|
|
80
|
+
const backgroundRef = useRef(null);
|
|
81
|
+
const processorRef = useRef(null);
|
|
82
|
+
const applyFilters = useCallback((next) => {
|
|
83
|
+
filtersRef.current = next;
|
|
84
|
+
setFiltersState(next);
|
|
85
|
+
}, []);
|
|
86
|
+
const applyBackground = useCallback((picture) => {
|
|
87
|
+
backgroundRef.current = picture;
|
|
88
|
+
setBackground(picture);
|
|
89
|
+
}, []);
|
|
90
|
+
/** What was remembered from last time - read once, the first time anything needs it, and applied to the state it
|
|
91
|
+
* covers. Never during render: storage doesn't exist on the server, and reading it would make the first client
|
|
92
|
+
* render differ from the server's. */
|
|
93
|
+
const ensurePrefs = useCallback(() => {
|
|
94
|
+
if (prefsRef.current) {
|
|
95
|
+
return prefsRef.current;
|
|
96
|
+
}
|
|
97
|
+
const prefs = loadPreferences();
|
|
98
|
+
prefsRef.current = prefs;
|
|
99
|
+
if (prefs.micEnabled === false) {
|
|
100
|
+
micEnabledRef.current = false;
|
|
101
|
+
setMicEnabled(false);
|
|
102
|
+
}
|
|
103
|
+
if (prefs.filters) {
|
|
104
|
+
applyFilters(prefs.filters);
|
|
105
|
+
}
|
|
106
|
+
if (prefs.backgroundImage) {
|
|
107
|
+
loadPicture(prefs.backgroundImage).then((picture) => {
|
|
108
|
+
if (mountedRef.current) {
|
|
109
|
+
applyBackground(picture);
|
|
110
|
+
}
|
|
111
|
+
}, () => undefined);
|
|
112
|
+
}
|
|
113
|
+
return prefs;
|
|
114
|
+
}, [applyBackground, applyFilters]);
|
|
115
|
+
/** Saves a change to the remembered settings. */
|
|
116
|
+
const remember = useCallback((patch) => {
|
|
117
|
+
prefsRef.current = { ...ensurePrefs(), ...patch };
|
|
118
|
+
savePreferences(patch);
|
|
119
|
+
}, [ensurePrefs]);
|
|
57
120
|
const refreshDevices = useCallback(async () => {
|
|
58
121
|
const list = await listDevices();
|
|
59
122
|
if (list.ok && mountedRef.current) {
|
|
@@ -87,16 +150,22 @@ export function useLocalMedia() {
|
|
|
87
150
|
tracksRef.current = { ...tracksRef.current, [kind]: track };
|
|
88
151
|
setTracks(tracksRef.current);
|
|
89
152
|
setStatus((prev) => ({ ...prev, [kind]: "live" }));
|
|
90
|
-
|
|
153
|
+
if (kind === "video") {
|
|
154
|
+
remember({ cameraEnabled: true });
|
|
155
|
+
}
|
|
156
|
+
}, [remember]);
|
|
91
157
|
const adoptStream = useCallback((stream) => {
|
|
92
158
|
for (const track of stream.getTracks()) {
|
|
93
159
|
adoptTrack(track);
|
|
94
160
|
}
|
|
95
161
|
}, [adoptTrack]);
|
|
96
|
-
/** Asks for one kind on its own - a specific device when `deviceId` is given. */
|
|
162
|
+
/** Asks for one kind on its own - a specific device when `deviceId` is given, else the one used last time. */
|
|
97
163
|
const acquire = useCallback(async (kind, deviceId) => {
|
|
98
164
|
const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
|
|
99
|
-
const
|
|
165
|
+
const prefs = ensurePrefs();
|
|
166
|
+
const constraint = deviceId
|
|
167
|
+
? { ...base, deviceId: { exact: deviceId } }
|
|
168
|
+
: preferring(base, kind === "audio" ? prefs.microphoneId : prefs.cameraId);
|
|
100
169
|
const result = await requestUserMedia({ [kind]: constraint });
|
|
101
170
|
if (!result.ok) {
|
|
102
171
|
if (mountedRef.current) {
|
|
@@ -110,10 +179,22 @@ export function useLocalMedia() {
|
|
|
110
179
|
setError(null);
|
|
111
180
|
}
|
|
112
181
|
return true;
|
|
113
|
-
}, [adoptStream]);
|
|
182
|
+
}, [adoptStream, ensurePrefs]);
|
|
114
183
|
/** Asks for the camera and the microphone in a single prompt. */
|
|
115
184
|
const acquireBoth = useCallback(async () => {
|
|
116
|
-
const
|
|
185
|
+
const prefs = ensurePrefs();
|
|
186
|
+
if (prefs.cameraEnabled === false) {
|
|
187
|
+
// The camera was off when the participant last left: join with it off, without switching it on first.
|
|
188
|
+
if (mountedRef.current) {
|
|
189
|
+
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
190
|
+
}
|
|
191
|
+
await acquire("audio");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const both = await requestUserMedia({
|
|
195
|
+
audio: preferring(AUDIO_CONSTRAINTS, prefs.microphoneId),
|
|
196
|
+
video: preferring(VIDEO_CONSTRAINTS, prefs.cameraId),
|
|
197
|
+
});
|
|
117
198
|
if (both.ok) {
|
|
118
199
|
adoptStream(both.value);
|
|
119
200
|
}
|
|
@@ -129,7 +210,7 @@ export function useLocalMedia() {
|
|
|
129
210
|
await acquire("audio");
|
|
130
211
|
await acquire("video");
|
|
131
212
|
}
|
|
132
|
-
}, [acquire, adoptStream]);
|
|
213
|
+
}, [acquire, adoptStream, ensurePrefs]);
|
|
133
214
|
const requestAccess = useCallback(async (kind) => {
|
|
134
215
|
if (!isMediaDevicesSupported()) {
|
|
135
216
|
setSupported(false);
|
|
@@ -148,6 +229,7 @@ export function useLocalMedia() {
|
|
|
148
229
|
if (!track) {
|
|
149
230
|
micEnabledRef.current = true;
|
|
150
231
|
setMicEnabled(true);
|
|
232
|
+
remember({ micEnabled: true });
|
|
151
233
|
await acquire("audio");
|
|
152
234
|
await refreshDevices();
|
|
153
235
|
return;
|
|
@@ -156,7 +238,8 @@ export function useLocalMedia() {
|
|
|
156
238
|
micEnabledRef.current = next;
|
|
157
239
|
track.enabled = next;
|
|
158
240
|
setMicEnabled(next);
|
|
159
|
-
|
|
241
|
+
remember({ micEnabled: next });
|
|
242
|
+
}, [acquire, refreshDevices, remember]);
|
|
160
243
|
const toggleCamera = useCallback(async () => {
|
|
161
244
|
const track = tracksRef.current.video;
|
|
162
245
|
if (!track) {
|
|
@@ -169,7 +252,8 @@ export function useLocalMedia() {
|
|
|
169
252
|
tracksRef.current = { ...tracksRef.current, video: null };
|
|
170
253
|
setTracks(tracksRef.current);
|
|
171
254
|
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
172
|
-
|
|
255
|
+
remember({ cameraEnabled: false });
|
|
256
|
+
}, [acquire, refreshDevices, remember]);
|
|
173
257
|
const selectDevice = useCallback(async (kind, deviceId) => {
|
|
174
258
|
if (kind === "video") {
|
|
175
259
|
// Some phones can't open a second camera while the first is still running.
|
|
@@ -181,9 +265,28 @@ export function useLocalMedia() {
|
|
|
181
265
|
setTracks(tracksRef.current);
|
|
182
266
|
}
|
|
183
267
|
}
|
|
184
|
-
await acquire(kind, deviceId)
|
|
268
|
+
if (await acquire(kind, deviceId)) {
|
|
269
|
+
remember(kind === "audio" ? { microphoneId: deviceId } : { cameraId: deviceId });
|
|
270
|
+
}
|
|
185
271
|
await refreshDevices();
|
|
186
|
-
}, [acquire, refreshDevices]);
|
|
272
|
+
}, [acquire, refreshDevices, remember]);
|
|
273
|
+
const setFilters = useCallback((patch) => {
|
|
274
|
+
const next = { ...filtersRef.current, ...patch };
|
|
275
|
+
applyFilters(next);
|
|
276
|
+
remember({ filters: next });
|
|
277
|
+
}, [applyFilters, remember]);
|
|
278
|
+
const chooseBackgroundImage = useCallback(async (file) => {
|
|
279
|
+
const prepared = await prepareBackgroundImage(file);
|
|
280
|
+
if (!prepared.ok) {
|
|
281
|
+
return prepared.message;
|
|
282
|
+
}
|
|
283
|
+
if (mountedRef.current) {
|
|
284
|
+
applyBackground(prepared.picture);
|
|
285
|
+
remember({ backgroundImage: prepared.dataUrl });
|
|
286
|
+
setFilters({ background: "image" });
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}, [applyBackground, remember, setFilters]);
|
|
187
290
|
const release = useCallback(() => {
|
|
188
291
|
for (const track of Object.values(tracksRef.current)) {
|
|
189
292
|
if (track) {
|
|
@@ -231,7 +334,45 @@ export function useLocalMedia() {
|
|
|
231
334
|
setAudioLevel(0);
|
|
232
335
|
};
|
|
233
336
|
}, [tracks.audio, micEnabled]);
|
|
234
|
-
|
|
337
|
+
// Puts the camera through the filters for as long as any is on - see this module's doc comment. A layout effect,
|
|
338
|
+
// so the filtered track is in place before the first frame is painted rather than after an unfiltered one.
|
|
339
|
+
const filtersOn = filtersActive(filters);
|
|
340
|
+
const source = tracks.video;
|
|
341
|
+
useLayoutEffect(() => {
|
|
342
|
+
if (!source || !filtersOn) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
let processor;
|
|
346
|
+
try {
|
|
347
|
+
processor = new VideoFilterProcessor({
|
|
348
|
+
source,
|
|
349
|
+
filters: filtersRef.current,
|
|
350
|
+
backgroundImage: backgroundRef.current,
|
|
351
|
+
assetsUrl: effectsAssetsUrl,
|
|
352
|
+
onStatus: setFilterStatus,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
setFilterStatus({ loading: false, error: FILTERS_UNSUPPORTED });
|
|
357
|
+
// Turning the filters off (or the camera) clears the message again.
|
|
358
|
+
return () => setFilterStatus({ loading: false, error: null });
|
|
359
|
+
}
|
|
360
|
+
processorRef.current = processor;
|
|
361
|
+
setProcessed(processor.track);
|
|
362
|
+
return () => {
|
|
363
|
+
processor.stop();
|
|
364
|
+
processorRef.current = null;
|
|
365
|
+
setProcessed(null);
|
|
366
|
+
setFilterStatus({ loading: false, error: null });
|
|
367
|
+
};
|
|
368
|
+
}, [source, filtersOn, effectsAssetsUrl]);
|
|
369
|
+
// Passes a change of filters (or of the background picture) on to the running processor.
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
processorRef.current?.update(filters, background);
|
|
372
|
+
}, [filters, background]);
|
|
373
|
+
// While filters are on, the camera's own track is never what's sent: the filtered one is, or nothing.
|
|
374
|
+
const videoTrack = filtersOn ? (source ? processed : null) : source;
|
|
375
|
+
const videoStream = useMemo(() => (videoTrack ? new MediaStream([videoTrack]) : null), [videoTrack]);
|
|
235
376
|
const selectedDeviceIds = useMemo(() => ({
|
|
236
377
|
audio: tracks.audio?.getSettings?.().deviceId,
|
|
237
378
|
video: tracks.video?.getSettings?.().deviceId,
|
|
@@ -241,7 +382,7 @@ export function useLocalMedia() {
|
|
|
241
382
|
requesting,
|
|
242
383
|
error,
|
|
243
384
|
audioTrack: tracks.audio,
|
|
244
|
-
videoTrack
|
|
385
|
+
videoTrack,
|
|
245
386
|
videoStream,
|
|
246
387
|
micEnabled,
|
|
247
388
|
micOn: !!tracks.audio && micEnabled,
|
|
@@ -250,6 +391,11 @@ export function useLocalMedia() {
|
|
|
250
391
|
devices,
|
|
251
392
|
selectedDeviceIds,
|
|
252
393
|
audioLevel,
|
|
394
|
+
filters,
|
|
395
|
+
filterStatus,
|
|
396
|
+
hasBackgroundImage: !!background,
|
|
397
|
+
setFilters,
|
|
398
|
+
chooseBackgroundImage,
|
|
253
399
|
requestAccess,
|
|
254
400
|
toggleMic,
|
|
255
401
|
toggleCamera,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Frame } from "./frames.js";
|
|
2
|
+
import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
|
|
3
|
+
export declare const PLAYBACK_SAMPLE_RATE = 48000;
|
|
4
|
+
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
5
|
+
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
6
|
+
export declare const JITTER_BUFFER_SECONDS = 0.08;
|
|
7
|
+
/** If scheduled audio already runs this far past the clock the receiver has fallen behind (a burst, or the sender's
|
|
8
|
+
* clock running fast), so further packets are dropped until playback catches up - better a brief skip than latency
|
|
9
|
+
* that keeps growing. */
|
|
10
|
+
export declare const MAX_AHEAD_SECONDS = 0.4;
|
|
11
|
+
/** Skip a packet instead of queueing it when the decoder is this far behind. */
|
|
12
|
+
export declare const AUDIO_MAX_DECODE_QUEUE = 16;
|
|
13
|
+
/**
|
|
14
|
+
* Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
|
|
15
|
+
*
|
|
16
|
+
* Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
|
|
17
|
+
* `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
|
|
18
|
+
* join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
|
|
19
|
+
* Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
|
|
20
|
+
* the decoder is created lazily and discarded on any error, and replaced by the next packet.
|
|
21
|
+
*/
|
|
22
|
+
export declare class AudioPlayer {
|
|
23
|
+
private readonly env;
|
|
24
|
+
private readonly ctx;
|
|
25
|
+
private readonly destination;
|
|
26
|
+
private decoder;
|
|
27
|
+
private nextTime;
|
|
28
|
+
private readonly timestamps;
|
|
29
|
+
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
30
|
+
constructor(env: RelayEnv, ctx: AudioContextLike, destination: unknown);
|
|
31
|
+
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
32
|
+
push(frame: Frame): void;
|
|
33
|
+
close(): void;
|
|
34
|
+
private discardDecoder;
|
|
35
|
+
private createDecoder;
|
|
36
|
+
/** Schedules one decoded block. Always closes `data`. */
|
|
37
|
+
private play;
|
|
38
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { TimestampUnwrapper } from "./frames.js";
|
|
6
|
+
import { safeClose } from "./senderCommon.js";
|
|
7
|
+
export const PLAYBACK_SAMPLE_RATE = 48000;
|
|
8
|
+
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
9
|
+
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
10
|
+
export const JITTER_BUFFER_SECONDS = 0.08;
|
|
11
|
+
/** If scheduled audio already runs this far past the clock the receiver has fallen behind (a burst, or the sender's
|
|
12
|
+
* clock running fast), so further packets are dropped until playback catches up - better a brief skip than latency
|
|
13
|
+
* that keeps growing. */
|
|
14
|
+
export const MAX_AHEAD_SECONDS = 0.4;
|
|
15
|
+
/** Skip a packet instead of queueing it when the decoder is this far behind. */
|
|
16
|
+
export const AUDIO_MAX_DECODE_QUEUE = 16;
|
|
17
|
+
/**
|
|
18
|
+
* Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
|
|
19
|
+
*
|
|
20
|
+
* Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
|
|
21
|
+
* `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
|
|
22
|
+
* join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
|
|
23
|
+
* Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
|
|
24
|
+
* the decoder is created lazily and discarded on any error, and replaced by the next packet.
|
|
25
|
+
*/
|
|
26
|
+
export class AudioPlayer {
|
|
27
|
+
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
28
|
+
constructor(env, ctx, destination) {
|
|
29
|
+
this.env = env;
|
|
30
|
+
this.ctx = ctx;
|
|
31
|
+
this.destination = destination;
|
|
32
|
+
this.nextTime = 0;
|
|
33
|
+
this.timestamps = new TimestampUnwrapper();
|
|
34
|
+
}
|
|
35
|
+
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
36
|
+
push(frame) {
|
|
37
|
+
const decoder = this.decoder ?? this.createDecoder();
|
|
38
|
+
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
decoder.decode(this.env.createEncodedAudioChunk({ type: "key", timestamp: this.timestamps.next(frame.timestampMs) * 1000, data: frame.data }));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
this.discardDecoder();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
close() {
|
|
49
|
+
this.discardDecoder();
|
|
50
|
+
}
|
|
51
|
+
discardDecoder() {
|
|
52
|
+
const decoder = this.decoder;
|
|
53
|
+
this.decoder = undefined;
|
|
54
|
+
safeClose(decoder);
|
|
55
|
+
}
|
|
56
|
+
createDecoder() {
|
|
57
|
+
let decoder;
|
|
58
|
+
try {
|
|
59
|
+
decoder = this.env.createAudioDecoder({
|
|
60
|
+
output: (data) => this.play(data),
|
|
61
|
+
error: () => {
|
|
62
|
+
// Only the current decoder's failure matters; a stale one was already replaced.
|
|
63
|
+
if (this.decoder === decoder) {
|
|
64
|
+
this.discardDecoder();
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
decoder.configure({ codec: "opus", sampleRate: PLAYBACK_SAMPLE_RATE, numberOfChannels: 1 });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
safeClose(decoder);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
this.decoder = decoder;
|
|
75
|
+
return decoder;
|
|
76
|
+
}
|
|
77
|
+
/** Schedules one decoded block. Always closes `data`. */
|
|
78
|
+
play(data) {
|
|
79
|
+
try {
|
|
80
|
+
const now = this.ctx.currentTime;
|
|
81
|
+
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
85
|
+
const pcm = new Float32Array(data.numberOfFrames);
|
|
86
|
+
data.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
|
|
87
|
+
buffer.copyToChannel(pcm, 0);
|
|
88
|
+
const source = this.ctx.createBufferSource();
|
|
89
|
+
source.buffer = buffer;
|
|
90
|
+
source.connect(this.destination);
|
|
91
|
+
source.onended = () => source.disconnect();
|
|
92
|
+
const start = Math.max(now + JITTER_BUFFER_SECONDS, this.nextTime);
|
|
93
|
+
source.start(start);
|
|
94
|
+
this.nextTime = start + buffer.duration;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A block the browser will not take (an odd sample rate, a closed context) is dropped like a lost packet.
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
data.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type SenderDeps } from "./senderCommon.js";
|
|
2
|
+
export declare const AUDIO_SAMPLE_RATE = 48000;
|
|
3
|
+
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
4
|
+
* thread is interrupted. */
|
|
5
|
+
export declare const AUDIO_PROCESS_FRAMES = 2048;
|
|
6
|
+
export declare const AUDIO_BITRATE = 24000;
|
|
7
|
+
/** The Opus frame duration in microseconds (20 ms). */
|
|
8
|
+
export declare const AUDIO_FRAME_DURATION_US = 20000;
|
|
9
|
+
/** Skip a block instead of queueing it when the encoder is this far behind. */
|
|
10
|
+
export declare const AUDIO_MAX_ENCODE_QUEUE = 8;
|
|
11
|
+
/**
|
|
12
|
+
* Captures one microphone track and publishes it as Opus.
|
|
13
|
+
*
|
|
14
|
+
* There is exactly one capture path, chosen for being testable and available everywhere the relay is: an
|
|
15
|
+
* `AudioContext` at 48 kHz, a `MediaStreamAudioSourceNode` on the track, and a `ScriptProcessorNode` that receives
|
|
16
|
+
* the raw PCM. (`ScriptProcessorNode` is deprecated in favour of `AudioWorklet`, but it needs no separate module
|
|
17
|
+
* file to load, which a plugin bundle has no good place for, and every browser still supports it.) The processor's
|
|
18
|
+
* output is routed through a zero-gain node to the destination, because browsers only run a processor that is
|
|
19
|
+
* connected to the destination, and the gain stops the microphone playing back locally.
|
|
20
|
+
*
|
|
21
|
+
* It encodes only while sending is active, a track is set and the socket can take the frames, and rebuilds the graph
|
|
22
|
+
* when the track is replaced. The encoder is created lazily and discarded on any error, so a coder that died is
|
|
23
|
+
* replaced at the next block rather than being repaired inside its own error callback.
|
|
24
|
+
*/
|
|
25
|
+
export declare class AudioSender {
|
|
26
|
+
private readonly deps;
|
|
27
|
+
private active;
|
|
28
|
+
private track;
|
|
29
|
+
private attachedTrack;
|
|
30
|
+
private ctx;
|
|
31
|
+
private source;
|
|
32
|
+
private processor;
|
|
33
|
+
private gain;
|
|
34
|
+
private stopResume;
|
|
35
|
+
private encoder;
|
|
36
|
+
private baseMs;
|
|
37
|
+
private samples;
|
|
38
|
+
constructor(deps: SenderDeps);
|
|
39
|
+
setActive(active: boolean): void;
|
|
40
|
+
setTrack(track: MediaStreamTrack | null): void;
|
|
41
|
+
/** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
|
|
42
|
+
* the track changed, untouched otherwise. */
|
|
43
|
+
private sync;
|
|
44
|
+
private attach;
|
|
45
|
+
private detach;
|
|
46
|
+
private discardEncoder;
|
|
47
|
+
private process;
|
|
48
|
+
private createEncoder;
|
|
49
|
+
private onChunk;
|
|
50
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { resumeAudioContext } from "./audioResume.js";
|
|
6
|
+
import { KIND_AUDIO } from "./frames.js";
|
|
7
|
+
import { safeClose } from "./senderCommon.js";
|
|
8
|
+
export const AUDIO_SAMPLE_RATE = 48000;
|
|
9
|
+
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
10
|
+
* thread is interrupted. */
|
|
11
|
+
export const AUDIO_PROCESS_FRAMES = 2048;
|
|
12
|
+
export const AUDIO_BITRATE = 24000;
|
|
13
|
+
/** The Opus frame duration in microseconds (20 ms). */
|
|
14
|
+
export const AUDIO_FRAME_DURATION_US = 20000;
|
|
15
|
+
/** Skip a block instead of queueing it when the encoder is this far behind. */
|
|
16
|
+
export const AUDIO_MAX_ENCODE_QUEUE = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Captures one microphone track and publishes it as Opus.
|
|
19
|
+
*
|
|
20
|
+
* There is exactly one capture path, chosen for being testable and available everywhere the relay is: an
|
|
21
|
+
* `AudioContext` at 48 kHz, a `MediaStreamAudioSourceNode` on the track, and a `ScriptProcessorNode` that receives
|
|
22
|
+
* the raw PCM. (`ScriptProcessorNode` is deprecated in favour of `AudioWorklet`, but it needs no separate module
|
|
23
|
+
* file to load, which a plugin bundle has no good place for, and every browser still supports it.) The processor's
|
|
24
|
+
* output is routed through a zero-gain node to the destination, because browsers only run a processor that is
|
|
25
|
+
* connected to the destination, and the gain stops the microphone playing back locally.
|
|
26
|
+
*
|
|
27
|
+
* It encodes only while sending is active, a track is set and the socket can take the frames, and rebuilds the graph
|
|
28
|
+
* when the track is replaced. The encoder is created lazily and discarded on any error, so a coder that died is
|
|
29
|
+
* replaced at the next block rather than being repaired inside its own error callback.
|
|
30
|
+
*/
|
|
31
|
+
export class AudioSender {
|
|
32
|
+
constructor(deps) {
|
|
33
|
+
this.deps = deps;
|
|
34
|
+
this.active = false;
|
|
35
|
+
this.track = null;
|
|
36
|
+
this.attachedTrack = null;
|
|
37
|
+
this.baseMs = 0;
|
|
38
|
+
this.samples = 0;
|
|
39
|
+
}
|
|
40
|
+
setActive(active) {
|
|
41
|
+
this.active = active;
|
|
42
|
+
this.sync();
|
|
43
|
+
}
|
|
44
|
+
setTrack(track) {
|
|
45
|
+
this.track = track;
|
|
46
|
+
this.sync();
|
|
47
|
+
}
|
|
48
|
+
/** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
|
|
49
|
+
* the track changed, untouched otherwise. */
|
|
50
|
+
sync() {
|
|
51
|
+
const wanted = this.active ? this.track : null;
|
|
52
|
+
if (wanted === this.attachedTrack) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.detach();
|
|
56
|
+
if (wanted) {
|
|
57
|
+
this.attach(wanted);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
attach(track) {
|
|
61
|
+
const env = this.deps.env;
|
|
62
|
+
this.attachedTrack = track;
|
|
63
|
+
this.baseMs = env.now();
|
|
64
|
+
this.samples = 0;
|
|
65
|
+
try {
|
|
66
|
+
const ctx = env.createAudioContext(AUDIO_SAMPLE_RATE);
|
|
67
|
+
this.ctx = ctx;
|
|
68
|
+
const source = ctx.createMediaStreamSource(env.createMediaStream([track]));
|
|
69
|
+
this.source = source;
|
|
70
|
+
const processor = ctx.createScriptProcessor(AUDIO_PROCESS_FRAMES, 1, 1);
|
|
71
|
+
this.processor = processor;
|
|
72
|
+
const gain = ctx.createGain();
|
|
73
|
+
gain.gain.value = 0;
|
|
74
|
+
this.gain = gain;
|
|
75
|
+
processor.onaudioprocess = (event) => this.process(event.inputBuffer.getChannelData(0));
|
|
76
|
+
source.connect(processor);
|
|
77
|
+
processor.connect(gain);
|
|
78
|
+
gain.connect(ctx.destination);
|
|
79
|
+
this.stopResume = resumeAudioContext(ctx, env.document);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// No usable AudioContext (or a browser that refuses one): audio simply isn't relayed.
|
|
83
|
+
this.detach();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
detach() {
|
|
87
|
+
this.attachedTrack = null;
|
|
88
|
+
this.stopResume?.();
|
|
89
|
+
this.stopResume = undefined;
|
|
90
|
+
if (this.processor) {
|
|
91
|
+
this.processor.onaudioprocess = null;
|
|
92
|
+
}
|
|
93
|
+
this.source?.disconnect();
|
|
94
|
+
this.processor?.disconnect();
|
|
95
|
+
this.gain?.disconnect();
|
|
96
|
+
this.source = this.processor = this.gain = undefined;
|
|
97
|
+
const ctx = this.ctx;
|
|
98
|
+
this.ctx = undefined;
|
|
99
|
+
// `close()` rejects for a context that is already closed; nothing to do about it.
|
|
100
|
+
ctx?.close().catch(() => undefined);
|
|
101
|
+
this.discardEncoder();
|
|
102
|
+
}
|
|
103
|
+
discardEncoder() {
|
|
104
|
+
const encoder = this.encoder;
|
|
105
|
+
this.encoder = undefined;
|
|
106
|
+
safeClose(encoder);
|
|
107
|
+
}
|
|
108
|
+
process(input) {
|
|
109
|
+
const samples = input.length;
|
|
110
|
+
const timestampUs = Math.round((this.samples * 1000000) / AUDIO_SAMPLE_RATE);
|
|
111
|
+
this.samples += samples;
|
|
112
|
+
if (!this.deps.canSend()) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const encoder = this.encoder ?? this.createEncoder();
|
|
116
|
+
if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let audio;
|
|
120
|
+
try {
|
|
121
|
+
// The browser reuses `input` for the next block, so the encoder gets its own copy.
|
|
122
|
+
audio = this.deps.env.createAudioData({
|
|
123
|
+
format: "f32-planar",
|
|
124
|
+
sampleRate: AUDIO_SAMPLE_RATE,
|
|
125
|
+
numberOfFrames: samples,
|
|
126
|
+
numberOfChannels: 1,
|
|
127
|
+
timestamp: timestampUs,
|
|
128
|
+
data: new Float32Array(input),
|
|
129
|
+
});
|
|
130
|
+
encoder.encode(audio);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
this.discardEncoder();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
audio?.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
createEncoder() {
|
|
140
|
+
let encoder;
|
|
141
|
+
try {
|
|
142
|
+
encoder = this.deps.env.createAudioEncoder({
|
|
143
|
+
output: (chunk) => this.onChunk(chunk),
|
|
144
|
+
error: () => {
|
|
145
|
+
// Only the current encoder's failure matters; a stale one was already replaced.
|
|
146
|
+
if (this.encoder === encoder) {
|
|
147
|
+
this.discardEncoder();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
encoder.configure({
|
|
152
|
+
codec: "opus",
|
|
153
|
+
sampleRate: AUDIO_SAMPLE_RATE,
|
|
154
|
+
numberOfChannels: 1,
|
|
155
|
+
bitrate: AUDIO_BITRATE,
|
|
156
|
+
opus: { frameDuration: AUDIO_FRAME_DURATION_US },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
safeClose(encoder);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
this.encoder = encoder;
|
|
164
|
+
return encoder;
|
|
165
|
+
}
|
|
166
|
+
onChunk(chunk) {
|
|
167
|
+
const bytes = new Uint8Array(chunk.byteLength);
|
|
168
|
+
chunk.copyTo(bytes);
|
|
169
|
+
// Every Opus packet decodes on its own, so each is flagged as a key frame.
|
|
170
|
+
this.deps.publish(KIND_AUDIO, true, bytes, this.baseMs + chunk.timestamp / 1000);
|
|
171
|
+
}
|
|
172
|
+
}
|