Update app.js
Browse files
app.js
CHANGED
|
@@ -24,7 +24,6 @@ const log = (tag, ...args) => {
|
|
| 24 |
|
| 25 |
let callLogs = [];
|
| 26 |
|
| 27 |
-
// ─── UTILITY: GLOBAL TELNYX COMMAND DISPATCHER ───
|
| 28 |
const sendTelnyxCmd = async (callId, action, body = {}) => {
|
| 29 |
try {
|
| 30 |
const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
|
|
@@ -32,7 +31,6 @@ const sendTelnyxCmd = async (callId, action, body = {}) => {
|
|
| 32 |
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 33 |
body: JSON.stringify(body)
|
| 34 |
});
|
| 35 |
-
|
| 36 |
if (!response.ok) {
|
| 37 |
log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${await response.text()}`);
|
| 38 |
} else {
|
|
@@ -56,7 +54,6 @@ const clearCallTimers = (entry) => {
|
|
| 56 |
entry.timers = {};
|
| 57 |
};
|
| 58 |
|
| 59 |
-
// ─── VAD MATH UTILITIES ───
|
| 60 |
const MU_LAW_TABLE = new Int16Array(256);
|
| 61 |
for (let i = 0; i < 256; i++) {
|
| 62 |
let sign = (i & 0x80) ? -1 : 1;
|
|
@@ -78,9 +75,6 @@ function calculateRMS(pcm16Array) {
|
|
| 78 |
return Math.sqrt(sum / pcm16Array.length);
|
| 79 |
}
|
| 80 |
|
| 81 |
-
// ─────────────────────────────────────────────
|
| 82 |
-
// 1. EMBEDDED DASHBOARD
|
| 83 |
-
// ─────────────────────────────────────────────
|
| 84 |
app.get("/", (req, res) => {
|
| 85 |
res.send(`
|
| 86 |
<!DOCTYPE html>
|
|
@@ -209,11 +203,9 @@ app.get("/api/logs", (req, res) => {
|
|
| 209 |
|
| 210 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 211 |
|
| 212 |
-
// ─── PROXY FOR TELNYX RECORDINGS ───────────────────────────
|
| 213 |
app.get("/play-recording/:call_control_id", async (req, res) => {
|
| 214 |
const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
|
| 215 |
if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
|
| 216 |
-
|
| 217 |
try {
|
| 218 |
const s3Res = await fetch(logEntry.raw_telnyx_url);
|
| 219 |
if (!s3Res.ok) return res.sendStatus(s3Res.status);
|
|
@@ -226,11 +218,9 @@ app.get("/play-recording/:call_control_id", async (req, res) => {
|
|
| 226 |
}
|
| 227 |
});
|
| 228 |
|
| 229 |
-
// ─── DIAL OUT ROUTE ────────────────────────────────────────
|
| 230 |
app.post("/dial", async (req, res) => {
|
| 231 |
const { to } = req.body;
|
| 232 |
-
|
| 233 |
-
const voice = "Enceladus";
|
| 234 |
log("DIAL", `Dialing ${to}...`);
|
| 235 |
|
| 236 |
try {
|
|
@@ -268,7 +258,6 @@ app.post("/dial", async (req, res) => {
|
|
| 268 |
}
|
| 269 |
});
|
| 270 |
|
| 271 |
-
// ─── MANUAL HANG UP ────────────────────────────────────────
|
| 272 |
app.post("/call/hangup", async (req, res) => {
|
| 273 |
const { call_control_id } = req.body;
|
| 274 |
if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
|
|
@@ -281,7 +270,6 @@ app.post("/call/hangup", async (req, res) => {
|
|
| 281 |
}
|
| 282 |
});
|
| 283 |
|
| 284 |
-
// ─── HYBRID WEBHOOK (Telnyx STT Engine + VAD) ──────────────
|
| 285 |
app.post("/webhook/call", async (req, res) => {
|
| 286 |
const type = req.body?.data?.event_type;
|
| 287 |
const payload = req.body?.data?.payload;
|
|
@@ -307,7 +295,7 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 307 |
|
| 308 |
callLogs.push({
|
| 309 |
id: randomUUID(), call_control_id: callId, target_number: payload?.from,
|
| 310 |
-
voice_used: "
|
| 311 |
raw_telnyx_url: null, is_playing: false, timers: {}, created_at: new Date().toISOString()
|
| 312 |
});
|
| 313 |
await sendTelnyxCmd(callId, "answer");
|
|
@@ -316,7 +304,7 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 316 |
|
| 317 |
case "call.answered":
|
| 318 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 319 |
-
updateLog(callId, { status: "in-progress" });
|
| 320 |
|
| 321 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 322 |
|
|
@@ -351,9 +339,8 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 351 |
const data = payload?.transcription_data;
|
| 352 |
if (!data) break;
|
| 353 |
|
| 354 |
-
const voice = entry ? entry.voice_used : "
|
| 355 |
|
| 356 |
-
// 1. BARGE-IN (User is currently speaking)
|
| 357 |
if (!data.is_final) {
|
| 358 |
if (entry && entry.is_playing) {
|
| 359 |
log("BARGE-IN", "User is speaking. Shutting up AI.");
|
|
@@ -362,7 +349,6 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 362 |
}
|
| 363 |
}
|
| 364 |
|
| 365 |
-
// 2. TURN ENDED (User finished speaking)
|
| 366 |
if (data.is_final) {
|
| 367 |
const transcript = data.transcript.trim();
|
| 368 |
if (!transcript) break;
|
|
@@ -370,7 +356,6 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 370 |
log("TRANSCRIPT", `User said: "${transcript}"`);
|
| 371 |
updateLog(callId, { last_transcript: transcript });
|
| 372 |
|
| 373 |
-
// FETCH FULL AI RESPONSE
|
| 374 |
try {
|
| 375 |
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
|
| 376 |
method: "POST",
|
|
@@ -381,7 +366,7 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 381 |
if (aiRes.ok) {
|
| 382 |
const aiData = await aiRes.json();
|
| 383 |
if (aiData.speak_url && entry && entry.status !== "completed") {
|
| 384 |
-
log("AI_REPLY", `Playing full reply
|
| 385 |
updateLog(callId, { is_playing: true });
|
| 386 |
await sendTelnyxCmd(callId, "playback_start", {
|
| 387 |
audio_url: aiData.speak_url,
|
|
@@ -413,7 +398,6 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 413 |
res.sendStatus(200);
|
| 414 |
});
|
| 415 |
|
| 416 |
-
// ─── WEBSOCKET (VAD & Early Staller) ───
|
| 417 |
wss.on("connection", (ws, req) => {
|
| 418 |
log("WS", `VAD WebSocket connected`);
|
| 419 |
|
|
@@ -424,9 +408,8 @@ wss.on("connection", (ws, req) => {
|
|
| 424 |
let hasGreeted = false;
|
| 425 |
let totalNoiseFrames = 0;
|
| 426 |
|
| 427 |
-
|
| 428 |
-
const
|
| 429 |
-
const VOLUME_THRESHOLD = 500; // Require louder noise to prevent false barge-ins
|
| 430 |
|
| 431 |
ws.on("message", async (raw) => {
|
| 432 |
try {
|
|
@@ -435,12 +418,11 @@ wss.on("connection", (ws, req) => {
|
|
| 435 |
if (msg.event === "start") {
|
| 436 |
callControlId = msg.start?.call_control_id;
|
| 437 |
|
| 438 |
-
// Smart Initial Greeting
|
| 439 |
setTimeout(async () => {
|
| 440 |
if (!hasGreeted && callControlId) {
|
| 441 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 442 |
if (entry && entry.status === "in-progress") {
|
| 443 |
-
const voice = entry.voice_used || "
|
| 444 |
log("SMART_GREETING", "No immediate human audio detected. Saying Hello.");
|
| 445 |
entry.is_playing = true;
|
| 446 |
hasGreeted = true;
|
|
@@ -482,23 +464,26 @@ wss.on("connection", (ws, req) => {
|
|
| 482 |
hasPlayedFiller = true;
|
| 483 |
isSpeaking = false;
|
| 484 |
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
const voice = entry ? entry.voice_used : "Enceladus";
|
| 492 |
|
|
|
|
|
|
|
| 493 |
const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
|
| 494 |
const filler = fillers[Math.floor(Math.random() * fillers.length)];
|
| 495 |
-
|
| 496 |
if (entry) entry.is_playing = true;
|
| 497 |
sendTelnyxCmd(callControlId, "playback_start", {
|
| 498 |
audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent(filler)}`,
|
| 499 |
target_legs: "self"
|
| 500 |
-
}).catch(()=>{});
|
|
|
|
|
|
|
| 501 |
}
|
|
|
|
| 502 |
totalNoiseFrames = 0;
|
| 503 |
}
|
| 504 |
}
|
|
|
|
| 24 |
|
| 25 |
let callLogs = [];
|
| 26 |
|
|
|
|
| 27 |
const sendTelnyxCmd = async (callId, action, body = {}) => {
|
| 28 |
try {
|
| 29 |
const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
|
|
|
|
| 31 |
headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
|
| 32 |
body: JSON.stringify(body)
|
| 33 |
});
|
|
|
|
| 34 |
if (!response.ok) {
|
| 35 |
log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${await response.text()}`);
|
| 36 |
} else {
|
|
|
|
| 54 |
entry.timers = {};
|
| 55 |
};
|
| 56 |
|
|
|
|
| 57 |
const MU_LAW_TABLE = new Int16Array(256);
|
| 58 |
for (let i = 0; i < 256; i++) {
|
| 59 |
let sign = (i & 0x80) ? -1 : 1;
|
|
|
|
| 75 |
return Math.sqrt(sum / pcm16Array.length);
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
app.get("/", (req, res) => {
|
| 79 |
res.send(`
|
| 80 |
<!DOCTYPE html>
|
|
|
|
| 203 |
|
| 204 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 205 |
|
|
|
|
| 206 |
app.get("/play-recording/:call_control_id", async (req, res) => {
|
| 207 |
const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
|
| 208 |
if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
|
|
|
|
| 209 |
try {
|
| 210 |
const s3Res = await fetch(logEntry.raw_telnyx_url);
|
| 211 |
if (!s3Res.ok) return res.sendStatus(s3Res.status);
|
|
|
|
| 218 |
}
|
| 219 |
});
|
| 220 |
|
|
|
|
| 221 |
app.post("/dial", async (req, res) => {
|
| 222 |
const { to } = req.body;
|
| 223 |
+
const voice = "Enceladus_2";
|
|
|
|
| 224 |
log("DIAL", `Dialing ${to}...`);
|
| 225 |
|
| 226 |
try {
|
|
|
|
| 258 |
}
|
| 259 |
});
|
| 260 |
|
|
|
|
| 261 |
app.post("/call/hangup", async (req, res) => {
|
| 262 |
const { call_control_id } = req.body;
|
| 263 |
if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
|
|
|
|
| 270 |
}
|
| 271 |
});
|
| 272 |
|
|
|
|
| 273 |
app.post("/webhook/call", async (req, res) => {
|
| 274 |
const type = req.body?.data?.event_type;
|
| 275 |
const payload = req.body?.data?.payload;
|
|
|
|
| 295 |
|
| 296 |
callLogs.push({
|
| 297 |
id: randomUUID(), call_control_id: callId, target_number: payload?.from,
|
| 298 |
+
voice_used: "Enceladus_2", status: "in-progress", last_transcript: "", recording_url: null,
|
| 299 |
raw_telnyx_url: null, is_playing: false, timers: {}, created_at: new Date().toISOString()
|
| 300 |
});
|
| 301 |
await sendTelnyxCmd(callId, "answer");
|
|
|
|
| 304 |
|
| 305 |
case "call.answered":
|
| 306 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 307 |
+
updateLog(callId, { status: "in-progress", answered_at: new Date().toISOString() });
|
| 308 |
|
| 309 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 310 |
|
|
|
|
| 339 |
const data = payload?.transcription_data;
|
| 340 |
if (!data) break;
|
| 341 |
|
| 342 |
+
const voice = entry ? entry.voice_used : "Enceladus_2";
|
| 343 |
|
|
|
|
| 344 |
if (!data.is_final) {
|
| 345 |
if (entry && entry.is_playing) {
|
| 346 |
log("BARGE-IN", "User is speaking. Shutting up AI.");
|
|
|
|
| 349 |
}
|
| 350 |
}
|
| 351 |
|
|
|
|
| 352 |
if (data.is_final) {
|
| 353 |
const transcript = data.transcript.trim();
|
| 354 |
if (!transcript) break;
|
|
|
|
| 356 |
log("TRANSCRIPT", `User said: "${transcript}"`);
|
| 357 |
updateLog(callId, { last_transcript: transcript });
|
| 358 |
|
|
|
|
| 359 |
try {
|
| 360 |
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
|
| 361 |
method: "POST",
|
|
|
|
| 366 |
if (aiRes.ok) {
|
| 367 |
const aiData = await aiRes.json();
|
| 368 |
if (aiData.speak_url && entry && entry.status !== "completed") {
|
| 369 |
+
log("AI_REPLY", `Playing full reply.`);
|
| 370 |
updateLog(callId, { is_playing: true });
|
| 371 |
await sendTelnyxCmd(callId, "playback_start", {
|
| 372 |
audio_url: aiData.speak_url,
|
|
|
|
| 398 |
res.sendStatus(200);
|
| 399 |
});
|
| 400 |
|
|
|
|
| 401 |
wss.on("connection", (ws, req) => {
|
| 402 |
log("WS", `VAD WebSocket connected`);
|
| 403 |
|
|
|
|
| 408 |
let hasGreeted = false;
|
| 409 |
let totalNoiseFrames = 0;
|
| 410 |
|
| 411 |
+
const SILENCE_THRESHOLD_FRAMES = 37;
|
| 412 |
+
const VOLUME_THRESHOLD = 500;
|
|
|
|
| 413 |
|
| 414 |
ws.on("message", async (raw) => {
|
| 415 |
try {
|
|
|
|
| 418 |
if (msg.event === "start") {
|
| 419 |
callControlId = msg.start?.call_control_id;
|
| 420 |
|
|
|
|
| 421 |
setTimeout(async () => {
|
| 422 |
if (!hasGreeted && callControlId) {
|
| 423 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 424 |
if (entry && entry.status === "in-progress") {
|
| 425 |
+
const voice = entry.voice_used || "Enceladus_2";
|
| 426 |
log("SMART_GREETING", "No immediate human audio detected. Saying Hello.");
|
| 427 |
entry.is_playing = true;
|
| 428 |
hasGreeted = true;
|
|
|
|
| 464 |
hasPlayedFiller = true;
|
| 465 |
isSpeaking = false;
|
| 466 |
|
| 467 |
+
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 468 |
+
const voice = entry ? entry.voice_used : "Enceladus_2";
|
| 469 |
+
|
| 470 |
+
const callAge = entry?.answered_at
|
| 471 |
+
? Date.now() - new Date(entry.answered_at).getTime()
|
| 472 |
+
: 0;
|
|
|
|
| 473 |
|
| 474 |
+
if (totalNoiseFrames > 40 && callAge > 8000) {
|
| 475 |
+
log("VAD_EARLY_TURN", "Long sentence detected. Playing early staller...");
|
| 476 |
const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
|
| 477 |
const filler = fillers[Math.floor(Math.random() * fillers.length)];
|
|
|
|
| 478 |
if (entry) entry.is_playing = true;
|
| 479 |
sendTelnyxCmd(callControlId, "playback_start", {
|
| 480 |
audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent(filler)}`,
|
| 481 |
target_legs: "self"
|
| 482 |
+
}).catch(() => {});
|
| 483 |
+
} else {
|
| 484 |
+
log("VAD_BLACKOUT", `Suppressed filler (call age: ${callAge}ms)`);
|
| 485 |
}
|
| 486 |
+
|
| 487 |
totalNoiseFrames = 0;
|
| 488 |
}
|
| 489 |
}
|