@inkibra/voice-sdk 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +116 -36
- package/dist/client.d.ts +18 -0
- package/dist/control.d.ts +77 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +803 -537
- package/dist/participant.d.ts +75 -0
- package/dist/public.d.ts +36 -102
- package/dist/server.d.ts +63 -63
- package/dist/server.js +653 -298
- package/dist/session-protocol.d.ts +103 -0
- package/package.json +6 -2
package/dist/server.js
CHANGED
|
@@ -1,391 +1,746 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
1
|
+
// src/control.ts
|
|
2
|
+
class ControlClient {
|
|
3
|
+
endpoint;
|
|
4
|
+
callbacks;
|
|
11
5
|
socket;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
constructor(
|
|
6
|
+
sessionToken;
|
|
7
|
+
snapshotValue;
|
|
8
|
+
waits = new Map;
|
|
9
|
+
executions = new Map;
|
|
10
|
+
requestCounter = 0;
|
|
11
|
+
prepared = false;
|
|
12
|
+
preparing;
|
|
13
|
+
starting;
|
|
14
|
+
started = false;
|
|
15
|
+
terminal = false;
|
|
16
|
+
constructor(endpoint, callbacks) {
|
|
17
|
+
this.endpoint = endpoint;
|
|
18
|
+
this.callbacks = callbacks;
|
|
19
|
+
}
|
|
20
|
+
ratingUrl(sessionId) {
|
|
21
|
+
const url = new URL(this.endpoint);
|
|
22
|
+
url.protocol = url.protocol === "wss:" ? "https:" : url.protocol === "ws:" ? "http:" : url.protocol;
|
|
23
|
+
return `${url.origin}/v1/sessions/${encodeURIComponent(sessionId)}/rating`;
|
|
24
|
+
}
|
|
25
|
+
setCallbacks(callbacks) {
|
|
26
|
+
this.callbacks = callbacks;
|
|
27
|
+
}
|
|
28
|
+
get currentSnapshot() {
|
|
29
|
+
return this.snapshotValue;
|
|
30
|
+
}
|
|
31
|
+
retainedTaskIds() {
|
|
32
|
+
return [...this.executions.keys()];
|
|
33
|
+
}
|
|
34
|
+
async open(options) {
|
|
35
|
+
if (!options.token)
|
|
36
|
+
throw new Error("A Live Voice token is required.");
|
|
37
|
+
if (this.terminal)
|
|
38
|
+
throw new Error("This participant has been disposed or stopped.");
|
|
39
|
+
if (this.waits.has("open"))
|
|
40
|
+
throw new Error("A connection is already opening.");
|
|
41
|
+
this.closeSocket("replaced", false);
|
|
42
|
+
this.sessionToken = options.token;
|
|
43
|
+
this.callbacks.onConnection("connecting");
|
|
44
|
+
const url = new URL(`${this.endpoint.replace(/\/$/u, "")}/${encodeURIComponent(options.sessionId ?? crypto.randomUUID())}`);
|
|
45
|
+
url.searchParams.set("token", options.token);
|
|
46
|
+
const socket = new WebSocket(url);
|
|
23
47
|
this.socket = socket;
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
const opening = this.exchange("open", 15000, () => {
|
|
49
|
+
const send = () => {
|
|
50
|
+
if (this.socket !== socket)
|
|
51
|
+
return;
|
|
52
|
+
this.send({
|
|
53
|
+
type: "session.open",
|
|
54
|
+
protocolVersion: 2,
|
|
55
|
+
mode: options.mode,
|
|
56
|
+
...options.sessionId ? { sessionId: options.sessionId } : {},
|
|
57
|
+
...options.participantId ? { participantId: options.participantId } : {},
|
|
58
|
+
tools: Object.entries(options.tools ?? {}).map(([name, tool]) => ({ name, description: tool.description, parameters: tool.parameters })),
|
|
59
|
+
...options.developer === undefined ? {} : { developer: options.developer },
|
|
60
|
+
...options.mediaMode ? { mediaMode: options.mediaMode } : {},
|
|
61
|
+
retainedTaskIds: options.retainedTaskIds ?? this.retainedTaskIds()
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
65
|
+
send();
|
|
66
|
+
else
|
|
67
|
+
socket.addEventListener("open", send, { once: true });
|
|
68
|
+
});
|
|
69
|
+
socket.addEventListener("message", (event) => {
|
|
70
|
+
if (this.socket === socket)
|
|
71
|
+
this.onMessage(event.data);
|
|
72
|
+
});
|
|
73
|
+
socket.addEventListener("close", (event) => {
|
|
74
|
+
if (this.socket === socket)
|
|
75
|
+
this.closeSocket(event.reason || "Connection lost", false);
|
|
76
|
+
});
|
|
77
|
+
socket.addEventListener("error", () => {
|
|
78
|
+
if (this.socket !== socket)
|
|
79
|
+
return;
|
|
80
|
+
this.callbacks.onConnection("error", "Live Voice WebSocket failed.");
|
|
81
|
+
this.closeSocket("Live Voice WebSocket failed.", false);
|
|
28
82
|
});
|
|
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
83
|
try {
|
|
39
|
-
await
|
|
84
|
+
return await opening;
|
|
40
85
|
} catch (error) {
|
|
41
|
-
|
|
86
|
+
if (this.socket === socket)
|
|
87
|
+
this.closeSocket("Connection opening failed", false);
|
|
42
88
|
throw error;
|
|
43
89
|
}
|
|
90
|
+
}
|
|
91
|
+
async prepare() {
|
|
92
|
+
if (this.prepared)
|
|
93
|
+
return;
|
|
94
|
+
if (this.preparing)
|
|
95
|
+
return this.preparing;
|
|
96
|
+
this.preparing = this.exchange("prepare", 90000, () => this.send({ type: "session.prepare", protocolVersion: 2 })).then(() => {
|
|
97
|
+
this.prepared = true;
|
|
98
|
+
});
|
|
44
99
|
try {
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
100
|
+
await this.preparing;
|
|
101
|
+
} finally {
|
|
102
|
+
this.preparing = undefined;
|
|
57
103
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}));
|
|
104
|
+
}
|
|
105
|
+
async start(options = {}) {
|
|
106
|
+
if (this.started)
|
|
107
|
+
return;
|
|
108
|
+
if (this.starting)
|
|
109
|
+
return this.starting;
|
|
110
|
+
const socket = this.socket;
|
|
111
|
+
this.starting = (async () => {
|
|
112
|
+
await this.prepare();
|
|
113
|
+
if (!socket || this.socket !== socket)
|
|
114
|
+
throw new Error("Connection changed during startup.");
|
|
115
|
+
await this.exchange("start", 90000, () => this.send({ type: "session.start", mediaMode: options.mediaMode }));
|
|
116
|
+
this.started = true;
|
|
117
|
+
this.callbacks.onConnection("live");
|
|
118
|
+
})();
|
|
74
119
|
try {
|
|
75
|
-
await
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
throw error;
|
|
120
|
+
await this.starting;
|
|
121
|
+
} finally {
|
|
122
|
+
this.starting = undefined;
|
|
79
123
|
}
|
|
80
|
-
session.acceptingInput = true;
|
|
81
|
-
session.emit("connection", { state: "live" });
|
|
82
|
-
return session;
|
|
83
124
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
125
|
+
async stop() {
|
|
126
|
+
if (this.snapshotValue?.status === "stopped") {
|
|
127
|
+
this.closeSocket("stopped", true);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.exchange("stop", 5000, () => this.send({ type: "session.stop" }));
|
|
132
|
+
} finally {
|
|
133
|
+
this.closeSocket("stopped", true);
|
|
134
|
+
}
|
|
89
135
|
}
|
|
90
|
-
|
|
91
|
-
|
|
136
|
+
async detach() {
|
|
137
|
+
if (!this.socket)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
await this.exchange("detach", 5000, () => this.send({ type: "participant.detach" }));
|
|
141
|
+
} finally {
|
|
142
|
+
this.closeSocket("detached", false);
|
|
143
|
+
}
|
|
92
144
|
}
|
|
93
|
-
|
|
94
|
-
this.
|
|
145
|
+
closeSocket(reason = "closed", terminal = true) {
|
|
146
|
+
const socket = this.socket;
|
|
147
|
+
this.socket = undefined;
|
|
148
|
+
this.prepared = false;
|
|
149
|
+
this.started = false;
|
|
150
|
+
this.terminal ||= terminal;
|
|
151
|
+
const error = new Error(reason);
|
|
152
|
+
for (const wait of this.waits.values())
|
|
153
|
+
wait.reject(error);
|
|
154
|
+
this.waits.clear();
|
|
155
|
+
if (terminal)
|
|
156
|
+
for (const execution of this.executions.values()) {
|
|
157
|
+
execution.controller.abort(error);
|
|
158
|
+
for (const wait of execution.pending.values())
|
|
159
|
+
wait.reject(error);
|
|
160
|
+
execution.pending.clear();
|
|
161
|
+
}
|
|
162
|
+
if (socket) {
|
|
163
|
+
if (this.snapshotValue?.participantId === "owner" && this.snapshotValue.status === "running") {
|
|
164
|
+
this.snapshotValue = { ...this.snapshotValue, status: "suspended", floor: { owner: "USER", assistantAudible: false } };
|
|
165
|
+
}
|
|
166
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
|
|
167
|
+
socket.close(1000, reason.slice(0, 100));
|
|
168
|
+
this.callbacks.onConnection("closed", reason);
|
|
169
|
+
}
|
|
95
170
|
}
|
|
96
171
|
dispose() {
|
|
97
|
-
this.
|
|
98
|
-
|
|
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
|
-
});
|
|
172
|
+
if (this.socket?.readyState === WebSocket.OPEN)
|
|
173
|
+
this.send({ type: this.snapshotValue?.participantId === "owner" ? "session.stop" : "participant.detach" });
|
|
174
|
+
this.closeSocket("disposed", true);
|
|
109
175
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return
|
|
176
|
+
reconnect(options) {
|
|
177
|
+
return this.open({ ...options, token: options.token ?? this.sessionToken ?? "", mode: "reconnect", retainedTaskIds: this.retainedTaskIds() });
|
|
178
|
+
}
|
|
179
|
+
async requestTask(operation, taskId, resolution) {
|
|
180
|
+
const requestId = String(++this.requestCounter);
|
|
181
|
+
return await this.exchange(`task:${requestId}`, 15000, () => this.send({ type: "task.request", requestId, operation, ...taskId ? { taskId } : {}, ...resolution ? { resolution } : {} }));
|
|
182
|
+
}
|
|
183
|
+
playUiCue(cue) {
|
|
184
|
+
this.send({ type: "cue.request", cue });
|
|
185
|
+
}
|
|
186
|
+
send(message) {
|
|
187
|
+
if (this.socket?.readyState !== WebSocket.OPEN)
|
|
188
|
+
throw new Error("Session is not connected.");
|
|
189
|
+
if (message instanceof Uint8Array) {
|
|
190
|
+
this.socket.send(message.buffer instanceof ArrayBuffer ? message : message.slice());
|
|
191
|
+
} else
|
|
192
|
+
this.socket.send(serialize(message));
|
|
193
|
+
}
|
|
194
|
+
async exchange(key, timeoutMs, send) {
|
|
195
|
+
if (this.waits.has(key))
|
|
196
|
+
throw new Error(`Operation already pending: ${key}`);
|
|
197
|
+
const wait = deferred();
|
|
198
|
+
this.waits.set(key, wait);
|
|
199
|
+
const timer = setTimeout(() => wait.reject(new Error(`Live Voice ${key} acknowledgement timed out.`)), timeoutMs);
|
|
200
|
+
try {
|
|
201
|
+
send();
|
|
202
|
+
return await wait.promise;
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
this.waits.delete(key);
|
|
206
|
+
}
|
|
116
207
|
}
|
|
117
208
|
onMessage(raw) {
|
|
118
209
|
if (typeof raw !== "string")
|
|
119
210
|
return;
|
|
120
211
|
let message;
|
|
121
212
|
try {
|
|
122
|
-
|
|
213
|
+
const value = JSON.parse(raw);
|
|
214
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
215
|
+
return;
|
|
216
|
+
message = value;
|
|
123
217
|
} catch {
|
|
124
218
|
return;
|
|
125
219
|
}
|
|
126
220
|
const type = String(message.type ?? "");
|
|
127
|
-
if (type === "session.
|
|
128
|
-
|
|
129
|
-
|
|
221
|
+
if (type === "session.opened") {
|
|
222
|
+
const snapshot = message.snapshot;
|
|
223
|
+
if (!snapshot || typeof snapshot.sessionId !== "string" || typeof snapshot.connectionId !== "string" || typeof snapshot.participantId !== "string" || !Number.isSafeInteger(snapshot.sequence) || !Array.isArray(snapshot.tasks) || !snapshot.transcript || !snapshot.floor) {
|
|
224
|
+
this.waits.get("open")?.reject(new Error("Session opened without a valid snapshot."));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.snapshotValue = snapshot;
|
|
228
|
+
this.callbacks.onSnapshot?.(snapshot);
|
|
229
|
+
this.waits.get("open")?.resolve(snapshot);
|
|
230
|
+
for (const execution of this.executions.values())
|
|
231
|
+
for (const pending of execution.pending.values())
|
|
232
|
+
this.socket?.send(pending.serialized);
|
|
233
|
+
this.callbacks.onConnection("ready");
|
|
130
234
|
return;
|
|
131
235
|
}
|
|
132
|
-
if (type === "session.prepare_failed") {
|
|
133
|
-
const
|
|
134
|
-
this.
|
|
135
|
-
this.prepared = undefined;
|
|
236
|
+
if (type === "session.open_failed" || type === "session.prepare_failed" || type === "session.start_failed") {
|
|
237
|
+
const key = type === "session.open_failed" ? "open" : type === "session.prepare_failed" ? "prepare" : "start";
|
|
238
|
+
this.waits.get(key)?.reject(new Error(String(message.message ?? type)));
|
|
136
239
|
return;
|
|
137
240
|
}
|
|
138
|
-
if (type === "session.
|
|
139
|
-
this.
|
|
140
|
-
this.started = undefined;
|
|
241
|
+
if (type === "session.prepared") {
|
|
242
|
+
this.waits.get("prepare")?.resolve();
|
|
141
243
|
return;
|
|
142
244
|
}
|
|
143
|
-
if (type === "
|
|
144
|
-
|
|
245
|
+
if (type === "session.started") {
|
|
246
|
+
if (this.snapshotValue)
|
|
247
|
+
this.snapshotValue = { ...this.snapshotValue, status: "running" };
|
|
248
|
+
this.waits.get("start")?.resolve();
|
|
145
249
|
return;
|
|
146
250
|
}
|
|
147
|
-
if (type === "
|
|
148
|
-
this.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
251
|
+
if (type === "session.stopped") {
|
|
252
|
+
if (this.snapshotValue)
|
|
253
|
+
this.snapshotValue = { ...this.snapshotValue, status: "stopped", phase: undefined, floor: { owner: "USER", assistantAudible: false }, transcript: { turns: [] }, tasks: this.snapshotValue.tasks.map((task) => ({ taskId: task.taskId, participantId: task.participantId, status: task.status, createdAt: task.createdAt, updatedAt: task.updatedAt })) };
|
|
254
|
+
this.waits.get("stop")?.resolve();
|
|
255
|
+
if (this.snapshotValue?.participantId === "owner")
|
|
256
|
+
this.closeSocket("stopped", true);
|
|
257
|
+
else if (this.snapshotValue)
|
|
258
|
+
this.callbacks.onSnapshot?.(this.snapshotValue);
|
|
153
259
|
return;
|
|
154
260
|
}
|
|
155
|
-
if (type === "
|
|
156
|
-
this.
|
|
157
|
-
reason: String(message.reason ?? "SESSION_LIMIT"),
|
|
158
|
-
...Number.isFinite(Number(message.durationSeconds)) ? { durationSeconds: Number(message.durationSeconds) } : {}
|
|
159
|
-
});
|
|
261
|
+
if (type === "participant.detached") {
|
|
262
|
+
this.waits.get("detach")?.resolve();
|
|
160
263
|
return;
|
|
161
264
|
}
|
|
162
|
-
if (type === "
|
|
163
|
-
|
|
265
|
+
if (type === "public.event") {
|
|
266
|
+
if (!message.payload || typeof message.payload !== "object" || Array.isArray(message.payload))
|
|
267
|
+
return;
|
|
268
|
+
const event = { name: String(message.name), payload: message.payload, ...typeof message.sequence === "number" ? { sequence: message.sequence } : {}, ...typeof message.atMs === "number" ? { atMs: message.atMs } : {} };
|
|
269
|
+
if (event.sequence !== undefined && this.snapshotValue && event.sequence <= this.snapshotValue.sequence)
|
|
270
|
+
return;
|
|
271
|
+
if (this.snapshotValue) {
|
|
272
|
+
const snapshot = { ...this.snapshotValue, sequence: event.sequence ?? this.snapshotValue.sequence };
|
|
273
|
+
if (event.name === "task.update" && isTask(event.payload))
|
|
274
|
+
snapshot.tasks = [...snapshot.tasks.filter((task) => task.taskId !== event.payload.taskId), event.payload];
|
|
275
|
+
if (event.name === "transcript.canonical" && Array.isArray(event.payload.turns))
|
|
276
|
+
snapshot.transcript = { turns: event.payload.turns };
|
|
277
|
+
if (event.name === "floor" && (event.payload.owner === "USER" || event.payload.owner === "ASSISTANT") && typeof event.payload.assistantAudible === "boolean")
|
|
278
|
+
snapshot.floor = { owner: event.payload.owner, assistantAudible: event.payload.assistantAudible };
|
|
279
|
+
if (event.name === "phase" && typeof event.payload.phase === "string")
|
|
280
|
+
snapshot.phase = event.payload.phase;
|
|
281
|
+
if (event.name === "session.status" && ["created", "running", "suspended", "stopped"].includes(String(event.payload.status))) {
|
|
282
|
+
snapshot.status = event.payload.status;
|
|
283
|
+
if (snapshot.status === "suspended" || snapshot.status === "stopped")
|
|
284
|
+
snapshot.floor = { owner: "USER", assistantAudible: false };
|
|
285
|
+
}
|
|
286
|
+
this.snapshotValue = snapshot;
|
|
287
|
+
}
|
|
288
|
+
this.callbacks.onPublicEvent(event);
|
|
289
|
+
if (event.name === "task.update" && isTask(event.payload))
|
|
290
|
+
this.callbacks.onTaskUpdate?.(event.payload);
|
|
164
291
|
return;
|
|
165
292
|
}
|
|
166
|
-
if (type === "
|
|
167
|
-
this.
|
|
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
|
-
});
|
|
293
|
+
if (type === "task.execute" && isTask(message.task)) {
|
|
294
|
+
this.execute(message.task);
|
|
175
295
|
return;
|
|
176
296
|
}
|
|
177
|
-
if (type === "
|
|
178
|
-
this.
|
|
297
|
+
if (type === "task.cancel") {
|
|
298
|
+
this.executions.get(String(message.taskId))?.controller.abort();
|
|
179
299
|
return;
|
|
180
300
|
}
|
|
181
|
-
if (type === "
|
|
182
|
-
this.
|
|
301
|
+
if (type === "task.ack" || type === "task.error") {
|
|
302
|
+
const execution = this.executions.get(String(message.taskId));
|
|
303
|
+
const pending = execution?.pending.get(Number(message.sequence));
|
|
304
|
+
if (!execution || !pending)
|
|
305
|
+
return;
|
|
306
|
+
execution.pending.delete(Number(message.sequence));
|
|
307
|
+
if (type === "task.ack")
|
|
308
|
+
pending.resolve();
|
|
309
|
+
else {
|
|
310
|
+
execution.rejected = true;
|
|
311
|
+
pending.reject(new Error(String(message.message ?? "Task update rejected.")));
|
|
312
|
+
}
|
|
183
313
|
return;
|
|
184
314
|
}
|
|
185
|
-
if (type === "
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
315
|
+
if (type === "task.response") {
|
|
316
|
+
const pending = this.waits.get(`task:${String(message.requestId)}`);
|
|
317
|
+
if (message.error)
|
|
318
|
+
pending?.reject(new Error(String(message.error.message ?? "Task request failed.")));
|
|
319
|
+
else
|
|
320
|
+
pending?.resolve(message.result);
|
|
191
321
|
return;
|
|
192
322
|
}
|
|
193
|
-
|
|
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." });
|
|
323
|
+
if (type === "session.limit") {
|
|
324
|
+
this.callbacks.onSessionLimit?.(String(message.reason ?? "SESSION_LIMIT"), typeof message.durationSeconds === "number" ? message.durationSeconds : undefined);
|
|
200
325
|
return;
|
|
201
326
|
}
|
|
202
|
-
this.
|
|
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 }));
|
|
327
|
+
this.callbacks.onUnknown?.(message);
|
|
208
328
|
}
|
|
209
|
-
async
|
|
210
|
-
if (
|
|
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 });
|
|
329
|
+
async execute(task) {
|
|
330
|
+
if (this.terminal || this.executions.has(task.taskId))
|
|
218
331
|
return;
|
|
219
|
-
}
|
|
332
|
+
const entry = { controller: new AbortController, nextSequence: 1, rejected: false, pending: new Map };
|
|
333
|
+
this.executions.set(task.taskId, entry);
|
|
334
|
+
const publish = async (update) => {
|
|
335
|
+
if (this.terminal || entry.rejected)
|
|
336
|
+
throw new Error("Task update channel is closed.");
|
|
337
|
+
const sequence = entry.nextSequence;
|
|
338
|
+
const serialized = serialize({ type: "task.update", taskId: task.taskId, sequence, ...update });
|
|
339
|
+
entry.nextSequence++;
|
|
340
|
+
const wait = deferred();
|
|
341
|
+
entry.pending.set(sequence, { ...wait, serialized });
|
|
342
|
+
if (this.socket?.readyState === WebSocket.OPEN && !this.waits.has("open"))
|
|
343
|
+
this.socket.send(serialized);
|
|
344
|
+
await wait.promise;
|
|
345
|
+
};
|
|
346
|
+
const context = {
|
|
347
|
+
taskId: task.taskId,
|
|
348
|
+
sourceUserTurnId: task.sourceUserTurnId,
|
|
349
|
+
signal: entry.controller.signal,
|
|
350
|
+
progress: async (progress) => {
|
|
351
|
+
if (progress.fraction !== undefined && (!Number.isFinite(progress.fraction) || progress.fraction < 0 || progress.fraction > 1))
|
|
352
|
+
throw new Error("Task progress fraction must be between 0 and 1.");
|
|
353
|
+
await publish({ status: "running", progress });
|
|
354
|
+
},
|
|
355
|
+
partial: (value) => publish({ status: "running", partial: value })
|
|
356
|
+
};
|
|
220
357
|
try {
|
|
221
|
-
|
|
222
|
-
this.
|
|
223
|
-
|
|
358
|
+
await publish({ status: "running" });
|
|
359
|
+
if (!this.callbacks.onTaskExecute)
|
|
360
|
+
throw new Error(`No handler is registered for task ${task.taskId}.`);
|
|
361
|
+
const output = await this.callbacks.onTaskExecute(task, context);
|
|
362
|
+
await publish({ status: "succeeded", output: output ?? null });
|
|
224
363
|
} catch (error) {
|
|
364
|
+
if (this.terminal)
|
|
365
|
+
return;
|
|
225
366
|
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
-
|
|
227
|
-
|
|
367
|
+
if (entry.rejected) {
|
|
368
|
+
this.callbacks.onPublicEvent({ name: "error", payload: { code: "TASK_UPDATE_REJECTED", message } });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const cancelled = entry.controller.signal.aborted && (error === entry.controller.signal.reason || error instanceof Error && error.name === "AbortError");
|
|
372
|
+
try {
|
|
373
|
+
await publish({ status: cancelled ? "cancelled" : "failed", error: message });
|
|
374
|
+
} catch (failure) {
|
|
375
|
+
if (!this.terminal)
|
|
376
|
+
this.callbacks.onPublicEvent({ name: "error", payload: { code: "TASK_UPDATE_FAILED", message: failure instanceof Error ? failure.message : String(failure) } });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function isTask(value) {
|
|
382
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
383
|
+
return false;
|
|
384
|
+
const task = value;
|
|
385
|
+
return typeof task.taskId === "string" && typeof task.participantId === "string" && typeof task.status === "string";
|
|
386
|
+
}
|
|
387
|
+
function serialize(value) {
|
|
388
|
+
return JSON.stringify(value, (_key, item) => {
|
|
389
|
+
if (typeof item === "function" || typeof item === "symbol" || typeof item === "bigint" || typeof item === "number" && !Number.isFinite(item))
|
|
390
|
+
throw new Error("Task data must be JSON serializable.");
|
|
391
|
+
return item;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
function deferred() {
|
|
395
|
+
let resolve;
|
|
396
|
+
let reject;
|
|
397
|
+
const promise = new Promise((onResolve, onReject) => {
|
|
398
|
+
resolve = onResolve;
|
|
399
|
+
reject = onReject;
|
|
400
|
+
});
|
|
401
|
+
return { promise, resolve, reject };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/participant.ts
|
|
405
|
+
var defaultEndpoint = "wss://inkibra-voice.inkibra.workers.dev/v1/live";
|
|
406
|
+
|
|
407
|
+
class LiveVoiceParticipant {
|
|
408
|
+
client;
|
|
409
|
+
tools;
|
|
410
|
+
developer;
|
|
411
|
+
connectedToken;
|
|
412
|
+
listeners = new Map;
|
|
413
|
+
lifetime = deferred();
|
|
414
|
+
closed = this.lifetime.promise;
|
|
415
|
+
constructor(options, client) {
|
|
416
|
+
this.client = client ?? new ControlClient(options.endpoint ?? defaultEndpoint, { onConnection: () => {}, onPublicEvent: () => {} });
|
|
417
|
+
this.tools = options.tools ?? {};
|
|
418
|
+
this.connectedToken = options.token;
|
|
419
|
+
this.developer = { prompt: options.prompt ?? "", ...options.conversation ? { conversation: options.conversation } : {} };
|
|
420
|
+
this.client.setCallbacks({
|
|
421
|
+
onConnection: (state, detail) => this.connectionChanged(state, detail),
|
|
422
|
+
onSnapshot: (snapshot) => this.emit("snapshot", snapshot),
|
|
423
|
+
onPublicEvent: (event) => this.acceptEvent(event),
|
|
424
|
+
onUnknown: (message) => this.unknownMessage(message),
|
|
425
|
+
onSessionLimit: (reason, durationSeconds) => this.emit("limit", { reason, durationSeconds }),
|
|
426
|
+
onTaskExecute: (task, context) => {
|
|
427
|
+
const tool = task.name ? this.tools[task.name] : undefined;
|
|
428
|
+
if (!tool || !task.input)
|
|
429
|
+
throw new Error(`No handler or invocation data for task ${task.taskId}.`);
|
|
430
|
+
return tool.execute(task.input, context);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
get snapshot() {
|
|
435
|
+
const snapshot = this.client.currentSnapshot;
|
|
436
|
+
if (!snapshot)
|
|
437
|
+
throw new Error("Participant has not opened.");
|
|
438
|
+
return snapshot;
|
|
439
|
+
}
|
|
440
|
+
get sessionId() {
|
|
441
|
+
return this.snapshot.sessionId;
|
|
442
|
+
}
|
|
443
|
+
get participantId() {
|
|
444
|
+
return this.snapshot.participantId;
|
|
445
|
+
}
|
|
446
|
+
get connectionId() {
|
|
447
|
+
return this.snapshot.connectionId;
|
|
448
|
+
}
|
|
449
|
+
get phase() {
|
|
450
|
+
return this.snapshot.phase;
|
|
451
|
+
}
|
|
452
|
+
on(event, handler) {
|
|
453
|
+
let listeners = this.listeners.get(event);
|
|
454
|
+
if (!listeners) {
|
|
455
|
+
listeners = new Set;
|
|
456
|
+
this.listeners.set(event, listeners);
|
|
228
457
|
}
|
|
458
|
+
listeners.add(handler);
|
|
459
|
+
return () => {
|
|
460
|
+
listeners.delete(handler);
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
async rateSession(rating) {
|
|
464
|
+
const response = await fetch(this.client.ratingUrl(this.sessionId), {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: { authorization: `Bearer ${this.connectedToken}`, "content-type": "application/json" },
|
|
467
|
+
body: JSON.stringify(rating)
|
|
468
|
+
});
|
|
469
|
+
const result = await response.json();
|
|
470
|
+
if (!response.ok)
|
|
471
|
+
throw new Error(typeof result.error === "string" ? result.error : `Unable to submit rating (${response.status}).`);
|
|
472
|
+
if (!result.rating || typeof result.rating !== "object" || !Array.isArray(result.storedFields))
|
|
473
|
+
throw new Error("Rating response was invalid.");
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
async reconnect(options = {}) {
|
|
477
|
+
if (options.token)
|
|
478
|
+
this.connectedToken = options.token;
|
|
479
|
+
return await this.client.reconnect({ token: this.connectedToken, sessionId: this.sessionId, participantId: this.participantId, tools: this.tools });
|
|
480
|
+
}
|
|
481
|
+
dispose() {
|
|
482
|
+
this.client.dispose();
|
|
483
|
+
this.lifetime.resolve();
|
|
484
|
+
}
|
|
485
|
+
finish() {
|
|
486
|
+
this.lifetime.resolve();
|
|
487
|
+
}
|
|
488
|
+
connectionChanged(state, detail) {
|
|
489
|
+
this.emit("connection", { state, detail });
|
|
490
|
+
if (state === "closed" && this.client.currentSnapshot?.status === "stopped")
|
|
491
|
+
this.finish();
|
|
229
492
|
}
|
|
230
|
-
|
|
493
|
+
unknownMessage(message) {
|
|
494
|
+
this.emit("unknown", { message });
|
|
495
|
+
}
|
|
496
|
+
emit(event, value) {
|
|
497
|
+
for (const listener of this.listeners.get(event) ?? [])
|
|
498
|
+
listener(value);
|
|
499
|
+
}
|
|
500
|
+
taskApi() {
|
|
501
|
+
return {
|
|
502
|
+
list: () => this.client.requestTask("list"),
|
|
503
|
+
get: (taskId) => this.client.requestTask("get", taskId),
|
|
504
|
+
cancel: (taskId) => this.client.requestTask("cancel", taskId)
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
attachmentTaskApi() {
|
|
508
|
+
return { ...this.taskApi(), reconcile: (taskId, resolution) => this.client.requestTask("reconcile", taskId, resolution) };
|
|
509
|
+
}
|
|
510
|
+
acceptEvent(event) {
|
|
511
|
+
const { name, payload } = event;
|
|
512
|
+
if (name === "session.status") {
|
|
513
|
+
this.emit("snapshot", this.snapshot);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (name === "task.update" && typeof payload.taskId === "string") {
|
|
517
|
+
this.emit("task.update", payload);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
231
520
|
if (name === "transcript.instantaneous" && typeof payload.text === "string") {
|
|
232
|
-
this.emit(
|
|
521
|
+
this.emit(name, { text: payload.text });
|
|
233
522
|
return;
|
|
234
523
|
}
|
|
235
524
|
if (name === "transcript.canonical" && Array.isArray(payload.turns)) {
|
|
236
|
-
|
|
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 });
|
|
525
|
+
this.emit(name, { turns: this.snapshot.transcript.turns });
|
|
252
526
|
return;
|
|
253
527
|
}
|
|
254
|
-
if (name === "floor"
|
|
255
|
-
this.emit(
|
|
528
|
+
if (name === "floor") {
|
|
529
|
+
this.emit(name, this.snapshot.floor);
|
|
256
530
|
return;
|
|
257
531
|
}
|
|
258
532
|
if (name === "phase" && typeof payload.phase === "string") {
|
|
259
|
-
|
|
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);
|
|
533
|
+
this.emit(name, { phase: payload.phase, source: payload.source === "floor" ? "floor" : "initial", ...typeof payload.previousPhase === "string" ? { previousPhase: payload.previousPhase } : {} });
|
|
266
534
|
return;
|
|
267
535
|
}
|
|
268
536
|
if (name === "quick-reaction" && typeof payload.reaction === "string" && (payload.status === "prepared" || payload.status === "playing" || payload.status === "completed" || payload.status === "cancelled")) {
|
|
269
|
-
this.emit(
|
|
537
|
+
this.emit(name, { reaction: payload.reaction, status: payload.status });
|
|
270
538
|
return;
|
|
271
539
|
}
|
|
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
540
|
if (name === "error" && typeof payload.code === "string" && typeof payload.message === "string") {
|
|
296
|
-
this.emit(
|
|
541
|
+
this.emit(name, { code: payload.code, message: payload.message });
|
|
297
542
|
return;
|
|
298
543
|
}
|
|
299
|
-
this.
|
|
544
|
+
this.unknownMessage({ type: "public.event", ...event });
|
|
300
545
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/server.ts
|
|
549
|
+
var liveVoicePcmFormat = { encoding: "pcm_s16le", sampleRate: 24000, channels: 1 };
|
|
550
|
+
|
|
551
|
+
class LiveVoiceServerSession extends LiveVoiceParticipant {
|
|
552
|
+
audio;
|
|
553
|
+
tasks = this.taskApi();
|
|
554
|
+
mediaWanted = false;
|
|
555
|
+
audioActive = false;
|
|
556
|
+
audioEpoch = 0;
|
|
557
|
+
starting;
|
|
558
|
+
audioStopping;
|
|
559
|
+
constructor(options, client, audio) {
|
|
560
|
+
super(options, client);
|
|
561
|
+
this.audio = audio;
|
|
562
|
+
if (audio.format.encoding !== "pcm_s16le" || audio.format.sampleRate !== 24000 || audio.format.channels !== 1)
|
|
563
|
+
throw new Error("Audio transports must expose mono PCM16LE at 24 kHz.");
|
|
564
|
+
}
|
|
565
|
+
async start() {
|
|
566
|
+
this.mediaWanted = true;
|
|
567
|
+
if (this.starting)
|
|
568
|
+
return this.starting;
|
|
569
|
+
if (this.audioActive && this.snapshot.status === "running")
|
|
570
|
+
return;
|
|
571
|
+
this.starting = this.startMedia();
|
|
572
|
+
try {
|
|
573
|
+
await this.starting;
|
|
574
|
+
} finally {
|
|
575
|
+
this.starting = undefined;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async startMedia() {
|
|
579
|
+
await this.client.prepare();
|
|
580
|
+
await this.audioStopping;
|
|
581
|
+
const connectionId = this.connectionId;
|
|
582
|
+
const epoch = ++this.audioEpoch;
|
|
583
|
+
this.audioActive = true;
|
|
584
|
+
const current = () => this.audioActive && epoch === this.audioEpoch && connectionId === this.connectionId;
|
|
585
|
+
try {
|
|
586
|
+
await this.audio.start({
|
|
587
|
+
input: (pcm) => {
|
|
588
|
+
if (!current())
|
|
589
|
+
return;
|
|
590
|
+
if (pcm.byteLength % 2 !== 0)
|
|
591
|
+
throw new Error("PCM16 input must contain whole samples.");
|
|
592
|
+
this.client.send(pcm);
|
|
593
|
+
},
|
|
594
|
+
receipt: (receipt) => {
|
|
595
|
+
if (current())
|
|
596
|
+
this.client.send({ type: "output.receipt", ...receipt });
|
|
597
|
+
},
|
|
598
|
+
close: (reason) => {
|
|
599
|
+
if (current())
|
|
600
|
+
this.client.closeSocket(reason ?? "Audio transport closed", false);
|
|
601
|
+
},
|
|
602
|
+
error: (error) => {
|
|
603
|
+
if (!current())
|
|
604
|
+
return;
|
|
605
|
+
this.emit("error", { code: "AUDIO_TRANSPORT", message: error.message });
|
|
606
|
+
this.client.closeSocket("Audio transport failed", false);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
if (!current())
|
|
610
|
+
throw new Error("Connection changed during audio startup.");
|
|
611
|
+
await this.client.start({ mediaMode: "server" });
|
|
612
|
+
} catch (error) {
|
|
613
|
+
await this.stopAudio();
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async stop() {
|
|
618
|
+
this.mediaWanted = false;
|
|
619
|
+
try {
|
|
620
|
+
await this.client.stop();
|
|
621
|
+
} finally {
|
|
622
|
+
await this.stopAudio();
|
|
623
|
+
this.finish();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async rate(rating) {
|
|
627
|
+
return this.rateSession(rating);
|
|
628
|
+
}
|
|
629
|
+
async reconnect(options = {}) {
|
|
630
|
+
this.client.closeSocket("reconnecting", false);
|
|
631
|
+
await this.starting?.catch(() => {});
|
|
632
|
+
await this.stopAudio();
|
|
633
|
+
await super.reconnect(options);
|
|
634
|
+
if (this.mediaWanted)
|
|
635
|
+
await this.start();
|
|
636
|
+
return this.snapshot;
|
|
637
|
+
}
|
|
638
|
+
dispose() {
|
|
639
|
+
this.mediaWanted = false;
|
|
640
|
+
super.dispose();
|
|
641
|
+
this.stopAudio().catch((error) => this.emit("error", { code: "AUDIO_STOP", message: String(error) }));
|
|
642
|
+
}
|
|
643
|
+
connectionChanged(state, detail) {
|
|
644
|
+
if (state === "closed" || state === "error")
|
|
645
|
+
this.stopAudio().catch((error) => this.emit("error", { code: "AUDIO_STOP", message: String(error) }));
|
|
646
|
+
super.connectionChanged(state, detail);
|
|
647
|
+
}
|
|
648
|
+
stopAudio() {
|
|
649
|
+
if (!this.audioActive)
|
|
650
|
+
return this.audioStopping ?? Promise.resolve();
|
|
651
|
+
this.audioEpoch++;
|
|
652
|
+
this.audioActive = false;
|
|
653
|
+
this.audioStopping = Promise.resolve().then(() => this.audio.stop());
|
|
654
|
+
return this.audioStopping;
|
|
304
655
|
}
|
|
305
|
-
|
|
306
|
-
if (
|
|
656
|
+
unknownMessage(message) {
|
|
657
|
+
if (typeof message.type !== "string" || !message.type.startsWith("output.")) {
|
|
658
|
+
super.unknownMessage(message);
|
|
307
659
|
return;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
660
|
+
}
|
|
661
|
+
if (!this.audioActive)
|
|
662
|
+
return;
|
|
663
|
+
const generationId = String(message.generationId ?? "");
|
|
664
|
+
const lane = message.queue === "reaction" ? "REACTION" : "FOREGROUND";
|
|
665
|
+
const value = Number(message.gain ?? 1);
|
|
666
|
+
const gain = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
|
|
667
|
+
let command;
|
|
668
|
+
if (message.type === "output.block")
|
|
669
|
+
command = { type: "block-foreground", generationId };
|
|
670
|
+
else if (message.type === "output.release")
|
|
671
|
+
command = { type: "release", generationId, gain };
|
|
672
|
+
else if (message.type === "output.cancel")
|
|
673
|
+
command = { type: "cancel", generationId };
|
|
674
|
+
else if (message.type === "output.done")
|
|
675
|
+
command = { type: "done", generationId, lane };
|
|
676
|
+
else if (message.type === "output.audio" && typeof message.delta === "string") {
|
|
677
|
+
const binary = atob(message.delta);
|
|
678
|
+
const pcm = new Uint8Array(binary.length);
|
|
679
|
+
for (let index = 0;index < binary.length; index++)
|
|
680
|
+
pcm[index] = binary.charCodeAt(index);
|
|
681
|
+
command = { type: "enqueue", generationId, lane, pcm, mode: message.mode === "play" ? "play" : "buffer", gain };
|
|
682
|
+
} else {
|
|
683
|
+
super.unknownMessage(message);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const epoch = this.audioEpoch;
|
|
687
|
+
Promise.resolve().then(() => {
|
|
688
|
+
if (epoch === this.audioEpoch && this.audioActive)
|
|
689
|
+
return this.audio.output(command);
|
|
690
|
+
}).catch((error) => {
|
|
691
|
+
if (epoch !== this.audioEpoch)
|
|
692
|
+
return;
|
|
693
|
+
this.emit("error", { code: "AUDIO_OUTPUT", message: String(error) });
|
|
694
|
+
this.client.closeSocket("Audio output failed", false);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
class LiveVoiceServerAttachment extends LiveVoiceParticipant {
|
|
700
|
+
tasks = this.attachmentTaskApi();
|
|
701
|
+
async detach() {
|
|
702
|
+
try {
|
|
703
|
+
await this.client.detach();
|
|
704
|
+
} finally {
|
|
705
|
+
this.finish();
|
|
706
|
+
}
|
|
321
707
|
}
|
|
322
708
|
}
|
|
323
709
|
|
|
324
710
|
class LiveVoiceServer {
|
|
325
|
-
static
|
|
326
|
-
|
|
711
|
+
static async create(options) {
|
|
712
|
+
const credentials = await resolveCredentials(options);
|
|
713
|
+
const endpoint = options.endpoint ?? credentials.endpoint ?? defaultEndpoint;
|
|
714
|
+
const client = new ControlClient(endpoint, { onConnection: () => {}, onPublicEvent: () => {} });
|
|
715
|
+
const session = new LiveVoiceServerSession({ ...options, token: credentials.token, endpoint }, client, options.audio);
|
|
716
|
+
await client.open({ token: credentials.token, mode: "create", participantId: "owner", mediaMode: "server", tools: options.tools, developer: { prompt: options.prompt ?? "", ...options.conversation ? { conversation: options.conversation } : {} } });
|
|
717
|
+
return session;
|
|
718
|
+
}
|
|
719
|
+
static async attach(options) {
|
|
720
|
+
const credentials = await resolveCredentials(options);
|
|
721
|
+
const endpoint = options.endpoint ?? credentials.endpoint ?? defaultEndpoint;
|
|
722
|
+
const client = new ControlClient(endpoint, { onConnection: () => {}, onPublicEvent: () => {} });
|
|
723
|
+
const attachment = new LiveVoiceServerAttachment({ ...options, token: credentials.token, endpoint }, client);
|
|
724
|
+
await client.open({ token: credentials.token, mode: "attach", sessionId: options.sessionId, participantId: options.participantId, tools: options.tools });
|
|
725
|
+
return attachment;
|
|
327
726
|
}
|
|
328
727
|
}
|
|
329
|
-
async function
|
|
728
|
+
async function resolveCredentials(options) {
|
|
330
729
|
if (Boolean(options.token) === Boolean(options.apiKey))
|
|
331
|
-
throw new Error("
|
|
730
|
+
throw new Error("Provide exactly one of token or apiKey.");
|
|
332
731
|
if (options.token)
|
|
333
|
-
return { token: options.token
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
body: JSON.stringify(options.voice ? { voice: options.voice } : {})
|
|
339
|
-
});
|
|
732
|
+
return { token: options.token };
|
|
733
|
+
const base = (options.apiBaseUrl ?? "https://inkibra-voice.inkibra.workers.dev").replace(/\/$/u, "");
|
|
734
|
+
const path = options.sessionId ? `/v1/sessions/${encodeURIComponent(options.sessionId)}/attachment-tokens` : "/v1/sessions";
|
|
735
|
+
const body = options.sessionId ? { participantId: options.participantId, observe: true, tools: Object.keys(options.tools ?? {}) } : options.voice ? { voice: options.voice } : {};
|
|
736
|
+
const response = await fetch(`${base}${path}`, { method: "POST", headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
340
737
|
const result = await response.json();
|
|
341
738
|
if (!response.ok || typeof result.token !== "string")
|
|
342
|
-
throw new Error(String(result.error ?? `Unable to
|
|
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 : {};
|
|
739
|
+
throw new Error(String(result.error ?? `Unable to authorize Live Voice session (${response.status}).`));
|
|
740
|
+
return { token: result.token, endpoint: typeof result.realtimeUrl === "string" ? result.realtimeUrl : undefined };
|
|
387
741
|
}
|
|
388
742
|
export {
|
|
743
|
+
LiveVoiceServerAttachment,
|
|
389
744
|
LiveVoiceServerSession,
|
|
390
745
|
LiveVoiceServer as default,
|
|
391
746
|
liveVoicePcmFormat
|