@rimelabs/sdk 0.1.0-alpha.4 → 0.1.0-alpha.6
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 +14 -0
- package/LICENSE-PRISM +201 -0
- package/NOTICE-PRISM +9 -0
- package/README.md +49 -122
- package/dist/auth.d.ts +8 -3
- package/dist/auth.js +8 -3
- package/dist/cancellation.d.ts +1 -0
- package/dist/cancellation.js +18 -0
- package/dist/client.d.ts +11 -23
- package/dist/client.js +22 -91
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/realtime/async.d.ts +27 -0
- package/dist/realtime/async.js +122 -0
- package/dist/realtime/audio.d.ts +13 -0
- package/dist/realtime/audio.js +54 -0
- package/dist/realtime/client.d.ts +12 -0
- package/dist/realtime/client.js +156 -0
- package/dist/realtime/protocol.d.ts +80 -0
- package/dist/realtime/protocol.js +354 -0
- package/dist/realtime/session.d.ts +67 -0
- package/dist/realtime/session.js +730 -0
- package/dist/realtime/transport.d.ts +16 -0
- package/dist/realtime/transport.js +83 -0
- package/dist/realtime/types.d.ts +190 -0
- package/dist/realtime/types.js +10 -0
- package/dist/{audio.js → tts/audio.js} +1 -1
- package/dist/tts/client.d.ts +36 -0
- package/dist/tts/client.js +111 -0
- package/dist/{policy.d.ts → tts/policy.d.ts} +0 -1
- package/dist/{policy.js → tts/policy.js} +1 -19
- package/dist/{sentences.js → tts/sentences.js} +2 -2
- package/dist/{stream.js → tts/stream.js} +3 -2
- package/dist/{transport.d.ts → tts/transport.d.ts} +1 -1
- package/dist/{transport.js → tts/transport.js} +10 -5
- package/docs/realtime.md +220 -0
- package/docs/tts.md +132 -0
- package/package.json +12 -5
- /package/dist/{audio.d.ts → tts/audio.d.ts} +0 -0
- /package/dist/{queue.d.ts → tts/queue.d.ts} +0 -0
- /package/dist/{queue.js → tts/queue.js} +0 -0
- /package/dist/{sentences.d.ts → tts/sentences.d.ts} +0 -0
- /package/dist/{stream.d.ts → tts/stream.d.ts} +0 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Protocol rules adapted from rimelabs/sglang-omni clients/prism_realtime at
|
|
3
|
+
// bc8a500249536f369d20bc094baa9169e5986758, as in the Python SDK.
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { setMaxListeners } from "node:events";
|
|
6
|
+
import { abortable } from "../cancellation.js";
|
|
7
|
+
import { RimeError, RimeInputError, RimeResourceLimitError, RimeStreamError, RimeTimeoutError, } from "../errors.js";
|
|
8
|
+
import { Deferred, Flag, Mutex, cancellation, scope } from "./async.js";
|
|
9
|
+
import { formatOf, InputConverter } from "./audio.js";
|
|
10
|
+
import * as t from "./types.js";
|
|
11
|
+
import * as p from "./protocol.js";
|
|
12
|
+
const NOT_READY = new Set([
|
|
13
|
+
"typed_turn_busy",
|
|
14
|
+
"instructions_busy",
|
|
15
|
+
"proactive_unavailable",
|
|
16
|
+
]);
|
|
17
|
+
const LIMIT = 128;
|
|
18
|
+
class ResponseState {
|
|
19
|
+
ref;
|
|
20
|
+
done = new Flag();
|
|
21
|
+
changed = new Flag();
|
|
22
|
+
calls = new Map();
|
|
23
|
+
outputs = new Set();
|
|
24
|
+
cancelLock = new Mutex();
|
|
25
|
+
token = null;
|
|
26
|
+
continued = false;
|
|
27
|
+
abandoned = false;
|
|
28
|
+
constructor(ref) {
|
|
29
|
+
this.ref = ref;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function outputKey(output) {
|
|
33
|
+
return JSON.stringify([
|
|
34
|
+
output.itemId,
|
|
35
|
+
output.outputIndex,
|
|
36
|
+
output.contentIndex,
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
function text(value) {
|
|
40
|
+
if (typeof value !== "string" || !value.trim() || [...value].length > 4000)
|
|
41
|
+
throw new RimeInputError("Text must be nonblank and at most 4000 characters");
|
|
42
|
+
}
|
|
43
|
+
/** One conversation. Consume events concurrently with session operations. */
|
|
44
|
+
export class RealtimeSession {
|
|
45
|
+
socket;
|
|
46
|
+
timeouts;
|
|
47
|
+
forget;
|
|
48
|
+
created = new Deferred();
|
|
49
|
+
sessionId = null;
|
|
50
|
+
sessionInfo = null;
|
|
51
|
+
lifetime = new AbortController();
|
|
52
|
+
failure = null;
|
|
53
|
+
closed = false;
|
|
54
|
+
closing = null;
|
|
55
|
+
consuming = false;
|
|
56
|
+
queue = [];
|
|
57
|
+
queued = new Flag();
|
|
58
|
+
ready = new Flag();
|
|
59
|
+
readyEpoch = 0;
|
|
60
|
+
admission = new Mutex();
|
|
61
|
+
writeLock = new Mutex();
|
|
62
|
+
audioLock = new Mutex();
|
|
63
|
+
truncateLock = new Mutex();
|
|
64
|
+
toolLock = new Mutex();
|
|
65
|
+
pending = new Map();
|
|
66
|
+
responses = new Map();
|
|
67
|
+
latest = null;
|
|
68
|
+
tasks = new Set();
|
|
69
|
+
converter = null;
|
|
70
|
+
/** @internal Use client.realtime.connect(). */
|
|
71
|
+
constructor(socket, timeouts, forget) {
|
|
72
|
+
this.socket = socket;
|
|
73
|
+
this.timeouts = timeouts;
|
|
74
|
+
this.forget = forget;
|
|
75
|
+
setMaxListeners(0, this.lifetime.signal);
|
|
76
|
+
socket.onFailure = (error) => this.fail(error);
|
|
77
|
+
socket.onMessage = (raw) => {
|
|
78
|
+
if (this.closed)
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
const event = p.decode(raw, this.sessionId);
|
|
82
|
+
if (event)
|
|
83
|
+
this.dispatch(event);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.fail(error instanceof RimeError
|
|
87
|
+
? error
|
|
88
|
+
: new RimeStreamError("Invalid realtime event"));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** @internal */
|
|
93
|
+
async initialize(settings, signal) {
|
|
94
|
+
const deadline = scope([signal, this.lifetime.signal], this.timeouts.connectS, new RimeTimeoutError("Realtime connection or session.created timed out"));
|
|
95
|
+
try {
|
|
96
|
+
await abortable(this.socket.ready, deadline.signal);
|
|
97
|
+
await abortable(this.created.promise, deadline.signal);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
deadline.dispose();
|
|
101
|
+
}
|
|
102
|
+
const event = await this.request(p.requests.update, { session: settings }, signal);
|
|
103
|
+
const view = event.session;
|
|
104
|
+
this.sessionInfo = Object.freeze({
|
|
105
|
+
sessionId: this.id(),
|
|
106
|
+
model: "prism",
|
|
107
|
+
voice: view.voice ?? "",
|
|
108
|
+
interruptOnSpeech: view.interruptOnSpeech ?? settings.turn_detection.interrupt_response,
|
|
109
|
+
toolResultTimeoutS: view.toolResultTimeoutS ?? null,
|
|
110
|
+
toolContinuationTimeoutS: view.toolContinuationTimeoutS ?? null,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
get info() {
|
|
114
|
+
if (!this.sessionInfo)
|
|
115
|
+
throw new RimeInputError("The session has not initialized");
|
|
116
|
+
return this.sessionInfo;
|
|
117
|
+
}
|
|
118
|
+
get events() {
|
|
119
|
+
return this.readEvents();
|
|
120
|
+
}
|
|
121
|
+
async *readEvents() {
|
|
122
|
+
if (this.consuming)
|
|
123
|
+
throw new RimeInputError("Only one consumer may read session events");
|
|
124
|
+
this.consuming = true;
|
|
125
|
+
try {
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (this.failure)
|
|
128
|
+
throw this.failure;
|
|
129
|
+
const event = this.queue.shift();
|
|
130
|
+
if (event) {
|
|
131
|
+
yield event;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (this.closed)
|
|
135
|
+
return;
|
|
136
|
+
this.queued.clear();
|
|
137
|
+
// Close wakes this flag; use no cancellation so explicit close ends normally.
|
|
138
|
+
await this.queued.wait(new AbortController().signal);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
this.consuming = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
id() {
|
|
146
|
+
if (!this.sessionId)
|
|
147
|
+
throw new RimeStreamError("Event arrived before session.created");
|
|
148
|
+
return this.sessionId;
|
|
149
|
+
}
|
|
150
|
+
check() {
|
|
151
|
+
if (this.failure)
|
|
152
|
+
throw this.failure;
|
|
153
|
+
if (this.closed)
|
|
154
|
+
throw new RimeInputError("The realtime session is closed");
|
|
155
|
+
}
|
|
156
|
+
response(ref) {
|
|
157
|
+
this.check();
|
|
158
|
+
if (ref.sessionId !== this.info.sessionId)
|
|
159
|
+
throw new RimeInputError("Reference belongs to another session");
|
|
160
|
+
const state = this.responses.get(ref.responseId);
|
|
161
|
+
if (!state)
|
|
162
|
+
throw new RimeInputError("Unknown or expired response reference");
|
|
163
|
+
if ("callId" in ref && !state.calls.has(ref.callId))
|
|
164
|
+
throw new RimeInputError("Unknown tool call reference");
|
|
165
|
+
return state;
|
|
166
|
+
}
|
|
167
|
+
track(task) {
|
|
168
|
+
this.tasks.add(task);
|
|
169
|
+
task.then(() => this.tasks.delete(task), () => this.tasks.delete(task));
|
|
170
|
+
return task;
|
|
171
|
+
}
|
|
172
|
+
capacity() {
|
|
173
|
+
this.check();
|
|
174
|
+
if (this.tasks.size >= LIMIT)
|
|
175
|
+
throw new RimeResourceLimitError("Too many pending realtime operations");
|
|
176
|
+
}
|
|
177
|
+
async operation(options, work) {
|
|
178
|
+
this.check();
|
|
179
|
+
const caller = cancellation(options.signal);
|
|
180
|
+
const lifetime = scope([caller.signal, this.lifetime.signal]);
|
|
181
|
+
try {
|
|
182
|
+
lifetime.signal.throwIfAborted();
|
|
183
|
+
return await work(lifetime.signal);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
lifetime.dispose();
|
|
187
|
+
caller.dispose();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async locked(mutex, signal, work) {
|
|
191
|
+
const release = await mutex.acquire(signal);
|
|
192
|
+
try {
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
this.check();
|
|
195
|
+
return await work();
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
release();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async send(event, signal, markSubmitted = () => { }) {
|
|
202
|
+
const deadline = scope([signal, this.lifetime.signal], this.timeouts.requestS, new RimeTimeoutError("Realtime write timed out; outcome unknown"));
|
|
203
|
+
try {
|
|
204
|
+
await this.locked(this.writeLock, deadline.signal, async () => {
|
|
205
|
+
const encoded = JSON.stringify(event);
|
|
206
|
+
markSubmitted();
|
|
207
|
+
await abortable(this.socket.send(encoded), deadline.signal);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error instanceof RimeTimeoutError || error instanceof RimeStreamError)
|
|
212
|
+
this.fail(error);
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
deadline.dispose();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async request(request, body, signal, target, continuation) {
|
|
220
|
+
const kind = request.kind;
|
|
221
|
+
signal.throwIfAborted();
|
|
222
|
+
this.capacity();
|
|
223
|
+
if (kind === "response.create") {
|
|
224
|
+
if (continuation)
|
|
225
|
+
continuation.continued = true;
|
|
226
|
+
else
|
|
227
|
+
this.ready.clear();
|
|
228
|
+
}
|
|
229
|
+
let accepted;
|
|
230
|
+
const task = this.track(this.runRequest(request, body, signal, this.readyEpoch, target, continuation).then((result) => {
|
|
231
|
+
if (result.kind === "response.started") {
|
|
232
|
+
accepted = result.response;
|
|
233
|
+
if (signal.aborted)
|
|
234
|
+
this.abandon(result.response);
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}));
|
|
238
|
+
try {
|
|
239
|
+
const result = await abortable(task, signal);
|
|
240
|
+
signal.throwIfAborted();
|
|
241
|
+
return request.result(result);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (signal.aborted) {
|
|
245
|
+
if (kind !== "response.create")
|
|
246
|
+
await task.catch(() => { });
|
|
247
|
+
else if (accepted)
|
|
248
|
+
this.abandon(accepted);
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async runRequest(request, body, caller, epoch, target, continuation) {
|
|
254
|
+
const kind = request.kind;
|
|
255
|
+
const eventId = `evt_${randomUUID().replaceAll("-", "")}`;
|
|
256
|
+
const result = new Deferred();
|
|
257
|
+
this.pending.set(eventId, { request, result, target });
|
|
258
|
+
const deadline = scope([this.lifetime.signal], this.timeouts.requestS, new RimeTimeoutError(`${kind}: no acknowledgment; outcome unknown`, eventId));
|
|
259
|
+
let submitted = false;
|
|
260
|
+
// Cancellation can prevent a write. After submission, keep matching its outcome.
|
|
261
|
+
const beforeSend = scope([caller, deadline.signal]);
|
|
262
|
+
try {
|
|
263
|
+
const release = await this.writeLock.acquire(beforeSend.signal);
|
|
264
|
+
try {
|
|
265
|
+
beforeSend.signal.throwIfAborted();
|
|
266
|
+
this.check();
|
|
267
|
+
const encoded = JSON.stringify({
|
|
268
|
+
type: kind,
|
|
269
|
+
event_id: eventId,
|
|
270
|
+
...body,
|
|
271
|
+
});
|
|
272
|
+
submitted = true;
|
|
273
|
+
await abortable(this.socket.send(encoded), deadline.signal);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
release();
|
|
277
|
+
beforeSend.dispose();
|
|
278
|
+
}
|
|
279
|
+
return await abortable(result.promise, deadline.signal);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (continuation &&
|
|
283
|
+
error instanceof t.RimeRealtimeError &&
|
|
284
|
+
error.fault.scope === "event" &&
|
|
285
|
+
error.fault.code === "tool_continuation_not_ready")
|
|
286
|
+
continuation.continued = false;
|
|
287
|
+
if (error instanceof RimeTimeoutError || error instanceof RimeStreamError)
|
|
288
|
+
this.fail(error);
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (kind === "response.create" && !submitted) {
|
|
293
|
+
if (continuation)
|
|
294
|
+
continuation.continued = false;
|
|
295
|
+
else if (epoch === this.readyEpoch)
|
|
296
|
+
this.ready.set();
|
|
297
|
+
}
|
|
298
|
+
this.pending.delete(eventId);
|
|
299
|
+
beforeSend.dispose();
|
|
300
|
+
deadline.dispose();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
abandon(ref) {
|
|
304
|
+
const state = this.responses.get(ref.responseId);
|
|
305
|
+
if (!state || state.abandoned || this.closed)
|
|
306
|
+
return;
|
|
307
|
+
state.abandoned = true;
|
|
308
|
+
this.publish({ kind: "response.abandoned", response: ref });
|
|
309
|
+
this.track(this.cancel(ref));
|
|
310
|
+
}
|
|
311
|
+
async create(body, signal, request = p.requests.create) {
|
|
312
|
+
const deadline = performance.now() + this.timeouts.readyS * 1000;
|
|
313
|
+
const admission = scope([signal], this.timeouts.readyS, new t.RealtimeAdmissionTimeout("Prism admission exceeded readyS"));
|
|
314
|
+
let release;
|
|
315
|
+
try {
|
|
316
|
+
release = await this.admission.acquire(admission.signal);
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
admission.dispose();
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
for (;;) {
|
|
323
|
+
this.check();
|
|
324
|
+
const ready = scope([signal], (deadline - performance.now()) / 1000, new t.RealtimeAdmissionTimeout("Prism did not admit the turn before readyS expired"));
|
|
325
|
+
try {
|
|
326
|
+
await this.ready.wait(ready.signal);
|
|
327
|
+
ready.signal.throwIfAborted();
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
ready.dispose();
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
return (await this.request(request, { response: body }, signal))
|
|
334
|
+
.response;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (!(error instanceof t.RimeRealtimeError) ||
|
|
338
|
+
error.fault.scope !== "event" ||
|
|
339
|
+
!NOT_READY.has(error.fault.code))
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
release();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async sendText(value, options = {}) {
|
|
349
|
+
text(value);
|
|
350
|
+
return this.operation(options, (signal) => this.create({ prsm_input_text: value, metadata: { prsm_cause: "user_text" } }, signal, p.requests.text));
|
|
351
|
+
}
|
|
352
|
+
async requestReply(options = {}) {
|
|
353
|
+
const body = {
|
|
354
|
+
metadata: { prsm_cause: "proactive" },
|
|
355
|
+
};
|
|
356
|
+
if (options.instruction != null) {
|
|
357
|
+
text(options.instruction);
|
|
358
|
+
body.prsm_instruction = options.instruction;
|
|
359
|
+
}
|
|
360
|
+
if (options.toolCall != null) {
|
|
361
|
+
const state = this.response(options.toolCall);
|
|
362
|
+
if (state.calls.get(options.toolCall.callId) === null)
|
|
363
|
+
throw new RimeInputError("Record the tool result before requesting its report");
|
|
364
|
+
body.prsm_call_id = options.toolCall.callId;
|
|
365
|
+
}
|
|
366
|
+
return this.operation(options, (signal) => this.create(body, signal));
|
|
367
|
+
}
|
|
368
|
+
async addMessage(role, value, options = {}) {
|
|
369
|
+
if (role !== "user" && role !== "assistant")
|
|
370
|
+
throw new RimeInputError("History role must be user or assistant");
|
|
371
|
+
text(value);
|
|
372
|
+
return this.operation(options, async (signal) => {
|
|
373
|
+
const event = await this.request(p.requests.item, { item: { type: "message", role, text: value } }, signal);
|
|
374
|
+
return Object.freeze({
|
|
375
|
+
sessionId: this.info.sessionId,
|
|
376
|
+
itemId: event.itemId,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async submitToolResult(call, output, options = {}) {
|
|
381
|
+
const ref = { ...call };
|
|
382
|
+
this.response(ref);
|
|
383
|
+
if (typeof output !== "string")
|
|
384
|
+
throw new RimeInputError("Tool output must be a string");
|
|
385
|
+
return this.operation(options, async (signal) => {
|
|
386
|
+
this.capacity();
|
|
387
|
+
const task = this.track(this.locked(this.toolLock, this.lifetime.signal, async () => {
|
|
388
|
+
const state = this.response(ref);
|
|
389
|
+
const digest = createHash("sha256").update(output).digest("hex");
|
|
390
|
+
const recorded = state.calls.get(ref.callId);
|
|
391
|
+
if (recorded !== null) {
|
|
392
|
+
if (recorded !== digest)
|
|
393
|
+
throw new RimeInputError("A recorded tool result cannot change");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await this.request(p.requests.toolResult, {
|
|
397
|
+
item: {
|
|
398
|
+
type: "function_call_output",
|
|
399
|
+
call_id: ref.callId,
|
|
400
|
+
output,
|
|
401
|
+
},
|
|
402
|
+
}, this.lifetime.signal, ref.callId);
|
|
403
|
+
state.calls.set(ref.callId, digest);
|
|
404
|
+
state.changed.set();
|
|
405
|
+
}));
|
|
406
|
+
await abortable(task, signal);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
async continueReply(parent, options = {}) {
|
|
410
|
+
const ref = { ...parent };
|
|
411
|
+
const state = this.response(ref);
|
|
412
|
+
return this.operation(options, async (signal) => {
|
|
413
|
+
const ready = scope([signal], this.timeouts.readyS, new t.RealtimeAdmissionTimeout("Tool results or parent completion did not arrive before readyS expired"));
|
|
414
|
+
try {
|
|
415
|
+
for (;;) {
|
|
416
|
+
this.check();
|
|
417
|
+
ready.signal.throwIfAborted();
|
|
418
|
+
if (ref.responseId !== this.latest || state.continued)
|
|
419
|
+
throw new RimeInputError("The tool round is superseded or already continued");
|
|
420
|
+
if (state.done.isSet &&
|
|
421
|
+
state.calls.size &&
|
|
422
|
+
[...state.calls.values()].every((value) => value !== null))
|
|
423
|
+
break;
|
|
424
|
+
if (state.done.isSet && !state.calls.size)
|
|
425
|
+
throw new RimeInputError("The response has no tool calls");
|
|
426
|
+
state.changed.clear();
|
|
427
|
+
await state.changed.wait(ready.signal);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
ready.dispose();
|
|
432
|
+
}
|
|
433
|
+
return (await this.request(p.requests.create, {
|
|
434
|
+
response: {
|
|
435
|
+
metadata: {
|
|
436
|
+
prsm_cause: "tool_continuation",
|
|
437
|
+
prsm_parent_response_id: ref.responseId,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
}, signal, undefined, state)).response;
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async cancel(response, options = {}) {
|
|
444
|
+
const state = this.response(response);
|
|
445
|
+
return this.operation(options, (signal) => this.locked(state.cancelLock, signal, async () => {
|
|
446
|
+
if (!state.done.isSet)
|
|
447
|
+
await this.request(p.requests.cancel, { response_id: state.ref.responseId }, signal, state.ref.responseId);
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
async clearAudio(options = {}) {
|
|
451
|
+
return this.operation(options, (signal) => this.locked(this.audioLock, signal, async () => {
|
|
452
|
+
await this.request(p.requests.clear, {}, signal);
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
async sendAudio(chunk, options = {}) {
|
|
456
|
+
const format = formatOf(chunk);
|
|
457
|
+
// Snapshot one bounded chunk before the first await.
|
|
458
|
+
const data = Buffer.from(chunk.data);
|
|
459
|
+
return this.operation(options, (signal) => this.locked(this.audioLock, signal, async () => {
|
|
460
|
+
const previous = this.converter;
|
|
461
|
+
const same = previous &&
|
|
462
|
+
previous.format.sampleRate === format.sampleRate &&
|
|
463
|
+
previous.format.channels === format.channels;
|
|
464
|
+
this.converter = same ? previous.clone() : new InputConverter(format);
|
|
465
|
+
const converted = this.converter.process(data);
|
|
466
|
+
let submitted = false;
|
|
467
|
+
try {
|
|
468
|
+
for (let offset = 0; offset < converted.length; offset += 1280)
|
|
469
|
+
await this.send({
|
|
470
|
+
type: "input_audio_buffer.append",
|
|
471
|
+
audio: converted
|
|
472
|
+
.subarray(offset, offset + 1280)
|
|
473
|
+
.toString("base64"),
|
|
474
|
+
}, signal, () => {
|
|
475
|
+
submitted = true;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
if (signal.aborted) {
|
|
480
|
+
if (submitted)
|
|
481
|
+
this.fail(new RimeStreamError("Realtime audio submission cancelled; outcome unknown"));
|
|
482
|
+
else
|
|
483
|
+
this.converter = previous;
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
async reportPlayback(report, options = {}) {
|
|
490
|
+
if (report.kind === "interrupted") {
|
|
491
|
+
const state = this.response(report.output.response);
|
|
492
|
+
if (!state.outputs.has(outputKey(report.output)))
|
|
493
|
+
throw new RimeInputError("Unknown output reference");
|
|
494
|
+
if (!Number.isInteger(report.audioEndMs) ||
|
|
495
|
+
report.audioEndMs < 0 ||
|
|
496
|
+
report.audioEndMs > 4294967295)
|
|
497
|
+
throw new RimeInputError("audioEndMs must be an unsigned 32-bit integer");
|
|
498
|
+
const body = {
|
|
499
|
+
item_id: report.output.itemId,
|
|
500
|
+
content_index: report.output.contentIndex,
|
|
501
|
+
audio_end_ms: report.audioEndMs,
|
|
502
|
+
};
|
|
503
|
+
return this.operation(options, (signal) => this.locked(this.truncateLock, signal, async () => {
|
|
504
|
+
await this.request(p.requests.truncate, body, signal, body.item_id);
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
if (report.kind !== "finished")
|
|
508
|
+
throw new RimeInputError("Unknown playback report kind");
|
|
509
|
+
const state = this.response(report.response);
|
|
510
|
+
const played = report.playedMs ?? null, tail = report.audibleTailMs ?? null;
|
|
511
|
+
for (const value of [played, tail])
|
|
512
|
+
if (value !== null &&
|
|
513
|
+
(typeof value !== "number" || !Number.isFinite(value) || value < 0))
|
|
514
|
+
throw new RimeInputError("Playback times must be finite and nonnegative");
|
|
515
|
+
return this.operation(options, async (signal) => {
|
|
516
|
+
const ready = scope([signal], this.timeouts.readyS, new RimeTimeoutError("Response did not finish before playback report deadline"));
|
|
517
|
+
try {
|
|
518
|
+
await state.done.wait(ready.signal);
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
ready.dispose();
|
|
522
|
+
}
|
|
523
|
+
this.check();
|
|
524
|
+
if (state.token)
|
|
525
|
+
await this.send({
|
|
526
|
+
type: "prsm.playback.drained",
|
|
527
|
+
response_id: state.ref.responseId,
|
|
528
|
+
drain_token: state.token,
|
|
529
|
+
played_ms: played,
|
|
530
|
+
audible_tail_ms: tail,
|
|
531
|
+
}, signal);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
settle(key, result) {
|
|
535
|
+
const pending = this.pending.get(key ?? "");
|
|
536
|
+
if (pending) {
|
|
537
|
+
pending.request.check(result);
|
|
538
|
+
pending.result.resolve(result);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
reject(key, error) {
|
|
542
|
+
this.pending.get(key ?? "")?.result.reject(error);
|
|
543
|
+
}
|
|
544
|
+
fail(error) {
|
|
545
|
+
if (this.closed)
|
|
546
|
+
return;
|
|
547
|
+
this.failure = error;
|
|
548
|
+
this.closed = true;
|
|
549
|
+
this.lifetime.abort(error);
|
|
550
|
+
this.created.reject(error);
|
|
551
|
+
for (const pending of this.pending.values())
|
|
552
|
+
pending.result.reject(error);
|
|
553
|
+
this.queued.set();
|
|
554
|
+
void this.socket.close().then(() => this.forget());
|
|
555
|
+
}
|
|
556
|
+
dispatch(event) {
|
|
557
|
+
const { payload, requestId: echo } = event;
|
|
558
|
+
// Check echoed acknowledgments before changing response or item state.
|
|
559
|
+
if (payload.kind === "session.updated" ||
|
|
560
|
+
payload.kind === "item.created" ||
|
|
561
|
+
payload.kind === "tool.result.created" ||
|
|
562
|
+
payload.kind === "response.started") {
|
|
563
|
+
const pending = this.pending.get(echo ?? "");
|
|
564
|
+
if (pending &&
|
|
565
|
+
!(payload.kind === "item.created" && pending.request.allowsItemCreated))
|
|
566
|
+
pending.request.check(payload);
|
|
567
|
+
if (pending &&
|
|
568
|
+
payload.kind === "tool.result.created" &&
|
|
569
|
+
pending.target !== payload.callId)
|
|
570
|
+
throw new RimeStreamError("Tool result acknowledgment names another call");
|
|
571
|
+
}
|
|
572
|
+
switch (payload.kind) {
|
|
573
|
+
case "session.created":
|
|
574
|
+
if (this.sessionId === null) {
|
|
575
|
+
this.sessionId = payload.session.id;
|
|
576
|
+
this.created.resolve(payload.session);
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
case "session.updated":
|
|
580
|
+
case "tool.result.created":
|
|
581
|
+
this.settle(echo, payload);
|
|
582
|
+
return;
|
|
583
|
+
case "item.created": {
|
|
584
|
+
const pending = this.pending.get(echo ?? "");
|
|
585
|
+
if (pending?.request.allowsItemCreated)
|
|
586
|
+
pending.itemId = payload.itemId;
|
|
587
|
+
else
|
|
588
|
+
this.settle(echo, payload);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
case "audio.cleared":
|
|
592
|
+
for (const [key, pending] of this.pending)
|
|
593
|
+
if (pending.request.kind === "input_audio_buffer.clear") {
|
|
594
|
+
this.converter = null;
|
|
595
|
+
this.settle(key, payload);
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
case "item.truncated":
|
|
600
|
+
for (const [key, pending] of this.pending)
|
|
601
|
+
if (pending.request.kind === "conversation.item.truncate" &&
|
|
602
|
+
pending.target === payload.itemId &&
|
|
603
|
+
payload.contentIndex === 0)
|
|
604
|
+
this.settle(key, payload);
|
|
605
|
+
return;
|
|
606
|
+
case "input.ready":
|
|
607
|
+
this.readyEpoch++;
|
|
608
|
+
this.ready.set();
|
|
609
|
+
break;
|
|
610
|
+
case "speech.started":
|
|
611
|
+
this.readyEpoch++;
|
|
612
|
+
this.ready.clear();
|
|
613
|
+
this.latest = null;
|
|
614
|
+
for (const state of this.responses.values())
|
|
615
|
+
state.changed.set();
|
|
616
|
+
break;
|
|
617
|
+
case "response.started": {
|
|
618
|
+
const ref = payload.response;
|
|
619
|
+
if (this.responses.has(ref.responseId))
|
|
620
|
+
throw new RimeStreamError("Duplicate response.created");
|
|
621
|
+
this.responses.set(ref.responseId, new ResponseState(ref));
|
|
622
|
+
for (const state of this.responses.values())
|
|
623
|
+
state.changed.set();
|
|
624
|
+
this.latest = ref.responseId;
|
|
625
|
+
this.readyEpoch++;
|
|
626
|
+
this.ready.clear();
|
|
627
|
+
while (this.responses.size > LIMIT) {
|
|
628
|
+
const oldest = this.responses.values().next().value;
|
|
629
|
+
if (!oldest.done.isSet)
|
|
630
|
+
throw new RimeResourceLimitError("Too many retained active responses");
|
|
631
|
+
this.responses.delete(oldest.ref.responseId);
|
|
632
|
+
}
|
|
633
|
+
this.settle(echo, payload);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case "response.ended": {
|
|
637
|
+
const state = this.wireResponse(payload.response.responseId);
|
|
638
|
+
if (state.done.isSet)
|
|
639
|
+
return;
|
|
640
|
+
state.token = payload.drainToken;
|
|
641
|
+
state.done.set();
|
|
642
|
+
state.changed.set();
|
|
643
|
+
for (const [key, pending] of this.pending)
|
|
644
|
+
if (pending.request.kind === "response.cancel" &&
|
|
645
|
+
pending.target === state.ref.responseId)
|
|
646
|
+
this.settle(key, payload);
|
|
647
|
+
const { drainToken, ...ended } = payload;
|
|
648
|
+
this.publish(ended, event.eventId, echo);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
case "tool.call": {
|
|
652
|
+
const state = this.wireResponse(payload.call.responseId), callId = payload.call.callId;
|
|
653
|
+
if (state.calls.has(callId))
|
|
654
|
+
return;
|
|
655
|
+
for (const other of this.responses.values())
|
|
656
|
+
if (other.calls.has(callId))
|
|
657
|
+
throw new RimeStreamError("Tool call ID reused by another response");
|
|
658
|
+
if (state.calls.size >= LIMIT)
|
|
659
|
+
throw new RimeResourceLimitError("Too many tool calls in one response");
|
|
660
|
+
state.calls.set(callId, null);
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
case "message.started": {
|
|
664
|
+
const state = this.wireResponse(payload.output.response.responseId);
|
|
665
|
+
if (state.outputs.size >= LIMIT)
|
|
666
|
+
throw new RimeResourceLimitError("Too many messages in one response");
|
|
667
|
+
state.outputs.add(outputKey(payload.output));
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
case "text.delta":
|
|
671
|
+
case "text.done":
|
|
672
|
+
case "audio.delta":
|
|
673
|
+
case "audio.done":
|
|
674
|
+
this.wireResponse(payload.output.response.responseId);
|
|
675
|
+
break;
|
|
676
|
+
case "error": {
|
|
677
|
+
const fault = payload.error, error = new t.RimeRealtimeError(fault);
|
|
678
|
+
if (NOT_READY.has(fault.code))
|
|
679
|
+
this.ready.clear();
|
|
680
|
+
this.reject(fault.requestId, error);
|
|
681
|
+
if (fault.code === "typed_turn_superseded" && fault.itemId)
|
|
682
|
+
for (const [key, pending] of this.pending)
|
|
683
|
+
if (pending.itemId === fault.itemId)
|
|
684
|
+
this.reject(key, error);
|
|
685
|
+
if (fault.scope === "session") {
|
|
686
|
+
this.fail(error);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
this.publish(payload, event.eventId, echo);
|
|
693
|
+
}
|
|
694
|
+
wireResponse(id) {
|
|
695
|
+
const state = this.responses.get(id);
|
|
696
|
+
if (!state)
|
|
697
|
+
throw new RimeStreamError("Event refers to an unknown response");
|
|
698
|
+
return state;
|
|
699
|
+
}
|
|
700
|
+
publish(payload, eventId = "", requestId = null) {
|
|
701
|
+
if (this.queue.length >= 256) {
|
|
702
|
+
const error = new RimeResourceLimitError("Realtime event consumer is too slow");
|
|
703
|
+
this.fail(error);
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
this.queue.push(Object.freeze({
|
|
707
|
+
sessionId: this.id(),
|
|
708
|
+
eventId,
|
|
709
|
+
requestId,
|
|
710
|
+
payload: Object.freeze(payload),
|
|
711
|
+
}));
|
|
712
|
+
this.queued.set();
|
|
713
|
+
}
|
|
714
|
+
async close() {
|
|
715
|
+
if (!this.closing) {
|
|
716
|
+
this.closing = (async () => {
|
|
717
|
+
if (!this.closed) {
|
|
718
|
+
this.fail(new RimeStreamError("Realtime session closed"));
|
|
719
|
+
this.failure = null;
|
|
720
|
+
}
|
|
721
|
+
await this.socket.close();
|
|
722
|
+
await Promise.allSettled([...this.tasks]);
|
|
723
|
+
})();
|
|
724
|
+
}
|
|
725
|
+
await this.closing;
|
|
726
|
+
}
|
|
727
|
+
async [Symbol.asyncDispose]() {
|
|
728
|
+
await this.close();
|
|
729
|
+
}
|
|
730
|
+
}
|