--- /home/claude/work/cur.php	2026-07-27 15:12:35.410147023 +0000
+++ ivr.php	2026-07-27 18:25:19.070686723 +0000
@@ -47,6 +47,28 @@
 define('SESSION_DIR',    '/tmp/ivr_sessions');
 define('LOG_FILE',       '/tmp/ivr_twilio.log');
 
+// Generated ElevenLabs audio (price quotes, style lists) lands here instead of
+// prompts/joined/, so your curated prompt library stays clean. These files are
+// disposable — Twilio only needs them for the few seconds the <Play> runs.
+// Anything older than TTS_KEEP_MINUTES is deleted next time one is generated.
+define('TTS_DIR',          '/home/auto11/public_html/ivr/prompts/tts/');
+define('TTS_URL',          'https://autoglasshosting.com/ivr/prompts/tts/');
+define('TTS_KEEP_MINUTES', 60);
+
+// ─── PROCESSING FILLER ────────────────────────────────────────────────────────
+// filler_loop.wav is a 1.34s seamless cut of silence_filler.mp3 (the bed inside
+// it repeats every 0.67s, so 2 cycles loop with no audible seam). WAV, not MP3:
+// MP3 encoder padding puts a click between <Play loop> iterations.
+//
+// The point is not just to cover silence — it's that transcription now runs
+// WHILE the filler plays instead of after it. Set FILLER_BACKGROUND to false to
+// go back to the original 9.5s block with Whisper running after it.
+define('FILLER_BACKGROUND',  true);
+define('FILLER_LOOP',        'joined/filler_loop.wav');
+define('FILLER_BLOCK',       'joined/silence_filler.mp3');
+define('FILLER_FIRST_LOOPS', 2);   // ~2.7s opening, covers most transcriptions
+define('FILLER_MAX_POLLS',   6);   // then ~8s of 1.34s polls before giving up
+
 // Account profiles keyed by DNIS
 $ACCOUNTS = array(
     '+18778072201' => array('code' => 'PTL',     'company' => 'Penske',  'ask_po' => true,  'po_prefill' => '',         'aff_id' => 0, 'requires_payment' => false),
@@ -57,6 +79,9 @@
 
 // ─── BOOTSTRAP ────────────────────────────────────────────────────────────────
 error_reporting(0);
+// Buffer output so ivr_flush_response() can send a correct Content-Length on
+// hosts without fastcgi_finish_request(). PHP flushes this at shutdown anyway.
+if (ob_get_level() === 0) ob_start();
 if (!is_dir(SESSION_DIR)) mkdir(SESSION_DIR, 0700, true);
 
 function ivr_log($msg) {
@@ -969,19 +994,61 @@
 
 // ─── ZIP CODE LOOKUP ──────────────────────────────────────────────────────────
 // ─── ELEVENLABS TTS ───────────────────────────────────────────────────────────
+// Delete generated audio past its keep window. Runs only after a new file is
+// created, so it costs nothing on a normal call.
+function tts_purge_old() {
+    $cutoff = time() - (TTS_KEEP_MINUTES * 60);
+    $n = 0;
+    foreach (glob(TTS_DIR . '*.mp3') as $f) {
+        if (filemtime($f) < $cutoff && @unlink($f)) $n++;
+    }
+    if ($n) ivr_log("TTS purge: removed $n expired file" . ($n === 1 ? '' : 's'));
+}
+
+// Spell a price out in words before it reaches TTS. Handing ElevenLabs a raw
+// "$415.77" leaves the expansion up to the engine, which is where the garbled
+// tail after "seventy-seven" comes from. Words remove the guesswork.
+function num_to_words($n) {
+    $n = (int)$n;
+    $ones = array('zero','one','two','three','four','five','six','seven','eight','nine','ten',
+                  'eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen',
+                  'eighteen','nineteen');
+    $tens = array(2 => 'twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety');
+    if ($n < 0)    return 'minus ' . num_to_words(-$n);
+    if ($n < 20)   return $ones[$n];
+    if ($n < 100)  return $tens[intdiv($n, 10)] . ($n % 10 ? '-' . $ones[$n % 10] : '');
+    if ($n < 1000) return $ones[intdiv($n, 100)] . ' hundred' . ($n % 100 ? ' ' . num_to_words($n % 100) : '');
+    if ($n < 1000000) {
+        return num_to_words(intdiv($n, 1000)) . ' thousand'
+             . ($n % 1000 ? ' ' . num_to_words($n % 1000) : '');
+    }
+    return (string)$n;
+}
+
+function spoken_price($amount) {
+    $amount  = round((float)$amount, 2);
+    $dollars = (int)floor($amount);
+    $cents   = (int)round(($amount - $dollars) * 100);
+    if ($cents === 100) { $dollars++; $cents = 0; }
+    $out = num_to_words($dollars) . ' dollar' . ($dollars === 1 ? '' : 's');
+    if ($cents > 0) $out .= ' and ' . num_to_words($cents) . ' cent' . ($cents === 1 ? '' : 's');
+    return $out;
+}
+
 function elevenlabs_say($text, $filename) {
     global $config;
     $api_key  = $config['elevenlabs_api_key']  ?? '';
     $voice_id = $config['elevenlabs_voice_id'] ?? '';
     if (!$api_key || !$voice_id) return '';
 
-    $out_dir = '/home/auto11/public_html/ivr/prompts/joined/';
+    $out_dir = TTS_DIR;
     if (!is_dir($out_dir)) mkdir($out_dir, 0755, true);
     $out_path = $out_dir . $filename;
-    $out_url  = 'https://autoglasshosting.com/ivr/prompts/joined/' . $filename;
+    $out_url  = TTS_URL . $filename;
 
-    // Return cached if exists and less than 1 hour old
-    if (file_exists($out_path) && (time() - filemtime($out_path)) < 3600) {
+    // $filename is an md5 of $text, so an existing file is always correct audio.
+    // Reuse it — saves an API call and 1-3s of dead air on repeat quotes.
+    if (file_exists($out_path) && filesize($out_path) > 0) {
         return $out_url;
     }
 
@@ -1008,6 +1075,7 @@
     if ($code === 200 && $audio) {
         file_put_contents($out_path, $audio);
         ivr_log("ElevenLabs TTS generated: $filename (" . strlen($audio) . " bytes)");
+        tts_purge_old();
         return $out_url;
     }
     ivr_log("ElevenLabs TTS failed: HTTP $code");
@@ -1145,6 +1213,132 @@
     exit;
 }
 
+// ─── BACKGROUND TRANSCRIPTION ────────────────────────────────────────────────
+// Hand the already-echoed TwiML to Twilio and keep this PHP process running, so
+// Twilio starts playing the filler while we transcribe underneath it.
+function ivr_flush_response() {
+    if (function_exists('fastcgi_finish_request'))   { fastcgi_finish_request();   return 'fastcgi'; }
+    if (function_exists('litespeed_finish_request')) { litespeed_finish_request(); return 'litespeed'; }
+    // Content-Length is REQUIRED here — without it Twilio can't tell the response
+    // has ended and will hold the socket until Whisper finishes, defeating this.
+    ignore_user_abort(true);
+    $len = ob_get_length();
+    if (!headers_sent()) {
+        header('Content-Type: text/xml');
+        header('Content-Length: ' . (int)$len);
+        header('Connection: close');
+    }
+    while (ob_get_level() > 0) ob_end_flush();
+    flush();
+    return 'flush';
+}
+
+// $loops = how many 1.34s cycles of the bed to play before coming back.
+function filler_twiml($next_action, $sid, $loops = 1) {
+    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
+    echo '<Response>' . "\n";
+    if (FILLER_BACKGROUND) {
+        echo '  <Play loop="' . (int)$loops . '">' . PROMPT_BASE . FILLER_LOOP . '</Play>' . "\n";
+    } else {
+        echo '  <Play>' . PROMPT_BASE . FILLER_BLOCK . '</Play>' . "\n";
+    }
+    echo '  <Redirect method="POST">' . htmlspecialchars(next_url($next_action, $sid)) . '</Redirect>' . "\n";
+    echo '</Response>' . "\n";
+}
+
+// Emit filler + redirect, flush to Twilio, THEN transcribe into the session.
+// $post is an optional callable($transcript, &$sess) for follow-up lookups that
+// would otherwise block the processing pass (geocoding, Claude extraction).
+function capture_and_transcribe($recording_url, $key, $next_action, $sid, &$sess, $post = null) {
+    $sess['rec_' . $key]        = $recording_url;
+    $sess[$key . '_processing'] = true;
+    $sess[$key . '_polls']      = 0;
+    if (FILLER_BACKGROUND) $sess[$key . '_stt_pending'] = true;
+    sess_save($sid, $sess);
+
+    filler_twiml($next_action, $sid, FILLER_FIRST_LOOPS);
+    if (!FILLER_BACKGROUND) exit;
+
+    $mode = ivr_flush_response();
+    $t0   = microtime(true);
+    $text = whisper_transcribe($recording_url);
+
+    $fresh = sess_load($sid);            // poll pass may have written meanwhile
+    if (is_array($fresh) && $fresh) $sess = $fresh;
+    $sess[$key . '_transcript'] = $text;
+    if (is_callable($post) && $text !== '') $post($text, $sess);
+    $sess[$key . '_stt_pending'] = false;
+    sess_save($sid, $sess);
+    ivr_log("[$sid] bg STT ($mode) $key: " . round((microtime(true) - $t0) * 1000) . "ms");
+    exit;
+}
+
+// Read a background transcript. Not ready yet? Play one more 1.34s cycle and
+// re-enter the same state, so the caller waits in small increments instead of
+// a fixed block.
+function take_transcript($key, $recording_url, $sid, &$sess, $poll_action = '') {
+    if (!FILLER_BACKGROUND) return whisper_transcribe($recording_url);
+
+    if (isset($sess[$key . '_transcript'])) {
+        $t = $sess[$key . '_transcript'];
+        unset($sess[$key . '_transcript'], $sess[$key . '_stt_pending'], $sess[$key . '_polls']);
+        sess_save($sid, $sess);
+        return $t;
+    }
+    if ($poll_action !== '' && !empty($sess[$key . '_stt_pending'])) {
+        $polls = (int)($sess[$key . '_polls'] ?? 0);
+        if ($polls < FILLER_MAX_POLLS) {
+            $sess[$key . '_polls']      = $polls + 1;
+            $sess[$key . '_processing'] = true;
+            sess_save($sid, $sess);
+            ivr_log("[$sid] STT poll " . ($polls + 1) . " for $key");
+            filler_twiml($poll_action, $sid, 1);
+            exit;
+        }
+        ivr_log("[$sid] STT poll limit hit for $key");
+    }
+    ivr_log("[$sid] bg STT miss on $key — falling back to blocking");
+    unset($sess[$key . '_polls']);
+    return whisper_transcribe($recording_url);
+}
+
+// Pull email + phone out of a spoken contact answer. Claude first, regex after.
+function parse_contact($ct, $acct) {
+    $cf    = claude_extract_order($ct, $acct);
+    $email = $cf['email'] ?? '';
+    $phone = $cf['phone'] ?? '';
+    if (!$phone) { $d = preg_replace('/[^0-9]/', '', $ct); if (strlen($d) >= 10) $phone = substr($d, -10); }
+    if (!$email) {
+        $er = preg_replace('/\s+at\s+/i', '@', strtolower($ct));
+        $er = preg_replace('/\s+dot\s+/i', '.', $er);
+        $er = preg_replace('/\s+/', '', $er);
+        if (strpos($er, '@') !== false) $email = $er;
+    }
+    return array('email' => $email, 'phone' => $phone);
+}
+
+// Clean a spoken street address, then normalize it via Nominatim.
+function validate_street($street_transcript, $zip, $sid = '') {
+    $street = ucwords(strtolower(trim($street_transcript)));
+    if ($street === '') return '';
+    $nom_url = 'https://nominatim.openstreetmap.org/search?q=' . urlencode($street . ' ' . $zip . ' USA')
+             . '&format=json&limit=1&addressdetails=1';
+    $ch = curl_init($nom_url);
+    curl_setopt_array($ch, array(
+        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5,
+        CURLOPT_SSL_VERIFYPEER => false,
+        CURLOPT_USERAGENT      => 'W2G-IVR/1.0 (w2gusa@gmail.com)',
+    ));
+    $nom_data = json_decode(curl_exec($ch), true);
+    curl_close($ch);
+    if (!empty($nom_data[0]['address'])) {
+        $a = $nom_data[0]['address'];
+        $h = $a['house_number'] ?? ''; $r = $a['road'] ?? '';
+        if ($h && $r) { ivr_log("[$sid] Address validated: '$street' → '$h $r'"); return $h . ' ' . $r; }
+    }
+    return $street;
+}
+
 // The glass question, asked via Deepgram Gather instead of Record + Whisper.
 // timeout=2 / speechTimeout=1 are the settings that made this field feel fast:
 // Deepgram returns the answer in the SAME webhook, so there is no recording to
@@ -1596,8 +1790,8 @@
         // Processing pass - transcribe ZIP
         if (!empty($sess['zip_processing'])) {
             unset($sess['zip_processing']);
-            $rec = isset($sess['rec_zip']) ? $sess['rec_zip'] : $recording_url;
-            $zip_transcript = whisper_transcribe($rec);
+            $rec = !empty($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ($sess['rec_zip'] ?? '');
+            $zip_transcript = take_transcript('zip', $rec, $sid, $sess, 'got_zip');
             ivr_log("[$sid] ZIP transcript: $zip_transcript");
             $zip = preg_replace('/[^0-9]/', '', $zip_transcript);
             // Handle spoken zip like "nine three one oh three"
@@ -1628,16 +1822,7 @@
         if (empty($recording_url)) {
             twiml_record('joined/01_welcome.mp3', 'got_zip', $sid, 6);
         }
-        // Store recording and play zipwait while we process
-        $sess['rec_zip'] = $recording_url;
-        $sess['zip_processing'] = true;
-        sess_save($sid, $sess);
-        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
-        echo '<Response>' . "\n";
-        echo '  <Play>' . PROMPT_BASE . 'joined/silence_filler.mp3</Play>' . "\n";
-        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('got_zip', $sid)) . '</Redirect>' . "\n";
-        echo '</Response>' . "\n";
-        exit;
+        capture_and_transcribe($recording_url, 'zip', 'got_zip', $sid, $sess);
 
     case 'process_zip':
         ask_glass('joined/03_ask_glass.mp3', $sid);
@@ -1663,11 +1848,19 @@
 
     // ── GOT OTHER GLASS ───────────────────────────────────────────────────────
     case 'got_other_glass':
-        $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : '';
-        if (empty($recording_url)) {
-            twiml_record('joined/04_otherglass.mp3', 'got_other_glass', $sid, 8);
+        // This case used to call Whisper inline with NO filler — the caller
+        // heard several seconds of dead silence.
+        if (!empty($sess['otherglass_processing'])) {
+            unset($sess['otherglass_processing']);
+            $rec = $sess['rec_otherglass'] ?? '';
+            $other_transcript = take_transcript('otherglass', $rec, $sid, $sess, 'got_other_glass');
+        } else {
+            $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : '';
+            if (empty($recording_url)) {
+                twiml_record('joined/04_otherglass.mp3', 'got_other_glass', $sid, 8);
+            }
+            capture_and_transcribe($recording_url, 'otherglass', 'got_other_glass', $sid, $sess);
         }
-        $other_transcript = whisper_transcribe($recording_url);
         ivr_log("[$sid] Other glass transcript: $other_transcript");
         if (empty($other_transcript)) {
             twiml_record('joined/04_1_otherglass_retry.mp3', 'got_other_glass', $sid, 8);
@@ -1698,20 +1891,12 @@
         if (empty($recording_url)) {
             twiml_record('joined/05_yearmake.mp3', 'got_yearmake', $sid, 12);
         }
-        // Store recording URL in session and play wait prompt
-        $sess['rec_yearmake'] = $recording_url;
-        sess_save($sid, $sess);
-        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
-        echo '<Response>' . "\n";
-        echo '  <Play>' . PROMPT_BASE . 'joined/silence_filler.mp3</Play>' . "\n";
-        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('process_yearmake', $sid)) . '</Redirect>' . "\n";
-        echo '</Response>' . "\n";
-        exit;
+        capture_and_transcribe($recording_url, 'yearmake', 'process_yearmake', $sid, $sess);
 
     // ── PROCESS YEAR/MAKE/MODEL ───────────────────────────────────────────────
     case 'process_yearmake':
         $recording_url = isset($sess['rec_yearmake']) ? $sess['rec_yearmake'] : '';
-        $transcript = whisper_transcribe($recording_url);
+        $transcript = take_transcript('yearmake', $recording_url, $sid, $sess, 'process_yearmake');
         ivr_log("[$sid] YMM transcript: $transcript");
         if (empty($transcript)) {
             twiml_record('joined/06_yearmake_retry.mp3', 'got_yearmake', $sid, 12);
@@ -1898,9 +2083,11 @@
         echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
         echo '<Response>' . "\n";
         if ($price > 0) {
-            $price_str   = '$' . number_format($price, 2);
+            $price_str   = '$' . number_format($price, 2);   // for <Say> fallback / display
+            $price_spoken = spoken_price($price);             // for ElevenLabs
             $vehicle_str = trim(($sess['year']??'') . ' ' . ($sess['make']??'') . ' ' . ($sess['model']??''));
-            $quote_text  = 'Your installed price for a ' . $vehicle_str . ' ' . strtolower($sess['glass']??'windshield') . ' is ' . $price_str . '. To place an order, say order or press 1. To speak with an agent, press 3.';
+            $quote_text  = 'Your installed price for a ' . $vehicle_str . ' ' . strtolower($sess['glass']??'windshield') . ' is ' . $price_spoken . '. To place an order, say order or press 1. To speak with an agent, press 3.';
+            ivr_log("[$sid] Quote spoken as: $price_spoken");
 
             echo '  <Gather input="dtmf speech" action="' . htmlspecialchars(next_url('got_price_response', $sid)) . '" method="POST" timeout="8" numDigits="1" hints="order" speechTimeout="3" speechModel="deepgram_nova-2">' . "\n";
             if (!empty($config['elevenlabs_price_voice'])) {
@@ -1944,22 +2131,15 @@
         if (!empty($sess['name_processing'])) {
             unset($sess['name_processing']);
             $recording_url = isset($sess['rec_name']) ? $sess['rec_name'] : '';
+            $sess['rec_name'] = $recording_url;
         } else {
             $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : '';
             if (empty($recording_url)) {
                 twiml_record('joined/07_name.mp3', 'got_name', $sid, 8);
             }
-            $sess['rec_name'] = $recording_url;
-            $sess['name_processing'] = true;
-            sess_save($sid, $sess);
-            echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
-            echo '<Response>' . "\n";
-            echo '  <Play>' . PROMPT_BASE . 'joined/silence_filler.mp3</Play>' . "\n";
-            echo '  <Redirect method="POST">' . htmlspecialchars(next_url('got_name', $sid)) . '</Redirect>' . "\n";
-            echo '</Response>' . "\n";
-            exit;
+            capture_and_transcribe($recording_url, 'name', 'got_name', $sid, $sess);
         }
-        $name_transcript = whisper_transcribe($recording_url);
+        $name_transcript = take_transcript('name', $recording_url, $sid, $sess, 'got_name');
         ivr_log("[$sid] Name transcript: $name_transcript");
         if (empty($name_transcript)) {
             twiml_record('joined/08_name_retry.mp3', 'got_name', $sid, 8);
@@ -1987,35 +2167,16 @@
             if (empty($rec)) {
                 twiml_record('joined/09_street.mp3', 'got_street', $sid, 15);
             }
-            $street_transcript = whisper_transcribe($rec);
+            $street_transcript = take_transcript('street', $rec, $sid, $sess, 'got_street');
             ivr_log("[$sid] Street transcript: $street_transcript");
             if (empty($street_transcript)) {
                 sess_save($sid, $sess);
                 twiml_record('joined/10_street_retry.mp3', 'got_street', $sid, 15);
             }
-            $street = ucwords(strtolower(trim($street_transcript)));
-            // Address validation via Nominatim
-            $zip     = isset($sess['zip']) ? $sess['zip'] : '';
-            $query   = urlencode($street . ' ' . $zip . ' USA');
-            $nom_url = 'https://nominatim.openstreetmap.org/search?q=' . $query . '&format=json&limit=1&addressdetails=1';
-            $ch = curl_init($nom_url);
-            curl_setopt_array($ch, array(
-                CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5,
-                CURLOPT_SSL_VERIFYPEER => false,
-                CURLOPT_USERAGENT      => 'W2G-IVR/1.0 (w2gusa@gmail.com)',
-            ));
-            $nom_data = json_decode(curl_exec($ch), true);
-            curl_close($ch);
-            if (!empty($nom_data[0]['address'])) {
-                $addr = $nom_data[0]['address'];
-                $h = isset($addr['house_number']) ? $addr['house_number'] : '';
-                $r = isset($addr['road'])         ? $addr['road']         : '';
-                if ($h && $r) {
-                    $validated = $h . ' ' . $r;
-                    ivr_log("[$sid] Address validated: '$street' → '$validated'");
-                    $street = $validated;
-                }
-            }
+            // Geocoding already ran in the flushed window; only redo it here if we
+            // fell back to a blocking transcribe.
+            $street = $sess['street_validated'] ?? validate_street($street_transcript, $sess['zip'] ?? '', $sid);
+            unset($sess['street_validated']);
             $sess['street'] = $street;
             sess_save($sid, $sess);
             if (!empty($acct['ask_po'])) {
@@ -2032,16 +2193,12 @@
             twiml_record('joined/09_street.mp3', 'got_street', $sid, 15);
         }
 
-        // Recording received — store in session, set processing flag, play wait
-        $sess['rec_street'] = $recording_url;
-        $sess['street_processing'] = true;
-        sess_save($sid, $sess);
-        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
-        echo '<Response>' . "\n";
-        echo '  <Play>' . PROMPT_BASE . 'joined/silence_filler.mp3</Play>' . "\n";
-        echo '  <Redirect method="POST">' . htmlspecialchars(next_url('got_street', $sid)) . '</Redirect>' . "\n";
-        echo '</Response>' . "\n";
-        exit;
+        // Transcribe AND geocode while the filler plays
+        $street_zip = $sess['zip'] ?? '';
+        capture_and_transcribe($recording_url, 'street', 'got_street', $sid, $sess,
+            function ($text, &$sx) use ($street_zip, $sid) {
+                $sx['street_validated'] = validate_street($text, $street_zip, $sid);
+            });
 
     // ── GOT INSTALL DATE (retail only) ───────────────────────────────────────
     case 'got_install_date':
@@ -2053,17 +2210,9 @@
             if (empty($recording_url)) {
                 twiml_record('joined/21_install_date.mp3', 'got_install_date', $sid, 8);
             }
-            $sess['rec_date'] = $recording_url;
-            $sess['date_processing'] = true;
-            sess_save($sid, $sess);
-            echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
-            echo '<Response>' . "\n";
-            echo '  <Play>' . PROMPT_BASE . 'joined/silence_filler.mp3</Play>' . "\n";
-            echo '  <Redirect method="POST">' . htmlspecialchars(next_url('got_install_date', $sid)) . '</Redirect>' . "\n";
-            echo '</Response>' . "\n";
-            exit;
+            capture_and_transcribe($recording_url, 'date', 'got_install_date', $sid, $sess);
         }
-        $date_transcript = whisper_transcribe($rec);
+        $date_transcript = take_transcript('date', $rec, $sid, $sess, 'got_install_date');
         ivr_log("[$sid] Date transcript: $date_transcript");
         if (empty($date_transcript)) {
             twiml_record('joined/22_install_date_retry.mp3', 'got_install_date', $sid, 8);
@@ -2129,28 +2278,21 @@
             unset($sess['contact_processing']);
             $rec = $sess['rec_contact'] ?? '';
             if (!$rec) twiml_record('joined/15_contact.mp3', 'got_contact', $sid, 30);
-            $ct = whisper_transcribe($rec);
+            $ct = take_transcript('contact', $rec, $sid, $sess, 'got_contact');
             ivr_log("[$sid] Contact transcript: $ct");
             if (!$ct) twiml_record('joined/16_contact_retry.mp3', 'got_contact', $sid, 30);
-            $cf = claude_extract_order($ct, $acct);
-            $email = $cf['email'] ?? '';
-            $phone = $cf['phone'] ?? '';
-            if (!$phone) { $d=preg_replace("/[^0-9]/","",$ct); if(strlen($d)>=10) $phone=substr($d,-10); }
-            if (!$email) { $er=preg_replace("/\s+at\s+/i","@",strtolower($ct)); $er=preg_replace("/\s+dot\s+/i",".",$er); $er=preg_replace("/\s+/","",$er); if(strpos($er,"@")!==false) $email=$er; }
+            // Claude extraction already ran in the flushed window
+            $parsed = $sess['contact_parsed'] ?? parse_contact($ct, $acct);
+            $email = $parsed['email']; $phone = $parsed['phone'];
+            unset($sess['contact_parsed']);
             $sess['email']=$email; $sess['phone']=$phone;
             sess_save($sid,$sess);
             twiml_gather('joined/19_notes_prompt.mp3', 'got_notes_final', $sid, 8);
         }
         $ru = $_POST['RecordingUrl'] ?? '';
         if (!$ru) twiml_record('joined/15_contact.mp3','got_contact',$sid,30);
-        $sess['rec_contact']=$ru; $sess['contact_processing']=true;
-        sess_save($sid,$sess);
-        echo '<?xml version="1.0" encoding="UTF-8"?>'."\n";
-        echo '<Response>'."\n";
-        echo '  <Play>'.PROMPT_BASE.'joined/silence_filler.mp3</Play>'."\n";
-        echo '  <Redirect method="POST">'.htmlspecialchars(next_url('got_contact',$sid)).'</Redirect>'."\n";
-        echo '</Response>'."\n";
-        exit;
+        capture_and_transcribe($ru, 'contact', 'got_contact', $sid, $sess,
+            function ($text, &$sx) use ($acct) { $sx['contact_parsed'] = parse_contact($text, $acct); });
 
     case 'got_cell_confirm':
         $answer = strtolower(trim($said));
