@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.
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 +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
- export function useLocalMedia() {
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 constraint = deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
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 both = await requestUserMedia({ audio: AUDIO_CONSTRAINTS, video: VIDEO_CONSTRAINTS });
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
- }, [acquire, refreshDevices]);
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
- }, [acquire, refreshDevices]);
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
- const videoStream = useMemo(() => (tracks.video ? new MediaStream([tracks.video]) : null), [tracks.video]);
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: tracks.video,
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
+ }