@inkibra/voice-sdk 0.1.1 → 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/README.md +115 -35
- package/dist/client.d.ts +18 -0
- package/dist/control.d.ts +77 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +804 -538
- package/dist/participant.d.ts +75 -0
- package/dist/public.d.ts +36 -110
- package/dist/server.d.ts +63 -60
- package/dist/server.js +655 -300
- package/dist/session-protocol.d.ts +103 -0
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -1,673 +1,939 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
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
|
-
|
|
22
|
-
|
|
14
|
+
started = false;
|
|
15
|
+
terminal = false;
|
|
23
16
|
constructor(endpoint, callbacks) {
|
|
24
17
|
this.endpoint = endpoint;
|
|
25
18
|
this.callbacks = callbacks;
|
|
26
19
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
65
|
-
this.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
async
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
84
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
this.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
112
|
-
this.
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
this.
|
|
117
|
-
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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.
|
|
149
|
-
|
|
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 === "
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 === "
|
|
161
|
-
this.
|
|
261
|
+
if (type === "participant.detached") {
|
|
262
|
+
this.waits.get("detach")?.resolve();
|
|
162
263
|
return;
|
|
163
264
|
}
|
|
164
|
-
if (type === "
|
|
165
|
-
|
|
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 === "
|
|
169
|
-
this.
|
|
293
|
+
if (type === "task.execute" && isTask(message.task)) {
|
|
294
|
+
this.execute(message.task);
|
|
170
295
|
return;
|
|
171
296
|
}
|
|
172
|
-
if (type === "
|
|
173
|
-
this.
|
|
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 === "
|
|
183
|
-
this.
|
|
301
|
+
if (type === "task.ack" || type === "task.error") {
|
|
302
|
+
const execution = this.executions.get(String(message.taskId));
|
|
303
|
+
const pending = execution?.pending.get(Number(message.sequence));
|
|
304
|
+
if (!execution || !pending)
|
|
305
|
+
return;
|
|
306
|
+
execution.pending.delete(Number(message.sequence));
|
|
307
|
+
if (type === "task.ack")
|
|
308
|
+
pending.resolve();
|
|
309
|
+
else {
|
|
310
|
+
execution.rejected = true;
|
|
311
|
+
pending.reject(new Error(String(message.message ?? "Task update rejected.")));
|
|
312
|
+
}
|
|
184
313
|
return;
|
|
185
314
|
}
|
|
186
|
-
if (type === "
|
|
187
|
-
this.
|
|
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 === "
|
|
191
|
-
this.
|
|
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
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.
|
|
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.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
229
|
-
if (!
|
|
230
|
-
return
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
this.
|
|
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
|
-
|
|
411
|
-
this.
|
|
443
|
+
closeSocket(reason = "closed", terminal = true) {
|
|
444
|
+
this.closeMedia();
|
|
445
|
+
super.closeSocket(reason, terminal);
|
|
412
446
|
}
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
437
|
-
|
|
469
|
+
class RealtimeMediaClient {
|
|
470
|
+
endpoint;
|
|
471
|
+
token;
|
|
438
472
|
onLevel;
|
|
439
|
-
|
|
473
|
+
onMediaStream;
|
|
474
|
+
onAudioElement;
|
|
475
|
+
onMediaClosed;
|
|
476
|
+
peer;
|
|
440
477
|
stream;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
448
|
-
this.
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
501
|
-
|
|
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/
|
|
726
|
+
// src/participant.ts
|
|
505
727
|
var defaultEndpoint = "wss://inkibra-voice.inkibra.workers.dev/v1/live";
|
|
506
728
|
|
|
507
|
-
class
|
|
729
|
+
class LiveVoiceParticipant {
|
|
508
730
|
client;
|
|
509
731
|
tools;
|
|
732
|
+
developer;
|
|
733
|
+
connectedToken;
|
|
510
734
|
listeners = new Map;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
this.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
return
|
|
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.
|
|
772
|
+
return this.snapshot.phase;
|
|
556
773
|
}
|
|
557
|
-
|
|
558
|
-
this.
|
|
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.
|
|
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
|
|
566
|
-
|
|
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
|
-
|
|
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(
|
|
843
|
+
this.emit(name, { text: payload.text });
|
|
571
844
|
return;
|
|
572
845
|
}
|
|
573
846
|
if (name === "transcript.canonical" && Array.isArray(payload.turns)) {
|
|
574
|
-
|
|
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"
|
|
593
|
-
this.emit(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
643
|
-
this.
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
this.
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
664
|
-
|
|
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
|
-
|
|
672
|
-
|
|
936
|
+
LiveVoiceAttachment,
|
|
937
|
+
LiveVoiceOwner,
|
|
938
|
+
src_default as default
|
|
673
939
|
};
|