@kuralle-syrinx/server-websocket 3.1.0 → 4.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.
@@ -26,11 +26,16 @@ import {
26
26
  type StreamingPcm16Resampler,
27
27
  } from "@kuralle-syrinx/core/audio";
28
28
  import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
29
+ import {
30
+ BackgroundAudioMixer,
31
+ wireBackgroundThinking,
32
+ type BackgroundAudioConfig,
33
+ } from "./background-audio.js";
29
34
  import {
30
35
  createWorkersInboundSocket,
31
36
  type WorkersDurableObjectWebSocketContext,
32
37
  } from "@kuralle-syrinx/ws/workers";
33
- import type { SessionStore } from "./session-store.js";
38
+ import type { SessionStore, ManagedSession } from "./session-store.js";
34
39
 
35
40
  const TWILIO_SAMPLE_RATE_HZ = 8_000;
36
41
  const DEFAULT_RESUME_WINDOW_MS = 15_000;
@@ -45,6 +50,14 @@ export interface TwilioEdgeWebSocketOptions {
45
50
  readonly scheduler?: Scheduler;
46
51
  /** Engine-side PCM rate (default 16000). Twilio's leg is always 8 kHz μ-law. */
47
52
  readonly engineSampleRateHz?: number;
53
+ /**
54
+ * Ambient/thinking bed: mixed (ducked) under assistant speech, and sent as
55
+ * comfort-noise frames between turns — pure digital silence on a phone line
56
+ * reads as "the call died". Thinking loop follows the G3 tool-call cues.
57
+ */
58
+ readonly backgroundAudio?: BackgroundAudioConfig;
59
+ /** Cadence of idle comfort-noise frames (ms). @default 200 */
60
+ readonly backgroundIdleFrameMs?: number;
48
61
  readonly resumeWindowMs?: number;
49
62
  readonly keepAliveIntervalMs?: number;
50
63
  readonly idleTimeoutMs?: number;
@@ -82,6 +95,8 @@ export async function runTwilioEdgeWebSocketConnection(
82
95
  const downlinkResamplers = new Map<string, StreamingPcm16Resampler>();
83
96
  const disposers: Array<() => void> = [];
84
97
  let session: VoiceAgentSession | null = null;
98
+ let managed: ManagedSession | null = null;
99
+ let pendingLease: Promise<{ managed: ManagedSession }> | null = null;
85
100
  let sessionId = "";
86
101
  let streamSid = "";
87
102
  let contextId = "";
@@ -100,9 +115,26 @@ export async function runTwilioEdgeWebSocketConnection(
100
115
  if (closed) return;
101
116
  closed = true;
102
117
  scheduler.cancel(KEEP_ALIVE_KEY);
118
+ scheduler.cancel("voice.edge.twilio.startup");
103
119
  for (const dispose of disposers.splice(0)) dispose();
104
- if (session && sessionId) {
120
+ if (managed && sessionId) {
121
+ // Decrement the connection count BEFORE releasing (R1) — otherwise release
122
+ // early-returns while connectionCount > 0 and session.close() never runs, so
123
+ // Deepgram/TTS provider sockets + the reasoner leak until DO eviction. A
124
+ // caller-hangup (`stopped`) releases immediately (retain 0); a transient drop
125
+ // keeps the session warm for the resume window.
126
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
105
127
  void options.sessionStore.release(sessionId, stopped ? 0 : resumeWindowMs);
128
+ } else if (pendingLease && sessionId) {
129
+ // Closed before the lease was adopted (e.g. startup-timeout race): tear the
130
+ // in-flight session down when it resolves so it is never orphaned in the store.
131
+ const id = sessionId;
132
+ void pendingLease
133
+ .then((leased) => {
134
+ leased.managed.connectionCount = Math.max(0, leased.managed.connectionCount - 1);
135
+ return options.sessionStore.release(id, 0);
136
+ })
137
+ .catch(() => undefined);
106
138
  }
107
139
  };
108
140
 
@@ -141,37 +173,61 @@ export async function runTwilioEdgeWebSocketConnection(
141
173
  reject(new Error("twilio session startup timeout"));
142
174
  });
143
175
  });
144
- const leased = await Promise.race([
145
- options.sessionStore.lease(sessionId, async () => {
146
- const sess = await options.createSession(request);
147
- await sess.start();
148
- return {
149
- id: sessionId,
150
- session: sess,
151
- currentContextId: "",
152
- contextSampleRates: new Map(),
153
- inputSequence: { lastSequence: null },
154
- turnMetricsTurns: new Map(),
155
- closeTimer: null,
156
- connectionCount: 1,
157
- };
158
- }),
159
- startupTimer,
160
- ]);
176
+ pendingLease = options.sessionStore.lease(sessionId, async () => {
177
+ const sess = await options.createSession(request);
178
+ await sess.start();
179
+ return {
180
+ id: sessionId,
181
+ session: sess,
182
+ currentContextId: "",
183
+ contextSampleRates: new Map(),
184
+ inputSequence: { lastSequence: null },
185
+ turnMetricsTurns: new Map(),
186
+ closeTimer: null,
187
+ connectionCount: 1,
188
+ };
189
+ });
190
+ const leased = await Promise.race([pendingLease, startupTimer]);
191
+ pendingLease = null;
161
192
  scheduler.cancel("voice.edge.twilio.startup");
162
- session = leased.managed.session;
193
+ managed = leased.managed;
194
+ session = managed.session;
163
195
  if (closed) {
196
+ // The socket closed while the session was starting up — tear the just-started
197
+ // session down instead of orphaning it (decrement so release actually closes it).
198
+ managed.connectionCount = Math.max(0, managed.connectionCount - 1);
164
199
  await options.sessionStore.release(sessionId, 0);
165
200
  return;
166
201
  }
167
202
 
203
+ // Background bed: ducked under speech in the tts.audio handler below, and
204
+ // sent as idle comfort-noise frames between turns — a phone line carrying
205
+ // pure digital silence reads as "the call died". Twilio's `clear` on
206
+ // barge-in drops any queued bed audio; the ticker refills on the next tick.
207
+ const backgroundAudio = options.backgroundAudio
208
+ ? new BackgroundAudioMixer(options.backgroundAudio)
209
+ : null;
210
+ if (backgroundAudio) {
211
+ wireBackgroundThinking(session, backgroundAudio);
212
+ const idleFrameMs = options.backgroundIdleFrameMs ?? 200;
213
+ const idleTimer = setInterval(() => {
214
+ if (!streamSid || stopped || closed || !socket.isOpen) return;
215
+ const frame = backgroundAudio.idleFrame(idleFrameMs, TWILIO_SAMPLE_RATE_HZ);
216
+ if (!frame) return;
217
+ const mulaw = encodePcm16ToMuLaw(pcm16BytesToSamples(frame));
218
+ sendTwilioJson({ event: "media", streamSid, media: { payload: bytesToBase64(mulaw) } });
219
+ }, idleFrameMs);
220
+ disposers.push(() => clearInterval(idleTimer));
221
+ }
222
+
168
223
  // Downlink: engine PCM → 8 kHz μ-law media frames; barge-in → clear.
169
224
  disposers.push(
170
225
  session.bus.on("tts.audio", (pkt) => {
171
226
  if (!streamSid) return;
172
227
  const audio = pkt as TextToSpeechAudioPacket;
173
- const samples = pcm16BytesToSamples(audio.audio);
174
228
  const sourceRate = audio.sampleRateHz ?? engineRate;
229
+ const wireAudio = backgroundAudio ? backgroundAudio.mix(audio.audio, sourceRate) : audio.audio;
230
+ const samples = pcm16BytesToSamples(wireAudio);
175
231
  const resampled = resamplePcm16Streaming(downlinkResamplers, samples, sourceRate, TWILIO_SAMPLE_RATE_HZ);
176
232
  const mulaw = encodePcm16ToMuLaw(resampled);
177
233
  sendTwilioJson({
package/src/edge.test.ts CHANGED
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  PipelineBusImpl,
6
6
  Route,
7
+ VoiceAgentSession as VoiceAgentSessionImpl,
7
8
  encodeSyrinxAudioEnvelope,
8
9
  type Scheduler,
9
10
  type ScheduledCallback,
@@ -381,6 +382,84 @@ describe("edge barge-in downlink", () => {
381
382
  });
382
383
  });
383
384
 
385
+ describe("edge tool-call cues (G3/WBS-3)", () => {
386
+ it("surfaces the typed lifecycle as tool_call_* wire messages: started → delayed → complete", async () => {
387
+ const socket = new FakeSocket();
388
+ const scheduler = new ManualScheduler();
389
+ const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 20 });
390
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
391
+ sessionStore: new InMemorySessionStore(),
392
+ scheduler,
393
+ createSession: () => session,
394
+ });
395
+ waitForReady(socket);
396
+
397
+ session.bus.push(Route.Main, {
398
+ kind: "llm.tool_call",
399
+ contextId: "turn-1",
400
+ timestampMs: Date.now(),
401
+ toolId: "t1",
402
+ toolName: "consult_knowledge",
403
+ toolArgs: { query: "fees" },
404
+ });
405
+ await new Promise((resolve) => setTimeout(resolve, 50));
406
+ session.bus.push(Route.Main, {
407
+ kind: "llm.tool_result",
408
+ contextId: "turn-1",
409
+ timestampMs: Date.now(),
410
+ toolId: "t1",
411
+ toolName: "consult_knowledge",
412
+ result: "answer",
413
+ });
414
+ await new Promise((resolve) => setTimeout(resolve, 20));
415
+
416
+ expect(socket.json()).toContainEqual({
417
+ type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
418
+ });
419
+ expect(socket.json()).toContainEqual({
420
+ type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 20,
421
+ });
422
+ expect(socket.json()).toContainEqual({
423
+ type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
424
+ });
425
+ });
426
+
427
+ it("surfaces tool_call_failed when a barge-in aborts the pending delegate (R5)", async () => {
428
+ const socket = new FakeSocket();
429
+ const scheduler = new ManualScheduler();
430
+ const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 0 });
431
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
432
+ sessionStore: new InMemorySessionStore(),
433
+ scheduler,
434
+ createSession: () => session,
435
+ });
436
+ waitForReady(socket);
437
+
438
+ session.bus.push(Route.Main, {
439
+ kind: "llm.tool_call",
440
+ contextId: "turn-1",
441
+ timestampMs: Date.now(),
442
+ toolId: "t1",
443
+ toolName: "consult_knowledge",
444
+ toolArgs: {},
445
+ });
446
+ await new Promise((resolve) => setTimeout(resolve, 10));
447
+ session.bus.push(Route.Critical, {
448
+ kind: "interrupt.detected",
449
+ contextId: "turn-1",
450
+ timestampMs: Date.now(),
451
+ source: "vad",
452
+ });
453
+ await new Promise((resolve) => setTimeout(resolve, 10));
454
+
455
+ expect(socket.json()).toContainEqual({
456
+ type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
457
+ });
458
+ // Barge-in downlink unaffected — the flush messages still went out.
459
+ expect(socket.json().some((m) => m.type === "audio_clear")).toBe(true);
460
+ });
461
+ });
462
+
384
463
  describe("edge client playout progress", () => {
385
464
  interface ProgressPacket {
386
465
  contextId: string;
@@ -450,3 +529,63 @@ describe("edge client playout progress", () => {
450
529
  expect(socket.json().some((m) => m.type === "error" && m.category === "invalid_input")).toBe(true);
451
530
  });
452
531
  });
532
+
533
+ describe("edge background audio", () => {
534
+ it("mixes the bed into the wire audio while the recorder keeps the clean track", async () => {
535
+ const socket = new FakeSocket();
536
+ const scheduler = new ManualScheduler();
537
+ const recorded: Int16Array[] = [];
538
+ const rec: EdgeRecorder = {
539
+ onUserAudio() {},
540
+ onAssistantAudio(_contextId: string, audio: Uint8Array) {
541
+ recorded.push(new Int16Array(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength)));
542
+ },
543
+ finalize() {},
544
+ };
545
+ const bus = new PipelineBusImpl();
546
+ const session = {
547
+ bus,
548
+ async start() {
549
+ void bus.start();
550
+ },
551
+ async close() {
552
+ bus.stop();
553
+ },
554
+ on() {},
555
+ off() {},
556
+ requestClientInterrupt() {},
557
+ } as unknown as VoiceAgentSession;
558
+
559
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
560
+ sessionStore: new InMemorySessionStore(),
561
+ scheduler,
562
+ createSession: () => session,
563
+ recorder: rec,
564
+ backgroundAudio: {
565
+ ambient: { pcm: new Int16Array(160).fill(2000), sampleRateHz: 16000, gain: 0.5 },
566
+ duckWhileSpeaking: 0.5,
567
+ fadeMs: 0,
568
+ },
569
+ });
570
+ waitForReady(socket);
571
+
572
+ bus.push(Route.Main, {
573
+ kind: "tts.audio",
574
+ contextId: "bg-turn",
575
+ timestampMs: Date.now(),
576
+ audio: pcm16SamplesToBytes(new Int16Array(4)), // silent speech chunk
577
+ sampleRateHz: 16000,
578
+ });
579
+ await new Promise((r) => setTimeout(r, 20));
580
+
581
+ // Recorder saw the CLEAN chunk.
582
+ expect(recorded).toHaveLength(1);
583
+ expect(Array.from(recorded[0]!)).toEqual([0, 0, 0, 0]);
584
+
585
+ // The wire envelope carries the bed: 2000 × 0.5 gain × 0.5 duck = 500.
586
+ const binary = socket.sent.find((d): d is Uint8Array => d instanceof Uint8Array);
587
+ expect(binary).toBeDefined();
588
+ const wireSamples = new Int16Array(binary!.buffer.slice(binary!.byteOffset + (binary!.byteLength - 8)));
589
+ expect(Array.from(wireSamples)).toEqual([500, 500, 500, 500]);
590
+ });
591
+ });
package/src/edge.ts CHANGED
@@ -14,6 +14,12 @@ import {
14
14
  } from "@kuralle-syrinx/core";
15
15
  import { TimerScheduler, type Scheduler } from "@kuralle-syrinx/core";
16
16
  import { type StreamingPcm16Resampler } from "@kuralle-syrinx/core/audio";
17
+ import {
18
+ BackgroundAudioMixer,
19
+ wireBackgroundThinking,
20
+ type BackgroundAudioConfig,
21
+ } from "./background-audio.js";
22
+ export type { BackgroundAudioConfig, BackgroundAudioSource } from "./background-audio.js";
17
23
  import {
18
24
  createWorkersInboundSocket,
19
25
  type WorkersDurableObjectWebSocketContext,
@@ -76,6 +82,12 @@ export interface VoiceEdgeWebSocketOptions {
76
82
  * the Syrinx binary envelope instead.
77
83
  */
78
84
  readonly rawBinaryInput?: boolean;
85
+ /**
86
+ * Ambient/thinking bed mixed (ducked) under assistant speech. Browser edge
87
+ * sends no idle bed between turns — a web client can loop ambience locally;
88
+ * the server-side bed exists for wires with no client runtime (telephony).
89
+ */
90
+ readonly backgroundAudio?: BackgroundAudioConfig;
79
91
  readonly sessionStore: SessionStore;
80
92
  readonly scheduler?: Scheduler;
81
93
  }
@@ -265,7 +277,14 @@ export async function runVoiceEdgeWebSocketConnection(
265
277
  await options.sessionStore.release(leased.managed.id, 0);
266
278
  return;
267
279
  }
268
- wireEdgeSessionEvents(session, socket, disposers, outputSampleRateHz, options.recorder);
280
+ wireEdgeSessionEvents(
281
+ session,
282
+ socket,
283
+ disposers,
284
+ outputSampleRateHz,
285
+ options.recorder,
286
+ options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined,
287
+ );
269
288
  if (options.recorder) {
270
289
  const recorder = options.recorder;
271
290
  disposers.push(
@@ -349,7 +368,9 @@ function wireEdgeSessionEvents(
349
368
  disposers: Array<() => void>,
350
369
  outputSampleRateHz: number,
351
370
  recorder?: EdgeRecorder,
371
+ backgroundAudio?: BackgroundAudioMixer,
352
372
  ): void {
373
+ if (backgroundAudio) wireBackgroundThinking(session, backgroundAudio);
353
374
  const onSession = <K extends keyof VoiceAgentSessionEvents>(
354
375
  event: K,
355
376
  handler: VoiceAgentSessionEvents[K],
@@ -367,6 +388,19 @@ function wireEdgeSessionEvents(
367
388
  onSession("agent_text_delta", (event) => {
368
389
  sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta });
369
390
  });
391
+ // G3 (RFC bimodel-delegate-seam): typed preamble/filler lifecycle. The standard
392
+ // "thinking" wire cue — clients key earcons/indicators on these instead of an
393
+ // app-invented message. started fires before the reasoner runs; delayed is the
394
+ // time-triggered "still working"; complete/failed end the wait (incl. barge-in).
395
+ onSession("tool_call_cue", (event) => {
396
+ sendJson(socket, {
397
+ type: `tool_call_${event.phase}`,
398
+ turnId: event.turnId,
399
+ toolId: event.toolId,
400
+ toolName: event.toolName,
401
+ ...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
402
+ });
403
+ });
370
404
  onSession("agent_finished", (event) => {
371
405
  sendJson(socket, { type: "agent_end", turnId: event.turnId });
372
406
  });
@@ -378,27 +412,29 @@ function wireEdgeSessionEvents(
378
412
  session.bus.on("tts.audio", (pkt) => {
379
413
  const audio = pkt as TextToSpeechAudioPacket;
380
414
  const ttsSampleRate = requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz;
415
+ // Recorder gets the CLEAN assistant track; the bed is a wire-level effect.
381
416
  recorder?.onAssistantAudio(audio.contextId, audio.audio, ttsSampleRate);
417
+ const wireAudio = backgroundAudio ? backgroundAudio.mix(audio.audio, ttsSampleRate) : audio.audio;
382
418
  sendJson(socket, {
383
419
  type: "tts_chunk",
384
420
  turnId: audio.contextId,
385
421
  sequence: 1,
386
- sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
422
+ sampleRateHz: ttsSampleRate,
387
423
  encoding: "pcm_s16le",
388
424
  channels: 1,
389
- byteLength: audio.audio.byteLength,
390
- durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
425
+ byteLength: wireAudio.byteLength,
426
+ durationMs: pcm16DurationMs(wireAudio, outputSampleRateHz),
391
427
  });
392
428
  socket.send(encodeSyrinxAudioEnvelope({
393
429
  type: "audio",
394
430
  contextId: audio.contextId,
395
431
  sequence: 1,
396
- sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
432
+ sampleRateHz: ttsSampleRate,
397
433
  encoding: "pcm_s16le",
398
434
  channels: 1,
399
- byteLength: audio.audio.byteLength,
400
- durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
401
- }, audio.audio));
435
+ byteLength: wireAudio.byteLength,
436
+ durationMs: pcm16DurationMs(wireAudio, outputSampleRateHz),
437
+ }, wireAudio));
402
438
  }),
403
439
  session.bus.on("tts.end", (pkt) => {
404
440
  const end = pkt as TextToSpeechEndPacket;
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
5
+ import { decodeInboundBinaryAudio, resampleAudioBytes, type OpusIngressDecoder } from "./inbound-audio.js";
6
+
7
+ // Regression for the opus mic-uplink double-resample P0: opus ingress is decoded
8
+ // AND resampled to the engine rate inside decodeInboundBinaryAudio, so it must
9
+ // report the ENGINE rate — not the 48 kHz header rate — or the caller resamples
10
+ // the already-16 kHz audio a second time and delivers ~1/3 the samples (3× fast).
11
+ describe("decodeInboundBinaryAudio opus rate", () => {
12
+ const ENGINE_RATE = 16000;
13
+ const WIRE_RATE = 48000;
14
+
15
+ // A fake opus decoder returning PCM at the opus native rate (48 kHz), as the real
16
+ // decoder does. decodeInboundBinaryAudio then resamples it ONCE to the engine rate.
17
+ // 20 ms at 48 kHz mono PCM16 = 960 samples = 1920 bytes → 320 samples = 640 bytes @ 16 kHz.
18
+ const opusNativePcm = new Uint8Array(1920);
19
+ const fakeOpusDecoder: OpusIngressDecoder = () => opusNativePcm;
20
+
21
+ function opusEnvelope(): Uint8Array {
22
+ // The opus payload bytes are opaque to the decoder mock.
23
+ const opusPayload = new Uint8Array([1, 2, 3, 4]);
24
+ return encodeSyrinxAudioEnvelope(
25
+ {
26
+ type: "audio",
27
+ contextId: "turn-1",
28
+ sampleRateHz: WIRE_RATE,
29
+ sequence: 1,
30
+ encoding: "opus",
31
+ channels: 1,
32
+ byteLength: opusPayload.byteLength,
33
+ durationMs: 20,
34
+ },
35
+ opusPayload,
36
+ );
37
+ }
38
+
39
+ it("reports the engine rate, not the header rate, so the caller does not resample again", () => {
40
+ const decoded = decodeInboundBinaryAudio(
41
+ opusEnvelope(),
42
+ ENGINE_RATE,
43
+ false,
44
+ ENGINE_RATE,
45
+ new Map(),
46
+ fakeOpusDecoder,
47
+ );
48
+
49
+ // Decoded once from 48 kHz → 16 kHz inside decodeInboundBinaryAudio: 640 bytes.
50
+ expect(decoded.sampleRateHz).toBe(ENGINE_RATE);
51
+ expect(decoded.audio.byteLength).toBe(640);
52
+
53
+ // The caller's final resample (reported rate → engine rate) is now an identity:
54
+ // 320 samples in, 320 samples out (not ~107 as under the old 48 kHz label → 3× fast).
55
+ const final = resampleAudioBytes(decoded.audio, decoded.sampleRateHz, ENGINE_RATE, new Map());
56
+ expect(final.byteLength).toBe(640);
57
+ });
58
+ });
@@ -42,12 +42,19 @@ export function decodeInboundBinaryAudio(
42
42
  }
43
43
  const { header, audio } = decodeSyrinxAudioEnvelope(data);
44
44
  const sampleRateHz = requirePositiveIntegerFromHeader(header.sampleRateHz) ?? defaultSampleRateHz;
45
- const wireAudio = header.encoding === "opus"
45
+ const isOpus = header.encoding === "opus";
46
+ const wireAudio = isOpus
46
47
  ? decodeOpusIngressMessage(audio, sampleRateHz, decodeOpusIngress, engineInputSampleRateHz, streamingResamplers)
47
48
  : audio;
49
+ // Opus ingress is decoded AND resampled to the engine rate inside
50
+ // decodeOpusIngressMessage, so the returned PCM is already at engineInputSampleRateHz.
51
+ // Report that as its rate — reporting the 48 kHz *header* rate made the caller
52
+ // resample the already-16 kHz audio a second time, delivering ~1/3 the samples
53
+ // (3× sped-up audio → STT garbage). PCM carries its true header source rate.
54
+ const effectiveSampleRateHz = isOpus ? engineInputSampleRateHz : sampleRateHz;
48
55
  return {
49
56
  contextId: typeof header.contextId === "string" && header.contextId.length > 0 ? header.contextId : undefined,
50
- sampleRateHz,
57
+ sampleRateHz: effectiveSampleRateHz,
51
58
  sequence: header.sequence,
52
59
  audio: wireAudio,
53
60
  };
package/src/index.test.ts CHANGED
@@ -601,7 +601,7 @@ describe("createVoiceWebSocketServer", () => {
601
601
  type: "tts_chunk",
602
602
  turnId: "turn-2",
603
603
  sequence: 1,
604
- sampleRateHz: 16000,
604
+ sampleRateHz: 48000, // opus frames are labeled at the 48 kHz codec rate
605
605
  encoding: "opus",
606
606
  channels: 1,
607
607
  });
@@ -610,7 +610,7 @@ describe("createVoiceWebSocketServer", () => {
610
610
  type: "audio",
611
611
  contextId: "turn-2",
612
612
  sequence: 1,
613
- sampleRateHz: 16000,
613
+ sampleRateHz: 48000,
614
614
  encoding: "opus",
615
615
  });
616
616
  expect(envelope.audio.byteLength).toBeGreaterThan(0);
@@ -1697,11 +1697,14 @@ describe("createVoiceWebSocketServer", () => {
1697
1697
  });
1698
1698
 
1699
1699
  const envelope = decodeTestBinaryAudioEnvelope(await binaryMessage);
1700
+ // Opus frames are encoded at the codec rate (48 kHz) and MUST be labeled as such,
1701
+ // so the client decodes then downsamples 48→16 kHz. Labeling them 16 kHz made the
1702
+ // client skip the downsample and play assistant audio ~3× slow.
1700
1703
  expect(envelope.header).toMatchObject({
1701
1704
  type: "audio",
1702
1705
  contextId: "turn-tts",
1703
1706
  sequence: 1,
1704
- sampleRateHz: 16000,
1707
+ sampleRateHz: 48000,
1705
1708
  encoding: "opus",
1706
1709
  channels: 1,
1707
1710
  });