@breeze.blue/sdk 0.5.1 → 0.6.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.
package/dist/client.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { AudioResponse } from "./audio.js";
2
- import { BreezeBlueConfigurationError, BreezeBlueRealtimeError, errorFromResponse } from "./errors.js";
2
+ import { BreezeBlueAPIError, BreezeBlueConfigurationError, BreezeBlueRealtimeError, errorFromResponse, } from "./errors.js";
3
3
  import { camelizeKeys, snakeizeKeys } from "./_transform.js";
4
4
  import { SDK_NAME, SDK_VERSION } from "./version.js";
5
5
  const DEFAULT_BASE_URL = "https://api.breeze.blue";
6
6
  const DEFAULT_TIMEOUT_MS = 60_000;
7
7
  const DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS = 30_000;
8
8
  const REALTIME_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;
9
+ const DEFAULT_REALTIME_ROTATION_MARGIN_MS = 120_000;
10
+ const DEFAULT_REALTIME_HEARTBEAT_TIMEOUT_MS = 5_000;
11
+ const DEFAULT_REALTIME_MAX_PHYSICAL_SESSION_MS = 10 * 60_000;
12
+ const DEFAULT_REALTIME_MAX_RECONNECT_ATTEMPTS = 3;
13
+ const DEFAULT_REALTIME_RECONNECT_BASE_DELAY_MS = 250;
14
+ const MAX_REALTIME_RECONNECT_DELAY_MS = 4_000;
15
+ const REALTIME_SETTLEMENT_DRAIN_TIMEOUT_MS = 5_000;
16
+ const REALTIME_CLOSE_EVENT_GRACE_MS = 50;
9
17
  const WEBSOCKET_OPEN = 1;
10
18
  export class BreezeBlueClient {
11
19
  apiKey;
@@ -135,11 +143,59 @@ class RealtimeTextToSpeechResource {
135
143
  voiceSettings: options.voiceSettings,
136
144
  inactivityTimeoutSeconds: options.inactivityTimeoutSeconds,
137
145
  enableLogging: options.enableLogging,
146
+ }, {
147
+ signal: options.signal,
148
+ timeout: options.timeout,
138
149
  })).websocketUrl);
139
150
  const WebSocketImpl = requireWebSocket(options.webSocket ?? this.client.webSocket);
140
151
  const socket = new WebSocketImpl(websocketUrl);
141
152
  return RealtimeTextToSpeechConnection.open(socket, {
142
153
  timeout: options.timeout ?? this.client.timeout ?? DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS,
154
+ signal: options.signal,
155
+ });
156
+ }
157
+ /**
158
+ * Open an SDK-managed logical connection backed by bounded physical
159
+ * WebSocket sessions. The manager keeps idle sessions alive, rotates at
160
+ * turn boundaries, and performs bounded idle reconnects with fresh
161
+ * credentials. It never replays an interrupted active turn.
162
+ */
163
+ async connectManaged(voiceId, options = {}) {
164
+ if (options.sessionFactory !== undefined && hasRealtimeSessionParams(options)) {
165
+ console.warn("@breeze.blue/sdk: realtime.connectManaged cannot apply modelId, languageCode, instructions, " +
166
+ "voiceSettings, inactivityTimeoutSeconds, or enableLogging when sessionFactory is provided. " +
167
+ "Configure these when the factory creates each realtime session.");
168
+ }
169
+ const sessionRequest = {
170
+ modelId: options.modelId,
171
+ languageCode: options.languageCode,
172
+ instructions: options.instructions,
173
+ voiceSettings: options.voiceSettings,
174
+ inactivityTimeoutSeconds: options.inactivityTimeoutSeconds,
175
+ enableLogging: options.enableLogging,
176
+ };
177
+ const connectionTimeout = options.timeout ?? this.client.timeout ?? DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS;
178
+ const createConnection = async (signal) => {
179
+ const credentials = options.sessionFactory
180
+ ? await options.sessionFactory({ signal })
181
+ : await this.createSession(voiceId, sessionRequest, {
182
+ signal,
183
+ timeout: connectionTimeout,
184
+ });
185
+ if (!credentials.clientSecret && !credentials.websocketUrl) {
186
+ throw new BreezeBlueConfigurationError("realtime.connectManaged sessionFactory must return a clientSecret or websocketUrl.");
187
+ }
188
+ return this.connect(voiceId, {
189
+ clientSecret: credentials.clientSecret,
190
+ websocketUrl: credentials.websocketUrl,
191
+ webSocket: options.webSocket,
192
+ signal,
193
+ timeout: connectionTimeout,
194
+ });
195
+ };
196
+ return ManagedRealtimeTextToSpeechConnection.open(createConnection, {
197
+ ...options,
198
+ timeout: connectionTimeout,
143
199
  });
144
200
  }
145
201
  }
@@ -158,6 +214,7 @@ export class RealtimeTextToSpeechConnection {
158
214
  closed = false;
159
215
  queuedBytes = 0;
160
216
  failure;
217
+ closeEventGraceTimer;
161
218
  constructor(socket) {
162
219
  this.socket = socket;
163
220
  }
@@ -167,20 +224,25 @@ export class RealtimeTextToSpeechConnection {
167
224
  await new Promise((resolve, reject) => {
168
225
  let settled = false;
169
226
  let timer;
227
+ const cleanup = () => {
228
+ if (timer !== undefined) {
229
+ clearTimeout(timer);
230
+ timer = undefined;
231
+ }
232
+ options.signal?.removeEventListener("abort", abort);
233
+ };
170
234
  const succeed = () => {
171
235
  if (settled)
172
236
  return;
173
237
  settled = true;
174
- if (timer !== undefined)
175
- clearTimeout(timer);
238
+ cleanup();
176
239
  resolve();
177
240
  };
178
241
  const fail = (error) => {
179
242
  if (settled)
180
243
  return;
181
244
  settled = true;
182
- if (timer !== undefined)
183
- clearTimeout(timer);
245
+ cleanup();
184
246
  try {
185
247
  socket.close(1000);
186
248
  }
@@ -189,9 +251,15 @@ export class RealtimeTextToSpeechConnection {
189
251
  }
190
252
  reject(error);
191
253
  };
254
+ const abort = () => fail(new BreezeBlueRealtimeError("Realtime TTS WebSocket opening was aborted."));
192
255
  if (timeoutMs > 0) {
193
256
  timer = setTimeout(() => fail(new Error(`Realtime TTS WebSocket did not open within ${timeoutMs}ms.`)), timeoutMs);
194
257
  }
258
+ if (options.signal?.aborted) {
259
+ abort();
260
+ return;
261
+ }
262
+ options.signal?.addEventListener("abort", abort, { once: true });
195
263
  socket.binaryType = "arraybuffer";
196
264
  socket.onopen = () => succeed();
197
265
  socket.onerror = (event) => fail(new Error(`Realtime TTS WebSocket failed to open: ${String(event.type)}`));
@@ -199,7 +267,7 @@ export class RealtimeTextToSpeechConnection {
199
267
  });
200
268
  socket.onmessage = (event) => connection.handleMessage(event.data);
201
269
  socket.onclose = (event) => connection.handleClose(event);
202
- socket.onerror = () => connection.finish();
270
+ socket.onerror = () => connection.handleSocketError();
203
271
  return connection;
204
272
  }
205
273
  startTurn(turnId) {
@@ -243,6 +311,13 @@ export class RealtimeTextToSpeechConnection {
243
311
  if (message.type === "audio") {
244
312
  yield message.audio;
245
313
  }
314
+ else if (message.type === "error") {
315
+ throw realtimeErrorFromEvent(message);
316
+ }
317
+ else if (message.type === "session.closed" &&
318
+ (message.reconnect === true || (message.code !== undefined && message.code !== 1000))) {
319
+ throw realtimeErrorFromClose(message);
320
+ }
246
321
  }
247
322
  }
248
323
  [Symbol.asyncIterator]() {
@@ -288,6 +363,7 @@ export class RealtimeTextToSpeechConnection {
288
363
  }
289
364
  }
290
365
  handleClose(event) {
366
+ this.clearCloseEventGraceTimer();
291
367
  if (!this.closed && event.code !== 1000) {
292
368
  this.push({
293
369
  type: "session.closed",
@@ -298,6 +374,17 @@ export class RealtimeTextToSpeechConnection {
298
374
  }
299
375
  this.finish();
300
376
  }
377
+ handleSocketError() {
378
+ if (this.closed || this.closeEventGraceTimer !== undefined) {
379
+ return;
380
+ }
381
+ // Browsers normally dispatch `close` immediately after `error`. Give that
382
+ // event a short window so its code/reason/reconnect semantics are not lost.
383
+ this.closeEventGraceTimer = setTimeout(() => {
384
+ this.closeEventGraceTimer = undefined;
385
+ this.fail(new BreezeBlueRealtimeError("Realtime TTS WebSocket failed without a close event.", { reconnect: true }));
386
+ }, REALTIME_CLOSE_EVENT_GRACE_MS);
387
+ }
301
388
  push(message, size) {
302
389
  if (this.closed) {
303
390
  return;
@@ -319,6 +406,7 @@ export class RealtimeTextToSpeechConnection {
319
406
  if (this.closed) {
320
407
  return;
321
408
  }
409
+ this.clearCloseEventGraceTimer();
322
410
  this.closed = true;
323
411
  this.failure = error;
324
412
  this.queue.length = 0;
@@ -332,11 +420,859 @@ export class RealtimeTextToSpeechConnection {
332
420
  if (this.closed) {
333
421
  return;
334
422
  }
423
+ this.clearCloseEventGraceTimer();
335
424
  this.closed = true;
336
425
  for (const waiter of this.waiters.splice(0)) {
337
426
  waiter.resolve({ value: undefined, done: true });
338
427
  }
339
428
  }
429
+ clearCloseEventGraceTimer() {
430
+ if (this.closeEventGraceTimer !== undefined) {
431
+ clearTimeout(this.closeEventGraceTimer);
432
+ this.closeEventGraceTimer = undefined;
433
+ }
434
+ }
435
+ }
436
+ /**
437
+ * A logical realtime TTS connection backed by bounded physical WebSocket
438
+ * epochs. Use `textToSpeech.realtime.connectManaged(...)` to create one.
439
+ *
440
+ * The manager automatically keeps an idle session alive, rotates before the
441
+ * server session limit at turn boundaries, and makes bounded reconnect
442
+ * attempts while idle. It does not buffer turn content or replay commands;
443
+ * startTurnWhenReady() may wait before its first turn.start write. If the
444
+ * transport is interrupted during a turn, iteration rejects with
445
+ * `BreezeBlueRealtimeError`.
446
+ */
447
+ export class ManagedRealtimeTextToSpeechConnection {
448
+ createConnection;
449
+ queue = [];
450
+ drainingEpochs = new Set();
451
+ openAttempts = new Set();
452
+ waiters = [];
453
+ heartbeatIntervalMs;
454
+ heartbeatTimeoutMs;
455
+ readyTimeoutMs;
456
+ rotationMarginMs;
457
+ maxPhysicalSessionMs;
458
+ maxReconnectAttempts;
459
+ reconnectBaseDelayMs;
460
+ current;
461
+ heartbeatTimer;
462
+ heartbeatAckTimer;
463
+ rotationTimer;
464
+ retryDelay;
465
+ transitionPromise;
466
+ transitionKind;
467
+ epochSequence = 0;
468
+ queuedBytes = 0;
469
+ activeTurn = false;
470
+ rotationPending = false;
471
+ closed = false;
472
+ failure;
473
+ constructor(createConnection, options) {
474
+ this.createConnection = createConnection;
475
+ this.readyTimeoutMs = positiveNumber(options.timeout ?? DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS, "timeout");
476
+ this.heartbeatIntervalMs = optionalNonNegativeNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
477
+ this.heartbeatTimeoutMs = positiveNumber(options.heartbeatTimeoutMs ?? DEFAULT_REALTIME_HEARTBEAT_TIMEOUT_MS, "heartbeatTimeoutMs");
478
+ this.rotationMarginMs = nonNegativeNumber(options.rotationMarginMs ?? DEFAULT_REALTIME_ROTATION_MARGIN_MS, "rotationMarginMs");
479
+ this.maxPhysicalSessionMs = nonNegativeNumber(options.maxPhysicalSessionMs ?? DEFAULT_REALTIME_MAX_PHYSICAL_SESSION_MS, "maxPhysicalSessionMs");
480
+ this.maxReconnectAttempts = nonNegativeInteger(options.maxReconnectAttempts ?? DEFAULT_REALTIME_MAX_RECONNECT_ATTEMPTS, "maxReconnectAttempts");
481
+ this.reconnectBaseDelayMs = nonNegativeNumber(options.reconnectBaseDelayMs ?? DEFAULT_REALTIME_RECONNECT_BASE_DELAY_MS, "reconnectBaseDelayMs");
482
+ }
483
+ static async open(createConnection, options = {}) {
484
+ const managed = new ManagedRealtimeTextToSpeechConnection(createConnection, options);
485
+ const epoch = await managed.openEpoch();
486
+ managed.installEpoch(epoch);
487
+ return managed;
488
+ }
489
+ startTurn(turnId) {
490
+ const connection = this.requireCurrentConnection();
491
+ try {
492
+ connection.startTurn(turnId);
493
+ this.activeTurn = true;
494
+ }
495
+ catch (error) {
496
+ this.activeTurn = false;
497
+ throw error;
498
+ }
499
+ }
500
+ /**
501
+ * Start a turn on the current physical epoch, waiting only when an idle
502
+ * unexpected reconnect is already replacing a missing epoch.
503
+ *
504
+ * No other turn command is buffered. Await this method before calling
505
+ * appendText(), flush(), endTurn(), or cancelTurn().
506
+ */
507
+ async startTurnWhenReady(turnId, options = {}) {
508
+ let waitedForUnexpectedTransition = false;
509
+ while (true) {
510
+ this.throwIfStartTurnAborted(options.signal);
511
+ if (this.current) {
512
+ if (waitedForUnexpectedTransition && this.activeTurn) {
513
+ throw new BreezeBlueRealtimeError("Realtime TTS managed connection started another turn while waiting for its replacement epoch.");
514
+ }
515
+ this.startTurn(turnId);
516
+ return;
517
+ }
518
+ const transition = this.transitionPromise;
519
+ if (this.transitionKind !== "unexpected" || transition === undefined) {
520
+ this.requireCurrentConnection();
521
+ return;
522
+ }
523
+ waitedForUnexpectedTransition = true;
524
+ await this.waitForStartTurnTransition(transition, options.signal);
525
+ }
526
+ }
527
+ appendText(text) {
528
+ this.requireCurrentConnection().appendText(text);
529
+ }
530
+ flush() {
531
+ this.requireCurrentConnection().flush();
532
+ }
533
+ endTurn() {
534
+ this.requireCurrentConnection().endTurn();
535
+ }
536
+ cancelTurn() {
537
+ this.requireCurrentConnection().cancelTurn();
538
+ }
539
+ /** Send an immediate ping in addition to the managed keepalive schedule. */
540
+ ping() {
541
+ this.requireCurrentConnection().ping();
542
+ }
543
+ close() {
544
+ if (this.closed) {
545
+ return;
546
+ }
547
+ this.closed = true;
548
+ this.clearTimers();
549
+ this.cancelRetryDelay();
550
+ this.cancelOpenAttempts();
551
+ const current = this.current;
552
+ this.current = undefined;
553
+ current?.connection.close();
554
+ this.closeSettlementDrains();
555
+ this.queue.length = 0;
556
+ this.queuedBytes = 0;
557
+ for (const waiter of this.waiters.splice(0)) {
558
+ waiter.resolve({ value: undefined, done: true });
559
+ }
560
+ }
561
+ async *events() {
562
+ for await (const message of this) {
563
+ if (message.type !== "audio") {
564
+ yield message;
565
+ }
566
+ }
567
+ }
568
+ async *audio() {
569
+ for await (const message of this) {
570
+ if (message.type === "audio") {
571
+ yield message.audio;
572
+ }
573
+ else if (message.type === "error") {
574
+ throw realtimeErrorFromEvent(message);
575
+ }
576
+ else if (message.type === "session.closed" &&
577
+ (message.reconnect === true || (message.code !== undefined && message.code !== 1000))) {
578
+ throw realtimeErrorFromClose(message);
579
+ }
580
+ }
581
+ }
582
+ [Symbol.asyncIterator]() {
583
+ return {
584
+ next: () => {
585
+ const entry = this.queue.shift();
586
+ if (entry) {
587
+ this.queuedBytes -= entry.size;
588
+ return Promise.resolve({ value: entry.message, done: false });
589
+ }
590
+ if (this.failure) {
591
+ return Promise.reject(this.failure);
592
+ }
593
+ if (this.closed) {
594
+ return Promise.resolve({ value: undefined, done: true });
595
+ }
596
+ return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
597
+ },
598
+ };
599
+ }
600
+ async openEpoch() {
601
+ const attempt = {
602
+ controller: new AbortController(),
603
+ abortListener: () => { },
604
+ connection: undefined,
605
+ timer: undefined,
606
+ active: true,
607
+ };
608
+ this.openAttempts.add(attempt);
609
+ const boundary = new Promise((_resolve, reject) => {
610
+ attempt.abortListener = () => reject(new BreezeBlueRealtimeError("Realtime TTS managed epoch opening was aborted."));
611
+ attempt.controller.signal.addEventListener("abort", attempt.abortListener, {
612
+ once: true,
613
+ });
614
+ attempt.timer = setTimeout(() => {
615
+ attempt.active = false;
616
+ reject(new BreezeBlueRealtimeError(`Realtime TTS managed epoch did not reach session.ready within ${this.readyTimeoutMs}ms.`, { reconnect: true }));
617
+ attempt.controller.abort();
618
+ }, this.readyTimeoutMs);
619
+ });
620
+ const connectionPromise = this.createConnection(attempt.controller.signal).then((connection) => {
621
+ attempt.connection = connection;
622
+ if (!attempt.active) {
623
+ connection.close();
624
+ throw new BreezeBlueRealtimeError("Realtime TTS managed epoch opening was aborted.");
625
+ }
626
+ return connection;
627
+ });
628
+ try {
629
+ const connection = await Promise.race([connectionPromise, boundary]);
630
+ const iterator = connection[Symbol.asyncIterator]();
631
+ const pending = [];
632
+ while (true) {
633
+ const result = await Promise.race([iterator.next(), boundary]);
634
+ if (result.done) {
635
+ throw new BreezeBlueRealtimeError("Realtime TTS connection closed before session.ready.", { reconnect: true });
636
+ }
637
+ const message = result.value;
638
+ if (message.type === "session.ready") {
639
+ this.completeOpenAttempt(attempt);
640
+ return {
641
+ id: ++this.epochSequence,
642
+ connection,
643
+ iterator,
644
+ ready: message,
645
+ pending,
646
+ pendingUsageHistoryIds: new Set(),
647
+ reconnectRequested: false,
648
+ retryAfterMs: 0,
649
+ heartbeatIntervalMs: 0,
650
+ heartbeatTimeoutMs: this.heartbeatTimeoutMs,
651
+ draining: false,
652
+ drainTimer: undefined,
653
+ };
654
+ }
655
+ if (message.type === "error") {
656
+ throw realtimeErrorFromEvent(message);
657
+ }
658
+ if (message.type === "session.closed") {
659
+ throw realtimeErrorFromClose(message);
660
+ }
661
+ pending.push(message);
662
+ }
663
+ }
664
+ catch (error) {
665
+ this.cancelOpenAttempt(attempt);
666
+ throw error;
667
+ }
668
+ finally {
669
+ this.openAttempts.delete(attempt);
670
+ }
671
+ }
672
+ installEpoch(epoch, transitionKind) {
673
+ if (this.closed) {
674
+ epoch.connection.close();
675
+ return;
676
+ }
677
+ const previous = this.current;
678
+ this.clearTimers();
679
+ this.current = epoch;
680
+ this.rotationPending = false;
681
+ this.push(epoch.ready);
682
+ for (const message of epoch.pending) {
683
+ this.handleEpochMessage(epoch, message);
684
+ }
685
+ this.scheduleTimers(epoch, transitionKind !== undefined);
686
+ void this.pumpEpoch(epoch);
687
+ if (previous && previous !== epoch) {
688
+ if (transitionKind === "planned" && previous.pendingUsageHistoryIds.size > 0) {
689
+ this.beginSettlementDrain(previous);
690
+ }
691
+ else {
692
+ previous.connection.close();
693
+ }
694
+ }
695
+ }
696
+ async pumpEpoch(epoch) {
697
+ try {
698
+ while (!this.closed && (this.current === epoch || epoch.draining)) {
699
+ const result = await epoch.iterator.next();
700
+ const isCurrent = this.current === epoch;
701
+ if ((!isCurrent && !epoch.draining) || this.closed) {
702
+ return;
703
+ }
704
+ if (result.done) {
705
+ if (epoch.draining && !isCurrent) {
706
+ this.finishSettlementDrain(epoch, false);
707
+ }
708
+ else {
709
+ this.handleEpochEnd(epoch);
710
+ }
711
+ return;
712
+ }
713
+ if (epoch.draining && !isCurrent) {
714
+ if (!this.handleSettlementDrainMessage(epoch, result.value)) {
715
+ return;
716
+ }
717
+ continue;
718
+ }
719
+ if (!this.handleEpochMessage(epoch, result.value)) {
720
+ return;
721
+ }
722
+ }
723
+ }
724
+ catch (error) {
725
+ if (epoch.draining && this.current !== epoch) {
726
+ this.finishSettlementDrain(epoch, false);
727
+ return;
728
+ }
729
+ if (this.current !== epoch || this.closed) {
730
+ return;
731
+ }
732
+ if (this.activeTurn) {
733
+ this.fail(activeTurnInterruptedError(error));
734
+ }
735
+ else if (error instanceof BreezeBlueRealtimeError && error.reconnect) {
736
+ this.requestUnexpectedReconnect(epoch, error);
737
+ }
738
+ else {
739
+ this.fail(asError(error));
740
+ }
741
+ }
742
+ }
743
+ handleEpochMessage(epoch, message) {
744
+ // Any inbound frame proves the full server-to-client path is alive. Pong
745
+ // is the expected idle acknowledgement; audio and events also prevent a
746
+ // false timeout while a turn is producing data.
747
+ this.acknowledgeHeartbeat(epoch);
748
+ if (message.type === "turn.started") {
749
+ this.activeTurn = true;
750
+ }
751
+ else if (message.type === "turn.done") {
752
+ this.activeTurn = false;
753
+ epoch.pendingUsageHistoryIds.add(message.historyItemId);
754
+ }
755
+ else if (message.type === "turn.cancelled") {
756
+ this.activeTurn = false;
757
+ }
758
+ else if (message.type === "usage.committed") {
759
+ epoch.pendingUsageHistoryIds.delete(message.historyItemId);
760
+ }
761
+ else if (message.type === "session.expiring") {
762
+ this.rotationPending = true;
763
+ }
764
+ else if (message.type === "error") {
765
+ const error = realtimeErrorFromEvent(message);
766
+ if (typeof message.meta?.retryAfterMs === "number") {
767
+ epoch.retryAfterMs = Math.max(epoch.retryAfterMs, message.meta.retryAfterMs);
768
+ }
769
+ if (error.recoverable) {
770
+ if (message.code === "GENERATION_CONCURRENCY_EXCEEDED") {
771
+ // The server rejected turn.start before establishing an active turn,
772
+ // so no turn.cancelled event will follow to clear local state.
773
+ this.activeTurn = false;
774
+ }
775
+ this.push(message);
776
+ if (!this.activeTurn && this.rotationPending) {
777
+ this.beginTransition("planned", epoch);
778
+ }
779
+ return true;
780
+ }
781
+ if (error.reconnect && this.activeTurn) {
782
+ this.fail(activeTurnInterruptedError(error));
783
+ return false;
784
+ }
785
+ if (error.reconnect) {
786
+ epoch.reconnectRequested = true;
787
+ this.requestUnexpectedReconnect(epoch, error);
788
+ return false;
789
+ }
790
+ this.fail(this.activeTurn ? activeTurnInterruptedError(error) : error);
791
+ return false;
792
+ }
793
+ else if (message.type === "session.closed") {
794
+ const reconnect = isReconnectableClose(message);
795
+ if (reconnect && this.activeTurn) {
796
+ this.fail(activeTurnInterruptedError(realtimeErrorFromClose(message)));
797
+ return false;
798
+ }
799
+ if (reconnect) {
800
+ epoch.reconnectRequested = true;
801
+ this.requestUnexpectedReconnect(epoch, realtimeErrorFromClose(message));
802
+ return false;
803
+ }
804
+ this.push(message);
805
+ return true;
806
+ }
807
+ this.push(message);
808
+ if (!this.activeTurn && this.rotationPending) {
809
+ this.beginTransition("planned", epoch);
810
+ }
811
+ return true;
812
+ }
813
+ handleEpochEnd(epoch) {
814
+ if (this.current !== epoch || this.closed) {
815
+ return;
816
+ }
817
+ if (this.activeTurn) {
818
+ this.fail(activeTurnInterruptedError());
819
+ return;
820
+ }
821
+ if (epoch.reconnectRequested) {
822
+ this.requestUnexpectedReconnect(epoch);
823
+ return;
824
+ }
825
+ this.clearTimers();
826
+ this.current = undefined;
827
+ this.finish();
828
+ }
829
+ requestUnexpectedReconnect(epoch, cause) {
830
+ if (this.current !== epoch || this.closed) {
831
+ return;
832
+ }
833
+ if (this.activeTurn) {
834
+ this.fail(activeTurnInterruptedError(cause));
835
+ return;
836
+ }
837
+ this.clearTimers();
838
+ this.current = undefined;
839
+ epoch.connection.close();
840
+ this.beginTransition("unexpected", epoch, cause);
841
+ }
842
+ beginTransition(kind, source, cause) {
843
+ if (this.closed || this.transitionPromise !== undefined) {
844
+ return;
845
+ }
846
+ this.transitionKind = kind;
847
+ this.transitionPromise = (async () => {
848
+ try {
849
+ await this.replaceEpoch(kind, source, cause);
850
+ }
851
+ catch (error) {
852
+ this.fail(asError(error));
853
+ }
854
+ finally {
855
+ this.transitionPromise = undefined;
856
+ this.transitionKind = undefined;
857
+ const current = this.current;
858
+ if (!this.closed && this.rotationPending && !this.activeTurn && current) {
859
+ this.beginTransition("planned", current);
860
+ }
861
+ }
862
+ })();
863
+ }
864
+ async replaceEpoch(kind, source, initialCause) {
865
+ let needsReconnectBudget = kind === "unexpected";
866
+ let lastError = initialCause;
867
+ let reconnectAttempts = 0;
868
+ while (!this.closed) {
869
+ if (this.deferPlannedTransitionForActiveTurn(kind, source)) {
870
+ return;
871
+ }
872
+ if (needsReconnectBudget) {
873
+ if (reconnectAttempts >= this.maxReconnectAttempts) {
874
+ if (this.deferPlannedTransitionForActiveTurn(kind, source)) {
875
+ return;
876
+ }
877
+ throw reconnectExhaustedError(lastError, this.maxReconnectAttempts);
878
+ }
879
+ reconnectAttempts += 1;
880
+ const backoffCeilingMs = Math.min(this.reconnectBaseDelayMs * 2 ** (reconnectAttempts - 1), MAX_REALTIME_RECONNECT_DELAY_MS);
881
+ const jitteredBackoffMs = Math.floor(backoffCeilingMs * (0.5 + Math.random() * 0.5));
882
+ const retryAfterMs = reconnectAttempts === 1 ? source.retryAfterMs : 0;
883
+ await this.waitForRetryDelay(Math.max(jitteredBackoffMs, retryAfterMs));
884
+ if (this.closed) {
885
+ return;
886
+ }
887
+ if (this.deferPlannedTransitionForActiveTurn(kind, source)) {
888
+ return;
889
+ }
890
+ }
891
+ let candidate;
892
+ try {
893
+ candidate = await this.openEpoch();
894
+ }
895
+ catch (error) {
896
+ if (this.deferPlannedTransitionForActiveTurn(kind, source)) {
897
+ return;
898
+ }
899
+ if ((error instanceof BreezeBlueAPIError && !isRetryableRealtimeSetupError(error)) ||
900
+ error instanceof BreezeBlueConfigurationError ||
901
+ (error instanceof BreezeBlueRealtimeError && !error.reconnect)) {
902
+ throw error;
903
+ }
904
+ lastError = error;
905
+ needsReconnectBudget = true;
906
+ continue;
907
+ }
908
+ if (this.closed) {
909
+ candidate.connection.close();
910
+ return;
911
+ }
912
+ if (this.deferPlannedTransitionForActiveTurn(kind, source)) {
913
+ // A new turn won the boundary race while the replacement socket was
914
+ // opening. Keep that turn on the old epoch and try again after it
915
+ // reaches a terminal event.
916
+ candidate.connection.close();
917
+ return;
918
+ }
919
+ this.installEpoch(candidate, kind);
920
+ return;
921
+ }
922
+ }
923
+ deferPlannedTransitionForActiveTurn(kind, source) {
924
+ if (kind !== "planned" ||
925
+ !this.activeTurn ||
926
+ this.current !== source ||
927
+ this.closed) {
928
+ return false;
929
+ }
930
+ this.rotationPending = true;
931
+ return true;
932
+ }
933
+ scheduleTimers(epoch, probeImmediately = false) {
934
+ const heartbeatInterval = this.heartbeatIntervalMs ??
935
+ Math.max(100, Math.min(15_000, Math.floor(epoch.ready.inactivityTimeoutSeconds * 400)));
936
+ epoch.heartbeatIntervalMs = heartbeatInterval;
937
+ epoch.heartbeatTimeoutMs = this.heartbeatTimeoutMs;
938
+ if (heartbeatInterval > 0) {
939
+ this.scheduleNextHeartbeat(epoch, probeImmediately ? 0 : heartbeatInterval);
940
+ }
941
+ const maxSessionMs = epoch.ready.maxSessionSeconds * 1_000;
942
+ const safeRotationMargin = Math.min(this.rotationMarginMs, maxSessionMs / 2);
943
+ const serverRotationDelay = Math.max(0, maxSessionMs - safeRotationMargin);
944
+ const rotationDelay = this.maxPhysicalSessionMs > 0
945
+ ? Math.min(serverRotationDelay, this.maxPhysicalSessionMs)
946
+ : serverRotationDelay;
947
+ this.rotationTimer = setTimeout(() => {
948
+ if (this.closed || this.current !== epoch) {
949
+ return;
950
+ }
951
+ this.rotationPending = true;
952
+ if (!this.activeTurn) {
953
+ this.beginTransition("planned", epoch);
954
+ }
955
+ }, rotationDelay);
956
+ }
957
+ scheduleNextHeartbeat(epoch, delayMs = epoch.heartbeatIntervalMs) {
958
+ if (epoch.heartbeatIntervalMs <= 0 ||
959
+ this.closed ||
960
+ this.current !== epoch) {
961
+ return;
962
+ }
963
+ this.heartbeatTimer = setTimeout(() => {
964
+ this.heartbeatTimer = undefined;
965
+ if (this.closed || this.current !== epoch) {
966
+ return;
967
+ }
968
+ try {
969
+ epoch.connection.ping();
970
+ }
971
+ catch (error) {
972
+ this.requestUnexpectedReconnect(epoch, error);
973
+ return;
974
+ }
975
+ this.heartbeatAckTimer = setTimeout(() => {
976
+ this.heartbeatAckTimer = undefined;
977
+ if (this.closed || this.current !== epoch) {
978
+ return;
979
+ }
980
+ this.requestUnexpectedReconnect(epoch, new BreezeBlueRealtimeError(`Realtime TTS managed heartbeat received no server response within ${epoch.heartbeatTimeoutMs}ms.`, { code: "HEARTBEAT_TIMEOUT", reconnect: true }));
981
+ }, epoch.heartbeatTimeoutMs);
982
+ }, delayMs);
983
+ }
984
+ acknowledgeHeartbeat(epoch) {
985
+ if (this.current !== epoch || this.heartbeatAckTimer === undefined) {
986
+ return;
987
+ }
988
+ clearTimeout(this.heartbeatAckTimer);
989
+ this.heartbeatAckTimer = undefined;
990
+ this.scheduleNextHeartbeat(epoch);
991
+ }
992
+ requireCurrentConnection() {
993
+ if (this.failure) {
994
+ throw this.failure;
995
+ }
996
+ if (this.closed) {
997
+ throw new BreezeBlueRealtimeError("Realtime TTS managed connection is closed.");
998
+ }
999
+ if (!this.current) {
1000
+ throw new BreezeBlueRealtimeError("Realtime TTS managed connection is reconnecting; wait for the next session.ready event.", { reconnect: true });
1001
+ }
1002
+ return this.current.connection;
1003
+ }
1004
+ async waitForStartTurnTransition(transition, signal) {
1005
+ if (!signal) {
1006
+ await transition;
1007
+ return;
1008
+ }
1009
+ this.throwIfStartTurnAborted(signal);
1010
+ let abortListener;
1011
+ try {
1012
+ await Promise.race([
1013
+ transition,
1014
+ new Promise((_resolve, reject) => {
1015
+ abortListener = () => reject(startTurnAbortReason(signal));
1016
+ signal.addEventListener("abort", abortListener, { once: true });
1017
+ }),
1018
+ ]);
1019
+ }
1020
+ finally {
1021
+ if (abortListener) {
1022
+ signal.removeEventListener("abort", abortListener);
1023
+ }
1024
+ }
1025
+ }
1026
+ throwIfStartTurnAborted(signal) {
1027
+ if (signal?.aborted) {
1028
+ throw startTurnAbortReason(signal);
1029
+ }
1030
+ }
1031
+ push(message) {
1032
+ if (this.closed) {
1033
+ return;
1034
+ }
1035
+ const waiter = this.waiters.shift();
1036
+ if (waiter) {
1037
+ waiter.resolve({ value: message, done: false });
1038
+ return;
1039
+ }
1040
+ const size = realtimeMessageSize(message);
1041
+ this.queue.push({ message, size });
1042
+ this.queuedBytes += size;
1043
+ if (this.queuedBytes > REALTIME_MAX_BUFFERED_BYTES) {
1044
+ this.fail(new BreezeBlueRealtimeError(`Realtime TTS managed connection buffered more than ${REALTIME_MAX_BUFFERED_BYTES} bytes without ` +
1045
+ "being consumed, so the logical connection was closed."));
1046
+ }
1047
+ }
1048
+ fail(error) {
1049
+ if (this.closed) {
1050
+ return;
1051
+ }
1052
+ this.failure = error;
1053
+ this.closed = true;
1054
+ this.clearTimers();
1055
+ this.cancelRetryDelay();
1056
+ this.cancelOpenAttempts();
1057
+ const current = this.current;
1058
+ this.current = undefined;
1059
+ current?.connection.close();
1060
+ this.closeSettlementDrains();
1061
+ this.queue.length = 0;
1062
+ this.queuedBytes = 0;
1063
+ for (const waiter of this.waiters.splice(0)) {
1064
+ waiter.reject(error);
1065
+ }
1066
+ }
1067
+ finish() {
1068
+ if (this.closed) {
1069
+ return;
1070
+ }
1071
+ this.closed = true;
1072
+ this.clearTimers();
1073
+ this.cancelRetryDelay();
1074
+ this.cancelOpenAttempts();
1075
+ this.closeSettlementDrains();
1076
+ for (const waiter of this.waiters.splice(0)) {
1077
+ waiter.resolve({ value: undefined, done: true });
1078
+ }
1079
+ }
1080
+ clearTimers() {
1081
+ if (this.heartbeatTimer !== undefined) {
1082
+ clearTimeout(this.heartbeatTimer);
1083
+ this.heartbeatTimer = undefined;
1084
+ }
1085
+ if (this.heartbeatAckTimer !== undefined) {
1086
+ clearTimeout(this.heartbeatAckTimer);
1087
+ this.heartbeatAckTimer = undefined;
1088
+ }
1089
+ if (this.rotationTimer !== undefined) {
1090
+ clearTimeout(this.rotationTimer);
1091
+ this.rotationTimer = undefined;
1092
+ }
1093
+ }
1094
+ waitForRetryDelay(milliseconds) {
1095
+ if (this.closed) {
1096
+ return Promise.reject(new BreezeBlueRealtimeError("Realtime TTS managed reconnect delay was cancelled."));
1097
+ }
1098
+ if (milliseconds <= 0) {
1099
+ return Promise.resolve();
1100
+ }
1101
+ return new Promise((resolve, reject) => {
1102
+ const timer = setTimeout(() => {
1103
+ if (this.retryDelay?.timer === timer) {
1104
+ this.retryDelay = undefined;
1105
+ }
1106
+ resolve();
1107
+ }, milliseconds);
1108
+ this.retryDelay = { timer, reject };
1109
+ });
1110
+ }
1111
+ cancelRetryDelay() {
1112
+ const pending = this.retryDelay;
1113
+ if (!pending) {
1114
+ return;
1115
+ }
1116
+ this.retryDelay = undefined;
1117
+ clearTimeout(pending.timer);
1118
+ pending.reject(new BreezeBlueRealtimeError("Realtime TTS managed reconnect delay was cancelled."));
1119
+ }
1120
+ completeOpenAttempt(attempt) {
1121
+ attempt.active = false;
1122
+ if (attempt.timer !== undefined) {
1123
+ clearTimeout(attempt.timer);
1124
+ attempt.timer = undefined;
1125
+ }
1126
+ attempt.controller.signal.removeEventListener("abort", attempt.abortListener);
1127
+ }
1128
+ cancelOpenAttempt(attempt) {
1129
+ attempt.active = false;
1130
+ if (attempt.timer !== undefined) {
1131
+ clearTimeout(attempt.timer);
1132
+ attempt.timer = undefined;
1133
+ }
1134
+ if (!attempt.controller.signal.aborted) {
1135
+ attempt.controller.abort();
1136
+ }
1137
+ attempt.controller.signal.removeEventListener("abort", attempt.abortListener);
1138
+ attempt.connection?.close();
1139
+ }
1140
+ cancelOpenAttempts() {
1141
+ for (const attempt of Array.from(this.openAttempts)) {
1142
+ this.cancelOpenAttempt(attempt);
1143
+ }
1144
+ this.openAttempts.clear();
1145
+ }
1146
+ beginSettlementDrain(epoch) {
1147
+ epoch.draining = true;
1148
+ this.drainingEpochs.add(epoch);
1149
+ epoch.drainTimer = setTimeout(() => this.finishSettlementDrain(epoch, true), REALTIME_SETTLEMENT_DRAIN_TIMEOUT_MS);
1150
+ }
1151
+ handleSettlementDrainMessage(epoch, message) {
1152
+ if (message.type === "usage.committed") {
1153
+ epoch.pendingUsageHistoryIds.delete(message.historyItemId);
1154
+ this.push(message);
1155
+ if (epoch.pendingUsageHistoryIds.size === 0) {
1156
+ this.finishSettlementDrain(epoch, true);
1157
+ return false;
1158
+ }
1159
+ return true;
1160
+ }
1161
+ if (message.type === "error") {
1162
+ this.finishSettlementDrain(epoch, true);
1163
+ return false;
1164
+ }
1165
+ if (message.type === "session.closed") {
1166
+ this.finishSettlementDrain(epoch, false);
1167
+ return false;
1168
+ }
1169
+ return true;
1170
+ }
1171
+ finishSettlementDrain(epoch, closeConnection) {
1172
+ if (!epoch.draining) {
1173
+ return;
1174
+ }
1175
+ epoch.draining = false;
1176
+ if (epoch.drainTimer !== undefined) {
1177
+ clearTimeout(epoch.drainTimer);
1178
+ epoch.drainTimer = undefined;
1179
+ }
1180
+ this.drainingEpochs.delete(epoch);
1181
+ if (closeConnection) {
1182
+ epoch.connection.close();
1183
+ }
1184
+ }
1185
+ closeSettlementDrains() {
1186
+ for (const epoch of Array.from(this.drainingEpochs)) {
1187
+ this.finishSettlementDrain(epoch, true);
1188
+ }
1189
+ }
1190
+ }
1191
+ const REALTIME_RECOVERABLE_ERROR_CODES = new Set([
1192
+ "BAD_REQUEST",
1193
+ "GENERATION_CAPACITY_EXCEEDED",
1194
+ "GENERATION_CONCURRENCY_EXCEEDED",
1195
+ "VALIDATION_ERROR",
1196
+ ]);
1197
+ function realtimeErrorFromEvent(event) {
1198
+ const reconnect = event.meta?.reconnect === true ||
1199
+ event.code === "SESSION_EXPIRED" ||
1200
+ event.code === "IDLE_TIMEOUT" ||
1201
+ event.code === "AUTH_CONFIG_MISSING" ||
1202
+ event.code === "GENERATION_INVALID_RESPONSE" ||
1203
+ (event.code.startsWith("UPSTREAM_") && event.code !== "UPSTREAM_BILLING_ERROR");
1204
+ return new BreezeBlueRealtimeError(`Realtime TTS failed: ${event.code}: ${event.message}`, {
1205
+ code: event.code,
1206
+ meta: event.meta,
1207
+ reconnect,
1208
+ recoverable: !reconnect && REALTIME_RECOVERABLE_ERROR_CODES.has(event.code),
1209
+ });
1210
+ }
1211
+ function realtimeErrorFromClose(event) {
1212
+ const detail = [event.code, event.reason].filter((value) => value !== undefined && value !== "").join(" ");
1213
+ return new BreezeBlueRealtimeError(`Realtime TTS connection closed${detail ? `: ${detail}` : "."}`, {
1214
+ closeCode: event.code,
1215
+ reconnect: isReconnectableClose(event),
1216
+ });
1217
+ }
1218
+ function isReconnectableClose(event) {
1219
+ return (event.reconnect === true ||
1220
+ event.code === 1001 ||
1221
+ event.code === 1006 ||
1222
+ event.code === 1011 ||
1223
+ event.code === 1012);
1224
+ }
1225
+ function activeTurnInterruptedError(cause) {
1226
+ const realtimeCause = cause instanceof BreezeBlueRealtimeError ? cause : undefined;
1227
+ return new BreezeBlueRealtimeError("Realtime TTS active turn was interrupted. The SDK did not replay any turn commands; start a new turn from application state.", {
1228
+ code: "TURN_INTERRUPTED",
1229
+ meta: realtimeCause?.meta,
1230
+ closeCode: realtimeCause?.closeCode,
1231
+ reconnect: realtimeCause?.reconnect ?? true,
1232
+ recoverable: false,
1233
+ cause,
1234
+ });
1235
+ }
1236
+ function reconnectExhaustedError(cause, maxReconnectAttempts) {
1237
+ return new BreezeBlueRealtimeError(`Realtime TTS managed connection exhausted ${maxReconnectAttempts} reconnect attempts.`, {
1238
+ code: "RECONNECT_EXHAUSTED",
1239
+ reconnect: false,
1240
+ recoverable: false,
1241
+ cause,
1242
+ });
1243
+ }
1244
+ function isRetryableRealtimeSetupError(error) {
1245
+ return error.status >= 500 || error.status === 408 || error.status === 425 || error.status === 429;
1246
+ }
1247
+ function realtimeMessageSize(message) {
1248
+ if (message.type === "audio") {
1249
+ return message.audio.byteLength;
1250
+ }
1251
+ return JSON.stringify(message).length * 2;
1252
+ }
1253
+ function optionalNonNegativeNumber(value, name) {
1254
+ return value === undefined ? undefined : nonNegativeNumber(value, name);
1255
+ }
1256
+ function nonNegativeNumber(value, name) {
1257
+ if (!Number.isFinite(value) || value < 0) {
1258
+ throw new BreezeBlueConfigurationError(`${name} must be a finite non-negative number.`);
1259
+ }
1260
+ return value;
1261
+ }
1262
+ function positiveNumber(value, name) {
1263
+ if (!Number.isFinite(value) || value <= 0) {
1264
+ throw new BreezeBlueConfigurationError(`${name} must be a finite positive number.`);
1265
+ }
1266
+ return value;
1267
+ }
1268
+ function nonNegativeInteger(value, name) {
1269
+ if (!Number.isInteger(value) || value < 0) {
1270
+ throw new BreezeBlueConfigurationError(`${name} must be a non-negative integer.`);
1271
+ }
1272
+ return value;
1273
+ }
1274
+ function asError(error) {
1275
+ return error instanceof Error ? error : new Error(String(error));
340
1276
  }
341
1277
  class GenerationJobsResource {
342
1278
  client;
@@ -498,6 +1434,10 @@ function buildAbortSignal(inputSignal, timeoutMs) {
498
1434
  },
499
1435
  };
500
1436
  }
1437
+ function startTurnAbortReason(signal) {
1438
+ return signal.reason ??
1439
+ new BreezeBlueRealtimeError("Realtime TTS managed startTurnWhenReady was aborted before turn.start was sent.");
1440
+ }
501
1441
  function buildUrl(baseUrl, path, params) {
502
1442
  const url = new URL(`${baseUrl}${path}`);
503
1443
  for (const [key, value] of Object.entries(params ?? {})) {