@rimelabs/sdk 0.1.0-alpha.5 → 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 +7 -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} +3 -2
- 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
package/dist/client.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { AudioFormat } from "./audio.js";
|
|
2
|
-
import { AudioStream, constructionKey } from "./stream.js";
|
|
3
1
|
import { Credentials } from "./auth.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { RimeAuthenticationError, RimeInputError } from "./errors.js";
|
|
3
|
+
import { TTS, } from "./tts/client.js";
|
|
4
|
+
import { resolve, timeout } from "./tts/policy.js";
|
|
5
|
+
import { Realtime } from "./realtime/client.js";
|
|
7
6
|
export class Rime {
|
|
8
|
-
deployment;
|
|
9
7
|
credentials;
|
|
10
|
-
|
|
8
|
+
ttsClient;
|
|
9
|
+
realtimeClient;
|
|
10
|
+
realtime;
|
|
11
11
|
closed = false;
|
|
12
|
-
streams = new Set();
|
|
13
|
-
discoveryControllers = new Set();
|
|
14
12
|
closing = null;
|
|
15
|
-
defaultTimeout;
|
|
16
13
|
tts;
|
|
17
14
|
voices;
|
|
18
15
|
languages;
|
|
@@ -22,99 +19,33 @@ export class Rime {
|
|
|
22
19
|
: options.apiKey;
|
|
23
20
|
if (typeof key !== "string" || !key.trim())
|
|
24
21
|
throw new RimeAuthenticationError("Provide apiKey or set RIME_API_KEY");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
this.credentials = new Credentials(key,
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
30
|
-
|
|
31
|
-
if (options.language !== undefined && options.language !== null)
|
|
32
|
-
nonempty(options.language, "language");
|
|
33
|
-
return this.discover("voices", options.language, options.timeout);
|
|
34
|
-
},
|
|
22
|
+
const defaultTimeout = timeout(options.timeout);
|
|
23
|
+
const deployment = resolve(options.model ?? "coda", options.endpoint);
|
|
24
|
+
this.credentials = new Credentials(key, deployment);
|
|
25
|
+
this.realtimeClient = new Realtime(this.credentials, () => this.checkOpen());
|
|
26
|
+
this.realtime = {
|
|
27
|
+
connect: (options) => this.realtimeClient.connect(options),
|
|
35
28
|
};
|
|
36
|
-
this.
|
|
37
|
-
|
|
29
|
+
this.ttsClient = new TTS(this.credentials, deployment, defaultTimeout, () => this.checkOpen());
|
|
30
|
+
this.tts = {
|
|
31
|
+
stream: (text, options) => this.ttsClient.stream(text, options),
|
|
38
32
|
};
|
|
33
|
+
this.voices = this.ttsClient.voices;
|
|
34
|
+
this.languages = this.ttsClient.languages;
|
|
39
35
|
}
|
|
40
36
|
checkOpen() {
|
|
41
37
|
if (this.closed)
|
|
42
38
|
throw new RimeInputError("The Rime client is closed");
|
|
43
39
|
}
|
|
44
|
-
async prepare(signal) {
|
|
45
|
-
this.checkOpen();
|
|
46
|
-
const auth = await this.credentials.metadata(signal);
|
|
47
|
-
this.checkOpen();
|
|
48
|
-
this.client ??= transport.makeClient(this.deployment.target);
|
|
49
|
-
await connect(this.client, signal);
|
|
50
|
-
return { client: this.client, metadata: metadata(auth) };
|
|
51
|
-
}
|
|
52
|
-
stream(text, options) {
|
|
53
|
-
this.checkOpen();
|
|
54
|
-
if (typeof text === "string") {
|
|
55
|
-
if (!text.trim())
|
|
56
|
-
throw new RimeInputError("Text must contain non-whitespace characters");
|
|
57
|
-
}
|
|
58
|
-
else if (!text || typeof text[Symbol.asyncIterator] !== "function")
|
|
59
|
-
throw new RimeInputError("text must be a string or an async iterable of strings");
|
|
60
|
-
const voice = options.voice === undefined || options.voice === null
|
|
61
|
-
? this.deployment.defaultVoice
|
|
62
|
-
: nonempty(options.voice, "voice");
|
|
63
|
-
const language = nonempty(options.language ?? "en", "language");
|
|
64
|
-
const format = options.audioFormat ?? AudioFormat.PCM_24000;
|
|
65
|
-
if (format !== AudioFormat.PCM_24000 && format !== AudioFormat.MULAW_8000)
|
|
66
|
-
throw new RimeAudioFormatError("Select a named AudioFormat profile");
|
|
67
|
-
const audio = new AudioStream(constructionKey, {
|
|
68
|
-
prepare: (s) => this.prepare(s),
|
|
69
|
-
checkOpen: () => this.checkOpen(),
|
|
70
|
-
forget: (s) => this.streams.delete(s),
|
|
71
|
-
}, text, voice, language, format, timeout(options.timeout, this.defaultTimeout));
|
|
72
|
-
this.streams.add(audio);
|
|
73
|
-
return audio;
|
|
74
|
-
}
|
|
75
|
-
async discover(kind, language, override) {
|
|
76
|
-
this.checkOpen();
|
|
77
|
-
const budget = Math.min(timeout(override, this.defaultTimeout) ?? Infinity, policy.discoveryTimeout);
|
|
78
|
-
const controller = new AbortController();
|
|
79
|
-
let requestId = null;
|
|
80
|
-
this.discoveryControllers.add(controller);
|
|
81
|
-
const timer = setTimeout(() => controller.abort(new RimeTimeoutError("Discovery deadline expired")), budget * 1000);
|
|
82
|
-
try {
|
|
83
|
-
const prepared = await this.prepare(controller.signal);
|
|
84
|
-
for (let attempt = 0;; attempt++) {
|
|
85
|
-
requestId = null;
|
|
86
|
-
try {
|
|
87
|
-
return await discover(prepared, kind, language, controller.signal);
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
if (!(error instanceof RimeUnavailableError) || attempt === 2)
|
|
91
|
-
throw error;
|
|
92
|
-
requestId = error.requestId;
|
|
93
|
-
await abortable(new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt)), controller.signal);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
if (error instanceof RimeTimeoutError && requestId !== null)
|
|
99
|
-
throw new RimeTimeoutError(error.message, requestId, {
|
|
100
|
-
cause: error.cause,
|
|
101
|
-
});
|
|
102
|
-
throw error;
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
clearTimeout(timer);
|
|
106
|
-
this.discoveryControllers.delete(controller);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
40
|
async close() {
|
|
110
41
|
if (!this.closing) {
|
|
111
42
|
this.closed = true;
|
|
112
43
|
this.closing = (async () => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
44
|
+
await Promise.all([
|
|
45
|
+
this.ttsClient.close(),
|
|
46
|
+
this.realtimeClient.close(),
|
|
47
|
+
]);
|
|
116
48
|
await this.credentials.close();
|
|
117
|
-
this.client?.close();
|
|
118
49
|
})();
|
|
119
50
|
}
|
|
120
51
|
await this.closing;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { Rime, type RimeOptions, type SynthesisOptions, type DiscoveryOptions, type VoiceListOptions, } from "./client.js";
|
|
2
|
-
export { AudioStream } from "./stream.js";
|
|
3
|
-
export { AudioFormat } from "./audio.js";
|
|
2
|
+
export { AudioStream } from "./tts/stream.js";
|
|
3
|
+
export { AudioFormat } from "./tts/audio.js";
|
|
4
4
|
export * from "./errors.js";
|
|
5
|
+
export { RealtimeSession } from "./realtime/session.js";
|
|
6
|
+
export * from "./realtime/types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { Rime, } from "./client.js";
|
|
2
|
-
export { AudioStream } from "./stream.js";
|
|
3
|
-
export { AudioFormat } from "./audio.js";
|
|
2
|
+
export { AudioStream } from "./tts/stream.js";
|
|
3
|
+
export { AudioFormat } from "./tts/audio.js";
|
|
4
4
|
export * from "./errors.js";
|
|
5
|
+
export { RealtimeSession } from "./realtime/session.js";
|
|
6
|
+
export * from "./realtime/types.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare class Deferred<T> {
|
|
2
|
+
readonly promise: Promise<T>;
|
|
3
|
+
resolve: (value: T) => void;
|
|
4
|
+
reject: (error: unknown) => void;
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export declare class Flag {
|
|
8
|
+
private waiters;
|
|
9
|
+
isSet: boolean;
|
|
10
|
+
set(): void;
|
|
11
|
+
clear(): void;
|
|
12
|
+
wait(signal: AbortSignal): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare class Mutex {
|
|
15
|
+
private locked;
|
|
16
|
+
private waiters;
|
|
17
|
+
acquire(signal: AbortSignal): Promise<() => void>;
|
|
18
|
+
}
|
|
19
|
+
/** A disposable deadline avoids retaining timers and caller abort listeners. */
|
|
20
|
+
export declare function scope(signals: (AbortSignal | undefined)[], seconds?: number, error?: Error): {
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
dispose(): void;
|
|
23
|
+
};
|
|
24
|
+
export declare function cancellation(signal?: AbortSignal): {
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
dispose: () => void | undefined;
|
|
27
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { abortable } from "../cancellation.js";
|
|
2
|
+
import { RimeCancelledError } from "../errors.js";
|
|
3
|
+
export class Deferred {
|
|
4
|
+
promise;
|
|
5
|
+
resolve;
|
|
6
|
+
reject;
|
|
7
|
+
constructor() {
|
|
8
|
+
this.promise = new Promise((resolve, reject) => {
|
|
9
|
+
this.resolve = resolve;
|
|
10
|
+
this.reject = reject;
|
|
11
|
+
});
|
|
12
|
+
// Session failures can arrive before a caller starts waiting.
|
|
13
|
+
this.promise.catch(() => { });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class Flag {
|
|
17
|
+
waiters = new Set();
|
|
18
|
+
isSet = false;
|
|
19
|
+
set() {
|
|
20
|
+
this.isSet = true;
|
|
21
|
+
for (const wake of this.waiters)
|
|
22
|
+
wake();
|
|
23
|
+
}
|
|
24
|
+
clear() {
|
|
25
|
+
this.isSet = false;
|
|
26
|
+
}
|
|
27
|
+
wait(signal) {
|
|
28
|
+
if (signal.aborted)
|
|
29
|
+
return Promise.reject(signal.reason);
|
|
30
|
+
if (this.isSet)
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const cleanup = () => {
|
|
34
|
+
this.waiters.delete(wake);
|
|
35
|
+
signal.removeEventListener("abort", abort);
|
|
36
|
+
};
|
|
37
|
+
const wake = () => {
|
|
38
|
+
cleanup();
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
const abort = () => {
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(signal.reason);
|
|
44
|
+
};
|
|
45
|
+
this.waiters.add(wake);
|
|
46
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class Mutex {
|
|
51
|
+
locked = false;
|
|
52
|
+
waiters = new Set();
|
|
53
|
+
async acquire(signal) {
|
|
54
|
+
signal.throwIfAborted();
|
|
55
|
+
while (this.locked) {
|
|
56
|
+
const wake = new Deferred();
|
|
57
|
+
const notify = () => wake.resolve();
|
|
58
|
+
this.waiters.add(notify);
|
|
59
|
+
try {
|
|
60
|
+
await abortable(wake.promise, signal);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
this.waiters.delete(notify);
|
|
64
|
+
}
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
}
|
|
67
|
+
this.locked = true;
|
|
68
|
+
return () => {
|
|
69
|
+
this.locked = false;
|
|
70
|
+
for (const wake of this.waiters)
|
|
71
|
+
wake();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A disposable deadline avoids retaining timers and caller abort listeners. */
|
|
76
|
+
export function scope(signals, seconds, error) {
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const handlers = new Map();
|
|
79
|
+
for (const signal of new Set(signals)) {
|
|
80
|
+
if (!signal)
|
|
81
|
+
continue;
|
|
82
|
+
const abort = () => controller.abort(signal.reason);
|
|
83
|
+
if (signal.aborted)
|
|
84
|
+
abort();
|
|
85
|
+
else {
|
|
86
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
87
|
+
handlers.set(signal, abort);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let timer;
|
|
91
|
+
if (seconds !== undefined) {
|
|
92
|
+
const end = performance.now() + seconds * 1000;
|
|
93
|
+
const expire = () => {
|
|
94
|
+
const remaining = end - performance.now();
|
|
95
|
+
if (remaining <= 0)
|
|
96
|
+
controller.abort(error);
|
|
97
|
+
else
|
|
98
|
+
timer = setTimeout(expire, Math.min(remaining, 2147483647));
|
|
99
|
+
};
|
|
100
|
+
expire();
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
dispose() {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
for (const [signal, handler] of handlers)
|
|
107
|
+
signal.removeEventListener("abort", handler);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function cancellation(signal) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const abort = () => controller.abort(new RimeCancelledError("Realtime operation cancelled"));
|
|
114
|
+
if (signal?.aborted)
|
|
115
|
+
abort();
|
|
116
|
+
else
|
|
117
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
118
|
+
return {
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
dispose: () => signal?.removeEventListener("abort", abort),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AudioChunk, PCMFormat } from "./types.js";
|
|
2
|
+
export declare function formatOf(chunk: AudioChunk): Required<PCMFormat>;
|
|
3
|
+
/** Streaming linear interpolation, matching Python audioop.ratecv with no filter.
|
|
4
|
+
* Integer phase remains bounded and preserves samples across chunk boundaries.
|
|
5
|
+
*/
|
|
6
|
+
export declare class InputConverter {
|
|
7
|
+
readonly format: Required<PCMFormat>;
|
|
8
|
+
private phase;
|
|
9
|
+
private previous;
|
|
10
|
+
constructor(format: Required<PCMFormat>);
|
|
11
|
+
clone(): InputConverter;
|
|
12
|
+
process(input: Uint8Array): Buffer;
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { RimeAudioFormatError } from "../errors.js";
|
|
2
|
+
export function formatOf(chunk) {
|
|
3
|
+
const format = {
|
|
4
|
+
sampleRate: chunk.format?.sampleRate ?? 16000,
|
|
5
|
+
channels: chunk.format?.channels ?? 1,
|
|
6
|
+
encoding: chunk.format?.encoding ?? "pcm_s16le",
|
|
7
|
+
};
|
|
8
|
+
if (![8000, 16000, 24000, 48000].includes(format.sampleRate) ||
|
|
9
|
+
![1, 2].includes(format.channels) ||
|
|
10
|
+
format.encoding !== "pcm_s16le")
|
|
11
|
+
throw new RimeAudioFormatError("Use PCM16 at 8, 16, 24 or 48 kHz, with one or two channels");
|
|
12
|
+
if (!(chunk.data instanceof Uint8Array) ||
|
|
13
|
+
chunk.data.length % (2 * format.channels))
|
|
14
|
+
throw new RimeAudioFormatError("Audio must contain complete PCM16 frames as Uint8Array");
|
|
15
|
+
if (chunk.data.length > 192000)
|
|
16
|
+
throw new RimeAudioFormatError("Send audio in chunks of at most 192000 bytes");
|
|
17
|
+
return format;
|
|
18
|
+
}
|
|
19
|
+
/** Streaming linear interpolation, matching Python audioop.ratecv with no filter.
|
|
20
|
+
* Integer phase remains bounded and preserves samples across chunk boundaries.
|
|
21
|
+
*/
|
|
22
|
+
export class InputConverter {
|
|
23
|
+
format;
|
|
24
|
+
phase = -16000;
|
|
25
|
+
previous = 0;
|
|
26
|
+
constructor(format) {
|
|
27
|
+
this.format = format;
|
|
28
|
+
}
|
|
29
|
+
clone() {
|
|
30
|
+
const copy = new InputConverter(this.format);
|
|
31
|
+
copy.phase = this.phase;
|
|
32
|
+
copy.previous = this.previous;
|
|
33
|
+
return copy;
|
|
34
|
+
}
|
|
35
|
+
process(input) {
|
|
36
|
+
const data = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
37
|
+
const samples = [];
|
|
38
|
+
for (let offset = 0; offset < data.length; offset += this.format.channels * 2) {
|
|
39
|
+
const current = this.format.channels === 1
|
|
40
|
+
? data.readInt16LE(offset)
|
|
41
|
+
: Math.floor((data.readInt16LE(offset) + data.readInt16LE(offset + 2)) / 2);
|
|
42
|
+
this.phase += 16000;
|
|
43
|
+
while (this.phase >= 0) {
|
|
44
|
+
samples.push(Math.floor((this.previous * this.phase + current * (16000 - this.phase)) /
|
|
45
|
+
16000));
|
|
46
|
+
this.phase -= this.format.sampleRate;
|
|
47
|
+
}
|
|
48
|
+
this.previous = current;
|
|
49
|
+
}
|
|
50
|
+
const output = Buffer.alloc(samples.length * 2);
|
|
51
|
+
samples.forEach((sample, index) => output.writeInt16LE(sample, index * 2));
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Credentials } from "../auth.js";
|
|
2
|
+
import { RealtimeSession } from "./session.js";
|
|
3
|
+
import type { RealtimeConnectOptions } from "./types.js";
|
|
4
|
+
export declare class Realtime {
|
|
5
|
+
private readonly credentials;
|
|
6
|
+
private readonly checkOpen;
|
|
7
|
+
private readonly sessions;
|
|
8
|
+
private readonly opening;
|
|
9
|
+
constructor(credentials: Credentials, checkOpen: () => void);
|
|
10
|
+
connect(options: RealtimeConnectOptions): Promise<RealtimeSession>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
function endpoint(value) {
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(value);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new RimeInputError("endpoint must be a full ws:// or wss:// URL ending in /v1/realtime");
|
|
13
|
+
}
|
|
14
|
+
if (!["ws:", "wss:"].includes(url.protocol) ||
|
|
15
|
+
!url.hostname ||
|
|
16
|
+
url.username ||
|
|
17
|
+
url.password ||
|
|
18
|
+
url.hash ||
|
|
19
|
+
url.pathname.replace(/\/+$/, "") !== "/v1/realtime")
|
|
20
|
+
throw new RimeInputError("endpoint must be a full ws:// or wss:// URL ending in /v1/realtime");
|
|
21
|
+
if (url.protocol === "ws:" &&
|
|
22
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
23
|
+
throw new RimeInputError("Use wss:// outside localhost to protect credentials");
|
|
24
|
+
return url.href;
|
|
25
|
+
}
|
|
26
|
+
function timeouts(options = {}) {
|
|
27
|
+
const result = {
|
|
28
|
+
connectS: options.connectS ?? 10,
|
|
29
|
+
readyS: options.readyS ?? 30,
|
|
30
|
+
requestS: options.requestS ?? 10,
|
|
31
|
+
};
|
|
32
|
+
if (Object.values(result).some((value) => typeof value !== "number" || !Number.isFinite(value) || value <= 0))
|
|
33
|
+
throw new RimeInputError("Timeouts must be finite and positive");
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Reject values that JSON.stringify would silently drop or change. */
|
|
37
|
+
function copyJson(value) {
|
|
38
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
39
|
+
return value;
|
|
40
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
41
|
+
return value;
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return Array.from(value, copyJson);
|
|
44
|
+
if (value &&
|
|
45
|
+
typeof value === "object" &&
|
|
46
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
47
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyJson(item)]));
|
|
48
|
+
throw new RimeInputError("Tool schemas must contain finite JSON values");
|
|
49
|
+
}
|
|
50
|
+
export class Realtime {
|
|
51
|
+
credentials;
|
|
52
|
+
checkOpen;
|
|
53
|
+
sessions = new Set();
|
|
54
|
+
opening = new Map();
|
|
55
|
+
constructor(credentials, checkOpen) {
|
|
56
|
+
this.credentials = credentials;
|
|
57
|
+
this.checkOpen = checkOpen;
|
|
58
|
+
}
|
|
59
|
+
async connect(options) {
|
|
60
|
+
this.checkOpen();
|
|
61
|
+
const address = endpoint(options.endpoint);
|
|
62
|
+
const limits = timeouts(options.timeouts);
|
|
63
|
+
if ((options.model ?? "prism") !== "prism")
|
|
64
|
+
throw new RimeInputError("The realtime model must be prism");
|
|
65
|
+
if (options.interruptOnSpeech !== undefined &&
|
|
66
|
+
typeof options.interruptOnSpeech !== "boolean")
|
|
67
|
+
throw new RimeInputError("interruptOnSpeech must be a boolean");
|
|
68
|
+
const names = new Set();
|
|
69
|
+
const settings = {
|
|
70
|
+
modalities: ["text", "audio"],
|
|
71
|
+
input_audio_format: "pcm16",
|
|
72
|
+
turn_detection: { interrupt_response: options.interruptOnSpeech ?? true },
|
|
73
|
+
tools: (options.tools ?? []).map((tool) => {
|
|
74
|
+
if (typeof tool.name !== "string" ||
|
|
75
|
+
!tool.name.trim() ||
|
|
76
|
+
names.has(tool.name))
|
|
77
|
+
throw new RimeInputError("Tool names must be nonblank and unique");
|
|
78
|
+
names.add(tool.name);
|
|
79
|
+
if (!tool.parameters ||
|
|
80
|
+
Array.isArray(tool.parameters) ||
|
|
81
|
+
typeof tool.parameters !== "object" ||
|
|
82
|
+
(tool.description !== undefined &&
|
|
83
|
+
typeof tool.description !== "string"))
|
|
84
|
+
throw new RimeInputError("Tool parameters must be a JSON object and description must be a string");
|
|
85
|
+
let parameters;
|
|
86
|
+
try {
|
|
87
|
+
parameters = copyJson(tool.parameters);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new RimeInputError("Tool schemas must contain finite JSON values");
|
|
91
|
+
}
|
|
92
|
+
if (parameters === null ||
|
|
93
|
+
typeof parameters !== "object" ||
|
|
94
|
+
Array.isArray(parameters))
|
|
95
|
+
throw new RimeInputError("Tool parameters must be an object");
|
|
96
|
+
return {
|
|
97
|
+
type: "function",
|
|
98
|
+
function: {
|
|
99
|
+
name: tool.name,
|
|
100
|
+
description: tool.description ?? "",
|
|
101
|
+
parameters,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
for (const name of ["voice", "instructions"]) {
|
|
107
|
+
const value = options[name];
|
|
108
|
+
if (value !== undefined && value !== null) {
|
|
109
|
+
if (typeof value !== "string" || !value.trim())
|
|
110
|
+
throw new RimeInputError(`${name} must be nonblank`);
|
|
111
|
+
settings[name] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const owner = new AbortController();
|
|
115
|
+
const caller = cancellation(options.signal);
|
|
116
|
+
const openingScope = scope([caller.signal, owner.signal]);
|
|
117
|
+
const task = (async () => {
|
|
118
|
+
let session;
|
|
119
|
+
try {
|
|
120
|
+
openingScope.signal.throwIfAborted();
|
|
121
|
+
let socket;
|
|
122
|
+
try {
|
|
123
|
+
socket = new RealtimeConnection(address, this.credentials.realtimeAuthorization());
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new RimeUnavailableError("Realtime handshake failed");
|
|
127
|
+
}
|
|
128
|
+
session = new RealtimeSession(socket, limits, () => this.sessions.delete(session));
|
|
129
|
+
this.sessions.add(session);
|
|
130
|
+
await abortable(session.initialize(settings, openingScope.signal), openingScope.signal);
|
|
131
|
+
openingScope.signal.throwIfAborted();
|
|
132
|
+
this.checkOpen();
|
|
133
|
+
return session;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await session?.close();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
this.opening.set(owner, task);
|
|
141
|
+
try {
|
|
142
|
+
return await task;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
this.opening.delete(owner);
|
|
146
|
+
openingScope.dispose();
|
|
147
|
+
caller.dispose();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async close() {
|
|
151
|
+
for (const controller of this.opening.keys())
|
|
152
|
+
controller.abort(new RimeCancelledError("Client closed"));
|
|
153
|
+
await Promise.allSettled(this.opening.values());
|
|
154
|
+
await Promise.all([...this.sessions].map((session) => session.close()));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
};
|
|
8
|
+
tools: {
|
|
9
|
+
type: "function";
|
|
10
|
+
function: {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
parameters: t.JsonObject;
|
|
14
|
+
};
|
|
15
|
+
}[];
|
|
16
|
+
voice?: string;
|
|
17
|
+
instructions?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionView {
|
|
20
|
+
id: string;
|
|
21
|
+
voice: string;
|
|
22
|
+
interruptOnSpeech: boolean | null;
|
|
23
|
+
toolResultTimeoutS: number | null;
|
|
24
|
+
toolContinuationTimeoutS: number | null;
|
|
25
|
+
}
|
|
26
|
+
export interface SessionCreated {
|
|
27
|
+
kind: "session.created";
|
|
28
|
+
session: SessionView;
|
|
29
|
+
}
|
|
30
|
+
export interface SessionUpdated {
|
|
31
|
+
kind: "session.updated";
|
|
32
|
+
session: SessionView;
|
|
33
|
+
}
|
|
34
|
+
export interface ItemCreated {
|
|
35
|
+
kind: "item.created";
|
|
36
|
+
itemId: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ToolResultCreated {
|
|
39
|
+
kind: "tool.result.created";
|
|
40
|
+
callId: string;
|
|
41
|
+
}
|
|
42
|
+
export interface AudioCleared {
|
|
43
|
+
kind: "audio.cleared";
|
|
44
|
+
}
|
|
45
|
+
export interface ItemTruncated {
|
|
46
|
+
kind: "item.truncated";
|
|
47
|
+
itemId: string;
|
|
48
|
+
contentIndex: number;
|
|
49
|
+
}
|
|
50
|
+
export interface ResponseEnded extends t.ResponseEnded {
|
|
51
|
+
drainToken: string | null;
|
|
52
|
+
}
|
|
53
|
+
export type Acknowledgment = SessionUpdated | ItemCreated | ToolResultCreated | AudioCleared | ItemTruncated | t.ResponseStarted | ResponseEnded;
|
|
54
|
+
export type ServerPayload = SessionCreated | Acknowledgment | t.InputEvent | t.MessageStarted | t.TextDelta | t.TextDone | t.AudioDelta | t.AudioDone | t.ToolCall | t.FaultEvent;
|
|
55
|
+
export interface ServerEvent {
|
|
56
|
+
eventId: string;
|
|
57
|
+
requestId: string | null;
|
|
58
|
+
payload: ServerPayload;
|
|
59
|
+
}
|
|
60
|
+
export type RequestKind = "session.update" | "conversation.item.create" | "response.create" | "response.cancel" | "input_audio_buffer.clear" | "conversation.item.truncate";
|
|
61
|
+
/** A request carries its result type and checks it before exposing the result. */
|
|
62
|
+
export declare class Request<T extends Acknowledgment> {
|
|
63
|
+
readonly kind: RequestKind;
|
|
64
|
+
private readonly accepts;
|
|
65
|
+
readonly allowsItemCreated: boolean;
|
|
66
|
+
constructor(kind: RequestKind, accepts: (event: Acknowledgment) => event is T, allowsItemCreated?: boolean);
|
|
67
|
+
result(event: Acknowledgment): T;
|
|
68
|
+
check(event: Acknowledgment): void;
|
|
69
|
+
}
|
|
70
|
+
export declare const requests: {
|
|
71
|
+
update: Request<SessionUpdated>;
|
|
72
|
+
item: Request<ItemCreated>;
|
|
73
|
+
toolResult: Request<ToolResultCreated>;
|
|
74
|
+
create: Request<t.ResponseStarted>;
|
|
75
|
+
text: Request<t.ResponseStarted>;
|
|
76
|
+
cancel: Request<ResponseEnded>;
|
|
77
|
+
clear: Request<AudioCleared>;
|
|
78
|
+
truncate: Request<ItemTruncated>;
|
|
79
|
+
};
|
|
80
|
+
export declare function decode(raw: string, sessionId: string | null): ServerEvent | null;
|