File size: 17,454 Bytes
91fe2e8
 
33ad64d
91fe2e8
 
 
 
33ad64d
91fe2e8
1c093c7
91fe2e8
 
 
 
bcc20d5
91fe2e8
bcc20d5
 
91fe2e8
bcc20d5
91fe2e8
bcc20d5
91fe2e8
 
22cfb93
5a99304
 
 
22cfb93
e97a49d
22cfb93
 
 
 
bcc20d5
 
22cfb93
bcc20d5
22cfb93
 
bcc20d5
22cfb93
bcc20d5
22cfb93
 
 
bcc20d5
22cfb93
 
bcc20d5
 
 
 
22cfb93
 
 
bcc20d5
 
 
22cfb93
bcc20d5
22cfb93
bcc20d5
 
22cfb93
 
bcc20d5
8412e4c
bcc20d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8412e4c
bcc20d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8412e4c
91fe2e8
d4be1dc
bcc20d5
d4be1dc
 
5a99304
8412e4c
bcc20d5
 
 
91fe2e8
bcc20d5
 
 
91fe2e8
bcc20d5
 
 
91fe2e8
 
 
 
 
 
a1abbca
 
 
 
 
bcc20d5
 
 
a1abbca
91fe2e8
 
 
 
bcc20d5
 
 
 
 
91fe2e8
 
5a99304
91fe2e8
 
a9c0f6a
 
bcc20d5
5a99304
 
a9c0f6a
 
bcc20d5
91fe2e8
bcc20d5
a9c0f6a
bcc20d5
91fe2e8
 
22cfb93
bcc20d5
9c6c3c0
22cfb93
 
bcc20d5
fc00f21
 
 
bcc20d5
 
 
fc00f21
 
 
22cfb93
 
 
bcc20d5
233fa4e
22cfb93
bcc20d5
 
 
 
 
 
 
 
22cfb93
 
 
bcc20d5
22cfb93
5a99304
22cfb93
 
 
 
 
 
 
 
fc00f21
bcc20d5
 
 
 
 
 
 
 
e97a49d
 
bcc20d5
 
 
 
fc00f21
 
 
 
bcc20d5
 
 
fc00f21
 
 
bcc20d5
 
 
 
 
fc00f21
 
e97a49d
fc00f21
 
 
22cfb93
bcc20d5
 
 
22cfb93
 
 
 
bcc20d5
 
22cfb93
 
 
 
9c6c3c0
bcc20d5
 
 
 
 
 
 
 
 
fc00f21
bcc20d5
22cfb93
bcc20d5
 
 
22cfb93
 
 
 
b97f4e8
22cfb93
 
bcc20d5
 
 
 
b97f4e8
bcc20d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22cfb93
 
 
 
5a99304
bcc20d5
22cfb93
bcc20d5
 
 
 
5a99304
 
bcc20d5
 
 
 
 
 
 
 
 
 
22cfb93
bcc20d5
5a99304
 
bcc20d5
22cfb93
fc00f21
22cfb93
 
bcc20d5
fc00f21
bcc20d5
 
 
22cfb93
 
0bf76da
33ad64d
bcc20d5
22cfb93
 
fc00f21
 
bcc20d5
 
 
 
 
fc00f21
233fa4e
bcc20d5
fc00f21
bcc20d5
fc00f21
bcc20d5
fc00f21
233fa4e
22cfb93
bcc20d5
33ad64d
b97f4e8
22cfb93
bcc20d5
22cfb93
 
 
0bf76da
bcc20d5
0bf76da
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
import express from "express";
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { randomUUID } from "crypto";

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server, path: "/audio-stream" });

app.use(express.static("public", { index: false }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const PORT = process.env.PORT || 7860;
const TELNYX_API_KEY       = process.env.TELNYX_API_KEY;
const TELNYX_CONNECTION_ID = process.env.TELNYX_CONNECTION_ID;
const TELNYX_FROM_NUMBER   = process.env.TELNYX_FROM_NUMBER;
const AI_SERVER_URL        = process.env.AI_SERVER_URL;

const ACTIVE_VOICE = "Enceladus_3";

const log = (tag, ...args) => console.log(`[${new Date().toISOString()}] [${tag}]`, ...args);
let callLogs = [];

const sendTelnyxCmd = async (callId, action, body = {}) => {
  if (action === "playback_start") {
    updateLog(callId, { is_playing: true, playback_started_at: Date.now() });
  }
  try {
    const response = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/${action}`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
      body: JSON.stringify(body)
    });
    if (!response.ok) log("TELNYX CMD ERROR", `[${action}] ${await response.text()}`);
    else log("TELNYX CMD SUCCESS", `[${action}] accepted.`);
    return response;
  } catch (e) { log("TELNYX CMD NETWORK ERROR", e.message); }
};

const updateLog = (callId, updates) => {
  const entry = callLogs.find(l => l.call_control_id === callId);
  if (entry) Object.assign(entry, updates);
  return entry;
};

// ── MU-LAW VAD ──
const MU_LAW_TABLE = new Int16Array(256);
for (let i = 0; i < 256; i++) {
  const sign = (i & 0x80) ? -1 : 1;
  const exponent = (~i >> 4) & 0x07;
  const mantissa = ~i & 0x0f;
  const sample = ((mantissa << 3) + 132) << exponent;
  MU_LAW_TABLE[i] = sign * (sample - 132);
}
function decodeMuLaw(buffer) {
  const out = new Int16Array(buffer.length);
  for (let i = 0; i < buffer.length; i++) out[i] = MU_LAW_TABLE[buffer[i]];
  return out;
}
function calculateRMS(pcm) {
  let sum = 0;
  for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i];
  return Math.sqrt(sum / pcm.length);
}

// ── DASHBOARD ──
app.get("/", (req, res) => {
  res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Elite Telecom Control Panel</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
  <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
  <script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
</head>
<body class="bg-gray-900 text-gray-100 p-8 font-sans">
  <div id="root" class="max-w-4xl mx-auto"></div>
  <script type="text/babel">
    const { useState, useEffect } = React;
    function App() {
      const [logs, setLogs] = useState([]);
      const [targetNumber, setTargetNumber] = useState("+1");
      const [status, setStatus] = useState("");
      const fetchLogs = () => fetch("/api/logs").then(r => r.json()).then(setLogs);
      useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 2000); return () => clearInterval(t); }, []);
      const dial = async () => {
        setStatus("Dialing...");
        const res = await fetch("/dial", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: targetNumber }) });
        const data = await res.json();
        setStatus(data.ok ? "Call initiated!" : "Error: " + data.error);
        fetchLogs();
      };
      const hangup = async (id) => {
        await fetch("/call/hangup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ call_control_id: id }) });
        fetchLogs();
      };
      return (
        <div className="space-y-8">
          <h1 className="text-3xl font-bold border-b border-gray-700 pb-4">Web Agency Telecom Dashboard</h1>
          <div className="bg-gray-800 p-6 rounded-lg flex gap-4 items-end">
            <div className="flex-1">
              <label className="block text-sm text-gray-400 mb-1">Target Phone Number</label>
              <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" />
            </div>
            <button onClick={dial} className="bg-blue-600 hover:bg-blue-500 px-8 py-2 rounded font-bold transition">Trigger Outbound Call</button>
          </div>
          {status && <div className="text-sm text-yellow-400">{status}</div>}
          <div className="bg-gray-800 rounded-lg overflow-hidden">
            <table className="w-full text-left">
              <thead className="bg-gray-900 text-gray-400 text-sm">
                <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>
              </thead>
              <tbody className="divide-y divide-gray-700">
                {logs.map(l => (
                  <tr key={l.id}>
                    <td className="p-4 text-xs font-mono text-gray-500">{l.call_control_id.slice(0,8)}...</td>
                    <td className="p-4 font-mono">{l.target_number}</td>
                    <td className="p-4 text-xs text-gray-300 italic truncate max-w-[200px]" title={l.last_transcript}>{l.last_transcript || "..."}</td>
                    <td className="p-4">
                      <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>
                      {l.is_playing && <span className="block text-xs text-purple-400 animate-pulse mt-1">AI speaking...</span>}
                    </td>
                    <td className="p-4">
                      {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>}
                    </td>
                    <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>
                  </tr>
                ))}
                {logs.length === 0 && <tr><td colSpan="6" className="p-4 text-center text-gray-500">No calls yet</td></tr>}
              </tbody>
            </table>
          </div>
        </div>
      );
    }
    ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  </script>
</body>
</html>`);
});

app.get("/api/logs", (req, res) => {
  res.json(callLogs.map(({ timers, ...e }) => e).reverse());
});

app.get("/health", (req, res) => res.json({ status: "ok" }));

app.get("/play-recording/:ccid", async (req, res) => {
  const entry = callLogs.find(l => l.call_control_id === req.params.ccid);
  if (!entry?.raw_telnyx_url) return res.sendStatus(404);
  try {
    const s3 = await fetch(entry.raw_telnyx_url);
    if (!s3.ok) return res.sendStatus(s3.status);
    const buf = Buffer.from(await s3.arrayBuffer());
    res.setHeader("Content-Type", "audio/mpeg");
    res.setHeader("Content-Length", buf.length);
    res.send(buf);
  } catch { res.sendStatus(500); }
});

app.post("/dial", async (req, res) => {
  const { to } = req.body;
  log("DIAL", `Dialing ${to}...`);
  try {
    const response = await fetch("https://api.telnyx.com/v2/calls", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${TELNYX_API_KEY}` },
      body: JSON.stringify({
        connection_id: TELNYX_CONNECTION_ID,
        to, from: TELNYX_FROM_NUMBER,
        custom_headers: [{ name: "X-Voice-Selection", value: ACTIVE_VOICE }]
      })
    });
    const data = await response.json();
    if (!response.ok) return res.status(400).json({ ok: false, error: data?.errors?.[0]?.detail });

    callLogs.push({
      id: randomUUID(), call_control_id: data.data.call_control_id,
      target_number: to, voice_used: ACTIVE_VOICE, status: "dialing",
      last_transcript: "", recording_url: null, raw_telnyx_url: null,
      is_playing: false, playback_started_at: null,
      answered_at: null, timers: {}, created_at: new Date().toISOString()
    });
    res.json({ ok: true });
  } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});

app.post("/call/hangup", async (req, res) => {
  const { call_control_id } = req.body;
  if (!call_control_id) return res.status(400).json({ error: "Missing call_control_id" });
  await sendTelnyxCmd(call_control_id, "hangup");
  res.json({ ok: true });
});

// ── WEBHOOK ──
app.post("/webhook/call", async (req, res) => {
  const type    = req.body?.data?.event_type;
  const payload = req.body?.data?.payload;
  const callId  = payload?.call_control_id;
  if (!type) return res.sendStatus(200);

  const entry = callLogs.find(l => l.call_control_id === callId);
  if (entry?.status === "completed" && type !== "call.recording.saved") return res.sendStatus(200);

  switch (type) {
    case "call.initiated":
      log("INITIATED", `dir=${payload?.direction}`);
      if (payload?.direction === "incoming") {
        callLogs.push({
          id: randomUUID(), call_control_id: callId, target_number: payload?.from,
          voice_used: ACTIVE_VOICE, status: "in-progress", last_transcript: "",
          recording_url: null, raw_telnyx_url: null, is_playing: false,
          playback_started_at: null, answered_at: null, timers: {}, created_at: new Date().toISOString()
        });
        await sendTelnyxCmd(callId, "answer");
      }
      break;

    case "call.answered":
      log("ANSWERED", `Call ${callId} connected.`);
      updateLog(callId, { status: "in-progress", answered_at: new Date().toISOString() });
      await sendTelnyxCmd(callId, "record_start", { format: "mp3", channels: "dual", play_beep: false });
      await sendTelnyxCmd(callId, "streaming_start", {
        stream_url: `wss://${req.headers.host}/audio-stream`,
        stream_track: "inbound_track"
      });
      await sendTelnyxCmd(callId, "transcription_start", {
        transcription_engine: "Telnyx",
        transcription_tracks: "inbound"
      });
      if (entry) {
        entry.timers.hangup = setTimeout(async () => {
          if (entry.status === "completed") return;
          log("HANGUP", "Enforcing 90-second limit.");
          await sendTelnyxCmd(callId, "hangup");
        }, 90000);
      }
      break;

    case "call.playback.ended":
    case "call.playback.stopped":
      updateLog(callId, { is_playing: false });
      break;

    case "call.transcription":
      const tData = payload?.transcription_data;
      if (!tData) break;
      const voice = entry?.voice_used || ACTIVE_VOICE;

      if (!tData.is_final && entry?.is_playing) {
        log("BARGE-IN", "User speaking β€” stopping AI.");
        updateLog(callId, { is_playing: false });
        await sendTelnyxCmd(callId, "playback_stop");
      }

      if (tData.is_final) {
        const transcript = tData.transcript.trim();
        if (!transcript) break;
        log("TRANSCRIPT", `"${transcript}"`);
        updateLog(callId, { last_transcript: transcript });

        try {
          const aiRes = await fetch(`${AI_SERVER_URL}/api/process-text-turn`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text: transcript, voice, call_id: callId })
          });
          if (aiRes.ok) {
            const aiData = await aiRes.json();
            if (aiData.speak_url && entry?.status !== "completed") {
              log("AI_REPLY", `"${aiData.raw_text}"`);
              await sendTelnyxCmd(callId, "playback_start", {
                audio_url: aiData.speak_url, target_legs: "self"
              });
            }
          }
        } catch (e) { log("AI_SERVER_ERROR", e.message); }
      }
      break;

    case "call.recording.saved":
      const rawUrl = payload.recording_urls.mp3;
      log("RECORDING", rawUrl);
      updateLog(callId, { raw_telnyx_url: rawUrl, recording_url: `/play-recording/${callId}` });
      break;

    case "call.hangup":
      updateLog(callId, { status: "completed", is_playing: false });
      if (entry?.timers) Object.values(entry.timers).forEach(clearTimeout);
      log("CALL ENDED", "Terminated.");
      break;
  }
  res.sendStatus(200);
});

// ── WEBSOCKET: VAD + ECHO SUPPRESSION ──
wss.on("connection", (ws) => {
  log("WS", "VAD WebSocket connected");

  let isSpeaking       = false;
  let silenceFrames    = 0;
  let callControlId    = null;
  let hasPlayedFiller  = false;
  let hasGreeted       = false;
  let totalNoiseFrames = 0;
  let wasAiPlaying     = false; // Tracks AI playing state across frames for transition detection

  const SILENCE_THRESHOLD_FRAMES = 35;  // 700ms
  const VOLUME_THRESHOLD         = 500;
  const ECHO_GRACE_PERIOD        = 2000; // 2s after AI starts playing, ignore inbound volume

  ws.on("message", async (raw) => {
    try {
      const msg = JSON.parse(raw);

      if (msg.event === "start") {
        callControlId = msg.start?.call_control_id;

        // Greeting fires 1.5s after call connects.
        // Uses "Hi, this is Nathan." then registers the turn to the AI server
        // so the LLM conversation history knows the intro already happened.
        setTimeout(async () => {
          if (hasGreeted || !callControlId) return;
          const e = callLogs.find(l => l.call_control_id === callControlId);
          if (e?.status !== "in-progress") return;

          hasGreeted = true;
          const greetingPhrase = "Hi, this is Nathan.";
          log("SMART_GREETING", `Playing: "${greetingPhrase}"`);

          await sendTelnyxCmd(callControlId, "playback_start", {
            audio_url: `${AI_SERVER_URL}/play-audio?voice=${e.voice_used}&phrase=${encodeURIComponent(greetingPhrase)}`,
            target_legs: "self"
          });

          // Register greeting into conversation history on the AI server
          fetch(`${AI_SERVER_URL}/api/register-greeting`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ call_id: callControlId, phrase: greetingPhrase })
          }).catch(() => {});
        }, 1500);
      }

      if (msg.event === "media") {
        const pcm16 = decodeMuLaw(Buffer.from(msg.media.payload, "base64"));
        const rms   = calculateRMS(pcm16);

        const entry        = callLogs.find(l => l.call_control_id === callControlId);
        const nowAiPlaying = entry?.is_playing || false;
        const timeSincePlayback = entry?.playback_started_at
          ? (Date.now() - entry.playback_started_at) : 9999;
        const isEchoWindow = timeSincePlayback < ECHO_GRACE_PERIOD;

        // ── When AI starts speaking, hard-reset VAD state ──
        // This prevents accumulated silence/noise frames from causing a phantom filler
        if (!wasAiPlaying && nowAiPlaying) {
          isSpeaking       = false;
          silenceFrames    = 0;
          totalNoiseFrames = 0;
          hasPlayedFiller  = false;
        }
        wasAiPlaying = nowAiPlaying;

        if (rms > VOLUME_THRESHOLD) {
          // Suppress echo: if AI just started talking, ignore inbound spikes
          if (isEchoWindow) return;

          hasGreeted    = true;
          silenceFrames = 0;
          totalNoiseFrames++;

          if (!isSpeaking) {
            isSpeaking      = true;
            hasPlayedFiller = false;

            if (entry?.is_playing) {
              log("BARGE-IN (VAD)", "User speaking β€” stopping AI.");
              entry.is_playing = false;
              await sendTelnyxCmd(callControlId, "playback_stop");
            }
          }
        } else if (isSpeaking) {
          silenceFrames++;

          if (silenceFrames > SILENCE_THRESHOLD_FRAMES && !hasPlayedFiller) {
            hasPlayedFiller = true;
            isSpeaking      = false;

            // 8-second blackout: don't fire fillers during IVR intro
            const callAge = entry?.answered_at
              ? Date.now() - new Date(entry.answered_at).getTime() : 0;

            if (totalNoiseFrames > 40 && callAge > 8000) {
              log("VAD_EARLY_TURN", "Long pause detected β€” playing filler.");
              const fillers = ["Right.", "Got it.", "Okay.", "Yeah.", "Ah, okay."];
              const filler  = fillers[Math.floor(Math.random() * fillers.length)];
              sendTelnyxCmd(callControlId, "playback_start", {
                audio_url: `${AI_SERVER_URL}/play-audio?voice=${entry?.voice_used || ACTIVE_VOICE}&phrase=${encodeURIComponent(filler)}`,
                target_legs: "self"
              }).catch(() => {});
            }
            totalNoiseFrames = 0;
          }
        }
      }
    } catch {}
  });
});

server.listen(PORT, "0.0.0.0", () => {
  log("STARTUP", `Telecom Server running on port ${PORT}`);
});