@rapidmx/meet-plugin 0.6.0 → 0.8.1

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 (131) hide show
  1. package/README.md +18 -5
  2. package/apps/meet/[token].tsx +11 -10
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +26 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetChrome.tsx +1 -1
  7. package/apps/meet/_MeetLobby.tsx +36 -4
  8. package/apps/meet/_ParticipantTile.tsx +73 -10
  9. package/apps/meet/_icons.tsx +4 -0
  10. package/apps/meet/_layout.tsx +2 -2
  11. package/apps/meet/_meetApi.ts +7 -1
  12. package/apps/meet/index.tsx +2 -2
  13. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
  14. package/apps/settings-video-conferencing/_layout.tsx +2 -2
  15. package/apps/settings-video-conferencing/index.tsx +3 -3
  16. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
  17. package/apps/shared/media/filters/accessories.ts +262 -0
  18. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  19. package/apps/shared/media/filters/filterTypes.ts +89 -0
  20. package/apps/shared/media/filters/mlModels.ts +174 -0
  21. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  22. package/apps/shared/media/mediaPreferences.ts +105 -0
  23. package/apps/shared/media/useLocalMedia.ts +198 -14
  24. package/apps/shared/push/GuestSignalingClient.ts +4 -4
  25. package/apps/shared/relay/AudioPlayer.ts +111 -0
  26. package/apps/shared/relay/AudioSender.ts +194 -0
  27. package/apps/shared/relay/RelayClient.ts +262 -0
  28. package/apps/shared/relay/RelayReceiver.ts +72 -0
  29. package/apps/shared/relay/RelaySender.ts +74 -0
  30. package/apps/shared/relay/RelayTransport.ts +188 -0
  31. package/apps/shared/relay/VideoPlayer.ts +110 -0
  32. package/apps/shared/relay/VideoSender.ts +216 -0
  33. package/apps/shared/relay/audioResume.ts +55 -0
  34. package/apps/shared/relay/frames.ts +275 -0
  35. package/apps/shared/relay/relayEnv.ts +230 -0
  36. package/apps/shared/relay/senderCommon.ts +30 -0
  37. package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
  38. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  39. package/apps/shared/webrtc/types.ts +32 -2
  40. package/dist/apps/meet/[token].d.ts +3 -3
  41. package/dist/apps/meet/[token].js +10 -10
  42. package/dist/apps/meet/_CallControls.d.ts +2 -1
  43. package/dist/apps/meet/_CallControls.js +6 -3
  44. package/dist/apps/meet/_CallView.d.ts +4 -1
  45. package/dist/apps/meet/_CallView.js +20 -3
  46. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  47. package/dist/apps/meet/_EffectsPanel.js +60 -0
  48. package/dist/apps/meet/_MeetChrome.d.ts +1 -1
  49. package/dist/apps/meet/_MeetLobby.d.ts +8 -0
  50. package/dist/apps/meet/_MeetLobby.js +15 -4
  51. package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
  52. package/dist/apps/meet/_ParticipantTile.js +27 -4
  53. package/dist/apps/meet/_icons.d.ts +1 -0
  54. package/dist/apps/meet/_icons.js +1 -0
  55. package/dist/apps/meet/_layout.d.ts +1 -1
  56. package/dist/apps/meet/_layout.js +1 -1
  57. package/dist/apps/meet/_meetApi.d.ts +6 -0
  58. package/dist/apps/meet/_meetApi.js +1 -1
  59. package/dist/apps/meet/index.js +2 -2
  60. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
  61. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
  62. package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
  63. package/dist/apps/settings-video-conferencing/_layout.js +1 -1
  64. package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
  65. package/dist/apps/settings-video-conferencing/index.js +3 -3
  66. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
  67. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
  68. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  69. package/dist/apps/shared/media/filters/accessories.js +211 -0
  70. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  71. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  72. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  73. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  74. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  75. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  76. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  77. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  78. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  79. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  80. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  81. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  82. package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
  83. package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
  84. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  85. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  86. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  87. package/dist/apps/shared/relay/AudioSender.js +172 -0
  88. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  89. package/dist/apps/shared/relay/RelayClient.js +236 -0
  90. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  91. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  92. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  93. package/dist/apps/shared/relay/RelaySender.js +54 -0
  94. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  95. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  96. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  97. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  98. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  99. package/dist/apps/shared/relay/VideoSender.js +204 -0
  100. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  101. package/dist/apps/shared/relay/audioResume.js +45 -0
  102. package/dist/apps/shared/relay/frames.d.ts +101 -0
  103. package/dist/apps/shared/relay/frames.js +221 -0
  104. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  105. package/dist/apps/shared/relay/relayEnv.js +69 -0
  106. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  107. package/dist/apps/shared/relay/senderCommon.js +10 -0
  108. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
  109. package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
  110. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  111. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  112. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  113. package/dist/lib/index.js +2 -0
  114. package/dist/lib/index.js.map +1 -1
  115. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  116. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  117. package/dist/lib/util/EffectsUtils.js +21 -0
  118. package/dist/lib/util/EffectsUtils.js.map +1 -0
  119. package/dist/lib/util/IceServerUtils.js +25 -4
  120. package/dist/lib/util/IceServerUtils.js.map +1 -1
  121. package/dist/lib/util/RedisRelayBus.js +241 -0
  122. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  123. package/dist/lib/util/RelayHub.js +405 -0
  124. package/dist/lib/util/RelayHub.js.map +1 -0
  125. package/dist/types/index.d.ts +2 -0
  126. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  127. package/dist/types/util/EffectsUtils.d.ts +10 -0
  128. package/dist/types/util/IceServerUtils.d.ts +12 -1
  129. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  130. package/dist/types/util/RelayHub.d.ts +188 -0
  131. package/package.json +25 -11
@@ -0,0 +1,275 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The media frame format carried inside a relay WebSocket message - see `RelayClient.ts` for the envelope the
7
+ * server wraps around it. The server treats the payload as opaque bytes, so everything in this module is a contract
8
+ * between two browsers running this same code, and it has to survive an unreliable transport: a WebSocket to the
9
+ * relay is ordered and lossless per connection, but the server drops binary messages under backpressure, so a
10
+ * receiver must cope with missing fragments without throwing or growing its memory without bound.
11
+ *
12
+ * ## Layout (all big-endian)
13
+ *
14
+ * A frame (one encoded audio packet or one encoded video picture) is split into fragments sized to the largest
15
+ * WebSocket message the server accepts (`fragmentPayloadSize()`): an older server allows only 16384 bytes (a larger
16
+ * message closes the socket) and a key frame easily runs to tens of kilobytes, while a current one announces a
17
+ * larger limit in its `ready` reply. Each fragment is
18
+ *
19
+ * `[kind u8][flags u8][seq u16][fragIndex u8][fragCount u8][timestampMs u32][fragment bytes]`
20
+ *
21
+ * `seq` is a per-kind frame counter that wraps at 65536, so a receiver can tell fragments of different frames
22
+ * apart and notice a gap. `timestampMs` is the sender's monotonic clock and also wraps (at about 49.7 days), which
23
+ * is why the receiver runs it through `TimestampUnwrapper` before handing it to a decoder.
24
+ */
25
+
26
+ export const KIND_AUDIO = 1;
27
+ export const KIND_VIDEO = 2;
28
+ export type FrameKind = typeof KIND_AUDIO | typeof KIND_VIDEO;
29
+
30
+ /** Bit 0 of the flags byte: this frame is independently decodable (always set for audio packets). */
31
+ export const FLAG_KEY_FRAME = 1;
32
+
33
+ export const HEADER_BYTES = 10;
34
+ /** The largest client-to-server message an older server accepts, and what a client assumes until the server's
35
+ * `ready` says otherwise. */
36
+ export const DEFAULT_MESSAGE_BYTES = 16_384;
37
+ /** The payload size used under the default limit. It is well below 16384 - 10 so an older server keeps behaving
38
+ * exactly as it always has, with headroom besides. */
39
+ export const DEFAULT_FRAGMENT_BYTES = 12_000;
40
+ /** The ceiling on the payload of one fragment, however large a message the server accepts: bigger messages gain
41
+ * nothing for a 480x360 stream and make one lost message cost more. */
42
+ export const MAX_FRAGMENT_BYTES = 60_000;
43
+ /** The most fragments a frame may have (`fragCount` is one byte). */
44
+ export const MAX_FRAGMENTS = 255;
45
+ /** The largest frame a receiver will buffer. A 480x360 VP8 key frame at the rates the sender uses is a small
46
+ * fraction of this; the cap exists so a hostile or buggy peer cannot make a receiver allocate without bound. */
47
+ export const MAX_FRAME_BYTES = 1024 * 1024;
48
+
49
+ const SEQ_MODULUS = 0x10000;
50
+ const HALF_SEQ = 0x8000;
51
+ /** How many consecutive fragments from an "older" sequence are ignored before the receiver decides the sender
52
+ * restarted its counter and resynchronises to it. */
53
+ const RESYNC_AFTER_STALE = 64;
54
+
55
+ /** One decoded fragment header plus a view of its bytes. */
56
+ export interface Fragment {
57
+ kind: FrameKind;
58
+ keyFrame: boolean;
59
+ seq: number;
60
+ fragIndex: number;
61
+ fragCount: number;
62
+ timestampMs: number;
63
+ data: Uint8Array;
64
+ }
65
+
66
+ /** A completely reassembled frame. */
67
+ export interface Frame {
68
+ kind: FrameKind;
69
+ keyFrame: boolean;
70
+ seq: number;
71
+ timestampMs: number;
72
+ data: Uint8Array;
73
+ }
74
+
75
+ /** The payload bytes per fragment for a server that accepts messages of `maxMessageBytes`. At or below the
76
+ * default limit that is `DEFAULT_FRAGMENT_BYTES` (or less, for a smaller limit); above it, everything the limit
77
+ * leaves after the header, capped at `MAX_FRAGMENT_BYTES`. A message is therefore never larger than the limit. */
78
+ export function fragmentPayloadSize(maxMessageBytes: number): number {
79
+ const room = Math.max(1, maxMessageBytes - HEADER_BYTES);
80
+ return maxMessageBytes <= DEFAULT_MESSAGE_BYTES ? Math.min(DEFAULT_FRAGMENT_BYTES, room) : Math.min(MAX_FRAGMENT_BYTES, room);
81
+ }
82
+
83
+ /** Splits `data` into wire-ready fragments (header included), each at most `maxMessageBytes` long (default: what an
84
+ * older server accepts). An empty frame still produces one (header-only) fragment. Returns an empty array for a frame
85
+ * the receiver would refuse anyway - larger than `MAX_FRAME_BYTES`, or needing more than `MAX_FRAGMENTS` fragments at
86
+ * this message size - so it is not worth sending. `seq` and `timestampMs` are reduced modulo their field widths, so a
87
+ * caller can pass a free-running counter or clock. */
88
+ export function fragmentFrame(
89
+ kind: FrameKind,
90
+ keyFrame: boolean,
91
+ seq: number,
92
+ timestampMs: number,
93
+ data: Uint8Array,
94
+ maxMessageBytes = DEFAULT_MESSAGE_BYTES,
95
+ ): Uint8Array[] {
96
+ const payload = fragmentPayloadSize(maxMessageBytes);
97
+ const fragCount = Math.max(1, Math.ceil(data.length / payload));
98
+ if (data.length > MAX_FRAME_BYTES || fragCount > MAX_FRAGMENTS) {
99
+ return [];
100
+ }
101
+ const fragments: Uint8Array[] = [];
102
+ for (let index = 0; index < fragCount; index++) {
103
+ const part = data.subarray(index * payload, (index + 1) * payload);
104
+ const out = new Uint8Array(HEADER_BYTES + part.length);
105
+ const view = new DataView(out.buffer);
106
+ view.setUint8(0, kind);
107
+ view.setUint8(1, keyFrame ? FLAG_KEY_FRAME : 0);
108
+ view.setUint16(2, seq % SEQ_MODULUS);
109
+ view.setUint8(4, index);
110
+ view.setUint8(5, fragCount);
111
+ view.setUint32(6, timestampMs >>> 0);
112
+ out.set(part, HEADER_BYTES);
113
+ fragments.push(out);
114
+ }
115
+ return fragments;
116
+ }
117
+
118
+ /** Parses one fragment, or returns `undefined` for anything that is not a well-formed one (too short, unknown
119
+ * kind, a zero fragment count, an index beyond the count). Never throws: the bytes come off the network. */
120
+ export function decodeFragment(bytes: Uint8Array): Fragment | undefined {
121
+ if (bytes.length < HEADER_BYTES) {
122
+ return undefined;
123
+ }
124
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
125
+ const kind = view.getUint8(0);
126
+ if (kind !== KIND_AUDIO && kind !== KIND_VIDEO) {
127
+ return undefined;
128
+ }
129
+ const fragIndex = view.getUint8(4);
130
+ const fragCount = view.getUint8(5);
131
+ if (fragCount === 0 || fragIndex >= fragCount) {
132
+ return undefined;
133
+ }
134
+ return {
135
+ kind,
136
+ keyFrame: (view.getUint8(1) & FLAG_KEY_FRAME) !== 0,
137
+ seq: view.getUint16(2),
138
+ fragIndex,
139
+ fragCount,
140
+ timestampMs: view.getUint32(6),
141
+ data: bytes.subarray(HEADER_BYTES),
142
+ };
143
+ }
144
+
145
+ /** True when `a` is a later sequence number than `b`, treating the 16 bit counter as circular. */
146
+ export function seqIsNewer(a: number, b: number): boolean {
147
+ const diff = (a - b + SEQ_MODULUS) % SEQ_MODULUS;
148
+ return diff !== 0 && diff < HALF_SEQ;
149
+ }
150
+
151
+ /** A frame being collected. */
152
+ interface Pending {
153
+ seq: number;
154
+ fragCount: number;
155
+ keyFrame: boolean;
156
+ timestampMs: number;
157
+ parts: (Uint8Array | undefined)[];
158
+ received: number;
159
+ bytes: number;
160
+ }
161
+
162
+ /**
163
+ * Collects the fragments of one sender's one media kind back into whole frames. It holds at most one frame in
164
+ * flight: the moment a fragment of a newer sequence arrives, an incomplete older frame is abandoned (its missing
165
+ * piece was dropped by the server, and waiting longer would only add latency). Everything is validated and nothing
166
+ * throws, so a garbage or hostile stream can at worst cost the frames it corrupts.
167
+ */
168
+ export class FrameReassembler {
169
+ private pending: Pending | undefined;
170
+ private lastSeq: number | undefined;
171
+ private stale = 0;
172
+
173
+ /** Feeds one fragment. Returns the frame if this fragment completed it. */
174
+ push(fragment: Fragment): Frame | undefined {
175
+ if (this.lastSeq === undefined || seqIsNewer(fragment.seq, this.lastSeq)) {
176
+ this.stale = 0;
177
+ this.lastSeq = fragment.seq;
178
+ this.pending = undefined;
179
+ } else if (fragment.seq !== this.lastSeq) {
180
+ // Older than what is already being (or has been) collected: a straggler, unless the sender restarted its
181
+ // counter and everything it sends now looks older, in which case follow it after a run of them.
182
+ this.stale += 1;
183
+ if (this.stale < RESYNC_AFTER_STALE) {
184
+ return undefined;
185
+ }
186
+ this.stale = 0;
187
+ this.lastSeq = fragment.seq;
188
+ this.pending = undefined;
189
+ } else if (!this.pending) {
190
+ // A further fragment of a frame that was already delivered, or that was abandoned as too big.
191
+ return undefined;
192
+ }
193
+
194
+ if (fragment.fragCount === 1 && !this.pending) {
195
+ // The common case (every audio packet, most delta frames): nothing to collect.
196
+ return this.finish(fragment, [fragment.data], fragment.data.length);
197
+ }
198
+ return this.collect(fragment);
199
+ }
200
+
201
+ /** Drops any frame in flight, e.g. after the consumer reset its decoder. */
202
+ reset(): void {
203
+ this.pending = undefined;
204
+ this.lastSeq = undefined;
205
+ this.stale = 0;
206
+ }
207
+
208
+ private collect(fragment: Fragment): Frame | undefined {
209
+ let pending = this.pending;
210
+ if (!pending) {
211
+ pending = {
212
+ seq: fragment.seq,
213
+ fragCount: fragment.fragCount,
214
+ keyFrame: fragment.keyFrame,
215
+ timestampMs: fragment.timestampMs,
216
+ parts: new Array<Uint8Array | undefined>(fragment.fragCount).fill(undefined),
217
+ received: 0,
218
+ bytes: 0,
219
+ };
220
+ this.pending = pending;
221
+ }
222
+ if (
223
+ fragment.fragCount !== pending.fragCount ||
224
+ fragment.keyFrame !== pending.keyFrame ||
225
+ fragment.timestampMs !== pending.timestampMs ||
226
+ pending.parts[fragment.fragIndex] !== undefined
227
+ ) {
228
+ // Inconsistent with the frame being built (or a duplicate): ignore this fragment, keep what is there.
229
+ return undefined;
230
+ }
231
+ pending.bytes += fragment.data.length;
232
+ if (pending.bytes > MAX_FRAME_BYTES) {
233
+ // Too big to be a frame this sender could legitimately have produced. Abandon it; `lastSeq` stays put so
234
+ // its remaining fragments are ignored rather than starting a fresh collection.
235
+ this.pending = undefined;
236
+ return undefined;
237
+ }
238
+ pending.parts[fragment.fragIndex] = fragment.data;
239
+ pending.received += 1;
240
+ if (pending.received < pending.fragCount) {
241
+ return undefined;
242
+ }
243
+ this.pending = undefined;
244
+ return this.finish(fragment, pending.parts as Uint8Array[], pending.bytes);
245
+ }
246
+
247
+ private finish(fragment: Fragment, parts: Uint8Array[], total: number): Frame {
248
+ const data = new Uint8Array(total);
249
+ let offset = 0;
250
+ for (const part of parts) {
251
+ data.set(part, offset);
252
+ offset += part.length;
253
+ }
254
+ return { kind: fragment.kind, keyFrame: fragment.keyFrame, seq: fragment.seq, timestampMs: fragment.timestampMs, data };
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Turns the wrapping 32 bit millisecond timestamps into a monotonic number a decoder can be given. The signed
260
+ * difference from the previous value is taken modulo 2^32, so it is correct across the wrap point and also for a
261
+ * frame that arrives slightly out of order (a small negative step).
262
+ */
263
+ export class TimestampUnwrapper {
264
+ private last: number | undefined;
265
+ private unwrapped = 0;
266
+
267
+ /** Returns the unwrapped time in milliseconds relative to the first timestamp seen. */
268
+ next(timestampMs: number): number {
269
+ if (this.last !== undefined) {
270
+ this.unwrapped += (timestampMs - this.last) | 0;
271
+ }
272
+ this.last = timestampMs;
273
+ return this.unwrapped;
274
+ }
275
+ }
@@ -0,0 +1,230 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Every browser API the relay tier touches, gathered behind one injectable object.
7
+ *
8
+ * WebCodecs, Web Audio and canvas capture are exactly the surfaces jsdom does not have, and the sender and
9
+ * receiver need a lot of them at once, so instead of patching a dozen globals in each test (and reading `globalThis`
10
+ * all over the implementation) the code takes a `RelayEnv` and `detectRelayEnv()` builds the real one. The interfaces
11
+ * here are deliberately the narrowest structural subset the relay actually calls - they are satisfied by the real
12
+ * browser objects, and small enough that a test's fake is a few lines.
13
+ */
14
+
15
+ /** The half of an encoded chunk (`EncodedAudioChunk`/`EncodedVideoChunk`) an encoder's output callback reads. */
16
+ export interface EncodedChunkLike {
17
+ readonly type: "key" | "delta";
18
+ /** Microseconds, as given to the encoder. */
19
+ readonly timestamp: number;
20
+ readonly byteLength: number;
21
+ copyTo(destination: Uint8Array): void;
22
+ }
23
+
24
+ export interface CoderInit<T> {
25
+ output: (output: T, metadata?: unknown) => void;
26
+ error: (error: Error) => void;
27
+ }
28
+
29
+ /** `AudioData`. */
30
+ export interface AudioDataLike {
31
+ readonly numberOfFrames: number;
32
+ readonly sampleRate: number;
33
+ copyTo(destination: Float32Array, options: { planeIndex: number; format?: string }): void;
34
+ close(): void;
35
+ }
36
+
37
+ export interface AudioDataInit {
38
+ format: "f32-planar";
39
+ sampleRate: number;
40
+ numberOfFrames: number;
41
+ numberOfChannels: number;
42
+ timestamp: number;
43
+ data: Float32Array;
44
+ }
45
+
46
+ /** `VideoFrame`. */
47
+ export interface VideoFrameLike {
48
+ readonly displayWidth: number;
49
+ readonly displayHeight: number;
50
+ close(): void;
51
+ }
52
+
53
+ export interface EncoderLike<TInput, TOptions = undefined> {
54
+ readonly encodeQueueSize: number;
55
+ configure(config: Record<string, unknown>): void;
56
+ encode(input: TInput, options?: TOptions): void;
57
+ close(): void;
58
+ }
59
+
60
+ export interface DecoderLike {
61
+ readonly decodeQueueSize: number;
62
+ configure(config: Record<string, unknown>): void;
63
+ decode(chunk: unknown): void;
64
+ close(): void;
65
+ }
66
+
67
+ export interface AudioNodeLike {
68
+ connect(destination: unknown): unknown;
69
+ disconnect(): void;
70
+ }
71
+
72
+ export interface ScriptProcessorLike extends AudioNodeLike {
73
+ onaudioprocess: ((event: { inputBuffer: { getChannelData(channel: number): Float32Array } }) => void) | null;
74
+ }
75
+
76
+ export interface GainLike extends AudioNodeLike {
77
+ gain: { value: number };
78
+ }
79
+
80
+ export interface AudioBufferLike {
81
+ copyToChannel(source: Float32Array, channel: number): void;
82
+ readonly duration: number;
83
+ }
84
+
85
+ export interface BufferSourceLike extends AudioNodeLike {
86
+ buffer: AudioBufferLike | null;
87
+ onended: (() => void) | null;
88
+ start(when?: number): void;
89
+ }
90
+
91
+ export interface AudioContextLike {
92
+ readonly state: string;
93
+ readonly currentTime: number;
94
+ readonly destination: unknown;
95
+ createMediaStreamSource(stream: MediaStream): AudioNodeLike;
96
+ createScriptProcessor(bufferSize: number, inputChannels: number, outputChannels: number): ScriptProcessorLike;
97
+ createGain(): GainLike;
98
+ createMediaStreamDestination(): { readonly stream: MediaStream };
99
+ createBuffer(channels: number, length: number, sampleRate: number): AudioBufferLike;
100
+ createBufferSource(): BufferSourceLike;
101
+ resume(): Promise<void>;
102
+ close(): Promise<void>;
103
+ }
104
+
105
+ export interface CanvasContextLike {
106
+ drawImage(source: unknown, dx: number, dy: number, dw?: number, dh?: number): void;
107
+ }
108
+
109
+ /** A hidden `<canvas>`. */
110
+ export interface CanvasLike {
111
+ width: number;
112
+ height: number;
113
+ getContext(type: "2d"): CanvasContextLike | null;
114
+ captureStream(frameRate: number): MediaStream;
115
+ }
116
+
117
+ /** A hidden `<video>`. */
118
+ export interface VideoElementLike {
119
+ muted: boolean;
120
+ autoplay: boolean;
121
+ playsInline: boolean;
122
+ srcObject: unknown;
123
+ readonly readyState: number;
124
+ readonly videoWidth: number;
125
+ readonly videoHeight: number;
126
+ play(): Promise<void> | undefined;
127
+ pause(): void;
128
+ }
129
+
130
+ /** The subset of `WebSocket` the relay client uses (the same seam shape `GuestSignalingClient`'s `PushSocket` has). */
131
+ export interface RelaySocket {
132
+ readyState: number;
133
+ /** Bytes queued by `send()` that the browser has not yet handed to the network - the backpressure signal. */
134
+ readonly bufferedAmount: number;
135
+ binaryType: string;
136
+ send(data: string | Uint8Array): void;
137
+ close(code?: number, reason?: string): void;
138
+ onopen: ((event: unknown) => void) | null;
139
+ onmessage: ((event: { data: unknown }) => void) | null;
140
+ onclose: ((event: unknown) => void) | null;
141
+ onerror: ((event: unknown) => void) | null;
142
+ }
143
+
144
+ /** What `resumeAudioContext()` needs from `document`. */
145
+ export interface EventTargetLike {
146
+ addEventListener(type: string, listener: () => void, options?: boolean): void;
147
+ removeEventListener(type: string, listener: () => void, options?: boolean): void;
148
+ }
149
+
150
+ export interface RelayEnv {
151
+ createSocket(url: string): RelaySocket;
152
+ /** `new AudioContext({ sampleRate })`. */
153
+ createAudioContext(sampleRate: number): AudioContextLike;
154
+ createMediaStream(tracks: MediaStreamTrack[]): MediaStream;
155
+ createVideoElement(): VideoElementLike;
156
+ createCanvas(): CanvasLike;
157
+ createAudioEncoder(init: CoderInit<EncodedChunkLike>): EncoderLike<AudioDataLike>;
158
+ createAudioDecoder(init: CoderInit<AudioDataLike>): DecoderLike;
159
+ createVideoEncoder(init: CoderInit<EncodedChunkLike>): EncoderLike<VideoFrameLike, { keyFrame: boolean }>;
160
+ createVideoDecoder(init: CoderInit<VideoFrameLike>): DecoderLike;
161
+ createAudioData(init: AudioDataInit): AudioDataLike;
162
+ /** `new VideoFrame(canvas, { timestamp })`. */
163
+ createVideoFrame(canvas: CanvasLike, timestampUs: number): VideoFrameLike;
164
+ createEncodedAudioChunk(init: { type: "key" | "delta"; timestamp: number; data: Uint8Array }): unknown;
165
+ createEncodedVideoChunk(init: { type: "key" | "delta"; timestamp: number; data: Uint8Array }): unknown;
166
+ document: EventTargetLike;
167
+ setInterval(callback: () => void, ms: number): unknown;
168
+ clearInterval(handle: unknown): void;
169
+ /** A monotonic clock in milliseconds (`performance.now()`). */
170
+ now(): number;
171
+ random(): number;
172
+ }
173
+
174
+ /** The names of the globals `detectRelayEnv()` requires, in one place so the check and the tests agree. */
175
+ export const REQUIRED_GLOBALS = [
176
+ "WebSocket",
177
+ "AudioEncoder",
178
+ "AudioDecoder",
179
+ "VideoEncoder",
180
+ "VideoDecoder",
181
+ "AudioData",
182
+ "VideoFrame",
183
+ "EncodedAudioChunk",
184
+ "EncodedVideoChunk",
185
+ "AudioContext",
186
+ "MediaStream",
187
+ "HTMLCanvasElement",
188
+ "document",
189
+ ] as const;
190
+
191
+ /**
192
+ * Builds the real environment from the browser's globals, or returns `undefined` when this browser lacks any
193
+ * piece of it (no WebSocket, no WebCodecs, no `AudioContext`, no `canvas.captureStream`, or not a browser at all).
194
+ * The relay codecs are fixed (Opus and VP8), so there is nothing useful to do with a partial environment - the
195
+ * mesh simply never falls back to the relay on such a browser.
196
+ */
197
+ export function detectRelayEnv(): RelayEnv | undefined {
198
+ const scope = globalThis as unknown as Record<string, unknown>;
199
+ for (const name of REQUIRED_GLOBALS) {
200
+ if (typeof scope[name] === "undefined") {
201
+ return undefined;
202
+ }
203
+ }
204
+ if (typeof HTMLCanvasElement.prototype.captureStream !== "function") {
205
+ return undefined;
206
+ }
207
+ // Each factory resolves its constructor when called (not once here) so the environment follows whatever the page
208
+ // has at that moment, and so a test can stub a constructor after detection.
209
+ const construct = (name: string, ...args: unknown[]): never => new (scope[name] as new (...a: unknown[]) => never)(...args);
210
+ return {
211
+ createSocket: (url) => construct("WebSocket", url),
212
+ createAudioContext: (sampleRate) => construct("AudioContext", { sampleRate }),
213
+ createMediaStream: (tracks) => construct("MediaStream", tracks),
214
+ createVideoElement: () => document.createElement("video"),
215
+ createCanvas: () => document.createElement("canvas"),
216
+ createAudioEncoder: (init) => construct("AudioEncoder", init),
217
+ createAudioDecoder: (init) => construct("AudioDecoder", init),
218
+ createVideoEncoder: (init) => construct("VideoEncoder", init),
219
+ createVideoDecoder: (init) => construct("VideoDecoder", init),
220
+ createAudioData: (init) => construct("AudioData", init),
221
+ createVideoFrame: (canvas, timestamp) => construct("VideoFrame", canvas, { timestamp }),
222
+ createEncodedAudioChunk: (init) => construct("EncodedAudioChunk", init),
223
+ createEncodedVideoChunk: (init) => construct("EncodedVideoChunk", init),
224
+ document,
225
+ setInterval: (callback, ms) => globalThis.setInterval(callback, ms),
226
+ clearInterval: (handle) => globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
227
+ now: () => performance.now(),
228
+ random: () => Math.random(),
229
+ };
230
+ }
@@ -0,0 +1,30 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { FrameKind } from "./frames.js";
6
+ import type { RelayEnv } from "./relayEnv.js";
7
+
8
+ /** Hands one encoded frame to the transport. `timestampMs` is on the `env.now()` clock. Returns whether every
9
+ * fragment of it was accepted by the socket - `false` means it was dropped (not ready, or backed up), which for
10
+ * video means the receiver will need a fresh key frame. */
11
+ export type PublishFrame = (kind: FrameKind, keyFrame: boolean, data: Uint8Array, timestampMs: number) => boolean;
12
+
13
+ /** What `AudioSender` and `VideoSender` are given. */
14
+ export interface SenderDeps {
15
+ env: RelayEnv;
16
+ publish: PublishFrame;
17
+ /** Whether a frame encoded now could actually be sent. Encoding while the socket is down or still handshaking
18
+ * would burn CPU on frames that are then dropped, so the senders idle instead. */
19
+ canSend: () => boolean;
20
+ }
21
+
22
+ /** Closes an encoder/decoder, ignoring the `InvalidStateError` a browser throws for one that is already closed
23
+ * (which is what a coder that reported an error is). */
24
+ export function safeClose(coder: { close(): void } | undefined): void {
25
+ try {
26
+ coder?.close();
27
+ } catch {
28
+ // Already closed.
29
+ }
30
+ }