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