[object Object]

← back to Butlr

audio: finalize-recordings.js + diagnostics that found Twilio fix

4a7760662184cdee6a347007f4261341ad41e5c0 · 2026-05-12 17:34:11 -0700 · SteveStudio2

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

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.`);

← fc2ef8e twilio: fix StatusCallbackEvent — repeated param not space-s  ·  back to Butlr  ·  call-live: integrate listen-live audio inline so call audio da345d1 →