@kuralle-syrinx/server-websocket 2.1.1 → 3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "2.1.1",
3
+ "version": "3.1.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": "2.1.1",
19
- "@kuralle-syrinx/ws": "2.1.1"
18
+ "@kuralle-syrinx/core": "3.1.0",
19
+ "@kuralle-syrinx/ws": "3.1.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
- socket.onClose(() => {
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: decodeBase64(message.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");
@@ -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
@@ -241,6 +241,7 @@ export async function createTelnyxMediaStreamServer(
241
241
  const finalFrame = frames.at(-1);
242
242
  if (finalFrame) {
243
243
  frames[frames.length - 1] = {
244
+ contextId,
244
245
  send: finalFrame.send,
245
246
  afterSend: () => {
246
247
  if (state.stopped) return;