@sayna-ai/node-sdk 0.0.18 → 0.0.20
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 +10 -0
- package/dist/index.cjs +244 -57
- package/dist/index.cjs.map +3 -3
- package/dist/index.js +244 -57
- package/dist/index.js.map +3 -3
- package/dist/sayna-client.d.ts +40 -2
- package/dist/sayna-client.d.ts.map +1 -1
- package/dist/types.d.ts +38 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -240,14 +240,24 @@ Registers a callback for error messages.
|
|
|
240
240
|
|
|
241
241
|
Registers a callback for participant messages.
|
|
242
242
|
|
|
243
|
+
### `client.registerOnParticipantConnected(callback)`
|
|
244
|
+
|
|
245
|
+
Registers a callback for participant connection events.
|
|
246
|
+
|
|
243
247
|
### `client.registerOnParticipantDisconnected(callback)`
|
|
244
248
|
|
|
245
249
|
Registers a callback for participant disconnection events.
|
|
246
250
|
|
|
251
|
+
### `client.registerOnTrackSubscribed(callback)`
|
|
252
|
+
|
|
253
|
+
Registers a callback for track subscription events.
|
|
254
|
+
|
|
247
255
|
### `client.registerOnTtsPlaybackComplete(callback)`
|
|
248
256
|
|
|
249
257
|
Registers a callback for TTS playback completion events.
|
|
250
258
|
|
|
259
|
+
Unknown or malformed websocket control messages are logged and ignored so protocol drift does not break the client receive loop. Actual server `error` messages still reach `client.registerOnError(callback)`.
|
|
260
|
+
|
|
251
261
|
### `await client.speak(text, flush?, allowInterruption?)`
|
|
252
262
|
|
|
253
263
|
Sends text to be synthesized as speech.
|
package/dist/index.cjs
CHANGED
|
@@ -98,9 +98,11 @@ class SaynaServerError extends SaynaError {
|
|
|
98
98
|
|
|
99
99
|
// src/sayna-client.ts
|
|
100
100
|
var isBun = typeof process !== "undefined" && typeof process.versions?.bun === "string";
|
|
101
|
-
var
|
|
101
|
+
var isNode = typeof process !== "undefined" && typeof process.versions?.node === "string" && !isBun;
|
|
102
102
|
var WS;
|
|
103
|
-
if (
|
|
103
|
+
if (isNode) {
|
|
104
|
+
WS = require("ws");
|
|
105
|
+
} else if (typeof globalThis.WebSocket !== "undefined") {
|
|
104
106
|
WS = globalThis.WebSocket;
|
|
105
107
|
} else {
|
|
106
108
|
WS = require("ws");
|
|
@@ -126,7 +128,9 @@ class SaynaClient {
|
|
|
126
128
|
ttsCallback;
|
|
127
129
|
errorCallback;
|
|
128
130
|
messageCallback;
|
|
131
|
+
participantConnectedCallback;
|
|
129
132
|
participantDisconnectedCallback;
|
|
133
|
+
trackSubscribedCallback;
|
|
130
134
|
ttsPlaybackCompleteCallback;
|
|
131
135
|
sipTransferErrorCallback;
|
|
132
136
|
readyPromiseResolve;
|
|
@@ -155,7 +159,7 @@ class SaynaClient {
|
|
|
155
159
|
if (this.isConnected) {
|
|
156
160
|
return;
|
|
157
161
|
}
|
|
158
|
-
const wsUrl = this.
|
|
162
|
+
const wsUrl = this.getWebSocketUrl();
|
|
159
163
|
return new Promise((resolve, reject) => {
|
|
160
164
|
this.readyPromiseResolve = resolve;
|
|
161
165
|
this.readyPromiseReject = reject;
|
|
@@ -197,7 +201,7 @@ class SaynaClient {
|
|
|
197
201
|
const wasReady = this.isReady;
|
|
198
202
|
this.cleanup();
|
|
199
203
|
if (!wasReady && this.readyPromiseReject) {
|
|
200
|
-
const reason = event.reason.length > 0 ? event.reason : "none";
|
|
204
|
+
const reason = event.reason && event.reason.length > 0 ? event.reason : "none";
|
|
201
205
|
this.readyPromiseReject(new SaynaConnectionError(`WebSocket closed before ready (code: ${event.code}, reason: ${reason})`));
|
|
202
206
|
}
|
|
203
207
|
};
|
|
@@ -210,27 +214,30 @@ class SaynaClient {
|
|
|
210
214
|
try {
|
|
211
215
|
if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
|
|
212
216
|
const buffer = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
|
|
213
|
-
|
|
214
|
-
await this.ttsCallback(buffer);
|
|
215
|
-
}
|
|
217
|
+
await this.invokeCallback(this.ttsCallback, buffer, "TTS audio");
|
|
216
218
|
} else {
|
|
217
219
|
if (typeof event.data !== "string") {
|
|
218
|
-
|
|
220
|
+
this.logProtocolWarning("Ignoring websocket message with unsupported non-binary payload type", event.data);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let data;
|
|
224
|
+
try {
|
|
225
|
+
data = JSON.parse(event.data);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.logProtocolWarning(`Ignoring invalid websocket JSON: ${this.describeError(error)}`, event.data);
|
|
228
|
+
return;
|
|
219
229
|
}
|
|
220
|
-
const data = JSON.parse(event.data);
|
|
221
230
|
await this.handleJsonMessage(data);
|
|
222
231
|
}
|
|
223
232
|
} catch (error) {
|
|
224
|
-
|
|
225
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
226
|
-
await this.errorCallback({
|
|
227
|
-
type: "error",
|
|
228
|
-
message: `Failed to process message: ${errorMessage}`
|
|
229
|
-
});
|
|
230
|
-
}
|
|
233
|
+
await this.reportHandlerError("WebSocket message", error);
|
|
231
234
|
}
|
|
232
235
|
}
|
|
233
|
-
async handleJsonMessage(
|
|
236
|
+
async handleJsonMessage(raw) {
|
|
237
|
+
const data = this.parseIncomingMessage(raw);
|
|
238
|
+
if (!data) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
234
241
|
const messageType = data.type;
|
|
235
242
|
try {
|
|
236
243
|
switch (messageType) {
|
|
@@ -248,72 +255,246 @@ class SaynaClient {
|
|
|
248
255
|
break;
|
|
249
256
|
}
|
|
250
257
|
case "stt_result": {
|
|
251
|
-
|
|
252
|
-
if (this.sttCallback) {
|
|
253
|
-
await this.sttCallback(sttResult);
|
|
254
|
-
}
|
|
258
|
+
await this.invokeCallback(this.sttCallback, data, "STT result");
|
|
255
259
|
break;
|
|
256
260
|
}
|
|
257
261
|
case "error": {
|
|
258
|
-
|
|
259
|
-
if (this.errorCallback) {
|
|
260
|
-
await this.errorCallback(errorMsg);
|
|
261
|
-
}
|
|
262
|
+
await this.invokeCallback(this.errorCallback, data, "error");
|
|
262
263
|
if (this.readyPromiseReject && !this.isReady) {
|
|
263
|
-
this.readyPromiseReject(new SaynaServerError(
|
|
264
|
+
this.readyPromiseReject(new SaynaServerError(data.message));
|
|
264
265
|
}
|
|
265
266
|
break;
|
|
266
267
|
}
|
|
267
268
|
case "sip_transfer_error": {
|
|
268
|
-
const sipTransferError = data;
|
|
269
269
|
if (this.sipTransferErrorCallback) {
|
|
270
|
-
await this.sipTransferErrorCallback
|
|
270
|
+
await this.invokeCallback(this.sipTransferErrorCallback, data, "SIP transfer error");
|
|
271
271
|
} else if (this.errorCallback) {
|
|
272
272
|
await this.errorCallback({
|
|
273
273
|
type: "error",
|
|
274
|
-
message:
|
|
274
|
+
message: data.message
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
277
|
break;
|
|
278
278
|
}
|
|
279
279
|
case "message": {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
await this.invokeCallback(this.messageCallback, data.message, "message");
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "participant_connected": {
|
|
284
|
+
await this.invokeCallback(this.participantConnectedCallback, data.participant, "participant connected");
|
|
284
285
|
break;
|
|
285
286
|
}
|
|
286
287
|
case "participant_disconnected": {
|
|
287
|
-
|
|
288
|
-
if (this.participantDisconnectedCallback) {
|
|
289
|
-
await this.participantDisconnectedCallback(participantMsg.participant);
|
|
290
|
-
}
|
|
288
|
+
await this.invokeCallback(this.participantDisconnectedCallback, data.participant, "participant disconnected");
|
|
291
289
|
break;
|
|
292
290
|
}
|
|
293
|
-
case "
|
|
294
|
-
|
|
295
|
-
if (this.ttsPlaybackCompleteCallback) {
|
|
296
|
-
await this.ttsPlaybackCompleteCallback(ttsPlaybackCompleteMsg.timestamp);
|
|
297
|
-
}
|
|
291
|
+
case "track_subscribed": {
|
|
292
|
+
await this.invokeCallback(this.trackSubscribedCallback, data.track, "track subscribed");
|
|
298
293
|
break;
|
|
299
294
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (this.errorCallback) {
|
|
304
|
-
await this.errorCallback({ type: "error", message: errorMessage });
|
|
305
|
-
} else {
|
|
306
|
-
console.warn(errorMessage);
|
|
307
|
-
}
|
|
295
|
+
case "tts_playback_complete": {
|
|
296
|
+
await this.invokeCallback(this.ttsPlaybackCompleteCallback, data.timestamp, "TTS playback complete");
|
|
297
|
+
break;
|
|
308
298
|
}
|
|
309
299
|
}
|
|
310
300
|
} catch (error) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
301
|
+
await this.reportHandlerError(`"${messageType}"`, error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
getWebSocketUrl() {
|
|
305
|
+
const wsUrl = this.url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
306
|
+
const parsedUrl = new URL(wsUrl);
|
|
307
|
+
if (!parsedUrl.pathname.endsWith("/ws")) {
|
|
308
|
+
parsedUrl.pathname = parsedUrl.pathname.endsWith("/") ? `${parsedUrl.pathname}ws` : `${parsedUrl.pathname}/ws`;
|
|
309
|
+
}
|
|
310
|
+
return parsedUrl.toString();
|
|
311
|
+
}
|
|
312
|
+
parseIncomingMessage(raw) {
|
|
313
|
+
if (!this.isJsonObject(raw)) {
|
|
314
|
+
this.logProtocolWarning("Ignoring websocket payload because it is not a JSON object", raw);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const messageType = raw.type;
|
|
318
|
+
if (typeof messageType !== "string") {
|
|
319
|
+
this.logProtocolWarning('Ignoring websocket payload without a string "type" field', raw);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
switch (messageType) {
|
|
324
|
+
case "ready":
|
|
325
|
+
return {
|
|
326
|
+
type: "ready",
|
|
327
|
+
stream_id: this.getOptionalString(raw, "stream_id"),
|
|
328
|
+
livekit_room_name: this.getOptionalString(raw, "livekit_room_name"),
|
|
329
|
+
livekit_url: this.getOptionalString(raw, "livekit_url"),
|
|
330
|
+
sayna_participant_identity: this.getOptionalString(raw, "sayna_participant_identity"),
|
|
331
|
+
sayna_participant_name: this.getOptionalString(raw, "sayna_participant_name")
|
|
332
|
+
};
|
|
333
|
+
case "stt_result":
|
|
334
|
+
return {
|
|
335
|
+
type: "stt_result",
|
|
336
|
+
transcript: this.getRequiredString(raw, "transcript"),
|
|
337
|
+
is_final: this.getRequiredBoolean(raw, "is_final"),
|
|
338
|
+
is_speech_final: this.getRequiredBoolean(raw, "is_speech_final"),
|
|
339
|
+
confidence: this.getRequiredNumber(raw, "confidence")
|
|
340
|
+
};
|
|
341
|
+
case "error":
|
|
342
|
+
return {
|
|
343
|
+
type: "error",
|
|
344
|
+
message: this.getRequiredString(raw, "message")
|
|
345
|
+
};
|
|
346
|
+
case "sip_transfer_error":
|
|
347
|
+
return {
|
|
348
|
+
type: "sip_transfer_error",
|
|
349
|
+
message: this.getRequiredString(raw, "message")
|
|
350
|
+
};
|
|
351
|
+
case "message":
|
|
352
|
+
return {
|
|
353
|
+
type: "message",
|
|
354
|
+
message: this.parseSaynaMessage(raw, "message")
|
|
355
|
+
};
|
|
356
|
+
case "participant_connected":
|
|
357
|
+
return {
|
|
358
|
+
type: "participant_connected",
|
|
359
|
+
participant: this.parseParticipant(raw, "participant")
|
|
360
|
+
};
|
|
361
|
+
case "participant_disconnected":
|
|
362
|
+
return {
|
|
363
|
+
type: "participant_disconnected",
|
|
364
|
+
participant: this.parseParticipant(raw, "participant")
|
|
365
|
+
};
|
|
366
|
+
case "track_subscribed":
|
|
367
|
+
return {
|
|
368
|
+
type: "track_subscribed",
|
|
369
|
+
track: this.parseTrack(raw, "track")
|
|
370
|
+
};
|
|
371
|
+
case "tts_playback_complete":
|
|
372
|
+
return {
|
|
373
|
+
type: "tts_playback_complete",
|
|
374
|
+
timestamp: this.getRequiredNumber(raw, "timestamp")
|
|
375
|
+
};
|
|
376
|
+
default:
|
|
377
|
+
this.logProtocolWarning(`Ignoring unknown websocket message type "${messageType}"`, raw);
|
|
378
|
+
return;
|
|
316
379
|
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
this.logProtocolWarning(`Ignoring malformed websocket message "${messageType}": ${this.describeError(error)}`, raw);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
parseSaynaMessage(container, fieldName) {
|
|
386
|
+
const message = this.getRequiredObject(container, fieldName);
|
|
387
|
+
return {
|
|
388
|
+
message: this.getOptionalString(message, "message"),
|
|
389
|
+
data: this.getOptionalString(message, "data"),
|
|
390
|
+
identity: this.getRequiredString(message, "identity"),
|
|
391
|
+
topic: this.getRequiredString(message, "topic"),
|
|
392
|
+
room: this.getRequiredString(message, "room"),
|
|
393
|
+
timestamp: this.getRequiredNumber(message, "timestamp")
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
parseParticipant(container, fieldName) {
|
|
397
|
+
const participant = this.getRequiredObject(container, fieldName);
|
|
398
|
+
return {
|
|
399
|
+
identity: this.getRequiredString(participant, "identity"),
|
|
400
|
+
name: this.getOptionalString(participant, "name"),
|
|
401
|
+
room: this.getRequiredString(participant, "room"),
|
|
402
|
+
timestamp: this.getRequiredNumber(participant, "timestamp")
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
parseTrack(container, fieldName) {
|
|
406
|
+
const track = this.getRequiredObject(container, fieldName);
|
|
407
|
+
const trackKind = this.getRequiredString(track, "track_kind");
|
|
408
|
+
if (trackKind !== "audio" && trackKind !== "video") {
|
|
409
|
+
throw new Error('Field "track_kind" must be either "audio" or "video"');
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
identity: this.getRequiredString(track, "identity"),
|
|
413
|
+
name: this.getOptionalString(track, "name"),
|
|
414
|
+
track_kind: trackKind,
|
|
415
|
+
track_sid: this.getRequiredString(track, "track_sid"),
|
|
416
|
+
room: this.getRequiredString(track, "room"),
|
|
417
|
+
timestamp: this.getRequiredNumber(track, "timestamp")
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
getRequiredObject(container, fieldName) {
|
|
421
|
+
const value = container[fieldName];
|
|
422
|
+
if (!this.isJsonObject(value)) {
|
|
423
|
+
throw new Error(`Field "${fieldName}" must be an object`);
|
|
424
|
+
}
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
getRequiredString(container, fieldName) {
|
|
428
|
+
const value = container[fieldName];
|
|
429
|
+
if (typeof value !== "string") {
|
|
430
|
+
throw new Error(`Field "${fieldName}" must be a string`);
|
|
431
|
+
}
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
getOptionalString(container, fieldName) {
|
|
435
|
+
const value = container[fieldName];
|
|
436
|
+
if (typeof value === "undefined" || value === null) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (typeof value !== "string") {
|
|
440
|
+
throw new Error(`Field "${fieldName}" must be a string when present`);
|
|
441
|
+
}
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
getRequiredBoolean(container, fieldName) {
|
|
445
|
+
const value = container[fieldName];
|
|
446
|
+
if (typeof value !== "boolean") {
|
|
447
|
+
throw new Error(`Field "${fieldName}" must be a boolean`);
|
|
448
|
+
}
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
getRequiredNumber(container, fieldName) {
|
|
452
|
+
const value = container[fieldName];
|
|
453
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
454
|
+
throw new Error(`Field "${fieldName}" must be a finite number`);
|
|
455
|
+
}
|
|
456
|
+
return value;
|
|
457
|
+
}
|
|
458
|
+
isJsonObject(value) {
|
|
459
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
460
|
+
}
|
|
461
|
+
logProtocolWarning(message, payload) {
|
|
462
|
+
console.warn(`${message}; payload=${this.safeStringify(payload)}`);
|
|
463
|
+
}
|
|
464
|
+
safeStringify(payload) {
|
|
465
|
+
try {
|
|
466
|
+
return JSON.stringify(payload);
|
|
467
|
+
} catch {
|
|
468
|
+
return String(payload);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
describeError(error) {
|
|
472
|
+
return error instanceof Error ? error.message : String(error);
|
|
473
|
+
}
|
|
474
|
+
async invokeCallback(callback, payload, label) {
|
|
475
|
+
if (!callback) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
await callback(payload);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
await this.reportHandlerError(`${label} callback`, error);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async reportHandlerError(label, error) {
|
|
485
|
+
const message = `${label} failed: ${this.describeError(error)}`;
|
|
486
|
+
if (!this.errorCallback) {
|
|
487
|
+
console.error(message);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
await this.errorCallback({
|
|
492
|
+
type: "error",
|
|
493
|
+
message
|
|
494
|
+
});
|
|
495
|
+
} catch (callbackError) {
|
|
496
|
+
console.error(message);
|
|
497
|
+
console.error(`error callback failed: ${this.describeError(callbackError)}`);
|
|
317
498
|
}
|
|
318
499
|
}
|
|
319
500
|
createWebSocket(url) {
|
|
@@ -321,7 +502,7 @@ class SaynaClient {
|
|
|
321
502
|
return new WS(url);
|
|
322
503
|
}
|
|
323
504
|
const headers = { Authorization: `Bearer ${this.apiKey}` };
|
|
324
|
-
if (
|
|
505
|
+
if (isNode) {
|
|
325
506
|
return new WS(url, undefined, { headers });
|
|
326
507
|
}
|
|
327
508
|
if (isBun) {
|
|
@@ -433,9 +614,15 @@ class SaynaClient {
|
|
|
433
614
|
registerOnMessage(callback) {
|
|
434
615
|
this.messageCallback = callback;
|
|
435
616
|
}
|
|
617
|
+
registerOnParticipantConnected(callback) {
|
|
618
|
+
this.participantConnectedCallback = callback;
|
|
619
|
+
}
|
|
436
620
|
registerOnParticipantDisconnected(callback) {
|
|
437
621
|
this.participantDisconnectedCallback = callback;
|
|
438
622
|
}
|
|
623
|
+
registerOnTrackSubscribed(callback) {
|
|
624
|
+
this.trackSubscribedCallback = callback;
|
|
625
|
+
}
|
|
439
626
|
registerOnTtsPlaybackComplete(callback) {
|
|
440
627
|
this.ttsPlaybackCompleteCallback = callback;
|
|
441
628
|
}
|
|
@@ -877,4 +1064,4 @@ async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAud
|
|
|
877
1064
|
return client;
|
|
878
1065
|
}
|
|
879
1066
|
|
|
880
|
-
//# debugId=
|
|
1067
|
+
//# debugId=5E4C5FDAA012925764756E2164756E21
|