@rimelabs/sdk 0.1.0-alpha.18 → 0.1.0-alpha.19

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.0-alpha.19](https://github.com/rimelabs/rime-sdk/compare/typescript-v0.1.0-alpha.18...typescript-v0.1.0-alpha.19) (2026-10-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * add realtime conversation runners ([#89](https://github.com/rimelabs/rime-sdk/issues/89)) ([5447be7](https://github.com/rimelabs/rime-sdk/commit/5447be7713896ac8f68191cc957d82c58228296c))
9
+
3
10
  ## [0.1.0-alpha.18](https://github.com/rimelabs/rime-sdk/compare/typescript-v0.1.0-alpha.17...typescript-v0.1.0-alpha.18) (2026-10-21)
4
11
 
5
12
 
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export { RealtimeSession } from "./realtime/session.js";
8
8
  export * from "./realtime/types.js";
9
9
  export { TranscriptStream } from "./stt/stream.js";
10
10
  export type { AudioSource, TranscribeOptions, Transcription, TranscriptionOptions, TranscriptionMode, TranscriptionPartial, TranscriptionDelta, TranscriptionOutputContract, TranscriptionFinal, TranscriptionUpdate, } from "./stt/types.js";
11
+ export { runConversation } from "./realtime/conversation.js";
12
+ export type { ConversationSource, ConversationOutput, ConversationPlayback, ConversationOptions, } from "./realtime/conversation.js";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export * from "./errors.js";
5
5
  export { RealtimeSession } from "./realtime/session.js";
6
6
  export * from "./realtime/types.js";
7
7
  export { TranscriptStream } from "./stt/stream.js";
8
+ export { runConversation } from "./realtime/conversation.js";
@@ -0,0 +1,47 @@
1
+ import type { RealtimeSession } from "./session.js";
2
+ import type { AudioChunk, OutputRef, SessionEvent } from "./types.js";
3
+ export interface ConversationSource {
4
+ /** Return one chunk, or null at EOF. Abort must release a pending read. */
5
+ read(signal: AbortSignal): Promise<AudioChunk | null>;
6
+ }
7
+ export interface ConversationOutput {
8
+ /** Queue audio without blocking. Raise on overflow; never silently drop audio. */
9
+ write(chunk: AudioChunk): void;
10
+ /** Seal this output; completion still waits for the device to drain. */
11
+ end(): void;
12
+ /** Measured played milliseconds. Reject on measurement failure or abort.
13
+ * Abort stops the wait, without cancelling the device receipt. */
14
+ completion(signal: AbortSignal): Promise<number>;
15
+ }
16
+ export interface ConversationPlayback {
17
+ /** Queue an output in open order; audible playback must be FIFO.
18
+ * Keep completion and interruption state separate for each output.
19
+ * Do not mix outputs or play a later output ahead of an earlier one. */
20
+ open(output: OutputRef): ConversationOutput;
21
+ /** Stop selected outputs as one batch, including outputs after end().
22
+ * Discard their unplayed audio before starting any remaining output.
23
+ * Resume remaining outputs in FIFO order without waiting for new audio.
24
+ * Repeated interruptions are harmless; each receipt remains independent. */
25
+ interrupt(outputs: readonly OutputRef[]): void;
26
+ /** Confirm all playback has stopped and queued audio is discarded.
27
+ * Caller resources stay open. Reject and release the wait on abort.
28
+ * The runner supplies a separate signal with a five-second deadline and
29
+ * waits for cancellation cleanup before returning. */
30
+ stop(signal: AbortSignal): Promise<void>;
31
+ }
32
+ export interface ConversationOptions {
33
+ readonly source: ConversationSource;
34
+ readonly playback: ConversationPlayback;
35
+ /** Called inline for every event. Must be synchronous and nonblocking.
36
+ * Scheduled application work remains the caller's responsibility. */
37
+ readonly onEvent?: (event: SessionEvent) => void;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ /** Run until input EOF, session closure, failure, or abort.
41
+ * Owns the session's active lifetime and closes it on exit. The caller owns
42
+ * adapter resource contexts. Do not consume session.events or report playback
43
+ * elsewhere. Adapter operations must settle on abort. No input or callback
44
+ * queue is added. Limits: 64 responses, 128 outputs, 256 pending operations.
45
+ * Overflow fails the run. Callback and adapter failures reach the caller.
46
+ */
47
+ export declare function runConversation(session: RealtimeSession, { source, playback, onEvent, signal }: ConversationOptions): Promise<void>;
@@ -0,0 +1,309 @@
1
+ import { abortable, scope } from "../cancellation.js";
2
+ import { RimeCancelledError, RimeInputError, RimeResourceLimitError, RimeTimeoutError, } from "../errors.js";
3
+ import { Deferred } from "./async.js";
4
+ import { isSessionCloseError } from "./session.js";
5
+ import { RimeRealtimeError } from "./types.js";
6
+ /** Run until input EOF, session closure, failure, or abort.
7
+ * Owns the session's active lifetime and closes it on exit. The caller owns
8
+ * adapter resource contexts. Do not consume session.events or report playback
9
+ * elsewhere. Adapter operations must settle on abort. No input or callback
10
+ * queue is added. Limits: 64 responses, 128 outputs, 256 pending operations.
11
+ * Overflow fails the run. Callback and adapter failures reach the caller.
12
+ */
13
+ export async function runConversation(session, { source, playback, onEvent, signal }) {
14
+ const controller = new AbortController();
15
+ const running = controller.signal;
16
+ const receipts = new AbortController();
17
+ const abort = () => controller.abort(signal?.reason);
18
+ if (signal?.aborted)
19
+ abort();
20
+ else
21
+ signal?.addEventListener("abort", abort, { once: true });
22
+ const replies = new Map();
23
+ const parents = new Map();
24
+ const interrupted = new Set();
25
+ const tasks = new Set();
26
+ let fail;
27
+ let failed = false;
28
+ let failureReason;
29
+ const failure = new Promise((_, reject) => {
30
+ fail = (error) => {
31
+ if (!failed) {
32
+ failed = true;
33
+ failureReason = error;
34
+ reject(error);
35
+ }
36
+ };
37
+ });
38
+ void failure.catch(() => { });
39
+ function start(work, local) {
40
+ if (tasks.size >= 256)
41
+ throw new RimeResourceLimitError("Conversation pending operation limit exceeded");
42
+ const task = work();
43
+ tasks.add(task);
44
+ void task.then(() => tasks.delete(task), (error) => {
45
+ tasks.delete(task);
46
+ const expected = [local, running, receipts.signal].some((signal) => signal?.aborted &&
47
+ (error === signal.reason ||
48
+ error instanceof RimeCancelledError ||
49
+ (error instanceof DOMException && error.name === "AbortError")));
50
+ if (!expected && !isSessionCloseError(error))
51
+ fail(error);
52
+ });
53
+ return task;
54
+ }
55
+ async function measure(stream) {
56
+ const value = await stream.completion(receipts.signal);
57
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
58
+ throw new RimeInputError("Playback measurement must be finite nonnegative milliseconds");
59
+ return value;
60
+ }
61
+ function end(output) {
62
+ if (!output.sealed) {
63
+ output.sealed = true;
64
+ output.stream.end();
65
+ }
66
+ }
67
+ function cut(output) {
68
+ if (output.report)
69
+ return;
70
+ end(output);
71
+ output.report = start(async () => {
72
+ const played = await output.receipt;
73
+ running.throwIfAborted();
74
+ try {
75
+ await session.reportPlayback({
76
+ kind: "interrupted",
77
+ output: output.ref,
78
+ audioEndMs: Math.floor(played),
79
+ }, { signal: running });
80
+ }
81
+ catch (error) {
82
+ // The session also forwards the refusal to the application callback.
83
+ if (!(error instanceof RimeRealtimeError) ||
84
+ error.fault.scope !== "event" ||
85
+ error.fault.code !== "truncate_not_current")
86
+ throw error;
87
+ }
88
+ });
89
+ }
90
+ function finish(reply) {
91
+ const earlier = [];
92
+ for (const previous of replies.values()) {
93
+ if (previous === reply)
94
+ break;
95
+ earlier.push(previous.drained.promise);
96
+ }
97
+ const finishing = new AbortController();
98
+ reply.finish = finishing;
99
+ const cancel = () => finishing.abort(running.reason);
100
+ if (running.aborted)
101
+ cancel();
102
+ else
103
+ running.addEventListener("abort", cancel, { once: true });
104
+ start(async () => {
105
+ try {
106
+ let played = 0;
107
+ for (const output of reply.outputs.values())
108
+ played += await abortable(output.receipt, finishing.signal);
109
+ reply.drained.resolve();
110
+ if (reply.interrupted) {
111
+ for (const output of reply.outputs.values())
112
+ if (output.report)
113
+ await abortable(output.report, finishing.signal);
114
+ }
115
+ else {
116
+ // Prism's latest drain token clears all earlier playback too.
117
+ for (const drained of earlier)
118
+ await abortable(drained, finishing.signal);
119
+ await session.reportPlayback({ kind: "finished", response: reply.ref, playedMs: played }, { signal: finishing.signal });
120
+ }
121
+ finishing.signal.throwIfAborted();
122
+ replies.delete(reply.ref.responseId);
123
+ }
124
+ finally {
125
+ running.removeEventListener("abort", cancel);
126
+ }
127
+ }, finishing.signal);
128
+ }
129
+ function wasInterrupted(responseId) {
130
+ let current = responseId;
131
+ // The session retains at most 128 response references.
132
+ for (let count = 0; current !== undefined && count < 128; count++) {
133
+ if (interrupted.has(current))
134
+ return true;
135
+ current = parents.get(current);
136
+ }
137
+ return false;
138
+ }
139
+ function interrupt(selected) {
140
+ const affected = selected.filter((reply) => !reply.interrupted);
141
+ const outputs = affected.flatMap((reply) => [...reply.outputs.values()]);
142
+ for (const reply of affected) {
143
+ reply.interrupted = true;
144
+ if (parents.has(reply.ref.responseId))
145
+ interrupted.add(reply.ref.responseId);
146
+ reply.finish?.abort();
147
+ }
148
+ if (outputs.length)
149
+ playback.interrupt(outputs.map((output) => output.ref));
150
+ for (const output of outputs)
151
+ cut(output);
152
+ for (const reply of affected)
153
+ if (reply.ended)
154
+ finish(reply);
155
+ }
156
+ function outputFor(ref) {
157
+ const reply = replies.get(ref.response.responseId);
158
+ if (!reply || reply.ended)
159
+ return;
160
+ const key = JSON.stringify([ref.itemId, ref.outputIndex, ref.contentIndex]);
161
+ let output = reply.outputs.get(key);
162
+ if (!output) {
163
+ let count = 0;
164
+ for (const value of replies.values())
165
+ count += value.outputs.size;
166
+ if (count >= 128)
167
+ throw new RimeResourceLimitError("Conversation output limit exceeded");
168
+ const stream = playback.open(ref);
169
+ output = {
170
+ ref,
171
+ stream,
172
+ receipt: start(() => measure(stream)),
173
+ sealed: false,
174
+ };
175
+ reply.outputs.set(key, output);
176
+ if (reply.interrupted) {
177
+ playback.interrupt([ref]);
178
+ cut(output);
179
+ }
180
+ }
181
+ return output;
182
+ }
183
+ async function send() {
184
+ while (!running.aborted) {
185
+ const chunk = await source.read(running);
186
+ if (chunk === null)
187
+ return;
188
+ await session.sendAudio(chunk, { signal: running });
189
+ }
190
+ running.throwIfAborted();
191
+ }
192
+ async function read() {
193
+ for await (const event of session.events) {
194
+ running.throwIfAborted();
195
+ const payload = event.payload;
196
+ switch (payload.kind) {
197
+ case "response.started": {
198
+ if (replies.size >= 64)
199
+ throw new RimeResourceLimitError("Conversation response limit exceeded");
200
+ const id = payload.response.responseId;
201
+ parents.set(id, payload.parent?.responseId);
202
+ if (parents.size > 128) {
203
+ const expired = parents.keys().next().value;
204
+ parents.delete(expired);
205
+ interrupted.delete(expired);
206
+ }
207
+ const blocked = wasInterrupted(id);
208
+ if (blocked)
209
+ interrupted.add(id);
210
+ replies.set(id, {
211
+ ref: payload.response,
212
+ outputs: new Map(),
213
+ interrupted: blocked,
214
+ ended: false,
215
+ drained: new Deferred(),
216
+ });
217
+ break;
218
+ }
219
+ case "message.started":
220
+ case "audio.delta":
221
+ case "audio.done": {
222
+ const output = outputFor(payload.output);
223
+ if (output) {
224
+ const reply = replies.get(payload.output.response.responseId);
225
+ if (payload.kind === "audio.delta" && !reply.interrupted)
226
+ output.stream.write(payload.audio);
227
+ else if (payload.kind === "audio.done")
228
+ end(output);
229
+ }
230
+ break;
231
+ }
232
+ case "speech.started":
233
+ if (session.info.interruptOnSpeech)
234
+ interrupt([...replies.values()]);
235
+ break;
236
+ case "response.abandoned": {
237
+ if (parents.has(payload.response.responseId))
238
+ interrupted.add(payload.response.responseId);
239
+ interrupt([...replies.values()].filter((reply) => wasInterrupted(reply.ref.responseId)));
240
+ break;
241
+ }
242
+ case "response.ended": {
243
+ const reply = replies.get(payload.response.responseId);
244
+ if (reply) {
245
+ reply.ended = true;
246
+ for (const output of reply.outputs.values())
247
+ end(output);
248
+ finish(reply);
249
+ }
250
+ break;
251
+ }
252
+ }
253
+ const result = onEvent?.(event);
254
+ if (result &&
255
+ typeof result.then === "function") {
256
+ void Promise.resolve(result).catch(() => { });
257
+ throw new RimeInputError("onEvent must be synchronous and nonblocking");
258
+ }
259
+ }
260
+ }
261
+ let original = false;
262
+ try {
263
+ running.throwIfAborted();
264
+ await abortable(Promise.race([start(send), start(read), failure]), running);
265
+ if (failed)
266
+ throw failureReason;
267
+ }
268
+ catch (error) {
269
+ if (!isSessionCloseError(error)) {
270
+ original = true;
271
+ throw error;
272
+ }
273
+ }
274
+ finally {
275
+ let cleanupError;
276
+ // Stop input, event processing and reports, but keep receipt waits alive
277
+ // until the adapter confirms playback has stopped or the deadline expires.
278
+ controller.abort();
279
+ const shutdown = scope([], 5, new RimeTimeoutError("Playback shutdown confirmation timed out"));
280
+ try {
281
+ // Await the adapter itself, including cleanup after the deadline aborts.
282
+ // Racing this promise against abort would leave that cleanup running.
283
+ await playback.stop(shutdown.signal);
284
+ shutdown.signal.throwIfAborted();
285
+ }
286
+ catch (error) {
287
+ cleanupError = error;
288
+ }
289
+ finally {
290
+ shutdown.dispose();
291
+ }
292
+ receipts.abort();
293
+ try {
294
+ await session.close();
295
+ }
296
+ catch (error) {
297
+ cleanupError ??= error;
298
+ }
299
+ await Promise.allSettled(tasks);
300
+ signal?.removeEventListener("abort", abort);
301
+ if (!original) {
302
+ if (failed)
303
+ throw failureReason;
304
+ signal?.throwIfAborted();
305
+ if (cleanupError !== undefined)
306
+ throw cleanupError;
307
+ }
308
+ }
309
+ }
@@ -1,6 +1,8 @@
1
1
  import { RealtimeConnection } from "./transport.js";
2
2
  import * as t from "./types.js";
3
3
  import * as p from "./protocol.js";
4
+ /** @internal Recognize only failures created by intentional session closure. */
5
+ export declare function isSessionCloseError(error: unknown): boolean;
4
6
  /** One conversation. Consume events concurrently with session operations. */
5
7
  export declare class RealtimeSession {
6
8
  private readonly socket;
@@ -16,6 +16,11 @@ const NOT_READY = new Set([
16
16
  "proactive_unavailable",
17
17
  ]);
18
18
  const LIMIT = 128;
19
+ const closeErrors = new WeakSet();
20
+ /** @internal Recognize only failures created by intentional session closure. */
21
+ export function isSessionCloseError(error) {
22
+ return error instanceof Error && closeErrors.has(error);
23
+ }
19
24
  class ResponseState {
20
25
  ref;
21
26
  done = new Flag();
@@ -152,8 +157,13 @@ export class RealtimeSession {
152
157
  check() {
153
158
  if (this.failure)
154
159
  throw this.failure;
155
- if (this.closed)
156
- throw new RimeInputError("The realtime session is closed");
160
+ if (this.closed) {
161
+ // Reports can start after intentional close, before runner cleanup.
162
+ // Failed sessions retain failure and take the branch above.
163
+ const error = new RimeInputError("The realtime session is closed");
164
+ closeErrors.add(error);
165
+ throw error;
166
+ }
157
167
  }
158
168
  response(ref) {
159
169
  this.check();
@@ -717,7 +727,9 @@ export class RealtimeSession {
717
727
  if (!this.closing) {
718
728
  this.closing = (async () => {
719
729
  if (!this.closed) {
720
- this.fail(new RimeStreamError("Realtime session closed"));
730
+ const error = new RimeStreamError("Realtime session closed");
731
+ closeErrors.add(error);
732
+ this.fail(error);
721
733
  this.failure = null;
722
734
  }
723
735
  await this.socket.close();
package/docs/realtime.md CHANGED
@@ -10,10 +10,145 @@ microphone capture, tool execution, and playback.
10
10
  Start with the [recorded-speech quick start](../README.md#prism-send-recorded-speech)
11
11
  to save a spoken reply without audio hardware.
12
12
 
13
- For live conversations, keep the session open after normal cancellation and
14
- report playback from the output clock. Saving a file is not playback.
13
+ For live conversations, keep the session open after a response is cancelled.
14
+ Report playback from the output clock. Saving a file is not playback.
15
15
  Typed turns also support mixed text and voice applications.
16
16
 
17
+ ## Optional conversation runner
18
+
19
+ Use `runConversation` to connect your audio adapters once. It sends input,
20
+ queues output, handles interruption, reports measured playback, and stops its
21
+ operations when the conversation ends. The direct session methods remain available.
22
+
23
+ ```ts
24
+ import { Rime, runConversation } from "@rimelabs/sdk";
25
+
26
+ const client = new Rime();
27
+ const controller = new AbortController();
28
+ try {
29
+ const session = await client.realtime.connect();
30
+ // microphone and speaker are adapters owned by your application.
31
+ await runConversation(session, {
32
+ source: microphone,
33
+ playback: speaker,
34
+ onEvent(event) {
35
+ if (event.payload.kind === "transcript.final")
36
+ console.log(event.payload.text);
37
+ },
38
+ signal: controller.signal,
39
+ });
40
+ } finally {
41
+ await client.close();
42
+ }
43
+ ```
44
+
45
+ The input adapter implements `ConversationSource`.
46
+ `read(signal: AbortSignal): Promise<AudioChunk | null>` returns one chunk,
47
+ with an optional format, or `null` at EOF. An omitted format uses 16 kHz mono
48
+ PCM16. Abort must reject and release a pending
49
+ read. The runner awaits each `session.sendAudio` call before reading again. It
50
+ uses the session's existing PCM conversion and transport backpressure. Input
51
+ continues during output playback.
52
+
53
+ The playback adapter implements `ConversationPlayback`:
54
+
55
+ | Method | Contract |
56
+ | --- | --- |
57
+ | `open(output: OutputRef): ConversationOutput` | Queue this output after previously opened outputs. It can be opened before the first audio chunk. |
58
+ | `interrupt(outputs: readonly OutputRef[]): void` | Stop the selected outputs as one batch, discard their unplayed audio, then resume remaining output in queue order. |
59
+ | `stop(signal: AbortSignal): Promise<void>` | Confirm all playback has stopped and queued audio is discarded. Keep caller-owned resources open. |
60
+
61
+ Each `ConversationOutput` implements:
62
+
63
+ | Method | Contract |
64
+ | --- | --- |
65
+ | `write(chunk: AudioChunk): void` | Queue 24 kHz mono PCM16 without blocking. Bound the queue and throw on overflow. |
66
+ | `end(): void` | Accept no more audio. Let queued and device audio drain. |
67
+ | `completion(signal: AbortSignal): Promise<number>` | Return actual played milliseconds after drain or interruption. Reject if measurement fails or the signal aborts. |
68
+
69
+ All synchronous adapter methods must return promptly. Completion can be awaited
70
+ before `end()`. Keep receipts separate for each `OutputRef`, even when a new
71
+ response starts before an old receipt completes. Play outputs in the order they
72
+ were opened, using one FIFO audio queue. Do not mix outputs, interleave their
73
+ audio, or play later outputs before earlier outputs finish or are interrupted.
74
+ Prism assumes that interrupting a partially played reply leaves later replies
75
+ in the same turn unheard. Independent concurrent playback violates that assumption.
76
+ The runner passes all affected output references in one `interrupt` call. Stop
77
+ them and discard their queued audio before starting any remaining output. Then
78
+ resume valid output in queue order, without waiting for another chunk or event.
79
+ Interruption must work after `end()` and tolerate repeated calls. A remote
80
+ adapter must preserve the batch, for example with one message containing all
81
+ selected output IDs. Shutdown stops all output and must not resume playback.
82
+ Receipt delivery can be delayed or arrive out of order; audible playback cannot.
83
+ A receipt includes the device buffer where device timing is available. Bytes
84
+ received or queued do not measure playback. Zero is valid for an output that
85
+ played no audio.
86
+
87
+ Abort must release each pending adapter operation and remove its listeners.
88
+ Cancellation of a completion wait must not cancel the device receipt. The runner
89
+ stops input, event processing, and report submission on exit. It then awaits
90
+ `stop(signal)` before aborting remaining receipt waits. The stop signal is
91
+ independent of caller cancellation and has a five-second deadline. `stop` must
92
+ resolve only after local or remote playback has stopped, including device
93
+ buffers. Sending a stop command is not confirmation. It must reject and release
94
+ its wait when the stop signal aborts. The runner awaits this cancellation cleanup
95
+ before it returns, so cleanup can extend beyond the deadline. A missing confirmation raises
96
+ `RimeTimeoutError`; disconnects and other shutdown failures must reject too.
97
+ The caller opens and closes the adapter resources. The runner does not open
98
+ audio devices or host a browser.
99
+
100
+ `onEvent` is optional. It receives every event read from `session.events`, in
101
+ order, after the runner applies audio coordination. It must be synchronous and
102
+ nonblocking. Returning a promise is rejected. Callback exceptions reach the
103
+ caller. The callback can schedule application work. Continue to use
104
+ `submitToolResult` and `continueReply`; the runner neither registers nor executes
105
+ tools. The application must cancel and await its own tool tasks. Response
106
+ failures and nonterminal faults are forwarded for application handling. Session
107
+ failures reject the runner promise.
108
+
109
+ The runner is the only event reader. Do not consume `session.events` at the same
110
+ time, call `reportPlayback` independently, or send a second audio source. Other
111
+ session methods remain available for manual tools and application actions.
112
+
113
+ On confirmed `speech.started`, the runner uses the effective
114
+ `session.info.interruptOnSpeech` setting. It stops affected output, waits for the
115
+ interrupted receipt, and reports the measured position. It ignores late audio
116
+ for that response. A `speech.hint` alone does not stop playback. A server
117
+ `truncate_not_current` refusal is forwarded as an event; it does not stop a newer
118
+ response. Finished reports wait for generation completion and every output
119
+ receipt, including outstanding playback from earlier responses. Prism treats
120
+ the latest drain token as confirmation that all earlier playback has stopped.
121
+ An abandoned response also stops its dependent tool continuations, identified
122
+ by response parent references. Unrelated later turns remain eligible to play.
123
+ Late audio from an interrupted response or its continuations is discarded.
124
+ The runner retains at most 128 response parent references.
125
+ A report retains the measured duration of its own response. Waiting for earlier
126
+ playback does not block events or input, and does not wait for later responses.
127
+
128
+ Once called, the runner owns the session's active lifetime. Input EOF, explicit
129
+ session closure, abort, or failure stops all runner operations and closes the
130
+ session. EOF stops remaining playback and waits for shutdown confirmation.
131
+ Abort, including an abort received during cleanup, waits for playback shutdown
132
+ confirmation or its five-second deadline, then awaits adapter cancellation cleanup
133
+ before rejecting with `signal.reason`.
134
+ Explicit session closure ends normally even with playback reports pending;
135
+ remote connection failures reject with the session error.
136
+ Expected cancellation errors during cleanup are ignored. Other cleanup errors
137
+ reach the caller if there is no original failure. They do not replace an
138
+ existing failure or caller abort. The signal is optional. Use
139
+ an application deadline if an adapter can wait indefinitely.
140
+
141
+ The runner adds no input or callback queue. It retains at most 64 responses,
142
+ 128 output streams, and 256 pending operations. Exceeding a limit throws
143
+ `RimeResourceLimitError`. The adapters must also bound their audio queues.
144
+
145
+ The [live voice example](https://github.com/rimelabs/rime-sdk/blob/main/typescript/examples/realtime/conversation.ts) supplies the
146
+ [browser audio adapter](https://github.com/rimelabs/rime-sdk/blob/main/typescript/examples/common/browser-audio.ts). Browser hosting
147
+ stays in the example. Its player uses `AudioContext.getOutputTimestamp()` for
148
+ receipts and shutdown confirmation. A disconnect cannot confirm a remote stop.
149
+ The [manual tool example](https://github.com/rimelabs/rime-sdk/blob/main/typescript/examples/realtime/manual-tools.ts) keeps
150
+ tool execution outside the runner.
151
+
17
152
  ## Connect
18
153
 
19
154
  Use `await client.realtime.connect()` with `RIME_API_KEY` set. It opens a WebSocket,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelabs/sdk",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "Rime streaming TTS, STT and Prism realtime SDK for Node.js",
5
5
  "license": "MIT",
6
6
  "type": "module",