[object Object]

← back to Butlr

listen-bridge: per-call PCM archive + ffmpeg→MP3 finalize on stream close

5150ae3d5d47d60a9b7721db2cd779f88798325b · 2026-05-12 16:43:42 -0700 · SteveStudio2

Persists every Twilio MediaStream to data/recordings/<id>.pcm even
when no browser is connected. On close, shells out to ffmpeg to
produce an MP3 + (optionally) kicks local Whisper. Prevents repeat of
the 2026-05-12 DTCI7PuZ Wells Fargo loss where audio path failed and
the call left zero artifact on disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5150ae3d5d47d60a9b7721db2cd779f88798325b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:43:42 2026 -0700

    listen-bridge: per-call PCM archive + ffmpeg→MP3 finalize on stream close
    
    Persists every Twilio MediaStream to data/recordings/<id>.pcm even
    when no browser is connected. On close, shells out to ffmpeg to
    produce an MP3 + (optionally) kicks local Whisper. Prevents repeat of
    the 2026-05-12 DTCI7PuZ Wells Fargo loss where audio path failed and
    the call left zero artifact on disk.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/listen-bridge.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 59 insertions(+), 3 deletions(-)

diff --git a/lib/listen-bridge.js b/lib/listen-bridge.js
index 7dc7e41..b9ebdf3 100644
--- a/lib/listen-bridge.js
+++ b/lib/listen-bridge.js
@@ -13,6 +13,61 @@
 
 const { WebSocketServer } = require('ws');
 const url = require('url');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+// ── Per-call audio archive ──────────────────────────────────────────────
+// As Twilio MediaStreams send µ-law audio to our /stream/<id> WS lane,
+// we ALSO append the decoded PCM16 LE bytes to data/recordings/<id>.pcm.
+// On stream close we shell out to ffmpeg, convert PCM → MP3, drop the .pcm,
+// and (optionally) kick off local Whisper transcription. This way every
+// call gets persisted to disk even if no browser is connected — the bug
+// that lost the 2026-05-12 Wells Fargo call (DTCI7PuZ).
+const RECORDINGS_DIR = path.join(__dirname, '..', 'data', 'recordings');
+fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
+const pcmStreams = new Map();  // callId → { fd, bytes }
+
+function openArchive(callId) {
+  if (pcmStreams.has(callId)) return pcmStreams.get(callId);
+  const pcmPath = path.join(RECORDINGS_DIR, `${callId}.pcm`);
+  const fd = fs.openSync(pcmPath, 'a');
+  const handle = { fd, pcmPath, bytes: 0 };
+  pcmStreams.set(callId, handle);
+  return handle;
+}
+function archiveAppend(callId, pcmBuf) {
+  const h = openArchive(callId);
+  try { fs.writeSync(h.fd, pcmBuf); h.bytes += pcmBuf.length; } catch {}
+}
+function finalizeArchive(callId) {
+  const h = pcmStreams.get(callId);
+  if (!h) return;
+  pcmStreams.delete(callId);
+  try { fs.closeSync(h.fd); } catch {}
+  if (h.bytes < 4096) { try { fs.unlinkSync(h.pcmPath); } catch {} ; return; }
+
+  // Convert raw PCM16 LE @ 8kHz mono → MP3
+  const mp3Path = path.join(RECORDINGS_DIR, `${callId}.mp3`);
+  const ff = spawn('ffmpeg', ['-y','-f','s16le','-ar','8000','-ac','1','-i', h.pcmPath, '-codec:a','libmp3lame','-q:a','4', mp3Path], { stdio: 'ignore' });
+  ff.on('exit', (code) => {
+    if (code === 0) {
+      try { fs.unlinkSync(h.pcmPath); } catch {}
+      console.log(`[listen-bridge] archived call=${callId} → ${path.basename(mp3Path)} (${h.bytes} pcm bytes)`);
+      // Best-effort post-call transcription via the existing transcribe module
+      try {
+        const t = require('./transcribe');
+        if (t && t.transcribe) {
+          t.transcribe(callId, mp3Path).then(r => {
+            if (r && r.ok) console.log(`[listen-bridge] auto-transcribed call=${callId} (source=${r.source})`);
+          }).catch(() => {});
+        }
+      } catch {}
+    } else {
+      console.error(`[listen-bridge] ffmpeg exit ${code} for call=${callId}`);
+    }
+  });
+}
 
 // µ-law → linear PCM16 LE decoder (G.711 standard).
 // Bit-fiddly but small. Adapted from RFC 5391.
@@ -87,10 +142,11 @@ function attachListenBridge(httpServer) {
         if (msg.event !== 'media' || !msg.media || !msg.media.payload) return;
         const mulaw = Buffer.from(msg.media.payload, 'base64');
         const pcm = mulawDecodeBuffer(mulaw);
-        broadcastPcm(callId, pcm);
+        archiveAppend(callId, pcm);   // persist to disk (always, regardless of browser listeners)
+        broadcastPcm(callId, pcm);    // fan out to any connected browser listeners
       });
-      ws.on('close', () => {});
-      ws.on('error', () => {});
+      ws.on('close', () => { finalizeArchive(callId); });
+      ws.on('error', () => { finalizeArchive(callId); });
     });
   });
 }

← 5ef12b5 security(P0): kill ?reveal=1 leak + owner-auth + robots noin  ·  back to Butlr  ·  twilio: number-level voice-default + status-default fallback ab050b2 →