Update app.js
Browse files
app.js
CHANGED
|
@@ -51,7 +51,7 @@ const updateLog = (callId, statusUpdates) => {
|
|
| 51 |
const clearCallTimers = (entry) => {
|
| 52 |
if (!entry || !entry.timers) return;
|
| 53 |
Object.values(entry.timers).forEach(clearTimeout);
|
| 54 |
-
entry.timers = {};
|
| 55 |
};
|
| 56 |
|
| 57 |
// βββ VAD MATH UTILITIES βββ
|
|
@@ -216,7 +216,16 @@ app.get("/", (req, res) => {
|
|
| 216 |
`);
|
| 217 |
});
|
| 218 |
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 221 |
|
| 222 |
// βββ PROXY FOR TELNYX RECORDINGS βββββββββββββββββββββββββββ
|
|
@@ -266,7 +275,7 @@ app.post("/dial", async (req, res) => {
|
|
| 266 |
recording_url: null,
|
| 267 |
raw_telnyx_url: null,
|
| 268 |
is_playing: false,
|
| 269 |
-
timers: {},
|
| 270 |
created_at: new Date().toISOString()
|
| 271 |
});
|
| 272 |
|
|
@@ -289,7 +298,7 @@ app.post("/call/hangup", async (req, res) => {
|
|
| 289 |
}
|
| 290 |
});
|
| 291 |
|
| 292 |
-
// βββ TELNYX WEBHOOK
|
| 293 |
app.post("/webhook/call", async (req, res) => {
|
| 294 |
const type = req.body?.data?.event_type;
|
| 295 |
const payload = req.body?.data?.payload;
|
|
@@ -299,7 +308,6 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 299 |
|
| 300 |
const entry = callLogs.find(l => l.call_control_id === callId);
|
| 301 |
|
| 302 |
-
// Local Lock: Ignore events for dead calls to prevent 422 errors
|
| 303 |
if (entry && entry.status === "completed" && type !== "call.recording.saved") {
|
| 304 |
return res.sendStatus(200);
|
| 305 |
}
|
|
@@ -327,21 +335,13 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 327 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 328 |
updateLog(callId, { status: "in-progress" });
|
| 329 |
|
| 330 |
-
// Start Dual-Channel Recording
|
| 331 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 332 |
|
| 333 |
-
// DEACTIVATED UNTIL YOU FIX AUDIO LEVELS:
|
| 334 |
-
/*
|
| 335 |
-
await sendTelnyxCmd(callId, "playback_start", {
|
| 336 |
-
audio_url: `https://${req.headers.host}/ambience.mp3`,
|
| 337 |
-
loop: "infinity",
|
| 338 |
-
target_legs: "self"
|
| 339 |
-
});
|
| 340 |
-
*/
|
| 341 |
-
|
| 342 |
// Start WebSocket Media Stream to listen to the caller natively via VAD
|
| 343 |
const wsUrl = `wss://${req.headers.host}/audio-stream`;
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
|
| 346 |
if (entry) {
|
| 347 |
entry.timers.hangup = setTimeout(async () => {
|
|
@@ -358,7 +358,6 @@ app.post("/webhook/call", async (req, res) => {
|
|
| 358 |
break;
|
| 359 |
|
| 360 |
case "call.recording.saved":
|
| 361 |
-
// THE PROMINENT RECORDING LOG YOU REQUESTED
|
| 362 |
const rawUrl = payload.recording_urls.mp3;
|
| 363 |
log("RECORDING URL", `\n\nβ¬οΈ DOWNLOAD RAW AUDIO HERE β¬οΈ\n${rawUrl}\n\n`);
|
| 364 |
|
|
@@ -388,8 +387,8 @@ wss.on("connection", (ws, req) => {
|
|
| 388 |
let isProcessingTurn = false;
|
| 389 |
let hasGreeted = false;
|
| 390 |
|
| 391 |
-
const SILENCE_THRESHOLD_FRAMES = 25;
|
| 392 |
-
const VOLUME_THRESHOLD = 400;
|
| 393 |
|
| 394 |
ws.on("message", async (raw) => {
|
| 395 |
try {
|
|
@@ -399,7 +398,6 @@ wss.on("connection", (ws, req) => {
|
|
| 399 |
callControlId = msg.start?.call_control_id;
|
| 400 |
log("WS_STREAM", `Receiving audio for call ${callControlId}`);
|
| 401 |
|
| 402 |
-
// Smart Initial Greeting
|
| 403 |
setTimeout(async () => {
|
| 404 |
if (!hasGreeted && callControlId) {
|
| 405 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
|
@@ -422,7 +420,6 @@ wss.on("connection", (ws, req) => {
|
|
| 422 |
const pcm16 = decodeMuLaw(mulawBuffer);
|
| 423 |
const rms = calculateRMS(pcm16);
|
| 424 |
|
| 425 |
-
// ββ 1. NOISE DETECTED (Human is speaking) ββ
|
| 426 |
if (rms > VOLUME_THRESHOLD) {
|
| 427 |
hasGreeted = true;
|
| 428 |
silenceFrames = 0;
|
|
@@ -433,7 +430,6 @@ wss.on("connection", (ws, req) => {
|
|
| 433 |
isProcessingTurn = false;
|
| 434 |
log("VAD", "Human started speaking.");
|
| 435 |
|
| 436 |
-
// ββ BARGE IN ββ
|
| 437 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 438 |
if (entry && entry.is_playing) {
|
| 439 |
log("BARGE-IN", "Interrupting AI playback.");
|
|
@@ -442,13 +438,10 @@ wss.on("connection", (ws, req) => {
|
|
| 442 |
}
|
| 443 |
}
|
| 444 |
}
|
| 445 |
-
|
| 446 |
-
// ββ 2. SILENCE DETECTED ββ
|
| 447 |
else if (isSpeaking) {
|
| 448 |
pcmChunks.push(pcm16);
|
| 449 |
silenceFrames++;
|
| 450 |
|
| 451 |
-
// ββ 3. SOFT TURN TRIGGER (500ms silence limit reached) ββ
|
| 452 |
if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !isProcessingTurn) {
|
| 453 |
log("VAD", "500ms silence detected. Turn ended.");
|
| 454 |
isSpeaking = false;
|
|
@@ -462,9 +455,8 @@ wss.on("connection", (ws, req) => {
|
|
| 462 |
const finalPcm = new Int16Array(totalLength);
|
| 463 |
let offset = 0;
|
| 464 |
for (let chunk of pcmChunks) { finalPcm.set(chunk, offset); offset += chunk.length; }
|
| 465 |
-
pcmChunks = [];
|
| 466 |
|
| 467 |
-
// Ignore tiny noise bursts (coughs, mic bumps)
|
| 468 |
if (totalLength < 8000 * 0.2) {
|
| 469 |
isProcessingTurn = false;
|
| 470 |
return;
|
|
@@ -472,7 +464,6 @@ wss.on("connection", (ws, req) => {
|
|
| 472 |
|
| 473 |
const wavBuffer = pcm16ToWav(finalPcm, 8000);
|
| 474 |
|
| 475 |
-
// ββ IMMEDIATE BACKCHANNEL HACK (0ms perceived latency) ββ
|
| 476 |
const fillers = ["Right.", "Got it.", "Okay."];
|
| 477 |
const filler = fillers[Math.floor(Math.random() * fillers.length)];
|
| 478 |
|
|
@@ -482,7 +473,6 @@ wss.on("connection", (ws, req) => {
|
|
| 482 |
target_legs: "self"
|
| 483 |
}).catch(()=>{});
|
| 484 |
|
| 485 |
-
// ββ BACKGROUND AI PROCESSING ββ
|
| 486 |
try {
|
| 487 |
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-audio-turn?voice=${voice}&call_id=${callControlId}`, {
|
| 488 |
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wavBuffer
|
|
@@ -492,7 +482,6 @@ wss.on("connection", (ws, req) => {
|
|
| 492 |
const aiData = await aiRes.json();
|
| 493 |
if (aiData.transcript) updateLog(callControlId, { last_transcript: aiData.transcript });
|
| 494 |
|
| 495 |
-
// Check race condition: Did human interrupt the AI while it was thinking?
|
| 496 |
if (!isSpeaking && aiData.speak_url) {
|
| 497 |
log("AI_REPLY", `Playing full reply.`);
|
| 498 |
if (entry) entry.is_playing = true;
|
|
|
|
| 51 |
const clearCallTimers = (entry) => {
|
| 52 |
if (!entry || !entry.timers) return;
|
| 53 |
Object.values(entry.timers).forEach(clearTimeout);
|
| 54 |
+
entry.timers = {}; // clear out the objects
|
| 55 |
};
|
| 56 |
|
| 57 |
// βββ VAD MATH UTILITIES βββ
|
|
|
|
| 216 |
`);
|
| 217 |
});
|
| 218 |
|
| 219 |
+
// ββ FIX: Strip Circular Timer Objects Before Sending to Dashboard ββ
|
| 220 |
+
app.get("/api/logs", (req, res) => {
|
| 221 |
+
const safeLogs = callLogs.map(entry => {
|
| 222 |
+
// Destructure to remove 'timers' from the object we send to the frontend
|
| 223 |
+
const { timers, ...safeEntry } = entry;
|
| 224 |
+
return safeEntry;
|
| 225 |
+
});
|
| 226 |
+
res.json(safeLogs.reverse());
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
|
| 230 |
|
| 231 |
// βββ PROXY FOR TELNYX RECORDINGS βββββββββββββββββββββββββββ
|
|
|
|
| 275 |
recording_url: null,
|
| 276 |
raw_telnyx_url: null,
|
| 277 |
is_playing: false,
|
| 278 |
+
timers: {}, // Timers object correctly initialized
|
| 279 |
created_at: new Date().toISOString()
|
| 280 |
});
|
| 281 |
|
|
|
|
| 298 |
}
|
| 299 |
});
|
| 300 |
|
| 301 |
+
// βββ TELNYX WEBHOOK ββββββββββββββββββββββββββββββββββββββββ
|
| 302 |
app.post("/webhook/call", async (req, res) => {
|
| 303 |
const type = req.body?.data?.event_type;
|
| 304 |
const payload = req.body?.data?.payload;
|
|
|
|
| 308 |
|
| 309 |
const entry = callLogs.find(l => l.call_control_id === callId);
|
| 310 |
|
|
|
|
| 311 |
if (entry && entry.status === "completed" && type !== "call.recording.saved") {
|
| 312 |
return res.sendStatus(200);
|
| 313 |
}
|
|
|
|
| 335 |
log("ANSWERED", `Call ${callId} connected.`);
|
| 336 |
updateLog(callId, { status: "in-progress" });
|
| 337 |
|
|
|
|
| 338 |
await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
// Start WebSocket Media Stream to listen to the caller natively via VAD
|
| 341 |
const wsUrl = `wss://${req.headers.host}/audio-stream`;
|
| 342 |
+
|
| 343 |
+
// Changed to `fork_start` as it's the more stable command for Telnyx V2 Media Streaming
|
| 344 |
+
await sendTelnyxCmd(callId, "fork_start", { stream_url: wsUrl, track: "inbound_track" });
|
| 345 |
|
| 346 |
if (entry) {
|
| 347 |
entry.timers.hangup = setTimeout(async () => {
|
|
|
|
| 358 |
break;
|
| 359 |
|
| 360 |
case "call.recording.saved":
|
|
|
|
| 361 |
const rawUrl = payload.recording_urls.mp3;
|
| 362 |
log("RECORDING URL", `\n\nβ¬οΈ DOWNLOAD RAW AUDIO HERE β¬οΈ\n${rawUrl}\n\n`);
|
| 363 |
|
|
|
|
| 387 |
let isProcessingTurn = false;
|
| 388 |
let hasGreeted = false;
|
| 389 |
|
| 390 |
+
const SILENCE_THRESHOLD_FRAMES = 25;
|
| 391 |
+
const VOLUME_THRESHOLD = 400;
|
| 392 |
|
| 393 |
ws.on("message", async (raw) => {
|
| 394 |
try {
|
|
|
|
| 398 |
callControlId = msg.start?.call_control_id;
|
| 399 |
log("WS_STREAM", `Receiving audio for call ${callControlId}`);
|
| 400 |
|
|
|
|
| 401 |
setTimeout(async () => {
|
| 402 |
if (!hasGreeted && callControlId) {
|
| 403 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
|
|
|
| 420 |
const pcm16 = decodeMuLaw(mulawBuffer);
|
| 421 |
const rms = calculateRMS(pcm16);
|
| 422 |
|
|
|
|
| 423 |
if (rms > VOLUME_THRESHOLD) {
|
| 424 |
hasGreeted = true;
|
| 425 |
silenceFrames = 0;
|
|
|
|
| 430 |
isProcessingTurn = false;
|
| 431 |
log("VAD", "Human started speaking.");
|
| 432 |
|
|
|
|
| 433 |
const entry = callLogs.find(l => l.call_control_id === callControlId);
|
| 434 |
if (entry && entry.is_playing) {
|
| 435 |
log("BARGE-IN", "Interrupting AI playback.");
|
|
|
|
| 438 |
}
|
| 439 |
}
|
| 440 |
}
|
|
|
|
|
|
|
| 441 |
else if (isSpeaking) {
|
| 442 |
pcmChunks.push(pcm16);
|
| 443 |
silenceFrames++;
|
| 444 |
|
|
|
|
| 445 |
if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !isProcessingTurn) {
|
| 446 |
log("VAD", "500ms silence detected. Turn ended.");
|
| 447 |
isSpeaking = false;
|
|
|
|
| 455 |
const finalPcm = new Int16Array(totalLength);
|
| 456 |
let offset = 0;
|
| 457 |
for (let chunk of pcmChunks) { finalPcm.set(chunk, offset); offset += chunk.length; }
|
| 458 |
+
pcmChunks = [];
|
| 459 |
|
|
|
|
| 460 |
if (totalLength < 8000 * 0.2) {
|
| 461 |
isProcessingTurn = false;
|
| 462 |
return;
|
|
|
|
| 464 |
|
| 465 |
const wavBuffer = pcm16ToWav(finalPcm, 8000);
|
| 466 |
|
|
|
|
| 467 |
const fillers = ["Right.", "Got it.", "Okay."];
|
| 468 |
const filler = fillers[Math.floor(Math.random() * fillers.length)];
|
| 469 |
|
|
|
|
| 473 |
target_legs: "self"
|
| 474 |
}).catch(()=>{});
|
| 475 |
|
|
|
|
| 476 |
try {
|
| 477 |
const aiRes = await fetch(`${AI_SERVER_URL}/api/process-audio-turn?voice=${voice}&call_id=${callControlId}`, {
|
| 478 |
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wavBuffer
|
|
|
|
| 482 |
const aiData = await aiRes.json();
|
| 483 |
if (aiData.transcript) updateLog(callControlId, { last_transcript: aiData.transcript });
|
| 484 |
|
|
|
|
| 485 |
if (!isSpeaking && aiData.speak_url) {
|
| 486 |
log("AI_REPLY", `Playing full reply.`);
|
| 487 |
if (entry) entry.is_playing = true;
|