Pepguy commited on
Commit
22cfb93
Β·
verified Β·
1 Parent(s): 33ad64d

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +325 -133
app.js CHANGED
@@ -24,6 +24,68 @@ const log = (tag, ...args) => {
24
 
25
  let callLogs = [];
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  // ─────────────────────────────────────────────
28
  // 1. EMBEDDED DASHBOARD
29
  // ─────────────────────────────────────────────
@@ -34,7 +96,7 @@ app.get("/", (req, res) => {
34
  <head>
35
  <meta charset="UTF-8">
36
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
- <title>Telecom Control Panel</title>
38
  <script src="https://cdn.tailwindcss.com"></script>
39
  <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
40
  <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
@@ -96,13 +158,16 @@ app.get("/", (req, res) => {
96
  <div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
97
  <table className="w-full text-left">
98
  <thead className="bg-gray-900 text-gray-400 text-sm">
99
- <tr><th className="p-4">Call ID</th><th className="p-4">Number</th><th className="p-4">Status</th><th className="p-4">Action</th><th className="p-4">Recording</th></tr>
100
  </thead>
101
  <tbody className="divide-y divide-gray-700">
102
  {logs.map(log => (
103
  <tr key={log.id}>
104
  <td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
105
  <td className="p-4 font-mono">{log.target_number}</td>
 
 
 
106
  <td className="p-4">
107
  <div className="flex flex-col gap-1">
108
  <span className={\`inline-block w-fit px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
@@ -116,9 +181,7 @@ app.get("/", (req, res) => {
116
  <button onClick={() => hangup(log.call_control_id)} className="bg-red-600 hover:bg-red-500 text-xs px-3 py-1 rounded font-bold transition">
117
  Force Hang Up
118
  </button>
119
- ) : (
120
- <span className="text-gray-500 text-xs">Ended</span>
121
- )}
122
  </td>
123
  <td className="p-4">
124
  {log.recording_url ?
@@ -128,7 +191,7 @@ app.get("/", (req, res) => {
128
  </td>
129
  </tr>
130
  ))}
131
- {logs.length === 0 && <tr><td colSpan="5" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
132
  </tbody>
133
  </table>
134
  </div>
@@ -142,16 +205,13 @@ app.get("/", (req, res) => {
142
  `);
143
  });
144
 
145
- app.get("/api/logs", (req, res) => {
146
- res.json([...callLogs].reverse());
147
- });
148
-
149
  app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
150
 
 
151
  app.get("/play-recording/:call_control_id", async (req, res) => {
152
  const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
153
  if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
154
-
155
  try {
156
  const s3Res = await fetch(logEntry.raw_telnyx_url);
157
  if (!s3Res.ok) return res.sendStatus(s3Res.status);
@@ -159,27 +219,22 @@ app.get("/play-recording/:call_control_id", async (req, res) => {
159
  res.setHeader("Content-Type", "audio/mpeg");
160
  res.setHeader("Content-Length", buffer.length);
161
  res.send(buffer);
162
- } catch (e) {
163
- res.sendStatus(500);
164
- }
165
  });
166
 
 
167
  app.post("/dial", async (req, res) => {
168
  const { to } = req.body;
169
  const voice = "Algieba";
170
  log("DIAL", `Dialing ${to}...`);
171
 
172
  try {
173
- const response = await fetch("https://api.telnyx.com/v2/calls", {
174
- method: "POST",
175
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
176
- body: JSON.stringify({
177
- connection_id: TELNYX_CONNECTION_ID,
178
- to,
179
- from: TELNYX_FROM_NUMBER,
180
- custom_headers: [{ name: "X-Voice-Selection", value: voice }]
181
- }),
182
- });
183
 
184
  const data = await response.json();
185
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
@@ -190,32 +245,26 @@ app.post("/dial", async (req, res) => {
190
  target_number: to,
191
  voice_used: voice,
192
  status: "dialing",
193
- has_spoken: false,
194
- is_playing: false, // Tracks if AI is currently making noise
195
  recording_url: null,
196
  raw_telnyx_url: null,
 
 
197
  created_at: new Date().toISOString()
198
  });
199
-
200
  res.json({ ok: true });
201
- } catch (err) {
202
- res.status(500).json({ ok: false, error: err.message });
203
- }
204
  });
205
 
 
206
  app.post("/call/hangup", async (req, res) => {
207
  const { call_control_id } = req.body;
208
- try {
209
- await fetch(`https://api.telnyx.com/v2/calls/${call_control_id}/actions/hangup`, {
210
- method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` }, body: JSON.stringify({}),
211
- });
212
- res.json({ ok: true });
213
- } catch (err) {
214
- res.status(500).json({ ok: false, error: err.message });
215
- }
216
  });
217
 
218
- // ─── TELNYX WEBHOOK (Barge-in & Smart Greeting) ──
219
  app.post("/webhook/call", async (req, res) => {
220
  const type = req.body?.data?.event_type;
221
  const payload = req.body?.data?.payload;
@@ -223,124 +272,267 @@ app.post("/webhook/call", async (req, res) => {
223
 
224
  if (!type) return res.sendStatus(200);
225
 
226
- const telnyxCmd = async (action, body = {}) => {
227
- try {
228
- const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
229
- method: "POST",
230
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
231
- body: JSON.stringify(body)
232
- });
233
- if (!response.ok) log("TELNYX CMD ERROR", `[${action}] failed (${response.status})`);
234
- return response;
235
- } catch (e) {
236
- log("TELNYX CMD NETWORK ERROR", e.message);
237
- }
238
- };
239
-
240
- const updateLog = (statusUpdates) => {
241
- const entry = callLogs.find(l => l.call_control_id === callId);
242
- if (entry) Object.assign(entry, statusUpdates);
243
- return entry;
244
- };
245
 
246
- try {
247
- switch (type) {
248
- case "call.answered":
249
- log("ANSWERED", `Call connected.`);
250
- const ansEntry = updateLog({ status: "in-progress", is_playing: false, has_spoken: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- await telnyxCmd("record_start", { format: "mp3", channels: "dual", play_beep: false });
253
- await telnyxCmd("transcription_start", { transcription_engine: "Telnyx", transcription_tracks: "inbound" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- // Smart Greeting: If the human says nothing for 3.5 seconds, we say "Hello?"
 
 
 
 
256
  setTimeout(async () => {
257
- const checkEntry = callLogs.find(l => l.call_control_id === callId);
258
- if (checkEntry && !checkEntry.has_spoken && checkEntry.status === "in-progress") {
259
- log("SILENCE_TIMEOUT", "Human is silent. Prompting with 'Hello.'");
260
- const voice = checkEntry.voice_used || "Algieba";
261
- const phrase = encodeURIComponent("Hello.");
262
- updateLog({ is_playing: true });
263
- await telnyxCmd("playback_start", {
264
- audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${phrase}`,
265
- target_legs: "self"
266
- });
 
 
267
  }
268
- }, 3500);
269
-
270
- // Safety net hangup
271
- setTimeout(async () => { await telnyxCmd("hangup", {}); }, 60000);
272
- break;
273
-
274
- case "call.transcription":
275
- const data = payload?.transcription_data;
276
- if (data) {
277
- updateLog({ has_spoken: true });
278
-
279
- // 1. THE BARGE-IN: User is speaking (interim result)
280
- if (!data.is_final) {
281
- const entry = callLogs.find(l => l.call_control_id === callId);
282
- // If the AI is currently playing audio, stop it immediately!
 
 
 
 
 
 
283
  if (entry && entry.is_playing) {
284
- log("BARGE-IN", "Human interrupted! Stopping AI playback.");
285
- updateLog({ is_playing: false });
286
- await telnyxCmd("playback_stop", {});
287
  }
288
- return res.sendStatus(200); // Exit early, wait for final transcript
289
  }
290
-
291
- // 2. TURN END: User finished speaking
292
- if (data.is_final) {
293
- const transcript = data.transcript;
294
- const entry = callLogs.find(l => l.call_control_id === callId);
 
 
 
 
 
 
 
 
 
 
295
  const voice = entry ? entry.voice_used : "Algieba";
296
- log("TRANSCRIPT", `User said: "${transcript}"`);
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  try {
299
- // Pass call_id so AI Server can remember the history
300
- const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
301
- method: "POST",
302
- headers: { "Content-Type": "application/json" },
303
- body: JSON.stringify({ text: transcript, voice: voice, call_id: callId })
304
  });
305
 
306
  if (aiRes.ok) {
307
  const aiData = await aiRes.json();
308
- if (aiData.speak_url) {
309
- log("AI_REPLY", `Playing reply.`);
310
- updateLog({ is_playing: true }); // Mark AI as speaking
311
- await telnyxCmd("playback_start", {
312
- audio_url: aiData.speak_url,
313
- target_legs: "self"
 
 
314
  });
 
 
315
  }
316
  }
317
  } catch (e) { log("AI_SERVER_ERROR", e.message); }
318
  }
319
  }
320
- break;
321
-
322
- // Track when the AI naturally finishes its sentence
323
- case "call.playback.ended":
324
- case "call.playback.stopped":
325
- updateLog({ is_playing: false });
326
- break;
327
-
328
- case "call.recording.saved":
329
- updateLog({ status: "completed", raw_telnyx_url: payload.recording_urls.mp3, recording_url: `/play-recording/${callId}` });
330
- break;
331
-
332
- case "call.hangup":
333
- updateLog({ status: "completed", is_playing: false });
334
- log("CALL ENDED", `Terminated.`);
335
- break;
336
- }
337
- } catch (fatalError) {
338
- log("WEBHOOK FATAL ERROR", fatalError.message);
339
  }
 
 
340
 
341
- res.sendStatus(200);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  });
343
 
344
  server.listen(PORT, "0.0.0.0", () => {
345
- log("STARTUP", `Telecom Server running on port ${PORT}`);
346
  });
 
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}`, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
33
+ body: JSON.stringify(body)
34
+ });
35
+ if (!response.ok) log("TELNYX CMD ERROR", `[${action}] failed (${response.status}): ${await response.text()}`);
36
+ return response;
37
+ } catch (e) {
38
+ log("TELNYX CMD NETWORK ERROR", e.message);
39
+ }
40
+ };
41
+
42
+ const updateLog = (callId, statusUpdates) => {
43
+ const entry = callLogs.find(l => l.call_control_id === callId);
44
+ if (entry) Object.assign(entry, statusUpdates);
45
+ return entry;
46
+ };
47
+
48
+ // ─── VAD MATH UTILITIES ───
49
+ const MU_LAW_TABLE = new Int16Array(256);
50
+ for (let i = 0; i < 256; i++) {
51
+ let sign = (i & 0x80) ? -1 : 1;
52
+ let exponent = (~i >> 4) & 0x07;
53
+ let mantissa = ~i & 0x0f;
54
+ let sample = ((mantissa << 3) + 132) << exponent;
55
+ MU_LAW_TABLE[i] = sign * (sample - 132);
56
+ }
57
+
58
+ function decodeMuLaw(buffer) {
59
+ const pcm16 = new Int16Array(buffer.length);
60
+ for (let i = 0; i < buffer.length; i++) { pcm16[i] = MU_LAW_TABLE[buffer[i]]; }
61
+ return pcm16;
62
+ }
63
+
64
+ function calculateRMS(pcm16Array) {
65
+ let sum = 0;
66
+ for (let i = 0; i < pcm16Array.length; i++) sum += pcm16Array[i] * pcm16Array[i];
67
+ return Math.sqrt(sum / pcm16Array.length);
68
+ }
69
+
70
+ function pcm16ToWav(pcm16Array, sampleRate = 8000) {
71
+ const pcmBuffer = Buffer.from(pcm16Array.buffer);
72
+ const header = Buffer.alloc(44);
73
+ header.write("RIFF", 0);
74
+ header.writeUInt32LE(36 + pcmBuffer.length, 4);
75
+ header.write("WAVE", 8);
76
+ header.write("fmt ", 12);
77
+ header.writeUInt32LE(16, 16);
78
+ header.writeUInt16LE(1, 20); // PCM
79
+ header.writeUInt16LE(1, 22); // Mono
80
+ header.writeUInt32LE(sampleRate, 24);
81
+ header.writeUInt32LE(sampleRate * 2, 28); // ByteRate
82
+ header.writeUInt16LE(2, 32); // BlockAlign
83
+ header.writeUInt16LE(16, 34); // BitsPerSample
84
+ header.write("data", 36);
85
+ header.writeUInt32LE(pcmBuffer.length, 40);
86
+ return Buffer.concat([header, pcmBuffer]);
87
+ }
88
+
89
  // ─────────────────────────────────────────────
90
  // 1. EMBEDDED DASHBOARD
91
  // ─────────────────────────────────────────────
 
96
  <head>
97
  <meta charset="UTF-8">
98
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
99
+ <title>Elite Telecom Control Panel</title>
100
  <script src="https://cdn.tailwindcss.com"></script>
101
  <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
102
  <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
 
158
  <div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
159
  <table className="w-full text-left">
160
  <thead className="bg-gray-900 text-gray-400 text-sm">
161
+ <tr><th className="p-4">Call ID</th><th className="p-4">Number</th><th className="p-4">Last Transcript</th><th className="p-4">Status</th><th className="p-4">Action</th><th className="p-4">Recording</th></tr>
162
  </thead>
163
  <tbody className="divide-y divide-gray-700">
164
  {logs.map(log => (
165
  <tr key={log.id}>
166
  <td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
167
  <td className="p-4 font-mono">{log.target_number}</td>
168
+ <td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={log.last_transcript}>
169
+ {log.last_transcript || "..."}
170
+ </td>
171
  <td className="p-4">
172
  <div className="flex flex-col gap-1">
173
  <span className={\`inline-block w-fit px-2 py-1 rounded text-xs \${log.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>
 
181
  <button onClick={() => hangup(log.call_control_id)} className="bg-red-600 hover:bg-red-500 text-xs px-3 py-1 rounded font-bold transition">
182
  Force Hang Up
183
  </button>
184
+ ) : <span className="text-gray-500 text-xs">Ended</span>}
 
 
185
  </td>
186
  <td className="p-4">
187
  {log.recording_url ?
 
191
  </td>
192
  </tr>
193
  ))}
194
+ {logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
195
  </tbody>
196
  </table>
197
  </div>
 
205
  `);
206
  });
207
 
208
+ app.get("/api/logs", (req, res) => res.json([...callLogs].reverse()));
 
 
 
209
  app.get("/health", (req, res) => res.json({ status: "ok", uptime: process.uptime() }));
210
 
211
+ // ─── PROXY FOR TELNYX RECORDINGS ─────────────
212
  app.get("/play-recording/:call_control_id", async (req, res) => {
213
  const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
214
  if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
 
215
  try {
216
  const s3Res = await fetch(logEntry.raw_telnyx_url);
217
  if (!s3Res.ok) return res.sendStatus(s3Res.status);
 
219
  res.setHeader("Content-Type", "audio/mpeg");
220
  res.setHeader("Content-Length", buffer.length);
221
  res.send(buffer);
222
+ } catch (e) { res.sendStatus(500); }
 
 
223
  });
224
 
225
+ // ─── DIAL OUT ROUTE ──────────────────────────
226
  app.post("/dial", async (req, res) => {
227
  const { to } = req.body;
228
  const voice = "Algieba";
229
  log("DIAL", `Dialing ${to}...`);
230
 
231
  try {
232
+ const response = await sendTelnyxCmd("", "v2/calls", {
233
+ connection_id: TELNYX_CONNECTION_ID,
234
+ to,
235
+ from: TELNYX_FROM_NUMBER,
236
+ custom_headers: [{ name: "X-Voice-Selection", value: voice }]
237
+ }, true);
 
 
 
 
238
 
239
  const data = await response.json();
240
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
 
245
  target_number: to,
246
  voice_used: voice,
247
  status: "dialing",
248
+ last_transcript: "",
 
249
  recording_url: null,
250
  raw_telnyx_url: null,
251
+ is_playing: false,
252
+ timers: {},
253
  created_at: new Date().toISOString()
254
  });
 
255
  res.json({ ok: true });
256
+ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
 
 
257
  });
258
 
259
+ // ─── MANUAL HANG UP ──────────────────────────
260
  app.post("/call/hangup", async (req, res) => {
261
  const { call_control_id } = req.body;
262
+ if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
263
+ await sendTelnyxCmd(call_control_id, "hangup");
264
+ res.json({ ok: true });
 
 
 
 
 
265
  });
266
 
267
+ // ─── TELNYX WEBHOOK (State Only, No STT) ──────
268
  app.post("/webhook/call", async (req, res) => {
269
  const type = req.body?.data?.event_type;
270
  const payload = req.body?.data?.payload;
 
272
 
273
  if (!type) return res.sendStatus(200);
274
 
275
+ const entry = callLogs.find(l => l.call_control_id === callId);
276
+ if (entry && entry.status === "completed" && type !== "call.recording.saved") {
277
+ return res.sendStatus(200);
278
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
+ switch (type) {
281
+ case "call.initiated":
282
+ log("INITIATED", `from=${payload?.from} to=${payload?.to}`);
283
+ if (payload?.direction === "incoming") {
284
+ log("INBOUND", "Answering inbound call...");
285
+ fetch(`${AI_SERVER_URL}/api/inbound-call`, {
286
+ method: "POST", headers: { "Content-Type": "application/json" },
287
+ body: JSON.stringify({ from: payload?.from, to: payload?.to, call_id: callId })
288
+ }).catch(() => {});
289
+
290
+ callLogs.push({
291
+ id: randomUUID(), call_control_id: callId, target_number: payload?.from,
292
+ voice_used: "Algieba", status: "in-progress", last_transcript: "", recording_url: null,
293
+ raw_telnyx_url: null, is_playing: false, timers: {}, created_at: new Date().toISOString()
294
+ });
295
+ await sendTelnyxCmd(callId, "answer");
296
+ }
297
+ break;
298
+
299
+ case "call.answered":
300
+ log("ANSWERED", `Call ${callId} connected.`);
301
+ updateLog(callId, { status: "in-progress" });
302
+
303
+ await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
304
+
305
+ // Start WebSocket Media Stream to listen to the caller natively
306
+ const wsUrl = `wss://${req.headers.host}/audio-stream`;
307
+ await sendTelnyxCmd(callId, "stream_start", { stream_url: wsUrl, track: "inbound_track" });
308
+
309
+ if (entry) {
310
+ entry.timers.hangup = setTimeout(async () => {
311
+ if (entry.status === "completed") return;
312
+ log("HANGUP", `Enforcing 60-second limit.`);
313
+ await sendTelnyxCmd(callId, "hangup");
314
+ }, 60000);
315
+ }
316
+ break;
317
+
318
+ case "call.playback.ended":
319
+ case "call.playback.stopped":
320
+ updateLog(callId, { is_playing: false });
321
+ break;
322
+
323
+ case "call.recording.saved":
324
+ log("RECORDING URL", `\n\n⬇️ DOWNLOAD RAW AUDIO HERE ⬇️\n${payload.recording_urls.mp3}\n`);
325
+ updateLog(callId, {
326
+ raw_telnyx_url: payload.recording_urls.mp3,
327
+ recording_url: `/play-recording/${callId}`
328
+ });
329
+ break;
330
+
331
+ case "call.hangup":
332
+ updateLog(callId, { status: "completed", is_playing: false });
333
+ if (entry && entry.timers) {
334
+ Object.values(entry.timers).forEach(clearTimeout);
335
+ entry.timers = {};
336
+ }
337
+ log("CALL ENDED", `Terminated.`);
338
+ break;
339
+ }
340
+ res.sendStatus(200);
341
+ });
342
 
343
+ // ─── WEBSOCKET (Zero-Dependency VAD & Turn Engine) ───
344
+ wss.on("connection", (ws, req) => {
345
+ log("WS", `VAD WebSocket connected from ${req.socket.remoteAddress}`);
346
+
347
+ let pcmChunks = [];
348
+ let isSpeaking = false;
349
+ let silenceFrames = 0;
350
+ let callControlId = null;
351
+ let isProcessingTurn = false;
352
+ let hasGreeted = false;
353
+
354
+ const SILENCE_THRESHOLD_FRAMES = 25; // 25 frames * 20ms = 500ms of silence
355
+ const VOLUME_THRESHOLD = 400; // RMS Volume Threshold
356
+
357
+ ws.on("message", async (raw) => {
358
+ try {
359
+ const msg = JSON.parse(raw);
360
 
361
+ if (msg.event === "start") {
362
+ callControlId = msg.start?.call_control_id;
363
+ log("WS_STREAM", `Receiving audio for call ${callControlId}`);
364
+
365
+ // Smart Initial Greeting
366
  setTimeout(async () => {
367
+ if (!hasGreeted && callControlId) {
368
+ const entry = callLogs.find(l => l.call_control_id === callControlId);
369
+ if (entry && entry.status === "in-progress") {
370
+ const voice = entry.voice_used || "Algieba";
371
+ log("SMART_GREETING", "No immediate human audio detected. Saying Hello.");
372
+ entry.is_playing = true;
373
+ hasGreeted = true;
374
+ await sendTelnyxCmd(callControlId, "playback_start", {
375
+ audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent("am I speaking with the business owner?")}`,
376
+ target_legs: "self"
377
+ });
378
+ }
379
  }
380
+ }, 1500);
381
+ }
382
+
383
+ if (msg.event === "media") {
384
+ const mulawBuffer = Buffer.from(msg.media.payload, "base64");
385
+ const pcm16 = decodeMuLaw(mulawBuffer);
386
+ const rms = calculateRMS(pcm16);
387
+
388
+ // ── 1. NOISE DETECTED (Human is speaking) ──
389
+ if (rms > VOLUME_THRESHOLD) {
390
+ hasGreeted = true; // They spoke, disable smart greeting timer
391
+ silenceFrames = 0;
392
+ pcmChunks.push(pcm16);
393
+
394
+ if (!isSpeaking) {
395
+ isSpeaking = true;
396
+ isProcessingTurn = false;
397
+ log("VAD", "Human started speaking.");
398
+
399
+ // ── BARGE IN ──
400
+ const entry = callLogs.find(l => l.call_control_id === callControlId);
401
  if (entry && entry.is_playing) {
402
+ log("BARGE-IN", "Interrupting AI playback.");
403
+ entry.is_playing = false;
404
+ await sendTelnyxCmd(callControlId, "playback_stop");
405
  }
 
406
  }
407
+ }
408
+
409
+ // ── 2. SILENCE DETECTED ──
410
+ else if (isSpeaking) {
411
+ pcmChunks.push(pcm16);
412
+ silenceFrames++;
413
+
414
+ // ── 3. SOFT TURN TRIGGER (500ms silence limit reached) ──
415
+ if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !isProcessingTurn) {
416
+ log("VAD", "500ms silence detected. Turn ended.");
417
+ isSpeaking = false;
418
+ isProcessingTurn = true;
419
+ silenceFrames = 0;
420
+
421
+ const entry = callLogs.find(l => l.call_control_id === callControlId);
422
  const voice = entry ? entry.voice_used : "Algieba";
 
423
 
424
+ // Compile WAV buffer for Groq
425
+ const totalLength = pcmChunks.reduce((acc, val) => acc + val.length, 0);
426
+ const finalPcm = new Int16Array(totalLength);
427
+ let offset = 0;
428
+ for (let chunk of pcmChunks) { finalPcm.set(chunk, offset); offset += chunk.length; }
429
+ pcmChunks = []; // Clear for next turn
430
+
431
+ // If the noise burst was less than ~200ms total, ignore it (mic bump/cough)
432
+ if (totalLength < 8000 * 0.2) {
433
+ isProcessingTurn = false;
434
+ return;
435
+ }
436
+
437
+ const wavBuffer = pcm16ToWav(finalPcm, 8000);
438
+
439
+ // ── IMMEDIATE BACKCHANNEL HACK (0ms perceived latency) ──
440
+ const fillers = ["Right.", "Got it.", "Okay."];
441
+ const filler = fillers[Math.floor(Math.random() * fillers.length)];
442
+
443
+ if (entry) entry.is_playing = true;
444
+ sendTelnyxCmd(callControlId, "playback_start", {
445
+ audio_url: `${AI_SERVER_URL}/play-audio?voice=${voice}&phrase=${encodeURIComponent(filler)}`,
446
+ target_legs: "self"
447
+ }).catch(()=>{});
448
+
449
+ // ── BACKGROUND AI PROCESSING ──
450
  try {
451
+ const aiRes = await fetch(`${AI_SERVER_URL}/api/process-audio-turn?voice=${voice}&call_id=${callControlId}`, {
452
+ method: "POST", headers: { "Content-Type": "audio/wav" }, body: wavBuffer
 
 
 
453
  });
454
 
455
  if (aiRes.ok) {
456
  const aiData = await aiRes.json();
457
+ if (aiData.transcript) updateLog(callControlId, { last_transcript: aiData.transcript });
458
+
459
+ // Check race condition: Did human interrupt the AI thinking?
460
+ if (!isSpeaking && aiData.speak_url) {
461
+ log("AI_REPLY", `Playing reply: ${aiData.raw_text}`);
462
+ if (entry) entry.is_playing = true;
463
+ await sendTelnyxCmd(callControlId, "playback_start", {
464
+ audio_url: aiData.speak_url, target_legs: "self"
465
  });
466
+ } else {
467
+ log("TURN_DROPPED", "User resumed speaking. Dropping response.");
468
  }
469
  }
470
  } catch (e) { log("AI_SERVER_ERROR", e.message); }
471
  }
472
  }
473
+ }
474
+ } catch (err) { log("WS PARSE ERROR", err.message); }
475
+ });
476
+ });
477
+
478
+ // Custom fetch wrapper for internal call initiation without full endpoint
479
+ const originalFetch = global.fetch;
480
+ global.fetch = async (url, options) => {
481
+ if (url === "https://api.telnyx.com/v2/calls" && options?.method === "POST" && options?.isCustomDial) {
482
+ const rawUrl = url;
483
+ delete options.isCustomDial;
484
+ return originalFetch(rawUrl, options);
 
 
 
 
 
 
 
485
  }
486
+ return originalFetch(url, options);
487
+ };
488
 
489
+ // Replace Dial API implementation to use standard fetch safely
490
+ app.post("/dial", async (req, res) => {
491
+ const { to } = req.body;
492
+ const voice = "Algieba";
493
+ log("DIAL", `Dialing ${to}...`);
494
+
495
+ try {
496
+ const response = await fetch("https://api.telnyx.com/v2/calls", {
497
+ method: "POST",
498
+ isCustomDial: true,
499
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
500
+ body: JSON.stringify({
501
+ connection_id: TELNYX_CONNECTION_ID,
502
+ to,
503
+ from: TELNYX_FROM_NUMBER,
504
+ custom_headers: [{ name: "X-Voice-Selection", value: voice }]
505
+ }),
506
+ });
507
+
508
+ const data = await response.json();
509
+ if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
510
+
511
+ callLogs.push({
512
+ id: randomUUID(),
513
+ call_control_id: data.data.call_control_id,
514
+ target_number: to,
515
+ voice_used: voice,
516
+ status: "dialing",
517
+ last_transcript: "",
518
+ recording_url: null,
519
+ raw_telnyx_url: null,
520
+ is_playing: false,
521
+ timers: {},
522
+ created_at: new Date().toISOString()
523
+ });
524
+
525
+ res.json({ ok: true });
526
+ } catch (err) {
527
+ res.status(500).json({ ok: false, error: err.message });
528
+ }
529
+ });
530
+
531
+ const requiredEnv = ["TELNYX_API_KEY", "TELNYX_CONNECTION_ID", "TELNYX_FROM_NUMBER", "AI_SERVER_URL"];
532
+ requiredEnv.forEach((key) => {
533
+ if (!process.env[key]) log("STARTUP WARNING", `Missing env var: ${key}`);
534
  });
535
 
536
  server.listen(PORT, "0.0.0.0", () => {
537
+ log("STARTUP", `Elite Telecom Server running on port ${PORT}`);
538
  });