voicecc 1.2.7 → 1.2.8

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { Hono } from "hono";
14
+ import twilioSdk from "twilio";
14
15
  import {
15
16
  listAgents,
16
17
  getAgent,
@@ -21,6 +22,8 @@ import {
21
22
  importAgent,
22
23
  } from "../../server/services/agent-store.js";
23
24
  import type { AgentConfig } from "../../server/services/agent-store.js";
25
+ import { readEnv } from "../../server/services/env.js";
26
+ import { getTunnelUrl } from "../../server/services/tunnel.js";
24
27
 
25
28
  /** Base URL for the Python voice server API */
26
29
  const VOICE_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
@@ -155,11 +158,30 @@ export function agentsRoutes(): Hono {
155
158
  app.post("/:id/call", async (c) => {
156
159
  const id = c.req.param("id");
157
160
  try {
161
+ const envVars = await readEnv();
162
+ const accountSid = envVars.TWILIO_ACCOUNT_SID;
163
+ const authToken = envVars.TWILIO_AUTH_TOKEN;
164
+ const userPhone = envVars.USER_PHONE_NUMBER;
165
+ const tunnelUrl = getTunnelUrl();
166
+
167
+ if (!accountSid || !authToken) {
168
+ return c.json({ error: "Twilio credentials not configured" }, 400);
169
+ }
170
+ if (!userPhone) {
171
+ return c.json({ error: "User phone number not configured" }, 400);
172
+ }
173
+ if (!tunnelUrl) {
174
+ return c.json({ error: "Tunnel is not running" }, 400);
175
+ }
176
+
177
+ const token = crypto.randomUUID();
178
+
179
+ // Register the token with the Python voice server
158
180
  const response = await fetch(`${VOICE_API_URL}/register-call`, {
159
181
  method: "POST",
160
182
  headers: { "Content-Type": "application/json" },
161
183
  body: JSON.stringify({
162
- token: crypto.randomUUID(),
184
+ token,
163
185
  agent_id: id,
164
186
  initial_prompt: "The user pressed the 'Call Me' button. Greet them and ask how you can help.",
165
187
  }),
@@ -168,7 +190,24 @@ export function agentsRoutes(): Hono {
168
190
  const data = await response.json();
169
191
  throw new Error(data.error ?? "Voice server error");
170
192
  }
171
- return c.json({ success: true });
193
+
194
+ // Place the actual Twilio call
195
+ const client = twilioSdk(accountSid, authToken);
196
+ const numbers = await client.incomingPhoneNumbers.list({ limit: 1 });
197
+ if (numbers.length === 0) {
198
+ return c.json({ error: "No Twilio phone numbers found on this account" }, 400);
199
+ }
200
+
201
+ const tunnelHost = tunnelUrl.replace(/^https?:\/\//, "");
202
+ const twiml = `<Response><Connect><Stream url="wss://${tunnelHost}/media/${token}?agentId=${id}" /></Connect></Response>`;
203
+
204
+ const call = await client.calls.create({
205
+ to: userPhone,
206
+ from: numbers[0].phoneNumber,
207
+ twiml,
208
+ });
209
+
210
+ return c.json({ success: true, callSid: call.sid });
172
211
  } catch (err) {
173
212
  return c.json({ error: (err as Error).message }, 400);
174
213
  }
@@ -16,10 +16,9 @@ import { readFileSync } from "fs";
16
16
  import { access } from "fs/promises";
17
17
  import { join } from "path";
18
18
  import { homedir } from "os";
19
- import { WebSocket as WsWebSocket, WebSocketServer } from "ws";
19
+ import { attachMediaProxy } from "./ws-proxy.js";
20
20
 
21
- import type { IncomingMessage } from "http";
22
- import type { Duplex } from "stream";
21
+ import type http from "http";
23
22
 
24
23
  import { claudeMdRoutes } from "./routes/claude-md.js";
25
24
  import { conversationRoutes } from "./routes/conversations.js";
@@ -142,42 +141,8 @@ export async function startDashboard(): Promise<number> {
142
141
  });
143
142
  server.on("error", reject);
144
143
 
145
- // Proxy /media/:token WebSocket upgrades to the Python server
146
- const wss = new WebSocketServer({ noServer: true });
147
- server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
148
- const url = req.url ?? "";
149
- const match = url.match(/^\/media\/([a-f0-9-]+)(?:\?.*)?$/);
150
- if (!match) return; // Not a Twilio media WebSocket -- let it fall through
151
-
152
- const targetWsUrl = VOICE_API_URL.replace(/^http/, "ws") + url;
153
- const upstream = new WsWebSocket(targetWsUrl);
154
-
155
- upstream.on("open", () => {
156
- wss.handleUpgrade(req, socket, head, (clientWs) => {
157
- // Bidirectional message proxy
158
- clientWs.on("message", (data) => {
159
- if (upstream.readyState === WsWebSocket.OPEN) {
160
- upstream.send(data);
161
- }
162
- });
163
- upstream.on("message", (data) => {
164
- if (clientWs.readyState === WsWebSocket.OPEN) {
165
- clientWs.send(data);
166
- }
167
- });
168
-
169
- clientWs.on("close", () => upstream.close());
170
- upstream.on("close", () => clientWs.close());
171
- clientWs.on("error", () => upstream.close());
172
- upstream.on("error", () => clientWs.close());
173
- });
174
- });
175
-
176
- upstream.on("error", (err) => {
177
- console.error(`[dashboard] Twilio WS proxy error: ${err.message}`);
178
- socket.destroy();
179
- });
180
- });
144
+ // Proxy /media/:token WebSocket upgrades to the Python voice server
145
+ attachMediaProxy(server as unknown as http.Server, VOICE_API_URL);
181
146
  });
182
147
 
183
148
  setDashboardPort(port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicecc",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,10 +21,16 @@ import os
21
21
  import aiohttp
22
22
  from fastapi import WebSocket
23
23
 
24
+ from pipecat.frames.frames import LLMFullResponseEndFrame, LLMFullResponseStartFrame
24
25
  from pipecat.pipeline.pipeline import Pipeline
25
26
  from pipecat.pipeline.runner import PipelineRunner
26
27
  from pipecat.pipeline.task import PipelineParams, PipelineTask
27
- from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
28
+ from pipecat.audio.vad.silero import SileroVADAnalyzer
29
+ from pipecat.processors.aggregators.llm_context import LLMContext
30
+ from pipecat.processors.aggregators.llm_response_universal import (
31
+ LLMContextAggregatorPair,
32
+ LLMUserAggregatorParams,
33
+ )
28
34
  from pipecat.serializers.twilio import TwilioFrameSerializer
29
35
  from pipecat.services.elevenlabs.stt import ElevenLabsSTTService
30
36
  from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
@@ -74,14 +80,18 @@ async def handle_twilio_websocket(websocket: WebSocket, call_token: str) -> None
74
80
  while True:
75
81
  message = await websocket.receive()
76
82
 
77
- # Skip binary frames (early audio before start)
78
83
  if message.get("type") == "websocket.disconnect":
79
84
  logger.warning("[twilio] WebSocket disconnected before start event")
80
85
  return
81
- if "text" not in message:
86
+
87
+ # Twilio may send frames as text or binary
88
+ raw = message.get("text") or (
89
+ message.get("bytes", b"").decode("utf-8") if message.get("bytes") else None
90
+ )
91
+ if not raw:
82
92
  continue
83
93
 
84
- msg = json.loads(message["text"])
94
+ msg = json.loads(raw)
85
95
 
86
96
  if msg.get("event") == "start":
87
97
  start_data = msg.get("start", {})
@@ -224,8 +234,13 @@ async def _run_twilio_pipeline(
224
234
  narration = NarrationProcessor()
225
235
 
226
236
  # Context aggregator
227
- context = OpenAILLMContext(messages=[], tools=[])
228
- context_aggregator = claude_llm.create_context_aggregator(context)
237
+ context = LLMContext()
238
+ context_aggregator = LLMContextAggregatorPair(
239
+ context,
240
+ user_params=LLMUserAggregatorParams(
241
+ vad_analyzer=SileroVADAnalyzer(),
242
+ ),
243
+ )
229
244
 
230
245
  # Pipeline
231
246
  pipeline = Pipeline(
@@ -246,16 +261,15 @@ async def _run_twilio_pipeline(
246
261
  params=PipelineParams(allow_interruptions=True),
247
262
  )
248
263
 
249
- # For Twilio, the WebSocket is already connected, so send the
250
- # initial prompt shortly after the pipeline starts.
251
- async def _send_initial_prompt():
252
- await asyncio.sleep(1) # Let the pipeline fully initialize
264
+ # Send initial prompt once the pipeline is fully ready
265
+ @task.event_handler("on_pipeline_started")
266
+ async def on_pipeline_started(task_ref, *args):
253
267
  if llm_config.initial_prompt and not claude_llm._initial_prompt_sent:
254
268
  claude_llm._initial_prompt_sent = True
255
269
  await claude_llm._ensure_client()
270
+ await claude_llm.push_frame(LLMFullResponseStartFrame())
256
271
  await claude_llm._send_to_claude(llm_config.initial_prompt)
257
-
258
- asyncio.create_task(_send_initial_prompt())
272
+ await claude_llm.push_frame(LLMFullResponseEndFrame())
259
273
 
260
274
  runner = PipelineRunner()
261
275
  await runner.run(task)