@kuralle-syrinx/server-workers 4.4.1 → 4.5.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/dist/kuralle-realtime-agent.d.ts +16 -0
- package/dist/kuralle-realtime-agent.d.ts.map +1 -0
- package/dist/kuralle-realtime-agent.js +156 -0
- package/dist/kuralle-realtime-agent.js.map +1 -0
- package/dist/live-realtime-session.d.ts +19 -0
- package/dist/live-realtime-session.d.ts.map +1 -0
- package/dist/live-realtime-session.js +65 -0
- package/dist/live-realtime-session.js.map +1 -0
- package/dist/live-session.d.ts +27 -0
- package/dist/live-session.d.ts.map +1 -0
- package/dist/live-session.js +93 -0
- package/dist/live-session.js.map +1 -0
- package/dist/worker-realtime.d.ts +13 -0
- package/dist/worker-realtime.d.ts.map +1 -0
- package/dist/worker-realtime.js +31 -0
- package/dist/worker-realtime.js.map +1 -0
- package/dist/worker.d.ts +32 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +172 -0
- package/dist/worker.js.map +1 -0
- package/dist/workers-ai-smart-turn.d.ts +24 -0
- package/dist/workers-ai-smart-turn.d.ts.map +1 -0
- package/dist/workers-ai-smart-turn.js +36 -0
- package/dist/workers-ai-smart-turn.js.map +1 -0
- package/package.json +22 -15
- package/src/live-realtime-session.test.ts +88 -0
- package/src/live-session.test.ts +129 -0
- package/src/worker-runtime.test.ts +480 -0
- package/src/workers-ai-smart-turn.test.ts +67 -0
- package/.dev.vars.example +0 -17
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-wal +0 -0
- package/wrangler.jsonc +0 -49
- package/wrangler.realtime-gemini.jsonc +0 -28
- package/wrangler.realtime.jsonc +0 -27
- package/wrangler.smartturn-spike.jsonc +0 -44
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
// Import Node's URL explicitly: this file's tsconfig loads @cloudflare/workers-types,
|
|
8
|
+
// whose global URL (as of 4.20260603) dropped the Node-style `new URL(path, base)` overload.
|
|
9
|
+
// This is a Node-side test (spawns wrangler `unstable_dev`), so it uses Node's URL.
|
|
10
|
+
import { fileURLToPath, URL } from "node:url";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { Miniflare } from "miniflare";
|
|
14
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
|
|
20
|
+
type WorkersWebSocket = WebSocket & { accept(): void };
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
23
|
+
const TURN_DETECTION_FIXTURE = join(
|
|
24
|
+
REPO_ROOT,
|
|
25
|
+
"examples/02-hello-voice-headless/test/fixtures/gemini-turn-detection/02-reset-password.wav",
|
|
26
|
+
);
|
|
27
|
+
const WORKER_INPUT_RATE_HZ = 16000;
|
|
28
|
+
|
|
29
|
+
loadRepoEnv();
|
|
30
|
+
const liveEnv = {
|
|
31
|
+
DEEPGRAM_API_KEY: process.env["DEEPGRAM_API_KEY"]?.trim() ?? "",
|
|
32
|
+
OPENAI_API_KEY: process.env["OPENAI_API_KEY"]?.trim() ?? "",
|
|
33
|
+
VECTORIZE: {},
|
|
34
|
+
};
|
|
35
|
+
const hasLiveKeys = Boolean(liveEnv.DEEPGRAM_API_KEY && liveEnv.OPENAI_API_KEY);
|
|
36
|
+
// The live turn hits paid, non-deterministic provider APIs, so it is opt-in
|
|
37
|
+
// (SYRINX_LIVE_WORKER_TEST=1) rather than running on every `pnpm -r test`.
|
|
38
|
+
// Run it with: pnpm --filter @kuralle-syrinx/server-workers test:live
|
|
39
|
+
const liveTurnEnabled = hasLiveKeys && process.env["SYRINX_LIVE_WORKER_TEST"] === "1";
|
|
40
|
+
|
|
41
|
+
const tempDirs: string[] = [];
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function buildWorker(): Promise<string> {
|
|
47
|
+
const tmp = await mkdtemp(join(tmpdir(), "syrinx-worker-"));
|
|
48
|
+
tempDirs.push(tmp);
|
|
49
|
+
await execFileAsync(
|
|
50
|
+
"npx",
|
|
51
|
+
["wrangler", "deploy", "--dry-run", "--outdir", tmp, "-c", "wrangler.jsonc"],
|
|
52
|
+
{ cwd: PKG_ROOT },
|
|
53
|
+
);
|
|
54
|
+
return readFile(join(tmp, "worker.js"), "utf8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function newMiniflare(script: string, bindings: Record<string, unknown>): Miniflare {
|
|
58
|
+
return new Miniflare({
|
|
59
|
+
modules: true,
|
|
60
|
+
script,
|
|
61
|
+
compatibilityDate: "2026-06-01",
|
|
62
|
+
compatibilityFlags: ["nodejs_compat"],
|
|
63
|
+
durableObjects: {
|
|
64
|
+
VOICE_CONVERSATIONS: { className: "VoiceConversation", useSQLite: true },
|
|
65
|
+
TWILIO_VOICE_CONVERSATIONS: { className: "TwilioVoiceConversation", useSQLite: true },
|
|
66
|
+
TELNYX_VOICE_CONVERSATIONS: { className: "TelnyxVoiceConversation", useSQLite: true },
|
|
67
|
+
},
|
|
68
|
+
vectorize: {
|
|
69
|
+
VECTORIZE: { dimensions: 1536, metric: "cosine", index_name: "kuralle-university-kb" },
|
|
70
|
+
},
|
|
71
|
+
bindings,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("VoiceConversation worker runtime", () => {
|
|
76
|
+
// Deterministic, no provider keys: proves the edge bundle boots inside workerd
|
|
77
|
+
// and accepts a WebSocketPair upgrade before any provider session starts.
|
|
78
|
+
it("boots in workerd and accepts a WebSocket upgrade", async () => {
|
|
79
|
+
const script = await buildWorker();
|
|
80
|
+
const mf = newMiniflare(script, {});
|
|
81
|
+
try {
|
|
82
|
+
const health = await mf.dispatchFetch("http://localhost/health");
|
|
83
|
+
expect(health.status).toBe(200);
|
|
84
|
+
expect(await health.text()).toBe("ok");
|
|
85
|
+
|
|
86
|
+
const response = await mf.dispatchFetch("http://localhost/ws?sessionId=boot-smoke", {
|
|
87
|
+
headers: { Upgrade: "websocket" },
|
|
88
|
+
});
|
|
89
|
+
expect(response.status).toBe(101);
|
|
90
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
91
|
+
expect(ws).toBeTruthy();
|
|
92
|
+
ws!.accept();
|
|
93
|
+
ws!.close();
|
|
94
|
+
} finally {
|
|
95
|
+
await mf.dispose();
|
|
96
|
+
}
|
|
97
|
+
}, 20_000);
|
|
98
|
+
|
|
99
|
+
// Live: drives a real STT -> LLM -> TTS turn through real providers inside
|
|
100
|
+
// workerd. Skipped when provider keys are absent so offline CI stays green.
|
|
101
|
+
it.skipIf(!liveTurnEnabled)(
|
|
102
|
+
"drives a real audio turn through Deepgram STT + kuralle + Deepgram TTS in workerd",
|
|
103
|
+
async () => {
|
|
104
|
+
expect(existsSync(TURN_DETECTION_FIXTURE)).toBe(true);
|
|
105
|
+
const pcm16 = readWav16kMono(TURN_DETECTION_FIXTURE);
|
|
106
|
+
const script = await buildWorker();
|
|
107
|
+
const mf = newMiniflare(script, liveEnv);
|
|
108
|
+
try {
|
|
109
|
+
const response = await mf.dispatchFetch("http://localhost/ws?sessionId=live-turn", {
|
|
110
|
+
headers: { Upgrade: "websocket" },
|
|
111
|
+
});
|
|
112
|
+
expect(response.status).toBe(101);
|
|
113
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
114
|
+
expect(ws).toBeTruthy();
|
|
115
|
+
|
|
116
|
+
const messages: Array<string | ArrayBuffer> = [];
|
|
117
|
+
ws!.addEventListener("message", (event) => {
|
|
118
|
+
messages.push(event.data as string | ArrayBuffer);
|
|
119
|
+
});
|
|
120
|
+
ws!.accept();
|
|
121
|
+
|
|
122
|
+
// Wait for the session to come up (provider sockets connected) before audio.
|
|
123
|
+
// Surface a provider init error (e.g. bad key, unreachable socket) instead
|
|
124
|
+
// of a bare timeout.
|
|
125
|
+
try {
|
|
126
|
+
await waitFor(() => messages.some((m) => typeof m === "string" && m.includes('"type":"ready"')), 20_000);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`session never became ready: ${firstSessionError(messages) ?? "no error reported"}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Stream the fixture as 20ms PCM16 frames, base64 JSON audio messages.
|
|
132
|
+
const frameSamples = (WORKER_INPUT_RATE_HZ / 1000) * 20; // 320 samples = 640 bytes
|
|
133
|
+
let sequence = 0;
|
|
134
|
+
for (let offset = 0; offset < pcm16.length; offset += frameSamples) {
|
|
135
|
+
const frame = pcm16.subarray(offset, Math.min(offset + frameSamples, pcm16.length));
|
|
136
|
+
sequence += 1;
|
|
137
|
+
ws!.send(JSON.stringify({
|
|
138
|
+
type: "audio",
|
|
139
|
+
audio: int16ToBase64(frame),
|
|
140
|
+
sampleRateHz: WORKER_INPUT_RATE_HZ,
|
|
141
|
+
sequence,
|
|
142
|
+
}));
|
|
143
|
+
await sleep(20);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Real Deepgram transcript for the "reset password" utterance.
|
|
147
|
+
await waitFor(
|
|
148
|
+
() => messages.some((m) => typeof m === "string" && m.includes('"type":"stt_output"')),
|
|
149
|
+
15_000,
|
|
150
|
+
);
|
|
151
|
+
// Real Deepgram TTS audio frames back from the kuralle reply.
|
|
152
|
+
await waitFor(
|
|
153
|
+
() => messages.some((m) => typeof m === "string" && m.includes('"type":"tts_chunk"')),
|
|
154
|
+
30_000,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const transcript = extractTranscript(messages);
|
|
158
|
+
expect(transcript.length).toBeGreaterThan(0);
|
|
159
|
+
expect(messages.some((m) => m instanceof ArrayBuffer && m.byteLength > 0)).toBe(true);
|
|
160
|
+
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.log(`[live-turn] STT transcript: ${JSON.stringify(transcript)}`);
|
|
163
|
+
ws!.close();
|
|
164
|
+
} finally {
|
|
165
|
+
await mf.dispose();
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
90_000,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("TwilioVoiceConversation worker runtime", () => {
|
|
173
|
+
// Deterministic, no keys: the Twilio Media Streams front accepts a WS upgrade at /twilio
|
|
174
|
+
// through withVoice(Agent, { transport: "twilio" }) — the cf-agents telephony front bundles
|
|
175
|
+
// and boots in workerd.
|
|
176
|
+
it("accepts a Twilio Media Streams WebSocket upgrade at /twilio", async () => {
|
|
177
|
+
const script = await buildWorker();
|
|
178
|
+
const mf = newMiniflare(script, {});
|
|
179
|
+
try {
|
|
180
|
+
const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-boot", {
|
|
181
|
+
headers: { Upgrade: "websocket" },
|
|
182
|
+
});
|
|
183
|
+
expect(response.status).toBe(101);
|
|
184
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
185
|
+
expect(ws).toBeTruthy();
|
|
186
|
+
ws!.accept();
|
|
187
|
+
ws!.close();
|
|
188
|
+
} finally {
|
|
189
|
+
await mf.dispose();
|
|
190
|
+
}
|
|
191
|
+
}, 20_000);
|
|
192
|
+
|
|
193
|
+
// Deterministic: the Twilio Voice webhook returns TwiML that bridges the call to /twilio.
|
|
194
|
+
it("returns <Connect><Stream> TwiML from /incoming-call pointing at /twilio", async () => {
|
|
195
|
+
const script = await buildWorker();
|
|
196
|
+
const mf = newMiniflare(script, {});
|
|
197
|
+
try {
|
|
198
|
+
const res = await mf.dispatchFetch("http://localhost/incoming-call", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
201
|
+
body: "CallSid=CA0123456789abcdef&From=%2B15551234567",
|
|
202
|
+
});
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
expect(res.headers.get("content-type")).toContain("text/xml");
|
|
205
|
+
const twiml = await res.text();
|
|
206
|
+
expect(twiml).toContain("<Connect>");
|
|
207
|
+
expect(twiml).toContain("<Stream");
|
|
208
|
+
// The Twilio CallSid becomes the /twilio sessionId.
|
|
209
|
+
expect(twiml).toContain("/twilio?sessionId=CA0123456789abcdef");
|
|
210
|
+
} finally {
|
|
211
|
+
await mf.dispose();
|
|
212
|
+
}
|
|
213
|
+
}, 20_000);
|
|
214
|
+
|
|
215
|
+
// Live: EMULATE A PSTN CALL — speak the Twilio Media Streams protocol (connected/start/media
|
|
216
|
+
// as base64 μ-law 8 kHz) at /twilio and assert the agent answers with non-silent μ-law media
|
|
217
|
+
// frames on the phone leg. No carrier, no phone number — the protocol is just a WebSocket.
|
|
218
|
+
it.skipIf(!liveTurnEnabled)(
|
|
219
|
+
"answers an emulated Twilio Media Streams call end-to-end in workerd",
|
|
220
|
+
async () => {
|
|
221
|
+
expect(existsSync(TURN_DETECTION_FIXTURE)).toBe(true);
|
|
222
|
+
const pcm16k = readWav16kMono(TURN_DETECTION_FIXTURE);
|
|
223
|
+
const pcm8k = downsampleTo8k(pcm16k);
|
|
224
|
+
const script = await buildWorker();
|
|
225
|
+
const mf = newMiniflare(script, liveEnv);
|
|
226
|
+
try {
|
|
227
|
+
const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-live", {
|
|
228
|
+
headers: { Upgrade: "websocket" },
|
|
229
|
+
});
|
|
230
|
+
expect(response.status).toBe(101);
|
|
231
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
232
|
+
expect(ws).toBeTruthy();
|
|
233
|
+
|
|
234
|
+
let downlinkFrames = 0;
|
|
235
|
+
let downlinkPeak = 0;
|
|
236
|
+
let clearReceived = false;
|
|
237
|
+
ws!.addEventListener("message", (event) => {
|
|
238
|
+
const data = event.data as string | ArrayBuffer;
|
|
239
|
+
if (typeof data !== "string" || !data.startsWith("{")) return;
|
|
240
|
+
const msg = JSON.parse(data) as Record<string, unknown>;
|
|
241
|
+
if (msg.event === "media") {
|
|
242
|
+
downlinkFrames += 1;
|
|
243
|
+
const media = msg.media as { payload?: string } | undefined;
|
|
244
|
+
if (media?.payload) {
|
|
245
|
+
const pcm = decodeMuLawToPcm16(base64ToBytes(media.payload));
|
|
246
|
+
for (const s of pcm) downlinkPeak = Math.max(downlinkPeak, Math.abs(s));
|
|
247
|
+
}
|
|
248
|
+
} else if (msg.event === "clear") {
|
|
249
|
+
clearReceived = true;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
ws!.accept();
|
|
253
|
+
|
|
254
|
+
const streamSid = "MZtwiliolivesmoke";
|
|
255
|
+
ws!.send(JSON.stringify({ event: "connected", protocol: "Call", version: "1.0.0" }));
|
|
256
|
+
ws!.send(JSON.stringify({
|
|
257
|
+
event: "start",
|
|
258
|
+
streamSid,
|
|
259
|
+
start: { streamSid, callSid: "CAtwiliolivesmoke", mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 } },
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
const sendMulaw = (frame: Int16Array): void => {
|
|
263
|
+
ws!.send(JSON.stringify({
|
|
264
|
+
event: "media",
|
|
265
|
+
streamSid,
|
|
266
|
+
media: { payload: bytesToBase64(encodePcm16ToMuLaw(frame)) },
|
|
267
|
+
}));
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const frameSamples8k = 160; // 20ms at 8 kHz
|
|
271
|
+
for (let offset = 0; offset < pcm8k.length; offset += frameSamples8k) {
|
|
272
|
+
const frame = new Int16Array(frameSamples8k);
|
|
273
|
+
frame.set(pcm8k.subarray(offset, Math.min(offset + frameSamples8k, pcm8k.length)));
|
|
274
|
+
sendMulaw(frame);
|
|
275
|
+
await sleep(20);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Pad silence until the agent answers (Deepgram endpointing + kuralle + TTS).
|
|
279
|
+
const deadline = Date.now() + 60_000;
|
|
280
|
+
while (downlinkFrames === 0 && Date.now() < deadline) {
|
|
281
|
+
sendMulaw(new Int16Array(frameSamples8k));
|
|
282
|
+
await sleep(20);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
ws!.send(JSON.stringify({ event: "stop", streamSid }));
|
|
286
|
+
ws!.close();
|
|
287
|
+
|
|
288
|
+
// eslint-disable-next-line no-console
|
|
289
|
+
console.log(`[twilio-live] downlink media frames=${downlinkFrames} peak=${downlinkPeak} clear=${clearReceived}`);
|
|
290
|
+
expect(downlinkFrames).toBeGreaterThan(0);
|
|
291
|
+
expect(downlinkPeak).toBeGreaterThan(100); // non-silent answer on the phone leg
|
|
292
|
+
} finally {
|
|
293
|
+
await mf.dispose();
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
120_000,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("TelnyxVoiceConversation worker runtime", () => {
|
|
301
|
+
// Deterministic, no keys: the Telnyx Media Streaming front accepts a WS upgrade at /telnyx
|
|
302
|
+
// through withVoice(Agent, { transport: "telnyx" }) — the cf-agents telephony front bundles
|
|
303
|
+
// and boots in workerd.
|
|
304
|
+
it("accepts a Telnyx Media Streaming WebSocket upgrade at /telnyx", async () => {
|
|
305
|
+
const script = await buildWorker();
|
|
306
|
+
const mf = newMiniflare(script, {});
|
|
307
|
+
try {
|
|
308
|
+
const response = await mf.dispatchFetch("http://localhost/telnyx?sessionId=telnyx-boot", {
|
|
309
|
+
headers: { Upgrade: "websocket" },
|
|
310
|
+
});
|
|
311
|
+
expect(response.status).toBe(101);
|
|
312
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
313
|
+
expect(ws).toBeTruthy();
|
|
314
|
+
ws!.accept();
|
|
315
|
+
ws!.close();
|
|
316
|
+
} finally {
|
|
317
|
+
await mf.dispose();
|
|
318
|
+
}
|
|
319
|
+
}, 20_000);
|
|
320
|
+
|
|
321
|
+
// Deterministic: TeXML Stream helper points at /telnyx with the sessionId.
|
|
322
|
+
it("returns TeXML <Stream> from /telnyx-stream-start pointing at /telnyx", async () => {
|
|
323
|
+
const script = await buildWorker();
|
|
324
|
+
const mf = newMiniflare(script, {});
|
|
325
|
+
try {
|
|
326
|
+
const res = await mf.dispatchFetch(
|
|
327
|
+
"http://localhost/telnyx-stream-start?sessionId=tx-session-1&codec=PCMA",
|
|
328
|
+
);
|
|
329
|
+
expect(res.status).toBe(200);
|
|
330
|
+
expect(res.headers.get("content-type")).toContain("text/xml");
|
|
331
|
+
const texml = await res.text();
|
|
332
|
+
expect(texml).toContain("<Stream");
|
|
333
|
+
expect(texml).toContain("/telnyx?sessionId=tx-session-1");
|
|
334
|
+
expect(texml).toContain('bidirectionalCodec="PCMA"');
|
|
335
|
+
} finally {
|
|
336
|
+
await mf.dispose();
|
|
337
|
+
}
|
|
338
|
+
}, 20_000);
|
|
339
|
+
|
|
340
|
+
// Deterministic: streaming_start JSON payload for Call Control (live dispatch carrier-gated).
|
|
341
|
+
it("returns streaming_start JSON from /telnyx-stream-start?format=streaming_start", async () => {
|
|
342
|
+
const script = await buildWorker();
|
|
343
|
+
const mf = newMiniflare(script, {});
|
|
344
|
+
try {
|
|
345
|
+
const res = await mf.dispatchFetch(
|
|
346
|
+
"http://localhost/telnyx-stream-start?sessionId=tx-json-1&format=streaming_start&codec=G722",
|
|
347
|
+
);
|
|
348
|
+
expect(res.status).toBe(200);
|
|
349
|
+
const body = await res.json() as {
|
|
350
|
+
stream_url: string;
|
|
351
|
+
stream_bidirectional_mode: string;
|
|
352
|
+
stream_bidirectional_codec: string;
|
|
353
|
+
stream_bidirectional_sampling_rate: number;
|
|
354
|
+
send_silence_when_idle: boolean;
|
|
355
|
+
};
|
|
356
|
+
expect(body.stream_url).toContain("/telnyx?sessionId=tx-json-1");
|
|
357
|
+
expect(body.stream_bidirectional_mode).toBe("rtp");
|
|
358
|
+
expect(body.stream_bidirectional_codec).toBe("G722");
|
|
359
|
+
expect(body.stream_bidirectional_sampling_rate).toBe(16000);
|
|
360
|
+
expect(body.send_silence_when_idle).toBe(true);
|
|
361
|
+
} finally {
|
|
362
|
+
await mf.dispose();
|
|
363
|
+
}
|
|
364
|
+
}, 20_000);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
function downsampleTo8k(samples: Int16Array): Int16Array {
|
|
368
|
+
const out = new Int16Array(Math.floor(samples.length / 2));
|
|
369
|
+
for (let i = 0; i < out.length; i += 1) out[i] = samples[i * 2]!;
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function base64ToBytes(value: string): Uint8Array {
|
|
374
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
378
|
+
return Buffer.from(bytes).toString("base64");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function firstSessionError(messages: ReadonlyArray<string | ArrayBuffer>): string | null {
|
|
382
|
+
for (const m of messages) {
|
|
383
|
+
if (typeof m !== "string") continue;
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(m) as { type?: string; message?: string };
|
|
386
|
+
if (parsed.type === "error" && typeof parsed.message === "string") return parsed.message;
|
|
387
|
+
} catch {
|
|
388
|
+
// not JSON — ignore
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function extractTranscript(messages: ReadonlyArray<string | ArrayBuffer>): string {
|
|
395
|
+
for (const m of messages) {
|
|
396
|
+
if (typeof m !== "string") continue;
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(m) as { type?: string; transcript?: string };
|
|
399
|
+
if (parsed.type === "stt_output" && typeof parsed.transcript === "string") return parsed.transcript;
|
|
400
|
+
} catch {
|
|
401
|
+
// not JSON — ignore
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return "";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
408
|
+
const deadline = Date.now() + timeoutMs;
|
|
409
|
+
while (Date.now() < deadline) {
|
|
410
|
+
if (predicate()) return;
|
|
411
|
+
await sleep(25);
|
|
412
|
+
}
|
|
413
|
+
throw new Error("timed out waiting for worker websocket output");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sleep(ms: number): Promise<void> {
|
|
417
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function int16ToBase64(samples: Int16Array): string {
|
|
421
|
+
const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
|
422
|
+
return Buffer.from(bytes).toString("base64");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Minimal PCM16 WAV reader that returns samples resampled to 16 kHz mono. */
|
|
426
|
+
function readWav16kMono(path: string): Int16Array {
|
|
427
|
+
const buf = readFileSync(path);
|
|
428
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
429
|
+
let sampleRate = 24000;
|
|
430
|
+
let dataStart = -1;
|
|
431
|
+
let dataLen = 0;
|
|
432
|
+
let pos = 12; // skip RIFF/WAVE header
|
|
433
|
+
while (pos + 8 <= buf.byteLength) {
|
|
434
|
+
const id = String.fromCharCode(buf[pos]!, buf[pos + 1]!, buf[pos + 2]!, buf[pos + 3]!);
|
|
435
|
+
const size = view.getUint32(pos + 4, true);
|
|
436
|
+
if (id === "fmt ") sampleRate = view.getUint32(pos + 8 + 4, true);
|
|
437
|
+
if (id === "data") {
|
|
438
|
+
dataStart = pos + 8;
|
|
439
|
+
dataLen = size;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
pos += 8 + size + (size % 2);
|
|
443
|
+
}
|
|
444
|
+
if (dataStart < 0) throw new Error(`no data chunk in WAV: ${path}`);
|
|
445
|
+
const sampleCount = Math.floor(dataLen / 2);
|
|
446
|
+
const src = new Int16Array(sampleCount);
|
|
447
|
+
for (let i = 0; i < sampleCount; i += 1) src[i] = view.getInt16(dataStart + i * 2, true);
|
|
448
|
+
return sampleRate === WORKER_INPUT_RATE_HZ ? src : resampleLinear(src, sampleRate, WORKER_INPUT_RATE_HZ);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resampleLinear(src: Int16Array, fromHz: number, toHz: number): Int16Array {
|
|
452
|
+
const outLen = Math.max(1, Math.round((src.length * toHz) / fromHz));
|
|
453
|
+
const out = new Int16Array(outLen);
|
|
454
|
+
const ratio = (src.length - 1) / Math.max(1, outLen - 1);
|
|
455
|
+
for (let i = 0; i < outLen; i += 1) {
|
|
456
|
+
const x = i * ratio;
|
|
457
|
+
const i0 = Math.floor(x);
|
|
458
|
+
const i1 = Math.min(src.length - 1, i0 + 1);
|
|
459
|
+
const frac = x - i0;
|
|
460
|
+
out[i] = Math.round(src[i0]! * (1 - frac) + src[i1]! * frac);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Load DEEPGRAM/OPENAI keys from the repo-root .env if not already set. */
|
|
466
|
+
function loadRepoEnv(): void {
|
|
467
|
+
const envPath = join(REPO_ROOT, ".env");
|
|
468
|
+
if (!existsSync(envPath)) return;
|
|
469
|
+
for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
|
|
470
|
+
const match = /^([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line.trim());
|
|
471
|
+
if (!match) continue;
|
|
472
|
+
const key = match[1]!;
|
|
473
|
+
if (process.env[key] !== undefined) continue;
|
|
474
|
+
let value = match[2]!.trim();
|
|
475
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
476
|
+
value = value.slice(1, -1);
|
|
477
|
+
}
|
|
478
|
+
process.env[key] = value;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { WorkersAiSmartTurnPredictor, type Ai } from "./workers-ai-smart-turn.js";
|
|
5
|
+
|
|
6
|
+
function mockAi(result: { is_complete?: boolean; probability?: number }): Ai {
|
|
7
|
+
return {
|
|
8
|
+
run: async (model: string, input: unknown) => {
|
|
9
|
+
return { ...result };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("WorkersAiSmartTurnPredictor", () => {
|
|
15
|
+
it("calls ai.run with the v2 model, float32 dtype, and base64 audio", async () => {
|
|
16
|
+
const calls: Array<{ model: string; input: unknown }> = [];
|
|
17
|
+
const ai: Ai = {
|
|
18
|
+
run: async (model, input) => {
|
|
19
|
+
calls.push({ model, input });
|
|
20
|
+
return { is_complete: true, probability: 0.9 };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const predictor = new WorkersAiSmartTurnPredictor(ai);
|
|
24
|
+
await predictor.initialize({});
|
|
25
|
+
|
|
26
|
+
const audio = new Float32Array([0.1, -0.2, 0.3]);
|
|
27
|
+
const probability = await predictor.predict(audio);
|
|
28
|
+
|
|
29
|
+
expect(calls).toHaveLength(1);
|
|
30
|
+
const call = calls[0]!;
|
|
31
|
+
expect(call.model).toBe("@cf/pipecat-ai/smart-turn-v2");
|
|
32
|
+
|
|
33
|
+
const input = call.input as Record<string, unknown>;
|
|
34
|
+
expect(input.dtype).toBe("float32");
|
|
35
|
+
expect(typeof input.audio).toBe("string");
|
|
36
|
+
|
|
37
|
+
// Verify base64 decodes back to the same Float32Array bytes
|
|
38
|
+
const decoded = atob(input.audio as string);
|
|
39
|
+
const decodedBytes = new Uint8Array(decoded.length);
|
|
40
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
41
|
+
decodedBytes[i] = decoded.charCodeAt(i);
|
|
42
|
+
}
|
|
43
|
+
const decodedFloats = new Float32Array(decodedBytes.buffer);
|
|
44
|
+
expect(decodedFloats).toEqual(audio);
|
|
45
|
+
|
|
46
|
+
expect(probability).toBe(0.9);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns 0 when probability is missing", async () => {
|
|
50
|
+
const predictor = new WorkersAiSmartTurnPredictor(mockAi({ is_complete: true }));
|
|
51
|
+
await predictor.initialize({});
|
|
52
|
+
const probability = await predictor.predict(new Float32Array([0.5]));
|
|
53
|
+
expect(probability).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns 0 when probability is undefined", async () => {
|
|
57
|
+
const predictor = new WorkersAiSmartTurnPredictor(mockAi({ is_complete: false, probability: undefined }));
|
|
58
|
+
await predictor.initialize({});
|
|
59
|
+
const probability = await predictor.predict(new Float32Array([0.5]));
|
|
60
|
+
expect(probability).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("close is a no-op", async () => {
|
|
64
|
+
const predictor = new WorkersAiSmartTurnPredictor(mockAi({ probability: 0.5 }));
|
|
65
|
+
await expect(predictor.close()).resolves.toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
package/.dev.vars.example
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Syrinx on Cloudflare Workers — local dev secrets.
|
|
2
|
-
# Copy to `.dev.vars` for `wrangler dev`; for production use `wrangler secret put <NAME>`.
|
|
3
|
-
# .dev.vars is gitignored — never commit real keys.
|
|
4
|
-
|
|
5
|
-
# --- Cascaded host (wrangler.jsonc → VoiceConversation / TwilioVoiceConversation) ---
|
|
6
|
-
# Deepgram Nova-3 STT + Deepgram Aura TTS.
|
|
7
|
-
DEEPGRAM_API_KEY=
|
|
8
|
-
|
|
9
|
-
# OpenAI (the kuralle reasoner's model).
|
|
10
|
-
OPENAI_API_KEY=
|
|
11
|
-
|
|
12
|
-
# --- Realtime host (wrangler.realtime.jsonc → RealtimeVoiceConversation) ---
|
|
13
|
-
# Front model select: "openai" (default) or "gemini".
|
|
14
|
-
# REALTIME_FRONT=openai
|
|
15
|
-
# OPENAI_API_KEY is reused for the OpenAI realtime front.
|
|
16
|
-
# GEMINI_API_KEY= # required when REALTIME_FRONT=gemini
|
|
17
|
-
# GEMINI_LIVE_MODEL=gemini-3.1-flash-live-preview
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/wrangler.jsonc
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
-
"name": "syrinx-voice-server-workers",
|
|
4
|
-
"main": "src/worker.ts",
|
|
5
|
-
"compatibility_date": "2026-06-01",
|
|
6
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
-
"durable_objects": {
|
|
8
|
-
"bindings": [
|
|
9
|
-
{
|
|
10
|
-
"name": "VOICE_CONVERSATIONS",
|
|
11
|
-
"class_name": "VoiceConversation"
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"name": "TWILIO_VOICE_CONVERSATIONS",
|
|
15
|
-
"class_name": "TwilioVoiceConversation"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"name": "TELNYX_VOICE_CONVERSATIONS",
|
|
19
|
-
"class_name": "TelnyxVoiceConversation"
|
|
20
|
-
}
|
|
21
|
-
]
|
|
22
|
-
},
|
|
23
|
-
"r2_buckets": [
|
|
24
|
-
{
|
|
25
|
-
"binding": "RECORDINGS",
|
|
26
|
-
"bucket_name": "syrinx-voice-recordings"
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"migrations": [
|
|
30
|
-
{
|
|
31
|
-
"tag": "v1",
|
|
32
|
-
"new_sqlite_classes": ["VoiceConversation"]
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"tag": "v2",
|
|
36
|
-
"new_sqlite_classes": ["TwilioVoiceConversation"]
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"tag": "v3",
|
|
40
|
-
"new_sqlite_classes": ["TelnyxVoiceConversation"]
|
|
41
|
-
}
|
|
42
|
-
],
|
|
43
|
-
"vectorize": [
|
|
44
|
-
{
|
|
45
|
-
"binding": "VECTORIZE",
|
|
46
|
-
"index_name": "kuralle-university-kb"
|
|
47
|
-
}
|
|
48
|
-
]
|
|
49
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
-
"name": "syrinx-voice-realtime-gemini",
|
|
4
|
-
"main": "src/worker-realtime.ts",
|
|
5
|
-
"compatibility_date": "2026-06-01",
|
|
6
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
-
"vars": { "REALTIME_FRONT": "gemini" },
|
|
8
|
-
"durable_objects": {
|
|
9
|
-
"bindings": [
|
|
10
|
-
{
|
|
11
|
-
"name": "REALTIME_VOICE_CONVERSATIONS",
|
|
12
|
-
"class_name": "RealtimeVoiceConversation"
|
|
13
|
-
}
|
|
14
|
-
]
|
|
15
|
-
},
|
|
16
|
-
"migrations": [
|
|
17
|
-
{
|
|
18
|
-
"tag": "v1",
|
|
19
|
-
"new_sqlite_classes": ["RealtimeVoiceConversation"]
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"vectorize": [
|
|
23
|
-
{
|
|
24
|
-
"binding": "VECTORIZE",
|
|
25
|
-
"index_name": "kuralle-university-kb"
|
|
26
|
-
}
|
|
27
|
-
]
|
|
28
|
-
}
|