Pepguy commited on
Commit
bcc20d5
Β·
verified Β·
1 Parent(s): 5a99304

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +226 -218
app.js CHANGED
@@ -12,206 +12,177 @@ app.use(express.json());
12
  app.use(express.urlencoded({ extended: true }));
13
 
14
  const PORT = process.env.PORT || 7860;
15
- const TELNYX_API_KEY = process.env.TELNYX_API_KEY;
16
  const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
17
- const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
18
- const AI_SERVER_URL = process.env.AI_SERVER_URL;
19
 
20
- const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
21
 
 
22
  let callLogs = [];
23
 
24
- // ── FIX: Automatically log the start time of any playback for Echo Suppression ──
25
  const sendTelnyxCmd = async (callId, action, body = {}) => {
26
  if (action === "playback_start") {
27
  updateLog(callId, { is_playing: true, playback_started_at: Date.now() });
28
  }
29
-
30
  try {
31
  const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
32
  method: "POST",
33
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
34
  body: JSON.stringify(body)
35
  });
36
- if (!response.ok) {
37
- log("TELNYX CMD ERROR", `[${action}] failed: ${await response.text()}`);
38
- } else {
39
- log("TELNYX CMD SUCCESS", `[${action}] accepted.`);
40
- }
41
  return response;
42
- } catch (e) {
43
- log("TELNYX CMD NETWORK ERROR", e.message);
44
- }
45
  };
46
 
47
- const updateLog = (callId, statusUpdates) => {
48
  const entry = callLogs.find(l => l.call_control_id === callId);
49
- if (entry) Object.assign(entry, statusUpdates);
50
  return entry;
51
  };
52
 
53
- // VAD MATH UTILITIES
54
  const MU_LAW_TABLE = new Int16Array(256);
55
  for (let i = 0; i < 256; i++) {
56
- let sign = (i & 0x80) ? -1 : 1;
57
- let exponent = (~i >> 4) & 0x07;
58
- let mantissa = ~i & 0x0f;
59
- let sample = ((mantissa << 3) + 132) << exponent;
60
  MU_LAW_TABLE[i] = sign * (sample - 132);
61
  }
62
  function decodeMuLaw(buffer) {
63
- const pcm16 = new Int16Array(buffer.length);
64
- for (let i = 0; i < buffer.length; i++) { pcm16[i] = MU_LAW_TABLE[buffer[i]]; }
65
- return pcm16;
66
  }
67
- function calculateRMS(pcm16Array) {
68
  let sum = 0;
69
- for (let i = 0; i < pcm16Array.length; i++) sum += pcm16Array[i] * pcm16Array[i];
70
- return Math.sqrt(sum / pcm16Array.length);
71
  }
72
 
73
- // ─────────────────────────────────────────────
74
- // 1. EMBEDDED DASHBOARD & PROXY
75
- // ─────────────────────────────────────────────
76
  app.get("/", (req, res) => {
77
- res.send(`
78
- <!DOCTYPE html>
79
- <html lang="en">
80
- <head>
81
- <meta charset="UTF-8">
82
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
83
- <title>Elite Telecom Control Panel</title>
84
- <script src="https://cdn.tailwindcss.com"></script>
85
- <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
86
- <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
87
- <script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
88
- </head>
89
- <body class="bg-gray-900 text-gray-100 p-8 font-sans">
90
- <div id="root" class="max-w-4xl mx-auto"></div>
91
- <script type="text/babel">
92
- const { useState, useEffect } = React;
93
- function App() {
94
- const [logs, setLogs] = useState([]);
95
- const [targetNumber, setTargetNumber] = useState("+1");
96
- const [status, setStatus] = useState("");
97
-
98
- const fetchLogs = () => fetch("/api/logs").then(r => r.json()).then(setLogs);
99
-
100
- useEffect(() => {
101
- fetchLogs();
102
- const interval = setInterval(fetchLogs, 2000);
103
- return () => clearInterval(interval);
104
- }, []);
105
-
106
- const dial = async () => {
107
- setStatus("Dialing...");
108
- const res = await fetch("/dial", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: targetNumber }) });
109
- const data = await res.json();
110
- setStatus(data.ok ? "Call initiated!" : "Error: " + data.error);
111
- fetchLogs();
112
- };
113
-
114
- const hangup = async (callControlId) => {
115
- setStatus("Hanging up...");
116
- await fetch("/call/hangup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_control_id: callControlId }) });
117
- fetchLogs();
118
- };
119
-
120
- return (
121
- <div className="space-y-8">
122
- <h1 className="text-3xl font-bold border-b border-gray-700 pb-4">Web Agency Telecom Dashboard</h1>
123
- <div className="bg-gray-800 p-6 rounded-lg shadow-lg flex gap-4 items-end">
124
- <div className="flex-1">
125
- <label className="block text-sm text-gray-400 mb-1">Target Phone Number</label>
126
- <input type="text" value={targetNumber} onChange={e => setTargetNumber(e.target.value)} className="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-blue-500" />
127
- </div>
128
- <button onClick={dial} className="bg-blue-600 hover:bg-blue-500 px-8 py-2 rounded font-bold transition">Trigger Outbound Call</button>
129
- </div>
130
- {status && <div className="text-sm text-yellow-400">{status}</div>}
131
- <div className="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
132
- <table className="w-full text-left">
133
- <thead className="bg-gray-900 text-gray-400 text-sm">
134
- <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>
135
- </thead>
136
- <tbody className="divide-y divide-gray-700">
137
- {logs.map(log => (
138
- <tr key={log.id}>
139
- <td className="p-4 text-xs font-mono text-gray-500">{log.call_control_id.slice(0,8)}...</td>
140
- <td className="p-4 font-mono">{log.target_number}</td>
141
- <td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={log.last_transcript}>{log.last_transcript || "..."}</td>
142
- <td className="p-4">
143
- <div className="flex flex-col gap-1">
144
- <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'}\`}>{log.status}</span>
145
- {log.is_playing && <span className="text-xs text-purple-400 animate-pulse">AI is speaking...</span>}
146
- </div>
147
- </td>
148
- <td className="p-4">
149
- {log.status === 'in-progress' || log.status === 'dialing' ? (
150
- <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">Force Hang Up</button>
151
- ) : <span className="text-gray-500 text-xs">Ended</span>}
152
- </td>
153
- <td className="p-4">
154
- {log.recording_url ? <audio controls src={log.recording_url} className="h-8 w-48"></audio> : <span className="text-gray-500 text-sm">Recording...</span>}
155
- </td>
156
- </tr>
157
- ))}
158
- {logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls registered yet</td></tr>}
159
- </tbody>
160
- </table>
161
- </div>
162
  </div>
163
- );
164
- }
165
- ReactDOM.createRoot(document.getElementById('root')).render(<App />);
166
- </script>
167
- </body>
168
- </html>
169
- `);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  });
171
 
172
  app.get("/api/logs", (req, res) => {
173
- const safeLogs = callLogs.map(entry => { const { timers, ...safeEntry } = entry; return safeEntry; });
174
- res.json(safeLogs.reverse());
175
  });
176
 
177
  app.get("/health", (req, res) => res.json({ status: "ok" }));
178
 
179
- app.get("/play-recording/:call_control_id", async (req, res) => {
180
- const logEntry = callLogs.find(l => l.call_control_id === req.params.call_control_id);
181
- if (!logEntry || !logEntry.raw_telnyx_url) return res.sendStatus(404);
182
  try {
183
- const s3Res = await fetch(logEntry.raw_telnyx_url);
184
- if (!s3Res.ok) return res.sendStatus(s3Res.status);
185
- const buffer = Buffer.from(await s3Res.arrayBuffer());
186
  res.setHeader("Content-Type", "audio/mpeg");
187
- res.setHeader("Content-Length", buffer.length);
188
- res.send(buffer);
189
- } catch (e) { res.sendStatus(500); }
190
  });
191
 
192
  app.post("/dial", async (req, res) => {
193
  const { to } = req.body;
194
- const voice = "Enceladus_2";
195
  log("DIAL", `Dialing ${to}...`);
196
-
197
  try {
198
  const response = await fetch("https://api.telnyx.com/v2/calls", {
199
  method: "POST",
200
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
201
  body: JSON.stringify({
202
  connection_id: TELNYX_CONNECTION_ID,
203
- to,
204
- from: TELNYX_FROM_NUMBER,
205
- custom_headers: [{ name: "X-Voice-Selection", value: voice }]
206
- }),
207
  });
208
  const data = await response.json();
209
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
210
 
211
  callLogs.push({
212
- id: randomUUID(), call_control_id: data.data.call_control_id, target_number: to,
213
- voice_used: voice, status: "dialing", last_transcript: "", recording_url: null,
214
- raw_telnyx_url: null, is_playing: false, playback_started_at: null, timers: {}, created_at: new Date().toISOString()
 
 
215
  });
216
  res.json({ ok: true });
217
  } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
@@ -219,44 +190,51 @@ app.post("/dial", async (req, res) => {
219
 
220
  app.post("/call/hangup", async (req, res) => {
221
  const { call_control_id } = req.body;
 
222
  await sendTelnyxCmd(call_control_id, "hangup");
223
  res.json({ ok: true });
224
  });
225
 
226
- // ─── HYBRID WEBHOOK ──────────────────────────────────────────
227
  app.post("/webhook/call", async (req, res) => {
228
- const type = req.body?.data?.event_type;
229
  const payload = req.body?.data?.payload;
230
- const callId = payload?.call_control_id;
231
-
232
  if (!type) return res.sendStatus(200);
233
 
234
  const entry = callLogs.find(l => l.call_control_id === callId);
235
- if (entry && entry.status === "completed" && type !== "call.recording.saved") return res.sendStatus(200);
236
 
237
  switch (type) {
238
  case "call.initiated":
 
239
  if (payload?.direction === "incoming") {
240
  callLogs.push({
241
  id: randomUUID(), call_control_id: callId, target_number: payload?.from,
242
- voice_used: "Enceladus_2", status: "in-progress", last_transcript: "", recording_url: null,
243
- raw_telnyx_url: null, is_playing: false, playback_started_at: null, timers: {}, created_at: new Date().toISOString()
 
244
  });
245
  await sendTelnyxCmd(callId, "answer");
246
  }
247
  break;
248
 
249
  case "call.answered":
 
250
  updateLog(callId, { status: "in-progress", answered_at: new Date().toISOString() });
251
  await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
252
-
253
- const wsUrl = `wss://${req.headers.host}/audio-stream`;
254
- await sendTelnyxCmd(callId, "streaming_start", { stream_url: wsUrl, stream_track: "inbound_track" });
255
- await sendTelnyxCmd(callId, "transcription_start", { transcription_engine: "Telnyx", transcription_tracks: "inbound" });
256
-
 
 
 
257
  if (entry) {
258
  entry.timers.hangup = setTimeout(async () => {
259
  if (entry.status === "completed") return;
 
260
  await sendTelnyxCmd(callId, "hangup");
261
  }, 90000);
262
  }
@@ -268,33 +246,35 @@ app.post("/webhook/call", async (req, res) => {
268
  break;
269
 
270
  case "call.transcription":
271
- const data = payload?.transcription_data;
272
- if (!data) break;
273
-
274
- const voice = entry ? entry.voice_used : "Enceladus_2";
275
-
276
- if (!data.is_final) {
277
- if (entry && entry.is_playing) {
278
- log("BARGE-IN", "User is speaking. Shutting up AI.");
279
- updateLog(callId, { is_playing: false });
280
- await sendTelnyxCmd(callId, "playback_stop");
281
- }
282
  }
283
 
284
- if (data.is_final) {
285
- const transcript = data.transcript.trim();
286
- if (!transcript) break;
 
287
  updateLog(callId, { last_transcript: transcript });
288
 
289
  try {
290
  const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
291
- method: "POST", headers: { "Content-Type": "application/json" },
292
- body: JSON.stringify({ text: transcript, voice: voice, call_id: callId })
 
293
  });
294
  if (aiRes.ok) {
295
  const aiData = await aiRes.json();
296
- if (aiData.speak_url && entry && entry.status !== "completed") {
297
- await sendTelnyxCmd(callId, "playback_start", { audio_url: aiData.speak_url, target_legs: "self" });
 
 
 
298
  }
299
  }
300
  } catch (e) { log("AI_SERVER_ERROR", e.message); }
@@ -302,30 +282,35 @@ app.post("/webhook/call", async (req, res) => {
302
  break;
303
 
304
  case "call.recording.saved":
305
- log("RECORDING URL", `\n\n⬇️ DOWNLOAD RAW AUDIO HERE ⬇️\n${payload.recording_urls.mp3}\n\n`);
306
- updateLog(callId, { raw_telnyx_url: payload.recording_urls.mp3, recording_url: `/play-recording/${callId}` });
 
307
  break;
308
 
309
  case "call.hangup":
310
  updateLog(callId, { status: "completed", is_playing: false });
311
- if (entry && entry.timers) Object.values(entry.timers).forEach(clearTimeout);
 
312
  break;
313
  }
314
  res.sendStatus(200);
315
  });
316
 
317
- // ─── WEBSOCKET (VAD & ECHO SUPPRESSION) ─────────
318
- wss.on("connection", (ws, req) => {
319
- let isSpeaking = false;
320
- let silenceFrames = 0;
321
- let callControlId = null;
322
- let hasPlayedFiller = false;
323
- let hasGreeted = false;
 
 
324
  let totalNoiseFrames = 0;
 
325
 
326
- const SILENCE_THRESHOLD_FRAMES = 37;
327
- const VOLUME_THRESHOLD = 500;
328
- const ECHO_GRACE_PERIOD = 1500; // Ignore volume spikes for 1.5s after AI starts talking
329
 
330
  ws.on("message", async (raw) => {
331
  try {
@@ -333,76 +318,99 @@ wss.on("connection", (ws, req) => {
333
 
334
  if (msg.event === "start") {
335
  callControlId = msg.start?.call_control_id;
336
-
337
- // Smart Greeting
 
 
338
  setTimeout(async () => {
339
- if (!hasGreeted && callControlId) {
340
- const entry = callLogs.find(l => l.call_control_id === callControlId);
341
- if (entry && entry.status === "in-progress") {
342
- log("SMART_GREETING", "Saying Hello.");
343
- hasGreeted = true;
344
- await sendTelnyxCmd(callControlId, "playback_start", {
345
- audio_url: `${AI_SERVER_URL}/play-audio?voice=${entry.voice_used}&phrase=${encodeURIComponent("am I speaking with the business owner?")}`,
346
- target_legs: "self"
347
- });
348
- }
349
- }
 
 
 
 
 
 
 
 
350
  }, 1500);
351
  }
352
 
353
  if (msg.event === "media") {
354
  const pcm16 = decodeMuLaw(Buffer.from(msg.media.payload, "base64"));
355
- const rms = calculateRMS(pcm16);
356
 
357
- const entry = callLogs.find(l => l.call_control_id === callControlId);
358
- const timeSincePlayback = entry?.playback_started_at ? (Date.now() - entry.playback_started_at) : 9999;
 
 
359
  const isEchoWindow = timeSincePlayback < ECHO_GRACE_PERIOD;
360
 
 
 
 
 
 
 
 
 
 
 
361
  if (rms > VOLUME_THRESHOLD) {
362
- // ── ECHO SUPPRESSION: Ignore spike if AI just started talking ──
363
  if (isEchoWindow) return;
364
 
365
- hasGreeted = true;
366
  silenceFrames = 0;
367
  totalNoiseFrames++;
368
 
369
  if (!isSpeaking) {
370
- isSpeaking = true;
371
  hasPlayedFiller = false;
372
-
373
- if (entry && entry.is_playing) {
374
- log("BARGE-IN", "User is speaking. Interrupting AI playback.");
375
  entry.is_playing = false;
376
  await sendTelnyxCmd(callControlId, "playback_stop");
377
  }
378
  }
379
- }
380
- else if (isSpeaking) {
381
  silenceFrames++;
382
 
383
  if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !hasPlayedFiller) {
384
  hasPlayedFiller = true;
385
- isSpeaking = false;
386
-
387
- const callAge = entry?.answered_at ? Date.now() - new Date(entry.answered_at).getTime() : 0;
 
 
388
 
389
  if (totalNoiseFrames > 40 && callAge > 8000) {
390
- log("VAD_EARLY_TURN", "Playing early staller...");
391
  const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
392
- const filler = fillers[Math.floor(Math.random() * fillers.length)];
393
  sendTelnyxCmd(callControlId, "playback_start", {
394
- audio_url: `${AI_SERVER_URL}/play-audio?voice=${entry.voice_used}&phrase=${encodeURIComponent(filler)}`,
395
  target_legs: "self"
396
  }).catch(() => {});
397
  }
398
- totalNoiseFrames = 0;
399
  }
400
  }
401
  }
402
- } catch (err) { }
403
  });
404
  });
405
 
406
  server.listen(PORT, "0.0.0.0", () => {
407
- log("STARTUP", `Elite Telecom Server running on port ${PORT}`);
408
  });
 
12
  app.use(express.urlencoded({ extended: true }));
13
 
14
  const PORT = process.env.PORT || 7860;
15
+ const TELNYX_API_KEY = process.env.TELNYX_API_KEY;
16
  const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
17
+ const TELNYX_FROM_NUMBER = process.env.TELNYX_FROM_NUMBER;
18
+ const AI_SERVER_URL = process.env.AI_SERVER_URL;
19
 
20
+ const ACTIVE_VOICE = "Enceladus_3";
21
 
22
+ const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
23
  let callLogs = [];
24
 
 
25
  const sendTelnyxCmd = async (callId, action, body = {}) => {
26
  if (action === "playback_start") {
27
  updateLog(callId, { is_playing: true, playback_started_at: Date.now() });
28
  }
 
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}] ${await response.text()}`);
36
+ else log("TELNYX CMD SUCCESS", `[${action}] accepted.`);
 
 
 
37
  return response;
38
+ } catch (e) { log("TELNYX CMD NETWORK ERROR", e.message); }
 
 
39
  };
40
 
41
+ const updateLog = (callId, updates) => {
42
  const entry = callLogs.find(l => l.call_control_id === callId);
43
+ if (entry) Object.assign(entry, updates);
44
  return entry;
45
  };
46
 
47
+ // ── MU-LAW VAD ──
48
  const MU_LAW_TABLE = new Int16Array(256);
49
  for (let i = 0; i < 256; i++) {
50
+ const sign = (i & 0x80) ? -1 : 1;
51
+ const exponent = (~i >> 4) & 0x07;
52
+ const mantissa = ~i & 0x0f;
53
+ const sample = ((mantissa << 3) + 132) << exponent;
54
  MU_LAW_TABLE[i] = sign * (sample - 132);
55
  }
56
  function decodeMuLaw(buffer) {
57
+ const out = new Int16Array(buffer.length);
58
+ for (let i = 0; i < buffer.length; i++) out[i] = MU_LAW_TABLE[buffer[i]];
59
+ return out;
60
  }
61
+ function calculateRMS(pcm) {
62
  let sum = 0;
63
+ for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
64
+ return Math.sqrt(sum / pcm.length);
65
  }
66
 
67
+ // ── DASHBOARD ──
 
 
68
  app.get("/", (req, res) => {
69
+ res.send(`<!DOCTYPE html>
70
+ <html lang="en">
71
+ <head>
72
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
73
+ <title>Elite Telecom Control Panel</title>
74
+ <script src="https://cdn.tailwindcss.com"></script>
75
+ <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
76
+ <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
77
+ <script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
78
+ </head>
79
+ <body class="bg-gray-900 text-gray-100 p-8 font-sans">
80
+ <div id="root" class="max-w-4xl mx-auto"></div>
81
+ <script type="text/babel">
82
+ const { useState, useEffect } = React;
83
+ function App() {
84
+ const [logs, setLogs] = useState([]);
85
+ const [targetNumber, setTargetNumber] = useState("+1");
86
+ const [status, setStatus] = useState("");
87
+ const fetchLogs = () => fetch("/api/logs").then(r => r.json()).then(setLogs);
88
+ useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 2000); return () => clearInterval(t); }, []);
89
+ const dial = async () => {
90
+ setStatus("Dialing...");
91
+ const res = await fetch("/dial", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: targetNumber }) });
92
+ const data = await res.json();
93
+ setStatus(data.ok ? "Call initiated!" : "Error: " + data.error);
94
+ fetchLogs();
95
+ };
96
+ const hangup = async (id) => {
97
+ await fetch("/call/hangup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_control_id: id }) });
98
+ fetchLogs();
99
+ };
100
+ return (
101
+ <div className="space-y-8">
102
+ <h1 className="text-3xl font-bold border-b border-gray-700 pb-4">Web Agency Telecom Dashboard</h1>
103
+ <div className="bg-gray-800 p-6 rounded-lg flex gap-4 items-end">
104
+ <div className="flex-1">
105
+ <label className="block text-sm text-gray-400 mb-1">Target Phone Number</label>
106
+ <input type="text" value={targetNumber} onChange={e => setTargetNumber(e.target.value)} className="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-blue-500" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  </div>
108
+ <button onClick={dial} className="bg-blue-600 hover:bg-blue-500 px-8 py-2 rounded font-bold transition">Trigger Outbound Call</button>
109
+ </div>
110
+ {status && <div className="text-sm text-yellow-400">{status}</div>}
111
+ <div className="bg-gray-800 rounded-lg overflow-hidden">
112
+ <table className="w-full text-left">
113
+ <thead className="bg-gray-900 text-gray-400 text-sm">
114
+ <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>
115
+ </thead>
116
+ <tbody className="divide-y divide-gray-700">
117
+ {logs.map(l => (
118
+ <tr key={l.id}>
119
+ <td className="p-4 text-xs font-mono text-gray-500">{l.call_control_id.slice(0,8)}...</td>
120
+ <td className="p-4 font-mono">{l.target_number}</td>
121
+ <td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={l.last_transcript}>{l.last_transcript || "..."}</td>
122
+ <td className="p-4">
123
+ <span className={\`inline-block px-2 py-1 rounded text-xs \${l.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'}\`}>{l.status}</span>
124
+ {l.is_playing && <span className="block text-xs text-purple-400 animate-pulse mt-1">AI speaking...</span>}
125
+ </td>
126
+ <td className="p-4">
127
+ {l.status !== 'completed' ? <button onClick={() => hangup(l.call_control_id)} className="bg-red-600 hover:bg-red-500 text-xs px-3 py-1 rounded font-bold">Force Hang Up</button> : <span className="text-gray-500 text-xs">Ended</span>}
128
+ </td>
129
+ <td className="p-4">{l.recording_url ? <audio controls src={l.recording_url} className="h-8 w-48"></audio> : <span className="text-gray-500 text-sm">Recording...</span>}</td>
130
+ </tr>
131
+ ))}
132
+ {logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls yet</td></tr>}
133
+ </tbody>
134
+ </table>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
140
+ </script>
141
+ </body>
142
+ </html>`);
143
  });
144
 
145
  app.get("/api/logs", (req, res) => {
146
+ res.json(callLogs.map(({ timers, ...e }) => e).reverse());
 
147
  });
148
 
149
  app.get("/health", (req, res) => res.json({ status: "ok" }));
150
 
151
+ app.get("/play-recording/:ccid", async (req, res) => {
152
+ const entry = callLogs.find(l => l.call_control_id === req.params.ccid);
153
+ if (!entry?.raw_telnyx_url) return res.sendStatus(404);
154
  try {
155
+ const s3 = await fetch(entry.raw_telnyx_url);
156
+ if (!s3.ok) return res.sendStatus(s3.status);
157
+ const buf = Buffer.from(await s3.arrayBuffer());
158
  res.setHeader("Content-Type", "audio/mpeg");
159
+ res.setHeader("Content-Length", buf.length);
160
+ res.send(buf);
161
+ } catch { res.sendStatus(500); }
162
  });
163
 
164
  app.post("/dial", async (req, res) => {
165
  const { to } = req.body;
 
166
  log("DIAL", `Dialing ${to}...`);
 
167
  try {
168
  const response = await fetch("https://api.telnyx.com/v2/calls", {
169
  method: "POST",
170
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
171
  body: JSON.stringify({
172
  connection_id: TELNYX_CONNECTION_ID,
173
+ to, from: TELNYX_FROM_NUMBER,
174
+ custom_headers: [{ name: "X-Voice-Selection", value: ACTIVE_VOICE }]
175
+ })
 
176
  });
177
  const data = await response.json();
178
  if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });
179
 
180
  callLogs.push({
181
+ id: randomUUID(), call_control_id: data.data.call_control_id,
182
+ target_number: to, voice_used: ACTIVE_VOICE, status: "dialing",
183
+ last_transcript: "", recording_url: null, raw_telnyx_url: null,
184
+ is_playing: false, playback_started_at: null,
185
+ answered_at: null, timers: {}, created_at: new Date().toISOString()
186
  });
187
  res.json({ ok: true });
188
  } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
 
190
 
191
  app.post("/call/hangup", async (req, res) => {
192
  const { call_control_id } = req.body;
193
+ if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
194
  await sendTelnyxCmd(call_control_id, "hangup");
195
  res.json({ ok: true });
196
  });
197
 
198
+ // ── WEBHOOK ──
199
  app.post("/webhook/call", async (req, res) => {
200
+ const type = req.body?.data?.event_type;
201
  const payload = req.body?.data?.payload;
202
+ const callId = payload?.call_control_id;
 
203
  if (!type) return res.sendStatus(200);
204
 
205
  const entry = callLogs.find(l => l.call_control_id === callId);
206
+ if (entry?.status === "completed" && type !== "call.recording.saved") return res.sendStatus(200);
207
 
208
  switch (type) {
209
  case "call.initiated":
210
+ log("INITIATED", `dir=${payload?.direction}`);
211
  if (payload?.direction === "incoming") {
212
  callLogs.push({
213
  id: randomUUID(), call_control_id: callId, target_number: payload?.from,
214
+ voice_used: ACTIVE_VOICE, status: "in-progress", last_transcript: "",
215
+ recording_url: null, raw_telnyx_url: null, is_playing: false,
216
+ playback_started_at: null, answered_at: null, timers: {}, created_at: new Date().toISOString()
217
  });
218
  await sendTelnyxCmd(callId, "answer");
219
  }
220
  break;
221
 
222
  case "call.answered":
223
+ log("ANSWERED", `Call ${callId} connected.`);
224
  updateLog(callId, { status: "in-progress", answered_at: new Date().toISOString() });
225
  await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
226
+ await sendTelnyxCmd(callId, "streaming_start", {
227
+ stream_url: `wss://${req.headers.host}/audio-stream`,
228
+ stream_track: "inbound_track"
229
+ });
230
+ await sendTelnyxCmd(callId, "transcription_start", {
231
+ transcription_engine: "Telnyx",
232
+ transcription_tracks: "inbound"
233
+ });
234
  if (entry) {
235
  entry.timers.hangup = setTimeout(async () => {
236
  if (entry.status === "completed") return;
237
+ log("HANGUP", "Enforcing 90-second limit.");
238
  await sendTelnyxCmd(callId, "hangup");
239
  }, 90000);
240
  }
 
246
  break;
247
 
248
  case "call.transcription":
249
+ const tData = payload?.transcription_data;
250
+ if (!tData) break;
251
+ const voice = entry?.voice_used || ACTIVE_VOICE;
252
+
253
+ if (!tData.is_final && entry?.is_playing) {
254
+ log("BARGE-IN", "User speaking β€” stopping AI.");
255
+ updateLog(callId, { is_playing: false });
256
+ await sendTelnyxCmd(callId, "playback_stop");
 
 
 
257
  }
258
 
259
+ if (tData.is_final) {
260
+ const transcript = tData.transcript.trim();
261
+ if (!transcript) break;
262
+ log("TRANSCRIPT", `"${transcript}"`);
263
  updateLog(callId, { last_transcript: transcript });
264
 
265
  try {
266
  const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ text: transcript, voice, call_id: callId })
270
  });
271
  if (aiRes.ok) {
272
  const aiData = await aiRes.json();
273
+ if (aiData.speak_url && entry?.status !== "completed") {
274
+ log("AI_REPLY", `"${aiData.raw_text}"`);
275
+ await sendTelnyxCmd(callId, "playback_start", {
276
+ audio_url: aiData.speak_url, target_legs: "self"
277
+ });
278
  }
279
  }
280
  } catch (e) { log("AI_SERVER_ERROR", e.message); }
 
282
  break;
283
 
284
  case "call.recording.saved":
285
+ const rawUrl = payload.recording_urls.mp3;
286
+ log("RECORDING", rawUrl);
287
+ updateLog(callId, { raw_telnyx_url: rawUrl, recording_url: `/play-recording/${callId}` });
288
  break;
289
 
290
  case "call.hangup":
291
  updateLog(callId, { status: "completed", is_playing: false });
292
+ if (entry?.timers) Object.values(entry.timers).forEach(clearTimeout);
293
+ log("CALL ENDED", "Terminated.");
294
  break;
295
  }
296
  res.sendStatus(200);
297
  });
298
 
299
+ // ── WEBSOCKET: VAD + ECHO SUPPRESSION ──
300
+ wss.on("connection", (ws) => {
301
+ log("WS", "VAD WebSocket connected");
302
+
303
+ let isSpeaking = false;
304
+ let silenceFrames = 0;
305
+ let callControlId = null;
306
+ let hasPlayedFiller = false;
307
+ let hasGreeted = false;
308
  let totalNoiseFrames = 0;
309
+ let wasAiPlaying = false; // Tracks AI playing state across frames for transition detection
310
 
311
+ const SILENCE_THRESHOLD_FRAMES = 35; // 700ms
312
+ const VOLUME_THRESHOLD = 500;
313
+ const ECHO_GRACE_PERIOD = 2000; // 2s after AI starts playing, ignore inbound volume
314
 
315
  ws.on("message", async (raw) => {
316
  try {
 
318
 
319
  if (msg.event === "start") {
320
  callControlId = msg.start?.call_control_id;
321
+
322
+ // Greeting fires 1.5s after call connects.
323
+ // Uses "Hi, this is Nathan." then registers the turn to the AI server
324
+ // so the LLM conversation history knows the intro already happened.
325
  setTimeout(async () => {
326
+ if (hasGreeted || !callControlId) return;
327
+ const e = callLogs.find(l => l.call_control_id === callControlId);
328
+ if (e?.status !== "in-progress") return;
329
+
330
+ hasGreeted = true;
331
+ const greetingPhrase = "Hi, this is Nathan.";
332
+ log("SMART_GREETING", `Playing: "${greetingPhrase}"`);
333
+
334
+ await sendTelnyxCmd(callControlId, "playback_start", {
335
+ audio_url: `${AI_SERVER_URL}/play-audio?voice=${e.voice_used}&phrase=${encodeURIComponent(greetingPhrase)}`,
336
+ target_legs: "self"
337
+ });
338
+
339
+ // Register greeting into conversation history on the AI server
340
+ fetch(`${AI_SERVER_URL}/api/register-greeting`, {
341
+ method: "POST",
342
+ headers: { "Content-Type": "application/json" },
343
+ body: JSON.stringify({ call_id: callControlId, phrase: greetingPhrase })
344
+ }).catch(() => {});
345
  }, 1500);
346
  }
347
 
348
  if (msg.event === "media") {
349
  const pcm16 = decodeMuLaw(Buffer.from(msg.media.payload, "base64"));
350
+ const rms = calculateRMS(pcm16);
351
 
352
+ const entry = callLogs.find(l => l.call_control_id === callControlId);
353
+ const nowAiPlaying = entry?.is_playing || false;
354
+ const timeSincePlayback = entry?.playback_started_at
355
+ ? (Date.now() - entry.playback_started_at) : 9999;
356
  const isEchoWindow = timeSincePlayback < ECHO_GRACE_PERIOD;
357
 
358
+ // ── When AI starts speaking, hard-reset VAD state ──
359
+ // This prevents accumulated silence/noise frames from causing a phantom filler
360
+ if (!wasAiPlaying && nowAiPlaying) {
361
+ isSpeaking = false;
362
+ silenceFrames = 0;
363
+ totalNoiseFrames = 0;
364
+ hasPlayedFiller = false;
365
+ }
366
+ wasAiPlaying = nowAiPlaying;
367
+
368
  if (rms > VOLUME_THRESHOLD) {
369
+ // Suppress echo: if AI just started talking, ignore inbound spikes
370
  if (isEchoWindow) return;
371
 
372
+ hasGreeted = true;
373
  silenceFrames = 0;
374
  totalNoiseFrames++;
375
 
376
  if (!isSpeaking) {
377
+ isSpeaking = true;
378
  hasPlayedFiller = false;
379
+
380
+ if (entry?.is_playing) {
381
+ log("BARGE-IN (VAD)", "User speaking β€” stopping AI.");
382
  entry.is_playing = false;
383
  await sendTelnyxCmd(callControlId, "playback_stop");
384
  }
385
  }
386
+ } else if (isSpeaking) {
 
387
  silenceFrames++;
388
 
389
  if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !hasPlayedFiller) {
390
  hasPlayedFiller = true;
391
+ isSpeaking = false;
392
+
393
+ // 8-second blackout: don't fire fillers during IVR intro
394
+ const callAge = entry?.answered_at
395
+ ? Date.now() - new Date(entry.answered_at).getTime() : 0;
396
 
397
  if (totalNoiseFrames > 40 && callAge > 8000) {
398
+ log("VAD_EARLY_TURN", "Long pause detected β€” playing filler.");
399
  const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
400
+ const filler = fillers[Math.floor(Math.random() * fillers.length)];
401
  sendTelnyxCmd(callControlId, "playback_start", {
402
+ audio_url: `${AI_SERVER_URL}/play-audio?voice=${entry?.voice_used || ACTIVE_VOICE}&phrase=${encodeURIComponent(filler)}`,
403
  target_legs: "self"
404
  }).catch(() => {});
405
  }
406
+ totalNoiseFrames = 0;
407
  }
408
  }
409
  }
410
+ } catch {}
411
  });
412
  });
413
 
414
  server.listen(PORT, "0.0.0.0", () => {
415
+ log("STARTUP", `Telecom Server running on port ${PORT}`);
416
  });