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