@displayxr/inline3d 1.47.6 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/call.d.ts +98 -8
- package/camera.d.ts +34 -5
- package/dist/call.js +8 -6
- package/docs/woven-canvas-rules.md +25 -13
- package/js/call/element.js +7 -3
- package/js/call/options.js +20 -3
- package/js/call/transport.js +11 -1
- package/js/call/ui.js +8 -1
- package/js/call/wire.js +163 -6
- package/js/camera/capture.js +107 -20
- package/js/camera/conv-meter.js +152 -0
- package/js/inline3d-call.js +392 -71
- package/js/inline3d-camera.js +29 -6
- package/js/inline3d-player.js +8 -4
- package/package.json +8 -1
- package/signaling/README.md +289 -0
- package/signaling/dev-server.mjs +320 -0
- package/signaling/gate.mjs +112 -0
- package/signaling/keys.mjs +123 -0
- package/signaling/limits.mjs +221 -0
- package/signaling/room.mjs +322 -0
- package/signaling/turn-budget.mjs +254 -0
|
@@ -196,14 +196,23 @@ tile while its content keeps running (for a video: sound, no picture). To put bl
|
|
|
196
196
|
tile, give it to an element **behind** the canvas. A cover you mean to hide the tile with (rule 5,
|
|
197
197
|
rule 11) is the one deliberate exception, and it comes off on a signal.
|
|
198
198
|
|
|
199
|
-
### 10.
|
|
200
|
-
|
|
201
|
-
Rule 7 covers the canvas and its ancestors. This one covers the **siblings on
|
|
202
|
-
control bar, a caption, a badge, a menu. `backdrop-filter` is defined as a function
|
|
203
|
-
behind the element, and behind it is the interlaced weave.
|
|
204
|
-
and smears them, and the 3D image is
|
|
205
|
-
|
|
206
|
-
|
|
199
|
+
### 10. `backdrop-filter` over the weave: only on DisplayXR Browser 0.1.16+, never over a whole tile
|
|
200
|
+
|
|
201
|
+
Rule 7 covers the canvas and its ancestors, and is unchanged. This one covers the **siblings on
|
|
202
|
+
top of it**: a control bar, a caption, a badge, a menu. `backdrop-filter` is defined as a function
|
|
203
|
+
of what is behind the element, and behind it is the interlaced weave. On browsers before
|
|
204
|
+
DisplayXR Browser 0.1.16 the blur reads the interlaced pixels and smears them, and the 3D image is
|
|
205
|
+
disturbed across the element's **whole box**, not only where it is opaque (seen on the panel, 24
|
|
206
|
+
September). From 0.1.16 the compositor draws the tile **mono inside each frost rect**
|
|
207
|
+
(displayxr-browser#120), so frosted chrome over a tile looks right: the frosted region is flat,
|
|
208
|
+
the rest of the tile stays 3D. [`samples/composition`](../samples/composition/) (its sticky
|
|
209
|
+
header) is the validated case.
|
|
210
|
+
|
|
211
|
+
- Frost a **partial** region only (a bar, a pill). Never frost a whole tile while it is visible:
|
|
212
|
+
the whole tile goes mono under it.
|
|
213
|
+
- If the page must also run on older browsers, use a plain translucent tint
|
|
214
|
+
(`background: rgba(0, 0, 0, 0.55)`) there. The SDK's own chrome (player, call) keeps its
|
|
215
|
+
near-solid tint so one build works on every generation.
|
|
207
216
|
|
|
208
217
|
### 11. Treat a resize as a fresh-canvas moment
|
|
209
218
|
|
|
@@ -331,11 +340,14 @@ for exactly that reason). It cannot check the join. On a DisplayXR display:
|
|
|
331
340
|
Found on hardware in a production photo app after the rules above were written. Each one looked
|
|
332
341
|
like a runtime or browser bug on the panel and turned out to be the page.
|
|
333
342
|
|
|
334
|
-
-
|
|
335
|
-
and its ancestors. A frosted bar or button placed *over* a woven tile is a
|
|
336
|
-
on
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
- **`backdrop-filter` on chrome that sits over the weave (superseded for 0.1.16+).** Rule 7 covers
|
|
344
|
+
effects on the canvas and its ancestors. A frosted bar or button placed *over* a woven tile is a
|
|
345
|
+
different element; on browsers before DisplayXR Browser 0.1.16 it visibly disturbed the 3D image
|
|
346
|
+
across that element's whole rect on a lenticular laptop panel, and the fix was a plain
|
|
347
|
+
translucent tint. Since 0.1.16 the compositor draws the tile mono inside each frost rect
|
|
348
|
+
(displayxr-browser#120), so partial frosted chrome is fine there (`samples/composition` is the
|
|
349
|
+
validated case); still never frost a whole visible tile ([rule 10](#10-backdrop-filter-over-the-weave-only-on-displayxr-browser-0116-never-over-a-whole-tile)).
|
|
350
|
+
Judge it on the panel, not on a 2D monitor.
|
|
339
351
|
- **Nothing opaque above the woven canvas.** Stacking order still applies: an element with an
|
|
340
352
|
opaque background painted *above* the canvas hides it, and on a draw-order browser that is
|
|
341
353
|
exactly what you get. The classic case is a full-bleed video plane under a page `<main>` with
|
package/js/call/element.js
CHANGED
|
@@ -37,7 +37,7 @@ import { mountCall, dxrSignaling } from '../inline3d-call.js';
|
|
|
37
37
|
|
|
38
38
|
/** Every call event is re-dispatched as `dxr-call:<type>`; `dxr-call:ready` carries the handle. */
|
|
39
39
|
export const CALL_EVENT_PREFIX = 'dxr-call:';
|
|
40
|
-
const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', 'display', 'speaker', 'quality', 'error', 'warning'];
|
|
40
|
+
const CALL_EVENTS = ['joined', 'left', 'peer', 'peerleft', 'state', 'display', 'speaker', 'quality', 'error', 'warning', 'name', 'message', 'signal', 'weave', 'speaking'];
|
|
41
41
|
const TAG = '[inline3d/call] <dxr-call>';
|
|
42
42
|
|
|
43
43
|
/** A boolean attribute: present is true, unless its value spells "false"/"0"/"off"/"no". */
|
|
@@ -71,8 +71,10 @@ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? u
|
|
|
71
71
|
* | `no-audio` | `audio: false` |
|
|
72
72
|
* | `no-self-view` | `selfView: false` |
|
|
73
73
|
* | `no-auto-converge` | `autoConverge: false` |
|
|
74
|
+
* | `no-send-convergence`| `sendConvergence: false` |
|
|
74
75
|
* | `cursor="depth"` | `cursor: 'depth'` |
|
|
75
|
-
* | `tile-aspect` | `theme.tileAspect` (a number,
|
|
76
|
+
* | `tile-aspect` | `theme.tileAspect` (a number, `w/h`, or `fill`) |
|
|
77
|
+
* | `name` | `name` (your display name) |
|
|
76
78
|
* | `invite-base` | `invite.base` |
|
|
77
79
|
* | `browser-url` | `landing.browserUrl` |
|
|
78
80
|
* | `debug` | `debug: true` |
|
|
@@ -102,9 +104,11 @@ export function attrsToOpts(source) {
|
|
|
102
104
|
if (isOn(get('no-audio'))) o.audio = false;
|
|
103
105
|
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
104
106
|
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
107
|
+
if (isOn(get('no-send-convergence'))) o.sendConvergence = false;
|
|
105
108
|
if (str(get('cursor')) === 'depth') o.cursor = 'depth';
|
|
106
109
|
// The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
|
|
107
|
-
|
|
110
|
+
put('name', str(get('name')));
|
|
111
|
+
const theme = { accent: str(get('accent')), tileAspect: str(get('tile-aspect')) === 'fill' ? 'fill' : num(get('tile-aspect')) };
|
|
108
112
|
if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
|
|
109
113
|
if (str(get('invite-base'))) o.invite = { base: str(get('invite-base')) };
|
|
110
114
|
if (str(get('browser-url'))) o.landing = { browserUrl: str(get('browser-url')) };
|
package/js/call/options.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 1.29 spellings accepted for one release with a warning. Pure (no DOM, no network) — see
|
|
4
4
|
// test/call.test.mjs. Internal: importable by path, not part of the `./call` entry.
|
|
5
5
|
|
|
6
|
-
import { isValidRoomId, parseInviteLink, DEFAULT_MAX_PEERS } from './wire.js';
|
|
6
|
+
import { isValidRoomId, parseInviteLink, DEFAULT_MAX_PEERS, sanitizeName } from './wire.js';
|
|
7
7
|
import { clampMaxPeers } from './transport.js';
|
|
8
8
|
import { normalizeMono3D } from './lift.js';
|
|
9
9
|
import { dxrSignaling } from './signaling.js';
|
|
@@ -53,7 +53,10 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
53
53
|
const ui = opts.ui === undefined ? true : opts.ui === 'tiles' ? 'tiles' : !!opts.ui;
|
|
54
54
|
const chrome = ui === true;
|
|
55
55
|
const tileAspect = theme.tileAspect !== undefined ? theme.tileAspect : opts.tileAspect;
|
|
56
|
-
|
|
56
|
+
// 'fill': each tile's per-eye aspect is its own stage box (the page sizes the tiles).
|
|
57
|
+
const aspect = tileAspect === 'fill' ? 'fill' : typeof tileAspect === 'number' && tileAspect > 0.3 && tileAspect < 4 ? tileAspect : 16 / 9;
|
|
58
|
+
const layout = opts.layout === 'speaker' ? 'speaker' : opts.layout === 'none' ? 'none' : 'grid';
|
|
59
|
+
const tileCover = opts.tileCover === 'auto' || opts.tileCover === 'off' ? opts.tileCover : layout === 'none' ? 'auto' : 'off';
|
|
57
60
|
let room = opts.room === undefined || opts.room === null ? 'auto' : opts.room;
|
|
58
61
|
if (room !== 'auto' && !isValidRoomId(room)) {
|
|
59
62
|
const fromLink = parseInviteLink(room);
|
|
@@ -90,6 +93,10 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
90
93
|
// peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
|
|
91
94
|
// offset on top. Off = the pair as sent (plus any `hint`).
|
|
92
95
|
autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
|
|
96
|
+
// Sender side of auto-convergence (docs/call.md): with a stereo camera, measure that disparity
|
|
97
|
+
// on this side's own raw frames and send it to every peer (`conv` on the data channel), so
|
|
98
|
+
// receivers apply it instead of measuring the compressed frames. Off = receivers measure.
|
|
99
|
+
sendConvergence: opts.sendConvergence === undefined ? true : !!opts.sendConvergence,
|
|
93
100
|
// `cursor: 'depth'` (runtime ADR-046 on a pair): the arrow comes in front of a person who pops
|
|
94
101
|
// out of the screen under it, measured off each stereo tile's two halves; else the plain OS
|
|
95
102
|
// arrow on the plane. Off by default (zero cost).
|
|
@@ -102,7 +109,15 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
102
109
|
// Concurrent lifted tiles (`liftOptions.max`). Default = maxPeers (4): every mono peer can be lifted.
|
|
103
110
|
maxLifted: Number.isFinite(+maxLiftedRaw) && maxLiftedRaw !== null && maxLiftedRaw !== undefined ? Math.max(0, Math.min(DEFAULT_MAX_PEERS, Math.floor(+maxLiftedRaw))) : DEFAULT_MAX_PEERS,
|
|
104
111
|
// 'grid' | 'speaker' | 'none' (the page lays the tiles out; handle.tile(id) finds them).
|
|
105
|
-
layout
|
|
112
|
+
layout,
|
|
113
|
+
// The core's rect cover on every woven tile (and a stereo self view): 'auto' covers a tile
|
|
114
|
+
// across a resize or move until it is woven again. Default 'auto' with layout: 'none' (the
|
|
115
|
+
// page moves and resizes the tiles), 'off' otherwise (the module's own layout).
|
|
116
|
+
tileCover,
|
|
117
|
+
// This side's display name (hello.name), sanitised; null = none.
|
|
118
|
+
name: sanitizeName(opts.name),
|
|
119
|
+
// Devices no camera probe opens (openCamera's `exclude`); false = not even the built-in list.
|
|
120
|
+
cameraExclude: opts.cameraExclude === false ? false : Array.isArray(opts.cameraExclude) ? [...opts.cameraExclude] : undefined,
|
|
106
121
|
// true: full chrome | 'tiles': badges + plates only | false: nothing. `chrome` = the full set.
|
|
107
122
|
ui,
|
|
108
123
|
chrome,
|
|
@@ -124,6 +139,8 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
124
139
|
log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
|
|
125
140
|
// Internal (tests): replaces the lift module importer behind `mono3D: 'auto'`.
|
|
126
141
|
_liftImporter: typeof opts._liftImporter === 'function' ? opts._liftImporter : undefined,
|
|
142
|
+
// Internal (tests): replaces the sender's frame grab (camera/conv-meter.js grabLumaFrame).
|
|
143
|
+
_convGrab: typeof opts._convGrab === 'function' ? opts._convGrab : undefined,
|
|
127
144
|
};
|
|
128
145
|
}
|
|
129
146
|
|
package/js/call/transport.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// transport.start({ room, localStream, onPeer, onPeerLeft, onStream, onMessage, onPeerState })
|
|
8
8
|
// transport.send(peerId, msg) / broadcast(msg)
|
|
9
|
-
// transport.
|
|
9
|
+
// transport.replaceTrack(kind, track, stream) / setSendFormat(format)
|
|
10
10
|
// transport.peerConnection(peerId) // for getStats (null on an SFU)
|
|
11
11
|
// transport.stop()
|
|
12
12
|
//
|
|
@@ -501,6 +501,16 @@ export class MeshTransport {
|
|
|
501
501
|
for (const pid of this.peers.keys()) this.send(pid, msg);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/** An already-serialised message (the app channel: sized before it is sent). */
|
|
505
|
+
sendRaw(pid, json) {
|
|
506
|
+
const rec = this.peers.get(pid);
|
|
507
|
+
if (rec && rec.dc && rec.dc.readyState === 'open') {
|
|
508
|
+
rec.dc.send(json);
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
504
514
|
peerConnection(pid) {
|
|
505
515
|
const rec = this.peers.get(pid);
|
|
506
516
|
return rec ? rec.pc : null;
|
package/js/call/ui.js
CHANGED
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
// pill, never a plate congruent with the tile (it would match the canvas's own quad on a
|
|
9
9
|
// legacy browser and take the tile out of the weave). The bottom bar and self view live in a
|
|
10
10
|
// footer BELOW the grid, over no tile at all.
|
|
11
|
-
// 2. NO `backdrop-filter
|
|
11
|
+
// 2. NO `backdrop-filter` in the SDK's own chrome. The near-solid `--dxr-shell`
|
|
12
|
+
// (rgba(16,17,22,.92)) is its choice, and it stays. A frosted element OVER a tile is no longer
|
|
13
|
+
// a defect on DisplayXR Browser 0.1.16+ (browser#120: the compositor draws the tile mono
|
|
14
|
+
// inside each frost rect; samples/composition is the validated case) — a page's own chrome may
|
|
15
|
+
// use it. Never frost a WHOLE visible tile, and rule 3 (effects on the canvas or an ancestor)
|
|
16
|
+
// is unchanged.
|
|
12
17
|
// 3. Promotion is `will-change: transform`. No CSS filter / opacity / border-radius / shadow on a
|
|
13
18
|
// woven canvas OR ANY ANCESTOR of one — the tile, the grid and the host stay visually bare.
|
|
14
19
|
// A speaking highlight is a separate bar UNDER the stage, not an outline around it.
|
|
@@ -147,6 +152,8 @@ const CSS = `
|
|
|
147
152
|
aside (display:contents) so each [data-dxr-peer] tile is laid out by the PAGE's own CSS on the
|
|
148
153
|
host. A tile is never moved; it is styled in place (woven-canvas rule 2). */
|
|
149
154
|
.dxr-call-grid[data-layout="none"]{display:contents;}
|
|
155
|
+
/* …and so do the footer and the self slot, so the page places the self tile ([data-dxr-peer="self"]). */
|
|
156
|
+
.dxr-call-host--none .dxr-call-foot,.dxr-call-host--none .dxr-call-self{display:contents;}
|
|
150
157
|
.dxr-call-tile{position:relative;min-width:0;}
|
|
151
158
|
.dxr-call-stage{position:relative;background:#000;}
|
|
152
159
|
.dxr-call-stage>canvas{display:block;width:100%;height:100%;}
|
package/js/call/wire.js
CHANGED
|
@@ -26,6 +26,31 @@ export const MESH_HARD_CAP = 4;
|
|
|
26
26
|
/** `hint` messages are rate-limited to this many per second, on the sender AND the receiver. */
|
|
27
27
|
export const HINT_MAX_HZ = 5;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `conv` (sender-measured auto-convergence, docs/call.md): a sender broadcasts at most this many
|
|
31
|
+
* per second; a receiver accepts at most `CONV_RECV_MAX_HZ`. A sender whose value has not changed
|
|
32
|
+
* still sends one every `CONV_HEARTBEAT_MS`; a receiver that has heard nothing for `CONV_FRESH_MS`
|
|
33
|
+
* goes back to measuring the received frames itself.
|
|
34
|
+
*/
|
|
35
|
+
export const CONV_MAX_HZ = 5;
|
|
36
|
+
export const CONV_RECV_MAX_HZ = 10;
|
|
37
|
+
export const CONV_HEARTBEAT_MS = 400;
|
|
38
|
+
export const CONV_FRESH_MS = 1200;
|
|
39
|
+
/** `conv.d` is a fraction of the per-eye width; anything beyond ±this is not a face's disparity. */
|
|
40
|
+
export const CONV_MAX_FRAC = 0.5;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* App messages (`handle.send` / `handle.broadcast`, docs/call.md § Headless): `{ type: 'app', d }`
|
|
44
|
+
* on the call's data channel. The serialised message is at most `APP_MAX_BYTES` (UTF-8), and a
|
|
45
|
+
* sender sends at most `APP_MAX_PER_SEC` to one peer in any second (a receiver drops anything
|
|
46
|
+
* beyond `APP_RECV_MAX_PER_SEC`: network jitter can bunch a legal burst).
|
|
47
|
+
*/
|
|
48
|
+
export const APP_MAX_BYTES = 16 * 1024;
|
|
49
|
+
export const APP_MAX_PER_SEC = 20;
|
|
50
|
+
export const APP_RECV_MAX_PER_SEC = 40;
|
|
51
|
+
/** A display name (`hello.name`, the `name` option) is at most this many characters. */
|
|
52
|
+
export const NAME_MAX_CHARS = 64;
|
|
53
|
+
|
|
29
54
|
/** Plausible subject distances, mm. A hint outside this is ignored as noise. */
|
|
30
55
|
export const SUBJECT_Z_MIN_MM = 150;
|
|
31
56
|
export const SUBJECT_Z_MAX_MM = 5000;
|
|
@@ -116,7 +141,7 @@ export function buildInviteLink(base, room) {
|
|
|
116
141
|
return `${b}#room=${room}`;
|
|
117
142
|
}
|
|
118
143
|
|
|
119
|
-
// ── hello / hint / state
|
|
144
|
+
// ── hello / hint / state / conv ────────────────────────────────────────────────────────────
|
|
120
145
|
|
|
121
146
|
/**
|
|
122
147
|
* Validate an incoming `hello`, filling the defaults a receiver may assume. Returns null for
|
|
@@ -138,11 +163,36 @@ export function normalizeHello(msg) {
|
|
|
138
163
|
hfovDeg: num(msg.hfovDeg, 5, 170),
|
|
139
164
|
rectified: msg.rectified === true,
|
|
140
165
|
sdk: typeof msg.sdk === 'string' ? msg.sdk.slice(0, 64) : null,
|
|
166
|
+
// The sender measures its own convergence and sends `conv` messages. Absent (an older sender) =
|
|
167
|
+
// false: the receiver measures the decoded frames itself, as before.
|
|
168
|
+
conv: msg.conv === true,
|
|
169
|
+
// The sender's display name (`name` option, 1.48+), sanitised; null when absent or empty.
|
|
170
|
+
name: sanitizeName(msg.name),
|
|
141
171
|
};
|
|
142
172
|
}
|
|
143
173
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
174
|
+
/**
|
|
175
|
+
* A display name as it may travel and be shown: a string, control characters removed (C0, DEL,
|
|
176
|
+
* C1, and the bidi / zero-width format controls that can disguise a name), whitespace collapsed
|
|
177
|
+
* and trimmed, at most `NAME_MAX_CHARS` characters (code points). Null for anything else or an
|
|
178
|
+
* empty result. Never throws: this reads data from another browser. Pure.
|
|
179
|
+
* @param {unknown} v
|
|
180
|
+
* @returns {string|null}
|
|
181
|
+
*/
|
|
182
|
+
export function sanitizeName(v) {
|
|
183
|
+
if (typeof v !== 'string') return null;
|
|
184
|
+
// eslint-disable-next-line no-control-regex
|
|
185
|
+
const clean = v.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
186
|
+
if (!clean) return null;
|
|
187
|
+
const cps = Array.from(clean);
|
|
188
|
+
return cps.length > NAME_MAX_CHARS ? cps.slice(0, NAME_MAX_CHARS).join('').trim() : clean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build the `hello` this side sends. `conv: true` (advertised for a side-by-side sender only)
|
|
193
|
+
* says this side sends `conv` messages.
|
|
194
|
+
*/
|
|
195
|
+
export function makeHello({ format, width, height, baselineMm, hfovDeg, rectified, conv, name } = {}) {
|
|
146
196
|
const h = {
|
|
147
197
|
type: 'hello',
|
|
148
198
|
v: WIRE_VERSION,
|
|
@@ -154,9 +204,90 @@ export function makeHello({ format, width, height, baselineMm, hfovDeg, rectifie
|
|
|
154
204
|
};
|
|
155
205
|
if (Number.isFinite(baselineMm)) h.baselineMm = baselineMm;
|
|
156
206
|
if (Number.isFinite(hfovDeg)) h.hfovDeg = hfovDeg;
|
|
207
|
+
if (h.format === 'sbs' && conv) h.conv = true;
|
|
208
|
+
const n = sanitizeName(name);
|
|
209
|
+
if (n) h.name = n;
|
|
157
210
|
return h;
|
|
158
211
|
}
|
|
159
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Wrap a page's app payload: `{ type: 'app', d: data }` serialised, or null when `data` cannot be
|
|
215
|
+
* serialised (undefined, a function, a cycle, a BigInt) or the message is over `APP_MAX_BYTES`
|
|
216
|
+
* (UTF-8). Returns the JSON string the data channel carries. Pure.
|
|
217
|
+
* @param {unknown} data
|
|
218
|
+
* @returns {{ json: string, bytes: number } | null}
|
|
219
|
+
*/
|
|
220
|
+
export function makeAppMessage(data) {
|
|
221
|
+
if (data === undefined || typeof data === 'function') return null;
|
|
222
|
+
let json;
|
|
223
|
+
try {
|
|
224
|
+
json = JSON.stringify({ type: 'app', d: data });
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
if (typeof json !== 'string') return null;
|
|
229
|
+
const bytes = utf8Length(json);
|
|
230
|
+
return bytes > APP_MAX_BYTES ? null : { json, bytes };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** An incoming `app` message: `{ data }`, or null when it is not one. Only this type reaches a page. */
|
|
234
|
+
export function normalizeApp(msg) {
|
|
235
|
+
if (!msg || typeof msg !== 'object' || msg.type !== 'app' || !('d' in msg)) return null;
|
|
236
|
+
return { data: msg.d };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function utf8Length(s) {
|
|
240
|
+
if (typeof TextEncoder === 'function') return new TextEncoder().encode(s).length;
|
|
241
|
+
let n = 0;
|
|
242
|
+
for (const ch of s) {
|
|
243
|
+
const c = ch.codePointAt(0);
|
|
244
|
+
n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
245
|
+
}
|
|
246
|
+
return n;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A sliding-window counter: `gate(nowMs)` is true at most `max` times in any `windowMs`. The app
|
|
251
|
+
* channel's per-peer cap (a burst of `max` is fine; the next one waits for the window). Pure.
|
|
252
|
+
*/
|
|
253
|
+
export function windowGate(max, windowMs = 1000) {
|
|
254
|
+
const at = [];
|
|
255
|
+
return (now) => {
|
|
256
|
+
while (at.length && now - at[0] >= windowMs) at.shift();
|
|
257
|
+
if (at.length >= max) return false;
|
|
258
|
+
at.push(now);
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `conv {d, c?}`: the sender's measured disparity of the point between its subject's eyes, as a
|
|
265
|
+
* FRACTION of its per-eye width (resolution-free: a receiver multiplies by the width of the frame it
|
|
266
|
+
* decodes), `null` = nothing measured yet (the receiver holds what it had). `c` = the match
|
|
267
|
+
* confidence (NCC, 0..1), informational.
|
|
268
|
+
* @param {number|null} frac
|
|
269
|
+
* @param {number|null} [ncc]
|
|
270
|
+
*/
|
|
271
|
+
export function makeConv(frac, ncc) {
|
|
272
|
+
const m = { type: 'conv', d: typeof frac === 'number' && Number.isFinite(frac) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, frac)) : null };
|
|
273
|
+
if (typeof ncc === 'number' && Number.isFinite(ncc)) m.c = Math.max(0, Math.min(1, ncc));
|
|
274
|
+
return m;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Validate an incoming `conv`. Null for anything that is not one, or whose `d` is neither null nor
|
|
279
|
+
* a finite number within ±CONV_MAX_FRAC. `c` is clamped to 0..1, or dropped. Never throws: this
|
|
280
|
+
* reads data from another browser.
|
|
281
|
+
*/
|
|
282
|
+
export function normalizeConv(msg) {
|
|
283
|
+
if (!msg || typeof msg !== 'object' || msg.type !== 'conv') return null;
|
|
284
|
+
const d = msg.d;
|
|
285
|
+
if (d !== null && !(typeof d === 'number' && Number.isFinite(d) && Math.abs(d) <= CONV_MAX_FRAC)) return null;
|
|
286
|
+
const out = { type: 'conv', d };
|
|
287
|
+
if (typeof msg.c === 'number' && Number.isFinite(msg.c)) out.c = Math.max(0, Math.min(1, msg.c));
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
160
291
|
/** `hint {subjectZmm}`: a number in range, or null (ignored). */
|
|
161
292
|
export function normalizeHint(msg) {
|
|
162
293
|
if (!msg || msg.type !== 'hint') return null;
|
|
@@ -173,11 +304,14 @@ export function normalizeState(msg) {
|
|
|
173
304
|
}
|
|
174
305
|
|
|
175
306
|
/**
|
|
176
|
-
* A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second
|
|
307
|
+
* A minimum-interval gate: `gate(nowMs)` is true at most `hz` times a second (`slackMs` shortens
|
|
308
|
+
* the interval by that much). Used on both ends
|
|
177
309
|
* of `hint` so a chatty (or hostile) sender cannot drive the receiver's convergence at frame rate.
|
|
178
310
|
*/
|
|
179
|
-
export function rateGate(hz) {
|
|
180
|
-
|
|
311
|
+
export function rateGate(hz, slackMs = 0) {
|
|
312
|
+
// `slackMs`: a timer-paced caller at exactly `hz` (the sender's `conv` tick) lands a few ms early
|
|
313
|
+
// now and then; without slack every such tick is refused and the rate halves.
|
|
314
|
+
const minMs = 1000 / hz - Math.max(0, slackMs);
|
|
181
315
|
let last = -Infinity;
|
|
182
316
|
return (now) => {
|
|
183
317
|
if (now - last < minMs) return false;
|
|
@@ -186,6 +320,29 @@ export function rateGate(hz) {
|
|
|
186
320
|
};
|
|
187
321
|
}
|
|
188
322
|
|
|
323
|
+
// ── connection quality ─────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The selected ICE candidate pair of one getStats() report: `{ path, rttMs }`. `path` is
|
|
327
|
+
* `'relay'` when either end is relayed (TURN), else the local candidate's type (`'host'` /
|
|
328
|
+
* `'srflx'` / `'prflx'`); null when no pair is selected yet. Chrome names the pair on the
|
|
329
|
+
* `transport` stats; Firefox flags it `selected`. Pure: reads an RTCStatsReport (maplike).
|
|
330
|
+
*/
|
|
331
|
+
export function selectedPath(report) {
|
|
332
|
+
const all = [];
|
|
333
|
+
report.forEach((s) => all.push(s));
|
|
334
|
+
const byId = (id) => (id && typeof report.get === 'function' ? report.get(id) : all.find((s) => s.id === id)) || null;
|
|
335
|
+
const tr = all.find((s) => s.type === 'transport' && s.selectedCandidatePairId);
|
|
336
|
+
const pair = (tr && byId(tr.selectedCandidatePairId)) || all.find((s) => s.type === 'candidate-pair' && s.selected) || all.find((s) => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
|
|
337
|
+
if (!pair) return { path: null, rttMs: null };
|
|
338
|
+
const lc = byId(pair.localCandidateId);
|
|
339
|
+
const rc = byId(pair.remoteCandidateId);
|
|
340
|
+
const lt = lc ? lc.candidateType : null;
|
|
341
|
+
const path = lt === 'relay' || (rc && rc.candidateType === 'relay') ? 'relay' : ['host', 'srflx', 'prflx'].includes(lt) ? lt : null;
|
|
342
|
+
const rtt = typeof pair.currentRoundTripTime === 'number' ? Math.round(pair.currentRoundTripTime * 1000) : null;
|
|
343
|
+
return { path, rttMs: rtt };
|
|
344
|
+
}
|
|
345
|
+
|
|
189
346
|
// ── routing (RFC §3) ───────────────────────────────────────────────────────────────────────
|
|
190
347
|
|
|
191
348
|
/**
|
package/js/camera/capture.js
CHANGED
|
@@ -5,16 +5,25 @@
|
|
|
5
5
|
// 1280x480, i.e. 640x480 per eye, grayscale and UNRECTIFIED). Found → sent as `sbs`. Otherwise the
|
|
6
6
|
// default webcam, sent as `mono`. Per-eye width is whatever the camera gives; nothing is upscaled.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Three field facts shape the probing:
|
|
9
9
|
// - On some laptops the stereo camera is held EXCLUSIVELY by the eye tracker, so opening it
|
|
10
10
|
// fails with NotReadableError ("Device in use"). That device is skipped silently — a call must
|
|
11
11
|
// never fail because an optional camera is busy.
|
|
12
|
+
// - Not every `videoinput` is a camera a person should be seen through. A hand-tracking camera
|
|
13
|
+
// (and an eye tracker's or a depth sensor's IR stream) enumerates as an ordinary video input
|
|
14
|
+
// and can even look like a pair by shape; on a Windows box with no stereo camera open, the
|
|
15
|
+
// last-resort "try every device" loop opened one and sent it as the call's 3D picture. Labels
|
|
16
|
+
// matching CAMERA_EXCLUDE_BUILTIN, plus the page's `exclude` list, are never opened by any probe
|
|
17
|
+
// path (an excluded default is closed again), and are reported in `skipped` as `excluded`.
|
|
12
18
|
// - Labels are empty until one getUserMedia has succeeded. Without them, permission is primed on
|
|
13
|
-
// the DEFAULT camera with no constraints
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
19
|
+
// the DEFAULT camera with no constraints (a plain open of one lens of a phone's pair, or its
|
|
20
|
+
// teardown, crashes some phones' camera HAL; any facing or size constraint lets the engine pick
|
|
21
|
+
// a lens instead). Where the runtime's 3D camera is the default, the prime opens the pair
|
|
22
|
+
// itself. NOTHING here assumes a list position: on Windows the DisplayXR Browser lists its
|
|
23
|
+
// "3D Camera (DisplayXR)" LAST. A pair is recognised by the track's own `displayxrStereo`
|
|
24
|
+
// description or by its label (stereoLabelHint), and after the prime the list is read again and
|
|
25
|
+
// a named pair is opened by id BEFORE a default that is a pair only by its shape is kept. The
|
|
26
|
+
// primed default is the fallback.
|
|
18
27
|
//
|
|
19
28
|
// RECTIFICATION is not done here (P2): it needs the camera's calibration (intrinsics/extrinsics
|
|
20
29
|
// for the ACTIVE device), which comes from a plug-in or the runtime. P1 sends the raw pair with
|
|
@@ -48,11 +57,44 @@ export function isDeniedError(err) {
|
|
|
48
57
|
* @param {Array<{error: string, busy?: boolean, denied?: boolean}>} skipped
|
|
49
58
|
*/
|
|
50
59
|
export function noCameraCode(skipped) {
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
// A device left out on purpose (`excluded`) says nothing about why the others failed.
|
|
61
|
+
const tried = skipped.filter((s) => !s.excluded);
|
|
62
|
+
if (tried.length && tried.every((s) => s.busy)) return 'camera-busy';
|
|
63
|
+
if (tried.some((s) => s.denied)) return 'permission-denied';
|
|
53
64
|
return 'no-camera';
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Labels of video inputs that are sensors, not cameras to be seen through: hand tracking, an eye
|
|
69
|
+
* tracker's or a "tracking camera"'s IR stream, a depth camera. Generic words only. Applied on
|
|
70
|
+
* every probe path unless `exclude: false`.
|
|
71
|
+
*/
|
|
72
|
+
export const CAMERA_EXCLUDE_BUILTIN = /\b(hand[- ]?track|tracking camera|eye[- ]?track|depth camera)/i;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The exclusion list in force: the built-in pattern plus `exclude` (strings match a label
|
|
76
|
+
* case-insensitively as a substring, or a deviceId exactly; RegExps test the label), or nothing at
|
|
77
|
+
* all for `exclude: false`. Returns a predicate `(label, deviceId) => boolean`. Pure.
|
|
78
|
+
* @param {false|null|undefined|ReadonlyArray<string|RegExp>|string|RegExp} exclude
|
|
79
|
+
*/
|
|
80
|
+
export function cameraExcluder(exclude) {
|
|
81
|
+
if (exclude === false) return () => false;
|
|
82
|
+
const list = [CAMERA_EXCLUDE_BUILTIN];
|
|
83
|
+
for (const x of exclude == null ? [] : Array.isArray(exclude) ? exclude : [exclude]) {
|
|
84
|
+
if (x instanceof RegExp || (typeof x === 'string' && x)) list.push(x);
|
|
85
|
+
}
|
|
86
|
+
return (label, deviceId) => {
|
|
87
|
+
const l = String(label || '');
|
|
88
|
+
for (const x of list) {
|
|
89
|
+
if (x instanceof RegExp) {
|
|
90
|
+
x.lastIndex = 0; // a /g or /y pattern is stateful
|
|
91
|
+
if (l && x.test(l)) return true;
|
|
92
|
+
} else if ((l && l.toLowerCase().includes(x.toLowerCase())) || (deviceId && deviceId === x)) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
56
98
|
/** A label that names a stereo camera, tried first when probing. Pure. */
|
|
57
99
|
export function stereoLabelHint(label) {
|
|
58
100
|
return /stereo|\b3d\b|\bsbs\b|dual/i.test(label || '');
|
|
@@ -65,7 +107,9 @@ export function stereoLabelHint(label) {
|
|
|
65
107
|
* @param {'auto'|'stereo'|'mono'|string|MediaStream} want 'auto' | 'stereo' | 'mono' | a
|
|
66
108
|
* deviceId | a page-supplied MediaStream
|
|
67
109
|
* @param {{ format?: 'sbs'|'mono', calibration?: {baselineMm?: number, hfovDeg?: number, rectified?: boolean},
|
|
68
|
-
* mediaDevices?: MediaDevices, log?: Function }} [o]
|
|
110
|
+
* mediaDevices?: MediaDevices, log?: Function, exclude?: false|Array<string|RegExp> }} [o]
|
|
111
|
+
* `exclude`: devices no probe may open (see cameraExcluder); an explicit deviceId is the
|
|
112
|
+
* page's own choice and is opened regardless.
|
|
69
113
|
* @returns {Promise<{ stream: MediaStream, format: 'sbs'|'mono', width: number, height: number,
|
|
70
114
|
* deviceId: string|null, label: string, owned: boolean, skipped: Array<{label: string, error: string}>,
|
|
71
115
|
* calibration: object }>}
|
|
@@ -75,6 +119,7 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
75
119
|
const log = o.log || (() => {});
|
|
76
120
|
const skipped = [];
|
|
77
121
|
const cal = o.calibration || {};
|
|
122
|
+
const isExcluded = cameraExcluder(o.exclude);
|
|
78
123
|
|
|
79
124
|
if (want && typeof want === 'object' && typeof want.getVideoTracks === 'function') {
|
|
80
125
|
const t = want.getVideoTracks()[0];
|
|
@@ -105,6 +150,32 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
105
150
|
log('camera-skip', { label, error: err.name });
|
|
106
151
|
};
|
|
107
152
|
const stop = (r) => r && r.stream.getTracks().forEach((t) => t.stop());
|
|
153
|
+
// Excluded devices: never opened by id; a default that turns out to be one is closed again.
|
|
154
|
+
const excludedIds = new Set();
|
|
155
|
+
const exclude = (label, deviceId) => {
|
|
156
|
+
if (deviceId && excludedIds.has(deviceId)) return;
|
|
157
|
+
if (deviceId) excludedIds.add(deviceId);
|
|
158
|
+
skipped.push({ label, error: 'excluded', excluded: true });
|
|
159
|
+
log('camera-skip', { label, error: 'excluded' });
|
|
160
|
+
};
|
|
161
|
+
const listedExcluded = (d) => {
|
|
162
|
+
if (!d.label || !isExcluded(d.label, d.deviceId)) return false;
|
|
163
|
+
exclude(d.label, d.deviceId);
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
// An open of the DEFAULT (no id): only after it opened is its label known. An excluded default
|
|
167
|
+
// is closed and never opened again in this probe.
|
|
168
|
+
let defaultExcluded = false;
|
|
169
|
+
const openDefault = async (constraints) => {
|
|
170
|
+
const r = await open(constraints);
|
|
171
|
+
if (isExcluded(r.label, r.deviceId)) {
|
|
172
|
+
stop(r);
|
|
173
|
+
exclude(r.label, r.deviceId);
|
|
174
|
+
defaultExcluded = true;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return r;
|
|
178
|
+
};
|
|
108
179
|
// Ask an open camera for another mode without a second start; keep it as it is if it refuses.
|
|
109
180
|
const withMode = async (r, constraints) => {
|
|
110
181
|
try {
|
|
@@ -165,6 +236,7 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
165
236
|
for (const d of list) {
|
|
166
237
|
if (!d.deviceId || !stereoLabelHint(d.label) || tried.has(d.deviceId)) continue;
|
|
167
238
|
tried.add(d.deviceId);
|
|
239
|
+
if (listedExcluded(d)) continue;
|
|
168
240
|
let r = null;
|
|
169
241
|
try {
|
|
170
242
|
r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
|
|
@@ -192,31 +264,35 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
192
264
|
pair = await tryNamedPairs(listed);
|
|
193
265
|
if (pair) return result(pair, 'sbs');
|
|
194
266
|
}
|
|
195
|
-
// No labels yet (no permission). Prime it on the default camera with NO constraints
|
|
196
|
-
//
|
|
197
|
-
//
|
|
267
|
+
// No labels yet (no permission). Prime it on the default camera with NO constraints (a facing
|
|
268
|
+
// or size constraint would let the engine pick a phone lens instead). Where the runtime's 3D
|
|
269
|
+
// camera is the default this opens the pair itself; elsewhere (Windows lists it LAST) the list
|
|
270
|
+
// is read again and a NAMED pair is opened by id. A default that is a pair only by its SHAPE is
|
|
271
|
+
// kept as the pair only when no named one opens (it could be any wide sensor).
|
|
198
272
|
if (listed.length && listed.every((d) => !d.label)) {
|
|
199
273
|
let primed = null;
|
|
200
274
|
try {
|
|
201
|
-
primed = await
|
|
275
|
+
primed = await openDefault(true);
|
|
202
276
|
} catch (err) {
|
|
203
277
|
skip('default', err);
|
|
204
278
|
}
|
|
205
|
-
if (primed &&
|
|
279
|
+
if (primed && primed.stereo) return result(primed, 'sbs'); // the device says it is the pair
|
|
280
|
+
if (primed && primed.deviceId) tried.add(primed.deviceId);
|
|
206
281
|
const named = await tryNamedPairs(await listCameras());
|
|
207
282
|
if (named) {
|
|
208
283
|
stop(primed);
|
|
209
284
|
return result(named, 'sbs');
|
|
210
285
|
}
|
|
286
|
+
if (primed && isPair(primed)) return result(primed, 'sbs');
|
|
211
287
|
// The primed default is the fallback as is (one camera start); ask it for the usual mode.
|
|
212
288
|
if (primed) mono = await withMode(primed, MONO_CONSTRAINTS);
|
|
213
289
|
}
|
|
214
290
|
}
|
|
215
291
|
|
|
216
292
|
// The default camera: the fallback (and, with labels already visible, also the probe base).
|
|
217
|
-
if (!mono) {
|
|
293
|
+
if (!mono && !defaultExcluded) {
|
|
218
294
|
try {
|
|
219
|
-
mono = await
|
|
295
|
+
mono = await openDefault(MONO_CONSTRAINTS);
|
|
220
296
|
} catch (err) {
|
|
221
297
|
skip('default', err);
|
|
222
298
|
}
|
|
@@ -230,11 +306,13 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
230
306
|
} catch {
|
|
231
307
|
/* no enumeration: keep the default */
|
|
232
308
|
}
|
|
309
|
+
// A label that names a pair first; never by list position (see the header).
|
|
233
310
|
devices.sort((a, b) => Number(stereoLabelHint(b.label)) - Number(stereoLabelHint(a.label)));
|
|
234
311
|
for (const d of devices) {
|
|
235
312
|
if (!d.deviceId) continue; // no permission yet: an anonymous entry cannot be opened by id
|
|
236
313
|
if (mono && d.deviceId === mono.deviceId) continue;
|
|
237
|
-
if (tried.has(d.deviceId)) continue; //
|
|
314
|
+
if (tried.has(d.deviceId) || excludedIds.has(d.deviceId)) continue; // already opened (not a pair, or busy) or left out
|
|
315
|
+
if (listedExcluded(d)) continue; // the last resort never opens a sensor (a hand-tracking camera)
|
|
238
316
|
let r = null;
|
|
239
317
|
try {
|
|
240
318
|
r = await open({ deviceId: { exact: d.deviceId }, ...PROBE_CONSTRAINTS });
|
|
@@ -243,11 +321,17 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
243
321
|
skip(d.label, err);
|
|
244
322
|
continue;
|
|
245
323
|
}
|
|
246
|
-
if (isPair(r)) {
|
|
324
|
+
if (isPair(r) && want !== 'mono') {
|
|
247
325
|
stop(mono);
|
|
248
326
|
log('camera-sbs', { label: r.label, width: r.width, height: r.height });
|
|
249
327
|
return result(r, 'sbs');
|
|
250
328
|
}
|
|
329
|
+
// No default could be used (excluded, busy): the first plain camera found is the fallback.
|
|
330
|
+
if (!mono && !isPair(r)) {
|
|
331
|
+
mono = await withMode(r, MONO_CONSTRAINTS);
|
|
332
|
+
if (want === 'mono') return result(mono, 'mono');
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
251
335
|
stop(r);
|
|
252
336
|
}
|
|
253
337
|
if (mono) {
|
|
@@ -261,11 +345,14 @@ export async function openCamera(want = 'auto', o = {}) {
|
|
|
261
345
|
);
|
|
262
346
|
}
|
|
263
347
|
|
|
264
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* The microphone, with echo cancellation and noise suppression. Null if unavailable/denied.
|
|
350
|
+
* `deviceId`: that input exactly (`handle.setMic`).
|
|
351
|
+
*/
|
|
265
352
|
export async function openMic(o = {}) {
|
|
266
353
|
const md = o.mediaDevices || (globalThis.navigator && navigator.mediaDevices);
|
|
267
354
|
try {
|
|
268
|
-
const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS }, video: false });
|
|
355
|
+
const s = await md.getUserMedia({ audio: { ...AUDIO_CONSTRAINTS, ...(o.deviceId ? { deviceId: { exact: o.deviceId } } : {}) }, video: false });
|
|
269
356
|
return s.getAudioTracks()[0] || null;
|
|
270
357
|
} catch (err) {
|
|
271
358
|
(o.log || (() => {}))('mic-skip', { error: err && err.name });
|