@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,10 +20,18 @@
|
|
|
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 {
|
|
28
36
|
type DeviceLists,
|
|
29
37
|
type MediaAccessError,
|
|
@@ -33,6 +41,10 @@ import {
|
|
|
33
41
|
requestUserMedia,
|
|
34
42
|
} from "./deviceMedia.js";
|
|
35
43
|
import { startLevelMeter } from "./levelMeter.js";
|
|
44
|
+
import { loadPicture, prepareBackgroundImage } from "./filters/backgroundImage.js";
|
|
45
|
+
import { NO_FILTERS, type VideoFilters, filtersActive } from "./filters/filterTypes.js";
|
|
46
|
+
import { type BackgroundPicture, type FilterStatus, VideoFilterProcessor } from "./filters/VideoFilterProcessor.js";
|
|
47
|
+
import { type MediaPreferences, loadPreferences, savePreferences } from "./mediaPreferences.js";
|
|
36
48
|
|
|
37
49
|
export type MediaKind = "audio" | "video";
|
|
38
50
|
|
|
@@ -47,6 +59,9 @@ const VIDEO_CONSTRAINTS: MediaTrackConstraints = { facingMode: "user", width: {
|
|
|
47
59
|
/** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
|
|
48
60
|
* below full scale. */
|
|
49
61
|
const LEVEL_FULL_SCALE = 20;
|
|
62
|
+
const FILTERS_UNSUPPORTED =
|
|
63
|
+
"Video effects aren't supported in this browser, so your camera is off. Turn the effects off to show your camera.";
|
|
64
|
+
|
|
50
65
|
export const AUDIO_LEVEL_STEPS = 5;
|
|
51
66
|
|
|
52
67
|
export interface LocalMedia {
|
|
@@ -70,6 +85,16 @@ export interface LocalMedia {
|
|
|
70
85
|
selectedDeviceIds: Partial<Record<MediaKind, string>>;
|
|
71
86
|
/** 0-`AUDIO_LEVEL_STEPS`: how loud the microphone is right now - always 0 while muted. */
|
|
72
87
|
audioLevel: number;
|
|
88
|
+
/** The video filters in effect - `NO_FILTERS` when the camera is sent as it is. */
|
|
89
|
+
filters: VideoFilters;
|
|
90
|
+
/** Whether the filters' models are still loading, and why a filter isn't fully working. */
|
|
91
|
+
filterStatus: FilterStatus;
|
|
92
|
+
/** Whether a custom background image has been chosen (and so "custom image" can be switched to at once). */
|
|
93
|
+
hasBackgroundImage: boolean;
|
|
94
|
+
/** Changes some of the filters, keeping the rest. */
|
|
95
|
+
setFilters(patch: Partial<VideoFilters>): void;
|
|
96
|
+
/** Uses `file` as the custom background and switches to it. Resolves to why the file can't be used, or `null`. */
|
|
97
|
+
chooseBackgroundImage(file: File): Promise<string | null>;
|
|
73
98
|
/** Asks for the camera and microphone (or just `kind`), see this module's doc comment. */
|
|
74
99
|
requestAccess(kind?: MediaKind): Promise<void>;
|
|
75
100
|
toggleMic(): Promise<void>;
|
|
@@ -90,7 +115,17 @@ function statusFor(error: MediaAccessError): TrackStatus {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
93
|
-
export
|
|
118
|
+
export interface LocalMediaOptions {
|
|
119
|
+
/** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
|
|
120
|
+
effectsAssetsUrl?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
|
|
124
|
+
function preferring(base: MediaTrackConstraints, preferredId?: string): MediaTrackConstraints {
|
|
125
|
+
return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): LocalMedia {
|
|
94
129
|
const [supported, setSupported] = useState(true);
|
|
95
130
|
const [requesting, setRequesting] = useState(false);
|
|
96
131
|
const [error, setError] = useState<MediaAccessError | null>(null);
|
|
@@ -99,10 +134,66 @@ export function useLocalMedia(): LocalMedia {
|
|
|
99
134
|
const [micEnabled, setMicEnabled] = useState(true);
|
|
100
135
|
const [devices, setDevices] = useState<DeviceLists>({ cameras: [], microphones: [] });
|
|
101
136
|
const [audioLevel, setAudioLevel] = useState(0);
|
|
137
|
+
const [filters, setFiltersState] = useState<VideoFilters>(NO_FILTERS);
|
|
138
|
+
const [background, setBackground] = useState<BackgroundPicture | null>(null);
|
|
139
|
+
const [filterStatus, setFilterStatus] = useState<FilterStatus>({ loading: false, error: null });
|
|
140
|
+
const [processed, setProcessed] = useState<MediaStreamTrack | null>(null);
|
|
102
141
|
|
|
103
142
|
const tracksRef = useRef<Record<MediaKind, MediaStreamTrack | null>>({ audio: null, video: null });
|
|
104
143
|
const micEnabledRef = useRef(true);
|
|
105
144
|
const mountedRef = useRef(true);
|
|
145
|
+
const prefsRef = useRef<MediaPreferences | null>(null);
|
|
146
|
+
const filtersRef = useRef<VideoFilters>(NO_FILTERS);
|
|
147
|
+
const backgroundRef = useRef<BackgroundPicture | null>(null);
|
|
148
|
+
const processorRef = useRef<VideoFilterProcessor | null>(null);
|
|
149
|
+
|
|
150
|
+
const applyFilters = useCallback((next: VideoFilters) => {
|
|
151
|
+
filtersRef.current = next;
|
|
152
|
+
setFiltersState(next);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const applyBackground = useCallback((picture: BackgroundPicture) => {
|
|
156
|
+
backgroundRef.current = picture;
|
|
157
|
+
setBackground(picture);
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
/** What was remembered from last time - read once, the first time anything needs it, and applied to the state it
|
|
161
|
+
* covers. Never during render: storage doesn't exist on the server, and reading it would make the first client
|
|
162
|
+
* render differ from the server's. */
|
|
163
|
+
const ensurePrefs = useCallback((): MediaPreferences => {
|
|
164
|
+
if (prefsRef.current) {
|
|
165
|
+
return prefsRef.current;
|
|
166
|
+
}
|
|
167
|
+
const prefs = loadPreferences();
|
|
168
|
+
prefsRef.current = prefs;
|
|
169
|
+
if (prefs.micEnabled === false) {
|
|
170
|
+
micEnabledRef.current = false;
|
|
171
|
+
setMicEnabled(false);
|
|
172
|
+
}
|
|
173
|
+
if (prefs.filters) {
|
|
174
|
+
applyFilters(prefs.filters);
|
|
175
|
+
}
|
|
176
|
+
if (prefs.backgroundImage) {
|
|
177
|
+
loadPicture(prefs.backgroundImage).then(
|
|
178
|
+
(picture) => {
|
|
179
|
+
if (mountedRef.current) {
|
|
180
|
+
applyBackground(picture);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
() => undefined,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return prefs;
|
|
187
|
+
}, [applyBackground, applyFilters]);
|
|
188
|
+
|
|
189
|
+
/** Saves a change to the remembered settings. */
|
|
190
|
+
const remember = useCallback(
|
|
191
|
+
(patch: Partial<MediaPreferences>) => {
|
|
192
|
+
prefsRef.current = { ...ensurePrefs(), ...patch };
|
|
193
|
+
savePreferences(patch);
|
|
194
|
+
},
|
|
195
|
+
[ensurePrefs],
|
|
196
|
+
);
|
|
106
197
|
|
|
107
198
|
const refreshDevices = useCallback(async () => {
|
|
108
199
|
const list = await listDevices();
|
|
@@ -138,7 +229,10 @@ export function useLocalMedia(): LocalMedia {
|
|
|
138
229
|
tracksRef.current = { ...tracksRef.current, [kind]: track };
|
|
139
230
|
setTracks(tracksRef.current);
|
|
140
231
|
setStatus((prev) => ({ ...prev, [kind]: "live" }));
|
|
141
|
-
|
|
232
|
+
if (kind === "video") {
|
|
233
|
+
remember({ cameraEnabled: true });
|
|
234
|
+
}
|
|
235
|
+
}, [remember]);
|
|
142
236
|
|
|
143
237
|
const adoptStream = useCallback(
|
|
144
238
|
(stream: MediaStream) => {
|
|
@@ -149,11 +243,14 @@ export function useLocalMedia(): LocalMedia {
|
|
|
149
243
|
[adoptTrack],
|
|
150
244
|
);
|
|
151
245
|
|
|
152
|
-
/** Asks for one kind on its own - a specific device when `deviceId` is given. */
|
|
246
|
+
/** Asks for one kind on its own - a specific device when `deviceId` is given, else the one used last time. */
|
|
153
247
|
const acquire = useCallback(
|
|
154
248
|
async (kind: MediaKind, deviceId?: string): Promise<boolean> => {
|
|
155
249
|
const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
|
|
156
|
-
const
|
|
250
|
+
const prefs = ensurePrefs();
|
|
251
|
+
const constraint: MediaTrackConstraints = deviceId
|
|
252
|
+
? { ...base, deviceId: { exact: deviceId } }
|
|
253
|
+
: preferring(base, kind === "audio" ? prefs.microphoneId : prefs.cameraId);
|
|
157
254
|
const result = await requestUserMedia({ [kind]: constraint });
|
|
158
255
|
if (!result.ok) {
|
|
159
256
|
if (mountedRef.current) {
|
|
@@ -168,12 +265,24 @@ export function useLocalMedia(): LocalMedia {
|
|
|
168
265
|
}
|
|
169
266
|
return true;
|
|
170
267
|
},
|
|
171
|
-
[adoptStream],
|
|
268
|
+
[adoptStream, ensurePrefs],
|
|
172
269
|
);
|
|
173
270
|
|
|
174
271
|
/** Asks for the camera and the microphone in a single prompt. */
|
|
175
272
|
const acquireBoth = useCallback(async () => {
|
|
176
|
-
const
|
|
273
|
+
const prefs = ensurePrefs();
|
|
274
|
+
if (prefs.cameraEnabled === false) {
|
|
275
|
+
// The camera was off when the participant last left: join with it off, without switching it on first.
|
|
276
|
+
if (mountedRef.current) {
|
|
277
|
+
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
278
|
+
}
|
|
279
|
+
await acquire("audio");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const both = await requestUserMedia({
|
|
283
|
+
audio: preferring(AUDIO_CONSTRAINTS, prefs.microphoneId),
|
|
284
|
+
video: preferring(VIDEO_CONSTRAINTS, prefs.cameraId),
|
|
285
|
+
});
|
|
177
286
|
if (both.ok) {
|
|
178
287
|
adoptStream(both.value);
|
|
179
288
|
} else if (both.error.kind === "permission-denied") {
|
|
@@ -187,7 +296,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
187
296
|
await acquire("audio");
|
|
188
297
|
await acquire("video");
|
|
189
298
|
}
|
|
190
|
-
}, [acquire, adoptStream]);
|
|
299
|
+
}, [acquire, adoptStream, ensurePrefs]);
|
|
191
300
|
|
|
192
301
|
const requestAccess = useCallback(
|
|
193
302
|
async (kind?: MediaKind) => {
|
|
@@ -211,6 +320,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
211
320
|
if (!track) {
|
|
212
321
|
micEnabledRef.current = true;
|
|
213
322
|
setMicEnabled(true);
|
|
323
|
+
remember({ micEnabled: true });
|
|
214
324
|
await acquire("audio");
|
|
215
325
|
await refreshDevices();
|
|
216
326
|
return;
|
|
@@ -219,7 +329,8 @@ export function useLocalMedia(): LocalMedia {
|
|
|
219
329
|
micEnabledRef.current = next;
|
|
220
330
|
track.enabled = next;
|
|
221
331
|
setMicEnabled(next);
|
|
222
|
-
|
|
332
|
+
remember({ micEnabled: next });
|
|
333
|
+
}, [acquire, refreshDevices, remember]);
|
|
223
334
|
|
|
224
335
|
const toggleCamera = useCallback(async () => {
|
|
225
336
|
const track = tracksRef.current.video;
|
|
@@ -233,7 +344,8 @@ export function useLocalMedia(): LocalMedia {
|
|
|
233
344
|
tracksRef.current = { ...tracksRef.current, video: null };
|
|
234
345
|
setTracks(tracksRef.current);
|
|
235
346
|
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
236
|
-
|
|
347
|
+
remember({ cameraEnabled: false });
|
|
348
|
+
}, [acquire, refreshDevices, remember]);
|
|
237
349
|
|
|
238
350
|
const selectDevice = useCallback(
|
|
239
351
|
async (kind: MediaKind, deviceId: string) => {
|
|
@@ -247,10 +359,37 @@ export function useLocalMedia(): LocalMedia {
|
|
|
247
359
|
setTracks(tracksRef.current);
|
|
248
360
|
}
|
|
249
361
|
}
|
|
250
|
-
await acquire(kind, deviceId)
|
|
362
|
+
if (await acquire(kind, deviceId)) {
|
|
363
|
+
remember(kind === "audio" ? { microphoneId: deviceId } : { cameraId: deviceId });
|
|
364
|
+
}
|
|
251
365
|
await refreshDevices();
|
|
252
366
|
},
|
|
253
|
-
[acquire, refreshDevices],
|
|
367
|
+
[acquire, refreshDevices, remember],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const setFilters = useCallback(
|
|
371
|
+
(patch: Partial<VideoFilters>) => {
|
|
372
|
+
const next = { ...filtersRef.current, ...patch };
|
|
373
|
+
applyFilters(next);
|
|
374
|
+
remember({ filters: next });
|
|
375
|
+
},
|
|
376
|
+
[applyFilters, remember],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const chooseBackgroundImage = useCallback(
|
|
380
|
+
async (file: File): Promise<string | null> => {
|
|
381
|
+
const prepared = await prepareBackgroundImage(file);
|
|
382
|
+
if (!prepared.ok) {
|
|
383
|
+
return prepared.message;
|
|
384
|
+
}
|
|
385
|
+
if (mountedRef.current) {
|
|
386
|
+
applyBackground(prepared.picture);
|
|
387
|
+
remember({ backgroundImage: prepared.dataUrl });
|
|
388
|
+
setFilters({ background: "image" });
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
},
|
|
392
|
+
[applyBackground, remember, setFilters],
|
|
254
393
|
);
|
|
255
394
|
|
|
256
395
|
const release = useCallback(() => {
|
|
@@ -304,7 +443,47 @@ export function useLocalMedia(): LocalMedia {
|
|
|
304
443
|
};
|
|
305
444
|
}, [tracks.audio, micEnabled]);
|
|
306
445
|
|
|
307
|
-
|
|
446
|
+
// Puts the camera through the filters for as long as any is on - see this module's doc comment. A layout effect,
|
|
447
|
+
// so the filtered track is in place before the first frame is painted rather than after an unfiltered one.
|
|
448
|
+
const filtersOn = filtersActive(filters);
|
|
449
|
+
const source = tracks.video;
|
|
450
|
+
useLayoutEffect(() => {
|
|
451
|
+
if (!source || !filtersOn) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
let processor: VideoFilterProcessor;
|
|
455
|
+
try {
|
|
456
|
+
processor = new VideoFilterProcessor({
|
|
457
|
+
source,
|
|
458
|
+
filters: filtersRef.current,
|
|
459
|
+
backgroundImage: backgroundRef.current,
|
|
460
|
+
assetsUrl: effectsAssetsUrl,
|
|
461
|
+
onStatus: setFilterStatus,
|
|
462
|
+
});
|
|
463
|
+
} catch {
|
|
464
|
+
setFilterStatus({ loading: false, error: FILTERS_UNSUPPORTED });
|
|
465
|
+
// Turning the filters off (or the camera) clears the message again.
|
|
466
|
+
return () => setFilterStatus({ loading: false, error: null });
|
|
467
|
+
}
|
|
468
|
+
processorRef.current = processor;
|
|
469
|
+
setProcessed(processor.track);
|
|
470
|
+
return () => {
|
|
471
|
+
processor.stop();
|
|
472
|
+
processorRef.current = null;
|
|
473
|
+
setProcessed(null);
|
|
474
|
+
setFilterStatus({ loading: false, error: null });
|
|
475
|
+
};
|
|
476
|
+
}, [source, filtersOn, effectsAssetsUrl]);
|
|
477
|
+
|
|
478
|
+
// Passes a change of filters (or of the background picture) on to the running processor.
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
processorRef.current?.update(filters, background);
|
|
481
|
+
}, [filters, background]);
|
|
482
|
+
|
|
483
|
+
// While filters are on, the camera's own track is never what's sent: the filtered one is, or nothing.
|
|
484
|
+
const videoTrack = filtersOn ? (source ? processed : null) : source;
|
|
485
|
+
|
|
486
|
+
const videoStream = useMemo(() => (videoTrack ? new MediaStream([videoTrack]) : null), [videoTrack]);
|
|
308
487
|
|
|
309
488
|
const selectedDeviceIds = useMemo(
|
|
310
489
|
() => ({
|
|
@@ -319,7 +498,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
319
498
|
requesting,
|
|
320
499
|
error,
|
|
321
500
|
audioTrack: tracks.audio,
|
|
322
|
-
videoTrack
|
|
501
|
+
videoTrack,
|
|
323
502
|
videoStream,
|
|
324
503
|
micEnabled,
|
|
325
504
|
micOn: !!tracks.audio && micEnabled,
|
|
@@ -328,6 +507,11 @@ export function useLocalMedia(): LocalMedia {
|
|
|
328
507
|
devices,
|
|
329
508
|
selectedDeviceIds,
|
|
330
509
|
audioLevel,
|
|
510
|
+
filters,
|
|
511
|
+
filterStatus,
|
|
512
|
+
hasBackgroundImage: !!background,
|
|
513
|
+
setFilters,
|
|
514
|
+
chooseBackgroundImage,
|
|
331
515
|
requestAccess,
|
|
332
516
|
toggleMic,
|
|
333
517
|
toggleCamera,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { TimestampUnwrapper, type Frame } from "./frames.js";
|
|
6
|
+
import type { AudioContextLike, AudioDataLike, DecoderLike, RelayEnv } from "./relayEnv.js";
|
|
7
|
+
import { safeClose } from "./senderCommon.js";
|
|
8
|
+
|
|
9
|
+
export const PLAYBACK_SAMPLE_RATE = 48_000;
|
|
10
|
+
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
11
|
+
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
12
|
+
export const JITTER_BUFFER_SECONDS = 0.08;
|
|
13
|
+
/** If scheduled audio already runs this far past the clock the receiver has fallen behind (a burst, or the sender's
|
|
14
|
+
* clock running fast), so further packets are dropped until playback catches up - better a brief skip than latency
|
|
15
|
+
* that keeps growing. */
|
|
16
|
+
export const MAX_AHEAD_SECONDS = 0.4;
|
|
17
|
+
/** Skip a packet instead of queueing it when the decoder is this far behind. */
|
|
18
|
+
export const AUDIO_MAX_DECODE_QUEUE = 16;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
|
|
22
|
+
*
|
|
23
|
+
* Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
|
|
24
|
+
* `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
|
|
25
|
+
* join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
|
|
26
|
+
* Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
|
|
27
|
+
* the decoder is created lazily and discarded on any error, and replaced by the next packet.
|
|
28
|
+
*/
|
|
29
|
+
export class AudioPlayer {
|
|
30
|
+
private decoder: DecoderLike | undefined;
|
|
31
|
+
private nextTime = 0;
|
|
32
|
+
private readonly timestamps = new TimestampUnwrapper();
|
|
33
|
+
|
|
34
|
+
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly env: RelayEnv,
|
|
37
|
+
private readonly ctx: AudioContextLike,
|
|
38
|
+
private readonly destination: unknown,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
42
|
+
push(frame: Frame): void {
|
|
43
|
+
const decoder = this.decoder ?? this.createDecoder();
|
|
44
|
+
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
decoder.decode(
|
|
49
|
+
this.env.createEncodedAudioChunk({ type: "key", timestamp: this.timestamps.next(frame.timestampMs) * 1000, data: frame.data }),
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
this.discardDecoder();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
close(): void {
|
|
57
|
+
this.discardDecoder();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private discardDecoder(): void {
|
|
61
|
+
const decoder = this.decoder;
|
|
62
|
+
this.decoder = undefined;
|
|
63
|
+
safeClose(decoder);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private createDecoder(): DecoderLike | undefined {
|
|
67
|
+
let decoder: DecoderLike | undefined;
|
|
68
|
+
try {
|
|
69
|
+
decoder = this.env.createAudioDecoder({
|
|
70
|
+
output: (data) => this.play(data),
|
|
71
|
+
error: () => {
|
|
72
|
+
// Only the current decoder's failure matters; a stale one was already replaced.
|
|
73
|
+
if (this.decoder === decoder) {
|
|
74
|
+
this.discardDecoder();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
decoder.configure({ codec: "opus", sampleRate: PLAYBACK_SAMPLE_RATE, numberOfChannels: 1 });
|
|
79
|
+
} catch {
|
|
80
|
+
safeClose(decoder);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
this.decoder = decoder;
|
|
84
|
+
return decoder;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Schedules one decoded block. Always closes `data`. */
|
|
88
|
+
private play(data: AudioDataLike): void {
|
|
89
|
+
try {
|
|
90
|
+
const now = this.ctx.currentTime;
|
|
91
|
+
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
95
|
+
const pcm = new Float32Array(data.numberOfFrames);
|
|
96
|
+
data.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
|
|
97
|
+
buffer.copyToChannel(pcm, 0);
|
|
98
|
+
const source = this.ctx.createBufferSource();
|
|
99
|
+
source.buffer = buffer;
|
|
100
|
+
source.connect(this.destination);
|
|
101
|
+
source.onended = () => source.disconnect();
|
|
102
|
+
const start = Math.max(now + JITTER_BUFFER_SECONDS, this.nextTime);
|
|
103
|
+
source.start(start);
|
|
104
|
+
this.nextTime = start + buffer.duration;
|
|
105
|
+
} catch {
|
|
106
|
+
// A block the browser will not take (an odd sample rate, a closed context) is dropped like a lost packet.
|
|
107
|
+
} finally {
|
|
108
|
+
data.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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 type {
|
|
8
|
+
AudioContextLike,
|
|
9
|
+
AudioDataLike,
|
|
10
|
+
AudioNodeLike,
|
|
11
|
+
EncodedChunkLike,
|
|
12
|
+
EncoderLike,
|
|
13
|
+
GainLike,
|
|
14
|
+
ScriptProcessorLike,
|
|
15
|
+
} from "./relayEnv.js";
|
|
16
|
+
import { safeClose, type SenderDeps } from "./senderCommon.js";
|
|
17
|
+
|
|
18
|
+
export const AUDIO_SAMPLE_RATE = 48_000;
|
|
19
|
+
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
20
|
+
* thread is interrupted. */
|
|
21
|
+
export const AUDIO_PROCESS_FRAMES = 2048;
|
|
22
|
+
export const AUDIO_BITRATE = 24_000;
|
|
23
|
+
/** The Opus frame duration in microseconds (20 ms). */
|
|
24
|
+
export const AUDIO_FRAME_DURATION_US = 20_000;
|
|
25
|
+
/** Skip a block instead of queueing it when the encoder is this far behind. */
|
|
26
|
+
export const AUDIO_MAX_ENCODE_QUEUE = 8;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Captures one microphone track and publishes it as Opus.
|
|
30
|
+
*
|
|
31
|
+
* There is exactly one capture path, chosen for being testable and available everywhere the relay is: an
|
|
32
|
+
* `AudioContext` at 48 kHz, a `MediaStreamAudioSourceNode` on the track, and a `ScriptProcessorNode` that receives
|
|
33
|
+
* the raw PCM. (`ScriptProcessorNode` is deprecated in favour of `AudioWorklet`, but it needs no separate module
|
|
34
|
+
* file to load, which a plugin bundle has no good place for, and every browser still supports it.) The processor's
|
|
35
|
+
* output is routed through a zero-gain node to the destination, because browsers only run a processor that is
|
|
36
|
+
* connected to the destination, and the gain stops the microphone playing back locally.
|
|
37
|
+
*
|
|
38
|
+
* It encodes only while sending is active, a track is set and the socket can take the frames, and rebuilds the graph
|
|
39
|
+
* when the track is replaced. The encoder is created lazily and discarded on any error, so a coder that died is
|
|
40
|
+
* replaced at the next block rather than being repaired inside its own error callback.
|
|
41
|
+
*/
|
|
42
|
+
export class AudioSender {
|
|
43
|
+
private active = false;
|
|
44
|
+
private track: MediaStreamTrack | null = null;
|
|
45
|
+
private attachedTrack: MediaStreamTrack | null = null;
|
|
46
|
+
|
|
47
|
+
private ctx: AudioContextLike | undefined;
|
|
48
|
+
private source: AudioNodeLike | undefined;
|
|
49
|
+
private processor: ScriptProcessorLike | undefined;
|
|
50
|
+
private gain: GainLike | undefined;
|
|
51
|
+
private stopResume: (() => void) | undefined;
|
|
52
|
+
private encoder: EncoderLike<AudioDataLike> | undefined;
|
|
53
|
+
private baseMs = 0;
|
|
54
|
+
private samples = 0;
|
|
55
|
+
|
|
56
|
+
constructor(private readonly deps: SenderDeps) {}
|
|
57
|
+
|
|
58
|
+
setActive(active: boolean): void {
|
|
59
|
+
this.active = active;
|
|
60
|
+
this.sync();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setTrack(track: MediaStreamTrack | null): void {
|
|
64
|
+
this.track = track;
|
|
65
|
+
this.sync();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
|
|
69
|
+
* the track changed, untouched otherwise. */
|
|
70
|
+
private sync(): void {
|
|
71
|
+
const wanted = this.active ? this.track : null;
|
|
72
|
+
if (wanted === this.attachedTrack) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.detach();
|
|
76
|
+
if (wanted) {
|
|
77
|
+
this.attach(wanted);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private attach(track: MediaStreamTrack): void {
|
|
82
|
+
const env = this.deps.env;
|
|
83
|
+
this.attachedTrack = track;
|
|
84
|
+
this.baseMs = env.now();
|
|
85
|
+
this.samples = 0;
|
|
86
|
+
try {
|
|
87
|
+
const ctx = env.createAudioContext(AUDIO_SAMPLE_RATE);
|
|
88
|
+
this.ctx = ctx;
|
|
89
|
+
const source = ctx.createMediaStreamSource(env.createMediaStream([track]));
|
|
90
|
+
this.source = source;
|
|
91
|
+
const processor = ctx.createScriptProcessor(AUDIO_PROCESS_FRAMES, 1, 1);
|
|
92
|
+
this.processor = processor;
|
|
93
|
+
const gain = ctx.createGain();
|
|
94
|
+
gain.gain.value = 0;
|
|
95
|
+
this.gain = gain;
|
|
96
|
+
processor.onaudioprocess = (event) => this.process(event.inputBuffer.getChannelData(0));
|
|
97
|
+
source.connect(processor);
|
|
98
|
+
processor.connect(gain);
|
|
99
|
+
gain.connect(ctx.destination);
|
|
100
|
+
this.stopResume = resumeAudioContext(ctx, env.document);
|
|
101
|
+
} catch {
|
|
102
|
+
// No usable AudioContext (or a browser that refuses one): audio simply isn't relayed.
|
|
103
|
+
this.detach();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private detach(): void {
|
|
108
|
+
this.attachedTrack = null;
|
|
109
|
+
this.stopResume?.();
|
|
110
|
+
this.stopResume = undefined;
|
|
111
|
+
if (this.processor) {
|
|
112
|
+
this.processor.onaudioprocess = null;
|
|
113
|
+
}
|
|
114
|
+
this.source?.disconnect();
|
|
115
|
+
this.processor?.disconnect();
|
|
116
|
+
this.gain?.disconnect();
|
|
117
|
+
this.source = this.processor = this.gain = undefined;
|
|
118
|
+
const ctx = this.ctx;
|
|
119
|
+
this.ctx = undefined;
|
|
120
|
+
// `close()` rejects for a context that is already closed; nothing to do about it.
|
|
121
|
+
ctx?.close().catch(() => undefined);
|
|
122
|
+
this.discardEncoder();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private discardEncoder(): void {
|
|
126
|
+
const encoder = this.encoder;
|
|
127
|
+
this.encoder = undefined;
|
|
128
|
+
safeClose(encoder);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private process(input: Float32Array): void {
|
|
132
|
+
const samples = input.length;
|
|
133
|
+
const timestampUs = Math.round((this.samples * 1_000_000) / AUDIO_SAMPLE_RATE);
|
|
134
|
+
this.samples += samples;
|
|
135
|
+
if (!this.deps.canSend()) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const encoder = this.encoder ?? this.createEncoder();
|
|
139
|
+
if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let audio: AudioDataLike | undefined;
|
|
143
|
+
try {
|
|
144
|
+
// The browser reuses `input` for the next block, so the encoder gets its own copy.
|
|
145
|
+
audio = this.deps.env.createAudioData({
|
|
146
|
+
format: "f32-planar",
|
|
147
|
+
sampleRate: AUDIO_SAMPLE_RATE,
|
|
148
|
+
numberOfFrames: samples,
|
|
149
|
+
numberOfChannels: 1,
|
|
150
|
+
timestamp: timestampUs,
|
|
151
|
+
data: new Float32Array(input),
|
|
152
|
+
});
|
|
153
|
+
encoder.encode(audio);
|
|
154
|
+
} catch {
|
|
155
|
+
this.discardEncoder();
|
|
156
|
+
} finally {
|
|
157
|
+
audio?.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private createEncoder(): EncoderLike<AudioDataLike> | undefined {
|
|
162
|
+
let encoder: EncoderLike<AudioDataLike> | undefined;
|
|
163
|
+
try {
|
|
164
|
+
encoder = this.deps.env.createAudioEncoder({
|
|
165
|
+
output: (chunk) => this.onChunk(chunk),
|
|
166
|
+
error: () => {
|
|
167
|
+
// Only the current encoder's failure matters; a stale one was already replaced.
|
|
168
|
+
if (this.encoder === encoder) {
|
|
169
|
+
this.discardEncoder();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
encoder.configure({
|
|
174
|
+
codec: "opus",
|
|
175
|
+
sampleRate: AUDIO_SAMPLE_RATE,
|
|
176
|
+
numberOfChannels: 1,
|
|
177
|
+
bitrate: AUDIO_BITRATE,
|
|
178
|
+
opus: { frameDuration: AUDIO_FRAME_DURATION_US },
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
safeClose(encoder);
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
this.encoder = encoder;
|
|
185
|
+
return encoder;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private onChunk(chunk: EncodedChunkLike): void {
|
|
189
|
+
const bytes = new Uint8Array(chunk.byteLength);
|
|
190
|
+
chunk.copyTo(bytes);
|
|
191
|
+
// Every Opus packet decodes on its own, so each is flagged as a key frame.
|
|
192
|
+
this.deps.publish(KIND_AUDIO, true, bytes, this.baseMs + chunk.timestamp / 1000);
|
|
193
|
+
}
|
|
194
|
+
}
|