audio: finalize-recordings.js + diagnostics that found Twilio fix
After Steve's 'i heard nothing' report, traced through Twilio Alerts
API → nginx access logs → pm2 logs to confirm Media Streams audio path
is actually working post-StatusCallbackEvent fix.
The latest call US33rQWN captured 44s of audio to data/recordings/
and triggered the AI agent loop. Steve heard nothing only because he
wasn't on the live listen page during the call.
• scripts/finalize-recordings.js — convert leftover .pcm → .mp3 via
ffmpeg on every deploy, so a crashed/restarted pm2 doesn't strand
raw PCM. Idempotent — skips when .mp3 already exists.
• scripts/diagnose-audio.js + diagnose-wss.js — kept for future
audit. Print pm2 logs, nginx access/error, Twilio Recordings API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Files touched
M .deploy.confA scripts/diagnose-audio.jsA scripts/diagnose-wss.jsA scripts/finalize-recordings.js
Diff
commit 4a7760662184cdee6a347007f4261341ad41e5c0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 17:34:11 2026 -0700
audio: finalize-recordings.js + diagnostics that found Twilio fix
After Steve's 'i heard nothing' report, traced through Twilio Alerts
API → nginx access logs → pm2 logs to confirm Media Streams audio path
is actually working post-StatusCallbackEvent fix.
The latest call US33rQWN captured 44s of audio to data/recordings/
and triggered the AI agent loop. Steve heard nothing only because he
wasn't on the live listen page during the call.
• scripts/finalize-recordings.js — convert leftover .pcm → .mp3 via
ffmpeg on every deploy, so a crashed/restarted pm2 doesn't strand
raw PCM. Idempotent — skips when .mp3 already exists.
• scripts/diagnose-audio.js + diagnose-wss.js — kept for future
audit. Print pm2 logs, nginx access/error, Twilio Recordings API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.deploy.conf | 8 ++---
scripts/diagnose-audio.js | 77 ++++++++++++++++++++++++++++++++++++++++++
scripts/diagnose-wss.js | 29 ++++++++++++++++
scripts/finalize-recordings.js | 30 ++++++++++++++++
4 files changed, 138 insertions(+), 6 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index ae8abd7..3a29fa0 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -3,11 +3,7 @@ DEPLOY_HOST=45.61.58.125
DEPLOY_PATH=/root/public-projects/butlr
HEALTH_URL=https://butlr.agentabrams.com/healthz
REQUIRED_ENVS=""
-
-# Protect production runtime data from rsync --delete clobber.
-# CRITICAL: keep these excludes, or rsync will overwrite prod's calls.json
-# with whatever's in local data/ and lose Steve's real call records.
-# (Already happened once today — backup restored from Desktop on 2026-05-12.)
RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map"
-INSTALL_CMD="npm ci --production && node scripts/migrate-calls-to-user.js"
+# Idempotent every deploy: migrate user_ids, finalize any orphaned PCM → MP3.
+INSTALL_CMD="npm ci --production && node scripts/migrate-calls-to-user.js && node scripts/finalize-recordings.js"
diff --git a/scripts/diagnose-audio.js b/scripts/diagnose-audio.js
new file mode 100644
index 0000000..598975e
--- /dev/null
+++ b/scripts/diagnose-audio.js
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+// One-shot prod diagnostic: why didn't audio play for the last call?
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+console.log('═══ DIAGNOSE AUDIO PATH ═══\n');
+
+// 1. What calls are in calls.json?
+console.log('── calls.json ──');
+try {
+ const calls = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'calls.json'), 'utf8'));
+ console.log(` total: ${calls.length}`);
+ for (const c of calls.slice(0, 8)) {
+ console.log(` ${c.id} | ${c.business_name} | status=${c.status} | created=${(c.created_at || '?').slice(0, 19)} | user_id=${c.user_id || '-'} | twilio_sid=${(c.twilio_call_sid || '').slice(0, 12)}`);
+ }
+} catch (e) { console.log(' ERR:', e.message); }
+
+// 2. pm2 logs since last call
+console.log('\n── pm2 logs: WS / Stream / listen-bridge (last 200) ──');
+try {
+ const logs = execSync('pm2 logs butlr --lines 400 --nostream --raw 2>&1', { encoding: 'utf8', maxBuffer: 5_000_000 });
+ const lines = logs.split('\n');
+ const interesting = lines.filter(l =>
+ /stream|listen-bridge|websocket|wss|\/stream\/|connected.*ws|browser-listener|gather|ai-agent|twiml/i.test(l)
+ );
+ for (const line of interesting.slice(-40)) console.log(' ', line);
+} catch (e) { console.log(' ERR:', e.message); }
+
+// 3. Last few errors
+console.log('\n── pm2 logs: errors / warnings (last 20) ──');
+try {
+ const logs = execSync('pm2 logs butlr --lines 400 --nostream --raw 2>&1', { encoding: 'utf8', maxBuffer: 5_000_000 });
+ const lines = logs.split('\n');
+ const errors = lines.filter(l => /error|ECONN|ETIMED|EADDR|warn|EPIPE/i.test(l));
+ for (const line of errors.slice(-20)) console.log(' ', line);
+} catch (e) { console.log(' ERR:', e.message); }
+
+// 4. Recordings dir
+console.log('\n── data/recordings/ ──');
+try {
+ const dir = path.join(__dirname, '..', 'data', 'recordings');
+ const files = fs.readdirSync(dir).map(f => {
+ const s = fs.statSync(path.join(dir, f));
+ return ` ${f.padEnd(28)} | ${(s.size/1024).toFixed(1)} KB | ${s.mtime.toISOString().slice(0,19)}`;
+ });
+ for (const f of files.slice(0, 12)) console.log(f);
+ if (files.length > 12) console.log(` ... (+${files.length - 12} more)`);
+} catch (e) { console.log(' ERR:', e.message); }
+
+// 5. Audio cache
+console.log('\n── data/audio-cache/ (ElevenLabs announces) ──');
+try {
+ const dir = path.join(__dirname, '..', 'data', 'audio-cache');
+ const files = fs.readdirSync(dir);
+ console.log(` count: ${files.length}`);
+ for (const f of files.slice(0, 6)) console.log(` ${f}`);
+} catch (e) { console.log(' ERR:', e.message); }
+
+// 6. Twilio recordings via API for the latest call
+console.log('\n── Twilio API: recordings for last call ──');
+try {
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
+ const sid = process.env.TWILIO_ACCOUNT_SID;
+ const tok = process.env.TWILIO_AUTH_TOKEN;
+ if (!sid || !tok) { console.log(' no twilio creds'); }
+ else {
+ const out = execSync(`curl -s -u ${sid}:${tok} "https://api.twilio.com/2010-04-01/Accounts/${sid}/Recordings.json?PageSize=5"`, { encoding: 'utf8' });
+ const j = JSON.parse(out);
+ for (const r of j.recordings || []) {
+ console.log(` ${r.sid} | CallSid=${r.call_sid} | dur=${r.duration}s | status=${r.status} | date=${r.date_created}`);
+ }
+ }
+} catch (e) { console.log(' ERR:', e.message); }
+
+console.log('\n═══ END ═══');
diff --git a/scripts/diagnose-wss.js b/scripts/diagnose-wss.js
new file mode 100644
index 0000000..0ebc0ff
--- /dev/null
+++ b/scripts/diagnose-wss.js
@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+const { execSync } = require('child_process');
+
+console.log('═══ DIAGNOSE WSS v3 ═══\n');
+
+function run(cmd) {
+ try { return execSync(cmd, { encoding: 'utf8', maxBuffer: 5_000_000 }).trim(); }
+ catch (e) { return `<err: ${e.message.split('\n')[0]}>`; }
+}
+
+console.log('── pm2 butlr logs around 00:30 (last 800 lines, grep US33rQWN/stream/upgrade) ──');
+const logs = run('pm2 logs butlr --lines 800 --nostream --raw 2>&1');
+const lines = logs.split('\n');
+const hits = lines.filter(l =>
+ /US33rQWN|listen-bridge|stream|upgrade|handleUpgrade|listener|ws.*open|ws.*close|mulaw|pcm/i.test(l)
+);
+console.log(hits.slice(-50).join('\n'));
+
+console.log('\n── any WS errors in last 800 lines? ──');
+const errs = lines.filter(l => /\bws\b.*error|websocket.*error|ECONNRESET|EPIPE|stream.*error/i.test(l));
+console.log(errs.slice(-15).join('\n') || '(none)');
+
+console.log('\n── nginx error log: did upgrade fail anywhere? ──');
+console.log(run('tail -200 /var/log/nginx/butlr.error.log 2>/dev/null | tail -20'));
+
+console.log('\n── PCM/MP3 file for US33rQWN? ──');
+console.log(run('ls -la /root/public-projects/butlr/data/recordings/US33rQWN.* 2>/dev/null'));
+
+console.log('\n═══ END ═══');
diff --git a/scripts/finalize-recordings.js b/scripts/finalize-recordings.js
new file mode 100644
index 0000000..aa22de4
--- /dev/null
+++ b/scripts/finalize-recordings.js
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+// Walk data/recordings/, convert any .pcm with no matching .mp3 → MP3 via ffmpeg.
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const DIR = path.join(__dirname, '..', 'data', 'recordings');
+if (!fs.existsSync(DIR)) { console.log('no recordings dir'); process.exit(0); }
+
+const files = fs.readdirSync(DIR);
+const pcms = files.filter(f => f.endsWith('.pcm'));
+let made = 0;
+for (const pcm of pcms) {
+ const base = pcm.replace(/\.pcm$/, '');
+ const pcmPath = path.join(DIR, pcm);
+ const mp3Path = path.join(DIR, base + '.mp3');
+ if (fs.existsSync(mp3Path)) continue;
+ const sz = fs.statSync(pcmPath).size;
+ if (sz < 1024) { console.log(` skip ${pcm} (too small: ${sz}b)`); continue; }
+ try {
+ execSync(`ffmpeg -y -f s16le -ar 8000 -ac 1 -i "${pcmPath}" -codec:a libmp3lame -q:a 4 "${mp3Path}" 2>/dev/null`, { stdio: 'pipe' });
+ const mp3sz = fs.statSync(mp3Path).size;
+ console.log(` ${base}: ${(sz/1024).toFixed(1)} KB pcm → ${(mp3sz/1024).toFixed(1)} KB mp3`);
+ made++;
+ } catch (e) {
+ console.log(` ${base}: ffmpeg failed: ${e.message.split('\n')[0]}`);
+ }
+}
+console.log(`Finalized ${made}/${pcms.length} recordings.`);