@kuralle-syrinx/server-websocket 4.0.0 → 4.2.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,9 +1,28 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Node WebSocket voice host for Syrinx — browser transport plus Twilio/Telnyx/SmartPBX telephony adapters, background audio, admission control",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "telephony",
12
+ "twilio",
13
+ "telnyx"
14
+ ],
6
15
  "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/server-websocket"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
7
26
  "main": "./src/index.ts",
8
27
  "types": "./src/index.ts",
9
28
  "exports": {
@@ -15,8 +34,8 @@
15
34
  "dependencies": {
16
35
  "@evan/opus": "1.0.3",
17
36
  "ws": "^8.18.0",
18
- "@kuralle-syrinx/core": "4.0.0",
19
- "@kuralle-syrinx/ws": "4.0.0"
37
+ "@kuralle-syrinx/core": "4.2.0",
38
+ "@kuralle-syrinx/ws": "4.2.0"
20
39
  },
21
40
  "devDependencies": {
22
41
  "@types/node": "^22.0.0",
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Deterministic placeholder backchannel cue PCM (16 kHz mono). Real voice-matched
4
+ // assets + locale table are a follow-up — these shaped tones prove the byte-config path.
5
+
6
+ import { synthesizeTonePcm16 } from "@kuralle-syrinx/core";
7
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
8
+ import type { BackgroundAudioSource } from "./background-audio.js";
9
+
10
+ const CUE_SAMPLE_RATE_HZ = 16_000;
11
+
12
+ function placeholderCue(frequencyHz: number, durationMs: number, gain: number): BackgroundAudioSource {
13
+ const bytes = synthesizeTonePcm16({
14
+ frequencyHz,
15
+ durationMs,
16
+ sampleRateHz: CUE_SAMPLE_RATE_HZ,
17
+ amplitude: 0.35,
18
+ });
19
+ return { pcm: pcm16BytesToSamples(bytes), sampleRateHz: CUE_SAMPLE_RATE_HZ, gain };
20
+ }
21
+
22
+ /** Placeholder cue map — pass as `backgroundAudio.cues` at session init. */
23
+ export function buildPlaceholderBackchannelCues(): Readonly<Record<string, BackgroundAudioSource>> {
24
+ return {
25
+ mm_hmm: placeholderCue(440, 400, 0.65),
26
+ yeah: placeholderCue(520, 350, 0.6),
27
+ got_it: placeholderCue(380, 450, 0.6),
28
+ };
29
+ }
@@ -0,0 +1,224 @@
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("IP-C3: plays a one-shot cue in an idle frame with thinking ducked under it", () => {
164
+ const ramp = Int16Array.from({ length: 2000 }, (_, i) => (i % 2 === 0 ? 800 : -800));
165
+ const cue = Int16Array.from({ length: 160 }, (_, i) => Math.round(6000 * Math.sin((2 * Math.PI * i) / 160)));
166
+ const mixer = new BackgroundAudioMixer({
167
+ thinking: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
168
+ cues: { mm_hmm: { pcm: cue, sampleRateHz: 16000, gain: 1 } },
169
+ fadeMs: 0,
170
+ });
171
+ const t0 = 12_000_000;
172
+ mixer.setThinking(true);
173
+ expect(mixer.queueCue("mm_hmm")).toBe(true);
174
+
175
+ const frame = mixer.idleFrame(10, 16000, t0)!;
176
+ const samples = samplesOf(frame);
177
+ expect(samples.some((s) => Math.abs(s) > 1000)).toBe(true);
178
+ expect(samples.every((s) => s <= 32767 && s >= -32768)).toBe(true);
179
+
180
+ mixer.setThinking(true);
181
+ expect(mixer.queueCue("missing")).toBe(false);
182
+ });
183
+
184
+ it("IP-C3: thinking loop continues before and after a one-shot cue", () => {
185
+ const thinking = Int16Array.from({ length: 1000 }, (_, i) => i);
186
+ const cue = new Int16Array(32).fill(5000);
187
+ const mixer = new BackgroundAudioMixer({
188
+ thinking: { pcm: thinking, sampleRateHz: 16000, gain: 1 },
189
+ cues: { mm_hmm: { pcm: cue, sampleRateHz: 16000, gain: 1 } },
190
+ fadeMs: 0,
191
+ });
192
+ const t0 = 13_000_000;
193
+ mixer.setThinking(true);
194
+ const before = samplesOf(mixer.idleFrame(1, 16000, t0)!);
195
+ expect(before[0]).toBe(0);
196
+
197
+ mixer.queueCue("mm_hmm");
198
+ mixer.idleFrame(1, 16000, t0 + 100);
199
+ const after = samplesOf(mixer.idleFrame(1, 16000, t0 + 200)!);
200
+ expect(after[0]).toBeGreaterThan(0);
201
+ });
202
+
203
+ it("IP-C3: resolves cues from config bytes without filesystem access", () => {
204
+ const cuePcm = new Int16Array(80).fill(1200);
205
+ const mixer = new BackgroundAudioMixer({
206
+ cues: { mm_hmm: { pcm: cuePcm, sampleRateHz: 16000, gain: 0.8 } },
207
+ fadeMs: 0,
208
+ });
209
+ expect(mixer.hasCue("mm_hmm")).toBe(true);
210
+ expect(mixer.queueCue("mm_hmm")).toBe(true);
211
+ const frame = mixer.idleFrame(5, 16000, 14_000_000)!;
212
+ expect(samplesOf(frame).some((s) => s !== 0)).toBe(true);
213
+ });
214
+
215
+ it("fades each new thinking episode in from silence", () => {
216
+ const mixer = new BackgroundAudioMixer({
217
+ thinking: { pcm: new Int16Array(1000).fill(1000), sampleRateHz: 16000, gain: 1 },
218
+ fadeMs: 1,
219
+ });
220
+ mixer.setThinking(true);
221
+ const first = mixer.idleFrame(1, 16000, 11_000_000)!;
222
+ expect(samplesOf(first)[0]).toBe(0); // soft entry, not a hard cut
223
+ });
224
+ });
@@ -0,0 +1,322 @@
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, one-shot backchannel cues, and ducking under 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 { InteractionBackchannelPacket, VoiceAgentSession } from "@kuralle-syrinx/core";
19
+ import { Route } from "@kuralle-syrinx/core";
20
+ import { pcm16BytesToSamples, pcm16SamplesToBytes, resamplePcm16 } from "@kuralle-syrinx/core/audio";
21
+
22
+ export interface BackgroundAudioSource {
23
+ /** Mono PCM16 samples. Must be loop-clean if it will loop audibly. */
24
+ readonly pcm: Int16Array;
25
+ readonly sampleRateHz: number;
26
+ /** Linear gain 0..1 applied to this source. */
27
+ readonly gain?: number;
28
+ }
29
+
30
+ export interface BackgroundAudioConfig {
31
+ /** Looped continuously: under speech (ducked) and alone between turns. */
32
+ readonly ambient?: BackgroundAudioSource;
33
+ /** Looped while `setThinking(true)` — the audible face of a pending tool call. */
34
+ readonly thinking?: BackgroundAudioSource;
35
+ /** Pre-cached one-shot backchannel cues keyed by cue id (IP-C3). */
36
+ readonly cues?: Readonly<Record<string, BackgroundAudioSource>>;
37
+ /**
38
+ * Extra multiplier on background sources while assistant speech is being
39
+ * mixed, so the bed never muddies the voice. 1 disables ducking. @default 0.5
40
+ */
41
+ readonly duckWhileSpeaking?: number;
42
+ /**
43
+ * Equal-power fade applied where the bed would otherwise hard-cut: the
44
+ * ambient's very first samples, and each thinking episode's start and stop.
45
+ * An abrupt full-volume onset is the harshest moment a caller hears from a
46
+ * wait — the fade is what makes an episode feel placed rather than switched.
47
+ * 0 disables. @default 250
48
+ */
49
+ readonly fadeMs?: number;
50
+ }
51
+
52
+ const DEFAULT_AMBIENT_GAIN = 0.25;
53
+ const DEFAULT_THINKING_GAIN = 0.4;
54
+ const DEFAULT_CUE_GAIN = 0.65;
55
+ const DEFAULT_DUCK = 0.5;
56
+ const DEFAULT_FADE_MS = 250;
57
+ const CUE_THINKING_DUCK = 0.25;
58
+
59
+ interface LoopedSource {
60
+ readonly original: BackgroundAudioSource;
61
+ readonly gain: number;
62
+ /** Resampled variants cached per wire rate. */
63
+ readonly byRate: Map<number, Int16Array>;
64
+ /** Playback position in samples at the current wire rate. */
65
+ position: number;
66
+ positionRateHz: number;
67
+ }
68
+
69
+ interface OneShotSource {
70
+ readonly original: BackgroundAudioSource;
71
+ readonly gain: number;
72
+ readonly byRate: Map<number, Int16Array>;
73
+ position: number;
74
+ positionRateHz: number;
75
+ }
76
+
77
+ function loopedSource(source: BackgroundAudioSource, defaultGain: number): LoopedSource {
78
+ return {
79
+ original: source,
80
+ gain: source.gain ?? defaultGain,
81
+ byRate: new Map(),
82
+ position: 0,
83
+ positionRateHz: source.sampleRateHz,
84
+ };
85
+ }
86
+
87
+ function oneShotSource(source: BackgroundAudioSource, defaultGain: number): OneShotSource {
88
+ return {
89
+ original: source,
90
+ gain: source.gain ?? defaultGain,
91
+ byRate: new Map(),
92
+ position: 0,
93
+ positionRateHz: source.sampleRateHz,
94
+ };
95
+ }
96
+
97
+ function samplesAtRate(
98
+ source: LoopedSource | OneShotSource,
99
+ rateHz: number,
100
+ ): Int16Array {
101
+ let samples = source.byRate.get(rateHz);
102
+ if (!samples) {
103
+ samples = resamplePcm16(source.original.pcm, source.original.sampleRateHz, rateHz);
104
+ source.byRate.set(rateHz, samples);
105
+ }
106
+ if (source.positionRateHz !== rateHz) {
107
+ source.position = Math.round((source.position * rateHz) / source.positionRateHz);
108
+ source.positionRateHz = rateHz;
109
+ }
110
+ return samples;
111
+ }
112
+
113
+ /**
114
+ * Add samples of `source` (scaled by `scale`, optionally shaped per-sample by
115
+ * `envelope(i)`) into `target`, advancing the loop.
116
+ */
117
+ function addLooped(
118
+ target: Float64Array,
119
+ source: LoopedSource,
120
+ rateHz: number,
121
+ scale: number,
122
+ envelope?: (i: number) => number,
123
+ ): void {
124
+ const samples = samplesAtRate(source, rateHz);
125
+ if (samples.length === 0 || scale === 0) return;
126
+ let pos = source.position % samples.length;
127
+ for (let i = 0; i < target.length; i += 1) {
128
+ const gain = envelope ? scale * envelope(i) : scale;
129
+ target[i]! += samples[pos]! * gain;
130
+ pos += 1;
131
+ if (pos >= samples.length) pos = 0;
132
+ }
133
+ source.position = pos;
134
+ }
135
+
136
+ function addOneShot(target: Float64Array, source: OneShotSource, rateHz: number): boolean {
137
+ const samples = samplesAtRate(source, rateHz);
138
+ if (samples.length === 0 || source.gain === 0) return true;
139
+ let pos = source.position;
140
+ for (let i = 0; i < target.length; i += 1) {
141
+ if (pos >= samples.length) {
142
+ source.position = samples.length;
143
+ return true;
144
+ }
145
+ target[i]! += samples[pos]! * source.gain;
146
+ pos += 1;
147
+ }
148
+ source.position = pos;
149
+ return pos >= samples.length;
150
+ }
151
+
152
+ /** Equal-power fade-in gain for sample `n` of a `total`-sample ramp (1 past the ramp). */
153
+ function fadeInGain(n: number, total: number): number {
154
+ if (total <= 0 || n >= total) return 1;
155
+ return Math.sin((n / total) * (Math.PI / 2));
156
+ }
157
+
158
+ /** Equal-power fade-out gain for sample `n` of a `total`-sample ramp (0 past the ramp). */
159
+ function fadeOutGain(n: number, total: number): number {
160
+ if (total <= 0 || n >= total) return 0;
161
+ return Math.cos((n / total) * (Math.PI / 2));
162
+ }
163
+
164
+ function clipToPcm16Bytes(mix: Float64Array): Uint8Array {
165
+ const out = new Int16Array(mix.length);
166
+ for (let i = 0; i < mix.length; i += 1) {
167
+ const v = Math.round(mix[i]!);
168
+ out[i] = v > 32767 ? 32767 : v < -32768 ? -32768 : v;
169
+ }
170
+ return pcm16SamplesToBytes(out);
171
+ }
172
+
173
+ export class BackgroundAudioMixer {
174
+ private readonly ambient: LoopedSource | null;
175
+ private readonly thinking: LoopedSource | null;
176
+ private readonly cueCatalog: ReadonlyMap<string, BackgroundAudioSource>;
177
+ private readonly duck: number;
178
+ private readonly fadeMs: number;
179
+ private thinkingState: "off" | "on" | "stopping" = "off";
180
+ private thinkingFadeInPos = 0;
181
+ private thinkingFadeOutPos = 0;
182
+ private ambientFadeInPos = 0;
183
+ private speakingUntilMs = 0;
184
+ private pendingCue: OneShotSource | null = null;
185
+
186
+ constructor(config: BackgroundAudioConfig) {
187
+ this.ambient = config.ambient ? loopedSource(config.ambient, DEFAULT_AMBIENT_GAIN) : null;
188
+ this.thinking = config.thinking ? loopedSource(config.thinking, DEFAULT_THINKING_GAIN) : null;
189
+ this.cueCatalog = new Map(Object.entries(config.cues ?? {}));
190
+ this.duck = config.duckWhileSpeaking ?? DEFAULT_DUCK;
191
+ this.fadeMs = config.fadeMs ?? DEFAULT_FADE_MS;
192
+ }
193
+
194
+ get hasSources(): boolean {
195
+ return this.ambient !== null || this.thinking !== null || this.cueCatalog.size > 0;
196
+ }
197
+
198
+ hasCue(cueId: string): boolean {
199
+ return this.cueCatalog.has(cueId);
200
+ }
201
+
202
+ isSpeaking(nowMs = Date.now()): boolean {
203
+ return nowMs < this.speakingUntilMs;
204
+ }
205
+
206
+ /** Queue a one-shot backchannel cue by id. Returns false when the cue is unknown. */
207
+ queueCue(cueId: string): boolean {
208
+ const source = this.cueCatalog.get(cueId);
209
+ if (!source) return false;
210
+ this.pendingCue = oneShotSource(source, DEFAULT_CUE_GAIN);
211
+ return true;
212
+ }
213
+
214
+ /** G3 cue wiring: started/delayed → true, complete/failed → false. */
215
+ setThinking(on: boolean): void {
216
+ if (on) {
217
+ if (this.thinkingState === "on") return;
218
+ this.thinkingState = "on";
219
+ this.thinkingFadeInPos = 0;
220
+ if (this.thinking) this.thinking.position = 0;
221
+ return;
222
+ }
223
+ if (this.thinkingState !== "on") return;
224
+ this.thinkingState = this.fadeMs > 0 ? "stopping" : "off";
225
+ this.thinkingFadeOutPos = 0;
226
+ }
227
+
228
+ private thinkingScale(bedScale: number): number {
229
+ return this.pendingCue ? bedScale * CUE_THINKING_DUCK : bedScale;
230
+ }
231
+
232
+ private addBed(mixBuf: Float64Array, sampleRateHz: number, bedScale: number): void {
233
+ const fadeSamples = Math.round((this.fadeMs / 1000) * sampleRateHz);
234
+ const thinkingScale = this.thinkingScale(bedScale);
235
+
236
+ if (this.ambient) {
237
+ const startPos = this.ambientFadeInPos;
238
+ const envelope = startPos >= fadeSamples
239
+ ? undefined
240
+ : (i: number) => fadeInGain(startPos + i, fadeSamples);
241
+ addLooped(mixBuf, this.ambient, sampleRateHz, this.ambient.gain * bedScale, envelope);
242
+ this.ambientFadeInPos = Math.min(fadeSamples, startPos + mixBuf.length);
243
+ }
244
+
245
+ if (this.thinking && this.thinkingState !== "off") {
246
+ if (this.thinkingState === "on") {
247
+ const startPos = this.thinkingFadeInPos;
248
+ const envelope = startPos >= fadeSamples
249
+ ? undefined
250
+ : (i: number) => fadeInGain(startPos + i, fadeSamples);
251
+ addLooped(mixBuf, this.thinking, sampleRateHz, this.thinking.gain * thinkingScale, envelope);
252
+ this.thinkingFadeInPos = Math.min(fadeSamples, startPos + mixBuf.length);
253
+ } else {
254
+ const startPos = this.thinkingFadeOutPos;
255
+ addLooped(
256
+ mixBuf,
257
+ this.thinking,
258
+ sampleRateHz,
259
+ this.thinking.gain * thinkingScale,
260
+ (i: number) => fadeOutGain(startPos + i, fadeSamples),
261
+ );
262
+ this.thinkingFadeOutPos = startPos + mixBuf.length;
263
+ if (this.thinkingFadeOutPos >= fadeSamples) this.thinkingState = "off";
264
+ }
265
+ }
266
+ }
267
+
268
+ private addPendingCue(mixBuf: Float64Array, sampleRateHz: number): void {
269
+ if (!this.pendingCue) return;
270
+ const finished = addOneShot(mixBuf, this.pendingCue, sampleRateHz);
271
+ if (finished) this.pendingCue = null;
272
+ }
273
+
274
+ mix(chunk: Uint8Array, sampleRateHz: number, nowMs = Date.now()): Uint8Array {
275
+ const speech = pcm16BytesToSamples(chunk);
276
+ const durationMs = (speech.length / sampleRateHz) * 1000;
277
+ this.speakingUntilMs = Math.max(this.speakingUntilMs, nowMs) + durationMs;
278
+
279
+ if (!this.hasSources && !this.pendingCue) return chunk;
280
+ const mixBuf = new Float64Array(speech.length);
281
+ for (let i = 0; i < speech.length; i += 1) mixBuf[i] = speech[i]!;
282
+ this.addBed(mixBuf, sampleRateHz, this.duck);
283
+ return clipToPcm16Bytes(mixBuf);
284
+ }
285
+
286
+ idleFrame(frameMs: number, sampleRateHz: number, nowMs = Date.now()): Uint8Array | null {
287
+ if (this.isSpeaking(nowMs)) return null;
288
+ const playThinking = this.thinking !== null && this.thinkingState !== "off";
289
+ if (!this.ambient && !playThinking && !this.pendingCue) return null;
290
+
291
+ const sampleCount = Math.max(1, Math.round((frameMs / 1000) * sampleRateHz));
292
+ const mixBuf = new Float64Array(sampleCount);
293
+ this.addBed(mixBuf, sampleRateHz, 1);
294
+ this.addPendingCue(mixBuf, sampleRateHz);
295
+ return clipToPcm16Bytes(mixBuf);
296
+ }
297
+ }
298
+
299
+ export function wireBackgroundThinking(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
300
+ session.on("tool_call_cue", (event) => {
301
+ mixer.setThinking(event.phase === "started" || event.phase === "delayed");
302
+ });
303
+ }
304
+
305
+ export function wireBackgroundBackchannel(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
306
+ session.bus.on("interaction.backchannel", (pkt) => {
307
+ const backchannel = pkt as InteractionBackchannelPacket;
308
+ if (mixer.queueCue(backchannel.cue)) return;
309
+ session.bus.push(Route.Background, {
310
+ kind: "metric.conversation",
311
+ contextId: backchannel.contextId,
312
+ timestampMs: backchannel.timestampMs,
313
+ name: "backchannel.suppressed_missing_asset",
314
+ value: backchannel.cue,
315
+ });
316
+ });
317
+ }
318
+
319
+ export function wireBackgroundAudio(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
320
+ wireBackgroundThinking(session, mixer);
321
+ wireBackgroundBackchannel(session, mixer);
322
+ }
@@ -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
+ wireBackgroundAudio,
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
+ wireBackgroundAudio(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({
@@ -262,6 +296,7 @@ export async function runTwilioEdgeWebSocketConnection(
262
296
  contextId,
263
297
  timestampMs: Date.now(),
264
298
  audio: pcm16SamplesToBytes(pcm16k),
299
+ sampleRateHz: engineRate,
265
300
  });
266
301
  return;
267
302
  }
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
+ wireBackgroundAudio,
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) wireBackgroundAudio(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;
@@ -471,6 +494,7 @@ function handleClientMessage(
471
494
  contextId: nextContextId,
472
495
  timestampMs: Date.now(),
473
496
  audio,
497
+ sampleRateHz: inputSampleRateHz,
474
498
  } satisfies UserAudioReceivedPacket);
475
499
  return nextContextId;
476
500
  }
@@ -534,6 +558,7 @@ function handleClientMessage(
534
558
  contextId: nextContextId,
535
559
  timestampMs: Date.now(),
536
560
  audio,
561
+ sampleRateHz: inputSampleRateHz,
537
562
  } satisfies UserAudioReceivedPacket);
538
563
  return nextContextId;
539
564
  }
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,15 @@ 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
+ wireBackgroundBackchannel,
67
+ wireBackgroundAudio,
68
+ type BackgroundAudioConfig,
69
+ type BackgroundAudioSource,
70
+ } from "./background-audio.js";
71
+ export { buildPlaceholderBackchannelCues } from "./backchannel-cue-fixtures.js";
62
72
  export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
63
73
 
64
74
  export interface VoiceWebSocketServerOptions {
@@ -97,6 +107,12 @@ export interface VoiceWebSocketServerOptions {
97
107
  * envelope. Set false only for tests or legacy clients that require PCM envelopes.
98
108
  */
99
109
  readonly browserOpusDownlink?: boolean;
110
+ /**
111
+ * Ambient/thinking bed mixed (ducked) under assistant speech. No idle bed is
112
+ * sent between turns on the browser path — a web client can loop ambience
113
+ * locally; the server-side idle bed exists for telephony wires.
114
+ */
115
+ readonly backgroundAudio?: BackgroundAudioConfig;
100
116
  readonly sessionStore?: SessionStore;
101
117
  readonly maxConcurrentSessions?: number;
102
118
  readonly maxConcurrentSessionsScope?: "path" | "server";
@@ -253,6 +269,7 @@ export async function createVoiceWebSocketServer(
253
269
  () => state.browserOpusDownlink,
254
270
  managed.turnMetricsTurns,
255
271
  state.streamingResamplers,
272
+ options.backgroundAudio ? new BackgroundAudioMixer(options.backgroundAudio) : undefined,
256
273
  );
257
274
  gracefulCloseRegistry.set(socket, (deadlineMs) => {
258
275
  if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) {
@@ -432,6 +449,7 @@ function wireBrowserSessionEvents(
432
449
  getBrowserOpusDownlink: () => boolean,
433
450
  turnMetricsTurns: Map<string, TurnTimestampState>,
434
451
  streamingResamplers: Map<string, StreamingPcm16Resampler>,
452
+ backgroundAudio?: BackgroundAudioMixer,
435
453
  ): TelephonyOutboundHandle {
436
454
  const ttsSequences = new Map<string, number>();
437
455
  const speechEndedSent = new Set<string>();
@@ -656,6 +674,7 @@ function wireBrowserSessionEvents(
656
674
  outboundFrameDurationMs,
657
675
  maxQueuedOutputAudioMs,
658
676
  callbacks,
677
+ ...(backgroundAudio ? { backgroundAudio } : {}),
659
678
  });
660
679
  }
661
680
 
@@ -695,6 +714,7 @@ function handleClientMessage(
695
714
  contextId: nextContextId,
696
715
  timestampMs: Date.now(),
697
716
  audio,
717
+ sampleRateHz: inputSampleRateHz,
698
718
  } satisfies UserAudioReceivedPacket);
699
719
  return nextContextId;
700
720
  }
@@ -754,6 +774,7 @@ function handleClientMessage(
754
774
  contextId: nextContextId,
755
775
  timestampMs: Date.now(),
756
776
  audio: resampleAudioBytes(decodeStrictBase64(message.audio, "audio"), sourceSampleRateHz, inputSampleRateHz, streamingResamplers),
777
+ sampleRateHz: inputSampleRateHz,
757
778
  } satisfies UserAudioReceivedPacket);
758
779
  return nextContextId;
759
780
  }
@@ -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 { wireBackgroundAudio } 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) wireBackgroundAudio(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,
@@ -288,6 +292,7 @@ export async function createSmartPbxMediaStreamServer(
288
292
  contextId: state.contextId,
289
293
  timestampMs: Date.now(),
290
294
  audio: pcm16SamplesToBytes(resampled),
295
+ sampleRateHz: inputSampleRateHz,
291
296
  });
292
297
  return;
293
298
  }
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,
@@ -616,6 +620,7 @@ function emitTelnyxMediaFrame(
616
620
  contextId: state.contextId,
617
621
  timestampMs: Date.now(),
618
622
  audio: pcm16SamplesToBytes(resampled),
623
+ sampleRateHz: inputSampleRateHz,
619
624
  });
620
625
  }
621
626
 
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,
@@ -320,6 +324,7 @@ export async function createTwilioMediaStreamServer(
320
324
  contextId: state.contextId,
321
325
  timestampMs: Date.now(),
322
326
  audio: pcm16SamplesToBytes(pcm16k),
327
+ sampleRateHz: inputSampleRateHz,
323
328
  });
324
329
  return;
325
330
  }