@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 +23 -4
- package/src/backchannel-cue-fixtures.ts +29 -0
- package/src/background-audio.test.ts +224 -0
- package/src/background-audio.ts +322 -0
- package/src/edge-twilio.test.ts +53 -1
- package/src/edge-twilio.ts +36 -1
- package/src/edge.test.ts +60 -0
- package/src/edge.ts +33 -8
- package/src/index.ts +21 -0
- package/src/outbound-playout-pipeline.test.ts +49 -0
- package/src/outbound-playout-pipeline.ts +16 -6
- package/src/smartpbx.ts +5 -0
- package/src/telnyx.ts +5 -0
- package/src/twilio.ts +5 -0
package/package.json
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
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.
|
|
19
|
-
"@kuralle-syrinx/ws": "4.
|
|
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
|
+
}
|
package/src/edge-twilio.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/edge-twilio.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
422
|
+
sampleRateHz: ttsSampleRate,
|
|
400
423
|
encoding: "pcm_s16le",
|
|
401
424
|
channels: 1,
|
|
402
|
-
byteLength:
|
|
403
|
-
durationMs: pcm16DurationMs(
|
|
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:
|
|
432
|
+
sampleRateHz: ttsSampleRate,
|
|
410
433
|
encoding: "pcm_s16le",
|
|
411
434
|
channels: 1,
|
|
412
|
-
byteLength:
|
|
413
|
-
durationMs: pcm16DurationMs(
|
|
414
|
-
},
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
audioPacket.
|
|
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
|
}
|