@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/index.js CHANGED
@@ -1,673 +1,939 @@
1
- // src/client.ts
2
- function sessionStartMessage(options) {
3
- return {
4
- type: "session.start",
5
- developer: options.developer
6
- };
7
- }
8
-
9
- class LiveVoiceClient {
1
+ // src/control.ts
2
+ class ControlClient {
10
3
  endpoint;
11
4
  callbacks;
12
5
  socket;
13
- microphone;
14
- output;
15
- preparation;
16
- resolvePrepared;
17
- rejectPrepared;
18
- prepared = false;
19
6
  sessionToken;
7
+ snapshotValue;
8
+ waits = new Map;
9
+ executions = new Map;
10
+ requestCounter = 0;
11
+ prepared = false;
12
+ preparing;
20
13
  starting;
21
- resolveStarted;
22
- rejectStarted;
14
+ started = false;
15
+ terminal = false;
23
16
  constructor(endpoint, callbacks) {
24
17
  this.endpoint = endpoint;
25
18
  this.callbacks = callbacks;
26
19
  }
27
- async prepare(token) {
28
- if (this.prepared && this.sessionToken === token && this.socket?.readyState === WebSocket.OPEN)
29
- return;
30
- if (this.preparation && this.sessionToken === token)
31
- return this.preparation;
32
- if (this.socket)
33
- this.stop();
34
- this.sessionToken = token;
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;
35
43
  this.callbacks.onConnection("connecting");
36
- const endpoint = this.endpoint.replace(/\/$/u, "");
37
- const socket = new WebSocket(withSessionToken(`${endpoint}/${crypto.randomUUID()}`, token));
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);
38
47
  this.socket = socket;
39
- socket.binaryType = "arraybuffer";
40
- socket.addEventListener("message", (event) => this.onMessage(event.data));
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
+ });
41
73
  socket.addEventListener("close", (event) => {
42
- if (this.socket !== socket)
43
- return;
44
- const wasPrepared = this.prepared;
45
- if (!wasPrepared)
46
- this.rejectPrepared?.(new Error("Session connection closed before preparation completed."));
47
- this.microphone?.stop();
48
- this.microphone = undefined;
49
- this.output?.clear();
50
- this.output = undefined;
51
- this.socket = undefined;
52
- this.prepared = false;
53
- this.preparation = undefined;
54
- this.resolvePrepared = undefined;
55
- this.rejectPrepared = undefined;
56
- this.sessionToken = undefined;
57
- this.rejectStarted?.(new Error("Session connection closed before startup completed."));
58
- this.clearStarting();
59
- this.callbacks.onConnection("closed", event.reason || undefined);
74
+ if (this.socket === socket)
75
+ this.closeSocket(event.reason || "Connection lost", false);
60
76
  });
61
77
  socket.addEventListener("error", () => {
62
78
  if (this.socket !== socket)
63
79
  return;
64
- const message = "Durable Object WebSocket failed.";
65
- this.failPreparation(message);
66
- this.rejectStarted?.(new Error(message));
67
- this.clearStarting();
68
- this.callbacks.onConnection("error", message);
80
+ this.callbacks.onConnection("error", "Live Voice WebSocket failed.");
81
+ this.closeSocket("Live Voice WebSocket failed.", false);
69
82
  });
70
- this.preparation = new Promise((resolve, reject) => {
71
- this.resolvePrepared = resolve;
72
- this.rejectPrepared = reject;
73
- });
74
- await waitForOpen(socket);
75
- socket.send(JSON.stringify({ type: "session.prepare", protocolVersion: 1 }));
76
- return this.preparation;
77
- }
78
- async start(options) {
79
- await this.prepare(options.token);
80
- this.output = new OutputAudio((receipt) => {
81
- if (this.socket?.readyState === WebSocket.OPEN)
82
- this.socket.send(JSON.stringify({ type: "output.receipt", ...receipt }));
83
- });
84
- await this.output.resume();
85
- this.starting = new Promise((resolve, reject) => {
86
- this.resolveStarted = resolve;
87
- this.rejectStarted = reject;
88
- });
89
- this.socket?.send(JSON.stringify(sessionStartMessage(options)));
90
- await this.starting;
91
- this.microphone = new Microphone((buffer) => {
92
- if (this.socket?.readyState === WebSocket.OPEN)
93
- this.socket.send(buffer);
94
- }, (level) => {
95
- this.output?.sidechain(level);
96
- this.callbacks.onLevel(level);
83
+ try {
84
+ return await opening;
85
+ } catch (error) {
86
+ if (this.socket === socket)
87
+ this.closeSocket("Connection opening failed", false);
88
+ throw error;
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;
97
98
  });
98
- await this.microphone.start();
99
- this.callbacks.onConnection("live");
100
- }
101
- stop() {
102
- this.microphone?.stop();
103
- this.microphone = undefined;
104
- this.output?.clear();
105
- this.output = undefined;
106
- if (this.socket?.readyState === WebSocket.OPEN)
107
- this.socket.send(JSON.stringify({ type: "session.stop" }));
108
- this.socket?.close(1000, "stopped");
99
+ try {
100
+ await this.preparing;
101
+ } finally {
102
+ this.preparing = undefined;
103
+ }
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
+ })();
119
+ try {
120
+ await this.starting;
121
+ } finally {
122
+ this.starting = undefined;
123
+ }
124
+ }
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
+ }
135
+ }
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
+ }
144
+ }
145
+ closeSocket(reason = "closed", terminal = true) {
146
+ const socket = this.socket;
109
147
  this.socket = undefined;
110
148
  this.prepared = false;
111
- this.preparation = undefined;
112
- this.resolvePrepared = undefined;
113
- this.rejectPrepared = undefined;
114
- this.sessionToken = undefined;
115
- this.rejectStarted?.(new Error("Session stopped before startup completed."));
116
- this.clearStarting();
117
- this.callbacks.onConnection("closed");
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
+ }
118
170
  }
119
171
  dispose() {
120
- this.stop();
172
+ if (this.socket?.readyState === WebSocket.OPEN)
173
+ this.send({ type: this.snapshotValue?.participantId === "owner" ? "session.stop" : "participant.detach" });
174
+ this.closeSocket("disposed", true);
175
+ }
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 } : {} }));
121
182
  }
122
183
  playUiCue(cue) {
123
- if (this.socket?.readyState === WebSocket.OPEN)
124
- this.socket.send(JSON.stringify({ type: "cue.request", 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
+ }
125
207
  }
126
208
  onMessage(raw) {
127
209
  if (typeof raw !== "string")
128
210
  return;
129
211
  let message;
130
212
  try {
131
- message = JSON.parse(raw);
213
+ const value = JSON.parse(raw);
214
+ if (!value || typeof value !== "object" || Array.isArray(value))
215
+ return;
216
+ message = value;
132
217
  } catch {
133
218
  return;
134
219
  }
135
220
  const type = String(message.type ?? "");
136
- if (type === "session.prepared") {
137
- this.prepared = true;
138
- this.resolvePrepared?.();
139
- this.resolvePrepared = undefined;
140
- this.rejectPrepared = 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");
141
234
  return;
142
235
  }
143
- if (type === "session.prepare_failed") {
144
- this.failPreparation(String(message.message ?? "Session preparation failed."));
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)));
239
+ return;
240
+ }
241
+ if (type === "session.prepared") {
242
+ this.waits.get("prepare")?.resolve();
145
243
  return;
146
244
  }
147
245
  if (type === "session.started") {
148
- this.resolveStarted?.();
149
- this.clearStarting();
246
+ if (this.snapshotValue)
247
+ this.snapshotValue = { ...this.snapshotValue, status: "running" };
248
+ this.waits.get("start")?.resolve();
150
249
  return;
151
250
  }
152
- if (type === "public.event") {
153
- this.callbacks.onPublicEvent({
154
- name: String(message.name ?? "unknown"),
155
- payload: asRecord(message.payload),
156
- ...Number.isFinite(Number(message.atMs)) ? { atMs: Number(message.atMs) } : {}
157
- });
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);
158
259
  return;
159
260
  }
160
- if (type === "tool.call") {
161
- this.handleToolCall(message);
261
+ if (type === "participant.detached") {
262
+ this.waits.get("detach")?.resolve();
162
263
  return;
163
264
  }
164
- if (type === "session.limit") {
165
- this.callbacks.onSessionLimit?.(String(message.reason ?? "SESSION_LIMIT"), Number.isFinite(Number(message.durationSeconds)) ? Number(message.durationSeconds) : undefined);
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);
166
291
  return;
167
292
  }
168
- if (type === "output.block") {
169
- this.output?.blockForeground(String(message.generationId ?? ""));
293
+ if (type === "task.execute" && isTask(message.task)) {
294
+ this.execute(message.task);
170
295
  return;
171
296
  }
172
- if (type === "output.audio" && typeof message.delta === "string") {
173
- this.output?.enqueue({
174
- encoded: message.delta,
175
- lane: message.queue === "reaction" ? "REACTION" : "FOREGROUND",
176
- generationId: String(message.generationId ?? ""),
177
- mode: message.mode === "play" ? "play" : "buffer",
178
- gain: clamp01(Number(message.gain ?? 1))
179
- });
297
+ if (type === "task.cancel") {
298
+ this.executions.get(String(message.taskId))?.controller.abort();
180
299
  return;
181
300
  }
182
- if (type === "output.release") {
183
- this.output?.release(String(message.generationId ?? ""), clamp01(Number(message.gain ?? 1)));
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
+ }
184
313
  return;
185
314
  }
186
- if (type === "output.cancel") {
187
- this.output?.cancel(String(message.generationId ?? ""));
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);
188
321
  return;
189
322
  }
190
- if (type === "output.done") {
191
- this.output?.done(message.queue === "reaction" ? "REACTION" : "FOREGROUND", String(message.generationId ?? ""));
323
+ if (type === "session.limit") {
324
+ this.callbacks.onSessionLimit?.(String(message.reason ?? "SESSION_LIMIT"), typeof message.durationSeconds === "number" ? message.durationSeconds : undefined);
192
325
  return;
193
326
  }
194
327
  this.callbacks.onUnknown?.(message);
195
328
  }
196
- async handleToolCall(message) {
197
- const callId = String(message.callId ?? "");
198
- const name = String(message.name ?? "");
199
- if (!callId || !name || this.socket?.readyState !== WebSocket.OPEN)
329
+ async execute(task) {
330
+ if (this.terminal || this.executions.has(task.taskId))
200
331
  return;
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
+ };
201
357
  try {
202
- if (!this.callbacks.onToolCall)
203
- throw new Error(`No browser handler is registered for tool ${name}`);
204
- const output = await this.callbacks.onToolCall({ callId, name, input: asRecord(message.input) });
205
- this.socket?.send(JSON.stringify({ type: "tool.result", callId, ok: true, output: output ?? null }));
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 });
206
363
  } catch (error) {
207
- this.socket?.send(JSON.stringify({
208
- type: "tool.result",
209
- callId,
210
- ok: false,
211
- error: error instanceof Error ? error.message : String(error)
212
- }));
213
- }
214
- }
215
- failPreparation(message) {
216
- if (!this.prepared)
217
- this.rejectPrepared?.(new Error(message));
218
- this.preparation = undefined;
219
- this.resolvePrepared = undefined;
220
- this.rejectPrepared = undefined;
221
- }
222
- clearStarting() {
223
- this.starting = undefined;
224
- this.resolveStarted = undefined;
225
- this.rejectStarted = undefined;
364
+ if (this.terminal)
365
+ return;
366
+ const message = error instanceof Error ? error.message : String(error);
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
+ }
226
379
  }
227
380
  }
228
- function withSessionToken(value, token) {
229
- if (!token)
230
- return value;
231
- const url = new URL(value);
232
- url.searchParams.set("token", token);
233
- return url.toString();
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 };
234
402
  }
235
- var OPTICAL_SIDECHAIN = {
236
- thresholdDb: -42,
237
- ratio: 4,
238
- maxReductionDb: 12,
239
- attackSeconds: 0.12,
240
- releaseSeconds: 0.36
241
- };
242
403
 
243
- class OutputAudio {
244
- onReceipt;
245
- context = new AudioContext({ sampleRate: 24000, latencyHint: "interactive" });
246
- foregroundGain = this.context.createGain();
247
- reactionGain = this.context.createGain();
248
- outputBus = this.context.createGain();
249
- nextStart = 0;
250
- sources = new Map;
251
- buffered = new Map;
252
- released = new Set;
253
- blockers = new Set;
254
- started = new Set;
255
- startedTimers = new Map;
256
- completed = new Set;
257
- doneGenerations = new Map;
258
- playedSamples = new Map;
259
- constructor(onReceipt) {
260
- this.onReceipt = onReceipt;
261
- this.foregroundGain.connect(this.outputBus);
262
- this.reactionGain.connect(this.outputBus);
263
- this.outputBus.connect(this.context.destination);
264
- }
265
- async resume() {
266
- await this.context.resume();
267
- }
268
- sidechain(level) {
269
- const gain = opticalSidechainGain(level);
270
- const now = this.context.currentTime;
271
- const current = this.foregroundGain.gain.value;
272
- this.foregroundGain.gain.cancelAndHoldAtTime(now);
273
- this.foregroundGain.gain.setTargetAtTime(gain, now, gain < current ? OPTICAL_SIDECHAIN.attackSeconds : OPTICAL_SIDECHAIN.releaseSeconds);
274
- }
275
- blockForeground(generationId) {
276
- if (generationId)
277
- this.blockers.add(generationId);
278
- }
279
- enqueue(chunk) {
280
- if (!chunk.generationId)
281
- return;
282
- if (chunk.lane === "REACTION")
283
- this.blockers.add(chunk.generationId);
284
- const blocked = chunk.lane === "FOREGROUND" && this.blockers.size > 0;
285
- if (blocked || chunk.mode === "buffer" && !this.released.has(chunk.generationId)) {
286
- const queue = this.buffered.get(chunk.generationId) ?? [];
287
- queue.push({ encoded: chunk.encoded, lane: chunk.lane, gain: chunk.gain });
288
- this.buffered.set(chunk.generationId, queue);
289
- return;
290
- }
291
- this.schedule(chunk.encoded, chunk.lane, chunk.generationId, chunk.gain);
292
- }
293
- release(generationId, gain = 1) {
294
- if (!generationId)
295
- return;
296
- const chunks = this.buffered.get(generationId);
297
- if (chunks?.[0]?.lane === "REACTION") {
298
- for (const chunk of chunks)
299
- chunk.gain = gain;
300
- }
301
- this.released.add(generationId);
302
- const lane = this.buffered.get(generationId)?.[0]?.lane ?? this.doneGenerations.get(generationId);
303
- if (lane === "REACTION" || !this.blockers.size)
304
- this.flush(generationId);
305
- }
306
- done(lane, generationId) {
307
- this.doneGenerations.set(generationId, lane);
308
- this.finishIfComplete(generationId);
309
- }
310
- cancel(generationId) {
311
- if (!generationId)
312
- return;
313
- const bufferedLane = this.buffered.get(generationId)?.[0]?.lane;
314
- const sourceLane = [...this.sources.values()].find((value) => value.generationId === generationId)?.lane;
315
- const lane = sourceLane ?? bufferedLane ?? this.doneGenerations.get(generationId) ?? "FOREGROUND";
316
- this.buffered.delete(generationId);
317
- this.released.delete(generationId);
318
- this.blockers.delete(generationId);
319
- this.doneGenerations.delete(generationId);
320
- for (const [source, metadata] of this.sources) {
321
- if (metadata.generationId !== generationId)
322
- continue;
323
- const elapsed = Math.max(0, this.context.currentTime - metadata.startAt);
324
- const played = Math.min(metadata.samples, Math.floor(elapsed * 24000));
325
- this.playedSamples.set(generationId, (this.playedSamples.get(generationId) ?? 0) + played);
326
- source.onended = null;
327
- this.sources.delete(source);
328
- try {
329
- source.stop();
330
- } catch {}
331
- }
332
- this.recomputeNextStart();
333
- const timer = this.startedTimers.get(generationId);
334
- if (timer !== undefined)
335
- window.clearTimeout(timer);
336
- this.startedTimers.delete(generationId);
337
- this.flushReleasedForeground();
338
- this.onReceipt({ event: "cancelled", lane, generationId, playedSamples: this.playedSamples.get(generationId) ?? 0 });
339
- }
340
- clear() {
341
- for (const source of this.sources.keys())
342
- try {
343
- source.stop();
344
- } catch {}
345
- for (const timer of this.startedTimers.values())
346
- window.clearTimeout(timer);
347
- this.sources.clear();
348
- this.buffered.clear();
349
- this.released.clear();
350
- this.blockers.clear();
351
- this.started.clear();
352
- this.startedTimers.clear();
353
- this.completed.clear();
354
- this.doneGenerations.clear();
355
- this.playedSamples.clear();
356
- this.nextStart = 0;
357
- this.context.close();
358
- }
359
- flush(generationId) {
360
- const chunks = this.buffered.get(generationId);
361
- if (!chunks)
362
- return;
363
- this.buffered.delete(generationId);
364
- for (const chunk of chunks)
365
- this.schedule(chunk.encoded, chunk.lane, generationId, chunk.gain);
366
- }
367
- flushReleasedForeground() {
368
- if (this.blockers.size)
404
+ // src/client.ts
405
+ class LiveVoiceClient extends ControlClient {
406
+ mediaCallbacks;
407
+ media;
408
+ mediaStarting;
409
+ constructor(endpoint, mediaCallbacks) {
410
+ super(endpoint, mediaCallbacks);
411
+ this.mediaCallbacks = mediaCallbacks;
412
+ }
413
+ async start(options = {}) {
414
+ if (this.mediaStarting)
415
+ return this.mediaStarting;
416
+ if (this.media)
369
417
  return;
370
- for (const generationId of this.released)
371
- this.flush(generationId);
372
- }
373
- schedule(encoded, lane, generationId, gain) {
374
- const binary = atob(encoded);
375
- const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
376
- const pcm = new Int16Array(bytes.buffer);
377
- const audio = this.context.createBuffer(1, pcm.length, 24000);
378
- const samples = audio.getChannelData(0);
379
- for (let index = 0;index < pcm.length; index += 1)
380
- samples[index] = pcm[index] / 32768;
381
- const source = this.context.createBufferSource();
382
- const chunkGain = this.context.createGain();
383
- chunkGain.gain.value = lane === "REACTION" ? gain : 1;
384
- source.buffer = audio;
385
- source.connect(chunkGain).connect(lane === "REACTION" ? this.reactionGain : this.foregroundGain);
386
- const startAt = Math.max(this.context.currentTime + 0.008, this.nextStart);
387
- this.nextStart = startAt + audio.duration;
388
- this.sources.set(source, { generationId, lane, samples: pcm.length, startAt });
389
- source.onended = () => {
390
- const metadata = this.sources.get(source);
391
- this.sources.delete(source);
392
- this.recomputeNextStart();
393
- if (!metadata)
394
- return;
395
- this.playedSamples.set(generationId, (this.playedSamples.get(generationId) ?? 0) + metadata.samples);
396
- this.finishIfComplete(generationId);
397
- };
398
- source.start(startAt);
399
- if (!this.started.has(generationId)) {
400
- this.started.add(generationId);
401
- const timer = window.setTimeout(() => {
402
- this.startedTimers.delete(generationId);
403
- if ([...this.sources.values()].some((value) => value.generationId === generationId)) {
404
- this.onReceipt({ event: "started", lane, generationId, playedSamples: 0 });
405
- }
406
- }, Math.max(0, (startAt - this.context.currentTime) * 1000));
407
- this.startedTimers.set(generationId, timer);
418
+ const socket = this.socket;
419
+ this.mediaStarting = (async () => {
420
+ if (options.mediaMode === "browser") {
421
+ if (!socket || this.socket !== socket)
422
+ throw new Error("Connection changed during media startup.");
423
+ const preparation = this.prepare();
424
+ const media = new RealtimeMediaClient(this.endpoint, options.token ?? this.sessionToken, this.mediaCallbacks.onLevel, this.mediaCallbacks.onMediaStream, this.mediaCallbacks.onAudioElement, this.mediaCallbacks.onMediaClosed);
425
+ this.media = media;
426
+ await Promise.all([preparation, media.connect()]);
427
+ if (this.socket !== socket)
428
+ throw new Error("Connection changed during media startup.");
429
+ } else {
430
+ await this.prepare();
431
+ }
432
+ await super.start(options);
433
+ })();
434
+ try {
435
+ await this.mediaStarting;
436
+ } catch (error) {
437
+ this.closeMedia();
438
+ throw error;
439
+ } finally {
440
+ this.mediaStarting = undefined;
408
441
  }
409
442
  }
410
- recomputeNextStart() {
411
- this.nextStart = Math.max(this.context.currentTime, ...[...this.sources.values()].map((source) => source.startAt + source.samples / 24000));
443
+ closeSocket(reason = "closed", terminal = true) {
444
+ this.closeMedia();
445
+ super.closeSocket(reason, terminal);
412
446
  }
413
- finishIfComplete(generationId) {
414
- const lane = this.doneGenerations.get(generationId);
415
- if (!lane || this.completed.has(generationId) || this.buffered.has(generationId))
416
- return;
417
- if ([...this.sources.values()].some((value) => value.generationId === generationId))
418
- return;
419
- this.completed.add(generationId);
420
- if (lane === "REACTION") {
421
- this.blockers.delete(generationId);
422
- this.flushReleasedForeground();
423
- }
424
- this.onReceipt({ event: "completed", lane, generationId, playedSamples: this.playedSamples.get(generationId) ?? 0 });
447
+ closeMedia() {
448
+ const media = this.media;
449
+ this.media = undefined;
450
+ media?.close();
425
451
  }
426
452
  }
427
- function opticalSidechainGain(level) {
428
- const safeLevel = Math.max(Number.EPSILON, level);
429
- const inputDb = 20 * Math.log10(safeLevel);
430
- if (inputDb <= OPTICAL_SIDECHAIN.thresholdDb)
431
- return 1;
432
- const compressedDb = Math.min(OPTICAL_SIDECHAIN.maxReductionDb, (inputDb - OPTICAL_SIDECHAIN.thresholdDb) * (1 - 1 / OPTICAL_SIDECHAIN.ratio));
433
- return 10 ** (-compressedDb / 20);
453
+ var MEDIA_REQUEST_TIMEOUT_MS = 1e4;
454
+ var BRIDGE_REQUEST_TIMEOUT_MS = 24000;
455
+
456
+ class MediaRequestError extends Error {
457
+ code;
458
+ status;
459
+ path;
460
+ constructor(message, code, status, path) {
461
+ super(message);
462
+ this.code = code;
463
+ this.status = status;
464
+ this.path = path;
465
+ this.name = "MediaRequestError";
466
+ }
434
467
  }
435
468
 
436
- class Microphone {
437
- onFrame;
469
+ class RealtimeMediaClient {
470
+ endpoint;
471
+ token;
438
472
  onLevel;
439
- context;
473
+ onMediaStream;
474
+ onAudioElement;
475
+ onMediaClosed;
476
+ peer;
440
477
  stream;
441
- node;
442
- samples = [];
443
- constructor(onFrame, onLevel) {
444
- this.onFrame = onFrame;
478
+ audio;
479
+ levelTimer;
480
+ lifetime = new AbortController;
481
+ constructor(endpoint, token, onLevel, onMediaStream, onAudioElement, onMediaClosed) {
482
+ this.endpoint = endpoint;
483
+ this.token = token;
445
484
  this.onLevel = onLevel;
485
+ this.onMediaStream = onMediaStream;
486
+ this.onAudioElement = onAudioElement;
487
+ this.onMediaClosed = onMediaClosed;
488
+ }
489
+ async connect() {
490
+ if (!this.token)
491
+ throw new Error("A session token is required for WebRTC media");
492
+ this.lifetime.signal.throwIfAborted();
493
+ const microphone = deferred();
494
+ const abortMicrophone = () => microphone.reject(new Error("Media startup was cancelled."));
495
+ this.lifetime.signal.addEventListener("abort", abortMicrophone, { once: true });
496
+ navigator.mediaDevices.getUserMedia({
497
+ audio: {
498
+ echoCancellation: true,
499
+ noiseSuppression: true,
500
+ autoGainControl: true,
501
+ channelCount: 1
502
+ }
503
+ }).then((stream) => {
504
+ if (this.lifetime.signal.aborted)
505
+ stream.getTracks().forEach((track2) => track2.stop());
506
+ else
507
+ microphone.resolve(stream);
508
+ }, microphone.reject);
509
+ try {
510
+ this.stream = await microphone.promise;
511
+ } finally {
512
+ this.lifetime.signal.removeEventListener("abort", abortMicrophone);
513
+ }
514
+ const track = this.stream.getAudioTracks()[0];
515
+ if (!track)
516
+ throw new Error("No microphone track is available");
517
+ for (let attempt = 1;attempt <= 2; attempt += 1) {
518
+ try {
519
+ this.onMediaStream?.("microphone", this.stream);
520
+ await this.connectPeer(track);
521
+ return;
522
+ } catch (error) {
523
+ this.closePeer();
524
+ const code = error instanceof MediaRequestError ? error.code.toLowerCase() : "";
525
+ const retryable = error instanceof MediaRequestError && error.path.includes("/bridge") && (code === "empty_track_error" || code === "media_input_timeout" || code === "realtime_request_timeout" || code === "media_request_timeout");
526
+ if (attempt === 1 && retryable) {
527
+ await new Promise((resolve) => window.setTimeout(resolve, 250));
528
+ continue;
529
+ }
530
+ this.stream?.getTracks().forEach((mediaTrack) => mediaTrack.stop());
531
+ this.stream = undefined;
532
+ throw error;
533
+ }
534
+ }
446
535
  }
447
- async start() {
448
- this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 } });
449
- this.context = new AudioContext({ sampleRate: 24000, latencyHint: "interactive" });
450
- const sourceCode = `class LiveVoiceMic extends AudioWorkletProcessor { process(inputs) { const input=inputs[0]?.[0]; if(input) this.port.postMessage(input.slice()); return true; } } registerProcessor('live-voice-mic', LiveVoiceMic);`;
451
- const moduleUrl = URL.createObjectURL(new Blob([sourceCode], { type: "text/javascript" }));
452
- await this.context.audioWorklet.addModule(moduleUrl);
453
- URL.revokeObjectURL(moduleUrl);
454
- const source = this.context.createMediaStreamSource(this.stream);
455
- this.node = new AudioWorkletNode(this.context, "live-voice-mic");
456
- const silent = this.context.createGain();
457
- silent.gain.value = 0;
458
- source.connect(this.node).connect(silent).connect(this.context.destination);
459
- this.node.port.onmessage = (event) => this.accept(event.data);
460
- await this.context.resume();
461
- }
462
- stop() {
463
- this.node?.disconnect();
536
+ async connectPeer(track) {
537
+ this.lifetime.signal.throwIfAborted();
538
+ const startedAt = performance.now();
539
+ const timing = {};
540
+ const iceStartedAt = performance.now();
541
+ const icePromise = this.request("/ice", "POST").then((value) => {
542
+ timing.ice = performance.now() - iceStartedAt;
543
+ return value;
544
+ });
545
+ const sessionStartedAt = performance.now();
546
+ const sessionPromise = this.request("/session", "POST").then((value) => {
547
+ timing.session = performance.now() - sessionStartedAt;
548
+ return value;
549
+ });
550
+ const [ice, session] = await Promise.all([icePromise, sessionPromise]);
551
+ if (!ice.iceServers?.length)
552
+ throw new Error("WebRTC ICE servers are unavailable");
553
+ if (!session.sessionId)
554
+ throw new Error("Cloudflare Realtime did not return a session");
555
+ const peer = new RTCPeerConnection({
556
+ iceServers: ice.iceServers,
557
+ bundlePolicy: "max-bundle"
558
+ });
559
+ this.peer = peer;
560
+ const transceiver = peer.addTransceiver(track, { direction: "sendonly" });
561
+ const sessionId = session.sessionId;
562
+ const offer = await peer.createOffer();
563
+ await peer.setLocalDescription(offer);
564
+ await waitForIceGathering(peer);
565
+ if (!peer.localDescription?.sdp || !transceiver.mid)
566
+ throw new Error("WebRTC microphone offer is incomplete");
567
+ const publishStartedAt = performance.now();
568
+ const published = await this.request(`/session/${encodeURIComponent(sessionId)}/publish`, "POST", {
569
+ sessionDescription: { type: "offer", sdp: peer.localDescription.sdp },
570
+ mid: transceiver.mid,
571
+ trackName: `microphone-${crypto.randomUUID()}`
572
+ });
573
+ timing.publish = performance.now() - publishStartedAt;
574
+ if (!published.sessionDescription)
575
+ throw new Error(published.errorDescription ?? "Cloudflare Realtime did not answer the microphone offer");
576
+ await peer.setRemoteDescription(published.sessionDescription);
577
+ const remoteTrack = deferred();
578
+ const abortTrack = () => remoteTrack.reject(new Error("Media startup was cancelled."));
579
+ this.lifetime.signal.addEventListener("abort", abortTrack, { once: true });
580
+ const remoteTrackTimeout = window.setTimeout(() => remoteTrack.reject(new Error("Assistant WebRTC track did not arrive")), 1e4);
581
+ peer.addEventListener("track", (event) => remoteTrack.resolve(event.track), { once: true });
582
+ let assistantTrack;
583
+ try {
584
+ const bridgeStartedAt = performance.now();
585
+ const bridged = await this.request(`/session/${encodeURIComponent(sessionId)}/bridge`, "POST", undefined, BRIDGE_REQUEST_TIMEOUT_MS);
586
+ timing.bridge = performance.now() - bridgeStartedAt;
587
+ let renegotiation = Promise.resolve();
588
+ if (bridged.requiresImmediateRenegotiation) {
589
+ if (!bridged.sessionDescription)
590
+ throw new Error("Assistant WebRTC offer is missing");
591
+ renegotiation = (async () => {
592
+ const renegotiateStartedAt = performance.now();
593
+ await peer.setRemoteDescription(bridged.sessionDescription);
594
+ const answer = await peer.createAnswer();
595
+ await peer.setLocalDescription(answer);
596
+ if (!peer.localDescription?.sdp)
597
+ throw new Error("Assistant WebRTC answer is incomplete");
598
+ await waitForIceGathering(peer);
599
+ timing.renegotiate = performance.now() - renegotiateStartedAt;
600
+ await this.request(`/session/${encodeURIComponent(sessionId)}/renegotiate`, "PUT", { sessionDescription: { type: "answer", sdp: peer.localDescription.sdp }, setupTimingMs: { ...timing, total: performance.now() - startedAt } });
601
+ })();
602
+ }
603
+ [assistantTrack] = await Promise.all([remoteTrack.promise, renegotiation]);
604
+ } finally {
605
+ window.clearTimeout(remoteTrackTimeout);
606
+ this.lifetime.signal.removeEventListener("abort", abortTrack);
607
+ }
608
+ this.lifetime.signal.throwIfAborted();
609
+ const audio = document.createElement("audio");
610
+ audio.autoplay = true;
611
+ audio.setAttribute("playsinline", "");
612
+ audio.style.display = "none";
613
+ audio.srcObject = new MediaStream([assistantTrack]);
614
+ document.body.appendChild(audio);
615
+ this.onMediaStream?.("assistant", new MediaStream([assistantTrack]));
616
+ this.onAudioElement?.(audio);
617
+ this.audio = audio;
618
+ await audio.play();
619
+ await waitForPeerConnection(peer);
620
+ this.lifetime.signal.throwIfAborted();
621
+ this.startLevelMeter(transceiver.sender);
622
+ }
623
+ close() {
624
+ this.lifetime.abort();
625
+ this.closePeer(true);
464
626
  this.stream?.getTracks().forEach((track) => track.stop());
465
- this.context?.close();
466
- }
467
- accept(input) {
468
- for (const value of input)
469
- this.samples.push(value);
470
- while (this.samples.length >= 480) {
471
- const frame = this.samples.splice(0, 480);
472
- const pcm = new Int16Array(480);
473
- let energy = 0;
474
- for (let index = 0;index < frame.length; index += 1) {
475
- const value = Math.max(-1, Math.min(1, frame[index]));
476
- energy += value * value;
477
- pcm[index] = value < 0 ? value * 32768 : value * 32767;
627
+ this.stream = undefined;
628
+ }
629
+ closePeer(notify = false) {
630
+ window.clearInterval(this.levelTimer);
631
+ this.levelTimer = undefined;
632
+ this.audio?.pause();
633
+ this.audio?.remove();
634
+ this.audio = undefined;
635
+ this.peer?.close();
636
+ this.peer = undefined;
637
+ if (notify)
638
+ this.onMediaClosed?.();
639
+ }
640
+ startLevelMeter(sender) {
641
+ this.levelTimer = window.setInterval(() => {
642
+ sender.getStats().then((report) => {
643
+ let level = 0;
644
+ report.forEach((stat) => {
645
+ const candidate = Number(stat.audioLevel);
646
+ if (Number.isFinite(candidate))
647
+ level = Math.max(level, candidate);
648
+ });
649
+ this.onLevel(level);
650
+ }).catch(() => {});
651
+ }, 50);
652
+ }
653
+ async request(path, method, body, timeoutMs = MEDIA_REQUEST_TIMEOUT_MS) {
654
+ const endpoint = new URL(this.endpoint);
655
+ endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
656
+ endpoint.pathname = `/v1/media${path}`;
657
+ endpoint.search = "";
658
+ endpoint.searchParams.set("token", this.token ?? "");
659
+ let response;
660
+ try {
661
+ response = await fetch(endpoint, {
662
+ method,
663
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
664
+ ...body === undefined ? {} : { body: JSON.stringify(body) },
665
+ signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(timeoutMs)])
666
+ });
667
+ } catch (error) {
668
+ if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
669
+ throw new MediaRequestError("WebRTC media request timed out", "MEDIA_REQUEST_TIMEOUT", 504, path);
478
670
  }
479
- this.onLevel(Math.sqrt(energy / frame.length));
480
- this.onFrame(pcm.buffer);
671
+ throw error;
481
672
  }
673
+ const payload = await response.json().catch(() => ({}));
674
+ if (!response.ok) {
675
+ throw new MediaRequestError(payload.error ?? `WebRTC media request failed (${response.status})`, payload.errorCode ?? `HTTP_${response.status}`, response.status, path);
676
+ }
677
+ return payload;
482
678
  }
483
679
  }
484
- function waitForOpen(socket) {
485
- return new Promise((resolve, reject) => {
486
- const timeout = window.setTimeout(() => reject(new Error("Durable Object connection timed out.")), 1e4);
487
- socket.addEventListener("open", () => {
488
- window.clearTimeout(timeout);
489
- resolve();
490
- }, { once: true });
491
- socket.addEventListener("error", () => {
492
- window.clearTimeout(timeout);
493
- reject(new Error("Could not connect to the Durable Object."));
494
- }, { once: true });
495
- });
496
- }
497
- function clamp01(value) {
498
- return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
680
+ function waitForIceGathering(peer) {
681
+ const hasRoutableCandidate = () => / typ (?:srflx|relay) /u.test(peer.localDescription?.sdp ?? "");
682
+ if (hasRoutableCandidate())
683
+ return Promise.resolve();
684
+ const pending = deferred();
685
+ const timeout = window.setTimeout(() => finish(new Error("WebRTC routable ICE candidate gathering timed out")), 1e4);
686
+ const changed = () => {
687
+ if (hasRoutableCandidate())
688
+ finish();
689
+ };
690
+ const finish = (error) => {
691
+ window.clearTimeout(timeout);
692
+ peer.removeEventListener("icecandidate", changed);
693
+ peer.removeEventListener("icegatheringstatechange", changed);
694
+ if (error)
695
+ pending.reject(error);
696
+ else
697
+ pending.resolve();
698
+ };
699
+ peer.addEventListener("icecandidate", changed);
700
+ peer.addEventListener("icegatheringstatechange", changed);
701
+ return pending.promise;
499
702
  }
500
- function asRecord(value) {
501
- return value && typeof value === "object" ? value : {};
703
+ function waitForPeerConnection(peer) {
704
+ if (peer.connectionState === "connected")
705
+ return Promise.resolve();
706
+ const pending = deferred();
707
+ const timeout = window.setTimeout(() => finish(new Error("WebRTC media connection timed out")), 1e4);
708
+ const changed = () => {
709
+ if (peer.connectionState === "connected")
710
+ finish();
711
+ if (peer.connectionState === "failed" || peer.connectionState === "closed")
712
+ finish(new Error(`WebRTC media connection ${peer.connectionState}`));
713
+ };
714
+ const finish = (error) => {
715
+ window.clearTimeout(timeout);
716
+ peer.removeEventListener("connectionstatechange", changed);
717
+ if (error)
718
+ pending.reject(error);
719
+ else
720
+ pending.resolve();
721
+ };
722
+ peer.addEventListener("connectionstatechange", changed);
723
+ return pending.promise;
502
724
  }
503
725
 
504
- // src/public.ts
726
+ // src/participant.ts
505
727
  var defaultEndpoint = "wss://inkibra-voice.inkibra.workers.dev/v1/live";
506
728
 
507
- class LiveVoiceSession {
729
+ class LiveVoiceParticipant {
508
730
  client;
509
731
  tools;
732
+ developer;
733
+ connectedToken;
510
734
  listeners = new Map;
511
- currentPhase;
512
- constructor(client, tools) {
513
- this.client = client;
514
- this.tools = tools;
515
- }
516
- static async connect(options) {
517
- if (!options.token)
518
- throw new Error("A short-lived Live Voice session token is required.");
519
- let session;
520
- const client = new LiveVoiceClient(options.endpoint ?? defaultEndpoint, {
521
- onConnection: (state, detail) => {
522
- session?.emit("connection", { state, detail });
523
- if (state === "error")
524
- session?.emit("error", { code: "CONNECTION_ERROR", message: detail ?? "Live Voice connection failed." });
525
- },
526
- onPublicEvent: (event) => session?.acceptPublicEvent(event.name, event.payload, event.atMs),
527
- onLevel: () => {},
528
- onUnknown: (message) => session?.emit("unknown", { message }),
529
- onSessionLimit: (reason, durationSeconds) => session?.emit("limit", { reason, durationSeconds }),
530
- onToolCall: async (call) => session.executeTool(call)
531
- });
532
- session = new LiveVoiceSession(client, options.tools ?? {});
533
- await client.start({
534
- token: options.token,
535
- developer: {
536
- prompt: options.prompt ?? "",
537
- conversation: options.conversation,
538
- tools: Object.entries(options.tools ?? {}).map(([name, tool]) => ({
539
- type: "function",
540
- name,
541
- description: tool.description,
542
- parameters: tool.parameters
543
- }))
735
+ lifetime = deferred();
736
+ closed = this.lifetime.promise;
737
+ constructor(options, client) {
738
+ this.client = client ?? new ControlClient(options.endpoint ?? defaultEndpoint, { onConnection: () => {}, onPublicEvent: () => {} });
739
+ this.tools = options.tools ?? {};
740
+ this.connectedToken = options.token;
741
+ this.developer = { prompt: options.prompt ?? "", ...options.conversation ? { conversation: options.conversation } : {} };
742
+ this.client.setCallbacks({
743
+ onConnection: (state, detail) => this.connectionChanged(state, detail),
744
+ onSnapshot: (snapshot) => this.emit("snapshot", snapshot),
745
+ onPublicEvent: (event) => this.acceptEvent(event),
746
+ onUnknown: (message) => this.unknownMessage(message),
747
+ onSessionLimit: (reason, durationSeconds) => this.emit("limit", { reason, durationSeconds }),
748
+ onTaskExecute: (task, context) => {
749
+ const tool = task.name ? this.tools[task.name] : undefined;
750
+ if (!tool || !task.input)
751
+ throw new Error(`No handler or invocation data for task ${task.taskId}.`);
752
+ return tool.execute(task.input, context);
544
753
  }
545
754
  });
546
- return session;
547
755
  }
548
- on(event, handler) {
549
- const handlers = this.listeners.get(event) ?? new Set;
550
- handlers.add(handler);
551
- this.listeners.set(event, handlers);
552
- return () => handlers.delete(handler);
756
+ get snapshot() {
757
+ const snapshot = this.client.currentSnapshot;
758
+ if (!snapshot)
759
+ throw new Error("Participant has not opened.");
760
+ return snapshot;
761
+ }
762
+ get sessionId() {
763
+ return this.snapshot.sessionId;
764
+ }
765
+ get participantId() {
766
+ return this.snapshot.participantId;
767
+ }
768
+ get connectionId() {
769
+ return this.snapshot.connectionId;
553
770
  }
554
771
  get phase() {
555
- return this.currentPhase;
772
+ return this.snapshot.phase;
556
773
  }
557
- stop() {
558
- this.client.stop();
774
+ on(event, handler) {
775
+ let listeners = this.listeners.get(event);
776
+ if (!listeners) {
777
+ listeners = new Set;
778
+ this.listeners.set(event, listeners);
779
+ }
780
+ listeners.add(handler);
781
+ return () => {
782
+ listeners.delete(handler);
783
+ };
784
+ }
785
+ async rateSession(rating) {
786
+ const response = await fetch(this.client.ratingUrl(this.sessionId), {
787
+ method: "POST",
788
+ headers: { authorization: `Bearer ${this.connectedToken}`, "content-type": "application/json" },
789
+ body: JSON.stringify(rating)
790
+ });
791
+ const result = await response.json();
792
+ if (!response.ok)
793
+ throw new Error(typeof result.error === "string" ? result.error : `Unable to submit rating (${response.status}).`);
794
+ if (!result.rating || typeof result.rating !== "object" || !Array.isArray(result.storedFields))
795
+ throw new Error("Rating response was invalid.");
796
+ return result;
797
+ }
798
+ async reconnect(options = {}) {
799
+ if (options.token)
800
+ this.connectedToken = options.token;
801
+ return await this.client.reconnect({ token: this.connectedToken, sessionId: this.sessionId, participantId: this.participantId, tools: this.tools });
559
802
  }
560
803
  dispose() {
561
804
  this.client.dispose();
562
- this.listeners.clear();
805
+ this.lifetime.resolve();
806
+ }
807
+ finish() {
808
+ this.lifetime.resolve();
809
+ }
810
+ connectionChanged(state, detail) {
811
+ this.emit("connection", { state, detail });
812
+ if (state === "closed" && this.client.currentSnapshot?.status === "stopped")
813
+ this.finish();
814
+ }
815
+ unknownMessage(message) {
816
+ this.emit("unknown", { message });
563
817
  }
564
818
  emit(event, value) {
565
- for (const handler of this.listeners.get(event) ?? [])
566
- handler(value);
819
+ for (const listener of this.listeners.get(event) ?? [])
820
+ listener(value);
821
+ }
822
+ taskApi() {
823
+ return {
824
+ list: () => this.client.requestTask("list"),
825
+ get: (taskId) => this.client.requestTask("get", taskId),
826
+ cancel: (taskId) => this.client.requestTask("cancel", taskId)
827
+ };
828
+ }
829
+ attachmentTaskApi() {
830
+ return { ...this.taskApi(), reconcile: (taskId, resolution) => this.client.requestTask("reconcile", taskId, resolution) };
567
831
  }
568
- acceptPublicEvent(name, payload, atMs) {
832
+ acceptEvent(event) {
833
+ const { name, payload } = event;
834
+ if (name === "session.status") {
835
+ this.emit("snapshot", this.snapshot);
836
+ return;
837
+ }
838
+ if (name === "task.update" && typeof payload.taskId === "string") {
839
+ this.emit("task.update", payload);
840
+ return;
841
+ }
569
842
  if (name === "transcript.instantaneous" && typeof payload.text === "string") {
570
- this.emit("transcript.instantaneous", { text: payload.text });
843
+ this.emit(name, { text: payload.text });
571
844
  return;
572
845
  }
573
846
  if (name === "transcript.canonical" && Array.isArray(payload.turns)) {
574
- const turns = [];
575
- for (const value of payload.turns) {
576
- if (!value || typeof value !== "object" || Array.isArray(value))
577
- continue;
578
- const turn = value;
579
- const role = turn.role;
580
- if (typeof turn.id !== "string" || role !== "user" && role !== "assistant" || typeof turn.text !== "string")
581
- continue;
582
- turns.push({
583
- id: turn.id,
584
- role,
585
- text: turn.text,
586
- ...typeof turn.truncated === "boolean" ? { truncated: turn.truncated } : {}
587
- });
588
- }
589
- this.emit("transcript.canonical", { turns });
847
+ this.emit(name, { turns: this.snapshot.transcript.turns });
590
848
  return;
591
849
  }
592
- if (name === "floor" && (payload.owner === "USER" || payload.owner === "ASSISTANT") && typeof payload.assistantAudible === "boolean") {
593
- this.emit("floor", { owner: payload.owner, assistantAudible: payload.assistantAudible });
850
+ if (name === "floor") {
851
+ this.emit(name, this.snapshot.floor);
594
852
  return;
595
853
  }
596
854
  if (name === "phase" && typeof payload.phase === "string") {
597
- const event = {
598
- phase: payload.phase,
599
- source: payload.source === "floor" ? "floor" : "initial",
600
- ...typeof payload.previousPhase === "string" ? { previousPhase: payload.previousPhase } : {}
601
- };
602
- this.currentPhase = event.phase;
603
- this.emit("phase", event);
855
+ this.emit(name, { phase: payload.phase, source: payload.source === "floor" ? "floor" : "initial", ...typeof payload.previousPhase === "string" ? { previousPhase: payload.previousPhase } : {} });
604
856
  return;
605
857
  }
606
858
  if (name === "quick-reaction" && typeof payload.reaction === "string" && (payload.status === "prepared" || payload.status === "playing" || payload.status === "completed" || payload.status === "cancelled")) {
607
- this.emit("quick-reaction", { reaction: payload.reaction, status: payload.status });
859
+ this.emit(name, { reaction: payload.reaction, status: payload.status });
608
860
  return;
609
861
  }
610
- if (name === "assistant.energy" && Number.isFinite(Number(payload.current)) && Array.isArray(payload.predictions)) {
611
- const predictions = [];
612
- let valid = true;
613
- for (const value of payload.predictions) {
614
- if (!value || typeof value !== "object" || Array.isArray(value)) {
615
- valid = false;
616
- break;
617
- }
618
- const prediction = value;
619
- const startMs = Number(prediction.startMs);
620
- const endMs = Number(prediction.endMs);
621
- const energy = Number(prediction.energy);
622
- if (!Number.isFinite(startMs) || startMs < 0 || !Number.isFinite(endMs) || endMs <= startMs || !Number.isFinite(energy)) {
623
- valid = false;
624
- break;
625
- }
626
- predictions.push({ startMs, endMs, energy: Math.max(0, Math.min(1, energy)) });
627
- }
628
- if (valid) {
629
- this.emit("assistant.energy", {
630
- current: Math.max(0, Math.min(1, Number(payload.current))),
631
- predictions
632
- });
633
- return;
634
- }
635
- }
636
862
  if (name === "error" && typeof payload.code === "string" && typeof payload.message === "string") {
637
- this.emit("error", { code: payload.code, message: payload.message });
863
+ this.emit(name, { code: payload.code, message: payload.message });
864
+ return;
865
+ }
866
+ this.unknownMessage({ type: "public.event", ...event });
867
+ }
868
+ }
869
+
870
+ // src/public.ts
871
+ class LiveVoiceOwner extends LiveVoiceParticipant {
872
+ tasks = this.taskApi();
873
+ started = false;
874
+ starting;
875
+ async start() {
876
+ if (this.started)
638
877
  return;
878
+ if (this.starting)
879
+ return this.starting;
880
+ this.starting = this.client.start({ mediaMode: "browser" });
881
+ try {
882
+ await this.starting;
883
+ this.started = true;
884
+ } finally {
885
+ this.starting = undefined;
639
886
  }
640
- this.emit("unknown", { message: { type: "public.event", name, payload, ...atMs === undefined ? {} : { atMs } } });
641
887
  }
642
- async executeTool(call) {
643
- this.emit("tool.call", call);
644
- const tool = this.tools[call.name];
645
- if (!tool) {
646
- const message = `No handler is registered for tool ${call.name}`;
647
- this.emit("tool.result", { ...call, ok: false, error: message });
648
- throw new Error(message);
888
+ async rate(rating) {
889
+ return this.rateSession(rating);
890
+ }
891
+ async stop() {
892
+ try {
893
+ await this.client.stop();
894
+ } finally {
895
+ this.started = false;
896
+ this.finish();
649
897
  }
898
+ }
899
+ async reconnect(options = {}) {
900
+ await super.reconnect(options);
901
+ if (this.started)
902
+ await this.client.start({ mediaMode: "browser" });
903
+ return this.snapshot;
904
+ }
905
+ }
906
+
907
+ class LiveVoiceAttachment extends LiveVoiceParticipant {
908
+ tasks = this.attachmentTaskApi();
909
+ async detach() {
650
910
  try {
651
- const output = await tool.execute(call.input);
652
- this.emit("tool.result", { ...call, ok: true, output });
653
- return output;
654
- } catch (error) {
655
- const message = error instanceof Error ? error.message : String(error);
656
- this.emit("tool.result", { ...call, ok: false, error: message });
657
- throw error;
911
+ await this.client.detach();
912
+ } finally {
913
+ this.finish();
658
914
  }
659
915
  }
660
916
  }
661
917
 
662
918
  class LiveVoice {
663
- static connect(options) {
664
- return LiveVoiceSession.connect(options);
919
+ static async create(options) {
920
+ const client = new LiveVoiceClient(options.endpoint ?? defaultEndpoint, { onConnection: () => {}, onPublicEvent: () => {}, onLevel: () => {} });
921
+ const owner = new LiveVoiceOwner(options, client);
922
+ await client.open({ token: options.token, mode: "create", participantId: "owner", mediaMode: "browser", tools: options.tools, developer: { prompt: options.prompt ?? "", ...options.conversation ? { conversation: options.conversation } : {} } });
923
+ return owner;
924
+ }
925
+ static async attach(options) {
926
+ const client = new ControlClient(options.endpoint ?? defaultEndpoint, { onConnection: () => {}, onPublicEvent: () => {} });
927
+ const attachment = new LiveVoiceAttachment(options, client);
928
+ await client.open({ token: options.token, mode: "attach", sessionId: options.sessionId, participantId: options.participantId, tools: options.tools });
929
+ return attachment;
665
930
  }
666
931
  }
667
932
 
668
933
  // src/index.ts
669
934
  var src_default = LiveVoice;
670
935
  export {
671
- LiveVoiceSession,
936
+ LiveVoiceAttachment,
937
+ LiveVoiceOwner,
672
938
  src_default as default
673
939
  };