@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.
Files changed (30) hide show
  1. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  2. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  3. package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  4. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite +0 -0
  5. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-shm +0 -0
  6. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-wal +0 -0
  7. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite +0 -0
  8. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-shm +0 -0
  9. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-wal +0 -0
  10. package/LICENSE +22 -0
  11. package/package.json +42 -0
  12. package/src/alarm-scheduler.test.ts +44 -0
  13. package/src/alarm-scheduler.ts +67 -0
  14. package/src/durable-session-store.test.ts +60 -0
  15. package/src/durable-session-store.ts +148 -0
  16. package/src/kuralle-realtime-agent.ts +211 -0
  17. package/src/live-realtime-session.test.ts +89 -0
  18. package/src/live-realtime-session.ts +94 -0
  19. package/src/live-session.test.ts +89 -0
  20. package/src/live-session.ts +85 -0
  21. package/src/r2-recorder.test.ts +100 -0
  22. package/src/r2-recorder.ts +218 -0
  23. package/src/test-storage.ts +80 -0
  24. package/src/worker-realtime.ts +91 -0
  25. package/src/worker-runtime.test.ts +265 -0
  26. package/src/worker.ts +125 -0
  27. package/tsconfig.json +22 -0
  28. package/wrangler.jsonc +33 -0
  29. package/wrangler.realtime-gemini.jsonc +28 -0
  30. package/wrangler.realtime.jsonc +27 -0
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
8
+ import { RealtimeBridge } from "@kuralle-syrinx/realtime";
9
+
10
+ import {
11
+ createRealtimeVoiceAgentSession,
12
+ hasRealtimeSessionCredentials,
13
+ resolveRealtimeFront,
14
+ } from "./live-realtime-session.js";
15
+
16
+ const srcDir = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ const mockVectorize = {} as import("@cloudflare/workers-types").VectorizeIndex;
19
+
20
+ describe("createRealtimeVoiceAgentSession", () => {
21
+ it("registers the realtime plugin with RealtimeBridge", async () => {
22
+ const registerSpy = vi.spyOn(VoiceAgentSession.prototype, "registerPlugin");
23
+ const session = await createRealtimeVoiceAgentSession({
24
+ OPENAI_API_KEY: "test-key",
25
+ VECTORIZE: mockVectorize,
26
+ });
27
+ expect(session).toBeInstanceOf(VoiceAgentSession);
28
+ expect(registerSpy).toHaveBeenCalledWith("realtime", expect.any(RealtimeBridge));
29
+ registerSpy.mockRestore();
30
+ });
31
+
32
+ it("requires OPENAI_API_KEY", async () => {
33
+ await expect(createRealtimeVoiceAgentSession({ VECTORIZE: mockVectorize })).rejects.toThrow(/OPENAI_API_KEY/);
34
+ });
35
+
36
+ it("reports credential presence via hasRealtimeSessionCredentials", () => {
37
+ expect(hasRealtimeSessionCredentials({ OPENAI_API_KEY: "k", VECTORIZE: mockVectorize })).toBe(true);
38
+ expect(hasRealtimeSessionCredentials({ VECTORIZE: mockVectorize })).toBe(false);
39
+ expect(hasRealtimeSessionCredentials({
40
+ REALTIME_FRONT: "gemini",
41
+ GEMINI_API_KEY: "g",
42
+ VECTORIZE: mockVectorize,
43
+ })).toBe(true);
44
+ expect(hasRealtimeSessionCredentials({
45
+ REALTIME_FRONT: "gemini",
46
+ VECTORIZE: mockVectorize,
47
+ })).toBe(false);
48
+ });
49
+
50
+ it("requires GEMINI_API_KEY when REALTIME_FRONT=gemini", async () => {
51
+ await expect(createRealtimeVoiceAgentSession({
52
+ REALTIME_FRONT: "gemini",
53
+ VECTORIZE: mockVectorize,
54
+ })).rejects.toThrow(/GEMINI_API_KEY/);
55
+ });
56
+
57
+ it("defaults REALTIME_FRONT to openai", () => {
58
+ expect(resolveRealtimeFront({ VECTORIZE: mockVectorize })).toBe("openai");
59
+ expect(resolveRealtimeFront({ REALTIME_FRONT: "gemini", VECTORIZE: mockVectorize })).toBe("gemini");
60
+ });
61
+ });
62
+
63
+ describe("realtime worker edge safety", () => {
64
+ it("live-realtime-session and worker-realtime stay free of Node-only primitives", () => {
65
+ const files = ["live-realtime-session.ts", "worker-realtime.ts", "kuralle-realtime-agent.ts"];
66
+ const banned = /\bBuffer\b|from "node:|process\./;
67
+ for (const file of files) {
68
+ const source = readFileSync(path.join(srcDir, file), "utf8");
69
+ for (const [index, line] of source.split("\n").entries()) {
70
+ expect(line, `${file}:${index + 1}`).not.toMatch(banned);
71
+ }
72
+ }
73
+ });
74
+
75
+ it("does not pull node: imports into the realtime worker src tree", () => {
76
+ const nodeImports = readdirSync(srcDir)
77
+ .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
78
+ .flatMap((name) => {
79
+ const source = readFileSync(path.join(srcDir, name), "utf8");
80
+ return source
81
+ .split("\n")
82
+ .filter((line) => line.includes('from "node:'))
83
+ .map((line) => `${name}: ${line.trim()}`);
84
+ });
85
+ expect(nodeImports.filter((entry) => entry.startsWith("live-realtime-session")
86
+ || entry.startsWith("worker-realtime")
87
+ || entry.startsWith("kuralle-realtime-agent"))).toEqual([]);
88
+ });
89
+ });
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Bi-model VoiceAgentSession for Cloudflare Workers: gpt-realtime or Gemini Live front model,
4
+ // with a kuralle Vectorize-backed Reasoner back model.
5
+
6
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
7
+ import { RealtimeBridge, fromGeminiLive, fromOpenAIRealtime } from "@kuralle-syrinx/realtime";
8
+ import type { RealtimeToolDef } from "@kuralle-syrinx/realtime";
9
+ import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";
10
+ import type { VectorizeIndex } from "@cloudflare/workers-types";
11
+ import { createRealtimeKuralleReasoner } from "./kuralle-realtime-agent.js";
12
+
13
+ export type RealtimeFront = "openai" | "gemini";
14
+
15
+ export interface RealtimeSessionEnv {
16
+ readonly OPENAI_API_KEY?: string;
17
+ readonly OPENAI_MODEL?: string;
18
+ readonly GEMINI_API_KEY?: string;
19
+ readonly GEMINI_LIVE_MODEL?: string;
20
+ readonly REALTIME_FRONT?: string;
21
+ readonly VECTORIZE: VectorizeIndex;
22
+ }
23
+
24
+ export interface RealtimeSessionOptions {
25
+ readonly sessionId?: string;
26
+ readonly inputSampleRateHz?: number;
27
+ readonly outputSampleRateHz?: number;
28
+ }
29
+
30
+ const DEFAULT_GEMINI_LIVE_MODEL = "gemini-3.1-flash-live-preview";
31
+
32
+ const REALTIME_SYSTEM_INSTRUCTION =
33
+ "You are a university student-relations voice assistant. Delegate factual questions to ask_university.";
34
+
35
+ const ASK_UNIVERSITY_TOOL: RealtimeToolDef = {
36
+ name: "ask_university",
37
+ description: "Answer university student-relations questions (enrollment, add/drop, advising).",
38
+ parameters: {
39
+ type: "object",
40
+ properties: { query: { type: "string" } },
41
+ required: ["query"],
42
+ },
43
+ };
44
+
45
+ export function resolveRealtimeFront(env: RealtimeSessionEnv): RealtimeFront {
46
+ const front = env.REALTIME_FRONT?.trim().toLowerCase();
47
+ return front === "gemini" ? "gemini" : "openai";
48
+ }
49
+
50
+ export function hasRealtimeSessionCredentials(env: RealtimeSessionEnv): boolean {
51
+ const front = resolveRealtimeFront(env);
52
+ if (front === "gemini") return Boolean(env.GEMINI_API_KEY?.trim());
53
+ return Boolean(env.OPENAI_API_KEY?.trim());
54
+ }
55
+
56
+ export async function createRealtimeVoiceAgentSession(
57
+ env: RealtimeSessionEnv,
58
+ options: RealtimeSessionOptions = {},
59
+ ): Promise<VoiceAgentSession> {
60
+ const sessionId = options.sessionId?.trim() || crypto.randomUUID();
61
+ const front = resolveRealtimeFront(env);
62
+
63
+ const adapter = front === "gemini"
64
+ ? fromGeminiLive({
65
+ apiKey: requireKey(env.GEMINI_API_KEY, "GEMINI_API_KEY"),
66
+ model: env.GEMINI_LIVE_MODEL?.trim() || DEFAULT_GEMINI_LIVE_MODEL,
67
+ systemInstruction: REALTIME_SYSTEM_INSTRUCTION,
68
+ tools: [ASK_UNIVERSITY_TOOL],
69
+ })
70
+ : fromOpenAIRealtime({
71
+ apiKey: requireKey(env.OPENAI_API_KEY, "OPENAI_API_KEY"),
72
+ socketFactory: createWorkersSocket,
73
+ turnDetection: { type: "server_vad", silence_duration_ms: 500 },
74
+ inputTranscription: true,
75
+ tools: [ASK_UNIVERSITY_TOOL],
76
+ });
77
+
78
+ const universityReasoner = await createRealtimeKuralleReasoner(env, { sessionId });
79
+
80
+ const bridge = new RealtimeBridge(adapter, universityReasoner, ASK_UNIVERSITY_TOOL.name);
81
+
82
+ const session = new VoiceAgentSession({
83
+ plugins: { realtime: {} },
84
+ endpointingOwner: "timer",
85
+ });
86
+ session.registerPlugin("realtime", bridge);
87
+ return session;
88
+ }
89
+
90
+ function requireKey(value: string | undefined, name: string): string {
91
+ const trimmed = value?.trim();
92
+ if (!trimmed) throw new Error(`${name} is required to start a realtime voice session`);
93
+ return trimmed;
94
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
8
+ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
9
+ import { DeepgramSTTPlugin, DeepgramTTSPlugin } from "@kuralle-syrinx/deepgram";
10
+
11
+ import {
12
+ createLiveVoiceAgentSession,
13
+ hasLiveSessionCredentials,
14
+ } from "./live-session.js";
15
+
16
+ const srcDir = path.dirname(fileURLToPath(import.meta.url));
17
+ const mockVectorize = {} as import("@cloudflare/workers-types").VectorizeIndex;
18
+
19
+ describe("createLiveVoiceAgentSession", () => {
20
+ it("registers stt, kuralle bridge, and Deepgram TTS plugins", async () => {
21
+ const registerSpy = vi.spyOn(VoiceAgentSession.prototype, "registerPlugin");
22
+ const session = await createLiveVoiceAgentSession({
23
+ DEEPGRAM_API_KEY: "dg-key",
24
+ OPENAI_API_KEY: "oa-key",
25
+ VECTORIZE: mockVectorize,
26
+ });
27
+ expect(session).toBeInstanceOf(VoiceAgentSession);
28
+ expect(registerSpy).toHaveBeenCalledWith("stt", expect.any(DeepgramSTTPlugin));
29
+ expect(registerSpy).toHaveBeenCalledWith("bridge", expect.any(ReasoningBridge));
30
+ expect(registerSpy).toHaveBeenCalledWith("tts", expect.any(DeepgramTTSPlugin));
31
+ registerSpy.mockRestore();
32
+ });
33
+
34
+ it("requires DEEPGRAM_API_KEY, OPENAI_API_KEY, and VECTORIZE", async () => {
35
+ await expect(createLiveVoiceAgentSession({
36
+ OPENAI_API_KEY: "k",
37
+ VECTORIZE: mockVectorize,
38
+ })).rejects.toThrow(/DEEPGRAM_API_KEY/);
39
+ await expect(createLiveVoiceAgentSession({
40
+ DEEPGRAM_API_KEY: "k",
41
+ VECTORIZE: mockVectorize,
42
+ })).rejects.toThrow(/OPENAI_API_KEY/);
43
+ await expect(createLiveVoiceAgentSession({
44
+ DEEPGRAM_API_KEY: "k",
45
+ OPENAI_API_KEY: "k",
46
+ VECTORIZE: undefined as unknown as typeof mockVectorize,
47
+ })).rejects.toThrow(/VECTORIZE/);
48
+ });
49
+
50
+ it("reports credential presence via hasLiveSessionCredentials", () => {
51
+ expect(hasLiveSessionCredentials({
52
+ DEEPGRAM_API_KEY: "d",
53
+ OPENAI_API_KEY: "o",
54
+ VECTORIZE: mockVectorize,
55
+ })).toBe(true);
56
+ expect(hasLiveSessionCredentials({
57
+ OPENAI_API_KEY: "o",
58
+ VECTORIZE: mockVectorize,
59
+ })).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("cascade worker edge safety", () => {
64
+ it("live-session and worker stay free of Node-only primitives", () => {
65
+ const files = ["live-session.ts", "worker.ts", "kuralle-realtime-agent.ts"];
66
+ const banned = /\bBuffer\b|from "node:|process\./;
67
+ for (const file of files) {
68
+ const source = readFileSync(path.join(srcDir, file), "utf8");
69
+ for (const [index, line] of source.split("\n").entries()) {
70
+ expect(line, `${file}:${index + 1}`).not.toMatch(banned);
71
+ }
72
+ }
73
+ });
74
+
75
+ it("does not pull node: imports into the cascade worker src tree", () => {
76
+ const nodeImports = readdirSync(srcDir)
77
+ .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
78
+ .flatMap((name) => {
79
+ const source = readFileSync(path.join(srcDir, name), "utf8");
80
+ return source
81
+ .split("\n")
82
+ .filter((line) => line.includes('from "node:'))
83
+ .map((line) => `${name}: ${line.trim()}`);
84
+ });
85
+ expect(nodeImports.filter((entry) => entry.startsWith("live-session")
86
+ || entry.startsWith("worker.ts")
87
+ || entry.startsWith("kuralle-realtime-agent"))).toEqual([]);
88
+ });
89
+ });
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Live VoiceAgentSession for the Cloudflare Workers Durable Object: real
4
+ // Deepgram STT + kuralle (Vectorize RAG) + Deepgram Aura TTS, all dialed over
5
+ // the Workers fetch-upgrade socket (createWorkersSocket) so no Node `ws` is pulled
6
+ // into the edge bundle. Turn-taking is owned by Deepgram endpointing
7
+ // (endpointingOwner: "provider_stt"), so no Silero VAD / Smart Turn ONNX is
8
+ // needed on the hot path.
9
+
10
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
11
+ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
12
+ import { DeepgramSTTPlugin, DeepgramTTSPlugin } from "@kuralle-syrinx/deepgram";
13
+ import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";
14
+ import type { VectorizeIndex } from "@cloudflare/workers-types";
15
+ import { createRealtimeKuralleReasoner } from "./kuralle-realtime-agent.js";
16
+
17
+ /** Provider secrets + optional tuning, supplied as Workers env/secret bindings. */
18
+ export interface LiveSessionEnv {
19
+ readonly DEEPGRAM_API_KEY?: string;
20
+ readonly OPENAI_API_KEY?: string;
21
+ readonly OPENAI_MODEL?: string;
22
+ readonly VECTORIZE: VectorizeIndex;
23
+ }
24
+
25
+ export interface LiveSessionOptions {
26
+ readonly sessionId?: string;
27
+ readonly inputSampleRateHz?: number;
28
+ readonly outputSampleRateHz?: number;
29
+ }
30
+
31
+ const DEFAULT_DEEPGRAM_TTS_MODEL = "aura-2-thalia-en";
32
+
33
+ /** True when every provider secret needed for a live turn is present. */
34
+ export function hasLiveSessionCredentials(env: LiveSessionEnv): boolean {
35
+ return Boolean(env.DEEPGRAM_API_KEY?.trim() && env.OPENAI_API_KEY?.trim() && env.VECTORIZE);
36
+ }
37
+
38
+ export async function createLiveVoiceAgentSession(
39
+ env: LiveSessionEnv,
40
+ options: LiveSessionOptions = {},
41
+ ): Promise<VoiceAgentSession> {
42
+ const deepgramKey = requireKey(env.DEEPGRAM_API_KEY, "DEEPGRAM_API_KEY");
43
+ requireKey(env.OPENAI_API_KEY, "OPENAI_API_KEY");
44
+ if (!env.VECTORIZE) throw new Error("VECTORIZE binding is required to start a live voice session");
45
+ const sessionId = options.sessionId?.trim() || crypto.randomUUID();
46
+ const inputSampleRateHz = options.inputSampleRateHz ?? 16000;
47
+ const outputSampleRateHz = options.outputSampleRateHz ?? 16000;
48
+
49
+ const session = new VoiceAgentSession({
50
+ plugins: {
51
+ stt: {
52
+ api_key: deepgramKey,
53
+ sample_rate: inputSampleRateHz,
54
+ model: "nova-3",
55
+ language: "en-US",
56
+ endpointing: 300,
57
+ provider_finalize_timeout_ms: 2500,
58
+ finalize_timeout_fallback: true,
59
+ // No local VAD on the edge: Deepgram SpeechStarted is the barge-in
60
+ // speech-start signal (vad.speech_started producer).
61
+ vad_events: true,
62
+ },
63
+ bridge: {},
64
+ tts: {
65
+ api_key: deepgramKey,
66
+ model: DEFAULT_DEEPGRAM_TTS_MODEL,
67
+ sample_rate: outputSampleRateHz,
68
+ },
69
+ },
70
+ sttForceFinalizeTimeoutMs: 3500,
71
+ endpointingOwner: "provider_stt",
72
+ });
73
+
74
+ const reasoner = await createRealtimeKuralleReasoner(env, { sessionId });
75
+ session.registerPlugin("stt", new DeepgramSTTPlugin(createWorkersSocket));
76
+ session.registerPlugin("bridge", new ReasoningBridge(reasoner));
77
+ session.registerPlugin("tts", new DeepgramTTSPlugin(createWorkersSocket));
78
+ return session;
79
+ }
80
+
81
+ function requireKey(value: string | undefined, name: string): string {
82
+ const trimmed = value?.trim();
83
+ if (!trimmed) throw new Error(`${name} is required to start a live voice session`);
84
+ return trimmed;
85
+ }
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { R2EdgeRecorder } from "./r2-recorder.js";
5
+
6
+ interface PutCall {
7
+ key: string;
8
+ body: Uint8Array | string;
9
+ }
10
+
11
+ function fakeBucket() {
12
+ const puts: PutCall[] = [];
13
+ const bucket = {
14
+ async put(key: string, body: Uint8Array | string) {
15
+ puts.push({ key, body });
16
+ return {} as unknown;
17
+ },
18
+ } as unknown as R2Bucket;
19
+ return { bucket, puts };
20
+ }
21
+
22
+ function asString(body: Uint8Array | string): string {
23
+ return typeof body === "string" ? body : new TextDecoder().decode(body);
24
+ }
25
+
26
+ function wavChannels(body: Uint8Array | string): number {
27
+ const wav = body as Uint8Array;
28
+ return new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint16(22, true);
29
+ }
30
+
31
+ describe("R2EdgeRecorder", () => {
32
+ it("writes a stereo conversation.wav plus user/assistant stems and manifest", async () => {
33
+ const { bucket, puts } = fakeBucket();
34
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "s1", startedAtMs: 1000, now: () => 1000 });
35
+
36
+ rec.onUserAudio("c1", new Uint8Array(640), 16000);
37
+ rec.onAssistantAudio("c1", new Uint8Array(320), 24000);
38
+ await rec.finalize({ sessionId: "s1", closedAtMs: 2000 });
39
+
40
+ const keys = puts.map((p) => p.key).sort();
41
+ expect(keys).toEqual([
42
+ "recordings/s1/1000/assistant.wav",
43
+ "recordings/s1/1000/conversation.wav",
44
+ "recordings/s1/1000/manifest.json",
45
+ "recordings/s1/1000/user.wav",
46
+ ]);
47
+
48
+ const conversation = puts.find((p) => p.key.endsWith("conversation.wav"))!.body;
49
+ expect(asString((conversation as Uint8Array).subarray(0, 4))).toBe("RIFF");
50
+ expect(wavChannels(conversation)).toBe(2); // stereo: user L / assistant R
51
+ expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
52
+
53
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
54
+ conversation: { channels: number };
55
+ };
56
+ expect(manifest.conversation.channels).toBe(2);
57
+ });
58
+
59
+ it("time-aligns the assistant after the user instead of stacking at 0", async () => {
60
+ const { bucket, puts } = fakeBucket();
61
+ let now = 0;
62
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "t", startedAtMs: 0, now: () => now });
63
+
64
+ now = 0;
65
+ rec.onUserAudio("c", new Uint8Array(640), 16000); // 20ms of user at t=0
66
+ now = 1000;
67
+ rec.onAssistantAudio("c", new Uint8Array(320), 16000); // assistant starts at t=1000ms
68
+ await rec.finalize({ sessionId: "t", closedAtMs: 1100 });
69
+
70
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
71
+ conversation: { durationMs: number };
72
+ assistant: { durationMs: number };
73
+ };
74
+ // Assistant anchored at ~1000ms, so both the assistant stem and the merged
75
+ // conversation run ~1010ms — not the ~20ms they'd be if stacked at offset 0.
76
+ expect(manifest.assistant.durationMs).toBe(1010);
77
+ expect(manifest.conversation.durationMs).toBe(1010);
78
+ });
79
+
80
+ it("does not write anything when no audio was captured", async () => {
81
+ const { bucket, puts } = fakeBucket();
82
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "empty", startedAtMs: 1 });
83
+ await rec.finalize({ sessionId: "empty", closedAtMs: 2 });
84
+ expect(puts).toHaveLength(0);
85
+ });
86
+
87
+ it("flags truncation past the per-stream cap instead of buffering unbounded", async () => {
88
+ const { bucket, puts } = fakeBucket();
89
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "big", startedAtMs: 1, maxBytesPerStream: 1000, now: () => 1 });
90
+ rec.onUserAudio("c", new Uint8Array(800), 16000);
91
+ rec.onUserAudio("c", new Uint8Array(800), 16000); // would exceed 1000 -> dropped
92
+ await rec.finalize({ sessionId: "big", closedAtMs: 2 });
93
+
94
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
95
+ user: { byteLength: number; truncated: boolean };
96
+ };
97
+ expect(manifest.user.byteLength).toBe(800);
98
+ expect(manifest.user.truncated).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,218 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // R2-backed implementation of the transport EdgeRecorder. Taps inbound caller
4
+ // audio and outbound TTS audio and, on call end, writes to an R2 bucket:
5
+ // - conversation.wav : the FULL conversation, one stereo file (user = left,
6
+ // assistant = right), time-aligned by wall-clock so the
7
+ // assistant sits after the user instead of stacked at 0.
8
+ // - user.wav / assistant.wav : the per-speaker stems (useful for diarization).
9
+ // - manifest.json : durations / byte lengths / truncation flags.
10
+ // Mirrors the Node `voice-recorder` conversation-track approach (wall-clock byte
11
+ // offsets + stereo interleave) but stays edge-safe (no node:fs). Cloudflare's own
12
+ // withVoice persists transcripts to SQLite, not raw audio — this is the additive
13
+ // piece. Buffered (memory-capped) and flushed once on finalize, off the hot path.
14
+
15
+ import type { EdgeRecorder } from "@kuralle-syrinx/server-websocket/edge";
16
+
17
+ export interface R2EdgeRecorderOptions {
18
+ readonly bucket: R2Bucket;
19
+ readonly sessionId: string;
20
+ readonly startedAtMs: number;
21
+ /** Object key prefix. Default "recordings". */
22
+ readonly keyPrefix?: string;
23
+ /** Per-stream memory cap; recording past it is dropped and flagged. Default 64 MiB. */
24
+ readonly maxBytesPerStream?: number;
25
+ /** Injectable clock (test seam). Defaults to Date.now. */
26
+ readonly now?: () => number;
27
+ }
28
+
29
+ const DEFAULT_MAX_BYTES_PER_STREAM = 64 * 1024 * 1024;
30
+
31
+ interface AudioChunk {
32
+ offsetBytes: number;
33
+ data: Uint8Array;
34
+ }
35
+
36
+ interface StreamBuffer {
37
+ chunks: AudioChunk[];
38
+ cursorBytes: number; // end of the last placed chunk on the wall-clock timeline
39
+ dataBytes: number; // actual audio bytes captured (for the cap)
40
+ sampleRateHz: number;
41
+ truncated: boolean;
42
+ }
43
+
44
+ function emptyStream(): StreamBuffer {
45
+ return { chunks: [], cursorBytes: 0, dataBytes: 0, sampleRateHz: 16000, truncated: false };
46
+ }
47
+
48
+ export class R2EdgeRecorder implements EdgeRecorder {
49
+ readonly #user = emptyStream();
50
+ readonly #assistant = emptyStream();
51
+ readonly #maxBytes: number;
52
+ readonly #now: () => number;
53
+ #finalized = false;
54
+
55
+ constructor(private readonly opts: R2EdgeRecorderOptions) {
56
+ this.#maxBytes = opts.maxBytesPerStream ?? DEFAULT_MAX_BYTES_PER_STREAM;
57
+ this.#now = opts.now ?? Date.now;
58
+ }
59
+
60
+ onUserAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
61
+ this.#append(this.#user, audio, sampleRateHz);
62
+ }
63
+
64
+ onAssistantAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
65
+ this.#append(this.#assistant, audio, sampleRateHz);
66
+ }
67
+
68
+ async finalize(meta: { sessionId: string; closedAtMs: number }): Promise<void> {
69
+ if (this.#finalized) return;
70
+ this.#finalized = true;
71
+ if (this.#user.dataBytes === 0 && this.#assistant.dataBytes === 0) return;
72
+
73
+ const prefix = `${this.opts.keyPrefix ?? "recordings"}/${this.opts.sessionId}/${this.opts.startedAtMs}`;
74
+ const rate = this.#user.sampleRateHz; // conversation timeline runs at the user (input) rate
75
+
76
+ const userPcm = gapFill(this.#user);
77
+ const assistantRaw = gapFill(this.#assistant);
78
+ const assistantPcm =
79
+ this.#assistant.sampleRateHz === rate
80
+ ? assistantRaw
81
+ : resamplePcm16(assistantRaw, this.#assistant.sampleRateHz, rate);
82
+ const conversation = interleaveStereo(userPcm, assistantPcm);
83
+
84
+ const manifest = {
85
+ schemaVersion: 1 as const,
86
+ sessionId: meta.sessionId,
87
+ startedAtMs: this.opts.startedAtMs,
88
+ closedAtMs: meta.closedAtMs,
89
+ conversation: {
90
+ path: `${prefix}/conversation.wav`,
91
+ sampleRateHz: rate,
92
+ channels: 2 as const,
93
+ encoding: "pcm_s16le" as const,
94
+ byteLength: conversation.byteLength,
95
+ durationMs: rate > 0 ? Math.round((conversation.byteLength / 4 / rate) * 1000) : 0,
96
+ },
97
+ user: this.#describe(this.#user, `${prefix}/user.wav`),
98
+ assistant: this.#describe(this.#assistant, `${prefix}/assistant.wav`),
99
+ };
100
+
101
+ await Promise.all([
102
+ this.opts.bucket.put(`${prefix}/conversation.wav`, pcm16ToWav(conversation, rate, 2), {
103
+ httpMetadata: { contentType: "audio/wav" },
104
+ }),
105
+ this.opts.bucket.put(`${prefix}/user.wav`, pcm16ToWav(userPcm, this.#user.sampleRateHz, 1), {
106
+ httpMetadata: { contentType: "audio/wav" },
107
+ }),
108
+ this.opts.bucket.put(`${prefix}/assistant.wav`, pcm16ToWav(assistantRaw, this.#assistant.sampleRateHz, 1), {
109
+ httpMetadata: { contentType: "audio/wav" },
110
+ }),
111
+ this.opts.bucket.put(`${prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
112
+ httpMetadata: { contentType: "application/json" },
113
+ }),
114
+ ]);
115
+ }
116
+
117
+ #append(buf: StreamBuffer, audio: Uint8Array, sampleRateHz: number): void {
118
+ buf.sampleRateHz = sampleRateHz;
119
+ if (buf.dataBytes + audio.byteLength > this.#maxBytes) {
120
+ buf.truncated = true;
121
+ return;
122
+ }
123
+ // Anchor each chunk at its wall-clock position so the two speakers line up on a
124
+ // shared timeline; never overlap the previous chunk in the same stream.
125
+ const wallOffset = this.#wallOffsetBytes(sampleRateHz);
126
+ const offsetBytes = Math.max(buf.cursorBytes, wallOffset);
127
+ buf.chunks.push({ offsetBytes, data: audio.slice() });
128
+ buf.cursorBytes = offsetBytes + audio.byteLength;
129
+ buf.dataBytes += audio.byteLength;
130
+ }
131
+
132
+ #wallOffsetBytes(sampleRateHz: number): number {
133
+ const elapsedMs = Math.max(0, this.#now() - this.opts.startedAtMs);
134
+ const bytes = Math.floor((elapsedMs * sampleRateHz * 2) / 1000);
135
+ return bytes - (bytes % 2);
136
+ }
137
+
138
+ #describe(buf: StreamBuffer, path: string) {
139
+ return {
140
+ path,
141
+ sampleRateHz: buf.sampleRateHz,
142
+ encoding: "pcm_s16le" as const,
143
+ channels: 1 as const,
144
+ byteLength: buf.cursorBytes,
145
+ durationMs: buf.sampleRateHz > 0 ? Math.round((buf.cursorBytes / 2 / buf.sampleRateHz) * 1000) : 0,
146
+ truncated: buf.truncated,
147
+ };
148
+ }
149
+ }
150
+
151
+ /** Lay chunks onto a silence-filled mono timeline at their wall-clock offsets. */
152
+ function gapFill(buf: StreamBuffer): Uint8Array {
153
+ const out = new Uint8Array(buf.cursorBytes); // zero = PCM16 silence
154
+ for (const chunk of buf.chunks) out.set(chunk.data, chunk.offsetBytes);
155
+ return out;
156
+ }
157
+
158
+ /** Interleave two mono PCM16 streams into stereo (left, right); pad the short one. */
159
+ function interleaveStereo(left: Uint8Array, right: Uint8Array): Uint8Array {
160
+ const leftSamples = left.byteLength >> 1;
161
+ const rightSamples = right.byteLength >> 1;
162
+ const frames = Math.max(leftSamples, rightSamples);
163
+ const out = new Uint8Array(frames * 4);
164
+ const lv = new DataView(left.buffer, left.byteOffset, left.byteLength);
165
+ const rv = new DataView(right.buffer, right.byteOffset, right.byteLength);
166
+ const ov = new DataView(out.buffer);
167
+ for (let i = 0; i < frames; i += 1) {
168
+ ov.setInt16(i * 4, i < leftSamples ? lv.getInt16(i * 2, true) : 0, true);
169
+ ov.setInt16(i * 4 + 2, i < rightSamples ? rv.getInt16(i * 2, true) : 0, true);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Array {
175
+ if (fromHz === toHz || pcm.byteLength === 0) return pcm;
176
+ const src = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
177
+ const inSamples = pcm.byteLength >> 1;
178
+ const outSamples = Math.max(1, Math.round((inSamples * toHz) / fromHz));
179
+ const out = new Uint8Array(outSamples * 2);
180
+ const ov = new DataView(out.buffer);
181
+ const ratio = (inSamples - 1) / Math.max(1, outSamples - 1);
182
+ for (let i = 0; i < outSamples; i += 1) {
183
+ const x = i * ratio;
184
+ const i0 = Math.floor(x);
185
+ const i1 = Math.min(inSamples - 1, i0 + 1);
186
+ const frac = x - i0;
187
+ const s = src.getInt16(i0 * 2, true) * (1 - frac) + src.getInt16(i1 * 2, true) * frac;
188
+ ov.setInt16(i * 2, Math.round(s), true);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** Wrap raw PCM16 little-endian bytes in a canonical 44-byte WAV header. */
194
+ function pcm16ToWav(pcm: Uint8Array, sampleRateHz: number, channels: number): Uint8Array {
195
+ const blockAlign = channels * 2; // 16-bit
196
+ const byteRate = sampleRateHz * blockAlign;
197
+ const out = new Uint8Array(44 + pcm.byteLength);
198
+ const view = new DataView(out.buffer);
199
+ writeAscii(view, 0, "RIFF");
200
+ view.setUint32(4, 36 + pcm.byteLength, true);
201
+ writeAscii(view, 8, "WAVE");
202
+ writeAscii(view, 12, "fmt ");
203
+ view.setUint32(16, 16, true);
204
+ view.setUint16(20, 1, true); // PCM
205
+ view.setUint16(22, channels, true);
206
+ view.setUint32(24, sampleRateHz, true);
207
+ view.setUint32(28, byteRate, true);
208
+ view.setUint16(32, blockAlign, true);
209
+ view.setUint16(34, 16, true);
210
+ writeAscii(view, 36, "data");
211
+ view.setUint32(40, pcm.byteLength, true);
212
+ out.set(pcm, 44);
213
+ return out;
214
+ }
215
+
216
+ function writeAscii(view: DataView, offset: number, text: string): void {
217
+ for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
218
+ }