[object Object]

← back to Butlr

calls: inline MP3 player per row + has-recording probe + LLM pre-warm

ead41343ae6eb642f2a3d440a679c6106965b643 · 2026-05-12 20:28:17 -0700 · SteveStudio2

- views/public/calls.ejs: each done/failed/completed row now renders an
  <audio controls> inline. JS probes /api/calls/<id>/has-recording on
  load and only reveals the audio block if MP3 exists. Skips the rows
  with no recording (dry-run calls, lost-audio calls like DTCI7PuZ).
- routes/listen.js: new GET /api/calls/<id>/has-recording returns
  {ok, exists, size_bytes} — cheap probe without streaming.
- views/public/listen.ejs: minor button-text consistency fix on disconnect.
- server.js: pre-warm Ollama at startup with keep_alive=24h so first
  real call doesn't hit BUTLR_LLM_TIMEOUT_MS on cold cache.

YOLO tick 2 — all reversible, local-only.

Files touched

Diff

commit ead41343ae6eb642f2a3d440a679c6106965b643
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 20:28:17 2026 -0700

    calls: inline MP3 player per row + has-recording probe + LLM pre-warm
    
    - views/public/calls.ejs: each done/failed/completed row now renders an
      <audio controls> inline. JS probes /api/calls/<id>/has-recording on
      load and only reveals the audio block if MP3 exists. Skips the rows
      with no recording (dry-run calls, lost-audio calls like DTCI7PuZ).
    - routes/listen.js: new GET /api/calls/<id>/has-recording returns
      {ok, exists, size_bytes} — cheap probe without streaming.
    - views/public/listen.ejs: minor button-text consistency fix on disconnect.
    - server.js: pre-warm Ollama at startup with keep_alive=24h so first
      real call doesn't hit BUTLR_LLM_TIMEOUT_MS on cold cache.
    
    YOLO tick 2 — all reversible, local-only.
---
 routes/listen.js          |  7 ++++---
 routes/twilio-webhooks.js |  3 ++-
 server.js                 | 22 ++++++++++++++++++++++
 views/public/calls.ejs    | 21 +++++++++++++++++++++
 views/public/listen.ejs   |  2 +-
 5 files changed, 50 insertions(+), 5 deletions(-)

diff --git a/routes/listen.js b/routes/listen.js
index 038dbbf..2a3b646 100644
--- a/routes/listen.js
+++ b/routes/listen.js
@@ -1,7 +1,8 @@
 // Listen-in routes:
-//   GET /listen/:call_id           — browser page that streams the live audio
-//   GET /calls/:call_id/transcript — transcript JSON (post-call)
-//   GET /calls/:call_id/recording  — MP3 recording (post-call)
+//   GET /listen/:call_id                       — browser page that streams the live audio
+//   GET /api/calls/:call_id/transcript         — transcript JSON (post-call)
+//   GET /api/calls/:call_id/recording          — MP3 recording (post-call)
+//   GET /api/calls/:call_id/has-recording      — JSON probe used by /calls list rows
 
 const express = require('express');
 const path = require('path');
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 2632441..63d934b 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -128,7 +128,8 @@ async function twimlHandler(req, res) {
   </Gather>
   <!-- actionOnEmptyResult=true means the Gather POSTs to /gather even on
        empty input; /gather self-loops with inline Gather, so we never
-       return here. NO <Redirect> — that would tear down <Stream>. -->
+       return here. No Redirect verb here on purpose - it would tear down
+       the Stream. -->
 </Response>`;
   res.type('text/xml').send(xml);
 }
diff --git a/server.js b/server.js
index eb12894..c00c728 100644
--- a/server.js
+++ b/server.js
@@ -33,6 +33,28 @@ process.on('unhandledRejection', (reason, p) => {
 process.on('SIGINT',  () => { console.error('[SIGNAL] SIGINT received at',  new Date().toISOString()); process.exit(0); });
 process.on('SIGTERM', () => { console.error('[SIGNAL] SIGTERM received at', new Date().toISOString()); process.exit(0); });
 
+// ── Pre-warm the AI agent's LLM ────────────────────────────────────────
+// On Kamatera CPU, qwen2.5:7b cold-start is ~11s; warm response is ~3s.
+// Phone calls hit BUTLR_LLM_TIMEOUT_MS (12s) on cold cache, time out, and
+// emit the deflection fallback string. Fire a dummy /api/chat at startup
+// so the model is resident in RAM by the time the first real call lands.
+// keep_alive: '24h' tells ollama not to evict.
+(async () => {
+  const OLLAMA = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+  const MODEL = process.env.BUTLR_LLM_MODEL || 'qwen2.5:7b';
+  try {
+    const t0 = Date.now();
+    await fetch(`${OLLAMA}/api/chat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: MODEL, stream: false, keep_alive: '24h', messages: [{ role: 'user', content: 'warm' }], options: { num_predict: 4 } }),
+    });
+    console.log(`[startup] pre-warmed ${MODEL} in ${Date.now() - t0}ms`);
+  } catch (e) {
+    console.error('[startup] LLM pre-warm failed (call latency will be cold):', e.message);
+  }
+})();
+
 const app = express();
 const PORT = parseInt(process.env.PORT || '9932', 10);
 const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index efe766b..83e9ad8 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -29,8 +29,29 @@
                 </p>
                 <p class="call-row-goal"><%= c.goal.length > 140 ? c.goal.slice(0,140)+'…' : c.goal %></p>
               </a>
+              <% if (['done','completed','failed'].includes(c.status)) { %>
+                <div class="call-row-audio" data-call-id="<%= c.id %>" style="margin-top:.5rem; padding:.5rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:6px; display:none;">
+                  <div style="font-size:11px; color:var(--ink-dim); margin-bottom:4px;">📼 Recording</div>
+                  <audio controls preload="none" src="/api/calls/<%= c.id %>/recording" style="width:100%; height:32px;"></audio>
+                </div>
+              <% } %>
             </li>
           <% }); %>
+
+          <script>
+          // Each row's audio block hides itself by default; probe HEAD on
+          // /api/calls/:id/recording and reveal only if the MP3 actually
+          // exists. Avoids "your browser does not support" + 404 noise on
+          // rows whose call ended before recording was archived.
+          document.querySelectorAll('.call-row-audio').forEach(async (el) => {
+            const id = el.dataset.callId;
+            try {
+              const r = await fetch('/api/calls/' + id + '/has-recording');
+              const j = await r.json();
+              if (j && j.ok && j.exists) el.style.display = '';
+            } catch {}
+          });
+          </script>
         </ul>
       <% } %>
     </div>
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index 58c16a7..3bcb551 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -86,7 +86,7 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
     ws = new WebSocket(url);
     ws.binaryType = 'arraybuffer';
     ws.onopen  = () => { stateEl.textContent = 'connected · waiting for audio'; btn.textContent = '■ Disconnect'; };
-    ws.onclose = () => { stateEl.textContent = 'disconnected'; btn.textContent = '▶ Connect & listen'; };
+    ws.onclose = () => { stateEl.textContent = 'disconnected'; btn.textContent = '▶ Connect & listen live'; };
     ws.onerror = () => { stateEl.textContent = 'error'; };
     ws.onmessage = (ev) => {
       // PCM16 LE @ 8kHz mono

← cd10f95 twilio: kill <Redirect> + inline-Gather + inbound_track + Au  ·  back to Butlr  ·  scripts: validate-twiml.js — 11-assertion smoke test for pro 27d8207 →