@mentra/engine 3.2.0-dev.217 → 3.2.0-dev.222
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/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1
- package/build/index.js.map +1 -1
- package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
- package/build/react/MentraLiveOtaFlow.js +19 -3
- package/build/react/MentraLiveOtaFlow.js.map +1 -1
- package/build/react/useMentraLiveOta.d.ts +12 -0
- package/build/react/useMentraLiveOta.d.ts.map +1 -1
- package/build/react/useMentraLiveOta.js +31 -5
- package/build/react/useMentraLiveOta.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +360 -10
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +836 -19
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AudioPlaybackService.d.ts +6 -0
- package/build/services/AudioPlaybackService.d.ts.map +1 -1
- package/build/services/AudioPlaybackService.js +4 -3
- package/build/services/AudioPlaybackService.js.map +1 -1
- package/build/services/GlassesMicProbe.d.ts +78 -0
- package/build/services/GlassesMicProbe.d.ts.map +1 -0
- package/build/services/GlassesMicProbe.js +258 -0
- package/build/services/GlassesMicProbe.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts +83 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +714 -39
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSLogPipeline.d.ts +39 -0
- package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
- package/build/services/MentraJSLogPipeline.js +59 -3
- package/build/services/MentraJSLogPipeline.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +2 -2
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MicStateCoordinator.d.ts +22 -0
- package/build/services/MicStateCoordinator.d.ts.map +1 -1
- package/build/services/MicStateCoordinator.js +34 -3
- package/build/services/MicStateCoordinator.js.map +1 -1
- package/build/services/OtaErrorMapping.d.ts +25 -2
- package/build/services/OtaErrorMapping.d.ts.map +1 -1
- package/build/services/OtaErrorMapping.js +68 -23
- package/build/services/OtaErrorMapping.js.map +1 -1
- package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
- package/build/services/OtaInstallCoordinator.js +6 -1
- package/build/services/OtaInstallCoordinator.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +8 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +2 -0
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +365 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -0
- package/build/services/SoftapCallTransport.js +722 -0
- package/build/services/SoftapCallTransport.js.map +1 -0
- package/build/services/SoftapCleanupBarrier.d.ts +45 -0
- package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
- package/build/services/SoftapCleanupBarrier.js +51 -0
- package/build/services/SoftapCleanupBarrier.js.map +1 -0
- package/build/utils/pcm16.d.ts +35 -0
- package/build/utils/pcm16.d.ts.map +1 -0
- package/build/utils/pcm16.js +85 -0
- package/build/utils/pcm16.js.map +1 -0
- package/build/utils/softapTrace.d.ts +39 -0
- package/build/utils/softapTrace.d.ts.map +1 -0
- package/build/utils/softapTrace.js +124 -0
- package/build/utils/softapTrace.js.map +1 -0
- package/package.json +7 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +8 -0
- package/src/react/MentraLiveOtaFlow.tsx +22 -3
- package/src/react/useMentraLiveOta.ts +44 -6
- package/src/services/AcsMeetingService.ts +956 -22
- package/src/services/AudioPlaybackService.ts +12 -3
- package/src/services/GlassesMicProbe.ts +300 -0
- package/src/services/LocalMiniappRuntime.ts +775 -42
- package/src/services/MentraJSLogPipeline.ts +70 -3
- package/src/services/MentraJSRouter.ts +2 -2
- package/src/services/MicStateCoordinator.ts +35 -3
- package/src/services/OtaErrorMapping.ts +77 -25
- package/src/services/OtaInstallCoordinator.ts +6 -1
- package/src/services/PhoneStreamCoordinator.ts +10 -0
- package/src/services/SoftapCallTransport.ts +928 -0
- package/src/services/SoftapCleanupBarrier.ts +72 -0
- package/src/utils/pcm16.ts +93 -0
- package/src/utils/softapTrace.ts +133 -0
|
@@ -3,20 +3,93 @@
|
|
|
3
3
|
* WHEP + ACS; this service maps that into miniapp envelopes and pipes
|
|
4
4
|
* incoming PCM into AudioPlaybackService (A2DP / PcmStreamPlayer).
|
|
5
5
|
*/
|
|
6
|
+
import { Platform } from "react-native";
|
|
7
|
+
import BluetoothSdk from "@mentra/bluetooth-sdk/internal";
|
|
6
8
|
import audioPlaybackService from "./AudioPlaybackService";
|
|
9
|
+
import micStateCoordinator from "./MicStateCoordinator";
|
|
7
10
|
import { SETTINGS, useSettingsStore } from "../stores/settings";
|
|
11
|
+
import { Pcm16LevelMeter } from "../utils/pcm16";
|
|
12
|
+
import { softapTrace, softapTraceFailure, softapTraceId } from "../utils/softapTrace";
|
|
8
13
|
import { ACS_CALL_MIC } from "./acsAudioSource";
|
|
9
14
|
export { ACS_CALL_MIC };
|
|
15
|
+
/** Error surfaced when the pinned glasses microphone stops producing audio during a call. */
|
|
16
|
+
export const GLASSES_MIC_UNAVAILABLE = "GLASSES_MIC_UNAVAILABLE";
|
|
17
|
+
export function parseMeetingCapabilities(raw) {
|
|
18
|
+
if (!raw || typeof raw !== "object")
|
|
19
|
+
return undefined;
|
|
20
|
+
const value = raw.hangUpForEveryone;
|
|
21
|
+
if (!value || typeof value !== "object")
|
|
22
|
+
return undefined;
|
|
23
|
+
const capability = value;
|
|
24
|
+
return {
|
|
25
|
+
hangUpForEveryone: {
|
|
26
|
+
allowed: typeof capability.allowed === "boolean" ? capability.allowed : null,
|
|
27
|
+
reason: typeof capability.reason === "string" && capability.reason ? capability.reason : null,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
10
31
|
/**
|
|
11
|
-
* The call microphone is [ACS_CALL_MIC]
|
|
12
|
-
*
|
|
13
|
-
*
|
|
32
|
+
* The call microphone is [ACS_CALL_MIC], and `preferred_mic` governs MentraOS STT capture rather
|
|
33
|
+
* than this call.
|
|
34
|
+
*
|
|
35
|
+
* `"glasses"` selects the wearer's own microphone. Which *transport* carries it is a separate
|
|
36
|
+
* decision made per call by [glassesLc3UplinkSupported]: BLE LC3 into the ACS raw outgoing stream
|
|
37
|
+
* where the host and native both support it, otherwise the glasses' published WebRTC audio track.
|
|
38
|
+
* `"phone"` selects the phone's microphone via `PhoneMicCapturer`; do not route it through ACS
|
|
39
|
+
* `LocalOutgoingAudioStream`, because that hands ACS the audio route
|
|
14
40
|
* (MODE_IN_COMMUNICATION + forced speaker) and opens an echo loop.
|
|
15
|
-
* preferred_mic governs MentraOS STT capture, not this call.
|
|
16
41
|
*/
|
|
17
42
|
export function resolveAcsAudioSource() {
|
|
18
43
|
return { source: ACS_CALL_MIC, reason: "explicit" };
|
|
19
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether this call can take the wearer's voice off the glasses over BLE LC3.
|
|
47
|
+
*
|
|
48
|
+
* Every term is a hard requirement, and the answer has to be known *before* the glasses are told
|
|
49
|
+
* what to publish — deciding afterwards is how a call ends up with either two copies of the wearer
|
|
50
|
+
* or none. A host whose native module has no `pushOutgoingPcm` keeps the WHIP audio track it has
|
|
51
|
+
* always used rather than silently joining a meeting nobody can be heard in.
|
|
52
|
+
*
|
|
53
|
+
* SoftAP takes BLE LC3 and publishes WHIP video only (`captureAudio=false`), so the glasses SoC
|
|
54
|
+
* microphone is never opened for a call.
|
|
55
|
+
*
|
|
56
|
+
* This was switched off once on a misread: a soak logged `P4 pcm meanAbs` 8–39 and called it
|
|
57
|
+
* digital silence. It was the room. The same LC3 path measured on the bench sits at `meanAbs`
|
|
58
|
+
* ≈30–60 / `peak` ≈80–100 in a quiet room with nobody talking, and that soak had already
|
|
59
|
+
* published with `captureAudio=false`, so the SoC mic was not the thief either. The uplink now
|
|
60
|
+
* logs its own `meanAbs`/`peak` every window ([logMicUplink]) so the next soak reads the level,
|
|
61
|
+
* not the frame rate. Disable only with a level log showing the floor *while the wearer talks*.
|
|
62
|
+
*/
|
|
63
|
+
const SOFTAP_BLE_LC3_UPLINK = true;
|
|
64
|
+
/**
|
|
65
|
+
* How long native may take to finish a leave before it reports the cleanup as stuck.
|
|
66
|
+
*
|
|
67
|
+
* Generous on purpose: this is a diagnostic ceiling on a hang-up plus an agent disposal, not a
|
|
68
|
+
* readiness deadline. The caller treats a timeout as "cleanup failed", never as "safe to restart".
|
|
69
|
+
*/
|
|
70
|
+
const ACS_LEAVE_WAIT_MS = 20_000;
|
|
71
|
+
/**
|
|
72
|
+
* SoftAP video (camera → H.264 → WHIP → WHEP → ACS) is slower than BLE LC3.
|
|
73
|
+
* Hold glasses PCM this long so the talk track does not lead the picture.
|
|
74
|
+
* Set from SoftAP+LC3 clap correlator (2026-09-10): leads 70/201/173/300/142 ms,
|
|
75
|
+
* median 173, rounded to 170. `AVSYNC clap audioLeadMs` is still the re-cal.
|
|
76
|
+
*/
|
|
77
|
+
export const SOFTAP_LC3_AUDIO_DELAY_MS = 170;
|
|
78
|
+
let softapBleLc3UplinkForTests = null;
|
|
79
|
+
export function glassesLc3UplinkSupported(args) {
|
|
80
|
+
if (!(softapBleLc3UplinkForTests ?? SOFTAP_BLE_LC3_UPLINK))
|
|
81
|
+
return false;
|
|
82
|
+
// iOS has no `setMicSourcePin` yet, so it cannot promise the phone microphone stays shut.
|
|
83
|
+
if (args.platform !== "android")
|
|
84
|
+
return false;
|
|
85
|
+
// WHEP audio comes back from Cloudflare already mixed into the subscribed track; there is no
|
|
86
|
+
// captureAudio flag on that path to turn off.
|
|
87
|
+
if (args.videoSource.type !== "softap")
|
|
88
|
+
return false;
|
|
89
|
+
if (args.audioSource !== "glasses")
|
|
90
|
+
return false;
|
|
91
|
+
return args.hasPushOutgoingPcm;
|
|
92
|
+
}
|
|
20
93
|
const PARTICIPANT_STATES = new Set([
|
|
21
94
|
"idle",
|
|
22
95
|
"connecting",
|
|
@@ -49,7 +122,7 @@ export function parseMeetingParticipants(raw) {
|
|
|
49
122
|
return result;
|
|
50
123
|
}
|
|
51
124
|
/** ACS VirtualOutgoingVideoStream documented 16:9 sizes. P540 is 960×540, not 540×960. */
|
|
52
|
-
const ACS_VIRTUAL_CAMERA_SIZES = new Set(["1280x720", "960x540"]);
|
|
125
|
+
const ACS_VIRTUAL_CAMERA_SIZES = new Set(["1280x720", "960x540", "858x480"]);
|
|
53
126
|
export function parseAcsOutgoingVideo(raw) {
|
|
54
127
|
if (raw == null)
|
|
55
128
|
return undefined;
|
|
@@ -68,6 +141,35 @@ export function parseAcsOutgoingVideo(raw) {
|
|
|
68
141
|
}
|
|
69
142
|
return { width, height, fps, maxBitrateBps };
|
|
70
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Validates a `videoSource` from a miniapp.
|
|
146
|
+
*
|
|
147
|
+
* Throws rather than defaulting to WHEP. A miniapp that asks for SoftAP and silently gets a
|
|
148
|
+
* Cloudflare call — or vice versa — is a bug that shows up as unexplained latency or a black tile,
|
|
149
|
+
* not as an error anyone can act on.
|
|
150
|
+
*/
|
|
151
|
+
export function parseAcsVideoSource(raw) {
|
|
152
|
+
if (raw == null || typeof raw !== "object") {
|
|
153
|
+
throw new Error(`videoSource must be {type: "whep", url} or {type: "softap"}`);
|
|
154
|
+
}
|
|
155
|
+
const value = raw;
|
|
156
|
+
if (value.type === "whep") {
|
|
157
|
+
const url = typeof value.url === "string" ? value.url.trim() : "";
|
|
158
|
+
if (!url)
|
|
159
|
+
throw new Error("videoSource.url is required for a WHEP source");
|
|
160
|
+
return { type: "whep", url };
|
|
161
|
+
}
|
|
162
|
+
if (value.type === "softap") {
|
|
163
|
+
const ssid = typeof value.ssid === "string" ? value.ssid.trim() : "";
|
|
164
|
+
const passphrase = typeof value.passphrase === "string" ? value.passphrase : "";
|
|
165
|
+
// Half a credential pair would otherwise present as a failed hotspot join much later.
|
|
166
|
+
if (Boolean(ssid) !== Boolean(passphrase)) {
|
|
167
|
+
throw new Error("videoSource.ssid and videoSource.passphrase must be provided together");
|
|
168
|
+
}
|
|
169
|
+
return ssid ? { type: "softap", ssid, passphrase } : { type: "softap" };
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`unsupported videoSource.type: ${String(value.type)}`);
|
|
172
|
+
}
|
|
71
173
|
let nativeModule;
|
|
72
174
|
function getNative() {
|
|
73
175
|
if (nativeModule !== undefined)
|
|
@@ -85,6 +187,10 @@ function getNative() {
|
|
|
85
187
|
export function setAcsMeetingNativeForTests(mod) {
|
|
86
188
|
nativeModule = mod;
|
|
87
189
|
}
|
|
190
|
+
/** Test seam: exercise the BLE LC3 path without flipping the production kill switch. */
|
|
191
|
+
export function setSoftapBleLc3UplinkForTests(enabled) {
|
|
192
|
+
softapBleLc3UplinkForTests = enabled;
|
|
193
|
+
}
|
|
88
194
|
let phoneNetwork;
|
|
89
195
|
function getPhoneNetwork() {
|
|
90
196
|
if (phoneNetwork !== undefined)
|
|
@@ -124,7 +230,54 @@ const MEDIA_ACTIVE_PHASES = new Set(["connecting", "lobby", "connected"]);
|
|
|
124
230
|
const MEDIA_RESTART_MIN_INTERVAL_MS = 3000;
|
|
125
231
|
/** Formats the native PcmStreamPlayer accepts (mono only). */
|
|
126
232
|
const PCM_SAMPLE_RATES = new Set([16000, 24000, 48000]);
|
|
127
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Playout headroom for the far end's voice.
|
|
235
|
+
*
|
|
236
|
+
* The native player defaults to half a second, which is right for a miniapp clip and wrong here:
|
|
237
|
+
* a streaming track sits at its buffer size, so that default is 500ms of delay before the wearer
|
|
238
|
+
* hears anyone, enough that both sides talk over each other. This trades cushion for immediacy —
|
|
239
|
+
* still several BLE playout beats, but a conversation instead of a broadcast.
|
|
240
|
+
*/
|
|
241
|
+
const PCM_JITTER_MS = 120;
|
|
242
|
+
/**
|
|
243
|
+
* Above this the backlog is real congestion rather than the configured cushion.
|
|
244
|
+
*
|
|
245
|
+
* Has to stay clear of [PCM_JITTER_MS], because the reported backlog *includes* the track's own
|
|
246
|
+
* buffer and so never falls below it. A threshold at or under the cushion logs once per interval
|
|
247
|
+
* for the whole call and says nothing.
|
|
248
|
+
*/
|
|
249
|
+
const PCM_BACKLOG_WARN_MS = PCM_JITTER_MS + 400;
|
|
250
|
+
/** The Bluetooth SDK mic identifier the call pins itself to. */
|
|
251
|
+
const GLASSES_MIC_SOURCE = "glasses";
|
|
252
|
+
/**
|
|
253
|
+
* How long the glasses may produce no PCM before the call reports its microphone gone.
|
|
254
|
+
*
|
|
255
|
+
* The pin makes a phone-mic fallback impossible, so the only honest response to a source that
|
|
256
|
+
* stopped is to say so. One second is several BLE mic-beats: long enough that a reconnect blip
|
|
257
|
+
* does not raise an error, short enough that the wearer is not talking into nothing for long.
|
|
258
|
+
*/
|
|
259
|
+
const GLASSES_MIC_GRACE_MS = 1000;
|
|
260
|
+
/** Cadence of the uplink health line, matching the native P8 ladder. */
|
|
261
|
+
const MIC_UPLINK_LOG_INTERVAL_MS = 5000;
|
|
262
|
+
/** A 50 ms LC3 frame arriving more than this late is a missed beat, not jitter. */
|
|
263
|
+
const MIC_GAP_WARN_MS = 90;
|
|
264
|
+
/**
|
|
265
|
+
* Encode one microphone buffer for `pushOutgoingPcm`.
|
|
266
|
+
*
|
|
267
|
+
* Hermes has no Node `Buffer`. Using it here is how a live SoftAP call selected `ble-lc3`,
|
|
268
|
+
* pinned the glasses, and still sent Teams a minute of silence: every `mic_pcm` event threw
|
|
269
|
+
* `Property 'Buffer' doesn't exist` before native saw a byte. `btoa` is what React Native
|
|
270
|
+
* actually has.
|
|
271
|
+
*/
|
|
272
|
+
export function pcmToBase64(pcm) {
|
|
273
|
+
const bytes = new Uint8Array(pcm);
|
|
274
|
+
let binary = "";
|
|
275
|
+
const step = 0x8000;
|
|
276
|
+
for (let i = 0; i < bytes.length; i += step) {
|
|
277
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + step));
|
|
278
|
+
}
|
|
279
|
+
return btoa(binary);
|
|
280
|
+
}
|
|
128
281
|
class AcsMeetingService {
|
|
129
282
|
owner = null;
|
|
130
283
|
pcmStreamId = null;
|
|
@@ -137,9 +290,54 @@ class AcsMeetingService {
|
|
|
137
290
|
onState = null;
|
|
138
291
|
/** WHEP URL native is (or should be) subscribed to; what a host-triggered restart re-feeds. */
|
|
139
292
|
whepUrl = null;
|
|
293
|
+
/** Transport for the active call, so recovery picks the right repair. */
|
|
294
|
+
videoSource = null;
|
|
295
|
+
/**
|
|
296
|
+
* SoftAP only: the URL the glasses must publish to. An output of the join rather than an input,
|
|
297
|
+
* because it is not known until native has bound a listener and been given a port.
|
|
298
|
+
*/
|
|
299
|
+
ingestUrl = null;
|
|
140
300
|
phoneNetworkUnsub = null;
|
|
141
301
|
lastPhoneNetworkKey = null;
|
|
142
302
|
lastMediaRestartAt = 0;
|
|
303
|
+
/** Callers parked in [waitForFirstFrame], woken by the next `mediaSource` verdict. */
|
|
304
|
+
firstFrameWaiters = new Set();
|
|
305
|
+
scopedLostSub = null;
|
|
306
|
+
scopedLostListeners = new Set();
|
|
307
|
+
/**
|
|
308
|
+
* Set before the scoped network is released, so the loss we are about to cause is not reported as
|
|
309
|
+
* one that happened to us. Android emits `onLost` for a deliberate release, and a naive wiring
|
|
310
|
+
* turns every successful Leave and End into a mid-call network error.
|
|
311
|
+
*/
|
|
312
|
+
scopedTerminating = false;
|
|
313
|
+
/**
|
|
314
|
+
* Bumped by every join and every release, and captured by everything that can outlive a call:
|
|
315
|
+
* the `mic_pcm` listener and the in-flight `native.join`.
|
|
316
|
+
*
|
|
317
|
+
* A leave during a slow join is the case this exists for. Without it the join resolves into a
|
|
318
|
+
* torn-down host, subscribes a microphone listener nobody will ever unsubscribe, and pushes the
|
|
319
|
+
* wearer's voice at a native session that has already left the meeting.
|
|
320
|
+
*/
|
|
321
|
+
callGeneration = 0;
|
|
322
|
+
micTransport = "whip";
|
|
323
|
+
micSub = null;
|
|
324
|
+
/** True between the pin/requirement being taken and released, so release is exactly once. */
|
|
325
|
+
micUplinkActive = false;
|
|
326
|
+
micFramesForwarded = 0;
|
|
327
|
+
/** Frames since the last health line, so the rate and the call total stay separate facts. */
|
|
328
|
+
micFramesWindow = 0;
|
|
329
|
+
micDropsStale = 0;
|
|
330
|
+
micDropsNonGlasses = 0;
|
|
331
|
+
micGaps = 0;
|
|
332
|
+
micGapMsMax = 0;
|
|
333
|
+
lastGlassesFrameAt = 0;
|
|
334
|
+
lastMicUplinkLogAt = 0;
|
|
335
|
+
lastMicDropLogAt = 0;
|
|
336
|
+
/** PCM level over the current health window; read and reset by [logMicUplink]. */
|
|
337
|
+
micLevel = new Pcm16LevelMeter();
|
|
338
|
+
lastMicLevel = null;
|
|
339
|
+
/** Guards [releaseHostState] so a remote hang-up followed by an explicit leave releases once. */
|
|
340
|
+
hostStateReleased = true;
|
|
143
341
|
setStateHandler(handler) {
|
|
144
342
|
this.onState = handler;
|
|
145
343
|
}
|
|
@@ -149,6 +347,206 @@ class AcsMeetingService {
|
|
|
149
347
|
ownerPackage() {
|
|
150
348
|
return this.owner;
|
|
151
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* The WHIP URL the glasses must POST their offer to, for a SoftAP call. Null for every other
|
|
352
|
+
* transport and until the join has bound a listener; the orchestrator reads it between the ACS
|
|
353
|
+
* join and telling the glasses to publish.
|
|
354
|
+
*/
|
|
355
|
+
softApIngestUrl() {
|
|
356
|
+
return this.ingestUrl;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Whether this call is taking the wearer's voice over BLE LC3, which means the glasses must
|
|
360
|
+
* publish video only. Read by the SoftAP orchestrator between the meeting join and the publish.
|
|
361
|
+
*/
|
|
362
|
+
glassesLc3UplinkActive() {
|
|
363
|
+
return this.micTransport === "ble-lc3";
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Join the glasses hotspot as a scoped, internet-less network, returning this phone's address on
|
|
367
|
+
* it. Called before the ACS join, because the local WHIP listener has to bind to that address.
|
|
368
|
+
*
|
|
369
|
+
* A host without the native function is not a host that silently skips the join — the SoftAP call
|
|
370
|
+
* has no network to run on, so this reports the reason instead.
|
|
371
|
+
*/
|
|
372
|
+
async joinScopedNetwork(ssid, passphrase) {
|
|
373
|
+
const native = getNative();
|
|
374
|
+
if (!native?.joinScopedNetwork) {
|
|
375
|
+
throw new Error("This host cannot join the glasses hotspot; SoftAP calling is unavailable");
|
|
376
|
+
}
|
|
377
|
+
this.scopedTerminating = false;
|
|
378
|
+
this.bindScopedNetworkLost(native);
|
|
379
|
+
await native.beginTrace?.(softapTraceId());
|
|
380
|
+
return await native.joinScopedNetwork(ssid, passphrase);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Subscribe to "the glasses hotspot went away while we still wanted it".
|
|
384
|
+
*
|
|
385
|
+
* Only unexpected losses arrive here. Native drops the framework callback for a network it
|
|
386
|
+
* released itself, and [leaveScopedNetwork] raises the terminal intent before releasing, so a
|
|
387
|
+
* normal Leave or End cannot manufacture a mid-call failure.
|
|
388
|
+
*/
|
|
389
|
+
onScopedNetworkLost(listener) {
|
|
390
|
+
this.scopedLostListeners.add(listener);
|
|
391
|
+
return () => this.scopedLostListeners.delete(listener);
|
|
392
|
+
}
|
|
393
|
+
bindScopedNetworkLost(native) {
|
|
394
|
+
if (this.scopedLostSub)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
this.scopedLostSub = native.addListener("onScopedNetworkLost", (event) => {
|
|
398
|
+
if (this.scopedTerminating) {
|
|
399
|
+
console.log("[AcsMeeting] phase=scoped-lost-expected", { code: event.code });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const error = {
|
|
403
|
+
code: typeof event.code === "string" ? event.code : "SOFTAP_NETWORK_LOST",
|
|
404
|
+
message: typeof event.message === "string" ? event.message : "The glasses hotspot went away",
|
|
405
|
+
};
|
|
406
|
+
console.warn("[AcsMeeting] phase=scoped-lost", error);
|
|
407
|
+
for (const listener of [...this.scopedLostListeners]) {
|
|
408
|
+
try {
|
|
409
|
+
listener(error);
|
|
410
|
+
}
|
|
411
|
+
catch (listenerError) {
|
|
412
|
+
console.warn("[AcsMeeting] scoped-lost listener threw", listenerError);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
// A native that predates the event simply never reports mid-call loss; that is a smaller
|
|
419
|
+
// problem than failing the join over a missing listener.
|
|
420
|
+
console.warn("[AcsMeeting] scoped network loss events unavailable", error);
|
|
421
|
+
this.scopedLostSub = null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** Raise the terminal intent so the release we are about to do is not read as a failure. */
|
|
425
|
+
beginScopedTeardown() {
|
|
426
|
+
this.scopedTerminating = true;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Wait until the phone's default network is validated again after the hotspot join.
|
|
430
|
+
*
|
|
431
|
+
* Joining the glasses hotspot takes the phone off Wi-Fi, and ACS needs the internet for the very
|
|
432
|
+
* next step. Waiting is what turns a 40-second join timeout with device-wide DNS failures into a
|
|
433
|
+
* short, named wait — or an honest failure. Null when the host cannot tell.
|
|
434
|
+
*/
|
|
435
|
+
async awaitValidatedDefaultNetwork() {
|
|
436
|
+
const native = getNative();
|
|
437
|
+
if (!native?.awaitValidatedDefaultNetwork)
|
|
438
|
+
return null;
|
|
439
|
+
return await native.awaitValidatedDefaultNetwork();
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Can this phone reach the glasses over the hotspot it just joined? Null when the host cannot
|
|
443
|
+
* tell (no native support), so the orchestrator narrates nothing rather than a guess.
|
|
444
|
+
*/
|
|
445
|
+
async probeScopedGateway() {
|
|
446
|
+
const native = getNative();
|
|
447
|
+
if (!native?.probeScopedGateway)
|
|
448
|
+
return null;
|
|
449
|
+
return await native.probeScopedGateway();
|
|
450
|
+
}
|
|
451
|
+
/** Safe to call when nothing was joined: teardown runs after failed starts too. */
|
|
452
|
+
async leaveScopedNetwork() {
|
|
453
|
+
// Intent first, release second. The other order is the false-positive bug: Android reports the
|
|
454
|
+
// loss we asked for, and the call reports a network failure as it is successfully ending.
|
|
455
|
+
this.scopedTerminating = true;
|
|
456
|
+
try {
|
|
457
|
+
await getNative()?.leaveScopedNetwork?.();
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
this.scopedLostSub?.remove();
|
|
461
|
+
this.scopedLostSub = null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Resolves once the host reports a frame actually reached ACS, which is the only signal that
|
|
466
|
+
* remote participants can see the camera.
|
|
467
|
+
*
|
|
468
|
+
* Rejects if the feed fails first, and on timeout. A SoftAP call that connects but never paints
|
|
469
|
+
* is the failure this exists to catch: without it the orchestrator would report `live` on the
|
|
470
|
+
* strength of an ACS join that says nothing about video.
|
|
471
|
+
*
|
|
472
|
+
* @param timeoutMs how long to wait before treating the silence as a failure
|
|
473
|
+
*/
|
|
474
|
+
waitForFirstFrame(timeoutMs) {
|
|
475
|
+
if (this.lastState.mediaSource === "live") {
|
|
476
|
+
softapTrace("acs_first_frame_already_live", { mediaSource: this.lastState.mediaSource });
|
|
477
|
+
return Promise.resolve();
|
|
478
|
+
}
|
|
479
|
+
const startedAt = Date.now();
|
|
480
|
+
softapTrace("acs_first_frame_wait", {
|
|
481
|
+
timeoutMs,
|
|
482
|
+
mediaSource: this.lastState.mediaSource ?? "unknown",
|
|
483
|
+
state: this.lastState.state,
|
|
484
|
+
});
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
const settle = (error) => {
|
|
487
|
+
if (done)
|
|
488
|
+
return;
|
|
489
|
+
done = true;
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
this.firstFrameWaiters.delete(settle);
|
|
492
|
+
// The step this closes is the one that decides whether the call is usable, so its outcome
|
|
493
|
+
// is named rather than inferred from whichever line happens to follow.
|
|
494
|
+
if (error) {
|
|
495
|
+
softapTraceFailure("acs_first_frame_wait_done", {
|
|
496
|
+
waitedMs: Date.now() - startedAt,
|
|
497
|
+
reason: error.message,
|
|
498
|
+
});
|
|
499
|
+
reject(error);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
softapTrace("acs_first_frame_wait_done", { waitedMs: Date.now() - startedAt });
|
|
503
|
+
resolve();
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
let done = false;
|
|
507
|
+
const timer = setTimeout(() => settle(new Error(`No glasses video reached the meeting within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
|
|
508
|
+
this.firstFrameWaiters.add(settle);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/** Wake every `waitForFirstFrame` caller with the outcome the host just reported. */
|
|
512
|
+
settleFirstFrameWaiters(mediaSource) {
|
|
513
|
+
if (mediaSource !== "live" && mediaSource !== "failed")
|
|
514
|
+
return;
|
|
515
|
+
const error = mediaSource === "failed" ? new Error("The glasses video feed failed") : undefined;
|
|
516
|
+
for (const settle of [...this.firstFrameWaiters])
|
|
517
|
+
settle(error);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Sign in to ACS before the glasses hotspot exists.
|
|
521
|
+
*
|
|
522
|
+
* SoftAP DNS cannot resolve Teams hosts. Doing this on the phone's existing internet is what
|
|
523
|
+
* stops `createCallAgent` from hanging until the hotspot is torn down.
|
|
524
|
+
*/
|
|
525
|
+
async prepareAgent(args) {
|
|
526
|
+
const native = getNative();
|
|
527
|
+
if (!native?.prepareAgent) {
|
|
528
|
+
// A host that cannot pre-sign-in still joins; it just does the sign-in inside the SoftAP
|
|
529
|
+
// join, which is the 20-second ACS_AGENT_TIMEOUT this step exists to avoid. Worth a line,
|
|
530
|
+
// because from the trace alone that build looks like one whose sign-in was instant.
|
|
531
|
+
softapTrace("acs_prepare_agent_skipped", { reason: native ? "unsupported build" : "no native module" });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
console.log("[AcsMeeting] phase=prepare-agent");
|
|
535
|
+
const startedAt = Date.now();
|
|
536
|
+
softapTrace("acs_native_prepare_agent", { hasDisplayName: Boolean(args.displayName) });
|
|
537
|
+
try {
|
|
538
|
+
await native.prepareAgent({ token: args.token, displayName: args.displayName });
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
softapTraceFailure("acs_native_prepare_agent_failed", {
|
|
542
|
+
durationMs: Date.now() - startedAt,
|
|
543
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
544
|
+
});
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
console.log("[AcsMeeting] phase=prepare-agent-ok");
|
|
548
|
+
softapTrace("acs_native_prepare_agent_ok", { durationMs: Date.now() - startedAt });
|
|
549
|
+
}
|
|
152
550
|
async join(packageName, args) {
|
|
153
551
|
const native = getNative();
|
|
154
552
|
if (!native) {
|
|
@@ -161,33 +559,89 @@ class AcsMeetingService {
|
|
|
161
559
|
// Validate before claiming ownership so a bad request cannot leave the slot taken.
|
|
162
560
|
const video = args.video ? parseAcsOutgoingVideo(args.video) : undefined;
|
|
163
561
|
const resolved = resolveAcsAudioSource();
|
|
562
|
+
const generation = ++this.callGeneration;
|
|
563
|
+
this.hostStateReleased = false;
|
|
164
564
|
this.owner = packageName;
|
|
165
|
-
|
|
565
|
+
// Only a whep source has a URL to re-feed on recovery; softap rebuilds instead.
|
|
566
|
+
this.whepUrl = args.videoSource.type === "whep" ? args.videoSource.url : null;
|
|
567
|
+
this.videoSource = args.videoSource;
|
|
568
|
+
// Decided here, before the join, so the SoftAP orchestrator can read it between the join and
|
|
569
|
+
// telling the glasses what to capture.
|
|
570
|
+
const lc3Uplink = glassesLc3UplinkSupported({
|
|
571
|
+
videoSource: args.videoSource,
|
|
572
|
+
audioSource: resolved.source,
|
|
573
|
+
hasPushOutgoingPcm: typeof native.pushOutgoingPcm === "function",
|
|
574
|
+
platform: Platform.OS,
|
|
575
|
+
});
|
|
576
|
+
this.micTransport = lc3Uplink ? "ble-lc3" : resolved.source === "phone" ? "phone" : "whip";
|
|
166
577
|
this.bindNative(native, packageName);
|
|
167
578
|
console.log("[AcsMeeting] phase=join-native", {
|
|
168
579
|
packageName,
|
|
169
580
|
nativeLoaded: true,
|
|
170
581
|
hasToken: Boolean(args.token),
|
|
171
|
-
|
|
582
|
+
transport: args.videoSource.type,
|
|
172
583
|
audioSource: resolved.source,
|
|
173
584
|
audioSourceReason: resolved.reason,
|
|
585
|
+
micTransport: this.micTransport,
|
|
174
586
|
preferredMic: useSettingsStore.getState().getSetting(SETTINGS.preferred_mic.key),
|
|
175
587
|
});
|
|
588
|
+
const joinStartedAt = Date.now();
|
|
589
|
+
softapTrace("acs_native_join", {
|
|
590
|
+
packageName,
|
|
591
|
+
generation,
|
|
592
|
+
transport: args.videoSource.type,
|
|
593
|
+
audioSource: resolved.source,
|
|
594
|
+
micTransport: this.micTransport,
|
|
595
|
+
audioDelayMs: lc3Uplink ? SOFTAP_LC3_AUDIO_DELAY_MS : 0,
|
|
596
|
+
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
597
|
+
});
|
|
176
598
|
try {
|
|
177
599
|
const state = await native.join({
|
|
178
600
|
meetingUrl: args.meetingUrl,
|
|
179
601
|
token: args.token,
|
|
180
|
-
whepUrl:
|
|
602
|
+
whepUrl: this.whepUrl ?? "",
|
|
603
|
+
videoSource: args.videoSource,
|
|
181
604
|
displayName: args.displayName,
|
|
182
605
|
audioSource: resolved.source,
|
|
606
|
+
...(lc3Uplink ? { audioDelayMs: SOFTAP_LC3_AUDIO_DELAY_MS } : {}),
|
|
183
607
|
...(video ? { video } : {}),
|
|
184
608
|
});
|
|
609
|
+
softapTrace("acs_native_join_returned", {
|
|
610
|
+
packageName,
|
|
611
|
+
generation,
|
|
612
|
+
state: state.state,
|
|
613
|
+
hasIngestUrl: typeof state.ingestUrl === "string" && state.ingestUrl.length > 0,
|
|
614
|
+
durationMs: Date.now() - joinStartedAt,
|
|
615
|
+
});
|
|
616
|
+
if (generation !== this.callGeneration) {
|
|
617
|
+
// The wearer left while ACS was still joining. Nothing above knows about this call, so
|
|
618
|
+
// hanging it up here is the only thing that takes the device out of the Teams roster.
|
|
619
|
+
console.warn("[AcsMeeting] phase=join-cancelled", { packageName, generation });
|
|
620
|
+
softapTraceFailure("acs_native_join_cancelled", {
|
|
621
|
+
packageName,
|
|
622
|
+
generation,
|
|
623
|
+
current: this.callGeneration,
|
|
624
|
+
durationMs: Date.now() - joinStartedAt,
|
|
625
|
+
});
|
|
626
|
+
await native.leave().catch((leaveError) => {
|
|
627
|
+
console.warn("[AcsMeeting] native leave after a cancelled join failed", leaveError);
|
|
628
|
+
softapTraceFailure("acs_native_leave_after_cancelled_join_failed", {
|
|
629
|
+
packageName,
|
|
630
|
+
reason: leaveError instanceof Error ? leaveError.message : String(leaveError),
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
throw new Error("The meeting was cancelled before it finished joining");
|
|
634
|
+
}
|
|
635
|
+
this.ingestUrl = typeof state.ingestUrl === "string" ? state.ingestUrl : null;
|
|
636
|
+
const { ingestUrl: _ingestUrl, ...meetingState } = state;
|
|
185
637
|
this.lastState = {
|
|
186
|
-
...
|
|
638
|
+
...meetingState,
|
|
187
639
|
audioSource: resolved.source,
|
|
188
640
|
audioSourceReason: resolved.reason,
|
|
641
|
+
micTransport: this.micTransport,
|
|
189
642
|
};
|
|
190
643
|
console.log("[AcsMeeting] phase=join-native-ok", { state: state.state, muted: state.muted });
|
|
644
|
+
this.startGlassesMicUplink(generation);
|
|
191
645
|
}
|
|
192
646
|
catch (error) {
|
|
193
647
|
// Native never joined (or is unwinding). Release the slot so the same or another
|
|
@@ -196,8 +650,18 @@ class AcsMeetingService {
|
|
|
196
650
|
packageName,
|
|
197
651
|
error: error instanceof Error ? error.message : String(error),
|
|
198
652
|
});
|
|
653
|
+
softapTraceFailure("acs_native_join_failed", {
|
|
654
|
+
packageName,
|
|
655
|
+
generation,
|
|
656
|
+
durationMs: Date.now() - joinStartedAt,
|
|
657
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
658
|
+
});
|
|
199
659
|
await native.leave().catch((leaveError) => {
|
|
200
660
|
console.warn("[AcsMeeting] native leave after failed join also failed", leaveError);
|
|
661
|
+
softapTraceFailure("acs_native_leave_after_failed_join_failed", {
|
|
662
|
+
packageName,
|
|
663
|
+
reason: leaveError instanceof Error ? leaveError.message : String(leaveError),
|
|
664
|
+
});
|
|
201
665
|
});
|
|
202
666
|
await this.releaseHostState();
|
|
203
667
|
throw error;
|
|
@@ -218,30 +682,347 @@ class AcsMeetingService {
|
|
|
218
682
|
return this.lastState;
|
|
219
683
|
}
|
|
220
684
|
async leave(packageName) {
|
|
221
|
-
if (this.owner && this.owner !== packageName)
|
|
685
|
+
if (this.owner && this.owner !== packageName) {
|
|
686
|
+
// Not the owner, so this is a no-op rather than a leave. Said out loud because a miniapp
|
|
687
|
+
// that thinks it left and a host that never hung up look identical from the miniapp's side.
|
|
688
|
+
softapTrace("acs_native_leave_ignored", { packageName, owner: this.owner });
|
|
222
689
|
return;
|
|
690
|
+
}
|
|
223
691
|
const native = getNative();
|
|
692
|
+
const startedAt = Date.now();
|
|
693
|
+
softapTrace("acs_native_leave", { packageName, nativeLoaded: Boolean(native) });
|
|
224
694
|
try {
|
|
225
695
|
await native?.leave();
|
|
696
|
+
softapTrace("acs_native_leave_ok", { packageName, durationMs: Date.now() - startedAt });
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
softapTraceFailure("acs_native_leave_failed", {
|
|
700
|
+
packageName,
|
|
701
|
+
durationMs: Date.now() - startedAt,
|
|
702
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
703
|
+
});
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
await this.releaseHostState();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Leave, and do not resolve until native reports its cleanup is finished.
|
|
712
|
+
*
|
|
713
|
+
* The difference from [leave] is the whole point: native's `leave()` queues the hang-up, the
|
|
714
|
+
* agent disposal, and the network releases on its session executor and returns straight away, so
|
|
715
|
+
* a caller that awaits it and then starts the next call is racing the previous one's teardown.
|
|
716
|
+
*
|
|
717
|
+
* Falls back to [leave] on builds that predate the signal and says so, rather than pretending to
|
|
718
|
+
* a guarantee it cannot give.
|
|
719
|
+
*
|
|
720
|
+
* @param timeoutMs how long native may take before it reports the cleanup as stuck
|
|
721
|
+
*/
|
|
722
|
+
async leaveAndAwait(packageName, timeoutMs = ACS_LEAVE_WAIT_MS) {
|
|
723
|
+
if (this.owner && this.owner !== packageName) {
|
|
724
|
+
softapTrace("acs_native_leave_and_await_ignored", { packageName, owner: this.owner });
|
|
725
|
+
return { completed: true };
|
|
726
|
+
}
|
|
727
|
+
const native = getNative();
|
|
728
|
+
if (!native?.leaveAndAwait) {
|
|
729
|
+
// The fallback is the case the cleanup barrier was built for: this build's leave returns
|
|
730
|
+
// before its own teardown has finished, so nothing downstream can treat "left" as "idle".
|
|
731
|
+
softapTraceFailure("acs_native_leave_and_await_unsupported", {
|
|
732
|
+
packageName,
|
|
733
|
+
nativeLoaded: Boolean(native),
|
|
734
|
+
});
|
|
735
|
+
await this.leave(packageName);
|
|
736
|
+
return { completed: false, reason: "unsupported" };
|
|
737
|
+
}
|
|
738
|
+
const startedAt = Date.now();
|
|
739
|
+
softapTrace("acs_native_leave_and_await", { packageName, timeoutMs });
|
|
740
|
+
try {
|
|
741
|
+
const outcome = await native.leaveAndAwait({ timeoutMs });
|
|
742
|
+
softapTrace("acs_native_leave_and_await_ok", {
|
|
743
|
+
packageName,
|
|
744
|
+
completed: outcome?.completed !== false,
|
|
745
|
+
durationMs: Date.now() - startedAt,
|
|
746
|
+
});
|
|
747
|
+
return { completed: outcome?.completed !== false };
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
softapTraceFailure("acs_native_leave_and_await_failed", {
|
|
751
|
+
packageName,
|
|
752
|
+
durationMs: Date.now() - startedAt,
|
|
753
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
754
|
+
});
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
await this.releaseHostState();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* End the Teams group call for everyone.
|
|
763
|
+
*
|
|
764
|
+
* Host state is released either way: native tears this device down whatever the hang-up did, so
|
|
765
|
+
* holding the meeting slot open after a rejection would leave the miniapp unable to start another
|
|
766
|
+
* call. The rejection still propagates — the caller has to be able to say "you left, but the
|
|
767
|
+
* meeting may still be active" instead of claiming a clean end.
|
|
768
|
+
*/
|
|
769
|
+
async endForEveryone(packageName) {
|
|
770
|
+
if (this.owner && this.owner !== packageName) {
|
|
771
|
+
throw new Error("This miniapp does not own the active meeting");
|
|
772
|
+
}
|
|
773
|
+
const native = getNative();
|
|
774
|
+
if (!native?.endForEveryone) {
|
|
775
|
+
softapTraceFailure("acs_native_end_unsupported", { packageName, nativeLoaded: Boolean(native) });
|
|
776
|
+
throw new Error("Update the Mentra App to end a meeting for everyone");
|
|
777
|
+
}
|
|
778
|
+
const startedAt = Date.now();
|
|
779
|
+
softapTrace("acs_native_end", { packageName });
|
|
780
|
+
try {
|
|
781
|
+
const state = await native.endForEveryone();
|
|
782
|
+
console.log("[AcsMeeting] phase=end-for-everyone-ok", { state: state.state });
|
|
783
|
+
softapTrace("acs_native_end_ok", { packageName, state: state.state, durationMs: Date.now() - startedAt });
|
|
784
|
+
return state;
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
// The meeting may still be live for the others; this device is out regardless. Logged as a
|
|
788
|
+
// failure of the claim, not of the teardown.
|
|
789
|
+
softapTraceFailure("acs_native_end_failed", {
|
|
790
|
+
packageName,
|
|
791
|
+
durationMs: Date.now() - startedAt,
|
|
792
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
793
|
+
});
|
|
794
|
+
throw error;
|
|
226
795
|
}
|
|
227
796
|
finally {
|
|
228
797
|
await this.releaseHostState();
|
|
229
798
|
}
|
|
230
799
|
}
|
|
800
|
+
/** Whether the wearer may end this meeting for everyone, as ACS last reported it. */
|
|
801
|
+
hangUpForEveryoneCapability() {
|
|
802
|
+
return this.lastState.capabilities?.hangUpForEveryone ?? { allowed: null, reason: null };
|
|
803
|
+
}
|
|
231
804
|
/**
|
|
232
|
-
* Drop everything the host set up around a session: the network watcher,
|
|
233
|
-
* audio, native listeners, ownership.
|
|
234
|
-
*
|
|
805
|
+
* Drop everything the host set up around a session: the microphone uplink, the network watcher,
|
|
806
|
+
* return audio, native listeners, ownership.
|
|
807
|
+
*
|
|
808
|
+
* Idempotent, because there are now several terminal paths into it and they overlap. A remote
|
|
809
|
+
* hang-up arrives as a native `disconnected`, and the miniapp usually calls `leave` right after
|
|
810
|
+
* seeing it; releasing the microphone pin twice would clear a pin the *next* call had already
|
|
811
|
+
* taken. Every release also bumps the call generation, which is what stops a `mic_pcm` event
|
|
812
|
+
* already queued on the JS thread from reaching a native session that is gone.
|
|
235
813
|
*/
|
|
236
814
|
async releaseHostState() {
|
|
815
|
+
if (this.hostStateReleased)
|
|
816
|
+
return;
|
|
817
|
+
this.hostStateReleased = true;
|
|
818
|
+
this.callGeneration++;
|
|
819
|
+
// Before anything else: a caller parked on a frame that will now never arrive has to be
|
|
820
|
+
// rejected, or a leave mid-join leaves the orchestrator waiting out its whole timeout.
|
|
821
|
+
for (const settle of [...this.firstFrameWaiters])
|
|
822
|
+
settle(new Error("The meeting ended"));
|
|
823
|
+
this.firstFrameWaiters.clear();
|
|
824
|
+
this.stopGlassesMicUplink();
|
|
237
825
|
this.unwatchPhoneNetwork();
|
|
238
826
|
await this.stopPcm();
|
|
239
827
|
this.unbindNative();
|
|
828
|
+
this.scopedLostSub?.remove();
|
|
829
|
+
this.scopedLostSub = null;
|
|
240
830
|
this.owner = null;
|
|
241
831
|
this.whepUrl = null;
|
|
832
|
+
this.videoSource = null;
|
|
833
|
+
this.ingestUrl = null;
|
|
242
834
|
this.lastMediaRestartAt = 0;
|
|
243
835
|
this.lastState = { state: "idle", muted: false };
|
|
244
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Start forwarding the glasses microphone into ACS for this call.
|
|
839
|
+
*
|
|
840
|
+
* Order matters and is the whole point: pin the Bluetooth SDK to the glasses *before* asking it
|
|
841
|
+
* for PCM, so the first frame the mic requirement produces is already from the right source and
|
|
842
|
+
* the phone microphone is never opened even for one buffer. `generation` is captured by the
|
|
843
|
+
* listener so a frame that lands after this call ended is dropped rather than pushed at a native
|
|
844
|
+
* session that has left the meeting.
|
|
845
|
+
*/
|
|
846
|
+
startGlassesMicUplink(generation) {
|
|
847
|
+
if (this.micTransport !== "ble-lc3")
|
|
848
|
+
return;
|
|
849
|
+
const native = getNative();
|
|
850
|
+
const push = native?.pushOutgoingPcm;
|
|
851
|
+
if (!native || !push)
|
|
852
|
+
return;
|
|
853
|
+
this.micFramesForwarded = 0;
|
|
854
|
+
this.micFramesWindow = 0;
|
|
855
|
+
this.micDropsStale = 0;
|
|
856
|
+
this.micDropsNonGlasses = 0;
|
|
857
|
+
this.micGaps = 0;
|
|
858
|
+
this.micGapMsMax = 0;
|
|
859
|
+
this.lastGlassesFrameAt = Date.now();
|
|
860
|
+
this.lastMicDropLogAt = 0;
|
|
861
|
+
this.lastMicUplinkLogAt = Date.now();
|
|
862
|
+
this.micLevel.take();
|
|
863
|
+
this.lastMicLevel = null;
|
|
864
|
+
try {
|
|
865
|
+
void Promise.resolve(BluetoothSdk.setMicSourcePin?.(GLASSES_MIC_SOURCE)).catch((error) => {
|
|
866
|
+
console.warn("[AcsMeeting] pinning the glasses microphone failed", error);
|
|
867
|
+
});
|
|
868
|
+
this.micUplinkActive = true;
|
|
869
|
+
micStateCoordinator.setCallRequirement(true);
|
|
870
|
+
this.micSub = BluetoothSdk.addListener("mic_pcm", (event) => {
|
|
871
|
+
if (generation !== this.callGeneration) {
|
|
872
|
+
this.micDropsStale += 1;
|
|
873
|
+
this.logMicDrop("stale", { generation, active: this.callGeneration });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
// The pin makes this unreachable in normal operation, which is exactly why it is checked:
|
|
877
|
+
// a mic the SDK moved under us would otherwise put the phone's room into a Teams call
|
|
878
|
+
// that reports the glasses.
|
|
879
|
+
if (event.source !== GLASSES_MIC_SOURCE) {
|
|
880
|
+
this.micDropsNonGlasses += 1;
|
|
881
|
+
this.logMicDrop("non-glasses", { source: event.source });
|
|
882
|
+
this.reportGlassesMicUnavailable(event.source);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const pcm = event.pcm;
|
|
886
|
+
if (!pcm)
|
|
887
|
+
return;
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
if (this.micFramesForwarded > 0) {
|
|
890
|
+
const gapMs = now - this.lastGlassesFrameAt;
|
|
891
|
+
if (gapMs > MIC_GAP_WARN_MS) {
|
|
892
|
+
this.micGaps += 1;
|
|
893
|
+
this.micGapMsMax = Math.max(this.micGapMsMax, gapMs);
|
|
894
|
+
this.logMicDrop("gap", { gapMs });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
this.lastGlassesFrameAt = now;
|
|
898
|
+
this.micFramesForwarded += 1;
|
|
899
|
+
this.micFramesWindow += 1;
|
|
900
|
+
this.micLevel.add(pcm);
|
|
901
|
+
try {
|
|
902
|
+
push.call(native, pcmToBase64(pcm), event.sampleRate ?? 16000, 1);
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
console.warn("[AcsMeeting] pushing glasses PCM to ACS failed", error);
|
|
906
|
+
}
|
|
907
|
+
this.logMicUplink();
|
|
908
|
+
});
|
|
909
|
+
console.log("[AcsMeeting] phase=glasses-mic-uplink-start", { generation, micTransport: this.micTransport });
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
// A host that cannot subscribe has no wearer audio at all, and that is worth saying loudly,
|
|
913
|
+
// but it is not worth failing a call the wearer can still see and hear.
|
|
914
|
+
console.error("[AcsMeeting] phase=glasses-mic-uplink-unavailable", error);
|
|
915
|
+
this.stopGlassesMicUplink();
|
|
916
|
+
this.markMicTransportNone();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/** Release the microphone claims this call took. Safe to call when it never took them. */
|
|
920
|
+
stopGlassesMicUplink() {
|
|
921
|
+
this.micSub?.remove();
|
|
922
|
+
this.micSub = null;
|
|
923
|
+
if (this.micUplinkActive) {
|
|
924
|
+
this.micUplinkActive = false;
|
|
925
|
+
micStateCoordinator.setCallRequirement(false);
|
|
926
|
+
// Last, and unconditionally: while the pin is set no other consumer can pick a microphone,
|
|
927
|
+
// so leaving it behind would leave captions and the cloud uplink stuck on the glasses.
|
|
928
|
+
void Promise.resolve(BluetoothSdk.setMicSourcePin?.(null)).catch((error) => {
|
|
929
|
+
console.warn("[AcsMeeting] releasing the glasses microphone pin failed", error);
|
|
930
|
+
});
|
|
931
|
+
console.log("[AcsMeeting] phase=glasses-mic-uplink-stop", {
|
|
932
|
+
frames: this.micFramesForwarded,
|
|
933
|
+
dropsStale: this.micDropsStale,
|
|
934
|
+
dropsNonGlasses: this.micDropsNonGlasses,
|
|
935
|
+
gaps: this.micGaps,
|
|
936
|
+
gapMsMax: this.micGapMsMax,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
this.micTransport = "whip";
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Report a call whose pinned microphone stopped delivering. There is deliberately no fallback:
|
|
943
|
+
* the wearer agreed to be heard from the glasses, and quietly switching to the phone in the
|
|
944
|
+
* middle of a meeting puts the room they are standing in on the call.
|
|
945
|
+
*/
|
|
946
|
+
reportGlassesMicUnavailable(source) {
|
|
947
|
+
if (Date.now() - this.lastGlassesFrameAt < GLASSES_MIC_GRACE_MS)
|
|
948
|
+
return;
|
|
949
|
+
if (this.micTransport === "none")
|
|
950
|
+
return;
|
|
951
|
+
console.error("[AcsMeeting] phase=glasses-mic-unavailable", { source, dropsNonGlasses: this.micDropsNonGlasses });
|
|
952
|
+
this.markMicTransportNone();
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Record that this call has no wearer audio path.
|
|
956
|
+
*
|
|
957
|
+
* The state has to carry it, not just the field: a call that reported `ble-lc3` and then failed
|
|
958
|
+
* to start the uplink looks identical from the miniapp's side to one that is working, and the
|
|
959
|
+
* wearer finds out by being asked to repeat themselves.
|
|
960
|
+
*/
|
|
961
|
+
markMicTransportNone() {
|
|
962
|
+
this.micTransport = "none";
|
|
963
|
+
this.lastState = { ...this.lastState, micTransport: "none", error: GLASSES_MIC_UNAVAILABLE };
|
|
964
|
+
if (this.owner)
|
|
965
|
+
this.onState?.(this.owner, this.lastState);
|
|
966
|
+
}
|
|
967
|
+
logMicUplink() {
|
|
968
|
+
const now = Date.now();
|
|
969
|
+
const elapsed = now - this.lastMicUplinkLogAt;
|
|
970
|
+
if (elapsed < MIC_UPLINK_LOG_INTERVAL_MS)
|
|
971
|
+
return;
|
|
972
|
+
this.lastMicUplinkLogAt = now;
|
|
973
|
+
// Level, not just cadence: a 20 Hz stream of the noise floor and a 20 Hz stream of speech
|
|
974
|
+
// have the same framesPerSecond. Quiet room on Mentra Live LC3 is meanAbs ≈30–60.
|
|
975
|
+
const level = this.micLevel.take();
|
|
976
|
+
this.lastMicLevel = { meanAbs: level.meanAbs, peak: level.peak };
|
|
977
|
+
console.log("[AcsMeeting] phase=glasses-mic-uplink", {
|
|
978
|
+
framesPerSecond: Math.round((this.micFramesWindow * 1000) / elapsed),
|
|
979
|
+
frames: this.micFramesForwarded,
|
|
980
|
+
meanAbs: level.meanAbs,
|
|
981
|
+
peak: level.peak,
|
|
982
|
+
dropsStale: this.micDropsStale,
|
|
983
|
+
dropsNonGlasses: this.micDropsNonGlasses,
|
|
984
|
+
gaps: this.micGaps,
|
|
985
|
+
gapMsMax: this.micGapMsMax,
|
|
986
|
+
});
|
|
987
|
+
// #region agent log
|
|
988
|
+
fetch("http://127.0.0.1:7905/ingest/5a9713c9-45ff-4d09-9435-2adc5db5e91d", {
|
|
989
|
+
method: "POST",
|
|
990
|
+
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "828181" },
|
|
991
|
+
body: JSON.stringify({
|
|
992
|
+
sessionId: "828181",
|
|
993
|
+
runId: "run1",
|
|
994
|
+
hypothesisId: "E",
|
|
995
|
+
location: "AcsMeetingService.ts:logMicUplink",
|
|
996
|
+
message: "phone decoded glasses PCM window",
|
|
997
|
+
data: {
|
|
998
|
+
fps: Math.round((this.micFramesWindow * 1000) / elapsed),
|
|
999
|
+
meanAbs: level.meanAbs,
|
|
1000
|
+
peak: level.peak,
|
|
1001
|
+
frames: this.micFramesForwarded,
|
|
1002
|
+
},
|
|
1003
|
+
timestamp: Date.now(),
|
|
1004
|
+
}),
|
|
1005
|
+
}).catch(() => { });
|
|
1006
|
+
// #endregion
|
|
1007
|
+
this.micFramesWindow = 0;
|
|
1008
|
+
}
|
|
1009
|
+
/** Level of the last uplink window, for tests and dev screens. `null` until a window closed. */
|
|
1010
|
+
lastGlassesMicLevel() {
|
|
1011
|
+
return this.lastMicLevel;
|
|
1012
|
+
}
|
|
1013
|
+
logMicDrop(reason, extra) {
|
|
1014
|
+
const now = Date.now();
|
|
1015
|
+
if (now - this.lastMicDropLogAt < 1000)
|
|
1016
|
+
return;
|
|
1017
|
+
this.lastMicDropLogAt = now;
|
|
1018
|
+
console.warn("[AcsMeeting] phase=glasses-mic-drop", {
|
|
1019
|
+
reason,
|
|
1020
|
+
dropsStale: this.micDropsStale,
|
|
1021
|
+
dropsNonGlasses: this.micDropsNonGlasses,
|
|
1022
|
+
gaps: this.micGaps,
|
|
1023
|
+
...extra,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
245
1026
|
async setMuted(packageName, muted) {
|
|
246
1027
|
this.assertOwner(packageName);
|
|
247
1028
|
const native = getNative();
|
|
@@ -252,6 +1033,9 @@ class AcsMeetingService {
|
|
|
252
1033
|
...this.lastState,
|
|
253
1034
|
...state,
|
|
254
1035
|
audioSourceReason: this.lastState.audioSourceReason,
|
|
1036
|
+
// Mute is an ACS-side gate. It does not change which microphone the call is using, and it
|
|
1037
|
+
// deliberately does not turn the glasses microphone off — see the mute chain in native.
|
|
1038
|
+
micTransport: this.micTransport,
|
|
255
1039
|
};
|
|
256
1040
|
return this.lastState;
|
|
257
1041
|
}
|
|
@@ -260,6 +1044,11 @@ class AcsMeetingService {
|
|
|
260
1044
|
const native = getNative();
|
|
261
1045
|
if (!native)
|
|
262
1046
|
throw new Error("ACS meeting module is not available on this host");
|
|
1047
|
+
if (this.videoSource?.type === "softap") {
|
|
1048
|
+
// The host owns the softap endpoint, so there is no URL for a caller to change. Failing is
|
|
1049
|
+
// better than accepting it and doing nothing.
|
|
1050
|
+
throw new Error("updateVideoSource is not applicable to a SoftAP call");
|
|
1051
|
+
}
|
|
263
1052
|
this.whepUrl = whepUrl;
|
|
264
1053
|
await native.updateVideoSource(whepUrl);
|
|
265
1054
|
}
|
|
@@ -271,8 +1060,8 @@ class AcsMeetingService {
|
|
|
271
1060
|
if (!native)
|
|
272
1061
|
return { state: "idle", muted: false };
|
|
273
1062
|
const state = await native.getState();
|
|
274
|
-
this.lastState = state;
|
|
275
|
-
return
|
|
1063
|
+
this.lastState = { ...state, micTransport: this.micTransport };
|
|
1064
|
+
return this.lastState;
|
|
276
1065
|
}
|
|
277
1066
|
async leaveIfOwner(packageName) {
|
|
278
1067
|
if (this.owner === packageName)
|
|
@@ -300,6 +1089,7 @@ class AcsMeetingService {
|
|
|
300
1089
|
}
|
|
301
1090
|
const participants = parseMeetingParticipants(event.participants);
|
|
302
1091
|
const mediaSource = parseMediaSource(event.mediaSource);
|
|
1092
|
+
const capabilities = parseMeetingCapabilities(event.capabilities);
|
|
303
1093
|
const state = {
|
|
304
1094
|
state: event.state ?? "idle",
|
|
305
1095
|
muted: Boolean(event.muted),
|
|
@@ -310,8 +1100,11 @@ class AcsMeetingService {
|
|
|
310
1100
|
audioSourceReason: this.lastState.audioSourceReason,
|
|
311
1101
|
activeStream: parseActiveStream(event.activeStream),
|
|
312
1102
|
audioSafety,
|
|
1103
|
+
micTransport: this.micTransport,
|
|
313
1104
|
...(mediaSource ? { mediaSource } : {}),
|
|
314
1105
|
...(participants ? { participants } : {}),
|
|
1106
|
+
// Absent means unknown, so keep the last known verdict rather than clearing it.
|
|
1107
|
+
...((capabilities ?? this.lastState.capabilities) ? { capabilities: capabilities ?? this.lastState.capabilities } : {}),
|
|
315
1108
|
};
|
|
316
1109
|
this.lastState = state;
|
|
317
1110
|
console.log("[AcsMeeting] phase=native-state", {
|
|
@@ -322,9 +1115,19 @@ class AcsMeetingService {
|
|
|
322
1115
|
activeStream: state.activeStream,
|
|
323
1116
|
audioSafety: state.audioSafety,
|
|
324
1117
|
mediaSource: state.mediaSource,
|
|
1118
|
+
micTransport: state.micTransport,
|
|
325
1119
|
participants: participants?.length,
|
|
326
1120
|
});
|
|
1121
|
+
this.settleFirstFrameWaiters(mediaSource);
|
|
327
1122
|
this.onState?.(packageName, state);
|
|
1123
|
+
// A remote hang-up, an ACS error or a dropped call never goes through `leave`, so without
|
|
1124
|
+
// this the microphone pin, the PCM requirement and the `mic_pcm` listener would outlive the
|
|
1125
|
+
// meeting — and the next miniapp to start a call would inherit them.
|
|
1126
|
+
if (state.state === "disconnected" || state.state === "error") {
|
|
1127
|
+
void this.releaseHostState().catch((error) => {
|
|
1128
|
+
console.warn("[AcsMeeting] releasing host state after a terminal native state failed", error);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
328
1131
|
}),
|
|
329
1132
|
native.addListener("onIncomingPcm", (event) => {
|
|
330
1133
|
const base64 = event.base64;
|
|
@@ -377,7 +1180,18 @@ class AcsMeetingService {
|
|
|
377
1180
|
}
|
|
378
1181
|
async restartMediaSource(native, reason) {
|
|
379
1182
|
const whepUrl = this.whepUrl;
|
|
380
|
-
|
|
1183
|
+
const softap = this.videoSource?.type === "softap";
|
|
1184
|
+
// SoftAP media is bound to the scoped hotspot, not the phone's default route. Joining that
|
|
1185
|
+
// hotspot is what *causes* NetInfo to flap `none:offline → cellular:online` — the default
|
|
1186
|
+
// route looks gone for a beat, then cellular comes back. Rebuilding the WHIP listener on
|
|
1187
|
+
// that flap changes the ingest port after the glasses already have the old URL, and their
|
|
1188
|
+
// POST hits a tombstone (HTTP 410). A real hotspot loss is `onScopedNetworkLost`, not this
|
|
1189
|
+
// default-network watch. WHEP still needs the rebuild: that path rides the default route.
|
|
1190
|
+
if (softap)
|
|
1191
|
+
return;
|
|
1192
|
+
if (!this.owner || !MEDIA_ACTIVE_PHASES.has(this.lastState.state))
|
|
1193
|
+
return;
|
|
1194
|
+
if (!whepUrl)
|
|
381
1195
|
return;
|
|
382
1196
|
const now = Date.now();
|
|
383
1197
|
if (now - this.lastMediaRestartAt < MEDIA_RESTART_MIN_INTERVAL_MS)
|
|
@@ -389,8 +1203,10 @@ class AcsMeetingService {
|
|
|
389
1203
|
// peer is healthy; after a network switch that belief is exactly what is wrong.
|
|
390
1204
|
if (native.restartVideoSource)
|
|
391
1205
|
await native.restartVideoSource();
|
|
392
|
-
else
|
|
1206
|
+
else if (whepUrl)
|
|
393
1207
|
await native.updateVideoSource(whepUrl);
|
|
1208
|
+
else
|
|
1209
|
+
console.warn("[AcsMeeting] softap restart needs a native restartVideoSource", { reason });
|
|
394
1210
|
}
|
|
395
1211
|
catch (error) {
|
|
396
1212
|
console.warn("[AcsMeeting] media restart failed", { reason, error });
|
|
@@ -435,7 +1251,7 @@ class AcsMeetingService {
|
|
|
435
1251
|
const now = Date.now();
|
|
436
1252
|
if (now - this.lastBacklogWarnAt > 5000) {
|
|
437
1253
|
this.lastBacklogWarnAt = now;
|
|
438
|
-
console.warn("[AcsMeeting] incoming PCM backlog high", { bufferedMs });
|
|
1254
|
+
console.warn("[AcsMeeting] incoming PCM backlog high (downlink playback, not glasses mic)", { bufferedMs });
|
|
439
1255
|
}
|
|
440
1256
|
}
|
|
441
1257
|
}
|
|
@@ -453,6 +1269,7 @@ class AcsMeetingService {
|
|
|
453
1269
|
sampleRate,
|
|
454
1270
|
channels,
|
|
455
1271
|
stopOtherAudio: true,
|
|
1272
|
+
jitterMs: PCM_JITTER_MS,
|
|
456
1273
|
onEnded: () => {
|
|
457
1274
|
if (this.pcmStreamId === streamId) {
|
|
458
1275
|
this.pcmStreamId = null;
|