← 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 →