@kuralle-syrinx/server-workers 2.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/.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/LICENSE +22 -0
- package/package.json +42 -0
- package/src/alarm-scheduler.test.ts +44 -0
- package/src/alarm-scheduler.ts +67 -0
- package/src/durable-session-store.test.ts +60 -0
- package/src/durable-session-store.ts +148 -0
- package/src/kuralle-realtime-agent.ts +211 -0
- package/src/live-realtime-session.test.ts +89 -0
- package/src/live-realtime-session.ts +94 -0
- package/src/live-session.test.ts +89 -0
- package/src/live-session.ts +85 -0
- package/src/r2-recorder.test.ts +100 -0
- package/src/r2-recorder.ts +218 -0
- package/src/test-storage.ts +80 -0
- package/src/worker-realtime.ts +91 -0
- package/src/worker-runtime.test.ts +265 -0
- package/src/worker.ts +125 -0
- package/tsconfig.json +22 -0
- package/wrangler.jsonc +33 -0
- package/wrangler.realtime-gemini.jsonc +28 -0
- package/wrangler.realtime.jsonc +27 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
interface SessionRow {
|
|
4
|
+
id: string;
|
|
5
|
+
current_context_id: string;
|
|
6
|
+
last_sequence: number | null;
|
|
7
|
+
retained_until_ms: number;
|
|
8
|
+
connection_count: number;
|
|
9
|
+
updated_at_ms: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TaskRow {
|
|
13
|
+
key: string;
|
|
14
|
+
deadline_ms: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryDurableStorage {
|
|
18
|
+
readonly sessions = new Map<string, SessionRow>();
|
|
19
|
+
readonly tasks = new Map<string, TaskRow>();
|
|
20
|
+
alarm: number | null = null;
|
|
21
|
+
|
|
22
|
+
readonly sql = {
|
|
23
|
+
exec: (query: string, ...bindings: unknown[]): Iterable<Record<string, unknown>> => {
|
|
24
|
+
const normalized = query.replace(/\s+/g, " ").trim();
|
|
25
|
+
if (normalized.startsWith("CREATE TABLE")) return [];
|
|
26
|
+
if (normalized.startsWith("INSERT OR REPLACE INTO scheduled_tasks")) {
|
|
27
|
+
const key = String(bindings[0]);
|
|
28
|
+
this.tasks.set(key, { key, deadline_ms: Number(bindings[1]) });
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (normalized.startsWith("DELETE FROM scheduled_tasks WHERE key")) {
|
|
32
|
+
this.tasks.delete(String(bindings[0]));
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
if (normalized.startsWith("SELECT key FROM scheduled_tasks")) {
|
|
36
|
+
const now = Number(bindings[0]);
|
|
37
|
+
return [...this.tasks.values()]
|
|
38
|
+
.filter((row) => row.deadline_ms <= now)
|
|
39
|
+
.sort((a, b) => a.deadline_ms - b.deadline_ms) as unknown as Record<string, unknown>[];
|
|
40
|
+
}
|
|
41
|
+
if (normalized.startsWith("SELECT deadline_ms FROM scheduled_tasks")) {
|
|
42
|
+
const next = [...this.tasks.values()].sort((a, b) => a.deadline_ms - b.deadline_ms)[0];
|
|
43
|
+
return (next ? [{ deadline_ms: next.deadline_ms }] : []) as Record<string, unknown>[];
|
|
44
|
+
}
|
|
45
|
+
if (normalized.startsWith("INSERT OR REPLACE INTO sessions")) {
|
|
46
|
+
const row: SessionRow = {
|
|
47
|
+
id: String(bindings[0]),
|
|
48
|
+
current_context_id: String(bindings[1]),
|
|
49
|
+
last_sequence: bindings[2] === null ? null : Number(bindings[2]),
|
|
50
|
+
retained_until_ms: Number(bindings[3]),
|
|
51
|
+
connection_count: Number(bindings[4]),
|
|
52
|
+
updated_at_ms: Number(bindings[5]),
|
|
53
|
+
};
|
|
54
|
+
this.sessions.set(row.id, row);
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (normalized.startsWith("DELETE FROM sessions WHERE id")) {
|
|
58
|
+
this.sessions.delete(String(bindings[0]));
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
if (normalized.startsWith("DELETE FROM sessions")) {
|
|
62
|
+
this.sessions.clear();
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (normalized.startsWith("SELECT * FROM sessions WHERE id")) {
|
|
66
|
+
const row = this.sessions.get(String(bindings[0]));
|
|
67
|
+
return (row ? [row] : []) as unknown as Record<string, unknown>[];
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Unsupported test SQL: ${normalized}`);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
async setAlarm(scheduledTime: number | Date): Promise<void> {
|
|
74
|
+
this.alarm = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async deleteAlarm(): Promise<void> {
|
|
78
|
+
this.alarm = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createVoiceEdgeWebSocketUpgrade,
|
|
5
|
+
type VoiceEdgeWebSocketUpgrade,
|
|
6
|
+
} from "@kuralle-syrinx/server-websocket/edge";
|
|
7
|
+
import type { WorkersInboundSocketController } from "@kuralle-syrinx/ws/workers";
|
|
8
|
+
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
9
|
+
import { DurableObjectSessionStore } from "./durable-session-store.js";
|
|
10
|
+
import {
|
|
11
|
+
createRealtimeVoiceAgentSession,
|
|
12
|
+
type RealtimeSessionEnv,
|
|
13
|
+
} from "./live-realtime-session.js";
|
|
14
|
+
|
|
15
|
+
export interface Env extends RealtimeSessionEnv {
|
|
16
|
+
readonly REALTIME_VOICE_CONVERSATIONS: DurableObjectNamespace;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const INPUT_SAMPLE_RATE_HZ = 16000;
|
|
20
|
+
const OUTPUT_SAMPLE_RATE_HZ = 16000;
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
if (url.pathname === "/health") return new Response("ok");
|
|
26
|
+
if (url.pathname !== "/ws") return new Response("not found", { status: 404 });
|
|
27
|
+
const sessionId = url.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
28
|
+
const id = env.REALTIME_VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
29
|
+
return await env.REALTIME_VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class RealtimeVoiceConversation {
|
|
34
|
+
private readonly scheduler: DurableObjectAlarmScheduler;
|
|
35
|
+
private readonly store: DurableObjectSessionStore;
|
|
36
|
+
private activeUpgrade: VoiceEdgeWebSocketUpgrade | null = null;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly ctx: DurableObjectState,
|
|
40
|
+
private readonly env: Env,
|
|
41
|
+
) {
|
|
42
|
+
this.scheduler = new DurableObjectAlarmScheduler(ctx.storage);
|
|
43
|
+
this.store = new DurableObjectSessionStore(ctx.storage, this.scheduler);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async fetch(request: Request): Promise<Response> {
|
|
47
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
48
|
+
return new Response("expected websocket", { status: 426 });
|
|
49
|
+
}
|
|
50
|
+
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
51
|
+
const upgrade = createVoiceEdgeWebSocketUpgrade(request, {
|
|
52
|
+
sessionStore: this.store,
|
|
53
|
+
scheduler: this.scheduler,
|
|
54
|
+
createSession: () =>
|
|
55
|
+
createRealtimeVoiceAgentSession(this.env, {
|
|
56
|
+
sessionId,
|
|
57
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
58
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
59
|
+
}),
|
|
60
|
+
sessionId: () => sessionId,
|
|
61
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
62
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
63
|
+
resumeWindowMs: 15_000,
|
|
64
|
+
}, {
|
|
65
|
+
acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
|
|
66
|
+
});
|
|
67
|
+
this.activeUpgrade = upgrade;
|
|
68
|
+
return upgrade.response;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async alarm(): Promise<void> {
|
|
72
|
+
await this.scheduler.runDue();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void {
|
|
76
|
+
this.controller?.message(message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
webSocketClose(_ws: WebSocket, code: number, reason: string): void {
|
|
80
|
+
this.controller?.close(code, reason);
|
|
81
|
+
this.activeUpgrade = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
webSocketError(_ws: WebSocket, error: unknown): void {
|
|
85
|
+
this.controller?.error(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private get controller(): WorkersInboundSocketController | undefined {
|
|
89
|
+
return this.activeUpgrade?.controller;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
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 { fileURLToPath } from "node:url";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { Miniflare } from "miniflare";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
|
|
16
|
+
type WorkersWebSocket = WebSocket & { accept(): void };
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
19
|
+
const TURN_DETECTION_FIXTURE = join(
|
|
20
|
+
REPO_ROOT,
|
|
21
|
+
"examples/02-hello-voice-headless/test/fixtures/gemini-turn-detection/02-reset-password.wav",
|
|
22
|
+
);
|
|
23
|
+
const WORKER_INPUT_RATE_HZ = 16000;
|
|
24
|
+
|
|
25
|
+
loadRepoEnv();
|
|
26
|
+
const liveEnv = {
|
|
27
|
+
DEEPGRAM_API_KEY: process.env["DEEPGRAM_API_KEY"]?.trim() ?? "",
|
|
28
|
+
OPENAI_API_KEY: process.env["OPENAI_API_KEY"]?.trim() ?? "",
|
|
29
|
+
VECTORIZE: {},
|
|
30
|
+
};
|
|
31
|
+
const hasLiveKeys = Boolean(liveEnv.DEEPGRAM_API_KEY && liveEnv.OPENAI_API_KEY);
|
|
32
|
+
// The live turn hits paid, non-deterministic provider APIs, so it is opt-in
|
|
33
|
+
// (SYRINX_LIVE_WORKER_TEST=1) rather than running on every `pnpm -r test`.
|
|
34
|
+
// Run it with: pnpm --filter @kuralle-syrinx/server-workers test:live
|
|
35
|
+
const liveTurnEnabled = hasLiveKeys && process.env["SYRINX_LIVE_WORKER_TEST"] === "1";
|
|
36
|
+
|
|
37
|
+
const tempDirs: string[] = [];
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function buildWorker(): Promise<string> {
|
|
43
|
+
const tmp = await mkdtemp(join(tmpdir(), "syrinx-worker-"));
|
|
44
|
+
tempDirs.push(tmp);
|
|
45
|
+
await execFileAsync(
|
|
46
|
+
"npx",
|
|
47
|
+
["wrangler", "deploy", "--dry-run", "--outdir", tmp, "-c", "wrangler.jsonc"],
|
|
48
|
+
{ cwd: PKG_ROOT },
|
|
49
|
+
);
|
|
50
|
+
return readFile(join(tmp, "worker.js"), "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function newMiniflare(script: string, bindings: Record<string, unknown>): Miniflare {
|
|
54
|
+
return new Miniflare({
|
|
55
|
+
modules: true,
|
|
56
|
+
script,
|
|
57
|
+
compatibilityDate: "2026-06-01",
|
|
58
|
+
compatibilityFlags: ["nodejs_compat"],
|
|
59
|
+
durableObjects: {
|
|
60
|
+
VOICE_CONVERSATIONS: { className: "VoiceConversation", useSQLite: true },
|
|
61
|
+
},
|
|
62
|
+
vectorize: {
|
|
63
|
+
VECTORIZE: { dimensions: 1536, metric: "cosine", index_name: "kuralle-university-kb" },
|
|
64
|
+
},
|
|
65
|
+
bindings,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("VoiceConversation worker runtime", () => {
|
|
70
|
+
// Deterministic, no provider keys: proves the edge bundle boots inside workerd
|
|
71
|
+
// and accepts a WebSocketPair upgrade before any provider session starts.
|
|
72
|
+
it("boots in workerd and accepts a WebSocket upgrade", async () => {
|
|
73
|
+
const script = await buildWorker();
|
|
74
|
+
const mf = newMiniflare(script, {});
|
|
75
|
+
try {
|
|
76
|
+
const health = await mf.dispatchFetch("http://localhost/health");
|
|
77
|
+
expect(health.status).toBe(200);
|
|
78
|
+
expect(await health.text()).toBe("ok");
|
|
79
|
+
|
|
80
|
+
const response = await mf.dispatchFetch("http://localhost/ws?sessionId=boot-smoke", {
|
|
81
|
+
headers: { Upgrade: "websocket" },
|
|
82
|
+
});
|
|
83
|
+
expect(response.status).toBe(101);
|
|
84
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
85
|
+
expect(ws).toBeTruthy();
|
|
86
|
+
ws!.accept();
|
|
87
|
+
ws!.close();
|
|
88
|
+
} finally {
|
|
89
|
+
await mf.dispose();
|
|
90
|
+
}
|
|
91
|
+
}, 20_000);
|
|
92
|
+
|
|
93
|
+
// Live: drives a real STT -> LLM -> TTS turn through real providers inside
|
|
94
|
+
// workerd. Skipped when provider keys are absent so offline CI stays green.
|
|
95
|
+
it.skipIf(!liveTurnEnabled)(
|
|
96
|
+
"drives a real audio turn through Deepgram STT + kuralle + Deepgram TTS in workerd",
|
|
97
|
+
async () => {
|
|
98
|
+
expect(existsSync(TURN_DETECTION_FIXTURE)).toBe(true);
|
|
99
|
+
const pcm16 = readWav16kMono(TURN_DETECTION_FIXTURE);
|
|
100
|
+
const script = await buildWorker();
|
|
101
|
+
const mf = newMiniflare(script, liveEnv);
|
|
102
|
+
try {
|
|
103
|
+
const response = await mf.dispatchFetch("http://localhost/ws?sessionId=live-turn", {
|
|
104
|
+
headers: { Upgrade: "websocket" },
|
|
105
|
+
});
|
|
106
|
+
expect(response.status).toBe(101);
|
|
107
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
108
|
+
expect(ws).toBeTruthy();
|
|
109
|
+
|
|
110
|
+
const messages: Array<string | ArrayBuffer> = [];
|
|
111
|
+
ws!.addEventListener("message", (event) => {
|
|
112
|
+
messages.push(event.data as string | ArrayBuffer);
|
|
113
|
+
});
|
|
114
|
+
ws!.accept();
|
|
115
|
+
|
|
116
|
+
// Wait for the session to come up (provider sockets connected) before audio.
|
|
117
|
+
// Surface a provider init error (e.g. bad key, unreachable socket) instead
|
|
118
|
+
// of a bare timeout.
|
|
119
|
+
try {
|
|
120
|
+
await waitFor(() => messages.some((m) => typeof m === "string" && m.includes('"type":"ready"')), 20_000);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`session never became ready: ${firstSessionError(messages) ?? "no error reported"}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Stream the fixture as 20ms PCM16 frames, base64 JSON audio messages.
|
|
126
|
+
const frameSamples = (WORKER_INPUT_RATE_HZ / 1000) * 20; // 320 samples = 640 bytes
|
|
127
|
+
let sequence = 0;
|
|
128
|
+
for (let offset = 0; offset < pcm16.length; offset += frameSamples) {
|
|
129
|
+
const frame = pcm16.subarray(offset, Math.min(offset + frameSamples, pcm16.length));
|
|
130
|
+
sequence += 1;
|
|
131
|
+
ws!.send(JSON.stringify({
|
|
132
|
+
type: "audio",
|
|
133
|
+
audio: int16ToBase64(frame),
|
|
134
|
+
sampleRateHz: WORKER_INPUT_RATE_HZ,
|
|
135
|
+
sequence,
|
|
136
|
+
}));
|
|
137
|
+
await sleep(20);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Real Deepgram transcript for the "reset password" utterance.
|
|
141
|
+
await waitFor(
|
|
142
|
+
() => messages.some((m) => typeof m === "string" && m.includes('"type":"stt_output"')),
|
|
143
|
+
15_000,
|
|
144
|
+
);
|
|
145
|
+
// Real Deepgram TTS audio frames back from the kuralle reply.
|
|
146
|
+
await waitFor(
|
|
147
|
+
() => messages.some((m) => typeof m === "string" && m.includes('"type":"tts_chunk"')),
|
|
148
|
+
30_000,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const transcript = extractTranscript(messages);
|
|
152
|
+
expect(transcript.length).toBeGreaterThan(0);
|
|
153
|
+
expect(messages.some((m) => m instanceof ArrayBuffer && m.byteLength > 0)).toBe(true);
|
|
154
|
+
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log(`[live-turn] STT transcript: ${JSON.stringify(transcript)}`);
|
|
157
|
+
ws!.close();
|
|
158
|
+
} finally {
|
|
159
|
+
await mf.dispose();
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
90_000,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function firstSessionError(messages: ReadonlyArray<string | ArrayBuffer>): string | null {
|
|
167
|
+
for (const m of messages) {
|
|
168
|
+
if (typeof m !== "string") continue;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(m) as { type?: string; message?: string };
|
|
171
|
+
if (parsed.type === "error" && typeof parsed.message === "string") return parsed.message;
|
|
172
|
+
} catch {
|
|
173
|
+
// not JSON — ignore
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractTranscript(messages: ReadonlyArray<string | ArrayBuffer>): string {
|
|
180
|
+
for (const m of messages) {
|
|
181
|
+
if (typeof m !== "string") continue;
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(m) as { type?: string; transcript?: string };
|
|
184
|
+
if (parsed.type === "stt_output" && typeof parsed.transcript === "string") return parsed.transcript;
|
|
185
|
+
} catch {
|
|
186
|
+
// not JSON — ignore
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
193
|
+
const deadline = Date.now() + timeoutMs;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
if (predicate()) return;
|
|
196
|
+
await sleep(25);
|
|
197
|
+
}
|
|
198
|
+
throw new Error("timed out waiting for worker websocket output");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sleep(ms: number): Promise<void> {
|
|
202
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function int16ToBase64(samples: Int16Array): string {
|
|
206
|
+
const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
|
207
|
+
return Buffer.from(bytes).toString("base64");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Minimal PCM16 WAV reader that returns samples resampled to 16 kHz mono. */
|
|
211
|
+
function readWav16kMono(path: string): Int16Array {
|
|
212
|
+
const buf = readFileSync(path);
|
|
213
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
214
|
+
let sampleRate = 24000;
|
|
215
|
+
let dataStart = -1;
|
|
216
|
+
let dataLen = 0;
|
|
217
|
+
let pos = 12; // skip RIFF/WAVE header
|
|
218
|
+
while (pos + 8 <= buf.byteLength) {
|
|
219
|
+
const id = String.fromCharCode(buf[pos]!, buf[pos + 1]!, buf[pos + 2]!, buf[pos + 3]!);
|
|
220
|
+
const size = view.getUint32(pos + 4, true);
|
|
221
|
+
if (id === "fmt ") sampleRate = view.getUint32(pos + 8 + 4, true);
|
|
222
|
+
if (id === "data") {
|
|
223
|
+
dataStart = pos + 8;
|
|
224
|
+
dataLen = size;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
pos += 8 + size + (size % 2);
|
|
228
|
+
}
|
|
229
|
+
if (dataStart < 0) throw new Error(`no data chunk in WAV: ${path}`);
|
|
230
|
+
const sampleCount = Math.floor(dataLen / 2);
|
|
231
|
+
const src = new Int16Array(sampleCount);
|
|
232
|
+
for (let i = 0; i < sampleCount; i += 1) src[i] = view.getInt16(dataStart + i * 2, true);
|
|
233
|
+
return sampleRate === WORKER_INPUT_RATE_HZ ? src : resampleLinear(src, sampleRate, WORKER_INPUT_RATE_HZ);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resampleLinear(src: Int16Array, fromHz: number, toHz: number): Int16Array {
|
|
237
|
+
const outLen = Math.max(1, Math.round((src.length * toHz) / fromHz));
|
|
238
|
+
const out = new Int16Array(outLen);
|
|
239
|
+
const ratio = (src.length - 1) / Math.max(1, outLen - 1);
|
|
240
|
+
for (let i = 0; i < outLen; i += 1) {
|
|
241
|
+
const x = i * ratio;
|
|
242
|
+
const i0 = Math.floor(x);
|
|
243
|
+
const i1 = Math.min(src.length - 1, i0 + 1);
|
|
244
|
+
const frac = x - i0;
|
|
245
|
+
out[i] = Math.round(src[i0]! * (1 - frac) + src[i1]! * frac);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Load DEEPGRAM/OPENAI keys from the repo-root .env if not already set. */
|
|
251
|
+
function loadRepoEnv(): void {
|
|
252
|
+
const envPath = join(REPO_ROOT, ".env");
|
|
253
|
+
if (!existsSync(envPath)) return;
|
|
254
|
+
for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
|
|
255
|
+
const match = /^([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line.trim());
|
|
256
|
+
if (!match) continue;
|
|
257
|
+
const key = match[1]!;
|
|
258
|
+
if (process.env[key] !== undefined) continue;
|
|
259
|
+
let value = match[2]!.trim();
|
|
260
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
261
|
+
value = value.slice(1, -1);
|
|
262
|
+
}
|
|
263
|
+
process.env[key] = value;
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createVoiceEdgeWebSocketUpgrade,
|
|
5
|
+
type VoiceEdgeWebSocketUpgrade,
|
|
6
|
+
} from "@kuralle-syrinx/server-websocket/edge";
|
|
7
|
+
import { createTwilioEdgeWebSocketUpgrade } from "@kuralle-syrinx/server-websocket/edge-twilio";
|
|
8
|
+
import type { WorkersInboundSocketController } from "@kuralle-syrinx/ws/workers";
|
|
9
|
+
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
10
|
+
import { DurableObjectSessionStore } from "./durable-session-store.js";
|
|
11
|
+
import { createLiveVoiceAgentSession, type LiveSessionEnv } from "./live-session.js";
|
|
12
|
+
import { R2EdgeRecorder } from "./r2-recorder.js";
|
|
13
|
+
|
|
14
|
+
export interface Env extends LiveSessionEnv {
|
|
15
|
+
VOICE_CONVERSATIONS: DurableObjectNamespace;
|
|
16
|
+
/** Optional: when bound, full call audio is recorded to this bucket. */
|
|
17
|
+
RECORDINGS?: R2Bucket;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INPUT_SAMPLE_RATE_HZ = 16000;
|
|
21
|
+
const OUTPUT_SAMPLE_RATE_HZ = 16000;
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
25
|
+
const url = new URL(request.url);
|
|
26
|
+
if (url.pathname === "/health") return new Response("ok");
|
|
27
|
+
if (url.pathname === "/recordings") return await listRecordings(url, env);
|
|
28
|
+
if (url.pathname !== "/ws" && url.pathname !== "/twilio") {
|
|
29
|
+
return new Response("not found", { status: 404 });
|
|
30
|
+
}
|
|
31
|
+
const sessionId = url.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
32
|
+
const id = env.VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
33
|
+
return await env.VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** List recorded objects for a session: GET /recordings?sessionId=<id>. */
|
|
38
|
+
async function listRecordings(url: URL, env: Env): Promise<Response> {
|
|
39
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
40
|
+
if (!env.RECORDINGS || !sessionId) return new Response("not found", { status: 404 });
|
|
41
|
+
const listed = await env.RECORDINGS.list({ prefix: `recordings/${sessionId}/` });
|
|
42
|
+
return Response.json(listed.objects.map((o) => ({ key: o.key, size: o.size })));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class VoiceConversation {
|
|
46
|
+
private readonly scheduler: DurableObjectAlarmScheduler;
|
|
47
|
+
private readonly store: DurableObjectSessionStore;
|
|
48
|
+
private activeUpgrade: VoiceEdgeWebSocketUpgrade | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly ctx: DurableObjectState,
|
|
52
|
+
private readonly env: Env,
|
|
53
|
+
) {
|
|
54
|
+
this.scheduler = new DurableObjectAlarmScheduler(ctx.storage);
|
|
55
|
+
this.store = new DurableObjectSessionStore(ctx.storage, this.scheduler);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async fetch(request: Request): Promise<Response> {
|
|
59
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
60
|
+
return new Response("expected websocket", { status: 426 });
|
|
61
|
+
}
|
|
62
|
+
const requestUrl = new URL(request.url);
|
|
63
|
+
const sessionId = requestUrl.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
64
|
+
if (requestUrl.pathname === "/twilio") {
|
|
65
|
+
const upgrade = createTwilioEdgeWebSocketUpgrade(request, {
|
|
66
|
+
sessionStore: this.store,
|
|
67
|
+
scheduler: this.scheduler,
|
|
68
|
+
createSession: () =>
|
|
69
|
+
createLiveVoiceAgentSession(this.env, {
|
|
70
|
+
sessionId,
|
|
71
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
72
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
73
|
+
}),
|
|
74
|
+
engineSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
75
|
+
}, {
|
|
76
|
+
acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
|
|
77
|
+
});
|
|
78
|
+
this.activeUpgrade = upgrade;
|
|
79
|
+
return upgrade.response;
|
|
80
|
+
}
|
|
81
|
+
const recorder = this.env.RECORDINGS
|
|
82
|
+
? new R2EdgeRecorder({ bucket: this.env.RECORDINGS, sessionId, startedAtMs: Date.now() })
|
|
83
|
+
: undefined;
|
|
84
|
+
const upgrade = createVoiceEdgeWebSocketUpgrade(request, {
|
|
85
|
+
sessionStore: this.store,
|
|
86
|
+
scheduler: this.scheduler,
|
|
87
|
+
recorder,
|
|
88
|
+
createSession: () =>
|
|
89
|
+
createLiveVoiceAgentSession(this.env, {
|
|
90
|
+
sessionId,
|
|
91
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
92
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
93
|
+
}),
|
|
94
|
+
sessionId: () => sessionId,
|
|
95
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
96
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
97
|
+
resumeWindowMs: 15_000,
|
|
98
|
+
}, {
|
|
99
|
+
acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
|
|
100
|
+
});
|
|
101
|
+
this.activeUpgrade = upgrade;
|
|
102
|
+
return upgrade.response;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async alarm(): Promise<void> {
|
|
106
|
+
await this.scheduler.runDue();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void {
|
|
110
|
+
this.controller?.message(message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
webSocketClose(_ws: WebSocket, code: number, reason: string): void {
|
|
114
|
+
this.controller?.close(code, reason);
|
|
115
|
+
this.activeUpgrade = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
webSocketError(_ws: WebSocket, error: unknown): void {
|
|
119
|
+
this.controller?.error(error instanceof Error ? error : new Error(String(error)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private get controller(): WorkersInboundSocketController | undefined {
|
|
123
|
+
return this.activeUpgrade?.controller;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noImplicitReturns": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|
package/wrangler.jsonc
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
},
|
|
15
|
+
"r2_buckets": [
|
|
16
|
+
{
|
|
17
|
+
"binding": "RECORDINGS",
|
|
18
|
+
"bucket_name": "syrinx-voice-recordings"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"migrations": [
|
|
22
|
+
{
|
|
23
|
+
"tag": "v1",
|
|
24
|
+
"new_sqlite_classes": ["VoiceConversation"]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"vectorize": [
|
|
28
|
+
{
|
|
29
|
+
"binding": "VECTORIZE",
|
|
30
|
+
"index_name": "kuralle-university-kb"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "syrinx-voice-realtime-workers",
|
|
4
|
+
"main": "src/worker-realtime.ts",
|
|
5
|
+
"compatibility_date": "2026-06-01",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"durable_objects": {
|
|
8
|
+
"bindings": [
|
|
9
|
+
{
|
|
10
|
+
"name": "REALTIME_VOICE_CONVERSATIONS",
|
|
11
|
+
"class_name": "RealtimeVoiceConversation"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"migrations": [
|
|
16
|
+
{
|
|
17
|
+
"tag": "v1",
|
|
18
|
+
"new_sqlite_classes": ["RealtimeVoiceConversation"]
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"vectorize": [
|
|
22
|
+
{
|
|
23
|
+
"binding": "VECTORIZE",
|
|
24
|
+
"index_name": "kuralle-university-kb"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|