@breeze.blue/sdk 0.6.3 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0
6
+
7
+ - Realtime TTS sessions prefer the optional query-free direct WebSocket URL
8
+ and authenticate with a non-echoed token-bearing subprotocol. Existing
9
+ public session URLs remain the fallback for older deployments and clients.
10
+
11
+ ## 0.7.0
12
+
13
+ - Realtime TTS connections can update synthesis instructions between turns
14
+ with `updateInstructions(...)`. The matching `session.updated` event remains
15
+ on the existing ordered stream, and managed connections carry confirmed
16
+ instructions across physical-session rotation and idle reconnects.
17
+ `BreezeBlueRealtimeError.recoverable` means the same connection remains
18
+ usable: most recoverable turn-command errors cancel the active turn, while
19
+ an active-turn `session.update` rejection leaves that turn running and
20
+ callers continue consuming it on the existing connection.
21
+
5
22
  ## 0.6.3
6
23
 
7
24
  - Simplified model listings to Breeze-native fields: model ID, name, supported
package/README.md CHANGED
@@ -152,7 +152,14 @@ a `BreezeBlueRealtimeError`. The `audio()` helper also throws
152
152
  `BreezeBlueRealtimeError` for server `error` events and abnormal WebSocket
153
153
  closes instead of silently ending.
154
154
 
155
- Events are a typed discriminated union (`session.ready`, `turn.started`,
155
+ When `BreezeBlueRealtimeError.recoverable` is `true`, the same connection
156
+ remains usable. Most recoverable turn-command errors cancel the active turn.
157
+ An active-turn `session.update` rejection is the exception: the turn keeps
158
+ running. If `connection.audio()` throws that error, start a fresh `audio()`
159
+ iterator immediately on the same connection and continue collecting the
160
+ current turn; do not start a replacement turn.
161
+
162
+ Events are a typed discriminated union (`session.ready`, `session.updated`, `turn.started`,
156
163
  `audio.started`, `turn.done`, `turn.cancelled`, `usage.committed`,
157
164
  `session.expiring`, `session.closed`, `error`, `pong`), with camelCase fields
158
165
  such as `turnId`, `historyItemId`, `expiresAt`, and `ttfaMs`. Treat the union as
@@ -160,6 +167,32 @@ non-exhaustive: the server may add event types, and the SDK delivers unknown
160
167
  JSON events unchanged — ignore event types you do not recognize instead of
161
168
  switching exhaustively.
162
169
 
170
+ Between turns, update the synthesis instructions without replacing the
171
+ WebSocket. `updateInstructions(...)` only sends the request; the matching
172
+ `session.updated` acknowledgement remains on the same ordered iterator. Wait
173
+ for it before starting the next turn:
174
+
175
+ ```ts
176
+ connection.updateInstructions("Speak faster and with more energy.");
177
+ for await (const message of connection) {
178
+ if (message.type === "session.updated") {
179
+ if (message.instructions !== "Speak faster and with more energy.") {
180
+ throw new Error("Unexpected instructions acknowledgement");
181
+ }
182
+ break;
183
+ }
184
+ }
185
+
186
+ connection.startTurn("turn_2");
187
+ ```
188
+
189
+ Instructions must be a non-empty string of at most 1,000 characters. Only one
190
+ update may await acknowledgement, and updates are accepted only while no turn
191
+ is active. An update attempted during a turn is rejected without interrupting
192
+ that turn. Keep one stream consumer: if a long-lived consumer owns the
193
+ iterator, have it signal your turn producer when `session.updated` arrives
194
+ instead of starting a second iterator.
195
+
163
196
  Send a keepalive ping inside the session's `inactivityTimeoutSeconds` window;
164
197
  the server answers with a `pong` event. Keep it running during active turns too:
165
198
  a long TTFA or upstream stall with no audio does not pause the idle deadline.
@@ -245,9 +278,15 @@ const session = await client.textToSpeech.realtime.createSession("voc_...", {
245
278
  // Later, when the first text is ready:
246
279
  const connection = await client.textToSpeech.realtime.connect("voc_...", {
247
280
  clientSecret: session.clientSecret,
281
+ websocketUrl: session.websocketUrl,
282
+ directWebsocketUrl: session.directWebsocketUrl,
248
283
  });
249
284
  ```
250
285
 
286
+ When the session response includes `directWebsocketUrl`, the SDK prefers its
287
+ query-free origin and authenticates with the WebSocket subprotocol. It falls
288
+ back to `websocketUrl` for services that have not enabled the direct transport.
289
+
251
290
  The same `clientSecret` handoff lets a browser connect without ever seeing your
252
291
  API key: create the session on your server, hand `session.clientSecret` to the
253
292
  page, and build a key-less client there:
@@ -282,7 +321,7 @@ const connection = await browserClient.textToSpeech.realtime.connectManaged("voc
282
321
  reconnect,
283
322
  });
284
323
  }
285
- return response.json(); // { clientSecret, websocketUrl? }
324
+ return response.json(); // { clientSecret, websocketUrl?, directWebsocketUrl? }
286
325
  },
287
326
  });
288
327
  ```
@@ -291,10 +330,18 @@ Honor the factory `signal`: the SDK aborts it when the logical connection closes
291
330
  or the physical-epoch timeout expires. A custom callback that ignores the
292
331
  signal must still enforce its own bounded request timeout.
293
332
 
294
- Session parameters (`modelId`, `languageCode`, `instructions`, `voiceSettings`,
295
- `inactivityTimeoutSeconds`, `enableLogging`) are fixed when the session is
296
- created. `connect` ignores them when `clientSecret` or `websocketUrl` is
297
- provided and logs a warning.
333
+ After a dynamic instruction update receives `session.updated`, the managed SDK
334
+ carries the confirmed value to later physical epochs. SDK-created replacements
335
+ include it in their session request; factory-created replacements receive an
336
+ automatic `session.update` before their next `session.ready` is exposed to the
337
+ logical iterator.
338
+
339
+ `modelId`, `languageCode`, `voiceSettings`, `inactivityTimeoutSeconds`, and
340
+ `enableLogging` are fixed when the session is created. The initial
341
+ `instructions` value is also supplied at creation, but it can later be
342
+ replaced between turns with `updateInstructions(...)`. `connect` ignores
343
+ initial session options when `clientSecret` or `websocketUrl` is provided and
344
+ logs a warning.
298
345
 
299
346
  If a realtime WebSocket is interrupted by a network change, service deployment,
300
347
  or upstream realtime worker restart, `connectManaged(...)` handles bounded
package/dist/client.d.ts CHANGED
@@ -48,17 +48,27 @@ export declare class RealtimeTextToSpeechConnection implements AsyncIterable<Rea
48
48
  private closed;
49
49
  private queuedBytes;
50
50
  private failure;
51
+ private pendingInstructions;
51
52
  private closeEventGraceTimer;
52
53
  private constructor();
53
54
  static open(socket: BreezeBlueWebSocket, options?: {
54
55
  timeout?: number;
55
56
  signal?: AbortSignal;
57
+ requiredSubprotocol?: string;
56
58
  }): Promise<RealtimeTextToSpeechConnection>;
57
59
  startTurn(turnId?: string): void;
58
60
  appendText(text: string): void;
59
61
  flush(): void;
60
62
  endTurn(): void;
61
63
  cancelTurn(): void;
64
+ /**
65
+ * Request new synthesis instructions for subsequent turns.
66
+ *
67
+ * The matching `session.updated` acknowledgement stays on this
68
+ * connection's existing message stream. Keep the single consumer running
69
+ * and wait for that event before starting the next turn.
70
+ */
71
+ updateInstructions(instructions: string): void;
62
72
  /** Send a keepalive ping. The server answers with a `pong` event. */
63
73
  ping(): void;
64
74
  close(): void;
@@ -73,6 +83,7 @@ export declare class RealtimeTextToSpeechConnection implements AsyncIterable<Rea
73
83
  private fail;
74
84
  private finish;
75
85
  private clearCloseEventGraceTimer;
86
+ private handleInstructionsEvent;
76
87
  }
77
88
  type RealtimeConnectionFactory = (signal: AbortSignal) => Promise<RealtimeTextToSpeechConnection>;
78
89
  /**
@@ -88,6 +99,7 @@ type RealtimeConnectionFactory = (signal: AbortSignal) => Promise<RealtimeTextTo
88
99
  */
89
100
  export declare class ManagedRealtimeTextToSpeechConnection implements AsyncIterable<RealtimeTextToSpeechMessage> {
90
101
  private readonly createConnection;
102
+ private readonly onInstructionsConfirmed;
91
103
  private readonly queue;
92
104
  private readonly drainingEpochs;
93
105
  private readonly openAttempts;
@@ -99,6 +111,7 @@ export declare class ManagedRealtimeTextToSpeechConnection implements AsyncItera
99
111
  private readonly maxPhysicalSessionMs;
100
112
  private readonly maxReconnectAttempts;
101
113
  private readonly reconnectBaseDelayMs;
114
+ private readonly reapplyInstructionsOnOpen;
102
115
  private current;
103
116
  private heartbeatTimer;
104
117
  private heartbeatAckTimer;
@@ -109,11 +122,13 @@ export declare class ManagedRealtimeTextToSpeechConnection implements AsyncItera
109
122
  private epochSequence;
110
123
  private queuedBytes;
111
124
  private activeTurn;
125
+ private instructions;
126
+ private pendingInstructions;
112
127
  private rotationPending;
113
128
  private closed;
114
129
  private failure;
115
130
  private constructor();
116
- static open(createConnection: RealtimeConnectionFactory, options?: RealtimeTextToSpeechManagedConnectOptions): Promise<ManagedRealtimeTextToSpeechConnection>;
131
+ static open(createConnection: RealtimeConnectionFactory, options?: RealtimeTextToSpeechManagedConnectOptions, onInstructionsConfirmed?: (instructions: string) => void): Promise<ManagedRealtimeTextToSpeechConnection>;
117
132
  startTurn(turnId?: string): void;
118
133
  /**
119
134
  * Start a turn on the current physical epoch, waiting only when an idle
@@ -129,6 +144,14 @@ export declare class ManagedRealtimeTextToSpeechConnection implements AsyncItera
129
144
  flush(): void;
130
145
  endTurn(): void;
131
146
  cancelTurn(): void;
147
+ /**
148
+ * Update the synthesis instructions used by subsequent turns.
149
+ *
150
+ * The matching `session.updated` acknowledgement stays on this logical
151
+ * connection's ordered event stream. Confirmed instructions are carried to
152
+ * later physical WebSocket epochs.
153
+ */
154
+ updateInstructions(instructions: string): void;
132
155
  /** Send an immediate ping in addition to the managed keepalive schedule. */
133
156
  ping(): void;
134
157
  close(): void;
package/dist/client.js CHANGED
@@ -14,6 +14,10 @@ const DEFAULT_REALTIME_RECONNECT_BASE_DELAY_MS = 250;
14
14
  const MAX_REALTIME_RECONNECT_DELAY_MS = 4_000;
15
15
  const REALTIME_SETTLEMENT_DRAIN_TIMEOUT_MS = 5_000;
16
16
  const REALTIME_CLOSE_EVENT_GRACE_MS = 50;
17
+ const REALTIME_MAX_INSTRUCTIONS_CHARACTERS = 1_000;
18
+ const REALTIME_SAFE_SUBPROTOCOL = "breeze-realtime-v1";
19
+ const REALTIME_TOKEN_SUBPROTOCOL_PREFIX = "breeze-realtime-token.";
20
+ const REALTIME_MAX_TOKEN_SUBPROTOCOL_LENGTH = 7 * 1024;
17
21
  const WEBSOCKET_OPEN = 1;
18
22
  export class BreezeBlueClient {
19
23
  apiKey;
@@ -128,30 +132,55 @@ class RealtimeTextToSpeechResource {
128
132
  return this.client.requestJson("POST", `/v1/text-to-speech/${encodeURIComponent(voiceId)}/realtime-sessions`, undefined, request, options);
129
133
  }
130
134
  async connect(voiceId, options = {}) {
131
- if ((options.clientSecret !== undefined || options.websocketUrl !== undefined) && hasRealtimeSessionParams(options)) {
135
+ if ((options.clientSecret !== undefined ||
136
+ options.websocketUrl !== undefined ||
137
+ options.directWebsocketUrl !== undefined) && hasRealtimeSessionParams(options)) {
132
138
  console.warn("@breeze.blue/sdk: realtime.connect ignores modelId, languageCode, instructions, voiceSettings, " +
133
- "inactivityTimeoutSeconds, and enableLogging when clientSecret or websocketUrl is provided. " +
134
- "Configure these when creating the realtime session instead.");
135
- }
136
- const websocketUrl = options.websocketUrl ??
137
- (options.clientSecret
138
- ? buildUrl(this.client.baseUrl.replace(/^http/i, "ws"), `/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream-input`, { client_secret: options.clientSecret })
139
- : (await this.createSession(voiceId, {
140
- modelId: options.modelId,
141
- languageCode: options.languageCode,
142
- instructions: options.instructions,
143
- voiceSettings: options.voiceSettings,
144
- inactivityTimeoutSeconds: options.inactivityTimeoutSeconds,
145
- enableLogging: options.enableLogging,
146
- }, {
147
- signal: options.signal,
148
- timeout: options.timeout,
149
- })).websocketUrl);
139
+ "inactivityTimeoutSeconds, and enableLogging when session credentials are provided. " +
140
+ "Configure these initial values when creating the realtime session instead. " +
141
+ "To change instructions between turns, call connection.updateInstructions() and wait for session.updated.");
142
+ }
143
+ let clientSecret = options.clientSecret;
144
+ let websocketUrl = options.websocketUrl;
145
+ let directWebsocketUrl = options.directWebsocketUrl;
146
+ if (!clientSecret && !websocketUrl && !directWebsocketUrl) {
147
+ const session = await this.createSession(voiceId, {
148
+ modelId: options.modelId,
149
+ languageCode: options.languageCode,
150
+ instructions: options.instructions,
151
+ voiceSettings: options.voiceSettings,
152
+ inactivityTimeoutSeconds: options.inactivityTimeoutSeconds,
153
+ enableLogging: options.enableLogging,
154
+ }, {
155
+ signal: options.signal,
156
+ timeout: options.timeout,
157
+ });
158
+ clientSecret = session.clientSecret;
159
+ websocketUrl = session.websocketUrl;
160
+ directWebsocketUrl = session.directWebsocketUrl;
161
+ }
162
+ if (!websocketUrl && clientSecret) {
163
+ websocketUrl = buildUrl(this.client.baseUrl.replace(/^http/i, "ws"), `/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream-input`, { client_secret: clientSecret });
164
+ }
165
+ let protocols;
166
+ let requiredSubprotocol;
167
+ if (directWebsocketUrl) {
168
+ if (!clientSecret) {
169
+ throw new BreezeBlueConfigurationError("realtime directWebsocketUrl requires a clientSecret.");
170
+ }
171
+ protocols = realtimeClientSecretSubprotocols(clientSecret);
172
+ requiredSubprotocol = REALTIME_SAFE_SUBPROTOCOL;
173
+ websocketUrl = directWebsocketUrl;
174
+ }
175
+ if (!websocketUrl) {
176
+ throw new BreezeBlueConfigurationError("realtime.connect requires session credentials or an API-created session.");
177
+ }
150
178
  const WebSocketImpl = requireWebSocket(options.webSocket ?? this.client.webSocket);
151
- const socket = new WebSocketImpl(websocketUrl);
179
+ const socket = new WebSocketImpl(websocketUrl, protocols);
152
180
  return RealtimeTextToSpeechConnection.open(socket, {
153
181
  timeout: options.timeout ?? this.client.timeout ?? DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS,
154
182
  signal: options.signal,
183
+ requiredSubprotocol,
155
184
  });
156
185
  }
157
186
  /**
@@ -164,7 +193,8 @@ class RealtimeTextToSpeechResource {
164
193
  if (options.sessionFactory !== undefined && hasRealtimeSessionParams(options)) {
165
194
  console.warn("@breeze.blue/sdk: realtime.connectManaged cannot apply modelId, languageCode, instructions, " +
166
195
  "voiceSettings, inactivityTimeoutSeconds, or enableLogging when sessionFactory is provided. " +
167
- "Configure these when the factory creates each realtime session.");
196
+ "Configure these when the factory creates each realtime session. After connecting, confirmed " +
197
+ "updateInstructions() values are carried across managed physical sessions.");
168
198
  }
169
199
  const sessionRequest = {
170
200
  modelId: options.modelId,
@@ -188,6 +218,7 @@ class RealtimeTextToSpeechResource {
188
218
  return this.connect(voiceId, {
189
219
  clientSecret: credentials.clientSecret,
190
220
  websocketUrl: credentials.websocketUrl,
221
+ directWebsocketUrl: credentials.directWebsocketUrl,
191
222
  webSocket: options.webSocket,
192
223
  signal,
193
224
  timeout: connectionTimeout,
@@ -196,6 +227,8 @@ class RealtimeTextToSpeechResource {
196
227
  return ManagedRealtimeTextToSpeechConnection.open(createConnection, {
197
228
  ...options,
198
229
  timeout: connectionTimeout,
230
+ }, (instructions) => {
231
+ sessionRequest.instructions = instructions;
199
232
  });
200
233
  }
201
234
  }
@@ -207,6 +240,16 @@ function hasRealtimeSessionParams(options) {
207
240
  options.inactivityTimeoutSeconds !== undefined ||
208
241
  options.enableLogging !== undefined);
209
242
  }
243
+ function realtimeClientSecretSubprotocols(clientSecret) {
244
+ if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(clientSecret)) {
245
+ throw new BreezeBlueConfigurationError("Realtime clientSecret cannot be encoded as a WebSocket subprotocol token.");
246
+ }
247
+ const tokenProtocol = `${REALTIME_TOKEN_SUBPROTOCOL_PREFIX}${clientSecret}`;
248
+ if (tokenProtocol.length > REALTIME_MAX_TOKEN_SUBPROTOCOL_LENGTH) {
249
+ throw new BreezeBlueConfigurationError("Realtime clientSecret WebSocket subprotocol is too long.");
250
+ }
251
+ return [REALTIME_SAFE_SUBPROTOCOL, tokenProtocol];
252
+ }
210
253
  export class RealtimeTextToSpeechConnection {
211
254
  socket;
212
255
  queue = [];
@@ -214,6 +257,7 @@ export class RealtimeTextToSpeechConnection {
214
257
  closed = false;
215
258
  queuedBytes = 0;
216
259
  failure;
260
+ pendingInstructions;
217
261
  closeEventGraceTimer;
218
262
  constructor(socket) {
219
263
  this.socket = socket;
@@ -234,6 +278,11 @@ export class RealtimeTextToSpeechConnection {
234
278
  const succeed = () => {
235
279
  if (settled)
236
280
  return;
281
+ if (options.requiredSubprotocol !== undefined &&
282
+ socket.protocol !== options.requiredSubprotocol) {
283
+ fail(new BreezeBlueRealtimeError(`Realtime TTS WebSocket did not negotiate ${options.requiredSubprotocol}.`, { code: "GENERATION_INVALID_RESPONSE" }));
284
+ return;
285
+ }
237
286
  settled = true;
238
287
  cleanup();
239
288
  resolve();
@@ -271,6 +320,9 @@ export class RealtimeTextToSpeechConnection {
271
320
  return connection;
272
321
  }
273
322
  startTurn(turnId) {
323
+ if (this.pendingInstructions !== undefined) {
324
+ throw new BreezeBlueRealtimeError("Realtime TTS instructions update is awaiting session.updated; wait for that event before starting the next turn.", { code: "BAD_REQUEST", recoverable: true });
325
+ }
274
326
  this.sendJson({ type: "turn.start", ...(turnId ? { turn_id: turnId } : {}) });
275
327
  }
276
328
  appendText(text) {
@@ -285,6 +337,29 @@ export class RealtimeTextToSpeechConnection {
285
337
  cancelTurn() {
286
338
  this.sendJson({ type: "turn.cancel" });
287
339
  }
340
+ /**
341
+ * Request new synthesis instructions for subsequent turns.
342
+ *
343
+ * The matching `session.updated` acknowledgement stays on this
344
+ * connection's existing message stream. Keep the single consumer running
345
+ * and wait for that event before starting the next turn.
346
+ */
347
+ updateInstructions(instructions) {
348
+ const validated = validateRealtimeInstructions(instructions);
349
+ if (this.pendingInstructions !== undefined) {
350
+ throw new BreezeBlueRealtimeError("Realtime TTS already has an instructions update awaiting session.updated.", { code: "BAD_REQUEST", recoverable: true });
351
+ }
352
+ this.pendingInstructions = validated;
353
+ try {
354
+ this.sendJson({ type: "session.update", instructions: validated });
355
+ }
356
+ catch (error) {
357
+ if (this.pendingInstructions === validated) {
358
+ this.pendingInstructions = undefined;
359
+ }
360
+ throw error;
361
+ }
362
+ }
288
363
  /** Send a keepalive ping. The server answers with a `pong` event. */
289
364
  ping() {
290
365
  this.sendJson({ type: "ping" });
@@ -296,6 +371,7 @@ export class RealtimeTextToSpeechConnection {
296
371
  if (this.socket.readyState === WEBSOCKET_OPEN) {
297
372
  this.sendJson({ type: "session.close" });
298
373
  }
374
+ this.pendingInstructions = undefined;
299
375
  this.socket.close(1000);
300
376
  this.finish();
301
377
  }
@@ -351,7 +427,10 @@ export class RealtimeTextToSpeechConnection {
351
427
  this.fail(new BreezeBlueRealtimeError("Realtime TTS returned an invalid JSON event.", { cause }));
352
428
  return;
353
429
  }
354
- this.push(camelizeKeys(parsed), data.length);
430
+ const message = camelizeKeys(parsed);
431
+ if (this.handleInstructionsEvent(message)) {
432
+ this.push(message, data.length);
433
+ }
355
434
  return;
356
435
  }
357
436
  if (data instanceof ArrayBuffer) {
@@ -363,6 +442,7 @@ export class RealtimeTextToSpeechConnection {
363
442
  }
364
443
  }
365
444
  handleClose(event) {
445
+ this.pendingInstructions = undefined;
366
446
  this.clearCloseEventGraceTimer();
367
447
  if (!this.closed && event.code !== 1000) {
368
448
  this.push({
@@ -409,6 +489,7 @@ export class RealtimeTextToSpeechConnection {
409
489
  this.clearCloseEventGraceTimer();
410
490
  this.closed = true;
411
491
  this.failure = error;
492
+ this.pendingInstructions = undefined;
412
493
  this.queue.length = 0;
413
494
  this.queuedBytes = 0;
414
495
  this.socket.close(1000);
@@ -422,6 +503,7 @@ export class RealtimeTextToSpeechConnection {
422
503
  }
423
504
  this.clearCloseEventGraceTimer();
424
505
  this.closed = true;
506
+ this.pendingInstructions = undefined;
425
507
  for (const waiter of this.waiters.splice(0)) {
426
508
  waiter.resolve({ value: undefined, done: true });
427
509
  }
@@ -432,6 +514,25 @@ export class RealtimeTextToSpeechConnection {
432
514
  this.closeEventGraceTimer = undefined;
433
515
  }
434
516
  }
517
+ handleInstructionsEvent(message) {
518
+ if (message.type === "session.updated") {
519
+ if (this.pendingInstructions === undefined ||
520
+ message.instructions !== this.pendingInstructions) {
521
+ this.fail(new BreezeBlueRealtimeError("Realtime TTS returned a session.updated acknowledgement that did not match the pending instructions update.", { code: "GENERATION_INVALID_RESPONSE" }));
522
+ return false;
523
+ }
524
+ this.pendingInstructions = undefined;
525
+ }
526
+ else if (message.type === "error" && this.pendingInstructions !== undefined) {
527
+ if (!realtimeErrorFromEvent(message).reconnect) {
528
+ this.pendingInstructions = undefined;
529
+ }
530
+ }
531
+ else if (message.type === "session.closed") {
532
+ this.pendingInstructions = undefined;
533
+ }
534
+ return true;
535
+ }
435
536
  }
436
537
  /**
437
538
  * A logical realtime TTS connection backed by bounded physical WebSocket
@@ -446,6 +547,7 @@ export class RealtimeTextToSpeechConnection {
446
547
  */
447
548
  export class ManagedRealtimeTextToSpeechConnection {
448
549
  createConnection;
550
+ onInstructionsConfirmed;
449
551
  queue = [];
450
552
  drainingEpochs = new Set();
451
553
  openAttempts = new Set();
@@ -457,6 +559,7 @@ export class ManagedRealtimeTextToSpeechConnection {
457
559
  maxPhysicalSessionMs;
458
560
  maxReconnectAttempts;
459
561
  reconnectBaseDelayMs;
562
+ reapplyInstructionsOnOpen;
460
563
  current;
461
564
  heartbeatTimer;
462
565
  heartbeatAckTimer;
@@ -467,11 +570,16 @@ export class ManagedRealtimeTextToSpeechConnection {
467
570
  epochSequence = 0;
468
571
  queuedBytes = 0;
469
572
  activeTurn = false;
573
+ instructions;
574
+ pendingInstructions;
470
575
  rotationPending = false;
471
576
  closed = false;
472
577
  failure;
473
- constructor(createConnection, options) {
578
+ constructor(createConnection, options, onInstructionsConfirmed) {
474
579
  this.createConnection = createConnection;
580
+ this.onInstructionsConfirmed = onInstructionsConfirmed;
581
+ this.reapplyInstructionsOnOpen = options.sessionFactory !== undefined;
582
+ this.instructions = this.reapplyInstructionsOnOpen ? undefined : options.instructions;
475
583
  this.readyTimeoutMs = positiveNumber(options.timeout ?? DEFAULT_REALTIME_HANDSHAKE_TIMEOUT_MS, "timeout");
476
584
  this.heartbeatIntervalMs = optionalNonNegativeNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
477
585
  this.heartbeatTimeoutMs = positiveNumber(options.heartbeatTimeoutMs ?? DEFAULT_REALTIME_HEARTBEAT_TIMEOUT_MS, "heartbeatTimeoutMs");
@@ -480,17 +588,22 @@ export class ManagedRealtimeTextToSpeechConnection {
480
588
  this.maxReconnectAttempts = nonNegativeInteger(options.maxReconnectAttempts ?? DEFAULT_REALTIME_MAX_RECONNECT_ATTEMPTS, "maxReconnectAttempts");
481
589
  this.reconnectBaseDelayMs = nonNegativeNumber(options.reconnectBaseDelayMs ?? DEFAULT_REALTIME_RECONNECT_BASE_DELAY_MS, "reconnectBaseDelayMs");
482
590
  }
483
- static async open(createConnection, options = {}) {
484
- const managed = new ManagedRealtimeTextToSpeechConnection(createConnection, options);
591
+ static async open(createConnection, options = {}, onInstructionsConfirmed = () => { }) {
592
+ const managed = new ManagedRealtimeTextToSpeechConnection(createConnection, options, onInstructionsConfirmed);
485
593
  const epoch = await managed.openEpoch();
486
594
  managed.installEpoch(epoch);
487
595
  return managed;
488
596
  }
489
597
  startTurn(turnId) {
598
+ if (this.pendingInstructions !== undefined) {
599
+ throw new BreezeBlueRealtimeError("Realtime TTS instructions update is awaiting session.updated; wait for that event before starting the next turn.", { code: "BAD_REQUEST", recoverable: true });
600
+ }
490
601
  const connection = this.requireCurrentConnection();
602
+ // Claim the turn before the first WebSocket write so a custom WebSocket
603
+ // implementation cannot re-enter updateInstructions() from send().
604
+ this.activeTurn = true;
491
605
  try {
492
606
  connection.startTurn(turnId);
493
- this.activeTurn = true;
494
607
  }
495
608
  catch (error) {
496
609
  this.activeTurn = false;
@@ -536,6 +649,36 @@ export class ManagedRealtimeTextToSpeechConnection {
536
649
  cancelTurn() {
537
650
  this.requireCurrentConnection().cancelTurn();
538
651
  }
652
+ /**
653
+ * Update the synthesis instructions used by subsequent turns.
654
+ *
655
+ * The matching `session.updated` acknowledgement stays on this logical
656
+ * connection's ordered event stream. Confirmed instructions are carried to
657
+ * later physical WebSocket epochs.
658
+ */
659
+ updateInstructions(instructions) {
660
+ const validated = validateRealtimeInstructions(instructions);
661
+ if (this.activeTurn) {
662
+ throw new BreezeBlueRealtimeError("Realtime TTS instructions can only be updated between turns.", { code: "BAD_REQUEST", recoverable: true });
663
+ }
664
+ if (this.pendingInstructions !== undefined) {
665
+ throw new BreezeBlueRealtimeError("Realtime TTS already has an instructions update awaiting session.updated.", { code: "BAD_REQUEST", recoverable: true });
666
+ }
667
+ if (this.transitionPromise !== undefined) {
668
+ throw new BreezeBlueRealtimeError("Realtime TTS is rotating its physical session; wait for the next session.ready event before updating instructions.", { code: "BAD_REQUEST", recoverable: true });
669
+ }
670
+ const connection = this.requireCurrentConnection();
671
+ this.pendingInstructions = validated;
672
+ try {
673
+ connection.updateInstructions(validated);
674
+ }
675
+ catch (error) {
676
+ if (this.pendingInstructions === validated) {
677
+ this.pendingInstructions = undefined;
678
+ }
679
+ throw error;
680
+ }
681
+ }
539
682
  /** Send an immediate ping in addition to the managed keepalive schedule. */
540
683
  ping() {
541
684
  this.requireCurrentConnection().ping();
@@ -545,6 +688,7 @@ export class ManagedRealtimeTextToSpeechConnection {
545
688
  return;
546
689
  }
547
690
  this.closed = true;
691
+ this.pendingInstructions = undefined;
548
692
  this.clearTimers();
549
693
  this.cancelRetryDelay();
550
694
  this.cancelOpenAttempts();
@@ -636,6 +780,26 @@ export class ManagedRealtimeTextToSpeechConnection {
636
780
  }
637
781
  const message = result.value;
638
782
  if (message.type === "session.ready") {
783
+ if (this.reapplyInstructionsOnOpen && this.instructions !== undefined) {
784
+ connection.updateInstructions(this.instructions);
785
+ while (true) {
786
+ const confirmation = await Promise.race([iterator.next(), boundary]);
787
+ if (confirmation.done) {
788
+ throw new BreezeBlueRealtimeError("Realtime TTS connection closed before confirming the instructions update.", { reconnect: true });
789
+ }
790
+ const confirmationMessage = confirmation.value;
791
+ if (confirmationMessage.type === "session.updated") {
792
+ break;
793
+ }
794
+ if (confirmationMessage.type === "error") {
795
+ throw realtimeErrorFromEvent(confirmationMessage);
796
+ }
797
+ if (confirmationMessage.type === "session.closed") {
798
+ throw realtimeErrorFromClose(confirmationMessage);
799
+ }
800
+ pending.push(confirmationMessage);
801
+ }
802
+ }
639
803
  this.completeOpenAttempt(attempt);
640
804
  return {
641
805
  id: ++this.epochSequence,
@@ -748,6 +912,17 @@ export class ManagedRealtimeTextToSpeechConnection {
748
912
  if (message.type === "turn.started") {
749
913
  this.activeTurn = true;
750
914
  }
915
+ else if (message.type === "session.updated") {
916
+ if (this.pendingInstructions === undefined ||
917
+ message.instructions !== this.pendingInstructions) {
918
+ this.fail(new BreezeBlueRealtimeError("Realtime TTS returned a session.updated acknowledgement that did not match the pending instructions update.", { code: "GENERATION_INVALID_RESPONSE" }));
919
+ return false;
920
+ }
921
+ const confirmedInstructions = this.pendingInstructions;
922
+ this.instructions = confirmedInstructions;
923
+ this.pendingInstructions = undefined;
924
+ this.onInstructionsConfirmed(confirmedInstructions);
925
+ }
751
926
  else if (message.type === "turn.done") {
752
927
  this.activeTurn = false;
753
928
  epoch.pendingUsageHistoryIds.add(message.historyItemId);
@@ -767,13 +942,16 @@ export class ManagedRealtimeTextToSpeechConnection {
767
942
  epoch.retryAfterMs = Math.max(epoch.retryAfterMs, message.meta.retryAfterMs);
768
943
  }
769
944
  if (error.recoverable) {
945
+ if (this.pendingInstructions !== undefined && !this.activeTurn) {
946
+ this.pendingInstructions = undefined;
947
+ }
770
948
  if (message.code === "GENERATION_CONCURRENCY_EXCEEDED") {
771
949
  // The server rejected turn.start before establishing an active turn,
772
950
  // so no turn.cancelled event will follow to clear local state.
773
951
  this.activeTurn = false;
774
952
  }
775
953
  this.push(message);
776
- if (!this.activeTurn && this.rotationPending) {
954
+ if (!this.activeTurn && this.pendingInstructions === undefined && this.rotationPending) {
777
955
  this.beginTransition("planned", epoch);
778
956
  }
779
957
  return true;
@@ -805,7 +983,7 @@ export class ManagedRealtimeTextToSpeechConnection {
805
983
  return true;
806
984
  }
807
985
  this.push(message);
808
- if (!this.activeTurn && this.rotationPending) {
986
+ if (!this.activeTurn && this.pendingInstructions === undefined && this.rotationPending) {
809
987
  this.beginTransition("planned", epoch);
810
988
  }
811
989
  return true;
@@ -818,6 +996,7 @@ export class ManagedRealtimeTextToSpeechConnection {
818
996
  this.fail(activeTurnInterruptedError());
819
997
  return;
820
998
  }
999
+ this.pendingInstructions = undefined;
821
1000
  if (epoch.reconnectRequested) {
822
1001
  this.requestUnexpectedReconnect(epoch);
823
1002
  return;
@@ -834,6 +1013,7 @@ export class ManagedRealtimeTextToSpeechConnection {
834
1013
  this.fail(activeTurnInterruptedError(cause));
835
1014
  return;
836
1015
  }
1016
+ this.pendingInstructions = undefined;
837
1017
  this.clearTimers();
838
1018
  this.current = undefined;
839
1019
  epoch.connection.close();
@@ -843,6 +1023,10 @@ export class ManagedRealtimeTextToSpeechConnection {
843
1023
  if (this.closed || this.transitionPromise !== undefined) {
844
1024
  return;
845
1025
  }
1026
+ if (kind === "planned" && this.pendingInstructions !== undefined) {
1027
+ this.rotationPending = true;
1028
+ return;
1029
+ }
846
1030
  this.transitionKind = kind;
847
1031
  this.transitionPromise = (async () => {
848
1032
  try {
@@ -855,7 +1039,11 @@ export class ManagedRealtimeTextToSpeechConnection {
855
1039
  this.transitionPromise = undefined;
856
1040
  this.transitionKind = undefined;
857
1041
  const current = this.current;
858
- if (!this.closed && this.rotationPending && !this.activeTurn && current) {
1042
+ if (!this.closed &&
1043
+ this.rotationPending &&
1044
+ !this.activeTurn &&
1045
+ this.pendingInstructions === undefined &&
1046
+ current) {
859
1047
  this.beginTransition("planned", current);
860
1048
  }
861
1049
  }
@@ -949,7 +1137,7 @@ export class ManagedRealtimeTextToSpeechConnection {
949
1137
  return;
950
1138
  }
951
1139
  this.rotationPending = true;
952
- if (!this.activeTurn) {
1140
+ if (!this.activeTurn && this.pendingInstructions === undefined) {
953
1141
  this.beginTransition("planned", epoch);
954
1142
  }
955
1143
  }, rotationDelay);
@@ -1050,6 +1238,7 @@ export class ManagedRealtimeTextToSpeechConnection {
1050
1238
  return;
1051
1239
  }
1052
1240
  this.failure = error;
1241
+ this.pendingInstructions = undefined;
1053
1242
  this.closed = true;
1054
1243
  this.clearTimers();
1055
1244
  this.cancelRetryDelay();
@@ -1410,6 +1599,18 @@ function requireWebSocket(WebSocketImpl) {
1410
1599
  }
1411
1600
  return WebSocketImpl;
1412
1601
  }
1602
+ function validateRealtimeInstructions(instructions) {
1603
+ if (typeof instructions !== "string") {
1604
+ throw new TypeError("instructions must be a string.");
1605
+ }
1606
+ if (instructions.trim().length === 0) {
1607
+ throw new RangeError("instructions must not be empty.");
1608
+ }
1609
+ if (Array.from(instructions).length > REALTIME_MAX_INSTRUCTIONS_CHARACTERS) {
1610
+ throw new RangeError(`instructions must be at most ${REALTIME_MAX_INSTRUCTIONS_CHARACTERS} characters.`);
1611
+ }
1612
+ return instructions;
1613
+ }
1413
1614
  function normalizeBaseUrl(value) {
1414
1615
  return value.replace(/\/+$/, "");
1415
1616
  }
package/dist/errors.d.ts CHANGED
@@ -16,10 +16,11 @@ export declare class BreezeBlueConfigurationError extends BreezeBlueError {
16
16
  * message iterator rejects with this error.
17
17
  *
18
18
  * `recoverable` mirrors the Python SDK's `RealtimeError.recoverable`: it is
19
- * `true` only when the failure cancelled just the active turn and the
20
- * connection stays usable. SDK-detected connection failures are never
21
- * recoverable. `reconnect` tells you whether opening a new session is
22
- * expected to succeed.
19
+ * `true` when the same connection remains usable. Most recoverable
20
+ * turn-command errors cancel the active turn; a `session.update` rejected
21
+ * while a turn is active does not, so keep consuming that turn. SDK-detected
22
+ * connection failures are never recoverable. `reconnect` tells you whether
23
+ * opening a new session is expected to succeed.
23
24
  */
24
25
  export declare class BreezeBlueRealtimeError extends BreezeBlueError {
25
26
  readonly code: string | undefined;
package/dist/errors.js CHANGED
@@ -12,10 +12,11 @@ export class BreezeBlueConfigurationError extends BreezeBlueError {
12
12
  * message iterator rejects with this error.
13
13
  *
14
14
  * `recoverable` mirrors the Python SDK's `RealtimeError.recoverable`: it is
15
- * `true` only when the failure cancelled just the active turn and the
16
- * connection stays usable. SDK-detected connection failures are never
17
- * recoverable. `reconnect` tells you whether opening a new session is
18
- * expected to succeed.
15
+ * `true` when the same connection remains usable. Most recoverable
16
+ * turn-command errors cancel the active turn; a `session.update` rejected
17
+ * while a turn is active does not, so keep consuming that turn. SDK-detected
18
+ * connection failures are never recoverable. `reconnect` tells you whether
19
+ * opening a new session is expected to succeed.
19
20
  */
20
21
  export class BreezeBlueRealtimeError extends BreezeBlueError {
21
22
  code;
package/dist/types.d.ts CHANGED
@@ -28,6 +28,7 @@ export type BreezeBlueWebSocketConstructor = new (url: string, protocols?: strin
28
28
  export interface BreezeBlueWebSocket {
29
29
  binaryType: string;
30
30
  readyState: number;
31
+ readonly protocol?: string;
31
32
  onopen: ((event: Event) => void) | null;
32
33
  onmessage: ((event: MessageEvent) => void) | null;
33
34
  onerror: ((event: Event) => void) | null;
@@ -54,12 +55,14 @@ export interface RealtimeTextToSpeechAudioFormat {
54
55
  export interface RealtimeTextToSpeechSession {
55
56
  clientSecret: string;
56
57
  websocketUrl: string;
58
+ directWebsocketUrl?: string;
57
59
  expiresAt: string;
58
60
  audioFormat: RealtimeTextToSpeechAudioFormat;
59
61
  }
60
62
  export interface RealtimeTextToSpeechConnectOptions extends RealtimeTextToSpeechSessionRequest {
61
63
  clientSecret?: string;
62
64
  websocketUrl?: string;
65
+ directWebsocketUrl?: string;
63
66
  webSocket?: BreezeBlueWebSocketConstructor;
64
67
  signal?: AbortSignal;
65
68
  /** Handshake timeout in milliseconds. Defaults to the client `timeout`. */
@@ -68,9 +71,11 @@ export interface RealtimeTextToSpeechConnectOptions extends RealtimeTextToSpeech
68
71
  export type RealtimeTextToSpeechSessionCredentials = {
69
72
  clientSecret: string;
70
73
  websocketUrl?: string;
74
+ directWebsocketUrl?: string;
71
75
  } | {
72
76
  clientSecret?: string;
73
77
  websocketUrl: string;
78
+ directWebsocketUrl?: string;
74
79
  };
75
80
  export interface RealtimeTextToSpeechSessionFactoryContext {
76
81
  /**
@@ -134,6 +139,10 @@ export interface RealtimeTextToSpeechSessionReadyEvent {
134
139
  inactivityTimeoutSeconds: number;
135
140
  maxSessionSeconds: number;
136
141
  }
142
+ export interface RealtimeTextToSpeechSessionUpdatedEvent {
143
+ type: "session.updated";
144
+ instructions: string;
145
+ }
137
146
  export interface RealtimeTextToSpeechSessionExpiringEvent {
138
147
  type: "session.expiring";
139
148
  sessionId: string;
@@ -209,7 +218,7 @@ export interface RealtimeTextToSpeechErrorEvent {
209
218
  * (with camelCase keys). Do not rely on an exhaustive `switch` over `type`;
210
219
  * ignore event types you do not recognize.
211
220
  */
212
- export type RealtimeTextToSpeechEvent = RealtimeTextToSpeechSessionReadyEvent | RealtimeTextToSpeechSessionExpiringEvent | RealtimeTextToSpeechPongEvent | RealtimeTextToSpeechTurnStartedEvent | RealtimeTextToSpeechAudioStartedEvent | RealtimeTextToSpeechTurnDoneEvent | RealtimeTextToSpeechTurnCancelledEvent | RealtimeTextToSpeechUsageCommittedEvent | RealtimeTextToSpeechSessionClosedEvent | RealtimeTextToSpeechErrorEvent;
221
+ export type RealtimeTextToSpeechEvent = RealtimeTextToSpeechSessionReadyEvent | RealtimeTextToSpeechSessionUpdatedEvent | RealtimeTextToSpeechSessionExpiringEvent | RealtimeTextToSpeechPongEvent | RealtimeTextToSpeechTurnStartedEvent | RealtimeTextToSpeechAudioStartedEvent | RealtimeTextToSpeechTurnDoneEvent | RealtimeTextToSpeechTurnCancelledEvent | RealtimeTextToSpeechUsageCommittedEvent | RealtimeTextToSpeechSessionClosedEvent | RealtimeTextToSpeechErrorEvent;
213
222
  export interface RealtimeTextToSpeechAudioMessage {
214
223
  type: "audio";
215
224
  audio: Uint8Array;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const SDK_VERSION = "0.6.3";
1
+ export declare const SDK_VERSION = "0.8.0";
2
2
  export declare const SDK_NAME = "@breeze.blue/sdk";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const SDK_VERSION = "0.6.3";
1
+ export const SDK_VERSION = "0.8.0";
2
2
  export const SDK_NAME = "@breeze.blue/sdk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@breeze.blue/sdk",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "ESM-first TypeScript SDK for the Breeze Blue Developer API.",
5
5
  "license": "MIT",
6
6
  "author": "Breeze Blue <support@breeze.blue>",