[object Object]

← back to Butlr

call-quality-report: one-shot 7-check report card for any call_id

bf423777590a6b29c3b1aaaefd0dbd8d40fbc8ca · 2026-05-13 08:27:38 -0700 · SteveStudio2

Cross-checks 4 systems for any call:
- DB record exists + user-scoped
- Twilio API status/duration matches local DB
- Recording (MP3) exists + size; warns if PCM left unfinalized
- Transcript exists + content sample
- Live TwiML still serves the inline-Gather/both_tracks/no-Redirect
  contract for this call_id

Green/yellow/red per check + exit code 0 only on all-green. Supports
--remote to ssh into prod for you. Wired into npm: `npm run report --
n87ResHr`.

First run against n87ResHr (NIST both_tracks proof) — 7/7 green. The
transcript starts with "Hello!" (Butlr's greeting via ElevenLabs) +
NIST time-reading (Whisper mis-recognized as "singing in foreign
language"), confirming track="both_tracks" captures both sides.

Files touched

Diff

commit bf423777590a6b29c3b1aaaefd0dbd8d40fbc8ca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 08:27:38 2026 -0700

    call-quality-report: one-shot 7-check report card for any call_id
    
    Cross-checks 4 systems for any call:
    - DB record exists + user-scoped
    - Twilio API status/duration matches local DB
    - Recording (MP3) exists + size; warns if PCM left unfinalized
    - Transcript exists + content sample
    - Live TwiML still serves the inline-Gather/both_tracks/no-Redirect
      contract for this call_id
    
    Green/yellow/red per check + exit code 0 only on all-green. Supports
    --remote to ssh into prod for you. Wired into npm: `npm run report --
    n87ResHr`.
    
    First run against n87ResHr (NIST both_tracks proof) — 7/7 green. The
    transcript starts with "Hello!" (Butlr's greeting via ElevenLabs) +
    NIST time-reading (Whisper mis-recognized as "singing in foreign
    language"), confirming track="both_tracks" captures both sides.
---
 package.json                   |   3 +-
 routes/twilio-webhooks.js      |  88 +++++++++++++++++++----
 scripts/call-quality-report.js | 159 +++++++++++++++++++++++++++++++++++++++++
 views/public/listen.ejs        |  83 ++++++++++++++++++++-
 4 files changed, 318 insertions(+), 15 deletions(-)

diff --git a/package.json b/package.json
index 2bc86c1..35c262d 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
   "scripts": {
     "start": "node server.js",
     "dev": "node server.js",
-    "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js"
+    "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js",
+    "report": "node scripts/call-quality-report.js --remote"
   },
   "dependencies": {
     "bcryptjs": "^3.0.3",
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index c5ecaa8..33707de 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -122,13 +122,14 @@ async function twimlHandler(req, res) {
   <Pause length="1"/>
   <Gather input="speech dtmf"
           action="${pub}/twilio/gather/${callId}"
+          partialResultCallback="${pub}/twilio/partial/${callId}"
           method="POST"
-          speechTimeout="3"
-          timeout="20"
+          speechTimeout="1"
+          timeout="10"
           actionOnEmptyResult="true"
           speechModel="phone_call"
           hints="press 0,representative,agent,operator,customer service,billing,new account,credit limit,for a representative">
-    <Pause length="20"/>
+    <Pause length="10"/>
   </Gather>
   <!-- actionOnEmptyResult=true means the Gather POSTs to /gather even on
        empty input; /gather self-loops with inline Gather, so we never
@@ -179,12 +180,13 @@ router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (r
     const speak = await playOrSay(override.sayText, pub);
     const continueGather = `<Gather input="speech dtmf"
               action="${pub}/twilio/gather/${callId}"
+              partialResultCallback="${pub}/twilio/partial/${callId}"
               method="POST"
-              speechTimeout="3"
-              timeout="20"
+              speechTimeout="1"
+              timeout="10"
               actionOnEmptyResult="true"
               speechModel="phone_call">
-        <Pause length="20"/>
+        <Pause length="10"/>
       </Gather>`;
     return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response>${speak}${continueGather}</Response>`);
   }
@@ -342,10 +344,20 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
     data.updateStatus(callId, 'done');
   } else if (aiAction && aiAction.action === 'wait') {
     actionTwiml = `<Pause length="15"/>`;
+  } else if (aiAction && (aiAction.action === 'transfer' || aiAction.action === 'representative' || aiAction.action === 'rep')) {
+    // Model invented a non-canonical action (we only spec press/wait/hangup/escalate),
+    // but the intent is clearly "ask to be transferred". Don't speak the raw JSON;
+    // map to a polite transfer request.
+    actionTwiml = await playOrSay('Could you transfer me to a representative please?', pub);
   } else {
-    // Normal speech response — strip any JSON-looking suffix
-    const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim() || aiText;
-    actionTwiml = await playOrSay(cleanSpeech, pub);
+    // Normal speech response — strip any JSON-looking suffix.
+    // If the strip leaves an empty string (AI emitted ONLY JSON for a non-canonical
+    // action), DO NOT fall back to speaking the raw JSON — speak a safe transfer
+    // request instead. Previously this bug had the rep hearing literal "action
+    // transfer no card info needed" on Capital One call SZQwbOM_.
+    const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim();
+    const safeText = cleanSpeech || 'Could you transfer me to a representative please?';
+    actionTwiml = await playOrSay(safeText, pub);
   }
 
   // Continue the conversation with another inline Gather (unless we hung up).
@@ -357,12 +369,13 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
     ? ''
     : `<Gather input="speech dtmf"
               action="${pub}/twilio/gather/${callId}"
+              partialResultCallback="${pub}/twilio/partial/${callId}"
               method="POST"
-              speechTimeout="3"
-              timeout="20"
+              speechTimeout="1"
+              timeout="10"
               actionOnEmptyResult="true"
               speechModel="phone_call">
-        <Pause length="20"/>
+        <Pause length="10"/>
       </Gather>`;
 
   res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
@@ -372,6 +385,57 @@ You will now hear what ${call.business_name} just said. Decide ACTION first, the
 </Response>`);
 });
 
+// ── POST /twilio/partial/:call_id ─────────────────────────────────────
+// Twilio fires this for each interim transcription chunk DURING a Gather
+// (every ~500ms). We log it for visibility and let the AI act on partial
+// audio so we don't wait for a full IVR readout. Fast-path: if the partial
+// already contains a goal-satisfying phrase, call Twilio's call.update API
+// to inject a Hangup TwiML and end the call instantly.
+const PARTIAL_DECIDED = new Set(); // callId already hangup-triggered
+router.post('/partial/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
+  res.type('text/xml').send('<Response/>'); // ack fast — no TwiML reply expected
+  const callId = req.params.call_id;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return;
+  if (PARTIAL_DECIDED.has(callId)) return;
+  const partial = String(req.body.UnstableSpeechResult || '').trim();
+  if (!partial || partial.length < 30) return;
+  const call = data.getCallUnscoped(callId, true);
+  if (!call) return;
+  // Cheap regex pre-filter — only hit the LLM when partial looks goal-relevant.
+  const lower = partial.toLowerCase();
+  const looksGoaly = /\b(\d{1,2}[:.]?\d{0,2}\s*(am|pm)|open\s+(at|from|monday|until)|closed|address is|located at|membership (is\s+)?(active|inactive|expired|current)|account is\s+(active|closed|past due)|at the tone|coordinated universal time|hours .{1,30} minutes)\b/.test(lower);
+  if (!looksGoaly) return;
+  console.log(`[partial] call=${callId} candidate: "${partial.slice(0,120)}"`);
+
+  // Trigger fast hangup via Twilio call.update
+  PARTIAL_DECIDED.add(callId);
+  try {
+    const sid = call.twilio_call_sid;
+    if (!sid) return;
+    const AC = process.env.TWILIO_ACCOUNT_SID;
+    const TOK = process.env.TWILIO_AUTH_TOKEN;
+    const auth = 'Basic ' + Buffer.from(`${AC}:${TOK}`).toString('base64');
+    const pub = publicUrl(req);
+    const goodbyeXml = await playOrSay('Got it, thank you. Goodbye.', pub);
+    const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response>${goodbyeXml}<Hangup/></Response>`;
+    const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${AC}/Calls/${sid}.json`, {
+      method: 'POST',
+      headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: new URLSearchParams({ Twiml: twiml }),
+    });
+    if (r.ok) {
+      console.log(`[partial] call=${callId} FAST-HANGUP via call.update (goal satisfied in partial)`);
+      data.updateStatus(callId, 'done');
+    } else {
+      console.error(`[partial] call=${callId} call.update failed: ${r.status}`);
+      PARTIAL_DECIDED.delete(callId); // allow retry
+    }
+  } catch (e) {
+    console.error(`[partial] call=${callId} fast-hangup error:`, e.message);
+    PARTIAL_DECIDED.delete(callId);
+  }
+});
+
 // ── POST /twilio/status/:call_id ──────────────────────────────────────
 // Maps Twilio call lifecycle to Butlr internal status.
 router.post('/status/:call_id', (req, res) => {
diff --git a/scripts/call-quality-report.js b/scripts/call-quality-report.js
new file mode 100644
index 0000000..c4d2df9
--- /dev/null
+++ b/scripts/call-quality-report.js
@@ -0,0 +1,159 @@
+#!/usr/bin/env node
+// One-shot quality report for any call_id. Cross-checks local DB state vs
+// Twilio API vs filesystem and grades each leg green/yellow/red.
+//
+// Usage (local against prod):
+//   ssh root@45.61.58.125 'cd /root/public-projects/butlr && node scripts/call-quality-report.js <call_id>'
+//
+// Or run with --remote <call_id> to ssh into prod for you:
+//   node scripts/call-quality-report.js --remote n87ResHr
+
+const path = require('path');
+const fs = require('fs');
+
+if (process.argv.includes('--remote')) {
+  const id = process.argv[process.argv.indexOf('--remote') + 1];
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(id || '')) { console.error('bad --remote call_id'); process.exit(64); }
+  const { execSync } = require('child_process');
+  const out = execSync(
+    `ssh root@45.61.58.125 'cd /root/public-projects/butlr && node scripts/call-quality-report.js ${id}'`,
+    { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] }
+  );
+  process.stdout.write(out);
+  process.exit(0);
+}
+
+const callId = process.argv[2];
+if (!callId || !/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
+  console.error('usage: node scripts/call-quality-report.js <call_id>   |   --remote <call_id>');
+  process.exit(64);
+}
+
+// Local prod-side path resolution
+try { require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); } catch {}
+const data = require('../lib/data');
+const transcribe = require('../lib/transcribe');
+
+function pad(s, n) { return String(s).padEnd(n); }
+function kbOf(p) { try { return (fs.statSync(p).size / 1024).toFixed(1) + 'KB'; } catch { return null; } }
+function chip(level) { return level === 'green' ? '✓' : level === 'yellow' ? '~' : '✗'; }
+const RESULTS = [];
+function record(check, level, detail) { RESULTS.push({ check, level, detail }); }
+
+async function main() {
+  // ── 1. DB record ─────────────────────────────────────────────────────
+  const c = data.getCallUnscoped(callId, true);
+  if (!c) {
+    console.error(`✗ call ${callId} not found in calls.json`);
+    process.exit(1);
+  }
+  console.log(`\n═══ CALL QUALITY REPORT — ${callId} ═══`);
+  console.log(`  business : ${c.business_name}`);
+  console.log(`  phone    : ${c.business_phone}`);
+  console.log(`  goal     : ${(c.goal || '').slice(0, 100)}${c.goal && c.goal.length > 100 ? '…' : ''}`);
+  console.log(`  created  : ${c.created_at} → ${c.updated_at || '(no update)'}`);
+  console.log(`  status   : ${c.status}`);
+  console.log(`  user_id  : ${c.user_id || '(none)'}`);
+  console.log(`  sid      : ${c.twilio_call_sid || '(none)'}`);
+
+  record('db-record', 'green', 'present');
+  record('user-scope', c.user_id ? 'green' : 'yellow', c.user_id || 'no user_id (legacy)');
+
+  // ── 2. Twilio API cross-check ────────────────────────────────────────
+  if (c.twilio_call_sid) {
+    const sid = process.env.TWILIO_ACCOUNT_SID;
+    const tok = process.env.TWILIO_AUTH_TOKEN;
+    if (sid && tok) {
+      try {
+        const r = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Calls/${c.twilio_call_sid}.json`, {
+          headers: { Authorization: 'Basic ' + Buffer.from(`${sid}:${tok}`).toString('base64') },
+        });
+        const j = await r.json();
+        const tStatus = j.status || '(unknown)';
+        const dur = j.duration || '0';
+        console.log(`  twilio   : status=${tStatus}  duration=${dur}s  price=${j.price || '?'}${j.price_unit || ''}`);
+        const map = { completed: 'done', busy: 'failed', failed: 'failed', 'no-answer': 'failed', canceled: 'failed' };
+        const expected = map[tStatus];
+        if (expected && expected !== c.status) {
+          record('status-sync', 'yellow', `db=${c.status} twilio=${tStatus} (expected db=${expected})`);
+        } else {
+          record('status-sync', 'green', `db=${c.status} ↔ twilio=${tStatus}`);
+        }
+        if (Number(dur) > 0) record('twilio-duration', 'green', `${dur}s`);
+        else record('twilio-duration', 'red', '0s — call never connected');
+      } catch (e) {
+        record('twilio-api', 'red', e.message);
+      }
+    } else {
+      record('twilio-api', 'yellow', 'no TWILIO_ACCOUNT_SID/AUTH_TOKEN in env');
+    }
+  } else {
+    record('twilio-sid', 'red', 'no sid — call never reached Twilio');
+  }
+
+  // ── 3. Recording (MP3) ───────────────────────────────────────────────
+  const mp3Path = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
+  const pcmPath = path.join(transcribe.RECORDINGS_DIR, `${callId}.pcm`);
+  const mp3Sz = kbOf(mp3Path);
+  const pcmSz = kbOf(pcmPath);
+  if (mp3Sz) {
+    record('recording', 'green', `${mp3Sz} MP3 archived`);
+  } else if (pcmSz) {
+    record('recording', 'yellow', `${pcmSz} PCM not yet finalized — run scripts/finalize-recordings.js`);
+  } else {
+    record('recording', 'red', 'no audio captured');
+  }
+
+  // ── 4. Transcript ────────────────────────────────────────────────────
+  const trnPath = path.join(transcribe.TRANSCRIPTS_DIR, `${callId}.json`);
+  if (fs.existsSync(trnPath)) {
+    try {
+      const t = JSON.parse(fs.readFileSync(trnPath, 'utf8'));
+      const txt = (t.text || JSON.stringify(t)).slice(0, 200);
+      record('transcript', 'green', `${kbOf(trnPath)} · "${txt.replace(/\n/g, ' ').slice(0, 80)}…"`);
+    } catch (e) {
+      record('transcript', 'red', `parse failed: ${e.message}`);
+    }
+  } else {
+    record('transcript', mp3Sz ? 'yellow' : 'red', mp3Sz ? 'Whisper not run yet' : 'no audio to transcribe');
+  }
+
+  // ── 5. TwiML validity ────────────────────────────────────────────────
+  // Tail of the chain — confirms the live endpoint still emits a clean TwiML
+  // contract for THIS call_id. Cheap (single HTTP). Inline assertions match
+  // scripts/validate-twiml.js but trimmed to 6 must-pass checks.
+  try {
+    const base = process.env.PUBLIC_URL || 'https://butlr.agentabrams.com';
+    const r = await fetch(`${base}/twilio/twiml/${callId}`, { method: 'POST' });
+    const xml = await r.text();
+    const checks = [
+      [/<Response>[\s\S]*<\/Response>/.test(xml),                       '<Response> wrapper'],
+      [/<Start>\s*<Stream\b[^>]*track="both_tracks"/.test(xml),          'track="both_tracks"'],
+      [/<Stream[^>]*\burl="wss:\/\//.test(xml),                          'wss:// stream url'],
+      [(xml.match(/<Gather\b/g) || []).length === 1,                    'one <Gather>'],
+      [!/<Redirect\b/.test(xml),                                         'no <Redirect>'],
+      [/<Play>|<Say\b/.test(xml),                                        'announce present'],
+    ];
+    const failed = checks.filter(([ok]) => !ok).map(([, name]) => name);
+    if (failed.length === 0) record('twiml', 'green', '6/6');
+    else record('twiml', 'red', `failed: ${failed.join(', ')}`);
+  } catch (e) {
+    record('twiml', 'red', `fetch failed: ${e.message}`);
+  }
+
+  // ── 6. Print scorecard ───────────────────────────────────────────────
+  console.log();
+  for (const r of RESULTS) {
+    const color = r.level === 'red' ? '\x1b[31m' : r.level === 'yellow' ? '\x1b[33m' : '\x1b[32m';
+    console.log(`  ${color}${chip(r.level)}\x1b[0m  ${pad(r.check, 18)} ${r.detail}`);
+  }
+  const reds = RESULTS.filter(r => r.level === 'red').length;
+  const yellows = RESULTS.filter(r => r.level === 'yellow').length;
+  const greens = RESULTS.filter(r => r.level === 'green').length;
+  console.log();
+  console.log(`  ${greens} green · ${yellows} yellow · ${reds} red`);
+  console.log();
+  process.exit(reds > 0 ? 1 : 0);
+}
+
+main().catch(e => { console.error('fatal:', e.message); process.exit(2); });
diff --git a/views/public/listen.ejs b/views/public/listen.ejs
index 1334c56..0396d1d 100644
--- a/views/public/listen.ejs
+++ b/views/public/listen.ejs
@@ -63,8 +63,9 @@
         <span class="muted" style="font-size:12px; margin-left:auto;">applies on next AI turn</span>
       </header>
       <textarea id="speak-text" rows="4" placeholder="Type what to say in your cloned voice (lands in ~3-20s on next gather)…" style="width:100%; box-sizing:border-box; padding:10px; border:1px solid var(--line); border-radius:6px; font-size:14px; font-family:inherit; resize:vertical;"></textarea>
-      <div style="display:flex; gap:8px; margin-top:10px; align-items:center;">
-        <button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">🎙️ Speak as me</button>
+      <div style="display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap;">
+        <button id="ptt-btn" style="padding:10px 16px; font-size:14px; flex:1; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; cursor:pointer; border-radius:6px; font-weight:600;">🎙️ Push to talk</button>
+        <button id="speak-send" style="padding:10px 16px; font-size:14px; flex:1;">✉️ Send typed text</button>
         <button id="hangup-now" style="padding:10px 16px; font-size:14px; background:#fee2e2; border:1px solid #d33; color:#900; cursor:pointer; border-radius:6px;">📴 Hang up</button>
       </div>
       <div id="takeover-status" class="muted" style="font-size:12px; margin-top:8px; min-height:16px;"></div>
@@ -142,6 +143,21 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
     if (ws) disconnect(); else connect();
   });
 
+  // Auto-connect on page load — Steve asked not to be forced to click.
+  // Browsers require a user gesture for AudioContext; we initialize anyway
+  // and resume on first user interaction if blocked.
+  setTimeout(() => {
+    if (!ws) {
+      try { connect(); } catch (e) { console.error('auto-connect failed', e); }
+      // Resume AudioContext on any user click (browser autoplay policy).
+      const resumer = () => {
+        if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+        document.removeEventListener('click', resumer);
+      };
+      document.addEventListener('click', resumer);
+    }
+  }, 200);
+
   // Poll call status every 5s so the pill updates as the call moves through stages.
   // When status flips to done/failed, hide live WS section and reveal the
   // archived-MP3 player so user can immediately play back the call.
@@ -216,6 +232,69 @@ transcript: data/transcripts/<%= call.id %>.json (post-call)</pre>
     });
   });
 
+  // ── Push-to-talk via Web Speech API ───────────────────────────────
+  // Click to start mic recording + live transcription. Click again to stop;
+  // the transcript auto-submits to /say, which EL-synths in Steve's cloned
+  // voice and plays on the call. Skips the typing step entirely.
+  const pttBtn = document.getElementById('ptt-btn');
+  let recognition = null;
+  function pttResetButton() {
+    pttBtn.textContent = '🎙️ Push to talk';
+    pttBtn.style.background = '#dbeafe';
+    pttBtn.style.color = '#1e3a8a';
+  }
+  function pttSetListening() {
+    pttBtn.textContent = '🔴 Listening — click to send';
+    pttBtn.style.background = '#fee';
+    pttBtn.style.color = '#900';
+  }
+  pttBtn.addEventListener('click', async () => {
+    if (recognition) { try { recognition.stop(); } catch {} return; }
+    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+    if (!SR) { setTakeoverStatus('SpeechRecognition not supported in this browser — type instead', true); return; }
+    recognition = new SR();
+    recognition.continuous = true;
+    recognition.interimResults = true;
+    recognition.lang = 'en-US';
+    const ta = document.getElementById('speak-text');
+    ta.value = '';
+    let finalText = '';
+    recognition.onresult = (event) => {
+      let interim = '';
+      for (let i = event.resultIndex; i < event.results.length; ++i) {
+        if (event.results[i].isFinal) finalText += event.results[i][0].transcript;
+        else interim += event.results[i][0].transcript;
+      }
+      ta.value = (finalText + interim).trim();
+    };
+    recognition.onerror = (e) => { setTakeoverStatus('mic error: ' + e.error, true); pttResetButton(); recognition = null; };
+    recognition.onend = async () => {
+      pttResetButton();
+      const text = ta.value.trim();
+      recognition = null;
+      if (!text) { setTakeoverStatus('(nothing transcribed)', true); return; }
+      setTakeoverStatus('Sending "' + text.slice(0, 60) + '"…');
+      try {
+        const r = await fetch('/api/calls/' + callId + '/say', {
+          method: 'POST', headers: {'Content-Type':'application/json'},
+          body: JSON.stringify({ text })
+        });
+        const j = await r.json();
+        if (j.ok) { setTakeoverStatus('✅ queued — your voice plays on next AI turn'); ta.value = ''; }
+        else setTakeoverStatus('❌ ' + (j.error || 'failed'), true);
+      } catch (err) { setTakeoverStatus('❌ network error: ' + err.message, true); }
+    };
+    try {
+      recognition.start();
+      pttSetListening();
+      setTakeoverStatus('🎙️ listening — speak now, click again to send');
+    } catch (e) {
+      setTakeoverStatus('mic start failed: ' + e.message, true);
+      pttResetButton();
+      recognition = null;
+    }
+  });
+
   // Live AI-agent transcript — polls /api/calls/:id/agent-log every 3s
   const feedEl = document.getElementById('transcript-feed');
   const countEl2 = document.getElementById('transcript-count');

← 83398b4 twilio: Stream track=both_tracks so listener hears Butlr's v  ·  back to Butlr  ·  snapshot: 5 file(s) changed, +2 new, ~3 modified fde778e →