@rapidmx/meet-plugin 0.6.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.
Files changed (110) hide show
  1. package/README.md +15 -2
  2. package/apps/meet/[token].tsx +3 -2
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +23 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetLobby.tsx +22 -0
  7. package/apps/meet/_ParticipantTile.tsx +62 -9
  8. package/apps/meet/_icons.tsx +4 -0
  9. package/apps/meet/_meetApi.ts +6 -0
  10. package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
  11. package/apps/shared/media/filters/accessories.ts +262 -0
  12. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  13. package/apps/shared/media/filters/filterTypes.ts +89 -0
  14. package/apps/shared/media/filters/mlModels.ts +174 -0
  15. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  16. package/apps/shared/media/mediaPreferences.ts +105 -0
  17. package/apps/shared/media/useLocalMedia.ts +198 -14
  18. package/apps/shared/relay/AudioPlayer.ts +111 -0
  19. package/apps/shared/relay/AudioSender.ts +194 -0
  20. package/apps/shared/relay/RelayClient.ts +262 -0
  21. package/apps/shared/relay/RelayReceiver.ts +72 -0
  22. package/apps/shared/relay/RelaySender.ts +74 -0
  23. package/apps/shared/relay/RelayTransport.ts +188 -0
  24. package/apps/shared/relay/VideoPlayer.ts +110 -0
  25. package/apps/shared/relay/VideoSender.ts +216 -0
  26. package/apps/shared/relay/audioResume.ts +55 -0
  27. package/apps/shared/relay/frames.ts +275 -0
  28. package/apps/shared/relay/relayEnv.ts +230 -0
  29. package/apps/shared/relay/senderCommon.ts +30 -0
  30. package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
  31. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  32. package/apps/shared/webrtc/types.ts +32 -2
  33. package/dist/apps/meet/[token].js +3 -3
  34. package/dist/apps/meet/_CallControls.d.ts +2 -1
  35. package/dist/apps/meet/_CallControls.js +6 -3
  36. package/dist/apps/meet/_CallView.d.ts +4 -1
  37. package/dist/apps/meet/_CallView.js +17 -3
  38. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  39. package/dist/apps/meet/_EffectsPanel.js +60 -0
  40. package/dist/apps/meet/_MeetLobby.d.ts +4 -0
  41. package/dist/apps/meet/_MeetLobby.js +8 -1
  42. package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
  43. package/dist/apps/meet/_ParticipantTile.js +20 -3
  44. package/dist/apps/meet/_icons.d.ts +1 -0
  45. package/dist/apps/meet/_icons.js +1 -0
  46. package/dist/apps/meet/_meetApi.d.ts +6 -0
  47. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
  48. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
  49. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  50. package/dist/apps/shared/media/filters/accessories.js +211 -0
  51. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  52. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  53. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  54. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  55. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  56. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  57. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  58. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  59. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  60. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  61. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  62. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  63. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  64. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  65. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  66. package/dist/apps/shared/relay/AudioSender.js +172 -0
  67. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  68. package/dist/apps/shared/relay/RelayClient.js +236 -0
  69. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  70. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  71. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  72. package/dist/apps/shared/relay/RelaySender.js +54 -0
  73. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  74. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  75. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  76. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  77. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  78. package/dist/apps/shared/relay/VideoSender.js +204 -0
  79. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  80. package/dist/apps/shared/relay/audioResume.js +45 -0
  81. package/dist/apps/shared/relay/frames.d.ts +101 -0
  82. package/dist/apps/shared/relay/frames.js +221 -0
  83. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  84. package/dist/apps/shared/relay/relayEnv.js +69 -0
  85. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  86. package/dist/apps/shared/relay/senderCommon.js +10 -0
  87. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
  88. package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
  89. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  90. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  91. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  92. package/dist/lib/index.js +2 -0
  93. package/dist/lib/index.js.map +1 -1
  94. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  95. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  96. package/dist/lib/util/EffectsUtils.js +21 -0
  97. package/dist/lib/util/EffectsUtils.js.map +1 -0
  98. package/dist/lib/util/IceServerUtils.js +25 -4
  99. package/dist/lib/util/IceServerUtils.js.map +1 -1
  100. package/dist/lib/util/RedisRelayBus.js +241 -0
  101. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  102. package/dist/lib/util/RelayHub.js +405 -0
  103. package/dist/lib/util/RelayHub.js.map +1 -0
  104. package/dist/types/index.d.ts +2 -0
  105. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  106. package/dist/types/util/EffectsUtils.d.ts +10 -0
  107. package/dist/types/util/IceServerUtils.d.ts +12 -1
  108. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  109. package/dist/types/util/RelayHub.d.ts +188 -0
  110. package/package.json +23 -6
@@ -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 function useLocalMedia(): LocalMedia {
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 constraint: MediaTrackConstraints = deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
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 both = await requestUserMedia({ audio: AUDIO_CONSTRAINTS, video: VIDEO_CONSTRAINTS });
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
- }, [acquire, refreshDevices]);
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
- }, [acquire, refreshDevices]);
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
- const videoStream = useMemo(() => (tracks.video ? new MediaStream([tracks.video]) : null), [tracks.video]);
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: tracks.video,
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
+ }