@mentra/cloud-client 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * @fileoverview `cloud.runtime`: the live session module.
3
+ *
4
+ * This is wiring only. It implements the public `RuntimeModule` by delegating to
5
+ * five collaborators and routing messages between them:
6
+ * - connection: owns the WebSocket, the handshake, reconnect, and liveness.
7
+ * - emitter: the one typed event emitter the public `on*` methods wrap.
8
+ * - subscriptions: the REST full-replace writer with the version counter.
9
+ * - camera: managed photo/stream (REST request, await the WebSocket push).
10
+ * - audio: the UDP audio path (encrypt each frame, hand bytes to the socket).
11
+ * - tts: runtime TTS source preparation for host playback.
12
+ *
13
+ * Keeping the orchestration here, and the mechanics in the collaborators, means
14
+ * each piece is testable on its own and this file reads as the protocol flow:
15
+ * connect, route inbound messages to events, re-send subscriptions on reconnect.
16
+ *
17
+ * See docs/issues/004-cloud-client/spec.md ("cloud.runtime") and design.md.
18
+ */
19
+ import type {
20
+ AudioSubscription,
21
+ TranscriptionData,
22
+ TranslationData,
23
+ ProtocolError,
24
+ ConnectionAck,
25
+ CloudToClientMessage,
26
+ } from "@mentra/cloud-protocol";
27
+ import type { Logger } from "../../logger";
28
+ import type { Connection } from "./connection";
29
+ import { HandshakeRejectedError } from "./connection";
30
+ import type { RuntimeEmitter, RuntimeEvents } from "./emitter";
31
+ import type { Subscriptions } from "./subscriptions";
32
+ import type { Camera, PhotoOptions, StreamOptions, ManagedStream, StreamStatusResult } from "./camera";
33
+ import type { Maps, DirectionsRequest, DirectionsResult, LatLng, ReverseGeocodeResult } from "./maps";
34
+ import type { Tts, RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
35
+ import type { UdpAudio } from "./audio-udp";
36
+ import type { RuntimeSnapshot } from "./status";
37
+
38
+ const UDP_PROBE_INTERVAL_MS = 1_000;
39
+ const UDP_LIVENESS_TIMEOUT_MS = 3_000;
40
+
41
+ // Re-export the camera option/result types so a host importing the runtime gets
42
+ // them from one place alongside the module that produces them.
43
+ export type { PhotoOptions, StreamOptions, ManagedStream, StreamStatusResult } from "./camera";
44
+ export type {
45
+ DirectionsRequest,
46
+ DirectionsResult,
47
+ Route,
48
+ RouteStep,
49
+ LatLng,
50
+ TravelMode,
51
+ ManeuverKind,
52
+ RouteAvoidances,
53
+ ReverseGeocodeResult,
54
+ } from "./maps";
55
+ export type { RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
56
+ export type { RuntimeAudioTransport } from "./audio-udp";
57
+ export type { RuntimeStatus, RuntimeSnapshot } from "./status";
58
+
59
+ /**
60
+ * The public runtime surface, implemented by `Runtime` below.
61
+ *
62
+ * Defined here (rather than in the protocol package) because it is the client's
63
+ * API, not a wire type: it composes the wire types from
64
+ * `@mentra/cloud-protocol` into the methods a host calls. The per-event
65
+ * `on*` methods are sugar over the generic typed emitter, so there is one source
66
+ * of truth and no event-name strings to mistype. Every subscribe call returns an
67
+ * unsubscribe function.
68
+ */
69
+ export interface RuntimeModule {
70
+ connect(): Promise<void>;
71
+ close(): void;
72
+
73
+ setSubscriptions(subs: AudioSubscription[]): Promise<void>;
74
+
75
+ /**
76
+ * Encrypt and send one captured audio frame over UDP. On the phone the native
77
+ * audio bridge calls this per frame (mic -> codec -> here); the bytes are
78
+ * encrypted in the shared core and handed to the injected UDP socket. A frame
79
+ * sent before the session is configured is dropped, not thrown on.
80
+ */
81
+ sendAudioFrame(frame: Uint8Array): void;
82
+
83
+ getStatus(): RuntimeSnapshot;
84
+
85
+ onTranscript(handler: (data: TranscriptionData) => void): () => void;
86
+ onTranslation(handler: (data: TranslationData) => void): () => void;
87
+
88
+ requestManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; readUrl: string }>;
89
+ startManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; uploadUrl: string; readUrl: string }>;
90
+ awaitManagedPhotoReady(requestId: string): Promise<{ requestId: string; readUrl: string }>;
91
+ startManagedStream(opts: StreamOptions): Promise<ManagedStream>;
92
+ getManagedStreamStatus(streamId: string): Promise<StreamStatusResult>;
93
+ stopManagedStream(streamId: string): Promise<void>;
94
+ tts: {
95
+ speak(text: string, options?: RuntimeTtsSpeakOptions): Promise<RuntimeTtsSpeechSource>;
96
+ };
97
+ maps: {
98
+ directions(req: DirectionsRequest): Promise<DirectionsResult>;
99
+ reverseGeocode(coord: LatLng): Promise<ReverseGeocodeResult>;
100
+ };
101
+
102
+ onConnected(handler: () => void): () => void;
103
+ onDisconnected(handler: (info: { reason: string }) => void): () => void;
104
+ onStatusChanged(handler: (status: RuntimeSnapshot) => void): () => void;
105
+ onError(handler: (err: ProtocolError) => void): () => void;
106
+
107
+ on<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): () => void;
108
+ off<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): void;
109
+ onAny(handler: (event: keyof RuntimeEvents, data: unknown) => void): () => void;
110
+ }
111
+
112
+ export interface RuntimeDeps {
113
+ connection: Connection;
114
+ emitter: RuntimeEmitter;
115
+ subscriptions: Subscriptions;
116
+ camera: Camera;
117
+ tts: Tts;
118
+ maps: Maps;
119
+ audio: UdpAudio;
120
+ logger: Logger;
121
+ /**
122
+ * Force `cloud.auth` to drop its cached access token and refresh now.
123
+ *
124
+ * Called when the cloud rejects the handshake with `AUTH_EXPIRED` even though
125
+ * the client thought its token was fresh (clock skew or a mid-session revoke).
126
+ * The connection re-reads the token through its own `getToken` on the next
127
+ * open, so this only has to invalidate-and-refresh; it returns the new token
128
+ * but the runtime ignores it (the reopen picks it up).
129
+ */
130
+ forceRefreshToken: () => Promise<string>;
131
+ }
132
+
133
+ /** Protocol error code the cloud sends when the handshake token is expired. */
134
+ const AUTH_EXPIRED_CODE = "AUTH_EXPIRED";
135
+
136
+ export class Runtime implements RuntimeModule {
137
+ private readonly connection: Connection;
138
+ private readonly emitter: RuntimeEmitter;
139
+ private readonly subscriptions: Subscriptions;
140
+ private readonly camera: Camera;
141
+ public readonly tts: Tts;
142
+ public readonly maps: Maps;
143
+ private readonly audio: UdpAudio;
144
+ private readonly logger: Logger;
145
+ private readonly forceRefreshToken: () => Promise<string>;
146
+ private status: RuntimeSnapshot = {
147
+ status: "disconnected",
148
+ audioTransport: "none",
149
+ };
150
+ private hostClosed = true;
151
+ private udpProbeTimer: ReturnType<typeof setInterval> | null = null;
152
+ private udpProbeStartedAt = 0;
153
+ private lastUdpAckAt = 0;
154
+
155
+ /**
156
+ * Whether the inbound-message routing has been wired to the connection yet.
157
+ *
158
+ * Wiring happens lazily on the first `connect()` so a re-`connect()` (after a
159
+ * `close()`) does not register the same routing twice and double-emit events.
160
+ */
161
+ private routed = false;
162
+
163
+ /**
164
+ * Whether the first successful open has been handled by `connect()`.
165
+ *
166
+ * The connection fires `onState("open")` on every open, including the first.
167
+ * `connect()` already handles the first open directly (configure audio, emit
168
+ * connected), so this flag lets the state callback treat only *subsequent*
169
+ * opens as reconnects, avoiding a double audio-configure and a re-send on the
170
+ * very first connect.
171
+ */
172
+ private opened = false;
173
+
174
+ constructor(deps: RuntimeDeps) {
175
+ this.connection = deps.connection;
176
+ this.emitter = deps.emitter;
177
+ this.subscriptions = deps.subscriptions;
178
+ this.camera = deps.camera;
179
+ this.tts = deps.tts;
180
+ this.maps = deps.maps;
181
+ this.audio = deps.audio;
182
+ this.logger = deps.logger;
183
+ this.forceRefreshToken = deps.forceRefreshToken;
184
+ }
185
+
186
+ /**
187
+ * Open the live session and start routing inbound messages to events.
188
+ *
189
+ * The connection owns the handshake (init/ack), reconnect with backoff, and
190
+ * liveness; this method wires the routing once, then opens it. On the ack we
191
+ * configure the UDP audio path (the ack carries the sessionTag, host/port, and
192
+ * key) and announce `connected`. A later reconnect re-runs the handshake inside
193
+ * the connection and re-fires the open path through the wired callbacks, so the
194
+ * subscription re-send below is what restores a fresh session's audio set.
195
+ *
196
+ * If the cloud rejects the handshake with a fatal `AUTH_EXPIRED` (the token the
197
+ * client presented was expired or revoked despite the client thinking it was
198
+ * fresh), we force `cloud.auth` to refresh and reopen ONCE. The connection
199
+ * re-reads the freshly refreshed token through its own `getToken` on the
200
+ * reopen. We retry only once: if the second open also fails, the original error
201
+ * surfaces, because a second failure means refresh did not fix it and looping
202
+ * would not help.
203
+ */
204
+ async connect(): Promise<void> {
205
+ this.hostClosed = false;
206
+ this.wireRouting();
207
+ const ack = await this.openWithAuthRetry();
208
+ if (!this.opened) {
209
+ this.onOpened(ack);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Open the connection, retrying exactly once on a fatal `AUTH_EXPIRED`.
215
+ *
216
+ * Only `AUTH_EXPIRED` triggers the retry; any other handshake rejection (a bad
217
+ * protocol version, a hard auth failure) is surfaced as-is, since refreshing
218
+ * the token would not change the outcome. The retry forces a token refresh
219
+ * first, then reopens; if that reopen also rejects, the reopen's error
220
+ * propagates so the host sees the real, post-refresh failure.
221
+ */
222
+ private async openWithAuthRetry(): Promise<ConnectionAck> {
223
+ try {
224
+ return await this.connection.open();
225
+ } catch (err) {
226
+ if (!(err instanceof HandshakeRejectedError) || err.code !== AUTH_EXPIRED_CODE) {
227
+ throw err;
228
+ }
229
+ // The cloud says the token is expired even though we believed it fresh.
230
+ // Refresh once (the reopen re-reads the new token via getToken) and retry.
231
+ this.logger.warn("handshake rejected AUTH_EXPIRED; refreshing token and reopening once");
232
+ await this.forceRefreshToken();
233
+ return this.connection.open();
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Wire the connection's message/state callbacks to the emitter and camera.
239
+ *
240
+ * Done once (guarded by `routed`). Inbound routing:
241
+ * - stream.transcript -> emitter "transcript"
242
+ * - stream.translation -> emitter "translation"
243
+ * - error -> emitter "error" (the typed ProtocolError payload)
244
+ * - photo pushes -> camera.handlePush (resolves the pending request)
245
+ *
246
+ * Connection state drives `disconnected` and the subscription re-send on
247
+ * reconnect. We hand every message to `camera.handlePush` regardless of type
248
+ * because it self-filters to photo pushes, which keeps the routing switch here
249
+ * about the events this module owns.
250
+ */
251
+ private wireRouting(): void {
252
+ if (this.routed) return;
253
+ this.routed = true;
254
+
255
+ this.connection.onMessage((msg: CloudToClientMessage) => {
256
+ switch (msg.type) {
257
+ case "stream.transcript":
258
+ // The discriminated union narrows `msg.payload` to TranscriptionData
259
+ // here, so the emit is fully typed with no cast.
260
+ this.emitter.emit("transcript", msg.payload);
261
+ break;
262
+ case "stream.translation":
263
+ this.emitter.emit("translation", msg.payload);
264
+ break;
265
+ case "audio.udp_liveness_ack":
266
+ this.handleUdpLivenessAck(msg.payload);
267
+ break;
268
+ case "error":
269
+ this.emitter.emit("error", msg.payload);
270
+ break;
271
+ default:
272
+ // Not one of the events this module surfaces directly. It may still be
273
+ // a camera push, so hand it on; camera ignores anything that is not a
274
+ // photo event.
275
+ break;
276
+ }
277
+ // Managed photo/stream completions are pushes too; let camera claim them.
278
+ this.camera.handlePush(msg);
279
+ });
280
+
281
+ this.connection.onState((state) => {
282
+ if (state === "connecting") {
283
+ this.updateStatus({
284
+ status: this.opened ? "reconnecting" : "connecting",
285
+ });
286
+ return;
287
+ }
288
+
289
+ if (state === "open") {
290
+ if (!this.opened) {
291
+ // Usually the first open is handled by connect() directly. If that
292
+ // initial attempt failed, Connection still keeps retrying underneath;
293
+ // the first later successful retry reaches us only through this state
294
+ // callback, so it must become the initial open instead of being
295
+ // ignored.
296
+ const ack = this.connection.ack;
297
+ if (ack) this.onOpened(ack);
298
+ return;
299
+ }
300
+ // A reconnect re-opened the socket and redid the handshake. The cloud may
301
+ // have a fresh session with an empty subscription set, so re-send the
302
+ // current set (at the current version) to restore live transcription.
303
+ void this.handleReopen();
304
+ } else if (state === "closed") {
305
+ this.stopUdpLiveness();
306
+ this.updateStatus({
307
+ status: this.hostClosed
308
+ ? "disconnected"
309
+ : this.opened
310
+ ? "reconnecting"
311
+ : "connecting",
312
+ audioTransport: "none",
313
+ });
314
+ this.emitter.emit("disconnected", { reason: "socket closed" });
315
+ }
316
+ });
317
+ }
318
+
319
+ /**
320
+ * React to a (re)opened socket after the handshake completed.
321
+ *
322
+ * Reconfigures the UDP audio path against the new ack (a fresh session brings a
323
+ * fresh key and tag) and re-sends the subscription set so the new session is
324
+ * not left transcribing against an empty set. Guarded on the ack being present
325
+ * because a reconnect's `open` state can briefly precede the new ack being
326
+ * recorded; the next `open` (or an explicit setSubscriptions) then covers it.
327
+ */
328
+ private async handleReopen(): Promise<void> {
329
+ const ack = this.connection.ack;
330
+ if (!ack) return;
331
+ this.configureAudio(ack);
332
+ this.updateStatus({ status: "connected" });
333
+ // Announce the reconnection the same way the first open does. Without this,
334
+ // a host that tracks liveness via `onConnected`/`onDisconnected` would stay
335
+ // stuck "disconnected" after every reconnect (its flag never flips back),
336
+ // even though the socket is live and transcription has resumed.
337
+ this.emitter.emit("connected", undefined);
338
+ try {
339
+ await this.subscriptions.resend(ack.sessionId);
340
+ } catch (err) {
341
+ // A failed re-send is not fatal to the connection: log and let the next
342
+ // explicit setSubscriptions or reconnect retry. Never log token material.
343
+ this.logger.warn("subscription resend failed after reconnect", {
344
+ sessionId: ack.sessionId,
345
+ });
346
+ void err;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Handle the first successful open from `connect()`.
352
+ *
353
+ * Configures audio from the ack and announces `connected`. Subscriptions are
354
+ * not re-sent here because the initial set rides in `connection.init` (seeded
355
+ * with the session); the re-send path is specifically for reconnects.
356
+ */
357
+ private onOpened(ack: ConnectionAck): void {
358
+ this.opened = true;
359
+ this.configureAudio(ack);
360
+ this.updateStatus({ status: "connected" });
361
+ this.emitter.emit("connected", undefined);
362
+ }
363
+
364
+ /**
365
+ * Configure the UDP audio path from an ack, when the cloud offered UDP audio.
366
+ *
367
+ * The ack's `audio` block is optional: on the WebSocket audio fallback there is
368
+ * no UDP and no key, so there is nothing to configure and we leave the audio
369
+ * path idle.
370
+ */
371
+ private configureAudio(ack: ConnectionAck): void {
372
+ if (ack.audio) {
373
+ this.audio.configure(ack.audio);
374
+ this.updateStatus({ audioTransport: "udp" });
375
+ this.startUdpLiveness();
376
+ return;
377
+ }
378
+ this.audio.close();
379
+ this.stopUdpLiveness();
380
+ this.updateStatus({ audioTransport: "none" });
381
+ }
382
+
383
+ /**
384
+ * Close the session: tear down the UDP audio path and the WebSocket.
385
+ *
386
+ * Audio is closed first so no frame is sent on a socket that is going away.
387
+ */
388
+ close(): void {
389
+ this.hostClosed = true;
390
+ this.opened = false;
391
+ this.stopUdpLiveness();
392
+ this.audio.close();
393
+ this.updateStatus({ status: "disconnected", audioTransport: "none" });
394
+ this.connection.close();
395
+ }
396
+
397
+ /**
398
+ * Replace the cloud's subscription set for the current session.
399
+ *
400
+ * Uses the `sessionId` from the current ack so the cloud ties the write to this
401
+ * session. Throws if there is no live session (no ack), because a subscription
402
+ * write without a session would be silently ignored by the cloud and a silent
403
+ * no-op is worse than a clear error here.
404
+ */
405
+ async setSubscriptions(subs: AudioSubscription[]): Promise<void> {
406
+ const ack = this.connection.ack;
407
+ if (!ack) {
408
+ throw new Error("Cannot set subscriptions before the session is connected");
409
+ }
410
+ await this.subscriptions.set(subs, ack.sessionId);
411
+ }
412
+
413
+ /** Encrypt and send one audio frame over the UDP path (see RuntimeModule). */
414
+ sendAudioFrame(frame: Uint8Array): void {
415
+ if (this.status.audioTransport === "ws") {
416
+ const packet = this.audio.buildPlainFrame(frame);
417
+ if (packet) {
418
+ this.connection.sendBinary(packet);
419
+ }
420
+ return;
421
+ }
422
+
423
+ if (this.status.audioTransport === "udp") {
424
+ this.audio.sendFrame(frame);
425
+ }
426
+ }
427
+
428
+ getStatus(): RuntimeSnapshot {
429
+ return { ...this.status };
430
+ }
431
+
432
+ // --- Camera: managed photo/stream (delegated) -----------------------------
433
+
434
+ requestManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; readUrl: string }> {
435
+ return this.camera.requestPhoto(opts);
436
+ }
437
+
438
+ /** Device-side managed photo: presign now, deliver bytes yourself, then await ready. */
439
+ startManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; uploadUrl: string; readUrl: string }> {
440
+ return this.camera.startPhoto(opts);
441
+ }
442
+
443
+ awaitManagedPhotoReady(requestId: string): Promise<{ requestId: string; readUrl: string }> {
444
+ return this.camera.awaitPhotoReady(requestId);
445
+ }
446
+
447
+ startManagedStream(opts: StreamOptions): Promise<ManagedStream> {
448
+ return this.camera.startStream(opts);
449
+ }
450
+
451
+ getManagedStreamStatus(streamId: string): Promise<StreamStatusResult> {
452
+ return this.camera.streamStatus(streamId);
453
+ }
454
+
455
+ stopManagedStream(streamId: string): Promise<void> {
456
+ return this.camera.stopStream(streamId);
457
+ }
458
+
459
+ // --- Events (delegated to the one typed emitter) --------------------------
460
+
461
+ onTranscript(handler: (data: TranscriptionData) => void): () => void {
462
+ return this.emitter.on("transcript", handler);
463
+ }
464
+
465
+ onTranslation(handler: (data: TranslationData) => void): () => void {
466
+ return this.emitter.on("translation", handler);
467
+ }
468
+
469
+ onConnected(handler: () => void): () => void {
470
+ // The "connected" event carries no payload; adapt the void payload to the
471
+ // caller's zero-arg handler.
472
+ return this.emitter.on("connected", () => handler());
473
+ }
474
+
475
+ onDisconnected(handler: (info: { reason: string }) => void): () => void {
476
+ return this.emitter.on("disconnected", handler);
477
+ }
478
+
479
+ onStatusChanged(handler: (status: RuntimeSnapshot) => void): () => void {
480
+ return this.emitter.on("status", handler);
481
+ }
482
+
483
+ onError(handler: (err: ProtocolError) => void): () => void {
484
+ return this.emitter.on("error", handler);
485
+ }
486
+
487
+ on<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): () => void {
488
+ return this.emitter.on(event, handler);
489
+ }
490
+
491
+ off<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): void {
492
+ this.emitter.off(event, handler);
493
+ }
494
+
495
+ onAny(handler: (event: keyof RuntimeEvents, data: unknown) => void): () => void {
496
+ return this.emitter.onAny(handler);
497
+ }
498
+
499
+ private updateStatus(next: Partial<RuntimeSnapshot>): void {
500
+ const snapshot = { ...this.status, ...next };
501
+ if (
502
+ snapshot.status === this.status.status &&
503
+ snapshot.audioTransport === this.status.audioTransport
504
+ ) {
505
+ return;
506
+ }
507
+ this.status = snapshot;
508
+ this.emitter.emit("status", { ...snapshot });
509
+ }
510
+
511
+ private startUdpLiveness(): void {
512
+ this.stopUdpLiveness();
513
+ this.udpProbeStartedAt = Date.now();
514
+ this.lastUdpAckAt = 0;
515
+ this.sendUdpProbe();
516
+ this.udpProbeTimer = setInterval(() => {
517
+ this.sendUdpProbe();
518
+ this.checkUdpLiveness();
519
+ }, UDP_PROBE_INTERVAL_MS);
520
+ }
521
+
522
+ private stopUdpLiveness(): void {
523
+ if (this.udpProbeTimer) {
524
+ clearInterval(this.udpProbeTimer);
525
+ this.udpProbeTimer = null;
526
+ }
527
+ this.udpProbeStartedAt = 0;
528
+ this.lastUdpAckAt = 0;
529
+ }
530
+
531
+ private sendUdpProbe(): void {
532
+ const tag = this.audio.sessionTag;
533
+ if (tag === null) return;
534
+ const probeId = `${tag}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
535
+ this.audio.sendProbe(probeId);
536
+ }
537
+
538
+ private checkUdpLiveness(): void {
539
+ if (this.status.status !== "connected") return;
540
+ const since = this.lastUdpAckAt || this.udpProbeStartedAt;
541
+ if (since === 0 || Date.now() - since < UDP_LIVENESS_TIMEOUT_MS) return;
542
+ this.updateStatus({ audioTransport: this.connection.isOpen ? "ws" : "none" });
543
+ }
544
+
545
+ private handleUdpLivenessAck(payload: {
546
+ sessionId: string;
547
+ sessionTag: number;
548
+ probeId: string;
549
+ receivedAt: number;
550
+ }): void {
551
+ if (payload.sessionTag !== this.audio.sessionTag) return;
552
+ this.lastUdpAckAt = Date.now();
553
+ if (this.status.status === "connected") {
554
+ this.updateStatus({ audioTransport: "udp" });
555
+ }
556
+ }
557
+ }
@@ -0,0 +1,12 @@
1
+ import type { RuntimeAudioTransport } from "./audio-udp";
2
+
3
+ export type RuntimeStatus =
4
+ | "connecting"
5
+ | "connected"
6
+ | "reconnecting"
7
+ | "disconnected";
8
+
9
+ export interface RuntimeSnapshot {
10
+ status: RuntimeStatus;
11
+ audioTransport: RuntimeAudioTransport;
12
+ }