@rimelabs/sdk 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +155 -0
- package/LICENSE-PRISM +201 -0
- package/NOTICE-PRISM +9 -0
- package/README.md +279 -114
- package/dist/auth.d.ts +9 -3
- package/dist/auth.js +12 -4
- package/dist/cancellation.d.ts +6 -0
- package/dist/cancellation.js +54 -0
- package/dist/client.d.ts +24 -23
- package/dist/client.js +41 -92
- package/dist/grpc.d.ts +27 -0
- package/dist/grpc.js +175 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +6 -2
- package/dist/pcm.d.ts +21 -0
- package/dist/pcm.js +52 -0
- package/dist/realtime/async.d.ts +23 -0
- package/dist/realtime/async.js +87 -0
- package/dist/realtime/audio.d.ts +2 -0
- package/dist/realtime/audio.js +11 -0
- package/dist/realtime/client.d.ts +12 -0
- package/dist/realtime/client.js +139 -0
- package/dist/realtime/conversation.d.ts +47 -0
- package/dist/realtime/conversation.js +321 -0
- package/dist/realtime/faults.d.ts +2 -0
- package/dist/realtime/faults.js +25 -0
- package/dist/realtime/protocol.d.ts +99 -0
- package/dist/realtime/protocol.js +432 -0
- package/dist/realtime/session.d.ts +79 -0
- package/dist/realtime/session.js +808 -0
- package/dist/realtime/settings.d.ts +11 -0
- package/dist/realtime/settings.js +142 -0
- package/dist/realtime/transport.d.ts +16 -0
- package/dist/realtime/transport.js +86 -0
- package/dist/realtime/types.d.ts +292 -0
- package/dist/realtime/types.js +12 -0
- package/dist/stt/audio.d.ts +15 -0
- package/dist/stt/audio.js +46 -0
- package/dist/stt/client.d.ts +17 -0
- package/dist/stt/client.js +121 -0
- package/dist/stt/policy.d.ts +11 -0
- package/dist/stt/policy.js +11 -0
- package/dist/stt/protocol.d.ts +14 -0
- package/dist/stt/protocol.js +76 -0
- package/dist/stt/queue.d.ts +16 -0
- package/dist/stt/queue.js +45 -0
- package/dist/stt/stream.d.ts +48 -0
- package/dist/stt/stream.js +224 -0
- package/dist/stt/transport.d.ts +15 -0
- package/dist/stt/transport.js +94 -0
- package/dist/stt/types.d.ts +51 -0
- package/dist/stt/types.js +1 -0
- package/dist/{audio.d.ts → tts/audio.d.ts} +9 -0
- package/dist/{audio.js → tts/audio.js} +44 -4
- package/dist/tts/buffer.d.ts +3 -0
- package/dist/tts/buffer.js +53 -0
- package/dist/tts/client.d.ts +53 -0
- package/dist/tts/client.js +151 -0
- package/dist/tts/lexicon.d.ts +7 -0
- package/dist/tts/lexicon.js +23 -0
- package/dist/{policy.d.ts → tts/policy.d.ts} +2 -3
- package/dist/tts/policy.js +41 -0
- package/dist/{stream.d.ts → tts/stream.d.ts} +15 -2
- package/dist/{stream.js → tts/stream.js} +54 -30
- package/dist/tts/timestamps.d.ts +24 -0
- package/dist/tts/timestamps.js +46 -0
- package/dist/{transport.d.ts → tts/transport.d.ts} +10 -13
- package/dist/tts/transport.js +133 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.js +28 -0
- package/docs/realtime.md +537 -0
- package/docs/stt.md +186 -0
- package/docs/tts.md +284 -0
- package/package.json +14 -12
- package/dist/policy.js +0 -82
- package/dist/sentences.d.ts +0 -21
- package/dist/sentences.js +0 -142
- package/dist/transport.js +0 -223
- package/vendor/LICENSE.blingfire +0 -21
- package/vendor/README.md +0 -11
- package/vendor/blingfire.cjs +0 -4
- package/vendor/blingfire.upstream.cjs +0 -1
- package/vendor/blingfire.wasm +0 -0
- /package/dist/{queue.d.ts → tts/queue.d.ts} +0 -0
- /package/dist/{queue.js → tts/queue.js} +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { abortable } from "../cancellation.js";
|
|
2
|
+
import { RimeCancelledError, RimeInputError, RimeUnavailableError, } from "../errors.js";
|
|
3
|
+
import { cancellation, scope } from "./async.js";
|
|
4
|
+
import { RealtimeSession } from "./session.js";
|
|
5
|
+
import { RealtimeConnection } from "./transport.js";
|
|
6
|
+
import { toolSettings } from "./settings.js";
|
|
7
|
+
import { responseMetadata } from "./protocol.js";
|
|
8
|
+
const DEFAULT_ENDPOINT = "wss://prism.api.rime.ai/v1/realtime";
|
|
9
|
+
function endpoint(value) {
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(value);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new RimeInputError("endpoint must be a full ws:// or wss:// URL ending in /v1/realtime");
|
|
16
|
+
}
|
|
17
|
+
if (!["ws:", "wss:"].includes(url.protocol) ||
|
|
18
|
+
!url.hostname ||
|
|
19
|
+
url.username ||
|
|
20
|
+
url.password ||
|
|
21
|
+
url.hash ||
|
|
22
|
+
url.pathname.replace(/\/+$/, "") !== "/v1/realtime")
|
|
23
|
+
throw new RimeInputError("endpoint must be a full ws:// or wss:// URL ending in /v1/realtime");
|
|
24
|
+
if (url.protocol === "ws:" &&
|
|
25
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
26
|
+
throw new RimeInputError("Use wss:// outside localhost to protect credentials");
|
|
27
|
+
return url.href;
|
|
28
|
+
}
|
|
29
|
+
function timeouts(options = {}) {
|
|
30
|
+
const result = {
|
|
31
|
+
connectS: options.connectS ?? 10,
|
|
32
|
+
readyS: options.readyS ?? 30,
|
|
33
|
+
requestS: options.requestS ?? 10,
|
|
34
|
+
};
|
|
35
|
+
if (Object.values(result).some((value) => typeof value !== "number" || !Number.isFinite(value) || value <= 0))
|
|
36
|
+
throw new RimeInputError("Timeouts must be finite and positive");
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
export class Realtime {
|
|
40
|
+
credentials;
|
|
41
|
+
checkOpen;
|
|
42
|
+
sessions = new Set();
|
|
43
|
+
opening = new Map();
|
|
44
|
+
constructor(credentials, checkOpen) {
|
|
45
|
+
this.credentials = credentials;
|
|
46
|
+
this.checkOpen = checkOpen;
|
|
47
|
+
}
|
|
48
|
+
async connect(options = {}) {
|
|
49
|
+
this.checkOpen();
|
|
50
|
+
const address = endpoint(options.endpoint === undefined ? DEFAULT_ENDPOINT : options.endpoint);
|
|
51
|
+
const limits = timeouts(options.timeouts);
|
|
52
|
+
if ((options.model ?? "prism") !== "prism")
|
|
53
|
+
throw new RimeInputError("The realtime model must be prism");
|
|
54
|
+
if (options.interruptOnSpeech !== undefined &&
|
|
55
|
+
typeof options.interruptOnSpeech !== "boolean")
|
|
56
|
+
throw new RimeInputError("interruptOnSpeech must be a boolean");
|
|
57
|
+
const settings = {
|
|
58
|
+
modalities: ["text", "audio"],
|
|
59
|
+
input_audio_format: "pcm16",
|
|
60
|
+
turn_detection: { interrupt_response: options.interruptOnSpeech ?? true },
|
|
61
|
+
tools: toolSettings(options.tools ?? []),
|
|
62
|
+
};
|
|
63
|
+
if (options.turnDetection !== undefined) {
|
|
64
|
+
const detection = options.turnDetection;
|
|
65
|
+
if (!detection ||
|
|
66
|
+
typeof detection !== "object" ||
|
|
67
|
+
Array.isArray(detection))
|
|
68
|
+
throw new RimeInputError("turnDetection must be an object");
|
|
69
|
+
if (detection.eagerness !== undefined) {
|
|
70
|
+
if (!["low", "medium", "high", "auto"].includes(detection.eagerness))
|
|
71
|
+
throw new RimeInputError("Turn eagerness must be low, medium, high, or auto");
|
|
72
|
+
settings.turn_detection.eagerness = detection.eagerness;
|
|
73
|
+
}
|
|
74
|
+
if (detection.endOfTurnTimeoutMs !== undefined) {
|
|
75
|
+
const value = detection.endOfTurnTimeoutMs;
|
|
76
|
+
if (!Number.isInteger(value) || value < 1000 || value > 10000)
|
|
77
|
+
throw new RimeInputError("End-of-turn timeout must be an integer from 1000 to 10000 ms");
|
|
78
|
+
settings.turn_detection.prism_end_of_turn_timeout_ms = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (options.responseMetadata != null)
|
|
82
|
+
settings.prism_response_metadata = responseMetadata(options.responseMetadata);
|
|
83
|
+
const requestId = options.requestId ?? undefined;
|
|
84
|
+
if (requestId !== undefined &&
|
|
85
|
+
(typeof requestId !== "string" ||
|
|
86
|
+
!requestId.trim() ||
|
|
87
|
+
!/^[\x20-\x7e]+$/.test(requestId)))
|
|
88
|
+
throw new RimeInputError("requestId must be nonblank printable ASCII");
|
|
89
|
+
for (const name of ["voice", "instructions"]) {
|
|
90
|
+
const value = options[name];
|
|
91
|
+
if (value !== undefined && value !== null) {
|
|
92
|
+
if (typeof value !== "string" || !value.trim())
|
|
93
|
+
throw new RimeInputError(`${name} must be nonblank`);
|
|
94
|
+
settings[name] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const owner = new AbortController();
|
|
98
|
+
const caller = cancellation(options.signal);
|
|
99
|
+
const openingScope = scope([caller.signal, owner.signal]);
|
|
100
|
+
const task = (async () => {
|
|
101
|
+
let session;
|
|
102
|
+
try {
|
|
103
|
+
openingScope.signal.throwIfAborted();
|
|
104
|
+
let socket;
|
|
105
|
+
try {
|
|
106
|
+
socket = new RealtimeConnection(address, this.credentials.realtimeAuthorization(), requestId);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new RimeUnavailableError("Realtime handshake failed");
|
|
110
|
+
}
|
|
111
|
+
session = new RealtimeSession(socket, limits, () => this.sessions.delete(session));
|
|
112
|
+
this.sessions.add(session);
|
|
113
|
+
await abortable(session.initialize(settings, openingScope.signal), openingScope.signal);
|
|
114
|
+
openingScope.signal.throwIfAborted();
|
|
115
|
+
this.checkOpen();
|
|
116
|
+
return session;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await session?.close();
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
this.opening.set(owner, task);
|
|
124
|
+
try {
|
|
125
|
+
return await task;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.opening.delete(owner);
|
|
129
|
+
openingScope.dispose();
|
|
130
|
+
caller.dispose();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async close() {
|
|
134
|
+
for (const controller of this.opening.keys())
|
|
135
|
+
controller.abort(new RimeCancelledError("Client closed"));
|
|
136
|
+
await Promise.allSettled(this.opening.values());
|
|
137
|
+
await Promise.all([...this.sessions].map((session) => session.close()));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RealtimeSession } from "./session.js";
|
|
2
|
+
import type { AudioChunk, NormalizedRealtimeFault, 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<NormalizedRealtimeFault>) => 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,321 @@
|
|
|
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, responseInterruptsSpeech } 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
|
+
measured: false,
|
|
175
|
+
};
|
|
176
|
+
const measuredOutput = output;
|
|
177
|
+
void output.receipt.then(() => {
|
|
178
|
+
measuredOutput.measured = true;
|
|
179
|
+
}, () => { });
|
|
180
|
+
reply.outputs.set(key, output);
|
|
181
|
+
if (reply.interrupted) {
|
|
182
|
+
playback.interrupt([ref]);
|
|
183
|
+
cut(output);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return output;
|
|
187
|
+
}
|
|
188
|
+
async function send() {
|
|
189
|
+
while (!running.aborted) {
|
|
190
|
+
const chunk = await source.read(running);
|
|
191
|
+
if (chunk === null)
|
|
192
|
+
return;
|
|
193
|
+
await session.sendAudio(chunk, { signal: running });
|
|
194
|
+
}
|
|
195
|
+
running.throwIfAborted();
|
|
196
|
+
}
|
|
197
|
+
async function read() {
|
|
198
|
+
for await (const event of session.events) {
|
|
199
|
+
running.throwIfAborted();
|
|
200
|
+
const payload = event.payload;
|
|
201
|
+
switch (payload.kind) {
|
|
202
|
+
case "response.started": {
|
|
203
|
+
if (replies.size >= 64)
|
|
204
|
+
throw new RimeResourceLimitError("Conversation response limit exceeded");
|
|
205
|
+
const id = payload.response.responseId;
|
|
206
|
+
parents.set(id, payload.parent?.responseId);
|
|
207
|
+
if (parents.size > 128) {
|
|
208
|
+
const expired = parents.keys().next().value;
|
|
209
|
+
parents.delete(expired);
|
|
210
|
+
interrupted.delete(expired);
|
|
211
|
+
}
|
|
212
|
+
const blocked = wasInterrupted(id);
|
|
213
|
+
if (blocked)
|
|
214
|
+
interrupted.add(id);
|
|
215
|
+
replies.set(id, {
|
|
216
|
+
ref: payload.response,
|
|
217
|
+
interruptOnSpeech: responseInterruptsSpeech(session, payload.response),
|
|
218
|
+
outputs: new Map(),
|
|
219
|
+
interrupted: blocked,
|
|
220
|
+
ended: false,
|
|
221
|
+
drained: new Deferred(),
|
|
222
|
+
});
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "message.started":
|
|
226
|
+
case "audio.delta":
|
|
227
|
+
case "audio.done": {
|
|
228
|
+
const output = outputFor(payload.output);
|
|
229
|
+
if (output) {
|
|
230
|
+
const reply = replies.get(payload.output.response.responseId);
|
|
231
|
+
if (payload.kind === "audio.delta" && !reply.interrupted)
|
|
232
|
+
output.stream.write(payload.audio);
|
|
233
|
+
else if (payload.kind === "audio.done")
|
|
234
|
+
end(output);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "speech.started": {
|
|
239
|
+
// The newest eligible reply controls the playback batch.
|
|
240
|
+
// A pending finished report is no longer physical playback.
|
|
241
|
+
const eligible = [...replies.values()].filter((reply) => !reply.interrupted &&
|
|
242
|
+
(!reply.ended ||
|
|
243
|
+
[...reply.outputs.values()].some((output) => !output.measured)));
|
|
244
|
+
if (eligible.at(-1)?.interruptOnSpeech)
|
|
245
|
+
interrupt(eligible);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "response.abandoned": {
|
|
249
|
+
if (parents.has(payload.response.responseId))
|
|
250
|
+
interrupted.add(payload.response.responseId);
|
|
251
|
+
interrupt([...replies.values()].filter((reply) => wasInterrupted(reply.ref.responseId)));
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "response.ended": {
|
|
255
|
+
const reply = replies.get(payload.response.responseId);
|
|
256
|
+
if (reply) {
|
|
257
|
+
reply.ended = true;
|
|
258
|
+
for (const output of reply.outputs.values())
|
|
259
|
+
end(output);
|
|
260
|
+
finish(reply);
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const result = onEvent?.(event);
|
|
266
|
+
if (result &&
|
|
267
|
+
typeof result.then === "function") {
|
|
268
|
+
void Promise.resolve(result).catch(() => { });
|
|
269
|
+
throw new RimeInputError("onEvent must be synchronous and nonblocking");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
let original = false;
|
|
274
|
+
try {
|
|
275
|
+
running.throwIfAborted();
|
|
276
|
+
await abortable(Promise.race([start(send), start(read), failure]), running);
|
|
277
|
+
if (failed)
|
|
278
|
+
throw failureReason;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!isSessionCloseError(error)) {
|
|
282
|
+
original = true;
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
let cleanupError;
|
|
288
|
+
// Stop input, event processing and reports, but keep receipt waits alive
|
|
289
|
+
// until the adapter confirms playback has stopped or the deadline expires.
|
|
290
|
+
controller.abort();
|
|
291
|
+
const shutdown = scope([], 5, new RimeTimeoutError("Playback shutdown confirmation timed out"));
|
|
292
|
+
try {
|
|
293
|
+
// Await the adapter itself, including cleanup after the deadline aborts.
|
|
294
|
+
// Racing this promise against abort would leave that cleanup running.
|
|
295
|
+
await playback.stop(shutdown.signal);
|
|
296
|
+
shutdown.signal.throwIfAborted();
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
cleanupError = error;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
shutdown.dispose();
|
|
303
|
+
}
|
|
304
|
+
receipts.abort();
|
|
305
|
+
try {
|
|
306
|
+
await session.close();
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
cleanupError ??= error;
|
|
310
|
+
}
|
|
311
|
+
await Promise.allSettled(tasks);
|
|
312
|
+
signal?.removeEventListener("abort", abort);
|
|
313
|
+
if (!original) {
|
|
314
|
+
if (failed)
|
|
315
|
+
throw failureReason;
|
|
316
|
+
signal?.throwIfAborted();
|
|
317
|
+
if (cleanupError !== undefined)
|
|
318
|
+
throw cleanupError;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Reuse SDK-normalized faults so events and rejected requests share one object.
|
|
2
|
+
const normalizedFaults = new WeakSet();
|
|
3
|
+
export function normalizeFault(fault) {
|
|
4
|
+
if (normalizedFaults.has(fault))
|
|
5
|
+
return fault;
|
|
6
|
+
const snapshot = {
|
|
7
|
+
code: fault.code,
|
|
8
|
+
message: fault.message,
|
|
9
|
+
scope: fault.scope,
|
|
10
|
+
requestId: fault.requestId,
|
|
11
|
+
responseId: fault.responseId,
|
|
12
|
+
itemId: fault.itemId,
|
|
13
|
+
callIds: Object.freeze([...(fault.callIds ?? [])]),
|
|
14
|
+
correlationId: fault.correlationId,
|
|
15
|
+
parameter: fault.parameter,
|
|
16
|
+
};
|
|
17
|
+
const normalized = Object.freeze({
|
|
18
|
+
...snapshot,
|
|
19
|
+
retryable: snapshot.code === "capacity_exceeded" ||
|
|
20
|
+
snapshot.code === "unavailable" ||
|
|
21
|
+
snapshot.code === "response_timeout",
|
|
22
|
+
});
|
|
23
|
+
normalizedFaults.add(normalized);
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as t from "./types.js";
|
|
2
|
+
export interface SessionSettings {
|
|
3
|
+
modalities: ("text" | "audio")[];
|
|
4
|
+
input_audio_format: "pcm16";
|
|
5
|
+
turn_detection: {
|
|
6
|
+
interrupt_response: boolean;
|
|
7
|
+
eagerness?: t.TurnEagerness;
|
|
8
|
+
prism_end_of_turn_timeout_ms?: number;
|
|
9
|
+
};
|
|
10
|
+
tools: {
|
|
11
|
+
type: "function";
|
|
12
|
+
function: {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
parameters: t.JsonObject;
|
|
16
|
+
};
|
|
17
|
+
prism_filler?: t.PrismFiller;
|
|
18
|
+
}[];
|
|
19
|
+
voice?: string;
|
|
20
|
+
instructions?: string;
|
|
21
|
+
prism_response_metadata?: t.Labels;
|
|
22
|
+
}
|
|
23
|
+
/** A copy of the client's labels for every reply: keys to strings. */
|
|
24
|
+
export declare function responseMetadata(value: unknown): t.Labels;
|
|
25
|
+
export interface SessionPatch {
|
|
26
|
+
voice?: string;
|
|
27
|
+
instructions?: string;
|
|
28
|
+
tools?: SessionSettings["tools"];
|
|
29
|
+
turn_detection?: {
|
|
30
|
+
interrupt_response: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface SessionView {
|
|
34
|
+
settingsRevision: number | null;
|
|
35
|
+
id: string;
|
|
36
|
+
requestId: string | null;
|
|
37
|
+
responseMetadata: t.Labels | null;
|
|
38
|
+
voice: string;
|
|
39
|
+
interruptOnSpeech: boolean | null;
|
|
40
|
+
toolResultTimeoutS: number | null;
|
|
41
|
+
toolContinuationTimeoutS: number | null;
|
|
42
|
+
turnDetection: t.TurnDetectionInfo;
|
|
43
|
+
}
|
|
44
|
+
export interface SessionCreated {
|
|
45
|
+
kind: "session.created";
|
|
46
|
+
session: SessionView;
|
|
47
|
+
}
|
|
48
|
+
export interface SessionUpdated {
|
|
49
|
+
info?: t.SessionInfo;
|
|
50
|
+
kind: "session.updated";
|
|
51
|
+
session: SessionView;
|
|
52
|
+
}
|
|
53
|
+
export interface ItemCreated {
|
|
54
|
+
kind: "item.created";
|
|
55
|
+
itemId: string;
|
|
56
|
+
}
|
|
57
|
+
export interface ToolResultCreated {
|
|
58
|
+
kind: "tool.result.created";
|
|
59
|
+
callId: string;
|
|
60
|
+
}
|
|
61
|
+
export interface AudioCleared {
|
|
62
|
+
kind: "audio.cleared";
|
|
63
|
+
}
|
|
64
|
+
export interface ItemTruncated {
|
|
65
|
+
kind: "item.truncated";
|
|
66
|
+
itemId: string;
|
|
67
|
+
contentIndex: number;
|
|
68
|
+
}
|
|
69
|
+
export interface ResponseEnded extends t.ResponseEnded {
|
|
70
|
+
drainToken: string | null;
|
|
71
|
+
}
|
|
72
|
+
export type Acknowledgment = SessionUpdated | ItemCreated | ToolResultCreated | AudioCleared | ItemTruncated | t.ResponseStarted | ResponseEnded;
|
|
73
|
+
export type ServerPayload = SessionCreated | Acknowledgment | t.InputEvent<t.NormalizedRealtimeFault> | t.MessageStarted | t.TextDelta | t.TextDone | t.AudioDelta | t.AudioDone | t.ToolCall | t.FaultEvent<t.NormalizedRealtimeFault>;
|
|
74
|
+
export interface ServerEvent {
|
|
75
|
+
eventId: string;
|
|
76
|
+
requestId: string | null;
|
|
77
|
+
payload: ServerPayload;
|
|
78
|
+
}
|
|
79
|
+
export type RequestKind = "session.update" | "conversation.item.create" | "response.create" | "response.cancel" | "input_audio_buffer.clear" | "conversation.item.truncate";
|
|
80
|
+
/** A request carries its result type and checks it before exposing the result. */
|
|
81
|
+
export declare class Request<T extends Acknowledgment> {
|
|
82
|
+
readonly kind: RequestKind;
|
|
83
|
+
private readonly accepts;
|
|
84
|
+
readonly allowsItemCreated: boolean;
|
|
85
|
+
constructor(kind: RequestKind, accepts: (event: Acknowledgment) => event is T, allowsItemCreated?: boolean);
|
|
86
|
+
result(event: Acknowledgment): T;
|
|
87
|
+
check(event: Acknowledgment): void;
|
|
88
|
+
}
|
|
89
|
+
export declare const requests: {
|
|
90
|
+
update: Request<SessionUpdated>;
|
|
91
|
+
item: Request<ItemCreated>;
|
|
92
|
+
toolResult: Request<ToolResultCreated>;
|
|
93
|
+
create: Request<t.ResponseStarted>;
|
|
94
|
+
text: Request<t.ResponseStarted>;
|
|
95
|
+
cancel: Request<ResponseEnded>;
|
|
96
|
+
clear: Request<AudioCleared>;
|
|
97
|
+
truncate: Request<ItemTruncated>;
|
|
98
|
+
};
|
|
99
|
+
export declare function decode(raw: string, sessionId: string | null): ServerEvent | null;
|