@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "3.1.0",
3
+ "version": "4.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": "3.1.0",
19
- "@kuralle-syrinx/ws": "3.1.0"
18
+ "@kuralle-syrinx/core": "4.1.0",
19
+ "@kuralle-syrinx/ws": "4.1.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
@@ -79,6 +79,29 @@ describe("WT-08 admission control and upgrade routing", () => {
79
79
  await server.close();
80
80
  });
81
81
 
82
+ it("rejects an unauthorized upgrade with 4401 and admits an authorized one", async () => {
83
+ const server = registerServer(await createVoiceWebSocketServer({
84
+ port: 0,
85
+ authorize: (request) => new URL(request.url ?? "/", "http://x").searchParams.get("token") === "secret",
86
+ createSession: () => new VoiceAgentSession({ plugins: {} }),
87
+ }));
88
+ const address = server.address();
89
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
90
+
91
+ const rejected = registerSocket(new WebSocket(`${websocketUrl(address.port)}?token=wrong`));
92
+ await new Promise<void>((resolve, reject) => {
93
+ rejected.once("open", resolve);
94
+ rejected.once("error", reject);
95
+ });
96
+ expect(await waitForClose(rejected)).toBe(4401);
97
+
98
+ const authorized = await openBrowserSocketReady(`${websocketUrl(address.port)}?token=secret`);
99
+ expect(authorized.readyState).toBe(WebSocket.OPEN);
100
+
101
+ authorized.close();
102
+ await server.close();
103
+ });
104
+
82
105
  it("destroys sockets on unmatched upgrade paths when this router is the sole upgrade handler", async () => {
83
106
  const httpServer = registerHttpServer(createServer());
84
107
  const server = registerServer(await createVoiceWebSocketServer({
@@ -0,0 +1,172 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { BackgroundAudioMixer } from "./background-audio.js";
6
+
7
+ function pcmBytes(samples: number[]): Uint8Array {
8
+ return new Uint8Array(Int16Array.from(samples).buffer.slice(0));
9
+ }
10
+
11
+ function samplesOf(bytes: Uint8Array): number[] {
12
+ return Array.from(new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2));
13
+ }
14
+
15
+ /** Constant-value ambient source: every mixed sample adds value*gain. */
16
+ function constantSource(value: number, length = 160, sampleRateHz = 16000) {
17
+ return { pcm: new Int16Array(length).fill(value), sampleRateHz };
18
+ }
19
+
20
+ describe("BackgroundAudioMixer", () => {
21
+ it("mixes ducked ambient into a TTS chunk and full-gain ambient into idle frames", () => {
22
+ const mixer = new BackgroundAudioMixer({
23
+ ambient: { ...constantSource(1000), gain: 0.5 },
24
+ duckWhileSpeaking: 0.5,
25
+ fadeMs: 0,
26
+ });
27
+
28
+ const t0 = 1_000_000;
29
+ const mixed = mixer.mix(pcmBytes([0, 0, 0, 0]), 16000, t0);
30
+ // ambient 1000 * gain 0.5 * duck 0.5 = 250 on top of silence
31
+ expect(samplesOf(mixed)).toEqual([250, 250, 250, 250]);
32
+
33
+ // Once speech has drained, the idle frame carries ambient at full gain.
34
+ const idle = mixer.idleFrame(1, 16000, t0 + 10_000);
35
+ expect(idle).not.toBeNull();
36
+ expect(samplesOf(idle!)).toEqual(Array(16).fill(500)); // 1ms @16k = 16 samples, 1000*0.5
37
+ });
38
+
39
+ it("suppresses idle frames while speech is still playing out", () => {
40
+ const mixer = new BackgroundAudioMixer({ ambient: constantSource(1000), fadeMs: 0 });
41
+ const t0 = 2_000_000;
42
+ // 160 samples @16k = 10ms of speech → speaking until t0+10.
43
+ mixer.mix(pcmBytes(Array(160).fill(0)), 16000, t0);
44
+ expect(mixer.isSpeaking(t0 + 5)).toBe(true);
45
+ expect(mixer.idleFrame(20, 16000, t0 + 5)).toBeNull();
46
+ expect(mixer.isSpeaking(t0 + 11)).toBe(false);
47
+ expect(mixer.idleFrame(20, 16000, t0 + 11)).not.toBeNull();
48
+ });
49
+
50
+ it("keeps the ambient position continuous across mix and idle frames", () => {
51
+ // Ramp source so position is observable: sample i has value i.
52
+ const ramp = Int16Array.from({ length: 1000 }, (_, i) => i);
53
+ const mixer = new BackgroundAudioMixer({
54
+ ambient: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
55
+ duckWhileSpeaking: 1,
56
+ fadeMs: 0,
57
+ });
58
+ const t0 = 3_000_000;
59
+ const first = mixer.mix(pcmBytes([0, 0, 0, 0]), 16000, t0);
60
+ expect(samplesOf(first)).toEqual([0, 1, 2, 3]);
61
+ const idle = mixer.idleFrame(1, 16000, t0 + 1000);
62
+ expect(samplesOf(idle!)[0]).toBe(4); // continues where mix left off
63
+ const next = mixer.mix(pcmBytes([0, 0]), 16000, t0 + 2000);
64
+ expect(samplesOf(next)).toEqual([20, 21]); // 4 + 16 idle samples consumed
65
+ });
66
+
67
+ it("loops the ambient source past its end", () => {
68
+ const ramp = Int16Array.from({ length: 4 }, (_, i) => i + 1); // 1,2,3,4
69
+ const mixer = new BackgroundAudioMixer({
70
+ ambient: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
71
+ duckWhileSpeaking: 1,
72
+ fadeMs: 0,
73
+ });
74
+ const mixed = mixer.mix(pcmBytes(Array(10).fill(0)), 16000, 4_000_000);
75
+ expect(samplesOf(mixed)).toEqual([1, 2, 3, 4, 1, 2, 3, 4, 1, 2]);
76
+ });
77
+
78
+ it("plays the thinking loop only while thinking, and restarts it fresh each episode", () => {
79
+ const ramp = Int16Array.from({ length: 100 }, (_, i) => i);
80
+ const mixer = new BackgroundAudioMixer({
81
+ thinking: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
82
+ fadeMs: 0,
83
+ });
84
+ const t0 = 5_000_000;
85
+ expect(mixer.idleFrame(1, 16000, t0)).toBeNull(); // nothing to play
86
+
87
+ mixer.setThinking(true);
88
+ const a = mixer.idleFrame(1, 16000, t0);
89
+ expect(samplesOf(a!)).toEqual(Array.from({ length: 16 }, (_, i) => i));
90
+
91
+ mixer.setThinking(false);
92
+ expect(mixer.idleFrame(1, 16000, t0)).toBeNull();
93
+
94
+ mixer.setThinking(true); // new episode restarts at 0
95
+ const b = mixer.idleFrame(1, 16000, t0);
96
+ expect(samplesOf(b!)[0]).toBe(0);
97
+ });
98
+
99
+ it("resamples sources to the wire rate once and mixes correctly", () => {
100
+ // 8k constant source mixed into a 16k chunk: values unchanged, length follows the chunk.
101
+ const mixer = new BackgroundAudioMixer({
102
+ ambient: { pcm: new Int16Array(80).fill(800), sampleRateHz: 8000, gain: 1 },
103
+ duckWhileSpeaking: 1,
104
+ fadeMs: 0,
105
+ });
106
+ const mixed = mixer.mix(pcmBytes(Array(8).fill(0)), 16000, 6_000_000);
107
+ expect(samplesOf(mixed)).toEqual(Array(8).fill(800));
108
+ });
109
+
110
+ it("clips the sum to int16 range", () => {
111
+ const mixer = new BackgroundAudioMixer({
112
+ ambient: { pcm: new Int16Array(16).fill(20000), sampleRateHz: 16000, gain: 1 },
113
+ duckWhileSpeaking: 1,
114
+ fadeMs: 0,
115
+ });
116
+ const mixed = mixer.mix(pcmBytes([30000, -30000]), 16000, 7_000_000);
117
+ expect(samplesOf(mixed)).toEqual([32767, -10000]);
118
+ });
119
+
120
+ it("passes TTS through untouched when no sources are configured", () => {
121
+ const mixer = new BackgroundAudioMixer({});
122
+ const chunk = pcmBytes([1, 2, 3]);
123
+ expect(samplesOf(mixer.mix(chunk, 16000, 8_000_000))).toEqual([1, 2, 3]);
124
+ expect(mixer.idleFrame(20, 16000, 8_000_000)).toBeNull();
125
+ expect(mixer.hasSources).toBe(false);
126
+ });
127
+
128
+ it("fades the ambient bed in at start instead of hard-cutting (equal-power ramp)", () => {
129
+ // fadeMs 1 @ 16k = 16-sample ramp.
130
+ const mixer = new BackgroundAudioMixer({
131
+ ambient: { ...constantSource(1000), gain: 1 },
132
+ fadeMs: 1,
133
+ });
134
+ const first = mixer.idleFrame(1, 16000, 9_000_000)!;
135
+ const samples = samplesOf(first);
136
+ expect(samples[0]).toBe(0); // sin(0) = 0
137
+ expect(samples[8]!).toBeGreaterThan(400); // mid-ramp
138
+ expect(samples[8]!).toBeLessThan(900);
139
+ // Past the ramp: full gain, permanently.
140
+ const second = mixer.idleFrame(1, 16000, 9_000_100)!;
141
+ expect(samplesOf(second)).toEqual(Array(16).fill(1000));
142
+ });
143
+
144
+ it("fades the thinking loop out on stop, then goes silent", () => {
145
+ const mixer = new BackgroundAudioMixer({
146
+ thinking: { pcm: new Int16Array(1000).fill(1000), sampleRateHz: 16000, gain: 1 },
147
+ fadeMs: 1, // 16-sample ramps
148
+ });
149
+ const t0 = 10_000_000;
150
+ mixer.setThinking(true);
151
+ mixer.idleFrame(2, 16000, t0); // ride past the fade-in
152
+ const steady = mixer.idleFrame(1, 16000, t0 + 100)!;
153
+ expect(samplesOf(steady)).toEqual(Array(16).fill(1000));
154
+
155
+ mixer.setThinking(false);
156
+ const fading = mixer.idleFrame(1, 16000, t0 + 200)!; // one fade-out ramp, decaying
157
+ const fadeSamples = samplesOf(fading);
158
+ expect(fadeSamples[0]).toBe(1000); // cos(0) = 1
159
+ expect(fadeSamples[15]!).toBeLessThan(250); // near the end of the ramp
160
+ expect(mixer.idleFrame(1, 16000, t0 + 300)).toBeNull(); // episode fully over
161
+ });
162
+
163
+ it("fades each new thinking episode in from silence", () => {
164
+ const mixer = new BackgroundAudioMixer({
165
+ thinking: { pcm: new Int16Array(1000).fill(1000), sampleRateHz: 16000, gain: 1 },
166
+ fadeMs: 1,
167
+ });
168
+ mixer.setThinking(true);
169
+ const first = mixer.idleFrame(1, 16000, 11_000_000)!;
170
+ expect(samplesOf(first)[0]).toBe(0); // soft entry, not a hard cut
171
+ });
172
+ });
@@ -0,0 +1,263 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Background audio for the outbound path: a looped ambient bed (which doubles
4
+ // as telephony comfort noise between turns), an optional "thinking" loop keyed
5
+ // off the G3 tool-call cues, and ducking under assistant speech.
6
+ //
7
+ // Syrinx wires (Twilio media stream, browser jitter buffer) are single ORDERED
8
+ // streams — unlike LiveKit's WebRTC rooms there is no client-side track mixing
9
+ // — so background audio is mixed server-side into the one stream: `mix()` layers
10
+ // the bed under every TTS chunk (ducked), and `idleFrame()` produces bed-only
11
+ // frames for the gaps between turns. Source playback positions are shared
12
+ // between the two so the bed is seamless across turn boundaries.
13
+ //
14
+ // Runtime-neutral: no Node imports — runs on workerd (edge hosts) as-is.
15
+ // Prior art: Pipecat SoundfileMixer (transport-level mix + volume + loop),
16
+ // LiveKit BackgroundAudioPlayer (ambient/thinking split, state-keyed thinking).
17
+
18
+ import type { VoiceAgentSession } from "@kuralle-syrinx/core";
19
+ import { pcm16BytesToSamples, pcm16SamplesToBytes, resamplePcm16 } from "@kuralle-syrinx/core/audio";
20
+
21
+ export interface BackgroundAudioSource {
22
+ /** Mono PCM16 samples. Must be loop-clean if it will loop audibly. */
23
+ readonly pcm: Int16Array;
24
+ readonly sampleRateHz: number;
25
+ /** Linear gain 0..1 applied to this source. */
26
+ readonly gain?: number;
27
+ }
28
+
29
+ export interface BackgroundAudioConfig {
30
+ /** Looped continuously: under speech (ducked) and alone between turns. */
31
+ readonly ambient?: BackgroundAudioSource;
32
+ /** Looped while `setThinking(true)` — the audible face of a pending tool call. */
33
+ readonly thinking?: BackgroundAudioSource;
34
+ /**
35
+ * Extra multiplier on background sources while assistant speech is being
36
+ * mixed, so the bed never muddies the voice. 1 disables ducking. @default 0.5
37
+ */
38
+ readonly duckWhileSpeaking?: number;
39
+ /**
40
+ * Equal-power fade applied where the bed would otherwise hard-cut: the
41
+ * ambient's very first samples, and each thinking episode's start and stop.
42
+ * An abrupt full-volume onset is the harshest moment a caller hears from a
43
+ * wait — the fade is what makes an episode feel placed rather than switched.
44
+ * 0 disables. @default 250
45
+ */
46
+ readonly fadeMs?: number;
47
+ }
48
+
49
+ const DEFAULT_AMBIENT_GAIN = 0.25;
50
+ const DEFAULT_THINKING_GAIN = 0.4;
51
+ const DEFAULT_DUCK = 0.5;
52
+ const DEFAULT_FADE_MS = 250;
53
+
54
+ interface LoopedSource {
55
+ readonly original: BackgroundAudioSource;
56
+ readonly gain: number;
57
+ /** Resampled variants cached per wire rate. */
58
+ readonly byRate: Map<number, Int16Array>;
59
+ /** Playback position in samples at the current wire rate. */
60
+ position: number;
61
+ positionRateHz: number;
62
+ }
63
+
64
+ function loopedSource(source: BackgroundAudioSource, defaultGain: number): LoopedSource {
65
+ return {
66
+ original: source,
67
+ gain: source.gain ?? defaultGain,
68
+ byRate: new Map(),
69
+ position: 0,
70
+ positionRateHz: source.sampleRateHz,
71
+ };
72
+ }
73
+
74
+ function samplesAtRate(source: LoopedSource, rateHz: number): Int16Array {
75
+ let samples = source.byRate.get(rateHz);
76
+ if (!samples) {
77
+ samples = resamplePcm16(source.original.pcm, source.original.sampleRateHz, rateHz);
78
+ source.byRate.set(rateHz, samples);
79
+ }
80
+ if (source.positionRateHz !== rateHz) {
81
+ // Wire rate changed mid-connection (rare): carry the position over proportionally.
82
+ source.position = Math.round((source.position * rateHz) / source.positionRateHz);
83
+ source.positionRateHz = rateHz;
84
+ }
85
+ return samples;
86
+ }
87
+
88
+ /**
89
+ * Add samples of `source` (scaled by `scale`, optionally shaped per-sample by
90
+ * `envelope(i)`) into `target`, advancing the loop.
91
+ */
92
+ function addLooped(
93
+ target: Float64Array,
94
+ source: LoopedSource,
95
+ rateHz: number,
96
+ scale: number,
97
+ envelope?: (i: number) => number,
98
+ ): void {
99
+ const samples = samplesAtRate(source, rateHz);
100
+ if (samples.length === 0 || scale === 0) return;
101
+ let pos = source.position % samples.length;
102
+ for (let i = 0; i < target.length; i += 1) {
103
+ const gain = envelope ? scale * envelope(i) : scale;
104
+ target[i]! += samples[pos]! * gain;
105
+ pos += 1;
106
+ if (pos >= samples.length) pos = 0;
107
+ }
108
+ source.position = pos;
109
+ }
110
+
111
+ /** Equal-power fade-in gain for sample `n` of a `total`-sample ramp (1 past the ramp). */
112
+ function fadeInGain(n: number, total: number): number {
113
+ if (total <= 0 || n >= total) return 1;
114
+ return Math.sin((n / total) * (Math.PI / 2));
115
+ }
116
+
117
+ /** Equal-power fade-out gain for sample `n` of a `total`-sample ramp (0 past the ramp). */
118
+ function fadeOutGain(n: number, total: number): number {
119
+ if (total <= 0 || n >= total) return 0;
120
+ return Math.cos((n / total) * (Math.PI / 2));
121
+ }
122
+
123
+ function clipToPcm16Bytes(mix: Float64Array): Uint8Array {
124
+ const out = new Int16Array(mix.length);
125
+ for (let i = 0; i < mix.length; i += 1) {
126
+ const v = Math.round(mix[i]!);
127
+ out[i] = v > 32767 ? 32767 : v < -32768 ? -32768 : v;
128
+ }
129
+ return pcm16SamplesToBytes(out);
130
+ }
131
+
132
+ export class BackgroundAudioMixer {
133
+ private readonly ambient: LoopedSource | null;
134
+ private readonly thinking: LoopedSource | null;
135
+ private readonly duck: number;
136
+ private readonly fadeMs: number;
137
+ /**
138
+ * Thinking episode lifecycle: "stopping" keeps the loop audible for one
139
+ * fade-out ramp after `setThinking(false)` instead of hard-cutting it.
140
+ */
141
+ private thinkingState: "off" | "on" | "stopping" = "off";
142
+ /** Samples rendered since the current thinking episode started (fade-in). */
143
+ private thinkingFadeInPos = 0;
144
+ /** Samples rendered since the stop was requested (fade-out). */
145
+ private thinkingFadeOutPos = 0;
146
+ /** Samples of ambient rendered so far (one-time fade-in at bed start). */
147
+ private ambientFadeInPos = 0;
148
+ /** Realtime estimate of when already-mixed speech finishes playing out. */
149
+ private speakingUntilMs = 0;
150
+
151
+ constructor(config: BackgroundAudioConfig) {
152
+ this.ambient = config.ambient ? loopedSource(config.ambient, DEFAULT_AMBIENT_GAIN) : null;
153
+ this.thinking = config.thinking ? loopedSource(config.thinking, DEFAULT_THINKING_GAIN) : null;
154
+ this.duck = config.duckWhileSpeaking ?? DEFAULT_DUCK;
155
+ this.fadeMs = config.fadeMs ?? DEFAULT_FADE_MS;
156
+ }
157
+
158
+ get hasSources(): boolean {
159
+ return this.ambient !== null || this.thinking !== null;
160
+ }
161
+
162
+ isSpeaking(nowMs = Date.now()): boolean {
163
+ return nowMs < this.speakingUntilMs;
164
+ }
165
+
166
+ /** G3 cue wiring: started/delayed → true, complete/failed → false. */
167
+ setThinking(on: boolean): void {
168
+ if (on) {
169
+ if (this.thinkingState === "on") return;
170
+ this.thinkingState = "on";
171
+ this.thinkingFadeInPos = 0;
172
+ if (this.thinking) this.thinking.position = 0; // each episode starts from the top
173
+ return;
174
+ }
175
+ if (this.thinkingState !== "on") return;
176
+ // Audible for one fade-out ramp, then off (immediately off when fades are disabled).
177
+ this.thinkingState = this.fadeMs > 0 ? "stopping" : "off";
178
+ this.thinkingFadeOutPos = 0;
179
+ }
180
+
181
+ /**
182
+ * Add the bed sources into `mixBuf` at `bedScale`, applying the fade
183
+ * envelopes and advancing their positions/counters. Shared by mix (ducked)
184
+ * and idleFrame (full gain) so episodes stay continuous across both.
185
+ */
186
+ private addBed(mixBuf: Float64Array, sampleRateHz: number, bedScale: number): void {
187
+ const fadeSamples = Math.round((this.fadeMs / 1000) * sampleRateHz);
188
+
189
+ if (this.ambient) {
190
+ const startPos = this.ambientFadeInPos;
191
+ const envelope = startPos >= fadeSamples
192
+ ? undefined
193
+ : (i: number) => fadeInGain(startPos + i, fadeSamples);
194
+ addLooped(mixBuf, this.ambient, sampleRateHz, this.ambient.gain * bedScale, envelope);
195
+ this.ambientFadeInPos = Math.min(fadeSamples, startPos + mixBuf.length);
196
+ }
197
+
198
+ if (this.thinking && this.thinkingState !== "off") {
199
+ if (this.thinkingState === "on") {
200
+ const startPos = this.thinkingFadeInPos;
201
+ const envelope = startPos >= fadeSamples
202
+ ? undefined
203
+ : (i: number) => fadeInGain(startPos + i, fadeSamples);
204
+ addLooped(mixBuf, this.thinking, sampleRateHz, this.thinking.gain * bedScale, envelope);
205
+ this.thinkingFadeInPos = Math.min(fadeSamples, startPos + mixBuf.length);
206
+ } else {
207
+ const startPos = this.thinkingFadeOutPos;
208
+ addLooped(
209
+ mixBuf,
210
+ this.thinking,
211
+ sampleRateHz,
212
+ this.thinking.gain * bedScale,
213
+ (i: number) => fadeOutGain(startPos + i, fadeSamples),
214
+ );
215
+ this.thinkingFadeOutPos = startPos + mixBuf.length;
216
+ if (this.thinkingFadeOutPos >= fadeSamples) this.thinkingState = "off";
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Mix the background bed (ducked) into an assistant speech chunk and advance
223
+ * the speaking-until estimate by the chunk's realtime duration. Returns a new
224
+ * buffer; the input is never mutated.
225
+ */
226
+ mix(chunk: Uint8Array, sampleRateHz: number, nowMs = Date.now()): Uint8Array {
227
+ const speech = pcm16BytesToSamples(chunk);
228
+ const durationMs = (speech.length / sampleRateHz) * 1000;
229
+ this.speakingUntilMs = Math.max(this.speakingUntilMs, nowMs) + durationMs;
230
+
231
+ if (!this.hasSources) return chunk;
232
+ const mixBuf = new Float64Array(speech.length);
233
+ for (let i = 0; i < speech.length; i += 1) mixBuf[i] = speech[i]!;
234
+ this.addBed(mixBuf, sampleRateHz, this.duck);
235
+ return clipToPcm16Bytes(mixBuf);
236
+ }
237
+
238
+ /**
239
+ * Bed-only frame for the gaps between turns (telephony comfort noise), or
240
+ * null while speech is still playing out / there is nothing to play.
241
+ */
242
+ idleFrame(frameMs: number, sampleRateHz: number, nowMs = Date.now()): Uint8Array | null {
243
+ if (this.isSpeaking(nowMs)) return null;
244
+ const playThinking = this.thinking !== null && this.thinkingState !== "off";
245
+ if (!this.ambient && !playThinking) return null;
246
+
247
+ const sampleCount = Math.max(1, Math.round((frameMs / 1000) * sampleRateHz));
248
+ const mixBuf = new Float64Array(sampleCount);
249
+ this.addBed(mixBuf, sampleRateHz, 1);
250
+ return clipToPcm16Bytes(mixBuf);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Drive the mixer's thinking loop from the session's G3 tool-call cues:
256
+ * started/delayed → thinking on; complete/failed → off. Listener lifetime is
257
+ * the session's — both are per-connection.
258
+ */
259
+ export function wireBackgroundThinking(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
260
+ session.on("tool_call_cue", (event) => {
261
+ mixer.setThinking(event.phase === "started" || event.phase === "delayed");
262
+ });
263
+ }
@@ -168,7 +168,7 @@ describe("WT-03 Browser outbound pacing", () => {
168
168
  expect(c.type).toBe("tts_chunk");
169
169
  expect(c.turnId).toBe("test-turn");
170
170
  expect(c.sequence).toBeGreaterThan(0);
171
- expect(c.sampleRateHz).toBe(16000);
171
+ expect(c.sampleRateHz).toBe(48000); // opus frames carry the 48 kHz codec rate
172
172
  expect(c.encoding).toBe("opus");
173
173
  expect(c.channels).toBe(1);
174
174
  expect(c.byteLength).toBeGreaterThan(0);
@@ -78,7 +78,10 @@ function bytesToBase64(bytes: Uint8Array): string {
78
78
  return Buffer.from(bytes).toString("base64");
79
79
  }
80
80
 
81
- async function startConnection(received: UserAudioReceivedPacket[] = []) {
81
+ async function startConnection(
82
+ received: UserAudioReceivedPacket[] = [],
83
+ extraOptions: Record<string, unknown> = {},
84
+ ) {
82
85
  const socket = new FakeSocket();
83
86
  const session = fakeSession(received);
84
87
  await runTwilioEdgeWebSocketConnection(
@@ -88,6 +91,7 @@ async function startConnection(received: UserAudioReceivedPacket[] = []) {
88
91
  sessionStore: new InMemorySessionStore(),
89
92
  createSession: () => session,
90
93
  keepAliveIntervalMs: 0,
94
+ ...extraOptions,
91
95
  },
92
96
  );
93
97
  socket.emit(JSON.stringify({ event: "connected", protocol: "Call" }));
@@ -139,6 +143,54 @@ describe("Twilio edge ingress", () => {
139
143
  expect(Math.abs(decoded[100]! - 6000)).toBeLessThan(600);
140
144
  });
141
145
 
146
+ it("mixes the background bed (ducked) under TTS media", async () => {
147
+ const { socket, session } = await startConnection([], {
148
+ backgroundAudio: {
149
+ ambient: { pcm: new Int16Array(320).fill(4000), sampleRateHz: 16000, gain: 0.5 },
150
+ duckWhileSpeaking: 0.5,
151
+ fadeMs: 0,
152
+ },
153
+ backgroundIdleFrameMs: 10_000, // keep the idle ticker out of this test
154
+ });
155
+
156
+ const silence = new Uint8Array(new Int16Array(640).buffer.slice(0)); // 40ms @16k of silence
157
+ session.bus.push(Route.Critical, {
158
+ kind: "tts.audio",
159
+ contextId: "twilio-CA456",
160
+ timestampMs: Date.now(),
161
+ audio: silence,
162
+ sampleRateHz: 16000,
163
+ });
164
+ await new Promise((resolve) => setTimeout(resolve, 10));
165
+
166
+ const media = socket.json().filter((msg) => msg.event === "media");
167
+ expect(media).toHaveLength(1);
168
+ const payload = (media[0]!.media as { payload: string }).payload;
169
+ const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
170
+ // ambient 4000 × gain 0.5 × duck 0.5 = 1000 under the silent speech
171
+ expect(Math.abs(decoded[100]! - 1000)).toBeLessThan(120); // mu-law quantization tolerance
172
+ });
173
+
174
+ it("sends idle comfort-noise frames between turns", async () => {
175
+ const { socket } = await startConnection([], {
176
+ backgroundAudio: {
177
+ ambient: { pcm: new Int16Array(320).fill(4000), sampleRateHz: 8000, gain: 0.5 },
178
+ fadeMs: 0,
179
+ },
180
+ backgroundIdleFrameMs: 25,
181
+ });
182
+
183
+ await new Promise((resolve) => setTimeout(resolve, 120));
184
+
185
+ const media = socket.json().filter((msg) => msg.event === "media");
186
+ expect(media.length).toBeGreaterThanOrEqual(2); // ticker fills the silence
187
+ const payload = (media[0]!.media as { payload: string }).payload;
188
+ const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
189
+ // full-gain bed between turns: 4000 × 0.5 = 2000
190
+ expect(Math.abs(decoded[10]! - 2000)).toBeLessThan(220);
191
+ expect(decoded.length).toBe(200); // 25ms @ 8k
192
+ });
193
+
142
194
  it("sends a clear event on interrupt.detected (barge-in)", async () => {
143
195
  const { socket, session } = await startConnection();
144
196
 
@@ -159,6 +211,32 @@ describe("Twilio edge ingress", () => {
159
211
  expect(socket.disposed).toBe(true);
160
212
  });
161
213
 
214
+ it("actually closes the session on hangup (no provider-socket leak)", async () => {
215
+ // Regression for the R1 leak: cleanup released without decrementing
216
+ // connectionCount, so release early-returned and session.close() never ran —
217
+ // Deepgram/TTS sockets leaked until DO eviction on every phone call.
218
+ const socket = new FakeSocket();
219
+ let closed = false;
220
+ const bus = new PipelineBusImpl();
221
+ const session = {
222
+ bus,
223
+ async start() { void bus.start(); },
224
+ async close() { closed = true; bus.stop(); },
225
+ on() {}, off() {}, requestClientInterrupt() {},
226
+ } as unknown as VoiceAgentSession;
227
+
228
+ await runTwilioEdgeWebSocketConnection(
229
+ socket,
230
+ new Request("https://edge.test/twilio?sessionId=tw-close"),
231
+ { sessionStore: new InMemorySessionStore(), createSession: () => session, keepAliveIntervalMs: 0 },
232
+ );
233
+ socket.emit(JSON.stringify({ event: "start", streamSid: "MZ9", start: { streamSid: "MZ9", callSid: "CA9" } }));
234
+ socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ9" }));
235
+ await new Promise((r) => setTimeout(r, 10));
236
+
237
+ expect(closed).toBe(true);
238
+ });
239
+
162
240
  it("rotates the uplink contextId after each completed turn", async () => {
163
241
  const received: UserAudioReceivedPacket[] = [];
164
242
  const { socket, session } = await startConnection(received);