--- /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 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 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 '' . "\n"; + echo '' . "\n"; + if (FILLER_BACKGROUND) { + echo ' ' . PROMPT_BASE . FILLER_LOOP . '' . "\n"; + } else { + echo ' ' . PROMPT_BASE . FILLER_BLOCK . '' . "\n"; + } + echo ' ' . htmlspecialchars(next_url($next_action, $sid)) . '' . "\n"; + echo '' . "\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 '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('got_zip', $sid)) . '' . "\n"; - echo '' . "\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 '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('process_yearmake', $sid)) . '' . "\n"; - echo '' . "\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 '' . "\n"; echo '' . "\n"; if ($price > 0) { - $price_str = '$' . number_format($price, 2); + $price_str = '$' . number_format($price, 2); // for 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 ' ' . "\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 '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('got_name', $sid)) . '' . "\n"; - echo '' . "\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 '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('got_street', $sid)) . '' . "\n"; - echo '' . "\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 '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('got_install_date', $sid)) . '' . "\n"; - echo '' . "\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 ''."\n"; - echo ''."\n"; - echo ' '.PROMPT_BASE.'joined/silence_filler.mp3'."\n"; - echo ' '.htmlspecialchars(next_url('got_contact',$sid)).''."\n"; - echo ''."\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));