← 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
M routes/listen.jsM routes/twilio-webhooks.jsM server.jsM views/public/calls.ejsM views/public/listen.ejs
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 →