@kuralle-syrinx/server-websocket 4.0.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": "4.0.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": "4.0.0",
19
- "@kuralle-syrinx/ws": "4.0.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",
@@ -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
+ }
@@ -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
 
@@ -26,6 +26,11 @@ 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,
@@ -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;
@@ -187,13 +200,34 @@ export async function runTwilioEdgeWebSocketConnection(
187
200
  return;
188
201
  }
189
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
+
190
223
  // Downlink: engine PCM → 8 kHz μ-law media frames; barge-in → clear.
191
224
  disposers.push(
192
225
  session.bus.on("tts.audio", (pkt) => {
193
226
  if (!streamSid) return;
194
227
  const audio = pkt as TextToSpeechAudioPacket;
195
- const samples = pcm16BytesToSamples(audio.audio);
196
228
  const sourceRate = audio.sampleRateHz ?? engineRate;
229
+ const wireAudio = backgroundAudio ? backgroundAudio.mix(audio.audio, sourceRate) : audio.audio;
230
+ const samples = pcm16BytesToSamples(wireAudio);
197
231
  const resampled = resamplePcm16Streaming(downlinkResamplers, samples, sourceRate, TWILIO_SAMPLE_RATE_HZ);
198
232
  const mulaw = encodePcm16ToMuLaw(resampled);
199
233
  sendTwilioJson({
package/src/edge.test.ts CHANGED
@@ -529,3 +529,63 @@ describe("edge client playout progress", () => {
529
529
  expect(socket.json().some((m) => m.type === "error" && m.category === "invalid_input")).toBe(true);
530
530
  });
531
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],
@@ -391,27 +412,29 @@ function wireEdgeSessionEvents(
391
412
  session.bus.on("tts.audio", (pkt) => {
392
413
  const audio = pkt as TextToSpeechAudioPacket;
393
414
  const ttsSampleRate = requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz;
415
+ // Recorder gets the CLEAN assistant track; the bed is a wire-level effect.
394
416
  recorder?.onAssistantAudio(audio.contextId, audio.audio, ttsSampleRate);
417
+ const wireAudio = backgroundAudio ? backgroundAudio.mix(audio.audio, ttsSampleRate) : audio.audio;
395
418
  sendJson(socket, {
396
419
  type: "tts_chunk",
397
420
  turnId: audio.contextId,
398
421
  sequence: 1,
399
- sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
422
+ sampleRateHz: ttsSampleRate,
400
423
  encoding: "pcm_s16le",
401
424
  channels: 1,
402
- byteLength: audio.audio.byteLength,
403
- durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
425
+ byteLength: wireAudio.byteLength,
426
+ durationMs: pcm16DurationMs(wireAudio, outputSampleRateHz),
404
427
  });
405
428
  socket.send(encodeSyrinxAudioEnvelope({
406
429
  type: "audio",
407
430
  contextId: audio.contextId,
408
431
  sequence: 1,
409
- sampleRateHz: requireTtsAudioSampleRate(audio.sampleRateHz) ?? outputSampleRateHz,
432
+ sampleRateHz: ttsSampleRate,
410
433
  encoding: "pcm_s16le",
411
434
  channels: 1,
412
- byteLength: audio.audio.byteLength,
413
- durationMs: pcm16DurationMs(audio.audio, outputSampleRateHz),
414
- }, audio.audio));
435
+ byteLength: wireAudio.byteLength,
436
+ durationMs: pcm16DurationMs(wireAudio, outputSampleRateHz),
437
+ }, wireAudio));
415
438
  }),
416
439
  session.bus.on("tts.end", (pkt) => {
417
440
  const end = pkt as TextToSpeechEndPacket;
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import { closeWebSocketWithFallback, waitForWebSocketClose } from "./websocket-c
32
32
  import { isRecord, parseJsonRecord, optionalString, requiredString } from "./json-message.js";
33
33
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
34
34
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
35
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
35
36
  import { wireTelephonyOutboundPipeline, type TelephonyOutboundCallbacks, type TelephonyOutboundHandle } from "./outbound-playout-pipeline.js";
36
37
  import { TurnMetricsTracker, type TurnTimestampState } from "./turn-metrics.js";
37
38
  import { type PacedPlayoutFrame } from "./paced-playout.js";
@@ -59,6 +60,12 @@ export * from "./telnyx.js";
59
60
  export * from "./smartpbx.js";
60
61
  export * from "./session-store.js";
61
62
  export { validateTwilioSignature } from "./twilio-auth.js";
63
+ export {
64
+ BackgroundAudioMixer,
65
+ wireBackgroundThinking,
66
+ type BackgroundAudioConfig,
67
+ type BackgroundAudioSource,
68
+ } from "./background-audio.js";
62
69
  export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
63
70
 
64
71
  export interface VoiceWebSocketServerOptions {
@@ -97,6 +104,12 @@ export interface VoiceWebSocketServerOptions {
97
104
  * envelope. Set false only for tests or legacy clients that require PCM envelopes.
98
105
  */
99
106
  readonly browserOpusDownlink?: boolean;
107
+ /**
108
+ * Ambient/thinking bed mixed (ducked) under assistant speech. No idle bed is
109
+ * sent between turns on the browser path — a web client can loop ambience
110
+ * locally; the server-side idle bed exists for telephony wires.
111
+ */
112
+ readonly backgroundAudio?: BackgroundAudioConfig;
100
113
  readonly sessionStore?: SessionStore;
101
114
  readonly maxConcurrentSessions?: number;
102
115
  readonly maxConcurrentSessionsScope?: "path" | "server";
@@ -253,6 +266,7 @@ export async function createVoiceWebSocketServer(
253
266
  () => state.browserOpusDownlink,
254
267
  managed.turnMetricsTurns,
255
268
  state.streamingResamplers,
269
+ options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined,
256
270
  );
257
271
  gracefulCloseRegistry.set(socket, (deadlineMs) => {
258
272
  if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
@@ -432,6 +446,7 @@ function wireBrowserSessionEvents(
432
446
  getBrowserOpusDownlink: () => boolean,
433
447
  turnMetricsTurns: Map<string, TurnTimestampState>,
434
448
  streamingResamplers: Map<string, StreamingPcm16Resampler>,
449
+ backgroundAudio?: BackgroundAudioMixer,
435
450
  ): TelephonyOutboundHandle {
436
451
  const ttsSequences = new Map<string, number>();
437
452
  const speechEndedSent = new Set<string>();
@@ -656,6 +671,7 @@ function wireBrowserSessionEvents(
656
671
  outboundFrameDurationMs,
657
672
  maxQueuedOutputAudioMs,
658
673
  callbacks,
674
+ ...(backgroundAudio ? { backgroundAudio } : {}),
659
675
  });
660
676
  }
661
677
 
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
5
5
  import WebSocket from "ws";
6
6
  import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
7
7
  import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
8
+ import { BackgroundAudioMixer } from "./background-audio.js";
8
9
  import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation, type RotatableTurnContext } from "./outbound-playout-pipeline.js";
9
10
 
10
11
  function createMockSocket(): WebSocket {
@@ -262,4 +263,52 @@ describe("installTelephonyTurnRotation", () => {
262
263
  for (const dispose of disposers) dispose();
263
264
  await session.close();
264
265
  });
266
+
267
+ it("mixes the background bed into speech before per-carrier encoding", async () => {
268
+ const socket = createMockSocket();
269
+ const session = new VoiceAgentSession({ plugins: {} });
270
+ void session.start();
271
+ const disposers: Array<() => void> = [];
272
+ const encodedChunks: Int16Array[] = [];
273
+ wireTelephonyOutboundPipeline({
274
+ session,
275
+ socket,
276
+ disposers,
277
+ outboundFrameDurationMs: 20,
278
+ maxQueuedOutputAudioMs: 30_000,
279
+ backgroundAudio: new BackgroundAudioMixer({
280
+ ambient: { pcm: new Int16Array(160).fill(1000), sampleRateHz: 16000, gain: 0.5 },
281
+ duckWhileSpeaking: 0.5,
282
+ fadeMs: 0,
283
+ }),
284
+ callbacks: {
285
+ carrierLabel: "test",
286
+ getContextId: () => "turn-mix",
287
+ isActive: () => true,
288
+ encodeFrames: (audio) => {
289
+ encodedChunks.push(new Int16Array(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength)));
290
+ return [{ contextId: "turn-mix", send: () => true }];
291
+ },
292
+ onInterrupt: () => undefined,
293
+ onDrain: () => undefined,
294
+ onStop: () => undefined,
295
+ },
296
+ });
297
+
298
+ session.bus.push(Route.Main, {
299
+ kind: "tts.audio",
300
+ contextId: "turn-mix",
301
+ timestampMs: Date.now(),
302
+ audio: pcm16SamplesToBytes(new Int16Array(4)), // silence in → ambient-only out
303
+ sampleRateHz: 16000,
304
+ });
305
+ await new Promise((r) => setTimeout(r, 20));
306
+
307
+ expect(encodedChunks).toHaveLength(1);
308
+ // ambient 1000 × gain 0.5 × duck 0.5 = 250 layered under the (silent) speech
309
+ expect(Array.from(encodedChunks[0]!)).toEqual([250, 250, 250, 250]);
310
+
311
+ for (const dispose of disposers) dispose();
312
+ await session.close();
313
+ });
265
314
  });
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { Route, type InterruptTtsPacket, type TextToSpeechAudioPacket, type TextToSpeechEndPacket, type VoiceAgentSession } from "@kuralle-syrinx/core";
4
4
  import { WebSocket } from "ws";
5
+ import type { BackgroundAudioMixer } from "./background-audio.js";
6
+ import { wireBackgroundThinking } from "./background-audio.js";
5
7
  import type { PacedPlayoutFrame } from "./paced-playout.js";
6
8
  import { PacedPlayoutQueue } from "./paced-playout.js";
7
9
  import { PlayoutProgressEmitter } from "./playout-progress.js";
@@ -70,8 +72,16 @@ export function wireTelephonyOutboundPipeline(args: {
70
72
  readonly outboundFrameDurationMs: number;
71
73
  readonly maxQueuedOutputAudioMs: number;
72
74
  readonly callbacks: TelephonyOutboundCallbacks;
75
+ /**
76
+ * Mixes the ambient/thinking bed under every outbound speech chunk (ducked).
77
+ * Idle comfort-noise frames are not generated on this path yet — the paced
78
+ * queue's per-encode mark machinery needs a per-carrier background-frame
79
+ * encoder first (see background-audio-implementation-notes.md).
80
+ */
81
+ readonly backgroundAudio?: BackgroundAudioMixer;
73
82
  }): TelephonyOutboundHandle {
74
- const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks } = args;
83
+ const { session, socket, disposers, outboundFrameDurationMs, maxQueuedOutputAudioMs, callbacks, backgroundAudio } = args;
84
+ if (backgroundAudio) wireBackgroundThinking(session, backgroundAudio);
75
85
 
76
86
  const recordDiscardedPlayout = (discardedMs: number, reason: string): void => {
77
87
  if (discardedMs <= 0) return;
@@ -147,11 +157,11 @@ export function wireTelephonyOutboundPipeline(args: {
147
157
  });
148
158
  return;
149
159
  }
150
- const frames = callbacks.encodeFrames(
151
- audioPacket.audio,
152
- requireTtsAudioSampleRate(audioPacket.sampleRateHz),
153
- audioPacket.contextId,
154
- );
160
+ const sampleRateHz = requireTtsAudioSampleRate(audioPacket.sampleRateHz);
161
+ const audio = backgroundAudio
162
+ ? backgroundAudio.mix(audioPacket.audio, sampleRateHz)
163
+ : audioPacket.audio;
164
+ const frames = callbacks.encodeFrames(audio, sampleRateHz, audioPacket.contextId);
155
165
  playout.enqueue(frames);
156
166
  }),
157
167
  session.bus.on("tts.end", (pkt) => {
package/src/smartpbx.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from "./json-message.js";
24
24
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
25
25
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
26
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
26
27
  import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
27
28
  import {
28
29
  decodeStrictBase64,
@@ -43,6 +44,8 @@ export interface SmartPbxMediaStreamServerOptions {
43
44
  readonly outputSampleRateHz?: number;
44
45
  readonly outboundFrameDurationMs?: number;
45
46
  readonly maxQueuedOutputAudioMs?: number;
47
+ /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
48
+ readonly backgroundAudio?: BackgroundAudioConfig;
46
49
  readonly heartbeatIntervalMs?: number;
47
50
  readonly startupTimeoutMs?: number;
48
51
  readonly maxSessionDurationMs?: number;
@@ -179,6 +182,7 @@ export async function createSmartPbxMediaStreamServer(
179
182
  disposers,
180
183
  outboundFrameDurationMs,
181
184
  maxQueuedOutputAudioMs,
185
+ ...(options.backgroundAudio ? { backgroundAudio: new BackgroundAudioMixer(options.backgroundAudio) } : {}),
182
186
  callbacks: {
183
187
  carrierLabel: "smartpbx",
184
188
  getContextId: () => state.contextId,
package/src/telnyx.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./json-message.js";
26
26
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
27
27
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
28
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
28
29
  import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
29
30
  import {
30
31
  decodeStrictBase64,
@@ -49,6 +50,8 @@ export interface TelnyxMediaStreamServerOptions {
49
50
  readonly bidirectionalCodec?: "PCMU" | "L16";
50
51
  readonly outboundFrameDurationMs?: number;
51
52
  readonly maxQueuedOutputAudioMs?: number;
53
+ /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
54
+ readonly backgroundAudio?: BackgroundAudioConfig;
52
55
  readonly maxInboundReorderFrames?: number;
53
56
  readonly heartbeatIntervalMs?: number;
54
57
  readonly startupTimeoutMs?: number;
@@ -224,6 +227,7 @@ export async function createTelnyxMediaStreamServer(
224
227
  disposers,
225
228
  outboundFrameDurationMs,
226
229
  maxQueuedOutputAudioMs,
230
+ ...(options.backgroundAudio ? { backgroundAudio: new BackgroundAudioMixer(options.backgroundAudio) } : {}),
227
231
  callbacks: {
228
232
  carrierLabel: "telnyx",
229
233
  getContextId: () => state.contextId,
package/src/twilio.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./json-message.js";
23
23
  import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
24
24
  import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
25
+ import { BackgroundAudioMixer, type BackgroundAudioConfig } from "./background-audio.js";
25
26
  import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
26
27
  import {
27
28
  decodeStrictBase64,
@@ -45,6 +46,8 @@ export interface TwilioMediaStreamServerOptions {
45
46
  readonly twilioSampleRateHz?: number;
46
47
  readonly outboundFrameDurationMs?: number;
47
48
  readonly maxQueuedOutputAudioMs?: number;
49
+ /** Ambient/thinking bed mixed (ducked) under assistant speech (see BackgroundAudioConfig). */
50
+ readonly backgroundAudio?: BackgroundAudioConfig;
48
51
  readonly heartbeatIntervalMs?: number;
49
52
  readonly startupTimeoutMs?: number;
50
53
  readonly maxSessionDurationMs?: number;
@@ -200,6 +203,7 @@ export async function createTwilioMediaStreamServer(
200
203
  disposers,
201
204
  outboundFrameDurationMs,
202
205
  maxQueuedOutputAudioMs,
206
+ ...(options.backgroundAudio ? { backgroundAudio: new BackgroundAudioMixer(options.backgroundAudio) } : {}),
203
207
  callbacks: {
204
208
  carrierLabel: "twilio",
205
209
  getContextId: () => state.contextId,