@inkibra/voice-sdk 0.1.0
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/README.md +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +673 -0
- package/dist/public.d.ts +109 -0
- package/dist/server.d.ts +120 -0
- package/dist/server.js +392 -0
- package/package.json +35 -0
package/dist/public.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type LiveVoiceTool<TInput extends Record<string, unknown> = Record<string, unknown>, TResult = unknown> = {
|
|
2
|
+
description: string;
|
|
3
|
+
parameters: Record<string, unknown>;
|
|
4
|
+
execute(input: TInput): TResult | Promise<TResult>;
|
|
5
|
+
};
|
|
6
|
+
export type LiveVoiceTools = Record<string, LiveVoiceTool>;
|
|
7
|
+
export type LiveVoicePlatformVoice = "eve" | "ara" | "rex" | "sal" | "leo";
|
|
8
|
+
export type LiveVoiceConversationOptions = {
|
|
9
|
+
/** Instructions for the rhythm and social behavior of the conversation. */
|
|
10
|
+
prompt?: string;
|
|
11
|
+
/** Selects which participant opens the session. Defaults to the user. */
|
|
12
|
+
starts?: "user" | "assistant";
|
|
13
|
+
phases?: {
|
|
14
|
+
/** The phase active when the session begins. */
|
|
15
|
+
initial: string;
|
|
16
|
+
/** Instructions governing when the agent should move between phases. */
|
|
17
|
+
prompt?: string;
|
|
18
|
+
/** Developer-defined phases and the behavior appropriate to each one. */
|
|
19
|
+
definitions: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export type LiveVoiceConnectOptions = {
|
|
23
|
+
token: string;
|
|
24
|
+
/** Task, domain, persona, and tool-use instructions for the agent. */
|
|
25
|
+
prompt?: string;
|
|
26
|
+
/** Provider-neutral control over conversational behavior and phases. */
|
|
27
|
+
conversation?: LiveVoiceConversationOptions;
|
|
28
|
+
tools?: LiveVoiceTools;
|
|
29
|
+
endpoint?: string;
|
|
30
|
+
};
|
|
31
|
+
export type LiveVoiceCanonicalTurn = {
|
|
32
|
+
id: string;
|
|
33
|
+
role: "user" | "assistant";
|
|
34
|
+
text: string;
|
|
35
|
+
truncated?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export type LiveVoiceConnectionState = "connecting" | "live" | "closed" | "error";
|
|
38
|
+
export type LiveVoiceFloorState = {
|
|
39
|
+
owner: "USER" | "ASSISTANT";
|
|
40
|
+
assistantAudible: boolean;
|
|
41
|
+
};
|
|
42
|
+
export type LiveVoiceToolCall = {
|
|
43
|
+
callId: string;
|
|
44
|
+
name: string;
|
|
45
|
+
input: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
export type LiveVoicePhaseEvent = {
|
|
48
|
+
phase: string;
|
|
49
|
+
previousPhase?: string;
|
|
50
|
+
source: "initial" | "floor";
|
|
51
|
+
};
|
|
52
|
+
export type LiveVoiceEventMap = {
|
|
53
|
+
"connection": {
|
|
54
|
+
state: LiveVoiceConnectionState;
|
|
55
|
+
detail?: string;
|
|
56
|
+
};
|
|
57
|
+
"transcript.instantaneous": {
|
|
58
|
+
text: string;
|
|
59
|
+
};
|
|
60
|
+
"transcript.canonical": {
|
|
61
|
+
turns: LiveVoiceCanonicalTurn[];
|
|
62
|
+
};
|
|
63
|
+
"floor": LiveVoiceFloorState;
|
|
64
|
+
"phase": LiveVoicePhaseEvent;
|
|
65
|
+
"quick-reaction": {
|
|
66
|
+
reaction: string;
|
|
67
|
+
status: "prepared" | "playing" | "completed" | "cancelled";
|
|
68
|
+
};
|
|
69
|
+
"tool.call": LiveVoiceToolCall;
|
|
70
|
+
"tool.result": LiveVoiceToolCall & {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
output?: unknown;
|
|
73
|
+
error?: string;
|
|
74
|
+
};
|
|
75
|
+
"limit": {
|
|
76
|
+
reason: string;
|
|
77
|
+
durationSeconds?: number;
|
|
78
|
+
};
|
|
79
|
+
"error": {
|
|
80
|
+
code: string;
|
|
81
|
+
message: string;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
type EventName = keyof LiveVoiceEventMap;
|
|
85
|
+
type EventHandler<K extends EventName> = (event: LiveVoiceEventMap[K]) => void;
|
|
86
|
+
export declare class LiveVoiceSession {
|
|
87
|
+
private client;
|
|
88
|
+
private tools;
|
|
89
|
+
private listeners;
|
|
90
|
+
private previousPartial;
|
|
91
|
+
private previousCanonical;
|
|
92
|
+
private previousFloor;
|
|
93
|
+
private reactionStates;
|
|
94
|
+
private currentPhase?;
|
|
95
|
+
private constructor();
|
|
96
|
+
static connect(options: LiveVoiceConnectOptions): Promise<LiveVoiceSession>;
|
|
97
|
+
on<K extends EventName>(event: K, handler: EventHandler<K>): () => void;
|
|
98
|
+
get phase(): string | undefined;
|
|
99
|
+
stop(): void;
|
|
100
|
+
dispose(): void;
|
|
101
|
+
private emit;
|
|
102
|
+
private acceptApplicationEvent;
|
|
103
|
+
private acceptState;
|
|
104
|
+
private executeTool;
|
|
105
|
+
}
|
|
106
|
+
export default class LiveVoice {
|
|
107
|
+
static connect(options: LiveVoiceConnectOptions): Promise<LiveVoiceSession>;
|
|
108
|
+
}
|
|
109
|
+
export {};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { LiveVoiceConversationOptions, LiveVoiceEventMap, LiveVoicePlatformVoice, LiveVoiceTools } from "./public";
|
|
2
|
+
export type { LiveVoiceCanonicalTurn, LiveVoiceConversationOptions, LiveVoiceEventMap, LiveVoiceFloorState, LiveVoicePhaseEvent, LiveVoicePlatformVoice, LiveVoiceTool, LiveVoiceToolCall, LiveVoiceTools, } from "./public";
|
|
3
|
+
export type LiveVoicePcmFormat = {
|
|
4
|
+
encoding: "pcm_s16le";
|
|
5
|
+
sampleRate: 24_000;
|
|
6
|
+
channels: 1;
|
|
7
|
+
};
|
|
8
|
+
export declare const liveVoicePcmFormat: LiveVoicePcmFormat;
|
|
9
|
+
export type LiveVoiceAudioOutputCommand =
|
|
10
|
+
/** Append PCM in arrival order. `buffer` is inaudible until release; `play` is immediately eligible for playback. */
|
|
11
|
+
{
|
|
12
|
+
type: "enqueue";
|
|
13
|
+
generationId: string;
|
|
14
|
+
lane: "FOREGROUND" | "REACTION";
|
|
15
|
+
pcm: Uint8Array;
|
|
16
|
+
mode: "buffer" | "play";
|
|
17
|
+
gain: number;
|
|
18
|
+
}
|
|
19
|
+
/** Prevent this foreground generation from becoming audible while its speculative audio arrives. */
|
|
20
|
+
| {
|
|
21
|
+
type: "block-foreground";
|
|
22
|
+
generationId: string;
|
|
23
|
+
}
|
|
24
|
+
/** Make the buffered generation eligible for playback at the supplied gain. */
|
|
25
|
+
| {
|
|
26
|
+
type: "release";
|
|
27
|
+
generationId: string;
|
|
28
|
+
gain: number;
|
|
29
|
+
}
|
|
30
|
+
/** Stop this generation and discard all of its queued PCM. */
|
|
31
|
+
| {
|
|
32
|
+
type: "cancel";
|
|
33
|
+
generationId: string;
|
|
34
|
+
}
|
|
35
|
+
/** No more PCM will arrive for this generation; completion still waits for the playback queue to drain. */
|
|
36
|
+
| {
|
|
37
|
+
type: "done";
|
|
38
|
+
generationId: string;
|
|
39
|
+
lane: "FOREGROUND" | "REACTION";
|
|
40
|
+
};
|
|
41
|
+
export type LiveVoicePlaybackReceipt = {
|
|
42
|
+
event: "started" | "completed" | "cancelled";
|
|
43
|
+
lane: "FOREGROUND" | "REACTION";
|
|
44
|
+
generationId: string;
|
|
45
|
+
/** Samples actually played, expressed at the native 24 kHz format. */
|
|
46
|
+
playedSamples: number;
|
|
47
|
+
};
|
|
48
|
+
export type LiveVoiceAudioTransportHandlers = {
|
|
49
|
+
/** Supply mono PCM16LE at 24 kHz. Frame boundaries do not imply conversational boundaries. */
|
|
50
|
+
input(pcm: Uint8Array): void;
|
|
51
|
+
/** Report the first audible sample and exactly one terminal outcome for each audible generation. */
|
|
52
|
+
receipt(receipt: LiveVoicePlaybackReceipt): void;
|
|
53
|
+
/** End the Live Voice session because the caller-side transport closed. */
|
|
54
|
+
close(reason?: string): void;
|
|
55
|
+
error(error: Error): void;
|
|
56
|
+
};
|
|
57
|
+
export interface LiveVoiceAudioTransport {
|
|
58
|
+
/** SDK-facing format after any provider-specific decoding or resampling performed by the adapter. */
|
|
59
|
+
readonly format: LiveVoicePcmFormat;
|
|
60
|
+
/** Attach the session callbacks and make the transport ready to accept output commands. */
|
|
61
|
+
start(handlers: LiveVoiceAudioTransportHandlers): void | Promise<void>;
|
|
62
|
+
/** Apply commands in call order; the transport owns only PCM playback, never conversational state. */
|
|
63
|
+
output(command: LiveVoiceAudioOutputCommand): void | Promise<void>;
|
|
64
|
+
stop(): void | Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
type LiveVoiceServerAuthorization = {
|
|
67
|
+
token: string;
|
|
68
|
+
apiKey?: never;
|
|
69
|
+
} | {
|
|
70
|
+
apiKey: string;
|
|
71
|
+
token?: never;
|
|
72
|
+
};
|
|
73
|
+
export type LiveVoiceServerConnectOptions = LiveVoiceServerAuthorization & {
|
|
74
|
+
audio: LiveVoiceAudioTransport;
|
|
75
|
+
prompt?: string;
|
|
76
|
+
conversation?: LiveVoiceConversationOptions;
|
|
77
|
+
tools?: LiveVoiceTools;
|
|
78
|
+
/** Built-in voice selected when this SDK creates a session with `apiKey`. */
|
|
79
|
+
voice?: LiveVoicePlatformVoice;
|
|
80
|
+
/** Override the Live Voice WebSocket URL. */
|
|
81
|
+
endpoint?: string;
|
|
82
|
+
/** Override the HTTP API origin used with apiKey. */
|
|
83
|
+
apiBaseUrl?: string;
|
|
84
|
+
};
|
|
85
|
+
type EventName = keyof LiveVoiceEventMap;
|
|
86
|
+
type EventHandler<K extends EventName> = (event: LiveVoiceEventMap[K]) => void;
|
|
87
|
+
export declare class LiveVoiceServerSession {
|
|
88
|
+
private socket;
|
|
89
|
+
private audio;
|
|
90
|
+
private tools;
|
|
91
|
+
readonly closed: Promise<void>;
|
|
92
|
+
private resolveClosed;
|
|
93
|
+
private listeners;
|
|
94
|
+
private previousPartial;
|
|
95
|
+
private previousCanonical;
|
|
96
|
+
private previousFloor;
|
|
97
|
+
private reactionStates;
|
|
98
|
+
private state?;
|
|
99
|
+
private stopped;
|
|
100
|
+
private prepared?;
|
|
101
|
+
private constructor();
|
|
102
|
+
static connect(options: LiveVoiceServerConnectOptions): Promise<LiveVoiceServerSession>;
|
|
103
|
+
on<K extends EventName>(event: K, handler: EventHandler<K>): () => void;
|
|
104
|
+
get phase(): string | undefined;
|
|
105
|
+
stop(): void;
|
|
106
|
+
dispose(): void;
|
|
107
|
+
private bindSocket;
|
|
108
|
+
private prepare;
|
|
109
|
+
private onMessage;
|
|
110
|
+
private writeInput;
|
|
111
|
+
private sendReceipt;
|
|
112
|
+
private executeTool;
|
|
113
|
+
private acceptState;
|
|
114
|
+
private acceptApplicationEvent;
|
|
115
|
+
private emit;
|
|
116
|
+
private finish;
|
|
117
|
+
}
|
|
118
|
+
export default class LiveVoiceServer {
|
|
119
|
+
static connect(options: LiveVoiceServerConnectOptions): Promise<LiveVoiceServerSession>;
|
|
120
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
var liveVoicePcmFormat = {
|
|
3
|
+
encoding: "pcm_s16le",
|
|
4
|
+
sampleRate: 24000,
|
|
5
|
+
channels: 1
|
|
6
|
+
};
|
|
7
|
+
var defaultApiBaseUrl = "https://inkibra-voice.inkibra.workers.dev";
|
|
8
|
+
var defaultEndpoint = "wss://inkibra-voice.inkibra.workers.dev/v1/live";
|
|
9
|
+
|
|
10
|
+
class LiveVoiceServerSession {
|
|
11
|
+
socket;
|
|
12
|
+
audio;
|
|
13
|
+
tools;
|
|
14
|
+
closed;
|
|
15
|
+
resolveClosed;
|
|
16
|
+
listeners = new Map;
|
|
17
|
+
stopped = false;
|
|
18
|
+
prepared;
|
|
19
|
+
started;
|
|
20
|
+
currentPhase;
|
|
21
|
+
acceptingInput = false;
|
|
22
|
+
constructor(socket, audio, tools) {
|
|
23
|
+
this.socket = socket;
|
|
24
|
+
this.audio = audio;
|
|
25
|
+
this.tools = tools;
|
|
26
|
+
this.closed = new Promise((resolve) => {
|
|
27
|
+
this.resolveClosed = resolve;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
static async connect(options) {
|
|
31
|
+
validateAudioFormat(options.audio.format);
|
|
32
|
+
const credentials = await resolveSessionCredentials(options);
|
|
33
|
+
const socket = new WebSocket(withSessionToken(`${credentials.endpoint.replace(/\/$/u, "")}/${crypto.randomUUID()}`, credentials.token));
|
|
34
|
+
const session = new LiveVoiceServerSession(socket, options.audio, options.tools ?? {});
|
|
35
|
+
session.bindSocket();
|
|
36
|
+
session.emit("connection", { state: "connecting" });
|
|
37
|
+
await waitForOpen(socket);
|
|
38
|
+
try {
|
|
39
|
+
await session.prepare();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
session.finish(error instanceof Error ? error.message : String(error));
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await options.audio.start({
|
|
46
|
+
input: (pcm) => session.writeInput(pcm),
|
|
47
|
+
receipt: (receipt) => session.sendReceipt(receipt),
|
|
48
|
+
close: (reason) => session.finish(reason),
|
|
49
|
+
error: (error) => {
|
|
50
|
+
session.emit("error", { code: "AUDIO_TRANSPORT_ERROR", message: error.message });
|
|
51
|
+
session.finish(error.message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
session.finish(error instanceof Error ? error.message : String(error));
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const started = new Promise((resolve, reject) => {
|
|
59
|
+
session.started = { resolve, reject };
|
|
60
|
+
});
|
|
61
|
+
socket.send(JSON.stringify({
|
|
62
|
+
type: "session.start",
|
|
63
|
+
developer: {
|
|
64
|
+
prompt: options.prompt ?? "",
|
|
65
|
+
conversation: options.conversation,
|
|
66
|
+
tools: Object.entries(options.tools ?? {}).map(([name, tool]) => ({
|
|
67
|
+
type: "function",
|
|
68
|
+
name,
|
|
69
|
+
description: tool.description,
|
|
70
|
+
parameters: tool.parameters
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
try {
|
|
75
|
+
await started;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
session.finish(error instanceof Error ? error.message : String(error));
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
session.acceptingInput = true;
|
|
81
|
+
session.emit("connection", { state: "live" });
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
on(event, handler) {
|
|
85
|
+
const handlers = this.listeners.get(event) ?? new Set;
|
|
86
|
+
handlers.add(handler);
|
|
87
|
+
this.listeners.set(event, handlers);
|
|
88
|
+
return () => handlers.delete(handler);
|
|
89
|
+
}
|
|
90
|
+
get phase() {
|
|
91
|
+
return this.currentPhase;
|
|
92
|
+
}
|
|
93
|
+
stop() {
|
|
94
|
+
this.finish("stopped", true);
|
|
95
|
+
}
|
|
96
|
+
dispose() {
|
|
97
|
+
this.stop();
|
|
98
|
+
this.listeners.clear();
|
|
99
|
+
}
|
|
100
|
+
bindSocket() {
|
|
101
|
+
this.socket.addEventListener("message", (event) => this.onMessage(event.data));
|
|
102
|
+
this.socket.addEventListener("close", () => this.finish("Live Voice connection closed."));
|
|
103
|
+
this.socket.addEventListener("error", () => {
|
|
104
|
+
const error = new Error("Live Voice WebSocket failed.");
|
|
105
|
+
this.prepared?.reject(error);
|
|
106
|
+
this.emit("error", { code: "CONNECTION_ERROR", message: error.message });
|
|
107
|
+
this.finish(error.message);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
prepare() {
|
|
111
|
+
const promise = new Promise((resolve, reject) => {
|
|
112
|
+
this.prepared = { resolve, reject };
|
|
113
|
+
});
|
|
114
|
+
this.socket.send(JSON.stringify({ type: "session.prepare", protocolVersion: 1 }));
|
|
115
|
+
return promise;
|
|
116
|
+
}
|
|
117
|
+
onMessage(raw) {
|
|
118
|
+
if (typeof raw !== "string")
|
|
119
|
+
return;
|
|
120
|
+
let message;
|
|
121
|
+
try {
|
|
122
|
+
message = JSON.parse(raw);
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const type = String(message.type ?? "");
|
|
127
|
+
if (type === "session.prepared") {
|
|
128
|
+
this.prepared?.resolve();
|
|
129
|
+
this.prepared = undefined;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (type === "session.prepare_failed") {
|
|
133
|
+
const error = new Error(String(message.message ?? "Session preparation failed."));
|
|
134
|
+
this.prepared?.reject(error);
|
|
135
|
+
this.prepared = undefined;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (type === "session.started") {
|
|
139
|
+
this.started?.resolve();
|
|
140
|
+
this.started = undefined;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (type === "public.event") {
|
|
144
|
+
this.acceptPublicEvent(String(message.name ?? "unknown"), asRecord(message.payload), Number.isFinite(Number(message.atMs)) ? Number(message.atMs) : undefined);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (type === "tool.call") {
|
|
148
|
+
this.executeTool({
|
|
149
|
+
callId: String(message.callId ?? ""),
|
|
150
|
+
name: String(message.name ?? ""),
|
|
151
|
+
input: asRecord(message.input)
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (type === "session.limit") {
|
|
156
|
+
this.emit("limit", {
|
|
157
|
+
reason: String(message.reason ?? "SESSION_LIMIT"),
|
|
158
|
+
...Number.isFinite(Number(message.durationSeconds)) ? { durationSeconds: Number(message.durationSeconds) } : {}
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (type === "output.block") {
|
|
163
|
+
this.audio.output({ type: "block-foreground", generationId: String(message.generationId ?? "") });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (type === "output.audio" && typeof message.delta === "string") {
|
|
167
|
+
this.audio.output({
|
|
168
|
+
type: "enqueue",
|
|
169
|
+
generationId: String(message.generationId ?? ""),
|
|
170
|
+
lane: message.queue === "reaction" ? "REACTION" : "FOREGROUND",
|
|
171
|
+
pcm: base64Bytes(message.delta),
|
|
172
|
+
mode: message.mode === "play" ? "play" : "buffer",
|
|
173
|
+
gain: clamp01(Number(message.gain ?? 1))
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (type === "output.release") {
|
|
178
|
+
this.audio.output({ type: "release", generationId: String(message.generationId ?? ""), gain: clamp01(Number(message.gain ?? 1)) });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (type === "output.cancel") {
|
|
182
|
+
this.audio.output({ type: "cancel", generationId: String(message.generationId ?? "") });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (type === "output.done") {
|
|
186
|
+
this.audio.output({
|
|
187
|
+
type: "done",
|
|
188
|
+
generationId: String(message.generationId ?? ""),
|
|
189
|
+
lane: message.queue === "reaction" ? "REACTION" : "FOREGROUND"
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.emit("unknown", { message });
|
|
194
|
+
}
|
|
195
|
+
writeInput(pcm) {
|
|
196
|
+
if (this.stopped || !this.acceptingInput || this.socket.readyState !== WebSocket.OPEN)
|
|
197
|
+
return;
|
|
198
|
+
if (pcm.byteLength % 2 !== 0) {
|
|
199
|
+
this.emit("error", { code: "INVALID_AUDIO_FRAME", message: "PCM16 input must contain complete 16-bit samples." });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.socket.send(pcm);
|
|
203
|
+
}
|
|
204
|
+
sendReceipt(receipt) {
|
|
205
|
+
if (this.stopped || this.socket.readyState !== WebSocket.OPEN)
|
|
206
|
+
return;
|
|
207
|
+
this.socket.send(JSON.stringify({ type: "output.receipt", ...receipt }));
|
|
208
|
+
}
|
|
209
|
+
async executeTool(call) {
|
|
210
|
+
if (!call.callId || !call.name || this.socket.readyState !== WebSocket.OPEN)
|
|
211
|
+
return;
|
|
212
|
+
this.emit("tool.call", call);
|
|
213
|
+
const tool = this.tools[call.name];
|
|
214
|
+
if (!tool) {
|
|
215
|
+
const error = `No server handler is registered for tool ${call.name}`;
|
|
216
|
+
this.socket.send(JSON.stringify({ type: "tool.result", callId: call.callId, ok: false, error }));
|
|
217
|
+
this.emit("tool.result", { ...call, ok: false, error });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const output = await tool.execute(call.input);
|
|
222
|
+
this.socket.send(JSON.stringify({ type: "tool.result", callId: call.callId, ok: true, output: output ?? null }));
|
|
223
|
+
this.emit("tool.result", { ...call, ok: true, output });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
this.socket.send(JSON.stringify({ type: "tool.result", callId: call.callId, ok: false, error: message }));
|
|
227
|
+
this.emit("tool.result", { ...call, ok: false, error: message });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
acceptPublicEvent(name, payload, atMs) {
|
|
231
|
+
if (name === "transcript.instantaneous" && typeof payload.text === "string") {
|
|
232
|
+
this.emit("transcript.instantaneous", { text: payload.text });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (name === "transcript.canonical" && Array.isArray(payload.turns)) {
|
|
236
|
+
const turns = [];
|
|
237
|
+
for (const value of payload.turns) {
|
|
238
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
239
|
+
continue;
|
|
240
|
+
const turn = value;
|
|
241
|
+
const role = turn.role;
|
|
242
|
+
if (typeof turn.id !== "string" || role !== "user" && role !== "assistant" || typeof turn.text !== "string")
|
|
243
|
+
continue;
|
|
244
|
+
turns.push({
|
|
245
|
+
id: turn.id,
|
|
246
|
+
role,
|
|
247
|
+
text: turn.text,
|
|
248
|
+
...typeof turn.truncated === "boolean" ? { truncated: turn.truncated } : {}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
this.emit("transcript.canonical", { turns });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (name === "floor" && (payload.owner === "USER" || payload.owner === "ASSISTANT") && typeof payload.assistantAudible === "boolean") {
|
|
255
|
+
this.emit("floor", { owner: payload.owner, assistantAudible: payload.assistantAudible });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (name === "phase" && typeof payload.phase === "string") {
|
|
259
|
+
const event = {
|
|
260
|
+
phase: payload.phase,
|
|
261
|
+
source: payload.source === "floor" ? "floor" : "initial",
|
|
262
|
+
...typeof payload.previousPhase === "string" ? { previousPhase: payload.previousPhase } : {}
|
|
263
|
+
};
|
|
264
|
+
this.currentPhase = event.phase;
|
|
265
|
+
this.emit("phase", event);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (name === "quick-reaction" && typeof payload.reaction === "string" && (payload.status === "prepared" || payload.status === "playing" || payload.status === "completed" || payload.status === "cancelled")) {
|
|
269
|
+
this.emit("quick-reaction", { reaction: payload.reaction, status: payload.status });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (name === "assistant.energy" && Number.isFinite(Number(payload.current)) && Array.isArray(payload.predictions)) {
|
|
273
|
+
const predictions = [];
|
|
274
|
+
let valid = true;
|
|
275
|
+
for (const value of payload.predictions) {
|
|
276
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
277
|
+
valid = false;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
const prediction = value;
|
|
281
|
+
const startMs = Number(prediction.startMs);
|
|
282
|
+
const endMs = Number(prediction.endMs);
|
|
283
|
+
const energy = Number(prediction.energy);
|
|
284
|
+
if (!Number.isFinite(startMs) || startMs < 0 || !Number.isFinite(endMs) || endMs <= startMs || !Number.isFinite(energy)) {
|
|
285
|
+
valid = false;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
predictions.push({ startMs, endMs, energy: clamp01(energy) });
|
|
289
|
+
}
|
|
290
|
+
if (valid) {
|
|
291
|
+
this.emit("assistant.energy", { current: clamp01(Number(payload.current)), predictions });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (name === "error" && typeof payload.code === "string" && typeof payload.message === "string") {
|
|
296
|
+
this.emit("error", { code: payload.code, message: payload.message });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.emit("unknown", { message: { type: "public.event", name, payload, ...atMs === undefined ? {} : { atMs } } });
|
|
300
|
+
}
|
|
301
|
+
emit(event, value) {
|
|
302
|
+
for (const handler of this.listeners.get(event) ?? [])
|
|
303
|
+
handler(value);
|
|
304
|
+
}
|
|
305
|
+
finish(reason, notifyServer = false) {
|
|
306
|
+
if (this.stopped)
|
|
307
|
+
return;
|
|
308
|
+
this.stopped = true;
|
|
309
|
+
const ended = new Error(reason ?? "Session ended.");
|
|
310
|
+
this.prepared?.reject(ended);
|
|
311
|
+
this.prepared = undefined;
|
|
312
|
+
this.started?.reject(ended);
|
|
313
|
+
this.started = undefined;
|
|
314
|
+
if (notifyServer && this.socket.readyState === WebSocket.OPEN)
|
|
315
|
+
this.socket.send(JSON.stringify({ type: "session.stop" }));
|
|
316
|
+
if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)
|
|
317
|
+
this.socket.close(1000, reason ?? "stopped");
|
|
318
|
+
this.audio.stop();
|
|
319
|
+
this.emit("connection", { state: "closed", ...reason ? { detail: reason } : {} });
|
|
320
|
+
this.resolveClosed();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
class LiveVoiceServer {
|
|
325
|
+
static connect(options) {
|
|
326
|
+
return LiveVoiceServerSession.connect(options);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function resolveSessionCredentials(options) {
|
|
330
|
+
if (Boolean(options.token) === Boolean(options.apiKey))
|
|
331
|
+
throw new Error("Supply exactly one of token or apiKey.");
|
|
332
|
+
if (options.token)
|
|
333
|
+
return { token: options.token, endpoint: options.endpoint ?? defaultEndpoint };
|
|
334
|
+
const apiBaseUrl = (options.apiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/u, "");
|
|
335
|
+
const response = await fetch(`${apiBaseUrl}/v1/sessions`, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
|
|
338
|
+
body: JSON.stringify(options.voice ? { voice: options.voice } : {})
|
|
339
|
+
});
|
|
340
|
+
const result = await response.json();
|
|
341
|
+
if (!response.ok || typeof result.token !== "string")
|
|
342
|
+
throw new Error(String(result.error ?? `Unable to create Live Voice session (${response.status}).`));
|
|
343
|
+
return {
|
|
344
|
+
token: result.token,
|
|
345
|
+
endpoint: options.endpoint ?? (typeof result.realtimeUrl === "string" ? result.realtimeUrl : defaultEndpoint)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function validateAudioFormat(format) {
|
|
349
|
+
if (!format)
|
|
350
|
+
throw new Error("Audio transports must declare their SDK-facing PCM format.");
|
|
351
|
+
if (format.encoding !== liveVoicePcmFormat.encoding || format.sampleRate !== liveVoicePcmFormat.sampleRate || format.channels !== liveVoicePcmFormat.channels) {
|
|
352
|
+
throw new Error("Audio transports must expose mono PCM16LE at 24 kHz.");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function waitForOpen(socket) {
|
|
356
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
357
|
+
return Promise.resolve();
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const opened = () => finish();
|
|
360
|
+
const failed = () => finish(new Error("Live Voice WebSocket failed while connecting."));
|
|
361
|
+
const finish = (error) => {
|
|
362
|
+
socket.removeEventListener("open", opened);
|
|
363
|
+
socket.removeEventListener("error", failed);
|
|
364
|
+
if (error)
|
|
365
|
+
reject(error);
|
|
366
|
+
else
|
|
367
|
+
resolve();
|
|
368
|
+
};
|
|
369
|
+
socket.addEventListener("open", opened, { once: true });
|
|
370
|
+
socket.addEventListener("error", failed, { once: true });
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function withSessionToken(value, token) {
|
|
374
|
+
const url = new URL(value);
|
|
375
|
+
url.searchParams.set("token", token);
|
|
376
|
+
return url.toString();
|
|
377
|
+
}
|
|
378
|
+
function base64Bytes(encoded) {
|
|
379
|
+
const binary = atob(encoded);
|
|
380
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
381
|
+
}
|
|
382
|
+
function clamp01(value) {
|
|
383
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
|
|
384
|
+
}
|
|
385
|
+
function asRecord(value) {
|
|
386
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
387
|
+
}
|
|
388
|
+
export {
|
|
389
|
+
LiveVoiceServerSession,
|
|
390
|
+
LiveVoiceServer as default,
|
|
391
|
+
liveVoicePcmFormat
|
|
392
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inkibra/voice-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser and server SDK for Inkibra Live Voice",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": { "type": "git", "url": "git+https://github.com/inkibra/voice.git", "directory": "packages/sdk" },
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./server": {
|
|
17
|
+
"types": "./dist/server.d.ts",
|
|
18
|
+
"import": "./dist/server.js",
|
|
19
|
+
"default": "./dist/server.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/index.js",
|
|
24
|
+
"dist/index.d.ts",
|
|
25
|
+
"dist/public.d.ts",
|
|
26
|
+
"dist/server.js",
|
|
27
|
+
"dist/server.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"publishConfig": { "access": "public" },
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun build src/index.ts --outdir dist --format esm --target browser && bun build src/server.ts --outdir dist --format esm --target node && tsc -p tsconfig.build.json && rm -rf dist/sdk dist/protocol",
|
|
33
|
+
"prepack": "bun run build"
|
|
34
|
+
}
|
|
35
|
+
}
|