@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 +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/realtime/conversation.d.ts +47 -0
- package/dist/realtime/conversation.js +309 -0
- package/dist/realtime/session.d.ts +2 -0
- package/dist/realtime/session.js +15 -3
- package/docs/realtime.md +137 -2
- package/package.json +1 -1
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
|
@@ -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;
|
package/dist/realtime/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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,
|