@breeze.blue/sdk 0.5.1 → 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,38 @@
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
+
3
36
  ## 0.5.1
4
37
 
5
38
  - Added official voice publishing metadata to `voices.savePreview(...)` and
package/README.md CHANGED
@@ -117,7 +117,7 @@ continue after `endTurn()`; keep the single consumer running until `turn.done`.
117
117
 
118
118
  ```ts
119
119
  const connection = await client.textToSpeech.realtime.connect("voc_...", {
120
- modelId: "bluebell-v1",
120
+ modelId: "breeze-tts-2",
121
121
  });
122
122
 
123
123
  const pcmChunks: Uint8Array[] = [];
@@ -148,33 +148,98 @@ Always consume the connection (or its `audio()` / `events()` iterators) while a
148
148
  turn is active, and call `close()` when you are done. When the SDK detects a
149
149
  connection failure — an invalid server frame, or more than 16 MiB of messages
150
150
  piling up unconsumed — it closes the connection and the iterator rejects with
151
- 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.
152
154
 
153
155
  Events are a typed discriminated union (`session.ready`, `turn.started`,
154
156
  `audio.started`, `turn.done`, `turn.cancelled`, `usage.committed`,
155
- `session.closed`, `error`, `pong`), with camelCase fields such as `turnId`,
156
- `historyItemId`, and `ttfaMs`. Treat the union as non-exhaustive: the server
157
- may add event types, and the SDK delivers unknown JSON events unchanged —
158
- 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.
159
162
 
160
- For connections that sit idle between turns, send a keepalive ping inside the
161
- session's `inactivityTimeoutSeconds` window; the server answers with a `pong`
162
- 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.
163
166
 
164
167
  ```ts
165
- const keepalive = setInterval(() => connection.ping(), 30_000);
168
+ const keepalive = setInterval(() => connection.ping(), 10_000);
166
169
  // ... run turns ...
167
170
  clearInterval(keepalive);
168
171
  connection.close();
169
172
  ```
170
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
+
171
236
  To cut time to first audio, create the session ahead of time (for example while
172
237
  your app is still preparing the turn) and connect with its `clientSecret` when
173
238
  the first text is ready — only the WebSocket handshake remains:
174
239
 
175
240
  ```ts
176
241
  const session = await client.textToSpeech.realtime.createSession("voc_...", {
177
- modelId: "bluebell-v1",
242
+ modelId: "breeze-tts-2",
178
243
  });
179
244
 
180
245
  // Later, when the first text is ready:
@@ -195,16 +260,57 @@ const connection = await browserClient.textToSpeech.realtime.connect("voc_...",
195
260
  });
196
261
  ```
197
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
+
198
294
  Session parameters (`modelId`, `languageCode`, `instructions`, `voiceSettings`,
199
295
  `inactivityTimeoutSeconds`, `enableLogging`) are fixed when the session is
200
296
  created. `connect` ignores them when `clientSecret` or `websocketUrl` is
201
297
  provided and logs a warning.
202
298
 
203
299
  If a realtime WebSocket is interrupted by a network change, service deployment,
204
- or upstream realtime worker restart, handle `error.meta.reconnect === true` or a
205
- `session.closed` event with `reconnect === true` by creating a new connection and
206
- starting a new turn from your own conversation state. Active turns are not
207
- 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.
208
314
 
209
315
  The API uses the default text-to-speech model when `modelId` is omitted. If
210
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;