voicecc 1.1.35 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/voicecc.js +94 -1
  2. package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
  3. package/dashboard/dist/index.html +1 -1
  4. package/dashboard/routes/agents.ts +28 -8
  5. package/dashboard/routes/browser-call.ts +3 -2
  6. package/dashboard/routes/chat.ts +75 -55
  7. package/dashboard/routes/providers.ts +5 -74
  8. package/dashboard/routes/twilio.ts +104 -5
  9. package/dashboard/routes/voice.ts +98 -0
  10. package/dashboard/server.ts +58 -2
  11. package/package.json +2 -3
  12. package/server/index.ts +96 -8
  13. package/server/services/device-pairing.ts +18 -2
  14. package/server/services/twilio-manager.ts +29 -10
  15. package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
  16. package/dashboard/dist/audio-processor.js +0 -126
  17. package/server/services/heartbeat.ts +0 -403
  18. package/server/voice/assets/chime.wav +0 -0
  19. package/server/voice/assets/startup.pcm +0 -0
  20. package/server/voice/audio-adapter.ts +0 -60
  21. package/server/voice/audio-inactivity.test.ts +0 -108
  22. package/server/voice/audio-inactivity.ts +0 -91
  23. package/server/voice/browser-audio-playback.test.ts +0 -149
  24. package/server/voice/browser-audio.ts +0 -147
  25. package/server/voice/browser-server.ts +0 -311
  26. package/server/voice/chat-server.ts +0 -236
  27. package/server/voice/chime.test.ts +0 -69
  28. package/server/voice/chime.ts +0 -36
  29. package/server/voice/claude-session.ts +0 -293
  30. package/server/voice/endpointing.ts +0 -163
  31. package/server/voice/mic-vpio +0 -0
  32. package/server/voice/narration.ts +0 -204
  33. package/server/voice/prompt-builder.ts +0 -108
  34. package/server/voice/session-lock.ts +0 -123
  35. package/server/voice/stt-elevenlabs.ts +0 -210
  36. package/server/voice/stt-provider.ts +0 -106
  37. package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
  38. package/server/voice/tts-elevenlabs.ts +0 -397
  39. package/server/voice/tts-provider.ts +0 -155
  40. package/server/voice/twilio-audio.ts +0 -338
  41. package/server/voice/twilio-server.ts +0 -540
  42. package/server/voice/types.ts +0 -282
  43. package/server/voice/vad.ts +0 -101
  44. package/server/voice/voice-loop-bugs.test.ts +0 -348
  45. package/server/voice/voice-server.ts +0 -129
  46. package/server/voice/voice-session.ts +0 -539
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Voice</title>
7
- <script type="module" crossorigin src="/assets/index-C62C9Gp0.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DCeOdulF.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DeN1WDo9.css">
9
9
  </head>
10
10
  <body>
@@ -21,7 +21,9 @@ import {
21
21
  importAgent,
22
22
  } from "../../server/services/agent-store.js";
23
23
  import type { AgentConfig } from "../../server/services/agent-store.js";
24
- import { getHeartbeatStatus, initiateAgentCall } from "../../server/services/heartbeat.js";
24
+
25
+ /** Base URL for the Python voice server API */
26
+ const VOICE_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
25
27
 
26
28
  // ============================================================================
27
29
  // ROUTES
@@ -36,10 +38,17 @@ export function agentsRoutes(): Hono {
36
38
  const app = new Hono();
37
39
 
38
40
  // /heartbeat/status and /import MUST be registered before /:id to avoid route conflict
39
- /** Get last heartbeat result per agent */
40
- app.get("/heartbeat/status", (c) => {
41
- const status = getHeartbeatStatus();
42
- return c.json(status);
41
+ /** Proxy heartbeat status from the Python voice server */
42
+ app.get("/heartbeat/status", async (c) => {
43
+ try {
44
+ const response = await fetch(`${VOICE_API_URL}/heartbeat/status`);
45
+ const data = await response.json();
46
+ return c.json(data);
47
+ } catch (err) {
48
+ const message = err instanceof Error ? err.message : "Proxy error";
49
+ console.error(`[agents] Error proxying heartbeat status: ${message}`);
50
+ return c.json({ error: "Voice server unavailable" }, 502);
51
+ }
43
52
  });
44
53
 
45
54
  /** Import an agent from a zip upload */
@@ -142,12 +151,23 @@ export function agentsRoutes(): Hono {
142
151
  }
143
152
  });
144
153
 
145
- /** Trigger outbound call for an agent */
154
+ /** Trigger outbound call for an agent via Python voice server */
146
155
  app.post("/:id/call", async (c) => {
147
156
  const id = c.req.param("id");
148
157
  try {
149
- const agent = await getAgent(id);
150
- await initiateAgentCall(agent, { initialPrompt: "The user pressed the 'Call Me' button. Greet them and ask how you can help." });
158
+ const response = await fetch(`${VOICE_API_URL}/register-call`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({
162
+ token: crypto.randomUUID(),
163
+ agent_id: id,
164
+ initial_prompt: "The user pressed the 'Call Me' button. Greet them and ask how you can help.",
165
+ }),
166
+ });
167
+ if (!response.ok) {
168
+ const data = await response.json();
169
+ throw new Error(data.error ?? "Voice server error");
170
+ }
151
171
  return c.json({ success: true });
152
172
  } catch (err) {
153
173
  return c.json({ error: (err as Error).message }, 400);
@@ -2,7 +2,7 @@
2
2
  * Browser call status API route.
3
3
  *
4
4
  * Browser calling is always enabled. This route exposes the call base URL
5
- * (tunnel URL when available, localhost otherwise).
5
+ * which now points through the dashboard (since tunnel targets dashboard port).
6
6
  *
7
7
  * - GET /status -- returns call base URL
8
8
  */
@@ -16,13 +16,14 @@ import { getCallBaseUrl } from "../../server/services/browser-call-manager.js";
16
16
 
17
17
  /**
18
18
  * Create Hono route group for browser call status.
19
+ * The call URL now routes through the dashboard voice proxy (/api/voice/).
19
20
  *
20
21
  * @returns Hono instance with status route
21
22
  */
22
23
  export function browserCallRoutes(): Hono {
23
24
  const app = new Hono();
24
25
 
25
- /** Get call base URL (tunnel or localhost) */
26
+ /** Get call base URL (tunnel or localhost, pointing at dashboard) */
26
27
  app.get("/status", (c) => {
27
28
  return c.json({ callBaseUrl: getCallBaseUrl() });
28
29
  });
@@ -1,20 +1,26 @@
1
1
  /**
2
- * Hono routes for text chat with agents via SSE.
2
+ * Hono routes for text chat -- proxied to the Python voice server.
3
3
  *
4
- * Thin route layer that delegates to the chat session manager in
5
- * server/voice/chat-server.ts. Handles request parsing, token validation,
6
- * and SSE streaming.
4
+ * Validates device tokens in Node.js, then proxies chat requests to the
5
+ * Python server's /chat/* endpoints. The Python server manages Claude
6
+ * sessions and streams responses.
7
7
  *
8
- * - POST /send: sends a message, streams response as SSE
9
- * - POST /close: explicitly closes a session
8
+ * - POST /send: proxies to Python /chat/send, returns SSE stream
9
+ * - POST /stop: proxies to Python /chat/stop
10
+ * - POST /close: proxies to Python /chat/close
10
11
  */
11
12
 
12
13
  import { Hono } from "hono";
13
- import { streamSSE } from "hono/streaming";
14
14
 
15
- import { getOrCreateSession, streamMessage, closeSession, interruptSession, hasSession } from "../../server/voice/chat-server.js";
16
15
  import { isValidDeviceToken } from "../../server/services/device-pairing.js";
17
16
 
17
+ // ============================================================================
18
+ // CONSTANTS
19
+ // ============================================================================
20
+
21
+ /** Base URL for the Python FastAPI server */
22
+ const VOICE_SERVER_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
23
+
18
24
  // ============================================================================
19
25
  // TYPES
20
26
  // ============================================================================
@@ -26,8 +32,8 @@ interface ChatSendBody {
26
32
  text: string;
27
33
  }
28
34
 
29
- /** Request body for POST /close */
30
- interface ChatCloseBody {
35
+ /** Request body for POST /stop and /close */
36
+ interface ChatTokenBody {
31
37
  token: string;
32
38
  }
33
39
 
@@ -37,13 +43,14 @@ interface ChatCloseBody {
37
43
 
38
44
  /**
39
45
  * Create Hono route group for text chat.
46
+ * Validates auth, then proxies to the Python server.
40
47
  *
41
- * @returns Hono instance with /send and /close routes
48
+ * @returns Hono instance with /send, /stop, and /close routes
42
49
  */
43
50
  export function chatRoutes(): Hono {
44
51
  const app = new Hono();
45
52
 
46
- /** POST /send - send a message and stream Claude's response as SSE */
53
+ /** POST /send - proxy to Python /chat/send, return SSE stream */
47
54
  app.post("/send", async (c) => {
48
55
  let body: ChatSendBody;
49
56
  try {
@@ -52,7 +59,6 @@ export function chatRoutes(): Hono {
52
59
  return c.json({ error: "Invalid JSON body" }, 400);
53
60
  }
54
61
 
55
- // Validate required fields
56
62
  if (!body.text || typeof body.text !== "string" || !body.text.trim()) {
57
63
  return c.json({ error: "Missing or empty 'text' field" }, 400);
58
64
  }
@@ -60,7 +66,7 @@ export function chatRoutes(): Hono {
60
66
  return c.json({ error: "Missing 'token' field" }, 400);
61
67
  }
62
68
 
63
- // Validate device token (localhost bypass via x-forwarded-for header set by voice-server proxy)
69
+ // Validate device token (localhost bypass via x-forwarded-for)
64
70
  const forwarded = c.req.header("x-forwarded-for") ?? "";
65
71
  const isLocalhost = forwarded === "127.0.0.1";
66
72
 
@@ -68,42 +74,42 @@ export function chatRoutes(): Hono {
68
74
  return c.json({ error: "Invalid device token" }, 401);
69
75
  }
70
76
 
71
- const sessionKey = body.token;
72
- const text = body.text.trim();
73
-
74
- // Get or create session
77
+ // Proxy to Python server
75
78
  try {
76
- await getOrCreateSession(sessionKey, body.agentId);
77
- } catch (err) {
78
- const msg = err instanceof Error ? err.message : "Failed to create session";
79
- console.error(`Failed to create chat session for token ${sessionKey}:`, err);
80
- return c.json({ error: msg }, 503);
81
- }
79
+ const response = await fetch(`${VOICE_SERVER_URL}/chat/send`, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ session_key: body.token,
84
+ agent_id: body.agentId,
85
+ text: body.text.trim(),
86
+ }),
87
+ });
82
88
 
83
- // Stream response as SSE
84
- try {
85
- const generator = streamMessage(sessionKey, text);
89
+ if (!response.ok && !response.headers.get("content-type")?.includes("text/event-stream")) {
90
+ const errorData = await response.json().catch(() => ({ error: "Voice server error" }));
91
+ return c.json(errorData, response.status as 400 | 409 | 500 | 503);
92
+ }
86
93
 
87
- return streamSSE(c, async (stream) => {
88
- for await (const event of generator) {
89
- await stream.writeSSE({ data: JSON.stringify(event) });
90
- }
91
- });
94
+ // Forward the SSE stream
95
+ const headers = new Headers();
96
+ headers.set("Content-Type", "text/event-stream");
97
+ headers.set("Cache-Control", "no-cache");
98
+ headers.set("Connection", "keep-alive");
99
+
100
+ return new Response(response.body, { status: 200, headers });
92
101
  } catch (err) {
93
- // streamMessage throws "ALREADY_STREAMING" if concurrent
94
- if (err instanceof Error && err.message === "ALREADY_STREAMING") {
95
- return c.json({ error: "Already streaming a response. Wait for it to complete." }, 409);
96
- }
97
- const msg = err instanceof Error ? err.message : "Stream error";
98
- return c.json({ error: msg }, 500);
102
+ const msg = err instanceof Error ? err.message : "Voice server unavailable";
103
+ console.error(`[chat-proxy] Error proxying /chat/send: ${msg}`);
104
+ return c.json({ error: "Voice server unavailable" }, 502);
99
105
  }
100
106
  });
101
107
 
102
- /** POST /stop - interrupt the current streaming response */
108
+ /** POST /stop - proxy to Python /chat/stop */
103
109
  app.post("/stop", async (c) => {
104
- let body: ChatCloseBody;
110
+ let body: ChatTokenBody;
105
111
  try {
106
- body = await c.req.json<ChatCloseBody>();
112
+ body = await c.req.json<ChatTokenBody>();
107
113
  } catch {
108
114
  return c.json({ error: "Invalid JSON body" }, 400);
109
115
  }
@@ -112,15 +118,27 @@ export function chatRoutes(): Hono {
112
118
  return c.json({ error: "Missing 'token' field" }, 400);
113
119
  }
114
120
 
115
- const interrupted = interruptSession(body.token);
116
- return c.json({ ok: true, interrupted });
121
+ try {
122
+ const response = await fetch(`${VOICE_SERVER_URL}/chat/stop`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ session_key: body.token }),
126
+ });
127
+
128
+ const data = await response.json();
129
+ return c.json(data, response.status as 200);
130
+ } catch (err) {
131
+ const msg = err instanceof Error ? err.message : "Voice server unavailable";
132
+ console.error(`[chat-proxy] Error proxying /chat/stop: ${msg}`);
133
+ return c.json({ error: "Voice server unavailable" }, 502);
134
+ }
117
135
  });
118
136
 
119
- /** POST /close - explicitly close a chat session */
137
+ /** POST /close - proxy to Python /chat/close */
120
138
  app.post("/close", async (c) => {
121
- let body: ChatCloseBody;
139
+ let body: ChatTokenBody;
122
140
  try {
123
- body = await c.req.json<ChatCloseBody>();
141
+ body = await c.req.json<ChatTokenBody>();
124
142
  } catch {
125
143
  return c.json({ error: "Invalid JSON body" }, 400);
126
144
  }
@@ -129,17 +147,19 @@ export function chatRoutes(): Hono {
129
147
  return c.json({ error: "Missing 'token' field" }, 400);
130
148
  }
131
149
 
132
- if (!hasSession(body.token)) {
133
- return c.json({ ok: true, message: "No active session" });
134
- }
135
-
136
150
  try {
137
- await closeSession(body.token);
138
- return c.json({ ok: true });
151
+ const response = await fetch(`${VOICE_SERVER_URL}/chat/close`, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({ session_key: body.token }),
155
+ });
156
+
157
+ const data = await response.json();
158
+ return c.json(data, response.status as 200);
139
159
  } catch (err) {
140
- const msg = err instanceof Error ? err.message : "Failed to close session";
141
- console.error(`Error closing chat session for token ${body.token}:`, err);
142
- return c.json({ error: msg }, 500);
160
+ const msg = err instanceof Error ? err.message : "Voice server unavailable";
161
+ console.error(`[chat-proxy] Error proxying /chat/close: ${msg}`);
162
+ return c.json({ error: "Voice server unavailable" }, 502);
143
163
  }
144
164
  });
145
165
 
@@ -1,23 +1,17 @@
1
1
  /**
2
2
  * Provider status API routes.
3
3
  *
4
- * Exposes TTS/STT provider information and readiness status:
5
- * - GET /tts -- list TTS providers with status
6
- * - GET /tts/status/:type -- check a specific TTS provider
7
- * - GET /stt -- list STT providers with status
8
- * - GET /stt/status/:type -- check a specific STT provider
4
+ * Exposes provider validation endpoints for the dashboard:
9
5
  * - GET /elevenlabs/validate -- validate the stored ElevenLabs API key
6
+ *
7
+ * TTS/STT provider selection has been removed -- Pipecat handles
8
+ * provider configuration in the Python voice server.
10
9
  */
11
10
 
12
11
  import { Hono } from "hono";
13
12
 
14
- import { getAvailableTtsProviders, getTtsProviderStatus, listVoicesForProvider } from "../../server/voice/tts-provider.js";
15
- import { getAvailableSttProviders, getSttProviderStatus } from "../../server/voice/stt-provider.js";
16
13
  import { readEnv } from "../../server/services/env.js";
17
14
 
18
- import type { TtsProviderType } from "../../server/voice/types.js";
19
- import type { SttProviderType } from "../../server/voice/types.js";
20
-
21
15
  // ============================================================================
22
16
  // ROUTES
23
17
  // ============================================================================
@@ -25,74 +19,11 @@ import type { SttProviderType } from "../../server/voice/types.js";
25
19
  /**
26
20
  * Create Hono route group for provider status operations.
27
21
  *
28
- * @returns Hono instance with TTS/STT status routes
22
+ * @returns Hono instance with provider validation routes
29
23
  */
30
24
  export function providersRoutes(): Hono {
31
25
  const app = new Hono();
32
26
 
33
- // ---- TTS routes ----
34
-
35
- /** List all TTS providers with their current status */
36
- app.get("/tts", async (c) => {
37
- const providers = getAvailableTtsProviders();
38
- const env = await readEnv();
39
- const active = env.TTS_PROVIDER || "elevenlabs";
40
-
41
- const providersWithStatus = await Promise.all(
42
- providers.map(async (p) => ({
43
- ...p,
44
- status: await getTtsProviderStatus(p.type),
45
- }))
46
- );
47
-
48
- return c.json({ providers: providersWithStatus, active });
49
- });
50
-
51
- /** Check readiness of a specific TTS provider */
52
- app.get("/tts/status/:type", async (c) => {
53
- const type = c.req.param("type") as TtsProviderType;
54
- const status = await getTtsProviderStatus(type);
55
- return c.json(status);
56
- });
57
-
58
- /** List available voices for a TTS provider */
59
- app.get("/tts/:type/voices", async (c) => {
60
- const type = c.req.param("type") as TtsProviderType;
61
- try {
62
- const voices = await listVoicesForProvider(type);
63
- return c.json({ voices });
64
- } catch (err) {
65
- return c.json({ error: (err as Error).message }, 400);
66
- }
67
- });
68
-
69
- // ---- STT routes ----
70
-
71
- /** List all STT providers with their current status */
72
- app.get("/stt", async (c) => {
73
- const providers = getAvailableSttProviders();
74
- const env = await readEnv();
75
- const active = env.STT_PROVIDER || "elevenlabs";
76
-
77
- const providersWithStatus = await Promise.all(
78
- providers.map(async (p) => ({
79
- ...p,
80
- status: await getSttProviderStatus(p.type),
81
- }))
82
- );
83
-
84
- return c.json({ providers: providersWithStatus, active });
85
- });
86
-
87
- /** Check readiness of a specific STT provider */
88
- app.get("/stt/status/:type", async (c) => {
89
- const type = c.req.param("type") as SttProviderType;
90
- const status = await getSttProviderStatus(type);
91
- return c.json(status);
92
- });
93
-
94
- // ---- ElevenLabs validation ----
95
-
96
27
  /** Check whether an ElevenLabs API key is configured in .env */
97
28
  app.get("/elevenlabs/validate", async (c) => {
98
29
  const env = await readEnv();
@@ -1,11 +1,16 @@
1
1
  /**
2
- * Twilio PSTN server management API routes.
2
+ * Twilio PSTN server management and webhook proxy API routes.
3
3
  *
4
- * Manages the Twilio voice server lifecycle for PSTN phone calls:
4
+ * Manages the Twilio integration lifecycle and proxies Twilio webhooks
5
+ * to the Python voice server. Keeps Twilio signature validation in Node.js.
6
+ *
7
+ * Responsibilities:
5
8
  * - GET /status -- server running state and tunnel URL
6
- * - POST /start -- start twilio server (requires tunnel)
7
- * - POST /stop -- stop twilio server
9
+ * - POST /start -- start twilio integration (requires tunnel)
10
+ * - POST /stop -- stop twilio integration
8
11
  * - GET /phone-numbers -- fetch phone numbers from Twilio API
12
+ * - POST /test-call -- place a test call
13
+ * - GET /heartbeat/status -- proxy heartbeat status from Python server
9
14
  */
10
15
 
11
16
  import { Hono } from "hono";
@@ -14,6 +19,13 @@ import { readEnv } from "../../server/services/env.js";
14
19
  import { startTwilioServer, stopTwilioServer, getStatus } from "../../server/services/twilio-manager.js";
15
20
  import { getTunnelUrl, isTunnelRunning } from "../../server/services/tunnel.js";
16
21
 
22
+ // ============================================================================
23
+ // CONSTANTS
24
+ // ============================================================================
25
+
26
+ /** Base URL for the Python FastAPI server */
27
+ const VOICE_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
28
+
17
29
  // ============================================================================
18
30
  // STATE
19
31
  // ============================================================================
@@ -38,7 +50,7 @@ export function setDashboardPort(port: number): void {
38
50
  /**
39
51
  * Create Hono route group for Twilio operations.
40
52
  *
41
- * @returns Hono instance with status, start, stop, phone-numbers routes
53
+ * @returns Hono instance with status, start, stop, phone-numbers, webhook proxy routes
42
54
  */
43
55
  export function twilioRoutes(): Hono {
44
56
  const app = new Hono();
@@ -143,5 +155,92 @@ export function twilioRoutes(): Hono {
143
155
  }
144
156
  });
145
157
 
158
+ /**
159
+ * Proxy Twilio incoming-call webhook to the Python server.
160
+ * Validates Twilio signature in Node.js before forwarding.
161
+ */
162
+ app.post("/incoming-call", async (c) => {
163
+ const envVars = await readEnv();
164
+ const authToken = envVars.TWILIO_AUTH_TOKEN;
165
+ const tunnelUrl = getTunnelUrl();
166
+
167
+ if (!authToken) {
168
+ console.log("[twilio] Rejected incoming call: TWILIO_AUTH_TOKEN not set");
169
+ return c.text("Server misconfigured", 500);
170
+ }
171
+
172
+ if (!tunnelUrl) {
173
+ console.log("[twilio] Rejected incoming call: no tunnel URL available");
174
+ return c.text("Server misconfigured", 500);
175
+ }
176
+
177
+ // Validate Twilio signature
178
+ const rawBody = await c.req.text();
179
+ const params = parseUrlEncodedBody(rawBody);
180
+ const webhookUrl = tunnelUrl.replace(/\/$/, "") + c.req.path;
181
+ const signature = c.req.header("x-twilio-signature") ?? "";
182
+
183
+ if (!signature || !twilioSdk.validateRequest(authToken, signature, webhookUrl, params)) {
184
+ console.log("[twilio] Rejected incoming call: invalid Twilio signature");
185
+ return c.text("Forbidden", 403);
186
+ }
187
+
188
+ // Proxy to Python server
189
+ try {
190
+ const response = await fetch(`${VOICE_API_URL}/twilio/incoming-call`, {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
193
+ body: rawBody,
194
+ });
195
+
196
+ const responseText = await response.text();
197
+ return new Response(responseText, {
198
+ status: response.status,
199
+ headers: { "Content-Type": response.headers.get("content-type") ?? "text/xml" },
200
+ });
201
+ } catch (err) {
202
+ const message = err instanceof Error ? err.message : "Proxy error";
203
+ console.error(`[twilio] Error proxying incoming-call: ${message}`);
204
+ return c.text("Voice server unavailable", 502);
205
+ }
206
+ });
207
+
208
+ /** Proxy heartbeat status from the Python server */
209
+ app.get("/heartbeat/status", async (c) => {
210
+ try {
211
+ const response = await fetch(`${VOICE_API_URL}/heartbeat/status`);
212
+ const data = await response.json();
213
+ return c.json(data);
214
+ } catch (err) {
215
+ const message = err instanceof Error ? err.message : "Proxy error";
216
+ console.error(`[twilio] Error proxying heartbeat status: ${message}`);
217
+ return c.json({ error: "Voice server unavailable" }, 502);
218
+ }
219
+ });
220
+
146
221
  return app;
147
222
  }
223
+
224
+ // ============================================================================
225
+ // HELPER FUNCTIONS
226
+ // ============================================================================
227
+
228
+ /**
229
+ * Parse a URL-encoded POST body into a key-value record.
230
+ *
231
+ * @param body - URL-encoded string
232
+ * @returns Record of decoded key-value pairs
233
+ */
234
+ function parseUrlEncodedBody(body: string): Record<string, string> {
235
+ const params: Record<string, string> = {};
236
+ if (!body) return params;
237
+
238
+ for (const pair of body.split("&")) {
239
+ const [key, value] = pair.split("=");
240
+ if (key) {
241
+ params[decodeURIComponent(key)] = decodeURIComponent(value ?? "");
242
+ }
243
+ }
244
+
245
+ return params;
246
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Proxy route group for voice/WebRTC signaling to the Python server.
3
+ *
4
+ * Validates device token auth in Node.js, then proxies WebRTC signaling
5
+ * requests to the Python SmallWebRTC server. The Python server is
6
+ * localhost-only; all external traffic goes through this proxy with auth.
7
+ *
8
+ * Responsibilities:
9
+ * - Validate device tokens on incoming requests
10
+ * - Proxy WebRTC signaling requests to the Python server (port 7860)
11
+ * - Read VOICE_SERVER_URL from environment
12
+ */
13
+
14
+ import { Hono } from "hono";
15
+
16
+ import { isValidDeviceToken } from "../../server/services/device-pairing.js";
17
+
18
+ // ============================================================================
19
+ // CONSTANTS
20
+ // ============================================================================
21
+
22
+ /** Base URL for the Python SmallWebRTC server */
23
+ const VOICE_SERVER_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7860";
24
+
25
+ // ============================================================================
26
+ // ROUTES
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Create Hono route group for voice proxy.
31
+ * Validates device token, then proxies WebRTC signaling to the Python server.
32
+ *
33
+ * @returns Hono instance with voice proxy routes
34
+ */
35
+ export function voiceRoutes(): Hono {
36
+ const app = new Hono();
37
+
38
+ /**
39
+ * Proxy all WebRTC signaling requests to the Python SmallWebRTC server.
40
+ * Supports both GET and POST for signaling endpoints like /offer, /ice, etc.
41
+ */
42
+ app.all("/*", async (c) => {
43
+ // Extract device token from query param or Authorization header
44
+ const tokenFromQuery = c.req.query("token");
45
+ const authHeader = c.req.header("authorization") ?? "";
46
+ const tokenFromHeader = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
47
+ const token = tokenFromQuery || tokenFromHeader;
48
+
49
+ if (!token || !isValidDeviceToken(token)) {
50
+ return c.json({ error: "Invalid device token" }, 401);
51
+ }
52
+
53
+ // Build the target URL on the Python server.
54
+ // Hono gives us the full path (e.g. /api/voice/offer). Strip the mount
55
+ // prefix (/api/voice) and prepend /api so it maps to Pipecat's /api/offer.
56
+ const fullPath = c.req.path;
57
+ const path = "/api" + fullPath.replace(/^\/api\/voice/, "");
58
+ const queryString = new URL(c.req.url).search;
59
+ const targetUrl = `${VOICE_SERVER_URL}${path}${queryString}`;
60
+
61
+ try {
62
+ const headers: Record<string, string> = {};
63
+ const contentType = c.req.header("content-type");
64
+ if (contentType) {
65
+ headers["Content-Type"] = contentType;
66
+ }
67
+
68
+ const fetchOptions: RequestInit = {
69
+ method: c.req.method,
70
+ headers,
71
+ };
72
+
73
+ // Forward body for non-GET requests
74
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
75
+ fetchOptions.body = await c.req.text();
76
+ }
77
+
78
+ const response = await fetch(targetUrl, fetchOptions);
79
+
80
+ // Forward the response back
81
+ const responseHeaders = new Headers();
82
+ response.headers.forEach((value, key) => {
83
+ responseHeaders.set(key, value);
84
+ });
85
+
86
+ return new Response(response.body, {
87
+ status: response.status,
88
+ headers: responseHeaders,
89
+ });
90
+ } catch (err) {
91
+ const msg = err instanceof Error ? err.message : "Proxy error";
92
+ console.error(`[voice-proxy] Error proxying to ${targetUrl}: ${msg}`);
93
+ return c.json({ error: "Voice server unavailable" }, 502);
94
+ }
95
+ });
96
+
97
+ return app;
98
+ }