@breeze.blue/sdk 0.5.0 → 0.6.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
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Added opt-in `textToSpeech.realtime.connectManaged(...)` for long logical
6
+ conversations. It derives session keepalive from `session.ready`, rotates
7
+ bounded physical WebSockets at turn boundaries, obtains fresh session
8
+ credentials for every epoch, and performs bounded jittered reconnects while
9
+ idle without replaying active turns. Replacement creation and retry delays
10
+ are cancellable,
11
+ and planned rotations briefly drain outstanding `usage.committed` events
12
+ from the old epoch without blocking the next turn.
13
+ - Managed heartbeat now requires a timely server response, so a half-open idle
14
+ WebSocket is replaced with fresh credentials instead of accepting the next
15
+ turn. Active turns still fail as `TURN_INTERRUPTED` and are never replayed.
16
+ Physical epochs also become eligible for turn-boundary rotation after 10
17
+ minutes by default, independently of a longer server-advertised deadline.
18
+ An active turn can delay the switch; both safety windows are configurable.
19
+ - Aligned managed realtime error handling with protocol semantics: terminal
20
+ auth, billing, and policy failures are not retried; recoverable turn errors
21
+ stay on the same socket; rejected concurrency admission clears the local
22
+ pending turn; transient 408, 425, 429, and 5xx replacement-session failures
23
+ use the bounded reconnect budget; a new turn on the healthy old epoch wins
24
+ even when an in-flight planned candidate fails; and active-turn transport
25
+ failures remain non-replayed. Shared upstream
26
+ `GENERATION_CAPACITY_EXCEEDED` waits for the corresponding
27
+ `turn.cancelled` and keeps the logical session usable.
28
+ - Added typed `session.expiring` and realtime expiry fields. Managed browser
29
+ callers can provide a `sessionFactory` that fetches a fresh client secret
30
+ from their own backend for every physical connection.
31
+ - Fixed the realtime `audio()` iterator so server errors and abnormal closes
32
+ throw `BreezeBlueRealtimeError` instead of ending silently, and preserved
33
+ close-code semantics when a WebSocket `error` event arrives just before its
34
+ `close` event.
35
+
36
+ ## 0.5.1
37
+
38
+ - Added official voice publishing metadata to `voices.savePreview(...)` and
39
+ `voices.edit(...)`, including `primaryCategoryCode`, `visibility`, and the
40
+ typed `SavedVoice` response.
41
+ - Aligned voice, history, and generation metadata with the public API by using
42
+ `origin` instead of the legacy category fields; voice search now accepts an
43
+ `origin` filter.
44
+ - Made `voices.edit(...)` support metadata-only updates without requiring a
45
+ voice name.
46
+ - Improved the realtime TTS example to start consuming before sending text and
47
+ continue until `turn.done`, so early audio is not missed.
48
+
3
49
  ## 0.5.0
4
50
 
5
51
  - Fixed `textToSpeech.realtime.connect(...)` so a failed WebSocket handshake
package/README.md CHANGED
@@ -112,29 +112,35 @@ Realtime needs a `WebSocket` implementation. Browsers, edge runtimes, and
112
112
  Node 22+ provide a global one; on Node 20, pass a constructor (for example the
113
113
  `ws` package's `WebSocket`) via `new BreezeBlueClient({ webSocket })`.
114
114
 
115
+ Start consuming before appending text. Audio can arrive after `flush()` and may
116
+ continue after `endTurn()`; keep the single consumer running until `turn.done`.
117
+
115
118
  ```ts
116
119
  const connection = await client.textToSpeech.realtime.connect("voc_...", {
117
- modelId: "bluebell-v1",
120
+ modelId: "breeze-tts-2",
118
121
  });
119
122
 
123
+ const pcmChunks: Uint8Array[] = [];
124
+ const consumer = (async () => {
125
+ for await (const message of connection) {
126
+ if (message.type === "audio") {
127
+ // Forward this chunk to your playback or transport layer immediately.
128
+ pcmChunks.push(message.audio);
129
+ } else if (message.type === "error") {
130
+ throw new Error(`Realtime TTS failed: ${message.code}: ${message.message}`);
131
+ } else if (message.type === "turn.done") {
132
+ return;
133
+ }
134
+ }
135
+ throw new Error("Realtime session closed before turn.done");
136
+ })();
137
+
120
138
  connection.startTurn("turn_1");
121
139
  connection.appendText("Hello from Breeze.");
122
140
  connection.flush();
123
141
  connection.endTurn();
124
142
 
125
- const pcmChunks: Uint8Array[] = [];
126
- for await (const message of connection) {
127
- if (message.type === "audio") {
128
- pcmChunks.push(message.audio);
129
- continue;
130
- }
131
- if (message.type === "error") {
132
- throw new Error(`Realtime TTS failed: ${message.code}: ${message.message}`);
133
- }
134
- if (message.type === "turn.done") {
135
- break; // all audio for this turn has been delivered
136
- }
137
- }
143
+ await consumer;
138
144
  connection.close();
139
145
  ```
140
146
 
@@ -142,33 +148,98 @@ Always consume the connection (or its `audio()` / `events()` iterators) while a
142
148
  turn is active, and call `close()` when you are done. When the SDK detects a
143
149
  connection failure — an invalid server frame, or more than 16 MiB of messages
144
150
  piling up unconsumed — it closes the connection and the iterator rejects with
145
- a `BreezeBlueRealtimeError`.
151
+ a `BreezeBlueRealtimeError`. The `audio()` helper also throws
152
+ `BreezeBlueRealtimeError` for server `error` events and abnormal WebSocket
153
+ closes instead of silently ending.
146
154
 
147
155
  Events are a typed discriminated union (`session.ready`, `turn.started`,
148
156
  `audio.started`, `turn.done`, `turn.cancelled`, `usage.committed`,
149
- `session.closed`, `error`, `pong`), with camelCase fields such as `turnId`,
150
- `historyItemId`, and `ttfaMs`. Treat the union as non-exhaustive: the server
151
- may add event types, and the SDK delivers unknown JSON events unchanged —
152
- ignore event types you do not recognize instead of switching exhaustively.
157
+ `session.expiring`, `session.closed`, `error`, `pong`), with camelCase fields
158
+ such as `turnId`, `historyItemId`, `expiresAt`, and `ttfaMs`. Treat the union as
159
+ non-exhaustive: the server may add event types, and the SDK delivers unknown
160
+ JSON events unchanged — ignore event types you do not recognize instead of
161
+ switching exhaustively.
153
162
 
154
- For connections that sit idle between turns, send a keepalive ping inside the
155
- session's `inactivityTimeoutSeconds` window; the server answers with a `pong`
156
- event:
163
+ Send a keepalive ping inside the session's `inactivityTimeoutSeconds` window;
164
+ the server answers with a `pong` event. Keep it running during active turns too:
165
+ a long TTFA or upstream stall with no audio does not pause the idle deadline.
157
166
 
158
167
  ```ts
159
- const keepalive = setInterval(() => connection.ping(), 30_000);
168
+ const keepalive = setInterval(() => connection.ping(), 10_000);
160
169
  // ... run turns ...
161
170
  clearInterval(keepalive);
162
171
  connection.close();
163
172
  ```
164
173
 
174
+ For a long logical conversation, use the opt-in managed connection. It derives
175
+ a safe keepalive interval from `session.ready` and requires a server response
176
+ after each ping. A missing acknowledgement replaces an idle half-open socket;
177
+ if a turn is active, it reports `TURN_INTERRUPTED` and never replays commands.
178
+ The manager also marks a physical WebSocket for turn-boundary rotation after
179
+ 10 minutes by default (or earlier when the server deadline requires it). An
180
+ active turn can delay the switch beyond that threshold; the server-advertised
181
+ hard lifetime still applies. The manager
182
+ performs up to three unexpected idle reconnect attempts per interruption with
183
+ fresh sessions and jittered exponential backoff:
184
+
185
+ ```ts
186
+ const connection = await client.textToSpeech.realtime.connectManaged("voc_...", {
187
+ modelId: "breeze-tts-2",
188
+ });
189
+
190
+ const consumer = (async () => {
191
+ for await (const message of connection) {
192
+ if (message.type === "audio") {
193
+ // Play or forward this PCM chunk immediately.
194
+ } else if (message.type === "session.ready") {
195
+ // A new physical epoch is ready; the logical connection remains the same.
196
+ } else if (message.type === "turn.done") {
197
+ return;
198
+ }
199
+ }
200
+ })();
201
+
202
+ await connection.startTurnWhenReady("turn_1");
203
+ connection.appendText("Hello from a long-running conversation.");
204
+ connection.endTurn();
205
+ await consumer;
206
+ connection.close();
207
+ ```
208
+
209
+ Use `heartbeatTimeoutMs` to tune the acknowledgement deadline and
210
+ `maxPhysicalSessionMs` to tune the client-side epoch cap. Set
211
+ `maxPhysicalSessionMs: 0` only when you intentionally want to rely solely on
212
+ the server-advertised deadline.
213
+
214
+ When an idle reconnect is handled successfully, the logical iterator stays
215
+ open, suppresses the replaced physical socket's terminal event, and emits the
216
+ new epoch's `session.ready`. The replacement sends its first heartbeat
217
+ immediately and resumes the derived cadence after an inbound acknowledgement.
218
+ Reconnect attempts are bounded per interruption. `startTurnWhenReady(...)`
219
+ waits only when one of these idle replacements is already in progress and
220
+ `turn.start` has not been sent; await it before sending any other turn command.
221
+ After a planned rotation, an old epoch with outstanding `usage.committed`
222
+ events drains in parallel for up to five seconds and closes as soon as all
223
+ known completed turns settle. This event is best-effort; use history and usage
224
+ APIs as the durable source of truth.
225
+
226
+ The managed connection never buffers turn content or replays a command. The
227
+ bounded `startTurnWhenReady(...)` wait happens before its first WebSocket
228
+ write. If a WebSocket is interrupted while a turn is active, its iterator rejects with
229
+ `BreezeBlueRealtimeError` and `error.code === "TURN_INTERRUPTED"`; decide from
230
+ your application conversation state whether and how to start a new turn.
231
+ Treat the manager as an active realtime call, not a presence channel. Always
232
+ call `connection.close()` when the call ends, the user leaves, or the page
233
+ enters a long-lived background state; otherwise its heartbeat intentionally
234
+ keeps a server WebSocket slot occupied.
235
+
165
236
  To cut time to first audio, create the session ahead of time (for example while
166
237
  your app is still preparing the turn) and connect with its `clientSecret` when
167
238
  the first text is ready — only the WebSocket handshake remains:
168
239
 
169
240
  ```ts
170
241
  const session = await client.textToSpeech.realtime.createSession("voc_...", {
171
- modelId: "bluebell-v1",
242
+ modelId: "breeze-tts-2",
172
243
  });
173
244
 
174
245
  // Later, when the first text is ready:
@@ -189,16 +260,57 @@ const connection = await browserClient.textToSpeech.realtime.connect("voc_...",
189
260
  });
190
261
  ```
191
262
 
263
+ For a managed browser connection, provide a `sessionFactory`. The SDK invokes it
264
+ for every physical epoch, so the callback must fetch a newly minted secret from
265
+ your backend rather than cache the first response. Session configuration belongs
266
+ in that backend request when a custom factory is used:
267
+
268
+ ```ts
269
+ import { BreezeBlueRealtimeError } from "@breeze.blue/sdk";
270
+
271
+ const connection = await browserClient.textToSpeech.realtime.connectManaged("voc_...", {
272
+ sessionFactory: async ({ signal }) => {
273
+ const response = await fetch("/api/breeze-realtime-session", {
274
+ method: "POST",
275
+ signal,
276
+ });
277
+ if (!response.ok) {
278
+ const reconnect =
279
+ [408, 425, 429].includes(response.status) || response.status >= 500;
280
+ throw new BreezeBlueRealtimeError("Could not create realtime session", {
281
+ code: "SESSION_FACTORY_ERROR",
282
+ reconnect,
283
+ });
284
+ }
285
+ return response.json(); // { clientSecret, websocketUrl? }
286
+ },
287
+ });
288
+ ```
289
+
290
+ Honor the factory `signal`: the SDK aborts it when the logical connection closes
291
+ or the physical-epoch timeout expires. A custom callback that ignores the
292
+ signal must still enforce its own bounded request timeout.
293
+
192
294
  Session parameters (`modelId`, `languageCode`, `instructions`, `voiceSettings`,
193
295
  `inactivityTimeoutSeconds`, `enableLogging`) are fixed when the session is
194
296
  created. `connect` ignores them when `clientSecret` or `websocketUrl` is
195
297
  provided and logs a warning.
196
298
 
197
299
  If a realtime WebSocket is interrupted by a network change, service deployment,
198
- or upstream realtime worker restart, handle `error.meta.reconnect === true` or a
199
- `session.closed` event with `reconnect === true` by creating a new connection and
200
- starting a new turn from your own conversation state. Active turns are not
201
- resumed in place.
300
+ or upstream realtime worker restart, `connectManaged(...)` handles bounded
301
+ reconnects only while there is no active turn. With the lower-level
302
+ `connect(...)`, handle `error.meta.reconnect === true` or a `session.closed`
303
+ event with `reconnect === true` by creating a new session and starting a new
304
+ turn from your own conversation state. Active turns are never resumed in place.
305
+ During a managed reconnect, transient session-creation responses (408, 425, 429,
306
+ and 5xx) share the same bounded reconnect budget; other 4xx responses fail the
307
+ logical connection immediately. A custom `sessionFactory` should preserve that
308
+ distinction with `new BreezeBlueRealtimeError(message, { reconnect })`; a plain
309
+ `Error` cannot communicate a terminal policy response and is treated as
310
+ transient inside the same bounded budget. `GENERATION_CAPACITY_EXCEEDED` is
311
+ turn-scoped: wait for the following `turn.cancelled`, back off using
312
+ `meta.retryAfterSeconds`, and start a new turn on the same managed logical
313
+ connection without replaying text.
202
314
 
203
315
  The API uses the default text-to-speech model when `modelId` is omitted. If
204
316
  you need to select a model explicitly, call `client.models.list()` and pass one
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AudioResponse } from "./audio.js";
2
- import type { AudioRequestOptions, BreezeBlueWebSocket, AsyncTextToSpeechJob, Balance, BreezeBlueClientOptions, GenericStatus, GenerationJob, HistoryItem, HistoryList, HistoryListParams, Model, RequestOptions, RealtimeTextToSpeechConnectOptions, RealtimeTextToSpeechEvent, RealtimeTextToSpeechMessage, RealtimeTextToSpeechSession, RealtimeTextToSpeechSessionRequest, SavedVoice, SaveVoiceRequest, StreamTextToSpeechOptions, TextToSpeechRequest, TtsEnhanceRequest, TtsEnhanceResponse, Usage, UsageParams, Voice, VoiceClonePreview, VoiceClonePreviewRequest, VoiceDesignRequest, VoiceDesignResponse, VoiceEditRequest, VoiceList, VoiceSearchParams, VoiceSettings } from "./types.js";
2
+ import type { AudioRequestOptions, BreezeBlueWebSocket, AsyncTextToSpeechJob, Balance, BreezeBlueClientOptions, GenericStatus, GenerationJob, HistoryItem, HistoryList, HistoryListParams, Model, RequestOptions, RealtimeTextToSpeechConnectOptions, RealtimeTextToSpeechEvent, RealtimeTextToSpeechManagedConnectOptions, RealtimeTextToSpeechMessage, RealtimeTextToSpeechSession, RealtimeTextToSpeechSessionRequest, SavedVoice, SaveVoiceRequest, StreamTextToSpeechOptions, TextToSpeechRequest, TtsEnhanceRequest, TtsEnhanceResponse, Usage, UsageParams, Voice, VoiceClonePreview, VoiceClonePreviewRequest, VoiceDesignRequest, VoiceDesignResponse, VoiceEditRequest, VoiceList, VoiceSearchParams, VoiceSettings } from "./types.js";
3
3
  export declare class BreezeBlueClient {
4
4
  readonly apiKey: string | undefined;
5
5
  readonly baseUrl: string;
@@ -33,6 +33,13 @@ declare class RealtimeTextToSpeechResource {
33
33
  constructor(client: BreezeBlueClient);
34
34
  createSession(voiceId: string, request?: RealtimeTextToSpeechSessionRequest, options?: RequestOptions): Promise<RealtimeTextToSpeechSession>;
35
35
  connect(voiceId: string, options?: RealtimeTextToSpeechConnectOptions): Promise<RealtimeTextToSpeechConnection>;
36
+ /**
37
+ * Open an SDK-managed logical connection backed by bounded physical
38
+ * WebSocket sessions. The manager keeps idle sessions alive, rotates at
39
+ * turn boundaries, and performs bounded idle reconnects with fresh
40
+ * credentials. It never replays an interrupted active turn.
41
+ */
42
+ connectManaged(voiceId: string, options?: RealtimeTextToSpeechManagedConnectOptions): Promise<ManagedRealtimeTextToSpeechConnection>;
36
43
  }
37
44
  export declare class RealtimeTextToSpeechConnection implements AsyncIterable<RealtimeTextToSpeechMessage> {
38
45
  private readonly socket;
@@ -41,9 +48,11 @@ export declare class RealtimeTextToSpeechConnection implements AsyncIterable<Rea
41
48
  private closed;
42
49
  private queuedBytes;
43
50
  private failure;
51
+ private closeEventGraceTimer;
44
52
  private constructor();
45
53
  static open(socket: BreezeBlueWebSocket, options?: {
46
54
  timeout?: number;
55
+ signal?: AbortSignal;
47
56
  }): Promise<RealtimeTextToSpeechConnection>;
48
57
  startTurn(turnId?: string): void;
49
58
  appendText(text: string): void;
@@ -59,9 +68,101 @@ export declare class RealtimeTextToSpeechConnection implements AsyncIterable<Rea
59
68
  private sendJson;
60
69
  private handleMessage;
61
70
  private handleClose;
71
+ private handleSocketError;
72
+ private push;
73
+ private fail;
74
+ private finish;
75
+ private clearCloseEventGraceTimer;
76
+ }
77
+ type RealtimeConnectionFactory = (signal: AbortSignal) => Promise<RealtimeTextToSpeechConnection>;
78
+ /**
79
+ * A logical realtime TTS connection backed by bounded physical WebSocket
80
+ * epochs. Use `textToSpeech.realtime.connectManaged(...)` to create one.
81
+ *
82
+ * The manager automatically keeps an idle session alive, rotates before the
83
+ * server session limit at turn boundaries, and makes bounded reconnect
84
+ * attempts while idle. It does not buffer turn content or replay commands;
85
+ * startTurnWhenReady() may wait before its first turn.start write. If the
86
+ * transport is interrupted during a turn, iteration rejects with
87
+ * `BreezeBlueRealtimeError`.
88
+ */
89
+ export declare class ManagedRealtimeTextToSpeechConnection implements AsyncIterable<RealtimeTextToSpeechMessage> {
90
+ private readonly createConnection;
91
+ private readonly queue;
92
+ private readonly drainingEpochs;
93
+ private readonly openAttempts;
94
+ private readonly waiters;
95
+ private readonly heartbeatIntervalMs;
96
+ private readonly heartbeatTimeoutMs;
97
+ private readonly readyTimeoutMs;
98
+ private readonly rotationMarginMs;
99
+ private readonly maxPhysicalSessionMs;
100
+ private readonly maxReconnectAttempts;
101
+ private readonly reconnectBaseDelayMs;
102
+ private current;
103
+ private heartbeatTimer;
104
+ private heartbeatAckTimer;
105
+ private rotationTimer;
106
+ private retryDelay;
107
+ private transitionPromise;
108
+ private transitionKind;
109
+ private epochSequence;
110
+ private queuedBytes;
111
+ private activeTurn;
112
+ private rotationPending;
113
+ private closed;
114
+ private failure;
115
+ private constructor();
116
+ static open(createConnection: RealtimeConnectionFactory, options?: RealtimeTextToSpeechManagedConnectOptions): Promise<ManagedRealtimeTextToSpeechConnection>;
117
+ startTurn(turnId?: string): void;
118
+ /**
119
+ * Start a turn on the current physical epoch, waiting only when an idle
120
+ * unexpected reconnect is already replacing a missing epoch.
121
+ *
122
+ * No other turn command is buffered. Await this method before calling
123
+ * appendText(), flush(), endTurn(), or cancelTurn().
124
+ */
125
+ startTurnWhenReady(turnId?: string, options?: {
126
+ signal?: AbortSignal;
127
+ }): Promise<void>;
128
+ appendText(text: string): void;
129
+ flush(): void;
130
+ endTurn(): void;
131
+ cancelTurn(): void;
132
+ /** Send an immediate ping in addition to the managed keepalive schedule. */
133
+ ping(): void;
134
+ close(): void;
135
+ events(): AsyncIterable<RealtimeTextToSpeechEvent>;
136
+ audio(): AsyncIterable<Uint8Array>;
137
+ [Symbol.asyncIterator](): AsyncIterator<RealtimeTextToSpeechMessage>;
138
+ private openEpoch;
139
+ private installEpoch;
140
+ private pumpEpoch;
141
+ private handleEpochMessage;
142
+ private handleEpochEnd;
143
+ private requestUnexpectedReconnect;
144
+ private beginTransition;
145
+ private replaceEpoch;
146
+ private deferPlannedTransitionForActiveTurn;
147
+ private scheduleTimers;
148
+ private scheduleNextHeartbeat;
149
+ private acknowledgeHeartbeat;
150
+ private requireCurrentConnection;
151
+ private waitForStartTurnTransition;
152
+ private throwIfStartTurnAborted;
62
153
  private push;
63
154
  private fail;
64
155
  private finish;
156
+ private clearTimers;
157
+ private waitForRetryDelay;
158
+ private cancelRetryDelay;
159
+ private completeOpenAttempt;
160
+ private cancelOpenAttempt;
161
+ private cancelOpenAttempts;
162
+ private beginSettlementDrain;
163
+ private handleSettlementDrainMessage;
164
+ private finishSettlementDrain;
165
+ private closeSettlementDrains;
65
166
  }
66
167
  declare class GenerationJobsResource {
67
168
  private readonly client;