@mentra/engine 3.2.0-dev.221 → 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.
Files changed (69) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/index.d.ts +2 -0
  4. package/build/index.d.ts.map +1 -1
  5. package/build/index.js +1 -0
  6. package/build/index.js.map +1 -1
  7. package/build/services/AcsMeetingService.d.ts +360 -10
  8. package/build/services/AcsMeetingService.d.ts.map +1 -1
  9. package/build/services/AcsMeetingService.js +836 -19
  10. package/build/services/AcsMeetingService.js.map +1 -1
  11. package/build/services/AudioPlaybackService.d.ts +6 -0
  12. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  13. package/build/services/AudioPlaybackService.js +4 -3
  14. package/build/services/AudioPlaybackService.js.map +1 -1
  15. package/build/services/GlassesMicProbe.d.ts +78 -0
  16. package/build/services/GlassesMicProbe.d.ts.map +1 -0
  17. package/build/services/GlassesMicProbe.js +258 -0
  18. package/build/services/GlassesMicProbe.js.map +1 -0
  19. package/build/services/LocalMiniappRuntime.d.ts +83 -0
  20. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  21. package/build/services/LocalMiniappRuntime.js +714 -39
  22. package/build/services/LocalMiniappRuntime.js.map +1 -1
  23. package/build/services/MentraJSLogPipeline.d.ts +39 -0
  24. package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
  25. package/build/services/MentraJSLogPipeline.js +59 -3
  26. package/build/services/MentraJSLogPipeline.js.map +1 -1
  27. package/build/services/MentraJSRouter.d.ts +1 -1
  28. package/build/services/MentraJSRouter.d.ts.map +1 -1
  29. package/build/services/MentraJSRouter.js +2 -2
  30. package/build/services/MentraJSRouter.js.map +1 -1
  31. package/build/services/MicStateCoordinator.d.ts +22 -0
  32. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  33. package/build/services/MicStateCoordinator.js +34 -3
  34. package/build/services/MicStateCoordinator.js.map +1 -1
  35. package/build/services/PhoneStreamCoordinator.d.ts +8 -0
  36. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  37. package/build/services/PhoneStreamCoordinator.js +2 -0
  38. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  39. package/build/services/SoftapCallTransport.d.ts +365 -0
  40. package/build/services/SoftapCallTransport.d.ts.map +1 -0
  41. package/build/services/SoftapCallTransport.js +722 -0
  42. package/build/services/SoftapCallTransport.js.map +1 -0
  43. package/build/services/SoftapCleanupBarrier.d.ts +45 -0
  44. package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
  45. package/build/services/SoftapCleanupBarrier.js +51 -0
  46. package/build/services/SoftapCleanupBarrier.js.map +1 -0
  47. package/build/utils/pcm16.d.ts +35 -0
  48. package/build/utils/pcm16.d.ts.map +1 -0
  49. package/build/utils/pcm16.js +85 -0
  50. package/build/utils/pcm16.js.map +1 -0
  51. package/build/utils/softapTrace.d.ts +39 -0
  52. package/build/utils/softapTrace.d.ts.map +1 -0
  53. package/build/utils/softapTrace.js +124 -0
  54. package/build/utils/softapTrace.js.map +1 -0
  55. package/package.json +7 -7
  56. package/src/generated/releaseMetadata.ts +5 -5
  57. package/src/index.ts +2 -0
  58. package/src/services/AcsMeetingService.ts +956 -22
  59. package/src/services/AudioPlaybackService.ts +12 -3
  60. package/src/services/GlassesMicProbe.ts +300 -0
  61. package/src/services/LocalMiniappRuntime.ts +775 -42
  62. package/src/services/MentraJSLogPipeline.ts +70 -3
  63. package/src/services/MentraJSRouter.ts +2 -2
  64. package/src/services/MicStateCoordinator.ts +35 -3
  65. package/src/services/PhoneStreamCoordinator.ts +10 -0
  66. package/src/services/SoftapCallTransport.ts +928 -0
  67. package/src/services/SoftapCleanupBarrier.ts +72 -0
  68. package/src/utils/pcm16.ts +93 -0
  69. 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]. Flip that constant to `"glasses"`
12
- * to restore glasses WHIP → Cloudflare → WHEP PCM. Do not use ACS
13
- * LocalOutgoingAudioStream for phone: that path makes ACS own the route
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
- const PCM_BACKLOG_WARN_MS = 600;
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
- this.whepUrl = args.whepUrl;
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
- hasWhep: Boolean(args.whepUrl),
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: args.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
- ...state,
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, return
233
- * audio, native listeners, ownership. Runs after native has been told to leave
234
- * (or after a join that never produced a native call).
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 state;
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
- if (!this.owner || !whepUrl || !MEDIA_ACTIVE_PHASES.has(this.lastState.state))
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;