@lumavox/client 0.0.0-stage → 0.1.1

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,763 @@
1
+ import { wireBytes } from "./pcm.js";
2
+ import { KEEPALIVE_S, MAX_AUDIO_BYTES, MAX_MESSAGE_CHARS, RECORDING_RATES, } from "./protocol.gen.js";
3
+ import { dial, isFinalClose, reconnectAfter, socketUrl, } from "./socket.js";
4
+ /** No `ready` this long after the dial — a proxy that took the upgrade and says nothing — is a dropped connection. */
5
+ export const READY_DEADLINE_MS = 15000;
6
+ const FRESH = {
7
+ id: null,
8
+ text: "",
9
+ chunks: [],
10
+ mood: null,
11
+ tool: null,
12
+ knowledge: [],
13
+ heard: null,
14
+ done: false,
15
+ interrupted: false,
16
+ };
17
+ const HANDLER = {
18
+ ready: "onReady",
19
+ "turn.start": "onTurnStart",
20
+ heard: "onHeard",
21
+ "heard.partial": "onHeardPartial",
22
+ knowledge: "onKnowledge",
23
+ mood: "onMood",
24
+ "thought.delta": "onThought",
25
+ "text.delta": "onText",
26
+ "text.chunk": "onChunk",
27
+ "tool.start": "onToolStart",
28
+ "tool.end": "onToolEnd",
29
+ usage: "onUsage",
30
+ muted: "onMuted",
31
+ "audio.start": "onAudioStart",
32
+ "audio.end": "onAudioEnd",
33
+ transcript: "onTranscript",
34
+ "turn.done": "onDone",
35
+ interrupted: "onInterrupted",
36
+ error: "onError",
37
+ };
38
+ /**
39
+ * Call an app's handler. One that throws is rethrown on its own turn of
40
+ * the event loop, where the app's error reporting sees it — not here,
41
+ * where it would leave the conversation half-changed.
42
+ */
43
+ function call(fn, value) {
44
+ if (!fn)
45
+ return;
46
+ try {
47
+ fn(value);
48
+ }
49
+ catch (error) {
50
+ setTimeout(() => {
51
+ throw error;
52
+ });
53
+ }
54
+ }
55
+ const dropped = (reason, detail) => detail
56
+ ? { status: "dropped", reason, detail }
57
+ : { status: "dropped", reason };
58
+ /** Characters as the edge counts them: code points. */
59
+ function characters(text) {
60
+ let n = 0;
61
+ for (const _ of text)
62
+ n++;
63
+ return n;
64
+ }
65
+ export class Conversation {
66
+ constructor(setup) {
67
+ this.snap = {
68
+ state: "connecting",
69
+ ready: null,
70
+ turn: null,
71
+ caption: null,
72
+ relation: null,
73
+ queued: 0,
74
+ };
75
+ this.listeners = new Set();
76
+ this.socket = null;
77
+ /** Moves with every dial and every end: a callback from an older one is stale. */
78
+ this.generation = 0;
79
+ this.key = null;
80
+ this.attempts = 0;
81
+ /** A new key was taken for a refusal, and no turn has finished on it yet. */
82
+ this.replaced = false;
83
+ this.closed = false;
84
+ /** Closed by giving up, not by a final code: `reconnect()` may dial again. */
85
+ this.revivable = false;
86
+ this.dormant = false;
87
+ this.everReady = false;
88
+ /** The socket now open reached `ready`. */
89
+ this.socketReady = false;
90
+ /** A turn this conversation did not send is running: the greeting. */
91
+ this.running = false;
92
+ this.inFlight = null;
93
+ this.queue = [];
94
+ this.open = null;
95
+ /** `transcribeOnly` recordings waiting for their words, oldest first. */
96
+ this.hearing = [];
97
+ /** Recordings waiting for `ready`. */
98
+ this.held = [];
99
+ this.recordings = 0;
100
+ /** The turn whose terminal frame was taken: a second for it says nothing new. */
101
+ this.finished = null;
102
+ this.relationSeen = "";
103
+ this.retry = null;
104
+ this.deadline = null;
105
+ this.watchdog = null;
106
+ this.heardAt = 0;
107
+ this.saidAt = 0;
108
+ this.sessionId = setup.sessionId;
109
+ this.setup = setup;
110
+ this.speech =
111
+ setup.options.speech ?? (setup.handlers.onAudio ? undefined : false);
112
+ void this.dial();
113
+ }
114
+ /** What is known now. A new object on every change: compare by identity. */
115
+ get snapshot() {
116
+ return this.snap;
117
+ }
118
+ get state() {
119
+ return this.snap.state;
120
+ }
121
+ /** `fn` is called after every change of `snapshot`. Answers the unsubscribe. */
122
+ subscribe(fn) {
123
+ this.listeners.add(fn);
124
+ return () => {
125
+ this.listeners.delete(fn);
126
+ };
127
+ }
128
+ /**
129
+ * Take a turn by typing. Queued behind the greeting, a running turn, a
130
+ * reconnect or a quiet spell; answers how it ended, and never rejects.
131
+ * 1 to 32,000 characters, or it is `dropped: refused` here.
132
+ */
133
+ send(text, options = {}) {
134
+ const length = characters(text);
135
+ if (length < 1 || length > MAX_MESSAGE_CHARS) {
136
+ return Promise.resolve(dropped("refused", `A message is 1 to ${MAX_MESSAGE_CHARS} characters; this is ${length}.`));
137
+ }
138
+ return this.enqueue({ type: "user_message", text }, options);
139
+ }
140
+ /**
141
+ * Open a recording at `rate`, which must be one of `RECORDING_RATES`.
142
+ * Answers its id. A recording already open is abandoned, as the edge
143
+ * abandons it.
144
+ */
145
+ startAudio(options) {
146
+ const { rate, language, model } = options;
147
+ if (!RECORDING_RATES.includes(rate)) {
148
+ throw new RangeError(`${rate} Hz is not a rate the socket accepts: ${RECORDING_RATES.join(", ")}.`);
149
+ }
150
+ if (this.open)
151
+ this.abandon("cancelled");
152
+ const id = `r${++this.recordings}`;
153
+ const recording = {
154
+ id,
155
+ bytes: 0,
156
+ held: null,
157
+ socket: -1,
158
+ over: false,
159
+ };
160
+ this.open = recording;
161
+ this.emit({ caption: null });
162
+ if (this.closed) {
163
+ recording.over = true;
164
+ recording.dropped = "closed";
165
+ return id;
166
+ }
167
+ const frame = this.turnFrame({ type: "user_audio.start", id, rate }, options);
168
+ if (language)
169
+ frame.language = language;
170
+ if (model)
171
+ frame.model = model;
172
+ if (this.socket?.ready) {
173
+ this.socket.send(frame);
174
+ this.said(recording);
175
+ }
176
+ else {
177
+ recording.held = [frame];
178
+ this.held.push(recording);
179
+ if (this.dormant)
180
+ void this.dial();
181
+ }
182
+ return id;
183
+ }
184
+ /**
185
+ * PCM for the open recording: 16-bit samples, or their little-endian
186
+ * bytes. Answers whether it was taken — not past the 10 MB a recording
187
+ * may carry (`onWarning`), nor with no recording open.
188
+ */
189
+ sendAudio(pcm) {
190
+ const recording = this.open;
191
+ if (!recording || recording.over)
192
+ return false;
193
+ const bytes = recording.bytes + pcm.byteLength;
194
+ if (bytes > MAX_AUDIO_BYTES) {
195
+ call(this.setup.handlers.onWarning, { type: "audio_limit", bytes });
196
+ return false;
197
+ }
198
+ recording.bytes = bytes;
199
+ const data = wireBytes(pcm);
200
+ // Held, it is copied: the recorder may reuse its buffer before `ready`.
201
+ if (recording.held)
202
+ recording.held.push(data.slice(0));
203
+ else if (recording.socket === this.generation && this.socket) {
204
+ this.socket.sendBinary(data);
205
+ this.saidAt = Date.now();
206
+ }
207
+ return true;
208
+ }
209
+ endAudio(options = {}) {
210
+ const recording = this.open;
211
+ this.open = null;
212
+ if (!recording) {
213
+ return Promise.resolve(dropped("refused", "No recording is open: call startAudio first."));
214
+ }
215
+ recording.over = true;
216
+ const failed = recording.failed;
217
+ if (failed) {
218
+ return Promise.resolve(options.transcribeOnly
219
+ ? { status: "failed", frame: failed }
220
+ : dropped("refused", failed.detail));
221
+ }
222
+ if (recording.dropped || this.closed) {
223
+ return Promise.resolve(dropped(recording.dropped ?? "closed"));
224
+ }
225
+ if (!options.transcribeOnly) {
226
+ return this.enqueue({ type: "user_audio.end" }, {}, recording);
227
+ }
228
+ return new Promise((resolve) => {
229
+ recording.settle = resolve;
230
+ this.hearing.push(recording);
231
+ const frame = { type: "user_audio.end", transcribe_only: true };
232
+ if (recording.held)
233
+ recording.held.push(frame);
234
+ else if (recording.socket === this.generation)
235
+ this.socket?.send(frame);
236
+ });
237
+ }
238
+ /** Stop the turn in flight, and drop what is queued and the open recording. What was said is still billed and stored. */
239
+ cancel() {
240
+ for (const pending of this.queue.splice(0)) {
241
+ pending.resolve(dropped("cancelled"));
242
+ }
243
+ if (this.open)
244
+ this.abandon("cancelled");
245
+ this.socket?.send({ type: "cancel" });
246
+ this.emit({ queued: 0, caption: null });
247
+ }
248
+ /** Stop speaking, and let the turn finish: the rest of the reply is never spoken, nor billed. */
249
+ mute() {
250
+ this.socket?.send({ type: "mute" });
251
+ }
252
+ /** Liveness only: answered with `pong`, and never counted as activity. Not needed — the SDK's watchdog listens for the edge's own. */
253
+ ping() {
254
+ this.socket?.send({ type: "ping" });
255
+ }
256
+ /**
257
+ * Dial now: the app came to the foreground, the network came back. A
258
+ * dormant conversation, one waiting to retry, and one that gave up (not
259
+ * a final close) dial at once; a live one is left alone.
260
+ */
261
+ reconnect() {
262
+ if (this.closed) {
263
+ if (!this.revivable)
264
+ return;
265
+ this.closed = false;
266
+ this.revivable = false;
267
+ this.attempts = 0;
268
+ }
269
+ else if (!this.retry && !this.dormant)
270
+ return;
271
+ void this.dial();
272
+ }
273
+ /** End it, for good. Every pending promise answers `dropped: closed`. */
274
+ close() {
275
+ if (this.closed)
276
+ return;
277
+ this.end();
278
+ this.sync();
279
+ }
280
+ // ---------------------------------------------------------------- dial
281
+ async dial(refused) {
282
+ this.stopTimers();
283
+ const generation = ++this.generation;
284
+ this.dormant = false;
285
+ this.sync();
286
+ let key;
287
+ try {
288
+ key =
289
+ refused !== undefined && this.key !== null
290
+ ? await this.setup.tokens.replace(this.key, { code: refused })
291
+ : await this.setup.tokens.current();
292
+ }
293
+ catch (error) {
294
+ // swallowed: getToken's own throw, the app's way to stop — handed
295
+ // back to it on `onClose` (`finish`).
296
+ if (generation === this.generation && !this.closed) {
297
+ this.finish(refused ?? 0, "getToken threw.", true, error);
298
+ }
299
+ return;
300
+ }
301
+ if (generation !== this.generation || this.closed)
302
+ return;
303
+ this.key = key;
304
+ try {
305
+ this.socket = dial({
306
+ url: socketUrl(this.setup.socketUrl, this.sessionId),
307
+ key,
308
+ sessionId: this.sessionId,
309
+ WebSocket: this.setup.WebSocket,
310
+ onFrame: (frame) => this.frame(frame),
311
+ onAudio: (frame) => {
312
+ this.heardAt = Date.now();
313
+ call(this.setup.handlers.onAudio, frame);
314
+ },
315
+ onClose: (code, reason) => this.lost(generation, code, reason),
316
+ onWarning: (warning) => call(this.setup.handlers.onWarning, warning),
317
+ });
318
+ }
319
+ catch (error) {
320
+ // swallowed: a URL the runtime's `WebSocket` refuses, which no retry
321
+ // cures — handed to the app on `onClose` (`finish`).
322
+ this.finish(0, "The socket could not be opened.", true, error);
323
+ return;
324
+ }
325
+ this.heardAt = this.saidAt = Date.now();
326
+ this.socketReady = false;
327
+ this.deadline = setTimeout(() => this.drop(generation, "The connection never became ready."), READY_DEADLINE_MS);
328
+ this.watchdog = setInterval(() => {
329
+ const quiet = Date.now() - Math.max(this.heardAt, this.saidAt);
330
+ if (quiet > 2 * KEEPALIVE_S * 1000) {
331
+ this.drop(generation, "Nothing came from the edge for a minute.");
332
+ }
333
+ }, KEEPALIVE_S * 500);
334
+ }
335
+ /** A socket given up on from this side: lost as a dropped network is (1006). */
336
+ drop(generation, reason) {
337
+ const socket = this.socket;
338
+ if (generation !== this.generation || !socket)
339
+ return;
340
+ socket.detach();
341
+ socket.close();
342
+ this.lost(generation, 1006, reason);
343
+ }
344
+ /** The socket closed: settle what it carried, then reconnect, rest or end. */
345
+ lost(generation, code, reason) {
346
+ if (generation !== this.generation || this.closed)
347
+ return;
348
+ call(this.setup.handlers.onDisconnect, {
349
+ code,
350
+ reason,
351
+ ready: this.socketReady,
352
+ });
353
+ // The handler may have closed it: then nothing follows.
354
+ if (generation !== this.generation || this.closed)
355
+ return;
356
+ this.stopTimers();
357
+ this.socket = null;
358
+ for (const recording of [this.open, ...this.hearing]) {
359
+ if (recording && recording.socket === generation) {
360
+ recording.over = true;
361
+ recording.dropped = "closed";
362
+ recording.settle?.(dropped("closed"));
363
+ }
364
+ }
365
+ this.hearing = this.hearing.filter((r) => r.socket !== generation);
366
+ const cut = this.inFlight;
367
+ this.inFlight = null;
368
+ if (cut?.started)
369
+ cut.resolve({ status: "interrupted", frame: null });
370
+ else if (cut && cut.sent < 2 && !cut.recording)
371
+ this.queue.unshift(cut);
372
+ else
373
+ cut?.resolve(dropped("closed"));
374
+ const turn = this.snap.turn;
375
+ this.running = false;
376
+ this.emit({
377
+ caption: null,
378
+ queued: this.queue.length,
379
+ turn: turn && !turn.done ? { ...turn, done: true, interrupted: true } : turn,
380
+ });
381
+ const next = reconnectAfter(code, this.attempts);
382
+ if (next.kind === "new-key") {
383
+ // A second refusal on a fresh key is final: the console's old 4005
384
+ // loop minted, re-sent and was refused every ~600 ms.
385
+ if (this.replaced)
386
+ this.finish(code, reason, true);
387
+ else {
388
+ this.replaced = true;
389
+ this.attempts += 1;
390
+ void this.dial(code);
391
+ }
392
+ }
393
+ else if (next.kind === "dormant") {
394
+ this.dormant = true;
395
+ this.sync();
396
+ }
397
+ else if (next.kind === "retry") {
398
+ this.attempts += 1;
399
+ this.retry = setTimeout(() => {
400
+ this.retry = null;
401
+ void this.dial();
402
+ }, next.delayMs);
403
+ this.sync();
404
+ }
405
+ else
406
+ this.finish(code, reason, isFinalClose(code));
407
+ }
408
+ /** It will not reconnect: say why, once. */
409
+ finish(code, reason, final, error) {
410
+ this.end();
411
+ this.revivable = !final;
412
+ this.sync();
413
+ const close = { code, reason, final };
414
+ if (error !== undefined)
415
+ close.error = error;
416
+ call(this.setup.handlers.onClose, close);
417
+ }
418
+ /** Closed: no socket, no timer, and every promise answered. */
419
+ end() {
420
+ this.closed = true;
421
+ this.generation += 1;
422
+ this.stopTimers();
423
+ const socket = this.socket;
424
+ this.socket = null;
425
+ socket?.detach();
426
+ socket?.close();
427
+ const pending = [...this.queue.splice(0)];
428
+ if (this.inFlight)
429
+ pending.push(this.inFlight);
430
+ this.inFlight = null;
431
+ for (const p of pending)
432
+ p.resolve(dropped("closed"));
433
+ for (const recording of [this.open, ...this.hearing, ...this.held]) {
434
+ if (recording) {
435
+ recording.over = true;
436
+ recording.dropped = "closed";
437
+ recording.settle?.(dropped("closed"));
438
+ }
439
+ }
440
+ this.hearing = [];
441
+ this.held = [];
442
+ this.running = false;
443
+ this.emit({ queued: 0, caption: null });
444
+ }
445
+ stopTimers() {
446
+ if (this.retry)
447
+ clearTimeout(this.retry);
448
+ if (this.deadline)
449
+ clearTimeout(this.deadline);
450
+ if (this.watchdog)
451
+ clearInterval(this.watchdog);
452
+ this.retry = this.deadline = this.watchdog = null;
453
+ }
454
+ // -------------------------------------------------------------- frames
455
+ frame(frame) {
456
+ this.heardAt = Date.now();
457
+ let quiet = false;
458
+ switch (frame.type) {
459
+ case "ready":
460
+ this.ready(frame);
461
+ break;
462
+ case "turn.start": {
463
+ const pending = this.inFlight;
464
+ if (pending && !pending.started)
465
+ pending.started = true;
466
+ else
467
+ this.running = true;
468
+ this.finished = null;
469
+ this.emit({ turn: { ...FRESH, id: frame.turn } });
470
+ this.sync();
471
+ break;
472
+ }
473
+ case "heard":
474
+ this.update({ heard: frame.text }, { caption: null });
475
+ break;
476
+ case "heard.partial":
477
+ if (this.listening())
478
+ this.emit({ caption: frame.text || null });
479
+ break;
480
+ case "transcript":
481
+ this.transcript(frame);
482
+ break;
483
+ case "mood":
484
+ this.update({ mood: { mood: frame.mood, intensity: frame.intensity } });
485
+ break;
486
+ case "text.delta":
487
+ this.update({ text: (this.snap.turn?.text ?? "") + frame.text });
488
+ break;
489
+ case "text.chunk":
490
+ this.update({
491
+ chunks: [...(this.snap.turn?.chunks ?? []), frame.text],
492
+ });
493
+ break;
494
+ case "tool.start":
495
+ this.update({ tool: frame.name || "tool" });
496
+ break;
497
+ case "tool.end":
498
+ this.update({ tool: null });
499
+ break;
500
+ case "knowledge":
501
+ this.update({
502
+ knowledge: [...(this.snap.turn?.knowledge ?? []), frame],
503
+ });
504
+ break;
505
+ case "relation": {
506
+ const { type: _t, seq: _s, turn: _turn, ...relation } = frame;
507
+ this.relate(relation);
508
+ break;
509
+ }
510
+ case "turn.done":
511
+ case "interrupted":
512
+ quiet = this.terminal(frame);
513
+ break;
514
+ case "error":
515
+ this.error(frame);
516
+ break;
517
+ }
518
+ const name = HANDLER[frame.type];
519
+ if (name && !quiet) {
520
+ call(this.setup.handlers[name], frame);
521
+ }
522
+ call(this.setup.handlers.onEvent, frame);
523
+ if (frame.type === "ready" ||
524
+ frame.type === "turn.done" ||
525
+ frame.type === "interrupted" ||
526
+ frame.type === "error") {
527
+ this.pump();
528
+ }
529
+ }
530
+ ready(frame) {
531
+ this.socketReady = true;
532
+ if (this.deadline)
533
+ clearTimeout(this.deadline);
534
+ this.deadline = null;
535
+ this.attempts = 0;
536
+ this.everReady = true;
537
+ this.running = frame.greeting;
538
+ this.emit(frame.greeting ? { ready: frame, turn: FRESH } : { ready: frame });
539
+ this.relate(frame.relation);
540
+ // What was started while the socket was down goes first, in order.
541
+ for (const recording of this.held.splice(0)) {
542
+ if (recording.dropped)
543
+ continue;
544
+ for (const item of recording.held ?? []) {
545
+ if (item instanceof ArrayBuffer)
546
+ this.socket?.sendBinary(item);
547
+ else
548
+ this.socket?.send(item);
549
+ }
550
+ recording.held = null;
551
+ this.said(recording);
552
+ }
553
+ this.sync();
554
+ }
555
+ /** The end of a turn: answers whether it was a second for the same turn, which says nothing new. */
556
+ terminal(frame) {
557
+ if (frame.turn === this.finished)
558
+ return true;
559
+ this.finished = frame.turn;
560
+ const turn = this.snap.turn ?? FRESH;
561
+ this.emit({
562
+ turn: {
563
+ ...turn,
564
+ id: frame.turn,
565
+ text: frame.message || turn.text,
566
+ mood: frame.mood
567
+ ? { mood: frame.mood, intensity: frame.mood_intensity }
568
+ : turn.mood,
569
+ tool: null,
570
+ done: true,
571
+ interrupted: frame.type === "interrupted",
572
+ },
573
+ });
574
+ const pending = this.inFlight;
575
+ if (pending?.started) {
576
+ this.inFlight = null;
577
+ pending.resolve(frame.type === "turn.done"
578
+ ? { status: "done", frame }
579
+ : { status: "interrupted", frame });
580
+ }
581
+ this.running = false;
582
+ // The new key took a turn: a later refusal may take another.
583
+ if (frame.type === "turn.done")
584
+ this.replaced = false;
585
+ this.sync();
586
+ return false;
587
+ }
588
+ error(frame) {
589
+ if (frame.id) {
590
+ this.refuse(frame);
591
+ return;
592
+ }
593
+ if (frame.turn)
594
+ return;
595
+ // Outside a turn: the last word a recording or a message will get.
596
+ this.emit({ caption: null });
597
+ const pending = this.inFlight;
598
+ if (pending && !pending.started) {
599
+ this.inFlight = null;
600
+ pending.resolve(dropped("refused", frame.detail));
601
+ this.update({ done: true });
602
+ this.sync();
603
+ }
604
+ }
605
+ /** An error naming a recording: it was refused, or failed. */
606
+ refuse(frame) {
607
+ const { id } = frame;
608
+ const open = this.open;
609
+ if (open && open.id === id) {
610
+ open.failed = frame;
611
+ open.over = true;
612
+ }
613
+ const hearing = this.hearing.find((r) => r.id === id);
614
+ if (hearing) {
615
+ this.hearing = this.hearing.filter((r) => r !== hearing);
616
+ hearing.settle?.({ status: "failed", frame });
617
+ }
618
+ const pending = this.inFlight;
619
+ if (pending && pending.recording?.id === id && !pending.started) {
620
+ this.inFlight = null;
621
+ pending.resolve(dropped("refused", frame.detail));
622
+ this.update({ done: true });
623
+ }
624
+ this.emit({ caption: null });
625
+ this.sync();
626
+ }
627
+ transcript(frame) {
628
+ // By its id; the oldest, from an edge that echoes none.
629
+ const recording = frame.id
630
+ ? this.hearing.find((r) => r.id === frame.id)
631
+ : this.hearing[0];
632
+ if (recording) {
633
+ this.hearing = this.hearing.filter((r) => r !== recording);
634
+ recording.settle?.({
635
+ status: "done",
636
+ text: frame.text,
637
+ seconds: frame.seconds,
638
+ frame,
639
+ });
640
+ }
641
+ this.emit({ caption: null });
642
+ }
643
+ /** Each change of the relationship once: `ready` repeats it on every reconnect. */
644
+ relate(relation) {
645
+ if (!relation)
646
+ return;
647
+ const seen = JSON.stringify(relation, Object.keys(relation).sort());
648
+ if (seen === this.relationSeen)
649
+ return;
650
+ this.relationSeen = seen;
651
+ this.emit({ relation });
652
+ call(this.setup.handlers.onRelation, relation);
653
+ }
654
+ // ------------------------------------------------------------- sending
655
+ enqueue(frame, options, recording) {
656
+ if (this.closed)
657
+ return Promise.resolve(dropped("closed"));
658
+ return new Promise((resolve) => {
659
+ this.queue.push({
660
+ frame: this.turnFrame(frame, options),
661
+ resolve,
662
+ recording,
663
+ sent: 0,
664
+ started: false,
665
+ });
666
+ if (this.dormant)
667
+ void this.dial();
668
+ else
669
+ this.pump();
670
+ this.emit({ queued: this.queue.length });
671
+ });
672
+ }
673
+ /** The next queued turn goes, when nothing is running and the socket is ready. */
674
+ pump() {
675
+ while (this.queue.length &&
676
+ !this.inFlight &&
677
+ !this.running &&
678
+ this.socket?.ready) {
679
+ const next = this.queue.shift();
680
+ const recording = next.recording;
681
+ if (recording &&
682
+ (recording.dropped || recording.socket !== this.generation)) {
683
+ // Its recording was on a socket that is gone.
684
+ next.resolve(dropped(recording.dropped ?? "closed"));
685
+ continue;
686
+ }
687
+ // A message abandons an open recording, at the edge and here.
688
+ if (next.frame.type === "user_message" && this.open) {
689
+ this.abandon("cancelled");
690
+ }
691
+ this.inFlight = next;
692
+ next.sent += 1;
693
+ this.socket.send(next.frame);
694
+ this.saidAt = Date.now();
695
+ this.emit({ turn: FRESH });
696
+ }
697
+ this.emit({ queued: this.queue.length });
698
+ this.sync();
699
+ }
700
+ /** A turn's frame with its `speech` and `thoughts`. */
701
+ turnFrame(frame, options) {
702
+ const speech = options.speech ?? this.speech;
703
+ if (speech !== undefined && frame.type !== "user_audio.end") {
704
+ frame.speech = speech;
705
+ }
706
+ if (options.thoughts ?? this.setup.options.thoughts)
707
+ frame.thoughts = true;
708
+ return frame;
709
+ }
710
+ /** The open recording is over before its end: the edge discards the rest. */
711
+ abandon(reason) {
712
+ const recording = this.open;
713
+ if (!recording)
714
+ return;
715
+ recording.over = true;
716
+ recording.dropped = reason;
717
+ }
718
+ said(recording) {
719
+ recording.socket = this.generation;
720
+ this.saidAt = Date.now();
721
+ }
722
+ /** A recording's words are still to come: a partial is a caption. */
723
+ listening() {
724
+ return Boolean((this.open && !this.open.over) ||
725
+ this.hearing.length ||
726
+ this.inFlight?.recording);
727
+ }
728
+ // ------------------------------------------------------------ snapshot
729
+ update(turn, rest = {}) {
730
+ this.emit({ ...rest, turn: { ...(this.snap.turn ?? FRESH), ...turn } });
731
+ }
732
+ emit(change) {
733
+ let changed = false;
734
+ for (const key in change) {
735
+ if (change[key] !== this.snap[key]) {
736
+ changed = true;
737
+ }
738
+ }
739
+ if (!changed)
740
+ return;
741
+ this.snap = { ...this.snap, ...change };
742
+ for (const listener of this.listeners)
743
+ call(listener, undefined);
744
+ }
745
+ /** The state, from what is true now; `onState` when it moved. */
746
+ sync() {
747
+ const state = this.closed
748
+ ? "closed"
749
+ : this.dormant
750
+ ? "dormant"
751
+ : !this.socket?.ready
752
+ ? this.everReady || this.attempts
753
+ ? "reconnecting"
754
+ : "connecting"
755
+ : this.inFlight || this.running
756
+ ? "busy"
757
+ : "ready";
758
+ if (state === this.snap.state)
759
+ return;
760
+ this.emit({ state });
761
+ call(this.setup.handlers.onState, state);
762
+ }
763
+ }