@kuralle-syrinx/server-websocket 2.1.1 → 3.0.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/package.json +3 -3
- package/src/edge.test.ts +182 -2
- package/src/edge.ts +52 -3
- package/src/telnyx.test.ts +44 -0
- package/src/telnyx.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@evan/opus": "1.0.3",
|
|
17
17
|
"ws": "^8.18.0",
|
|
18
|
-
"@kuralle-syrinx/core": "
|
|
19
|
-
"@kuralle-syrinx/ws": "
|
|
18
|
+
"@kuralle-syrinx/core": "3.0.0",
|
|
19
|
+
"@kuralle-syrinx/ws": "3.0.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
package/src/edge.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
14
14
|
import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
|
|
15
15
|
import { InMemorySessionStore } from "./session-store.js";
|
|
16
|
-
import { runVoiceEdgeWebSocketConnection } from "./edge.js";
|
|
16
|
+
import { runVoiceEdgeWebSocketConnection, type EdgeRecorder } from "./edge.js";
|
|
17
17
|
|
|
18
18
|
const KEEP_ALIVE_KEY = "voice.edge.keep_alive";
|
|
19
19
|
|
|
@@ -39,6 +39,7 @@ class FakeSocket implements ManagedSocket {
|
|
|
39
39
|
readonly sent: SocketData[] = [];
|
|
40
40
|
#onMessage?: (data: SocketData, isBinary: boolean) => void;
|
|
41
41
|
#onClose?: (code: number, reason: string) => void;
|
|
42
|
+
#onError?: (err: Error) => void;
|
|
42
43
|
get isOpenValue(): boolean {
|
|
43
44
|
return this.isOpen;
|
|
44
45
|
}
|
|
@@ -61,10 +62,15 @@ class FakeSocket implements ManagedSocket {
|
|
|
61
62
|
onClose(handler: (code: number, reason: string) => void): void {
|
|
62
63
|
this.#onClose = handler;
|
|
63
64
|
}
|
|
64
|
-
onError(): void {
|
|
65
|
+
onError(handler: (err: Error) => void): void {
|
|
66
|
+
this.#onError = handler;
|
|
67
|
+
}
|
|
65
68
|
emit(data: SocketData, isBinary = false): void {
|
|
66
69
|
this.#onMessage?.(data, isBinary);
|
|
67
70
|
}
|
|
71
|
+
emitError(err: Error = new Error("socket error")): void {
|
|
72
|
+
this.#onError?.(err);
|
|
73
|
+
}
|
|
68
74
|
json(): Array<Record<string, unknown>> {
|
|
69
75
|
return this.sent
|
|
70
76
|
.filter((s): s is string => typeof s === "string")
|
|
@@ -246,6 +252,110 @@ describe("edge inbound binary audio envelopes", () => {
|
|
|
246
252
|
});
|
|
247
253
|
});
|
|
248
254
|
|
|
255
|
+
describe("edge inbound JSON audio (sample-rate handling)", () => {
|
|
256
|
+
function sessionCapturing(
|
|
257
|
+
audio: UserAudioReceivedPacket[],
|
|
258
|
+
turns: Array<{ contextId: string; previousContextId?: string }>,
|
|
259
|
+
): VoiceAgentSession {
|
|
260
|
+
const bus = new PipelineBusImpl();
|
|
261
|
+
bus.on("user.audio_received", (p) => {
|
|
262
|
+
audio.push(p as UserAudioReceivedPacket);
|
|
263
|
+
});
|
|
264
|
+
bus.on("turn.change", (p) => {
|
|
265
|
+
turns.push(p as { contextId: string; previousContextId?: string });
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
bus,
|
|
269
|
+
async start() {
|
|
270
|
+
void bus.start();
|
|
271
|
+
},
|
|
272
|
+
async close() {
|
|
273
|
+
bus.stop();
|
|
274
|
+
},
|
|
275
|
+
on() {},
|
|
276
|
+
off() {},
|
|
277
|
+
requestClientInterrupt() {},
|
|
278
|
+
} as unknown as VoiceAgentSession;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it("resamples JSON audio from the client sampleRateHz to the engine input rate", async () => {
|
|
282
|
+
const audio: UserAudioReceivedPacket[] = [];
|
|
283
|
+
const turns: Array<{ contextId: string; previousContextId?: string }> = [];
|
|
284
|
+
const socket = new FakeSocket();
|
|
285
|
+
const scheduler = new ManualScheduler();
|
|
286
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
287
|
+
sessionStore: new InMemorySessionStore(),
|
|
288
|
+
scheduler,
|
|
289
|
+
createSession: () => sessionCapturing(audio, turns),
|
|
290
|
+
});
|
|
291
|
+
waitForReady(socket);
|
|
292
|
+
|
|
293
|
+
// 8 kHz mono PCM16 → engine default 16 kHz: sample count (and byte length) should ~double.
|
|
294
|
+
const pcm8k = pcm16SamplesToBytes(new Int16Array(80).fill(1000));
|
|
295
|
+
socket.emit(
|
|
296
|
+
JSON.stringify({ type: "audio", audio: Buffer.from(pcm8k).toString("base64"), sampleRateHz: 8000, contextId: "json-turn" }),
|
|
297
|
+
);
|
|
298
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
299
|
+
|
|
300
|
+
expect(audio).toHaveLength(1);
|
|
301
|
+
// BUG: JSON path pushed raw 8 kHz bytes unchanged instead of resampling to 16 kHz.
|
|
302
|
+
expect(audio[0]!.audio.byteLength).toBeGreaterThan(pcm8k.byteLength * 1.5);
|
|
303
|
+
// BUG: JSON path never emitted turn.change on contextId rotation (text/binary branches do).
|
|
304
|
+
expect(turns.some((t) => t.contextId === "json-turn")).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("edge recorder finalize on disconnect", () => {
|
|
309
|
+
function fakeRecorder() {
|
|
310
|
+
const calls: Array<{ sessionId: string; closedAtMs: number }> = [];
|
|
311
|
+
const rec: EdgeRecorder = {
|
|
312
|
+
onUserAudio() {},
|
|
313
|
+
onAssistantAudio() {},
|
|
314
|
+
finalize(meta) {
|
|
315
|
+
calls.push(meta);
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
return { rec, calls };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
it("finalizes the recording on an error-path disconnect, not just a clean close", async () => {
|
|
322
|
+
const socket = new FakeSocket();
|
|
323
|
+
const scheduler = new ManualScheduler();
|
|
324
|
+
const { rec, calls } = fakeRecorder();
|
|
325
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
326
|
+
sessionStore: new InMemorySessionStore(),
|
|
327
|
+
scheduler,
|
|
328
|
+
createSession: () => fakeSession(),
|
|
329
|
+
recorder: rec,
|
|
330
|
+
});
|
|
331
|
+
waitForReady(socket);
|
|
332
|
+
|
|
333
|
+
socket.emitError(new Error("abnormal disconnect")); // error path, no clean onClose
|
|
334
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
335
|
+
|
|
336
|
+
expect(calls).toHaveLength(1); // recording must still be finalized
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("finalizes exactly once when both an error and a close fire", async () => {
|
|
340
|
+
const socket = new FakeSocket();
|
|
341
|
+
const scheduler = new ManualScheduler();
|
|
342
|
+
const { rec, calls } = fakeRecorder();
|
|
343
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
344
|
+
sessionStore: new InMemorySessionStore(),
|
|
345
|
+
scheduler,
|
|
346
|
+
createSession: () => fakeSession(),
|
|
347
|
+
recorder: rec,
|
|
348
|
+
});
|
|
349
|
+
waitForReady(socket);
|
|
350
|
+
|
|
351
|
+
socket.emitError(new Error("drop"));
|
|
352
|
+
socket.dispose(); // fires onClose
|
|
353
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
354
|
+
|
|
355
|
+
expect(calls).toHaveLength(1); // idempotent teardown — not twice
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
249
359
|
describe("edge barge-in downlink", () => {
|
|
250
360
|
it("sends audio_clear and agent_interrupted when an interrupt is detected", async () => {
|
|
251
361
|
const socket = new FakeSocket();
|
|
@@ -270,3 +380,73 @@ describe("edge barge-in downlink", () => {
|
|
|
270
380
|
expect(socket.json()).toContainEqual({ type: "agent_interrupted", turnId: "assistant-turn", reason: "barge_in" });
|
|
271
381
|
});
|
|
272
382
|
});
|
|
383
|
+
|
|
384
|
+
describe("edge client playout progress", () => {
|
|
385
|
+
interface ProgressPacket {
|
|
386
|
+
contextId: string;
|
|
387
|
+
playedOutMs: number;
|
|
388
|
+
complete: boolean;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function sessionCapturingProgress(progress: ProgressPacket[]): VoiceAgentSession {
|
|
392
|
+
const bus = new PipelineBusImpl();
|
|
393
|
+
bus.on("tts.playout_progress", (p) => {
|
|
394
|
+
progress.push(p as unknown as ProgressPacket);
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
bus,
|
|
398
|
+
async start() {
|
|
399
|
+
void bus.start();
|
|
400
|
+
},
|
|
401
|
+
async close() {
|
|
402
|
+
bus.stop();
|
|
403
|
+
},
|
|
404
|
+
on() {},
|
|
405
|
+
off() {},
|
|
406
|
+
requestClientInterrupt() {},
|
|
407
|
+
} as unknown as VoiceAgentSession;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
it("maps a client playout_progress message onto a tts.playout_progress bus packet", async () => {
|
|
411
|
+
const progress: ProgressPacket[] = [];
|
|
412
|
+
const socket = new FakeSocket();
|
|
413
|
+
const scheduler = new ManualScheduler();
|
|
414
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
415
|
+
sessionStore: new InMemorySessionStore(),
|
|
416
|
+
scheduler,
|
|
417
|
+
createSession: () => sessionCapturingProgress(progress),
|
|
418
|
+
});
|
|
419
|
+
waitForReady(socket);
|
|
420
|
+
|
|
421
|
+
socket.emit(
|
|
422
|
+
JSON.stringify({ type: "playout_progress", contextId: "assistant-turn", playedOutMs: 1280 }),
|
|
423
|
+
);
|
|
424
|
+
socket.emit(
|
|
425
|
+
JSON.stringify({ type: "playout_progress", contextId: "assistant-turn", playedOutMs: 2400, complete: true }),
|
|
426
|
+
);
|
|
427
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
428
|
+
|
|
429
|
+
expect(progress).toEqual([
|
|
430
|
+
{ kind: "tts.playout_progress", contextId: "assistant-turn", playedOutMs: 1280, complete: false, timestampMs: expect.any(Number) },
|
|
431
|
+
{ kind: "tts.playout_progress", contextId: "assistant-turn", playedOutMs: 2400, complete: true, timestampMs: expect.any(Number) },
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("rejects a playout_progress message without a numeric playedOutMs", async () => {
|
|
436
|
+
const progress: ProgressPacket[] = [];
|
|
437
|
+
const socket = new FakeSocket();
|
|
438
|
+
const scheduler = new ManualScheduler();
|
|
439
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
440
|
+
sessionStore: new InMemorySessionStore(),
|
|
441
|
+
scheduler,
|
|
442
|
+
createSession: () => sessionCapturingProgress(progress),
|
|
443
|
+
});
|
|
444
|
+
waitForReady(socket);
|
|
445
|
+
|
|
446
|
+
socket.emit(JSON.stringify({ type: "playout_progress", contextId: "assistant-turn" }));
|
|
447
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
448
|
+
|
|
449
|
+
expect(progress).toHaveLength(0);
|
|
450
|
+
expect(socket.json().some((m) => m.type === "error" && m.category === "invalid_input")).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
});
|
package/src/edge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
encodeSyrinxAudioEnvelope,
|
|
7
7
|
type TextToSpeechAudioPacket,
|
|
8
8
|
type TextToSpeechEndPacket,
|
|
9
|
+
type TextToSpeechPlayoutProgressPacket,
|
|
9
10
|
type UserAudioReceivedPacket,
|
|
10
11
|
type UserTextReceivedPacket,
|
|
11
12
|
type VoiceAgentSession,
|
|
@@ -88,6 +89,7 @@ type ClientMessage =
|
|
|
88
89
|
| { readonly type: "text"; readonly text: string; readonly contextId?: string }
|
|
89
90
|
| { readonly type: "audio"; readonly audio: string; readonly contextId?: string; readonly sampleRateHz: number; readonly sequence?: number }
|
|
90
91
|
| { readonly type: "client_interrupt"; readonly assistantContextId?: string; readonly contextId?: string }
|
|
92
|
+
| { readonly type: "playout_progress"; readonly contextId?: string; readonly playedOutMs: number; readonly complete?: boolean }
|
|
91
93
|
| { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
|
|
92
94
|
| { readonly type: "ping" };
|
|
93
95
|
|
|
@@ -207,7 +209,12 @@ export async function runVoiceEdgeWebSocketConnection(
|
|
|
207
209
|
}
|
|
208
210
|
});
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
// Tear down on EITHER a clean close or an error-path disconnect, exactly once (guarded by
|
|
213
|
+
// `closed`). On Cloudflare Workers a DO webSocketError surfaces here as onError with no
|
|
214
|
+
// matching onClose, so finalize/release must also run on the error path — otherwise the
|
|
215
|
+
// in-memory recording is lost and the session lease never releases.
|
|
216
|
+
const teardownConnection = (): void => {
|
|
217
|
+
if (closed) return;
|
|
211
218
|
closed = true;
|
|
212
219
|
for (const dispose of disposers.splice(0)) dispose();
|
|
213
220
|
if (state.managed) {
|
|
@@ -219,7 +226,9 @@ export async function runVoiceEdgeWebSocketConnection(
|
|
|
219
226
|
options.recorder.finalize({ sessionId: state.managed?.id ?? "unknown", closedAtMs: Date.now() }),
|
|
220
227
|
).catch(() => undefined);
|
|
221
228
|
}
|
|
222
|
-
}
|
|
229
|
+
};
|
|
230
|
+
socket.onClose(teardownConnection);
|
|
231
|
+
socket.onError(teardownConnection);
|
|
223
232
|
|
|
224
233
|
try {
|
|
225
234
|
const requestedSessionId = sanitizeSessionId(sessionIdFromRequest(request) ?? sessionIdFn(request));
|
|
@@ -460,6 +469,23 @@ function handleClientMessage(
|
|
|
460
469
|
if (interruptedContextId) session.requestClientInterrupt(interruptedContextId);
|
|
461
470
|
return currentContextId;
|
|
462
471
|
}
|
|
472
|
+
if (message.type === "playout_progress") {
|
|
473
|
+
// On client-rendered-audio transports the browser is the playout clock: the server streams
|
|
474
|
+
// audio envelopes and the client schedules them. Map its reported position onto the same
|
|
475
|
+
// `tts.playout_progress` packet the server-paced path emits, so turn-truncation (realtime
|
|
476
|
+
// barge-in) and turn-metrics consume an accurate played-out offset instead of a stale 0.
|
|
477
|
+
const progressContextId = message.contextId ?? currentContextId;
|
|
478
|
+
if (progressContextId) {
|
|
479
|
+
session.bus.push(Route.Main, {
|
|
480
|
+
kind: "tts.playout_progress",
|
|
481
|
+
contextId: progressContextId,
|
|
482
|
+
timestampMs: Date.now(),
|
|
483
|
+
playedOutMs: message.playedOutMs,
|
|
484
|
+
complete: message.complete ?? false,
|
|
485
|
+
} satisfies TextToSpeechPlayoutProgressPacket);
|
|
486
|
+
}
|
|
487
|
+
return currentContextId;
|
|
488
|
+
}
|
|
463
489
|
if (message.type === "text") {
|
|
464
490
|
const nextContextId = message.contextId ?? contextId();
|
|
465
491
|
session.bus.push(Route.Main, {
|
|
@@ -478,12 +504,23 @@ function handleClientMessage(
|
|
|
478
504
|
return nextContextId;
|
|
479
505
|
}
|
|
480
506
|
const nextContextId = message.contextId ?? currentContextId;
|
|
507
|
+
if (nextContextId !== currentContextId) {
|
|
508
|
+
session.bus.push(Route.Main, {
|
|
509
|
+
kind: "turn.change",
|
|
510
|
+
contextId: nextContextId,
|
|
511
|
+
previousContextId: currentContextId,
|
|
512
|
+
reason: "websocket_audio_turn",
|
|
513
|
+
timestampMs: Date.now(),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
rememberContextSampleRate(contextSampleRates, nextContextId, message.sampleRateHz);
|
|
481
517
|
rememberInputSequence(session, inputSequence, nextContextId, message.sequence);
|
|
518
|
+
const audio = resampleAudioBytes(decodeBase64(message.audio), message.sampleRateHz, inputSampleRateHz, streamingResamplers);
|
|
482
519
|
session.bus.push(Route.Main, {
|
|
483
520
|
kind: "user.audio_received",
|
|
484
521
|
contextId: nextContextId,
|
|
485
522
|
timestampMs: Date.now(),
|
|
486
|
-
audio
|
|
523
|
+
audio,
|
|
487
524
|
} satisfies UserAudioReceivedPacket);
|
|
488
525
|
return nextContextId;
|
|
489
526
|
}
|
|
@@ -504,6 +541,18 @@ function parseClientMessage(value: unknown): ClientMessage {
|
|
|
504
541
|
contextId: optionalString(value.contextId),
|
|
505
542
|
};
|
|
506
543
|
}
|
|
544
|
+
if (value.type === "playout_progress") {
|
|
545
|
+
const playedOutMs = nonNegativeInteger(value.playedOutMs);
|
|
546
|
+
if (playedOutMs === null) {
|
|
547
|
+
throw new Error("Websocket playout_progress playedOutMs must be a non-negative integer");
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
type: "playout_progress",
|
|
551
|
+
contextId: optionalString(value.contextId),
|
|
552
|
+
playedOutMs,
|
|
553
|
+
complete: typeof value.complete === "boolean" ? value.complete : undefined,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
507
556
|
if (value.type === "text") {
|
|
508
557
|
const text = optionalString(value.text);
|
|
509
558
|
if (!text) throw new Error("Websocket JSON text must be a non-empty string");
|
package/src/telnyx.test.ts
CHANGED
|
@@ -848,6 +848,50 @@ describe("createTelnyxMediaStreamServer", () => {
|
|
|
848
848
|
await server.close();
|
|
849
849
|
});
|
|
850
850
|
|
|
851
|
+
it("attributes the final paced frame to its contextId (playout clock not short by one frame)", async () => {
|
|
852
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
853
|
+
const server = registerServer(await createTelnyxMediaStreamServer({
|
|
854
|
+
port: 0,
|
|
855
|
+
outputSampleRateHz: 16000,
|
|
856
|
+
createSession: () => session,
|
|
857
|
+
}));
|
|
858
|
+
const address = server.address();
|
|
859
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
860
|
+
|
|
861
|
+
const progress: Array<{ playedOutMs: number; complete: boolean }> = [];
|
|
862
|
+
session.bus.on("tts.playout_progress", (pkt) => {
|
|
863
|
+
const p = pkt as TextToSpeechPlayoutProgressPacket;
|
|
864
|
+
if (p.contextId === "telnyx-final-frame") progress.push({ playedOutMs: p.playedOutMs, complete: p.complete });
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const client = await openSocket(telnyxUrl(address.port));
|
|
868
|
+
client.send(JSON.stringify(telnyxStart()));
|
|
869
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
870
|
+
|
|
871
|
+
// 200ms @ 16 kHz = exactly 10 paced frames of 20ms; the playout clock must count all 10.
|
|
872
|
+
const samples = new Int16Array(3200);
|
|
873
|
+
for (let i = 0; i < samples.length; i += 1) samples[i] = i % 2 === 0 ? 1200 : -1200;
|
|
874
|
+
session.bus.push(Route.Main, {
|
|
875
|
+
kind: "tts.audio",
|
|
876
|
+
contextId: "telnyx-final-frame",
|
|
877
|
+
timestampMs: Date.now(),
|
|
878
|
+
audio: pcm16SamplesToBytes(samples),
|
|
879
|
+
sampleRateHz: 16000,
|
|
880
|
+
});
|
|
881
|
+
session.bus.push(Route.Main, {
|
|
882
|
+
kind: "tts.end",
|
|
883
|
+
contextId: "telnyx-final-frame",
|
|
884
|
+
timestampMs: Date.now(),
|
|
885
|
+
});
|
|
886
|
+
await waitForCondition(() => progress.at(-1)?.complete === true);
|
|
887
|
+
|
|
888
|
+
// BUG: the final frame was rebuilt without contextId, so its 20ms was dropped (reported 180, not 200).
|
|
889
|
+
expect(progress.at(-1)?.playedOutMs).toBe(200);
|
|
890
|
+
|
|
891
|
+
client.close();
|
|
892
|
+
await server.close();
|
|
893
|
+
});
|
|
894
|
+
|
|
851
895
|
it("uses configured L16 bidirectional output rather than assuming the inbound codec", async () => {
|
|
852
896
|
const session = new VoiceAgentSession({ plugins: {} });
|
|
853
897
|
const server = registerServer(await createTelnyxMediaStreamServer({
|
package/src/telnyx.ts
CHANGED