@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/dist/server.js CHANGED
@@ -1,391 +1,746 @@
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 {
1
+ // src/control.ts
2
+ class ControlClient {
3
+ endpoint;
4
+ callbacks;
11
5
  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) {
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.audio = audio;
25
- this.tools = tools;
26
- this.closed = new Promise((resolve) => {
27
- this.resolveClosed = resolve;
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 session.prepare();
84
+ return await opening;
40
85
  } catch (error) {
41
- session.finish(error instanceof Error ? error.message : String(error));
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 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;
100
+ await this.preparing;
101
+ } finally {
102
+ this.preparing = undefined;
57
103
  }
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
- }));
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 started;
76
- } catch (error) {
77
- session.finish(error instanceof Error ? error.message : String(error));
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
- 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);
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
- get phase() {
91
- return this.currentPhase;
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
- stop() {
94
- this.finish("stopped", true);
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.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
- });
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
- 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;
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
- message = JSON.parse(raw);
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.prepared") {
128
- this.prepared?.resolve();
129
- this.prepared = undefined;
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 error = new Error(String(message.message ?? "Session preparation failed."));
134
- this.prepared?.reject(error);
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.started") {
139
- this.started?.resolve();
140
- this.started = undefined;
241
+ if (type === "session.prepared") {
242
+ this.waits.get("prepare")?.resolve();
141
243
  return;
142
244
  }
143
- if (type === "public.event") {
144
- this.acceptPublicEvent(String(message.name ?? "unknown"), asRecord(message.payload), Number.isFinite(Number(message.atMs)) ? Number(message.atMs) : undefined);
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 === "tool.call") {
148
- this.executeTool({
149
- callId: String(message.callId ?? ""),
150
- name: String(message.name ?? ""),
151
- input: asRecord(message.input)
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 === "session.limit") {
156
- this.emit("limit", {
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 === "output.block") {
163
- this.audio.output({ type: "block-foreground", generationId: String(message.generationId ?? "") });
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 === "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
- });
293
+ if (type === "task.execute" && isTask(message.task)) {
294
+ this.execute(message.task);
175
295
  return;
176
296
  }
177
- if (type === "output.release") {
178
- this.audio.output({ type: "release", generationId: String(message.generationId ?? ""), gain: clamp01(Number(message.gain ?? 1)) });
297
+ if (type === "task.cancel") {
298
+ this.executions.get(String(message.taskId))?.controller.abort();
179
299
  return;
180
300
  }
181
- if (type === "output.cancel") {
182
- this.audio.output({ type: "cancel", generationId: String(message.generationId ?? "") });
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 === "output.done") {
186
- this.audio.output({
187
- type: "done",
188
- generationId: String(message.generationId ?? ""),
189
- lane: message.queue === "reaction" ? "REACTION" : "FOREGROUND"
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
- 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." });
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.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 }));
327
+ this.callbacks.onUnknown?.(message);
208
328
  }
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 });
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
- 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 });
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
- 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 });
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
- acceptPublicEvent(name, payload, atMs) {
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("transcript.instantaneous", { text: payload.text });
521
+ this.emit(name, { text: payload.text });
233
522
  return;
234
523
  }
235
524
  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 });
525
+ this.emit(name, { turns: this.snapshot.transcript.turns });
252
526
  return;
253
527
  }
254
- if (name === "floor" && (payload.owner === "USER" || payload.owner === "ASSISTANT") && typeof payload.assistantAudible === "boolean") {
255
- this.emit("floor", { owner: payload.owner, assistantAudible: payload.assistantAudible });
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
- 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);
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("quick-reaction", { reaction: payload.reaction, status: payload.status });
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("error", { code: payload.code, message: payload.message });
541
+ this.emit(name, { code: payload.code, message: payload.message });
297
542
  return;
298
543
  }
299
- this.emit("unknown", { message: { type: "public.event", name, payload, ...atMs === undefined ? {} : { atMs } } });
544
+ this.unknownMessage({ type: "public.event", ...event });
300
545
  }
301
- emit(event, value) {
302
- for (const handler of this.listeners.get(event) ?? [])
303
- handler(value);
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
- finish(reason, notifyServer = false) {
306
- if (this.stopped)
656
+ unknownMessage(message) {
657
+ if (typeof message.type !== "string" || !message.type.startsWith("output.")) {
658
+ super.unknownMessage(message);
307
659
  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();
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 connect(options) {
326
- return LiveVoiceServerSession.connect(options);
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 resolveSessionCredentials(options) {
728
+ async function resolveCredentials(options) {
330
729
  if (Boolean(options.token) === Boolean(options.apiKey))
331
- throw new Error("Supply exactly one of token or apiKey.");
730
+ throw new Error("Provide exactly one of token or apiKey.");
332
731
  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
- });
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 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 : {};
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