@convbased/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/cjs/client.js +635 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/endpoints.js +10 -0
- package/dist/cjs/endpoints.js.map +1 -0
- package/dist/cjs/events.js +39 -0
- package/dist/cjs/events.js.map +1 -0
- package/dist/cjs/graphql.js +40 -0
- package/dist/cjs/graphql.js.map +1 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/rtcServers.js +35 -0
- package/dist/cjs/rtcServers.js.map +1 -0
- package/dist/cjs/sdp.js +37 -0
- package/dist/cjs/sdp.js.map +1 -0
- package/dist/cjs/signaling.js +146 -0
- package/dist/cjs/signaling.js.map +1 -0
- package/dist/cjs/tts.js +227 -0
- package/dist/cjs/tts.js.map +1 -0
- package/dist/cjs/types.js +26 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/upload.js +87 -0
- package/dist/cjs/upload.js.map +1 -0
- package/dist/client.d.ts +169 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +631 -0
- package/dist/client.js.map +1 -0
- package/dist/convbased-sdk.global.js +1291 -0
- package/dist/endpoints.d.ts +3 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +7 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +35 -0
- package/dist/events.js.map +1 -0
- package/dist/graphql.d.ts +18 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +37 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/rtcServers.d.ts +13 -0
- package/dist/rtcServers.d.ts.map +1 -0
- package/dist/rtcServers.js +31 -0
- package/dist/rtcServers.js.map +1 -0
- package/dist/sdp.d.ts +6 -0
- package/dist/sdp.d.ts.map +1 -0
- package/dist/sdp.js +34 -0
- package/dist/sdp.js.map +1 -0
- package/dist/signaling.d.ts +33 -0
- package/dist/signaling.d.ts.map +1 -0
- package/dist/signaling.js +142 -0
- package/dist/signaling.js.map +1 -0
- package/dist/tts.d.ts +111 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +223 -0
- package/dist/tts.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/dist/upload.d.ts +46 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +82 -0
- package/dist/upload.js.map +1 -0
- package/package.json +57 -0
- package/src/client.ts +839 -0
- package/src/endpoints.ts +8 -0
- package/src/events.ts +38 -0
- package/src/graphql.ts +58 -0
- package/src/index.ts +50 -0
- package/src/rtcServers.ts +38 -0
- package/src/sdp.ts +45 -0
- package/src/signaling.ts +172 -0
- package/src/tts.ts +364 -0
- package/src/types.ts +201 -0
- package/src/upload.ts +132 -0
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @convbased/sdk — bundled IIFE build.
|
|
3
|
+
*
|
|
4
|
+
* Auto-generated by scripts/bundle.mjs. Do not edit directly.
|
|
5
|
+
*
|
|
6
|
+
* Hosted at https://cdn.weights.chat/sdk/convbased-sdk.global.js — drop a
|
|
7
|
+
* classic script tag and read symbols from window.Convbased:
|
|
8
|
+
*
|
|
9
|
+
* <script src="https://cdn.weights.chat/sdk/convbased-sdk.global.js"></script>
|
|
10
|
+
* <script>
|
|
11
|
+
* const client = new Convbased.ConvbasedClient({ apiKey });
|
|
12
|
+
* </script>
|
|
13
|
+
*/
|
|
14
|
+
(function (globalThis) {
|
|
15
|
+
"use strict";
|
|
16
|
+
// ===== events.js =====
|
|
17
|
+
|
|
18
|
+
// Minimal typed event emitter; we avoid pulling Node's EventEmitter so the
|
|
19
|
+
// SDK stays browser-first with no polyfill chain.
|
|
20
|
+
class TypedEmitter {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.listeners = {};
|
|
23
|
+
}
|
|
24
|
+
on(event, fn) {
|
|
25
|
+
var _a;
|
|
26
|
+
((_a = this.listeners)[event] ?? (_a[event] = new Set())).add(fn);
|
|
27
|
+
return () => this.off(event, fn);
|
|
28
|
+
}
|
|
29
|
+
off(event, fn) {
|
|
30
|
+
this.listeners[event]?.delete(fn);
|
|
31
|
+
}
|
|
32
|
+
emit(event, payload) {
|
|
33
|
+
const set = this.listeners[event];
|
|
34
|
+
if (!set)
|
|
35
|
+
return;
|
|
36
|
+
for (const fn of set) {
|
|
37
|
+
try {
|
|
38
|
+
fn(payload);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
// Listeners shouldn't crash the emitter.
|
|
42
|
+
console.error(`[convbased-sdk] listener for "${String(event)}" threw:`, e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
removeAllListeners() {
|
|
47
|
+
for (const key of Object.keys(this.listeners)) {
|
|
48
|
+
delete this.listeners[key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ===== sdp.js =====
|
|
54
|
+
|
|
55
|
+
// SDP mangling helpers. Mirrors `setAudioParameters` in Convbased-Web — we
|
|
56
|
+
// rewrite the Opus `a=fmtp:` line so the offer requests our target bitrate,
|
|
57
|
+
// stereo mode, and inband FEC. The signaling node passes the SDP through to
|
|
58
|
+
// aiortc largely unchanged, so changes here directly shape the negotiated
|
|
59
|
+
// audio.
|
|
60
|
+
function applyOpusSdpOptions(sdp, opts) {
|
|
61
|
+
const lines = sdp.split("\r\n");
|
|
62
|
+
let payloadType = null;
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const match = line.match(/a=rtpmap:(\d+) opus\/48000\/2/);
|
|
65
|
+
if (match) {
|
|
66
|
+
payloadType = match[1];
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!payloadType)
|
|
71
|
+
return sdp;
|
|
72
|
+
let fmtpIndex = lines.findIndex((line) => line.startsWith(`a=fmtp:${payloadType}`));
|
|
73
|
+
if (fmtpIndex === -1) {
|
|
74
|
+
lines.push(`a=fmtp:${payloadType}`);
|
|
75
|
+
fmtpIndex = lines.length - 1;
|
|
76
|
+
}
|
|
77
|
+
let fmtp = lines[fmtpIndex];
|
|
78
|
+
fmtp = fmtp.replace(/;\s*stereo=\d/, "");
|
|
79
|
+
fmtp = fmtp.replace(/;\s*maxaveragebitrate=\d+/, "");
|
|
80
|
+
if (opts.stereo)
|
|
81
|
+
fmtp += "; stereo=1";
|
|
82
|
+
fmtp += `; maxaveragebitrate=${opts.bitrateKbps * 1000}`;
|
|
83
|
+
if (!fmtp.includes("useinbandfec=1"))
|
|
84
|
+
fmtp += "; useinbandfec=1";
|
|
85
|
+
lines[fmtpIndex] = fmtp;
|
|
86
|
+
return lines.join("\r\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ===== types.js =====
|
|
90
|
+
|
|
91
|
+
// Wire-protocol types mirrored from ServerAPI / signaling.
|
|
92
|
+
var RTCStatusCode;
|
|
93
|
+
(function (RTCStatusCode) {
|
|
94
|
+
RTCStatusCode[RTCStatusCode["ERROR"] = 2000] = "ERROR";
|
|
95
|
+
RTCStatusCode[RTCStatusCode["GPU_INSUFFICIENT"] = 2001] = "GPU_INSUFFICIENT";
|
|
96
|
+
RTCStatusCode[RTCStatusCode["DUPLICATE_CONNECTION"] = 2002] = "DUPLICATE_CONNECTION";
|
|
97
|
+
RTCStatusCode[RTCStatusCode["MODEL_NOT_FOUND"] = 2003] = "MODEL_NOT_FOUND";
|
|
98
|
+
RTCStatusCode[RTCStatusCode["UNPAID_SERVICE"] = 2004] = "UNPAID_SERVICE";
|
|
99
|
+
RTCStatusCode[RTCStatusCode["REQUEST_TOO_FAST"] = 2005] = "REQUEST_TOO_FAST";
|
|
100
|
+
RTCStatusCode[RTCStatusCode["CONNECTED"] = 3000] = "CONNECTED";
|
|
101
|
+
RTCStatusCode[RTCStatusCode["REQUEST_RECEIVED"] = 3001] = "REQUEST_RECEIVED";
|
|
102
|
+
RTCStatusCode[RTCStatusCode["TRACK_READY"] = 3002] = "TRACK_READY";
|
|
103
|
+
RTCStatusCode[RTCStatusCode["RESPONSE_SENT"] = 3003] = "RESPONSE_SENT";
|
|
104
|
+
RTCStatusCode[RTCStatusCode["LOADING_MODEL"] = 3004] = "LOADING_MODEL";
|
|
105
|
+
RTCStatusCode[RTCStatusCode["SERVICE_READY"] = 3009] = "SERVICE_READY";
|
|
106
|
+
// File inference (voice-to-voice) task lifecycle codes.
|
|
107
|
+
RTCStatusCode[RTCStatusCode["TASK_PROGRESS"] = 3010] = "TASK_PROGRESS";
|
|
108
|
+
RTCStatusCode[RTCStatusCode["TASK_FINISHED"] = 3011] = "TASK_FINISHED";
|
|
109
|
+
RTCStatusCode[RTCStatusCode["TASK_ACK"] = 3012] = "TASK_ACK";
|
|
110
|
+
RTCStatusCode[RTCStatusCode["SHUTDOWN"] = 4000] = "SHUTDOWN";
|
|
111
|
+
RTCStatusCode[RTCStatusCode["SERVER_CLOSED"] = 5000] = "SERVER_CLOSED";
|
|
112
|
+
})(RTCStatusCode || (RTCStatusCode = {}));
|
|
113
|
+
|
|
114
|
+
// ===== endpoints.js =====
|
|
115
|
+
|
|
116
|
+
// Production endpoints baked into the SDK. These mirror Convbased-Web's
|
|
117
|
+
// `.env` (VITE_WS_ENDPOINT / VITE_API_BASE_URL) and are considered stable —
|
|
118
|
+
// they're consumed by every Convbased client today. Override only for
|
|
119
|
+
// self-hosted deployments or local development.
|
|
120
|
+
const DEFAULT_SIGNALING_URL = "wss://api.weights.chat/api/signaling/ws";
|
|
121
|
+
const DEFAULT_GRAPHQL_URL = "https://api.weights.chat/api/v1/graphql";
|
|
122
|
+
|
|
123
|
+
// ===== graphql.js =====
|
|
124
|
+
|
|
125
|
+
// Shared GraphQL transport for the Convbased service. Authentication mirrors
|
|
126
|
+
// the server middleware: `x-api-key: <key>` for API keys, or
|
|
127
|
+
// `Authorization: Bearer <jwt>` for access tokens.
|
|
128
|
+
/**
|
|
129
|
+
* Issue a single GraphQL operation and return `data`. Throws on HTTP failure
|
|
130
|
+
* or when the response carries a non-empty `errors` array — the thrown
|
|
131
|
+
* `Error.message` is the first server-reported message (often an i18n key the
|
|
132
|
+
* caller can localize).
|
|
133
|
+
*/
|
|
134
|
+
async function graphqlRequest(args) {
|
|
135
|
+
const headers = {
|
|
136
|
+
"content-type": "application/json",
|
|
137
|
+
};
|
|
138
|
+
if (args.apiKey)
|
|
139
|
+
headers["x-api-key"] = args.apiKey;
|
|
140
|
+
else if (args.accessToken) {
|
|
141
|
+
headers["authorization"] = `Bearer ${args.accessToken}`;
|
|
142
|
+
}
|
|
143
|
+
const res = await fetch(args.graphqlUrl, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify({ query: args.query, variables: args.variables }),
|
|
147
|
+
signal: args.signal,
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
throw new Error(`GraphQL request failed: HTTP ${res.status} ${res.statusText}`);
|
|
151
|
+
}
|
|
152
|
+
const json = (await res.json());
|
|
153
|
+
if (json.errors?.length) {
|
|
154
|
+
throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
155
|
+
}
|
|
156
|
+
if (json.data === undefined || json.data === null) {
|
|
157
|
+
throw new Error("GraphQL response contained no data");
|
|
158
|
+
}
|
|
159
|
+
return json.data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ===== upload.js =====
|
|
163
|
+
|
|
164
|
+
// Audio upload helpers shared by TTS (reference voice) and file inference
|
|
165
|
+
// (source audio). Two steps mirror Convbased-Web: ask the GraphQL service for
|
|
166
|
+
// a presigned PUT (`requestAudioUpload`), then PUT the bytes straight to object
|
|
167
|
+
// storage. The returned COS `key` is what you hand to `submitTts` /
|
|
168
|
+
// `startTask`.
|
|
169
|
+
const REQUEST_AUDIO_UPLOAD = /* GraphQL */ `
|
|
170
|
+
mutation RequestAudioUpload($input: RequestUploadInput!) {
|
|
171
|
+
requestAudioUpload(input: $input) {
|
|
172
|
+
key
|
|
173
|
+
upload_url
|
|
174
|
+
method
|
|
175
|
+
expires_in
|
|
176
|
+
headers {
|
|
177
|
+
name
|
|
178
|
+
value
|
|
179
|
+
}
|
|
180
|
+
bucket
|
|
181
|
+
region
|
|
182
|
+
url
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
/** Ask the service for a presigned PUT for an audio file. */
|
|
187
|
+
async function requestAudioUpload(args) {
|
|
188
|
+
const data = await graphqlRequest({
|
|
189
|
+
graphqlUrl: args.graphqlUrl,
|
|
190
|
+
apiKey: args.apiKey,
|
|
191
|
+
accessToken: args.accessToken,
|
|
192
|
+
signal: args.signal,
|
|
193
|
+
query: REQUEST_AUDIO_UPLOAD,
|
|
194
|
+
variables: {
|
|
195
|
+
input: {
|
|
196
|
+
filename: args.filename,
|
|
197
|
+
content_type: args.contentType,
|
|
198
|
+
size: args.size,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return data.requestAudioUpload;
|
|
203
|
+
}
|
|
204
|
+
/** PUT raw bytes to a presigned upload target. */
|
|
205
|
+
async function putToPresigned(presigned, body, signal) {
|
|
206
|
+
const headers = {};
|
|
207
|
+
for (const h of presigned.headers ?? []) {
|
|
208
|
+
if (h?.name)
|
|
209
|
+
headers[h.name] = h.value;
|
|
210
|
+
}
|
|
211
|
+
const res = await fetch(presigned.upload_url, {
|
|
212
|
+
method: presigned.method || "PUT",
|
|
213
|
+
headers,
|
|
214
|
+
body: body,
|
|
215
|
+
signal,
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new Error(`Audio upload failed: HTTP ${res.status} ${res.statusText}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Upload an audio `Blob`/`File` end-to-end (presign + PUT) and resolve the COS
|
|
223
|
+
* `key`. Filename and content type are taken from the `File` when available;
|
|
224
|
+
* override via `opts` when uploading a bare `Blob`.
|
|
225
|
+
*/
|
|
226
|
+
async function uploadAudio(args) {
|
|
227
|
+
const maybeFile = args.file;
|
|
228
|
+
const filename = args.filename ?? maybeFile.name ?? "audio.wav";
|
|
229
|
+
const contentType = args.contentType ||
|
|
230
|
+
args.file.type ||
|
|
231
|
+
"application/octet-stream";
|
|
232
|
+
const presigned = await requestAudioUpload({
|
|
233
|
+
graphqlUrl: args.graphqlUrl,
|
|
234
|
+
apiKey: args.apiKey,
|
|
235
|
+
accessToken: args.accessToken,
|
|
236
|
+
signal: args.signal,
|
|
237
|
+
filename,
|
|
238
|
+
contentType,
|
|
239
|
+
size: args.file.size,
|
|
240
|
+
});
|
|
241
|
+
await putToPresigned(presigned, args.file, args.signal);
|
|
242
|
+
return { key: presigned.key };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ===== signaling.js =====
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Thin WebSocket wrapper for the Convbased signaling endpoint. Builds the URL
|
|
249
|
+
* (`${base}/signaling/ws?api_key=…`), waits for `open`, and exposes JSON
|
|
250
|
+
* send/recv plus a 10s keepalive ping to match the server's expectations.
|
|
251
|
+
*/
|
|
252
|
+
class SignalingChannel {
|
|
253
|
+
constructor(opts) {
|
|
254
|
+
this.opts = opts;
|
|
255
|
+
this.ws = null;
|
|
256
|
+
this.pingTimer = null;
|
|
257
|
+
this.handlers = null;
|
|
258
|
+
}
|
|
259
|
+
get isOpen() {
|
|
260
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
261
|
+
}
|
|
262
|
+
async connect(handlers) {
|
|
263
|
+
if (this.ws) {
|
|
264
|
+
throw new Error("SignalingChannel is already connected");
|
|
265
|
+
}
|
|
266
|
+
this.handlers = handlers;
|
|
267
|
+
const url = this.buildUrl();
|
|
268
|
+
this.opts.logger.debug?.("[convbased-sdk] connecting signaling:", url);
|
|
269
|
+
const ws = new WebSocket(url);
|
|
270
|
+
this.ws = ws;
|
|
271
|
+
await new Promise((resolve, reject) => {
|
|
272
|
+
const timeout = setTimeout(() => {
|
|
273
|
+
cleanup();
|
|
274
|
+
try {
|
|
275
|
+
ws.close();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* ignore */
|
|
279
|
+
}
|
|
280
|
+
reject(new Error("Signaling WebSocket connect timeout"));
|
|
281
|
+
}, this.opts.connectTimeoutMs);
|
|
282
|
+
const onOpen = () => {
|
|
283
|
+
cleanup();
|
|
284
|
+
resolve();
|
|
285
|
+
};
|
|
286
|
+
const onError = (e) => {
|
|
287
|
+
cleanup();
|
|
288
|
+
reject(new Error(`Signaling WebSocket failed to open: ${describeEvent(e)}`));
|
|
289
|
+
};
|
|
290
|
+
const onClose = (e) => {
|
|
291
|
+
cleanup();
|
|
292
|
+
reject(new Error(`Signaling WebSocket closed before open (code=${e.code}, reason=${e.reason || "?"})`));
|
|
293
|
+
};
|
|
294
|
+
const cleanup = () => {
|
|
295
|
+
clearTimeout(timeout);
|
|
296
|
+
ws.removeEventListener("open", onOpen);
|
|
297
|
+
ws.removeEventListener("error", onError);
|
|
298
|
+
ws.removeEventListener("close", onClose);
|
|
299
|
+
};
|
|
300
|
+
ws.addEventListener("open", onOpen);
|
|
301
|
+
ws.addEventListener("error", onError);
|
|
302
|
+
ws.addEventListener("close", onClose);
|
|
303
|
+
});
|
|
304
|
+
ws.addEventListener("message", (e) => this.handleMessage(e));
|
|
305
|
+
ws.addEventListener("close", (e) => this.handleClose(e));
|
|
306
|
+
ws.addEventListener("error", (e) => this.handlers?.onError(e));
|
|
307
|
+
// The signaling server pings every 10s and expects us to keep the
|
|
308
|
+
// socket warm. We mirror that cadence rather than relying on TCP
|
|
309
|
+
// keepalive, which most browsers don't expose.
|
|
310
|
+
this.pingTimer = setInterval(() => {
|
|
311
|
+
if (this.isOpen)
|
|
312
|
+
this.send({ type: "ping" });
|
|
313
|
+
}, 10000);
|
|
314
|
+
}
|
|
315
|
+
send(msg) {
|
|
316
|
+
if (!this.isOpen) {
|
|
317
|
+
throw new Error("Signaling WebSocket is not open");
|
|
318
|
+
}
|
|
319
|
+
this.ws.send(JSON.stringify(msg));
|
|
320
|
+
}
|
|
321
|
+
close(code = 1000, reason = "client close") {
|
|
322
|
+
if (this.pingTimer) {
|
|
323
|
+
clearInterval(this.pingTimer);
|
|
324
|
+
this.pingTimer = null;
|
|
325
|
+
}
|
|
326
|
+
if (this.ws && this.ws.readyState <= WebSocket.OPEN) {
|
|
327
|
+
try {
|
|
328
|
+
this.ws.close(code, reason);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
/* ignore */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
this.ws = null;
|
|
335
|
+
}
|
|
336
|
+
handleMessage(event) {
|
|
337
|
+
if (typeof event.data !== "string")
|
|
338
|
+
return;
|
|
339
|
+
let parsed;
|
|
340
|
+
try {
|
|
341
|
+
parsed = JSON.parse(event.data);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
this.opts.logger.warn?.("[convbased-sdk] dropping non-JSON signaling frame:", event.data);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.handlers?.onMessage(parsed);
|
|
348
|
+
}
|
|
349
|
+
handleClose(event) {
|
|
350
|
+
if (this.pingTimer) {
|
|
351
|
+
clearInterval(this.pingTimer);
|
|
352
|
+
this.pingTimer = null;
|
|
353
|
+
}
|
|
354
|
+
this.handlers?.onClose(event);
|
|
355
|
+
}
|
|
356
|
+
buildUrl() {
|
|
357
|
+
// `signalingUrl` may be:
|
|
358
|
+
// - the full final URL ending in `/ws` (production default — used as-is)
|
|
359
|
+
// - a bare host (e.g. `ws://localhost:3010`) — we append `/signaling/ws`
|
|
360
|
+
// - something already containing `/signaling` — we append `/ws`
|
|
361
|
+
let base = this.opts.signalingUrl.trim();
|
|
362
|
+
if (base.endsWith("/"))
|
|
363
|
+
base = base.slice(0, -1);
|
|
364
|
+
let finalUrl;
|
|
365
|
+
if (/\/ws$/i.test(base)) {
|
|
366
|
+
finalUrl = base;
|
|
367
|
+
}
|
|
368
|
+
else if (/\/signaling$/i.test(base)) {
|
|
369
|
+
finalUrl = `${base}/ws`;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
finalUrl = `${base}/signaling/ws`;
|
|
373
|
+
}
|
|
374
|
+
const url = new URL(finalUrl);
|
|
375
|
+
if (this.opts.apiKey)
|
|
376
|
+
url.searchParams.set("api_key", this.opts.apiKey);
|
|
377
|
+
if (this.opts.accessToken && !this.opts.apiKey) {
|
|
378
|
+
url.searchParams.set("token", this.opts.accessToken);
|
|
379
|
+
}
|
|
380
|
+
return url.toString();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function describeEvent(e) {
|
|
384
|
+
if (e instanceof ErrorEvent && e.message)
|
|
385
|
+
return e.message;
|
|
386
|
+
return e.type || "unknown error";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ===== rtcServers.js =====
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fetch TURN credentials from the Convbased GraphQL service. The query matches
|
|
393
|
+
* the one used by Convbased-Web's `getRTCServers`. Authentication uses the
|
|
394
|
+
* same API key / access token passed to the SDK.
|
|
395
|
+
*/
|
|
396
|
+
async function fetchRTCServers(args) {
|
|
397
|
+
const data = await graphqlRequest({
|
|
398
|
+
graphqlUrl: args.graphqlUrl,
|
|
399
|
+
apiKey: args.apiKey,
|
|
400
|
+
accessToken: args.accessToken,
|
|
401
|
+
signal: args.signal,
|
|
402
|
+
query: /* GraphQL */ `
|
|
403
|
+
query {
|
|
404
|
+
rtcServers {
|
|
405
|
+
urls
|
|
406
|
+
username
|
|
407
|
+
credential
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
`,
|
|
411
|
+
});
|
|
412
|
+
if (!data.rtcServers) {
|
|
413
|
+
throw new Error("rtcServers returned an empty payload");
|
|
414
|
+
}
|
|
415
|
+
return data.rtcServers;
|
|
416
|
+
}
|
|
417
|
+
const DEFAULT_STUN_SERVERS = [
|
|
418
|
+
{ urls: ["stun:stun.l.google.com:19302"] },
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
// ===== tts.js =====
|
|
422
|
+
|
|
423
|
+
// Text-to-speech client for the Convbased IndexTTS2 service. This path is pure
|
|
424
|
+
// GraphQL — it does not touch WebRTC or the signaling socket. Synthesis is
|
|
425
|
+
// asynchronous: you submit a job, then poll until it reaches a terminal state.
|
|
426
|
+
//
|
|
427
|
+
// Typical flow:
|
|
428
|
+
// const tts = new TtsClient({ apiKey });
|
|
429
|
+
// const { key } = await tts.uploadReferenceAudio(file); // reference voice
|
|
430
|
+
// const result = await tts.synthesize({ referenceKey: key, text: "你好" });
|
|
431
|
+
// audio.src = result.url; // presigned, ~1h
|
|
432
|
+
const JOB_FIELDS = /* GraphQL */ `
|
|
433
|
+
job_id
|
|
434
|
+
status
|
|
435
|
+
position
|
|
436
|
+
result {
|
|
437
|
+
key
|
|
438
|
+
url
|
|
439
|
+
token_count
|
|
440
|
+
audio_duration_sec
|
|
441
|
+
amount_charged
|
|
442
|
+
balance_after
|
|
443
|
+
}
|
|
444
|
+
error
|
|
445
|
+
`;
|
|
446
|
+
class TtsClient {
|
|
447
|
+
constructor(options) {
|
|
448
|
+
if (!options.apiKey && !options.accessToken) {
|
|
449
|
+
throw new Error("TtsClient requires either `apiKey` or `accessToken`");
|
|
450
|
+
}
|
|
451
|
+
this.graphqlUrl = options.graphqlUrl ?? DEFAULT_GRAPHQL_URL;
|
|
452
|
+
this.apiKey = options.apiKey;
|
|
453
|
+
this.accessToken = options.accessToken;
|
|
454
|
+
const provided = options.logger ?? {};
|
|
455
|
+
this.logger = {
|
|
456
|
+
debug: provided.debug ?? (() => { }),
|
|
457
|
+
info: provided.info ?? (() => { }),
|
|
458
|
+
warn: provided.warn ?? console.warn.bind(console),
|
|
459
|
+
error: provided.error ?? console.error.bind(console),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** Upload a reference-voice `Blob`/`File` and resolve its COS key. */
|
|
463
|
+
async uploadReferenceAudio(file, opts) {
|
|
464
|
+
return uploadAudio({
|
|
465
|
+
graphqlUrl: this.graphqlUrl,
|
|
466
|
+
apiKey: this.apiKey,
|
|
467
|
+
accessToken: this.accessToken,
|
|
468
|
+
file,
|
|
469
|
+
filename: opts?.filename,
|
|
470
|
+
contentType: opts?.contentType,
|
|
471
|
+
signal: opts?.signal,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/** Current billing rule: `cost = max(tokens * pricePerToken, minCharge)`. */
|
|
475
|
+
async getPricing(signal) {
|
|
476
|
+
const data = await graphqlRequest({
|
|
477
|
+
graphqlUrl: this.graphqlUrl,
|
|
478
|
+
apiKey: this.apiKey,
|
|
479
|
+
accessToken: this.accessToken,
|
|
480
|
+
signal,
|
|
481
|
+
query: /* GraphQL */ `
|
|
482
|
+
query {
|
|
483
|
+
ttsPricing {
|
|
484
|
+
price_per_token
|
|
485
|
+
min_charge
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
`,
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
pricePerToken: data.ttsPricing.price_per_token,
|
|
492
|
+
minCharge: data.ttsPricing.min_charge,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/** Enqueue a synthesis job; resolves immediately with the queued job. */
|
|
496
|
+
async submit(opts, signal) {
|
|
497
|
+
const data = await graphqlRequest({
|
|
498
|
+
graphqlUrl: this.graphqlUrl,
|
|
499
|
+
apiKey: this.apiKey,
|
|
500
|
+
accessToken: this.accessToken,
|
|
501
|
+
signal,
|
|
502
|
+
query: /* GraphQL */ `
|
|
503
|
+
mutation SubmitTts($input: SynthesizeTtsInput!) {
|
|
504
|
+
submitTts(input: $input) {
|
|
505
|
+
${JOB_FIELDS}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
`,
|
|
509
|
+
variables: {
|
|
510
|
+
input: {
|
|
511
|
+
reference_key: opts.referenceKey,
|
|
512
|
+
text: opts.text,
|
|
513
|
+
params: opts.params ?? null,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
return toJob(data.submitTts);
|
|
518
|
+
}
|
|
519
|
+
/** Read the current status/result of a job. */
|
|
520
|
+
async getJob(jobId, signal) {
|
|
521
|
+
const data = await graphqlRequest({
|
|
522
|
+
graphqlUrl: this.graphqlUrl,
|
|
523
|
+
apiKey: this.apiKey,
|
|
524
|
+
accessToken: this.accessToken,
|
|
525
|
+
signal,
|
|
526
|
+
query: /* GraphQL */ `
|
|
527
|
+
query TtsJob($jobId: String!) {
|
|
528
|
+
ttsJob(jobId: $jobId) {
|
|
529
|
+
${JOB_FIELDS}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
`,
|
|
533
|
+
variables: { jobId },
|
|
534
|
+
});
|
|
535
|
+
return toJob(data.ttsJob);
|
|
536
|
+
}
|
|
537
|
+
/** Cancel a job. Only effective while it is still `queued`. */
|
|
538
|
+
async cancel(jobId, signal) {
|
|
539
|
+
const data = await graphqlRequest({
|
|
540
|
+
graphqlUrl: this.graphqlUrl,
|
|
541
|
+
apiKey: this.apiKey,
|
|
542
|
+
accessToken: this.accessToken,
|
|
543
|
+
signal,
|
|
544
|
+
query: /* GraphQL */ `
|
|
545
|
+
mutation CancelTtsJob($jobId: String!) {
|
|
546
|
+
cancelTtsJob(jobId: $jobId) {
|
|
547
|
+
${JOB_FIELDS}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
`,
|
|
551
|
+
variables: { jobId },
|
|
552
|
+
});
|
|
553
|
+
return toJob(data.cancelTtsJob);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* One-call synthesis: (optionally upload the reference voice,) submit, then
|
|
557
|
+
* poll until the job finishes. Resolves with the `TtsResult` on success;
|
|
558
|
+
* rejects if the job fails/cancels, times out, or `signal` aborts.
|
|
559
|
+
*/
|
|
560
|
+
async synthesize(opts) {
|
|
561
|
+
if (!opts.referenceKey && !opts.referenceAudio) {
|
|
562
|
+
throw new Error("synthesize() requires either `referenceKey` or `referenceAudio`");
|
|
563
|
+
}
|
|
564
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 1500;
|
|
565
|
+
const timeoutMs = opts.timeoutMs ?? 300000;
|
|
566
|
+
const deadline = Date.now() + timeoutMs;
|
|
567
|
+
const referenceKey = opts.referenceKey ??
|
|
568
|
+
(await this.uploadReferenceAudio(opts.referenceAudio, {
|
|
569
|
+
signal: opts.signal,
|
|
570
|
+
})).key;
|
|
571
|
+
const submitted = await this.submit({ referenceKey, text: opts.text, params: opts.params }, opts.signal);
|
|
572
|
+
opts.onJob?.(submitted);
|
|
573
|
+
let job = submitted;
|
|
574
|
+
try {
|
|
575
|
+
while (job.status !== "done") {
|
|
576
|
+
if (opts.signal?.aborted) {
|
|
577
|
+
throw new DOMException("Aborted", "AbortError");
|
|
578
|
+
}
|
|
579
|
+
if (job.status === "failed") {
|
|
580
|
+
throw new Error(job.error || "TTS job failed");
|
|
581
|
+
}
|
|
582
|
+
if (job.status === "cancelled") {
|
|
583
|
+
throw new Error("TTS job was cancelled");
|
|
584
|
+
}
|
|
585
|
+
if (Date.now() > deadline) {
|
|
586
|
+
throw new Error(`Timed out waiting for TTS job ${job.jobId} after ${timeoutMs}ms`);
|
|
587
|
+
}
|
|
588
|
+
await delay(pollIntervalMs, opts.signal);
|
|
589
|
+
job = await this.getJob(job.jobId, opts.signal);
|
|
590
|
+
opts.onJob?.(job);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
// Best-effort: stop a still-queued job so we don't pay for a result
|
|
595
|
+
// nobody is waiting for.
|
|
596
|
+
if (opts.signal?.aborted) {
|
|
597
|
+
this.cancel(job.jobId).catch(() => { });
|
|
598
|
+
}
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
if (!job.result) {
|
|
602
|
+
throw new Error("TTS job is done but carried no result");
|
|
603
|
+
}
|
|
604
|
+
return job.result;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function toJob(wire) {
|
|
608
|
+
return {
|
|
609
|
+
jobId: wire.job_id,
|
|
610
|
+
status: wire.status,
|
|
611
|
+
position: wire.position,
|
|
612
|
+
result: wire.result
|
|
613
|
+
? {
|
|
614
|
+
key: wire.result.key,
|
|
615
|
+
url: wire.result.url,
|
|
616
|
+
tokenCount: wire.result.token_count,
|
|
617
|
+
audioDurationSec: wire.result.audio_duration_sec,
|
|
618
|
+
amountCharged: wire.result.amount_charged,
|
|
619
|
+
balanceAfter: wire.result.balance_after,
|
|
620
|
+
}
|
|
621
|
+
: null,
|
|
622
|
+
error: wire.error,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function delay(ms, signal) {
|
|
626
|
+
return new Promise((resolve, reject) => {
|
|
627
|
+
if (signal?.aborted) {
|
|
628
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const timer = setTimeout(() => {
|
|
632
|
+
signal?.removeEventListener("abort", onAbort);
|
|
633
|
+
resolve();
|
|
634
|
+
}, ms);
|
|
635
|
+
const onAbort = () => {
|
|
636
|
+
clearTimeout(timer);
|
|
637
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
638
|
+
};
|
|
639
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ===== client.js =====
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Real-time voice conversion client. Mirrors the flow used by Convbased-Web:
|
|
647
|
+
*
|
|
648
|
+
* 1. Open WebSocket to `${signalingUrl}/signaling/ws?api_key=…`.
|
|
649
|
+
* 2. Capture the microphone (or accept a user-provided `MediaStream`).
|
|
650
|
+
* 3. Build an `RTCPeerConnection`, attach the mic track, mangle the offer's
|
|
651
|
+
* Opus parameters, and send `{type: "offer", sdp, preferences}` over the
|
|
652
|
+
* signaling socket.
|
|
653
|
+
* 4. Apply the `answer` and `ice_candidate` messages from the server, fire
|
|
654
|
+
* local ICE candidates back over signaling.
|
|
655
|
+
* 5. When the server sends `{code: SERVICE_READY}` the processed track is
|
|
656
|
+
* emitted via the `track` event — wire it to an `<audio>` element to hear
|
|
657
|
+
* converted audio.
|
|
658
|
+
*
|
|
659
|
+
* The client is single-use: call `connect()` once, then `disconnect()` to
|
|
660
|
+
* tear everything down. Create a fresh instance for a new session.
|
|
661
|
+
*/
|
|
662
|
+
class ConvbasedClient extends TypedEmitter {
|
|
663
|
+
constructor(options) {
|
|
664
|
+
super();
|
|
665
|
+
this.state = "idle";
|
|
666
|
+
this.signaling = null;
|
|
667
|
+
this.pc = null;
|
|
668
|
+
this.localStream = null;
|
|
669
|
+
this.convertedStream = null;
|
|
670
|
+
this.serviceReadyTimer = null;
|
|
671
|
+
this.offerInFlight = false;
|
|
672
|
+
if (!options.apiKey && !options.accessToken) {
|
|
673
|
+
throw new Error("ConvbasedClient requires either `apiKey` or `accessToken`");
|
|
674
|
+
}
|
|
675
|
+
this.opts = {
|
|
676
|
+
iceTransportPolicy: "all",
|
|
677
|
+
bitrate: 64,
|
|
678
|
+
stereo: false,
|
|
679
|
+
signalingTimeoutMs: 120000,
|
|
680
|
+
connectTimeoutMs: 20000,
|
|
681
|
+
...options,
|
|
682
|
+
// Endpoints fall back to the baked-in Convbased production URLs.
|
|
683
|
+
signalingUrl: options.signalingUrl ?? DEFAULT_SIGNALING_URL,
|
|
684
|
+
graphqlUrl: options.graphqlUrl === false
|
|
685
|
+
? false
|
|
686
|
+
: (options.graphqlUrl ?? DEFAULT_GRAPHQL_URL),
|
|
687
|
+
};
|
|
688
|
+
const provided = options.logger ?? {};
|
|
689
|
+
this.logger = {
|
|
690
|
+
debug: provided.debug ?? (() => { }),
|
|
691
|
+
info: provided.info ?? (() => { }),
|
|
692
|
+
warn: provided.warn ?? console.warn.bind(console),
|
|
693
|
+
error: provided.error ?? console.error.bind(console),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
getState() {
|
|
697
|
+
return this.state;
|
|
698
|
+
}
|
|
699
|
+
/** The converted (voice-changed) audio stream returned from the inference node. */
|
|
700
|
+
getConvertedStream() {
|
|
701
|
+
return this.convertedStream;
|
|
702
|
+
}
|
|
703
|
+
getPeerConnection() {
|
|
704
|
+
return this.pc;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Open the signaling socket, capture audio, and run WebRTC negotiation.
|
|
708
|
+
* Resolves when the server emits SERVICE_READY (audio is flowing both ways).
|
|
709
|
+
* Rejects on auth failures, signaling errors, or negotiation timeouts.
|
|
710
|
+
*/
|
|
711
|
+
async connect(opts) {
|
|
712
|
+
if (this.state !== "idle") {
|
|
713
|
+
throw new Error(`ConvbasedClient.connect() called from invalid state "${this.state}"`);
|
|
714
|
+
}
|
|
715
|
+
if (!opts.modelId) {
|
|
716
|
+
throw new Error("ConnectOptions.modelId is required");
|
|
717
|
+
}
|
|
718
|
+
this.setState("signaling");
|
|
719
|
+
try {
|
|
720
|
+
await this.openSignaling();
|
|
721
|
+
const iceServers = await this.resolveIceServers();
|
|
722
|
+
this.setState("negotiating");
|
|
723
|
+
await this.openPeer(opts, iceServers);
|
|
724
|
+
await this.waitForServiceReady();
|
|
725
|
+
this.setState("connected");
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
729
|
+
this.emit("error", error);
|
|
730
|
+
await this.disconnect().catch(() => { });
|
|
731
|
+
this.setState("error");
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Send a runtime config update to the inference node. Equivalent to the
|
|
737
|
+
* pitch / formant / RMS sliders in Convbased-Web — only the fields you set
|
|
738
|
+
* are forwarded.
|
|
739
|
+
*/
|
|
740
|
+
updateConfig(preferences) {
|
|
741
|
+
if (!this.signaling?.isOpen) {
|
|
742
|
+
throw new Error("Cannot updateConfig: signaling channel is closed");
|
|
743
|
+
}
|
|
744
|
+
this.signaling.send({ type: "config", preferences });
|
|
745
|
+
}
|
|
746
|
+
// ---------------------------------------------------------------------
|
|
747
|
+
// File inference (voice-to-voice) — convert an uploaded audio file within
|
|
748
|
+
// the current live session. Requires the client to be `connected` first;
|
|
749
|
+
// the inference node is provisioned by `connect()`.
|
|
750
|
+
// ---------------------------------------------------------------------
|
|
751
|
+
/**
|
|
752
|
+
* Upload a source-audio `Blob`/`File` and resolve its COS key, ready to pass
|
|
753
|
+
* to `startTask` / `runFileInference`. Requires `graphqlUrl` (the default
|
|
754
|
+
* production endpoint, or a self-hosted override — not `false`).
|
|
755
|
+
*/
|
|
756
|
+
async uploadAudio(file, opts) {
|
|
757
|
+
if (typeof this.opts.graphqlUrl !== "string") {
|
|
758
|
+
throw new Error("uploadAudio requires a GraphQL endpoint; do not set `graphqlUrl: false`");
|
|
759
|
+
}
|
|
760
|
+
return uploadAudio({
|
|
761
|
+
graphqlUrl: this.opts.graphqlUrl,
|
|
762
|
+
apiKey: this.opts.apiKey,
|
|
763
|
+
accessToken: this.opts.accessToken,
|
|
764
|
+
file,
|
|
765
|
+
filename: opts?.filename,
|
|
766
|
+
contentType: opts?.contentType,
|
|
767
|
+
signal: opts?.signal,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Submit a file-inference task over the signaling channel and return its
|
|
772
|
+
* task id. The client must be `connected`. Results arrive asynchronously via
|
|
773
|
+
* the `taskAck` / `taskProgress` / `taskFinished` events — use
|
|
774
|
+
* `runFileInference` for a promise-based wrapper.
|
|
775
|
+
*/
|
|
776
|
+
startTask(opts) {
|
|
777
|
+
if (this.state !== "connected") {
|
|
778
|
+
throw new Error(`startTask requires a connected session (current state "${this.state}")`);
|
|
779
|
+
}
|
|
780
|
+
if (!this.signaling?.isOpen) {
|
|
781
|
+
throw new Error("Cannot startTask: signaling channel is closed");
|
|
782
|
+
}
|
|
783
|
+
if (!opts.audioKey) {
|
|
784
|
+
throw new Error("StartTaskOptions.audioKey is required");
|
|
785
|
+
}
|
|
786
|
+
const taskId = opts.taskId ?? generateTaskId();
|
|
787
|
+
this.signaling.send({
|
|
788
|
+
type: "task_start",
|
|
789
|
+
task_id: taskId,
|
|
790
|
+
audio_key: opts.audioKey,
|
|
791
|
+
generate_name: opts.generateName ?? "output",
|
|
792
|
+
format: opts.format ?? "wav",
|
|
793
|
+
preferences: opts.preferences,
|
|
794
|
+
});
|
|
795
|
+
return taskId;
|
|
796
|
+
}
|
|
797
|
+
/** Cancel a file-inference task. Omit `taskId` to stop the current one. */
|
|
798
|
+
stopTask(taskId) {
|
|
799
|
+
if (!this.signaling?.isOpen) {
|
|
800
|
+
throw new Error("Cannot stopTask: signaling channel is closed");
|
|
801
|
+
}
|
|
802
|
+
this.signaling.send({ type: "task_stop", task_id: taskId });
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Promise-based file inference: (optionally upload the source audio,) start
|
|
806
|
+
* the task, and resolve with the `taskFinished` event on success. Rejects on
|
|
807
|
+
* task failure/cancellation, timeout, signaling close, or `signal` abort.
|
|
808
|
+
*/
|
|
809
|
+
async runFileInference(opts) {
|
|
810
|
+
if (!opts.audioKey && !opts.audio) {
|
|
811
|
+
throw new Error("runFileInference requires either `audioKey` or `audio`");
|
|
812
|
+
}
|
|
813
|
+
const audioKey = opts.audioKey ??
|
|
814
|
+
(await this.uploadAudio(opts.audio, { signal: opts.signal })).key;
|
|
815
|
+
const timeoutMs = opts.timeoutMs ?? 300000;
|
|
816
|
+
return new Promise((resolve, reject) => {
|
|
817
|
+
let taskId;
|
|
818
|
+
let timer = null;
|
|
819
|
+
const cleanup = () => {
|
|
820
|
+
offAck();
|
|
821
|
+
offProgress();
|
|
822
|
+
offFinished();
|
|
823
|
+
offErr();
|
|
824
|
+
offClosed();
|
|
825
|
+
if (timer)
|
|
826
|
+
clearTimeout(timer);
|
|
827
|
+
if (opts.signal)
|
|
828
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
829
|
+
};
|
|
830
|
+
const settleErr = (err) => {
|
|
831
|
+
cleanup();
|
|
832
|
+
reject(err);
|
|
833
|
+
};
|
|
834
|
+
const onAbort = () => {
|
|
835
|
+
try {
|
|
836
|
+
this.stopTask(taskId);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
/* ignore */
|
|
840
|
+
}
|
|
841
|
+
settleErr(new DOMException("Aborted", "AbortError"));
|
|
842
|
+
};
|
|
843
|
+
const offAck = this.on("taskAck", (e) => {
|
|
844
|
+
if (e.taskId === taskId)
|
|
845
|
+
opts.onAck?.(e);
|
|
846
|
+
});
|
|
847
|
+
const offProgress = this.on("taskProgress", (e) => {
|
|
848
|
+
if (e.taskId === taskId)
|
|
849
|
+
opts.onProgress?.(e);
|
|
850
|
+
});
|
|
851
|
+
const offFinished = this.on("taskFinished", (e) => {
|
|
852
|
+
if (e.taskId !== taskId)
|
|
853
|
+
return;
|
|
854
|
+
if (e.status === "success") {
|
|
855
|
+
cleanup();
|
|
856
|
+
resolve(e);
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
settleErr(new Error(e.error || `File inference task ${e.status}`));
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
const offErr = this.on("error", (err) => settleErr(err));
|
|
863
|
+
const offClosed = this.on("closed", () => settleErr(new Error("Session closed before task finished")));
|
|
864
|
+
if (opts.signal?.aborted) {
|
|
865
|
+
offAck();
|
|
866
|
+
offProgress();
|
|
867
|
+
offFinished();
|
|
868
|
+
offErr();
|
|
869
|
+
offClosed();
|
|
870
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
874
|
+
timer = setTimeout(() => {
|
|
875
|
+
settleErr(new Error(`Timed out waiting for file inference task after ${timeoutMs}ms`));
|
|
876
|
+
}, timeoutMs);
|
|
877
|
+
try {
|
|
878
|
+
taskId = this.startTask({
|
|
879
|
+
audioKey,
|
|
880
|
+
taskId: opts.taskId,
|
|
881
|
+
generateName: opts.generateName,
|
|
882
|
+
format: opts.format,
|
|
883
|
+
preferences: opts.preferences,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
887
|
+
settleErr(err instanceof Error ? err : new Error(String(err)));
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/** Mute / unmute the local mic by toggling the captured audio track. */
|
|
892
|
+
setMuted(muted) {
|
|
893
|
+
const track = this.localStream?.getAudioTracks()[0];
|
|
894
|
+
if (track)
|
|
895
|
+
track.enabled = !muted;
|
|
896
|
+
}
|
|
897
|
+
/** Replace the local input stream (mic) with a different `MediaStream` (hot-swap). */
|
|
898
|
+
async replaceLocalStream(newStream) {
|
|
899
|
+
if (!this.pc)
|
|
900
|
+
throw new Error("PeerConnection is not active");
|
|
901
|
+
const newTrack = newStream.getAudioTracks()[0];
|
|
902
|
+
if (!newTrack)
|
|
903
|
+
throw new Error("Replacement stream has no audio track");
|
|
904
|
+
const sender = this.pc
|
|
905
|
+
.getSenders()
|
|
906
|
+
.find((s) => s.track?.kind === "audio");
|
|
907
|
+
if (!sender)
|
|
908
|
+
throw new Error("No audio sender on PeerConnection");
|
|
909
|
+
await sender.replaceTrack(newTrack);
|
|
910
|
+
this.stopTracks(this.localStream);
|
|
911
|
+
this.localStream = newStream;
|
|
912
|
+
}
|
|
913
|
+
/** Snapshot of jitter / loss / RTT (sampled from `RTCPeerConnection.getStats`). */
|
|
914
|
+
async getStats() {
|
|
915
|
+
if (!this.pc || this.pc.connectionState !== "connected")
|
|
916
|
+
return null;
|
|
917
|
+
const report = await this.pc.getStats();
|
|
918
|
+
const stats = { rttMs: 0, jitter: 0, packetsLost: 0 };
|
|
919
|
+
report.forEach((entry) => {
|
|
920
|
+
if (entry.type === "candidate-pair" &&
|
|
921
|
+
entry.state === "succeeded" &&
|
|
922
|
+
typeof entry.currentRoundTripTime === "number") {
|
|
923
|
+
stats.rttMs = Math.round(entry.currentRoundTripTime * 1000);
|
|
924
|
+
}
|
|
925
|
+
if (entry.type === "inbound-rtp" && entry.kind === "audio") {
|
|
926
|
+
if (typeof entry.jitter === "number")
|
|
927
|
+
stats.jitter = entry.jitter;
|
|
928
|
+
if (typeof entry.packetsLost === "number") {
|
|
929
|
+
stats.packetsLost = entry.packetsLost;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
return stats;
|
|
934
|
+
}
|
|
935
|
+
/** Gracefully end the session — notifies the server, closes the PC. */
|
|
936
|
+
async disconnect() {
|
|
937
|
+
if (this.state === "closed" || this.state === "closing")
|
|
938
|
+
return;
|
|
939
|
+
this.setState("closing");
|
|
940
|
+
this.clearServiceReadyTimer();
|
|
941
|
+
try {
|
|
942
|
+
if (this.signaling?.isOpen) {
|
|
943
|
+
try {
|
|
944
|
+
this.signaling.send({ type: "exit" });
|
|
945
|
+
// Give the frame a tick to flush before we yank the socket.
|
|
946
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
/* socket already gone */
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
this.tearDownPeer();
|
|
955
|
+
this.signaling?.close();
|
|
956
|
+
this.signaling = null;
|
|
957
|
+
this.setState("closed");
|
|
958
|
+
this.emit("closed", {});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// ---------------------------------------------------------------------
|
|
962
|
+
// Internal
|
|
963
|
+
// ---------------------------------------------------------------------
|
|
964
|
+
async openSignaling() {
|
|
965
|
+
const channel = new SignalingChannel({
|
|
966
|
+
signalingUrl: this.opts.signalingUrl,
|
|
967
|
+
apiKey: this.opts.apiKey,
|
|
968
|
+
accessToken: this.opts.accessToken,
|
|
969
|
+
connectTimeoutMs: this.opts.connectTimeoutMs,
|
|
970
|
+
logger: this.logger,
|
|
971
|
+
});
|
|
972
|
+
await channel.connect({
|
|
973
|
+
onMessage: (msg) => this.handleSignalingMessage(msg),
|
|
974
|
+
onClose: (e) => this.handleSignalingClose(e),
|
|
975
|
+
onError: (e) => this.logger.warn?.("[convbased-sdk] signaling error event", e),
|
|
976
|
+
});
|
|
977
|
+
this.signaling = channel;
|
|
978
|
+
}
|
|
979
|
+
async resolveIceServers() {
|
|
980
|
+
if (this.opts.iceServers?.length)
|
|
981
|
+
return this.opts.iceServers;
|
|
982
|
+
if (typeof this.opts.graphqlUrl === "string") {
|
|
983
|
+
try {
|
|
984
|
+
const cfg = await fetchRTCServers({
|
|
985
|
+
graphqlUrl: this.opts.graphqlUrl,
|
|
986
|
+
apiKey: this.opts.apiKey,
|
|
987
|
+
accessToken: this.opts.accessToken,
|
|
988
|
+
});
|
|
989
|
+
if (cfg.urls?.length)
|
|
990
|
+
return [cfg];
|
|
991
|
+
}
|
|
992
|
+
catch (e) {
|
|
993
|
+
this.logger.warn?.("[convbased-sdk] fetchRTCServers failed, falling back to STUN:", e);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return DEFAULT_STUN_SERVERS;
|
|
997
|
+
}
|
|
998
|
+
async openPeer(opts, iceServers) {
|
|
999
|
+
const pc = new RTCPeerConnection({
|
|
1000
|
+
iceServers: iceServers,
|
|
1001
|
+
iceTransportPolicy: this.opts.iceTransportPolicy,
|
|
1002
|
+
});
|
|
1003
|
+
this.pc = pc;
|
|
1004
|
+
pc.onicecandidate = (event) => {
|
|
1005
|
+
if (event.candidate && this.signaling?.isOpen) {
|
|
1006
|
+
this.signaling.send({
|
|
1007
|
+
type: "ice_candidate",
|
|
1008
|
+
candidate: event.candidate.toJSON(),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
pc.ontrack = (event) => {
|
|
1013
|
+
const stream = event.streams[0] ?? new MediaStream([event.track]);
|
|
1014
|
+
this.convertedStream = stream;
|
|
1015
|
+
this.emit("track", { stream, track: event.track });
|
|
1016
|
+
};
|
|
1017
|
+
pc.onconnectionstatechange = () => {
|
|
1018
|
+
const cs = pc.connectionState;
|
|
1019
|
+
this.logger.debug?.("[convbased-sdk] pc state:", cs);
|
|
1020
|
+
if (cs === "connecting") {
|
|
1021
|
+
if (this.state === "negotiating")
|
|
1022
|
+
this.setState("connecting");
|
|
1023
|
+
}
|
|
1024
|
+
else if (cs === "failed" || cs === "disconnected" || cs === "closed") {
|
|
1025
|
+
if (this.state !== "closing" && this.state !== "closed") {
|
|
1026
|
+
this.emit("error", new Error(`PeerConnection entered "${cs}" state`));
|
|
1027
|
+
void this.disconnect();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
this.localStream = await this.acquireLocalStream(opts.audio);
|
|
1032
|
+
for (const track of this.localStream.getAudioTracks()) {
|
|
1033
|
+
pc.addTrack(track, this.localStream);
|
|
1034
|
+
}
|
|
1035
|
+
const offer = await pc.createOffer({ offerToReceiveAudio: true });
|
|
1036
|
+
offer.sdp = applyOpusSdpOptions(offer.sdp ?? "", {
|
|
1037
|
+
bitrateKbps: this.opts.bitrate,
|
|
1038
|
+
stereo: this.opts.stereo,
|
|
1039
|
+
});
|
|
1040
|
+
await pc.setLocalDescription(offer);
|
|
1041
|
+
const sampleRate = opts.sampleRate ??
|
|
1042
|
+
detectSampleRate(this.localStream) ??
|
|
1043
|
+
48000;
|
|
1044
|
+
const preferences = {
|
|
1045
|
+
...(opts.preferences ?? {}),
|
|
1046
|
+
model_id: opts.modelId,
|
|
1047
|
+
sample_rate: sampleRate,
|
|
1048
|
+
};
|
|
1049
|
+
if (!this.signaling?.isOpen) {
|
|
1050
|
+
throw new Error("Signaling channel closed before offer was sent");
|
|
1051
|
+
}
|
|
1052
|
+
this.offerInFlight = true;
|
|
1053
|
+
this.signaling.send({ type: "offer", sdp: offer.sdp, preferences });
|
|
1054
|
+
}
|
|
1055
|
+
async acquireLocalStream(audio) {
|
|
1056
|
+
if (audio instanceof MediaStream)
|
|
1057
|
+
return audio;
|
|
1058
|
+
const constraints = {
|
|
1059
|
+
audio: audio === undefined ? true : audio,
|
|
1060
|
+
video: false,
|
|
1061
|
+
};
|
|
1062
|
+
if (typeof navigator === "undefined" ||
|
|
1063
|
+
!navigator.mediaDevices?.getUserMedia) {
|
|
1064
|
+
throw new Error("navigator.mediaDevices.getUserMedia is unavailable — pass a MediaStream via `audio`");
|
|
1065
|
+
}
|
|
1066
|
+
return navigator.mediaDevices.getUserMedia(constraints);
|
|
1067
|
+
}
|
|
1068
|
+
waitForServiceReady() {
|
|
1069
|
+
return new Promise((resolve, reject) => {
|
|
1070
|
+
const off = this.on("ready", () => {
|
|
1071
|
+
cleanup();
|
|
1072
|
+
resolve();
|
|
1073
|
+
});
|
|
1074
|
+
const offErr = this.on("error", (err) => {
|
|
1075
|
+
cleanup();
|
|
1076
|
+
reject(err);
|
|
1077
|
+
});
|
|
1078
|
+
const offClosed = this.on("closed", () => {
|
|
1079
|
+
cleanup();
|
|
1080
|
+
reject(new Error("Signaling closed before SERVICE_READY"));
|
|
1081
|
+
});
|
|
1082
|
+
this.serviceReadyTimer = setTimeout(() => {
|
|
1083
|
+
cleanup();
|
|
1084
|
+
reject(new Error(`Timed out waiting for SERVICE_READY after ${this.opts.signalingTimeoutMs}ms`));
|
|
1085
|
+
}, this.opts.signalingTimeoutMs);
|
|
1086
|
+
const cleanup = () => {
|
|
1087
|
+
off();
|
|
1088
|
+
offErr();
|
|
1089
|
+
offClosed();
|
|
1090
|
+
this.clearServiceReadyTimer();
|
|
1091
|
+
};
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
clearServiceReadyTimer() {
|
|
1095
|
+
if (this.serviceReadyTimer) {
|
|
1096
|
+
clearTimeout(this.serviceReadyTimer);
|
|
1097
|
+
this.serviceReadyTimer = null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
handleSignalingMessage(msg) {
|
|
1101
|
+
const type = msg.type;
|
|
1102
|
+
this.emit("message", {
|
|
1103
|
+
code: msg.code,
|
|
1104
|
+
message: msg.message,
|
|
1105
|
+
raw: msg,
|
|
1106
|
+
});
|
|
1107
|
+
switch (type) {
|
|
1108
|
+
case "answer":
|
|
1109
|
+
void this.applyAnswer(msg);
|
|
1110
|
+
break;
|
|
1111
|
+
case "ice_candidate":
|
|
1112
|
+
void this.applyRemoteCandidate(msg);
|
|
1113
|
+
break;
|
|
1114
|
+
case "task_ack": {
|
|
1115
|
+
const m = msg;
|
|
1116
|
+
this.emit("taskAck", {
|
|
1117
|
+
taskId: m.task_id,
|
|
1118
|
+
status: m.status,
|
|
1119
|
+
queuePosition: m.queue_position,
|
|
1120
|
+
code: m.code,
|
|
1121
|
+
});
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
case "task_progress": {
|
|
1125
|
+
const m = msg;
|
|
1126
|
+
this.emit("taskProgress", {
|
|
1127
|
+
taskId: m.task_id,
|
|
1128
|
+
progress: m.progress,
|
|
1129
|
+
code: m.code,
|
|
1130
|
+
});
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
case "task_finished": {
|
|
1134
|
+
const m = msg;
|
|
1135
|
+
this.emit("taskFinished", {
|
|
1136
|
+
taskId: m.task_id,
|
|
1137
|
+
status: m.status,
|
|
1138
|
+
resultKey: m.result_key,
|
|
1139
|
+
downloadUrl: m.download_url,
|
|
1140
|
+
error: m.error,
|
|
1141
|
+
code: m.code,
|
|
1142
|
+
});
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
case "message": {
|
|
1146
|
+
const code = msg.code;
|
|
1147
|
+
const text = msg.message;
|
|
1148
|
+
if (code === RTCStatusCode.SERVICE_READY) {
|
|
1149
|
+
this.emit("ready", { code, message: text });
|
|
1150
|
+
}
|
|
1151
|
+
else if (code === RTCStatusCode.ERROR ||
|
|
1152
|
+
code === RTCStatusCode.GPU_INSUFFICIENT ||
|
|
1153
|
+
code === RTCStatusCode.UNPAID_SERVICE ||
|
|
1154
|
+
code === RTCStatusCode.MODEL_NOT_FOUND ||
|
|
1155
|
+
code === RTCStatusCode.DUPLICATE_CONNECTION ||
|
|
1156
|
+
code === RTCStatusCode.REQUEST_TOO_FAST) {
|
|
1157
|
+
this.emit("error", new Error(text || `Server reported error code ${code}`));
|
|
1158
|
+
}
|
|
1159
|
+
else if (code === RTCStatusCode.SHUTDOWN) {
|
|
1160
|
+
void this.disconnect();
|
|
1161
|
+
}
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
case "shutdown":
|
|
1165
|
+
case "error": {
|
|
1166
|
+
const text = msg.message;
|
|
1167
|
+
this.emit("error", new Error(text || `Server sent "${type}"`));
|
|
1168
|
+
void this.disconnect();
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
case "ping":
|
|
1172
|
+
if (this.signaling?.isOpen)
|
|
1173
|
+
this.signaling.send({ type: "pong" });
|
|
1174
|
+
break;
|
|
1175
|
+
case "pong":
|
|
1176
|
+
default:
|
|
1177
|
+
// task_* and other passthrough messages are surfaced via the
|
|
1178
|
+
// generic "message" event above.
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
async applyAnswer(msg) {
|
|
1183
|
+
if (!this.pc)
|
|
1184
|
+
return;
|
|
1185
|
+
try {
|
|
1186
|
+
await this.pc.setRemoteDescription({ type: "answer", sdp: msg.sdp });
|
|
1187
|
+
this.offerInFlight = false;
|
|
1188
|
+
}
|
|
1189
|
+
catch (e) {
|
|
1190
|
+
this.emit("error", new Error(`Failed to apply remote answer: ${describeErr(e)}`));
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
async applyRemoteCandidate(msg) {
|
|
1194
|
+
if (!this.pc)
|
|
1195
|
+
return;
|
|
1196
|
+
try {
|
|
1197
|
+
await this.pc.addIceCandidate(msg.candidate);
|
|
1198
|
+
}
|
|
1199
|
+
catch (e) {
|
|
1200
|
+
this.logger.warn?.("[convbased-sdk] addIceCandidate failed:", e);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
handleSignalingClose(event) {
|
|
1204
|
+
if (this.state === "closing" || this.state === "closed")
|
|
1205
|
+
return;
|
|
1206
|
+
this.emit("closed", { code: event.code, reason: event.reason });
|
|
1207
|
+
// 1008 is what the server sends for auth + balance failures.
|
|
1208
|
+
if (event.code === 1008) {
|
|
1209
|
+
this.emit("error", new Error(`Signaling rejected the connection: ${event.reason || "policy violation"}`));
|
|
1210
|
+
}
|
|
1211
|
+
else if (event.code !== 1000) {
|
|
1212
|
+
this.emit("error", new Error(`Signaling closed unexpectedly (code=${event.code}, reason=${event.reason || "?"})`));
|
|
1213
|
+
}
|
|
1214
|
+
this.tearDownPeer();
|
|
1215
|
+
this.setState("closed");
|
|
1216
|
+
}
|
|
1217
|
+
tearDownPeer() {
|
|
1218
|
+
if (this.pc) {
|
|
1219
|
+
try {
|
|
1220
|
+
this.pc.onicecandidate = null;
|
|
1221
|
+
this.pc.ontrack = null;
|
|
1222
|
+
this.pc.onconnectionstatechange = null;
|
|
1223
|
+
this.pc.close();
|
|
1224
|
+
}
|
|
1225
|
+
catch {
|
|
1226
|
+
/* ignore */
|
|
1227
|
+
}
|
|
1228
|
+
this.pc = null;
|
|
1229
|
+
}
|
|
1230
|
+
this.stopTracks(this.localStream);
|
|
1231
|
+
this.localStream = null;
|
|
1232
|
+
this.convertedStream = null;
|
|
1233
|
+
}
|
|
1234
|
+
stopTracks(stream) {
|
|
1235
|
+
stream?.getTracks().forEach((t) => {
|
|
1236
|
+
try {
|
|
1237
|
+
t.stop();
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
/* ignore */
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
setState(next) {
|
|
1245
|
+
if (next === this.state)
|
|
1246
|
+
return;
|
|
1247
|
+
const previous = this.state;
|
|
1248
|
+
this.state = next;
|
|
1249
|
+
this.emit("state", { state: next, previous });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function generateTaskId() {
|
|
1253
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1254
|
+
return crypto.randomUUID();
|
|
1255
|
+
}
|
|
1256
|
+
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
1257
|
+
}
|
|
1258
|
+
function detectSampleRate(stream) {
|
|
1259
|
+
const track = stream.getAudioTracks()[0];
|
|
1260
|
+
const rate = track?.getSettings()?.sampleRate;
|
|
1261
|
+
return typeof rate === "number" ? rate : null;
|
|
1262
|
+
}
|
|
1263
|
+
function describeErr(e) {
|
|
1264
|
+
if (e instanceof Error)
|
|
1265
|
+
return e.message;
|
|
1266
|
+
return String(e);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const api = {
|
|
1270
|
+
ConvbasedClient,
|
|
1271
|
+
TtsClient,
|
|
1272
|
+
uploadAudio,
|
|
1273
|
+
requestAudioUpload,
|
|
1274
|
+
putToPresigned,
|
|
1275
|
+
graphqlRequest,
|
|
1276
|
+
applyOpusSdpOptions,
|
|
1277
|
+
fetchRTCServers,
|
|
1278
|
+
DEFAULT_STUN_SERVERS,
|
|
1279
|
+
DEFAULT_SIGNALING_URL,
|
|
1280
|
+
DEFAULT_GRAPHQL_URL,
|
|
1281
|
+
RTCStatusCode,
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
// Expose as window.Convbased so consumers can do:
|
|
1285
|
+
// const client = new Convbased.ConvbasedClient({ apiKey });
|
|
1286
|
+
if (typeof globalThis.Convbased === "undefined") {
|
|
1287
|
+
globalThis.Convbased = api;
|
|
1288
|
+
} else {
|
|
1289
|
+
Object.assign(globalThis.Convbased, api);
|
|
1290
|
+
}
|
|
1291
|
+
})(typeof globalThis !== "undefined" ? globalThis : window);
|