--- /home/claude/work/ivr.php 2026-07-20 06:12:21.000000000 +0000 +++ ivr.php 2026-07-27 03:39:40.758613887 +0000 @@ -47,6 +47,56 @@ define('SESSION_DIR', '/tmp/ivr_sessions'); define('LOG_FILE', '/tmp/ivr_twilio.log'); +// ─── LATENCY TUNING ─────────────────────────────────────────────────────────── +// Flip these one at a time and listen to a test call after each. +// Set all to false to get the original (slow) behavior back. +define('FAST_BACKGROUND_STT', true); // #1 transcribe while filler plays, not after +define('FAST_GATHER_FIELDS', true); // #2 zip/glass/date via Deepgram Gather, no Whisper +define('FAST_FILLER_LOOP', true); // #1b short seamless loop + polling, not a 9.5s block +define('FAST_TRIM_PAUSES', true); // #5 Record silence timeout 3s -> 2s + +// filler_loop.wav is a 1.34s seamless cut of silence_filler.mp3 (its underlying +// bed repeats every 0.67s, so 2 cycles loop with no audible seam). WAV rather +// than MP3 on purpose: MP3 encoder padding puts a click between loop iterations. +// +// If your host or Twilio won't play the WAV, set FILLER_USE_WAV to false. That +// switches to filler_x1..x4.mp3 — the same bed pre-repeated 1-4 times inside a +// single file, so no attribute and no MP3 gap. +define('FILLER_USE_WAV', true); +define('FILLER_LOOP', 'joined/filler_loop.wav'); +define('FILLER_MP3_PREFIX', 'joined/filler_x'); // + "1".."4" + ".mp3" +define('FILLER_BLOCK', 'joined/silence_filler.mp3'); // original 9.5s +define('FILLER_FIRST_LOOPS', 2); // ~2.7s on the first pass, covers most transcriptions +define('FILLER_MAX_POLLS', 6); // then ~8s more of 1.34s polls before giving up + +define('REC_SILENCE_TIMEOUT', FAST_TRIM_PAUSES ? 2 : 3); + +// ─── GATHER ENDPOINTING ─────────────────────────────────────────────────────── +// 'auto' lets Twilio detect end-of-speech instead of waiting a fixed silence +// timer. This is the single biggest source of dead air on the Gather fields +// (zip, glass, other-glass, install date). Set to '3' to restore the old fixed +// behavior if auto ever clips people mid-answer. +define('GATHER_SPEECH_TIMEOUT', '1'); +// Digit strings get chunked ("nine three one... oh five") and a 1s timeout cuts +// people off mid-number. A retry costs ~8s, far more than the 2s this spends. +define('GATHER_SPEECH_TIMEOUT_DIGITS', '3'); + +// Standalone welcome + separate ZIP question. Both play in one TwiML response, +// so splitting the audio costs nothing in latency. The retry prompt is the +// question alone — the greeting is never replayed. +define('WELCOME_MP3', 'joined/01_welcome1.mp3'); +define('ASK_ZIP_MP3', 'joined/02_ask_zip.mp3'); +define('ASK_ZIP_RETRY_MP3','joined/02_ask_zip_retry.mp3'); +// First-time prompt = greeting + question, played back-to-back in one response. +define('WELCOME_ZIP_PROMPT', array(WELCOME_MP3, ASK_ZIP_MP3)); +define('GATHER_START_TIMEOUT', 3); // seconds to wait for speech to BEGIN + +// numDigits=5 makes keypad ZIP entry submit instantly on the 5th key instead of +// waiting out GATHER_START_TIMEOUT. Trade-off: it disables the single-key +// escapes (2 restart / 3 agent / 4 order) on the ZIP prompt only, because +// Twilio then waits for 5 keys before posting. Off by default. +define('ZIP_NUM_DIGITS', false); + // Account profiles keyed by DNIS $ACCOUNTS = array( '+18778072201' => array('code' => 'PTL', 'company' => 'Penske', 'ask_po' => true, 'po_prefill' => '', 'aff_id' => 0, 'requires_payment' => false), @@ -55,8 +105,16 @@ ); $DEFAULT_ACCOUNT = array('code' => 'W2G', 'company' => '', 'ask_po' => false, 'po_prefill' => '', 'ask_unit' => false, 'aff_id' => 0, 'requires_payment' => true); +// Used when the caller picks FLEET at the mode prompt but the number they dialed +// isn't one of the known fleet DNISes above (e.g. testing from a direct line). +// Without this, "fleet" callers get $DEFAULT_ACCOUNT and are quoted retail prices. +$FLEET_ACCOUNT = array('code' => 'FLEET', 'company' => '', 'ask_po' => true, 'po_prefill' => '', 'ask_unit' => true, 'aff_id' => 0, 'requires_payment' => false); + // ─── 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) { @@ -882,7 +940,24 @@ CURLOPT_USERPWD => ($config['twilio_account_sid'] ?? '') . ':' . ($config['twilio_auth_token'] ?? $config['twilio_api_secret'] ?? ''), )); $audio_data = curl_exec($ch); + $dl_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + // Twilio recordings are occasionally not readable the instant the action + // callback fires. One short retry, otherwise this reads as "caller said nothing". + if (!$audio_data || $dl_code !== 200) { + usleep(500000); + $ch = curl_init($audio_url); + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_USERPWD => ($config['twilio_account_sid'] ?? '') . ':' . ($config['twilio_auth_token'] ?? $config['twilio_api_secret'] ?? ''), + )); + $audio_data = curl_exec($ch); + $dl_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + ivr_log('Whisper: recording download retried, HTTP ' . $dl_code); + } if (!$audio_data) { ivr_log('Whisper: failed to download audio from ' . $audio_url); return ''; } $tmp = tempnam('/tmp', 'ivr_audio_') . '.mp3'; file_put_contents($tmp, $audio_data); @@ -905,9 +980,31 @@ $data = json_decode($resp, true); $text = isset($data['text']) ? trim(rtrim(trim($data['text']), '. ')) : ''; ivr_log('Whisper transcript: ' . substr($text, 0, 200)); + if (is_stt_hallucination($text)) { + ivr_log('Whisper: discarded hallucination on near-silent audio'); + return ''; + } return $text; } +// whisper-1 emits stock filler when handed silence or line noise — "Thank you", +// "you", "Thanks for watching". Left alone these become customer names and +// street addresses. Treat them as an empty transcript so the retry prompt fires. +function is_stt_hallucination($text) { + $t = strtolower(preg_replace('/[^a-z0-9. ]/i', '', $text)); + $t = trim(preg_replace('/[.\s]+/', ' ', $t)); + if ($t === '') return true; + $junk = array( + 'you', 'thank you', 'thanks', 'thank you very much', 'thanks for watching', + 'thanks for watching!', 'please subscribe', 'subscribe to my channel', + 'bye', 'bye bye', 'goodbye', 'okay', 'ok', 'oh', 'um', 'uh', 'hmm', 'mm', + 'the', 'a', 'and', 'so', 'yeah', 'yes', 'no', 'hello', 'hi', + 'subtitles by the amara.org community', 'transcription by eso translations', + 'im sorry', 'silence', 'music', 'applause', 'blank audio', + ); + return in_array($t, $junk, true); +} + // ─── CLAUDE ORDER EXTRACTION ────────────────────────────────────────────────── function claude_extract_order($transcript, $acct) { global $config; @@ -980,8 +1077,9 @@ $out_path = $out_dir . $filename; $out_url = 'https://autoglasshosting.com/ivr/prompts/joined/' . $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 the text, so identical text == identical audio. + // Cache is permanent — no TTL. Delete the file to force a regen. + if (file_exists($out_path) && filesize($out_path) > 0) { return $out_url; } @@ -1050,36 +1148,137 @@ return $styles[0]; } +// Pull email + phone out of a spoken contact answer. Claude first, regex fallback. +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 up a spoken street address, then try to normalize it via Nominatim. +// Falls back to the spoken form if the lookup misses or times out. +function validate_street($street_transcript, $zip, $sid = '') { + $street = ucwords(strtolower(trim($street_transcript))); + if ($street === '') return ''; + $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 = $addr['house_number'] ?? ''; + $r = $addr['road'] ?? ''; + if ($h && $r) { + ivr_log("[$sid] Address validated: '$street' → '$h $r'"); + return $h . ' ' . $r; + } + } + return $street; +} + +// Normalize a spoken or keyed ZIP: "nine two six eight eight", "92688", "9 2 6 8 8" +function parse_zip($text) { + $zip = preg_replace('/[^0-9]/', '', (string)$text); + if (strlen($zip) === 5) return $zip; + if (strlen($zip) > 5) return substr($zip, 0, 5); + $words = array('zero'=>'0','one'=>'1','two'=>'2','three'=>'3','four'=>'4','five'=>'5', + 'six'=>'6','seven'=>'7','eight'=>'8','nine'=>'9','oh'=>'0','o'=>'0'); + $digits = ''; + foreach (preg_split('/[\s,\-]+/', strtolower((string)$text)) as $w) { + $w = trim($w, '.'); + if (isset($words[$w])) $digits .= $words[$w]; + elseif (ctype_digit($w)) $digits .= $w; + } + return (strlen($digits) === 5) ? $digits : $zip; +} + +// Local table first (sub-millisecond). Falls back to the zippopotam.us API only +// if the ZIP isn't in ivr_zipcodes, and writes the result back so the next call +// for that ZIP is local too. function zip_to_city_state($zip) { + $zip = preg_replace('/[^0-9]/', '', (string)$zip); + if (strlen($zip) !== 5) return array('city' => '', 'state' => ''); + + $db = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME); + if ($db && !$db->connect_error) { + $z = $db->real_escape_string($zip); + $r = $db->query("SELECT city, state FROM ivr_zipcodes WHERE zip='$z' LIMIT 1"); + if ($r && $r->num_rows > 0) { + $row = $r->fetch_assoc(); + $db->close(); + return array('city' => $row['city'], 'state' => $row['state']); + } + } + + // Miss — fall back to the API + ivr_log("ZIP $zip not in ivr_zipcodes, falling back to API"); $ch = curl_init('https://api.zippopotam.us/us/' . urlencode($zip)); curl_setopt_array($ch, array( CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, + CURLOPT_TIMEOUT => 3, CURLOPT_SSL_VERIFYPEER => false, )); $body = curl_exec($ch); curl_close($ch); $data = json_decode($body, true); + + $out = array('city' => '', 'state' => ''); if (!empty($data['places'][0])) { - return array( - 'city' => $data['places'][0]['place name'] ?? '', - 'state' => $data['places'][0]['state abbreviation'] ?? '', - ); + $out['city'] = $data['places'][0]['place name'] ?? ''; + $out['state'] = $data['places'][0]['state abbreviation'] ?? ''; + // Backfill so we only ever pay for this ZIP once + if ($db && !$db->connect_error && $out['city'] !== '') { + $c = $db->real_escape_string($out['city']); + $s = $db->real_escape_string($out['state']); + $db->query("INSERT IGNORE INTO ivr_zipcodes (zip, city, state, source) + VALUES ('$z', '$c', '$s', 'api')"); + } + } + if ($db && !$db->connect_error) $db->close(); + return $out; +} + +// Render one or more verbs. Accepts a filename or an array of filenames, +// so a multi-part prompt (welcome + question) plays in ONE TwiML response +// instead of costing a round-trip between the parts. +function play_tags($prompt_mp3) { + $out = ''; + foreach ((array)$prompt_mp3 as $mp3) { + if ($mp3 === '' || $mp3 === null) continue; + $out .= ' ' . PROMPT_BASE . htmlspecialchars($mp3) . '' . "\n"; } - return array('city' => '', 'state' => ''); + return $out; } // Record after playing prompt — mic opens after prompt finishes // After recording, Twilio posts RecordingUrl back to $next_action -function twiml_record($prompt_mp3, $next_action, $call_sid, $max_length = 10) { +function twiml_record($prompt_mp3, $next_action, $call_sid, $max_length = 10, $silence = null) { $action_url = next_url($next_action, $call_sid); + $silence = ($silence === null) ? REC_SILENCE_TIMEOUT : $silence; echo '' . "\n"; echo '' . "\n"; - echo ' ' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '' . "\n"; + echo play_tags($prompt_mp3); echo ' ' . "\n"; echo ' ' . "\n"; @@ -1094,6 +1293,110 @@ return whisper_transcribe($recording_url); } +// ─── BACKGROUND STT ─────────────────────────────────────────────────────────── +// Hand the already-echoed TwiML to Twilio and keep the PHP process running. +// Twilio starts playing the filler immediately 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'; } + // Fallback. Content-Length is REQUIRED here — without it Twilio can't tell + // the response has ended and will sit on the open socket until this process + // finishes its Whisper call, which defeats the whole point. + 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'; +} + +// Emit filler + redirect, flush to Twilio, THEN transcribe into $sess[$key]. +// The processing pass reads $sess[$key] instead of calling Whisper itself. +// Emit "keep the caller company and come back to me" TwiML. +// $loops is how many 1.34s cycles of the ambient bed to play before returning. +function filler_twiml($next_action, $sid, $loops = 1) { + $use_loop = FAST_BACKGROUND_STT && FAST_FILLER_LOOP; + echo '' . "\n"; + echo '' . "\n"; + if (!$use_loop) { + echo ' ' . PROMPT_BASE . FILLER_BLOCK . '' . "\n"; + } elseif (FILLER_USE_WAV) { + echo ' ' . PROMPT_BASE . FILLER_LOOP . '' . "\n"; + } else { + // Pre-repeated MP3s: one file per cycle count, no loop attribute needed + $n = max(1, min(4, (int)$loops)); + echo ' ' . PROMPT_BASE . FILLER_MP3_PREFIX . $n . '.mp3' . "\n"; + } + echo ' ' . htmlspecialchars(next_url($next_action, $sid)) . '' . "\n"; + echo '' . "\n"; +} + +// $post is an optional callable($transcript, &$sess) run inside the same flushed +// window — use it for lookups that would otherwise block the processing pass. +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 (FAST_BACKGROUND_STT) $sess[$key . '_stt_pending'] = true; + sess_save($sid, $sess); + + filler_twiml($next_action, $sid, FILLER_FIRST_LOOPS); + + if (!FAST_BACKGROUND_STT) exit; + + $mode = ivr_flush_response(); + $t0 = microtime(true); + $text = whisper_transcribe($recording_url); + + // Re-load: the processing pass may have already landed and written to session + $fresh = sess_load($sid); + 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. If it isn't ready yet, play one more short loop +// and come back — so the caller waits in ~1.34s increments instead of a fixed +// block. $poll_action is the action to redirect back into. +function take_transcript($key, $recording_url, $sid, &$sess, $poll_action = '') { + if (!FAST_BACKGROUND_STT) 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; + } + + // Not ready. Keep the caller on the bed and re-enter this same state. + if ($poll_action !== '' && FAST_FILLER_LOOP && !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; // land back in the processing branch + 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); +} + function base_url() { $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; return $proto . '://' . $_SERVER['HTTP_HOST'] . strtok($_SERVER['REQUEST_URI'], '?'); @@ -1104,19 +1407,21 @@ } // Play an ElevenLabs MP3 prompt and gather speech response via Deepgram -function twiml_gather($prompt_mp3, $next_action, $call_sid, $timeout = 5, $hints = '') { +function twiml_gather($prompt_mp3, $next_action, $call_sid, $timeout = 5, $hints = '', $num_digits = 0, $speech_timeout = null) { $action_url = next_url($next_action, $call_sid); $hint_attr = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : ''; + $nd_attr = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : ''; + $sto = ($speech_timeout === null) ? GATHER_SPEECH_TIMEOUT : $speech_timeout; echo '' . "\n"; echo '' . "\n"; echo ' ' . "\n"; - echo ' ' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '' . "\n"; + . $nd_attr . $hint_attr . '>' . "\n"; + echo play_tags($prompt_mp3); echo ' ' . "\n"; echo ' ' . htmlspecialchars($action_url) . '' . "\n"; echo '' . "\n"; @@ -1124,20 +1429,22 @@ } // Play prompt THEN gather — no barge-in, prevents echo on critical fields -function twiml_play_then_gather($prompt_mp3, $next_action, $call_sid, $timeout = 8, $hints = '') { +function twiml_play_then_gather($prompt_mp3, $next_action, $call_sid, $timeout = 8, $hints = '', $num_digits = 0, $speech_timeout = null) { $action_url = next_url($next_action, $call_sid); $hint_attr = $hints ? ' hints="' . htmlspecialchars($hints) . '"' : ''; + $nd_attr = $num_digits ? ' numDigits="' . (int)$num_digits . '"' : ''; + $sto = ($speech_timeout === null) ? GATHER_SPEECH_TIMEOUT : $speech_timeout; echo '' . "\n"; echo '' . "\n"; - // Play prompt first — mic closed during playback, no echo - echo ' ' . PROMPT_BASE . htmlspecialchars($prompt_mp3) . '' . "\n"; + // Play prompt(s) first — mic closed during playback, no echo + echo play_tags($prompt_mp3); echo ' ' . "\n"; + . $nd_attr . $hint_attr . '>' . "\n"; echo ' ' . "\n"; echo ' ' . htmlspecialchars($action_url) . '' . "\n"; echo '' . "\n"; @@ -1519,8 +1826,13 @@ $sid = $_GET['sid'] ?? $call_sid; // Normalize DNIS — Twilio passes RC forwarded number -$dnis_clean = '+1' . preg_replace('/[^0-9]/', '', $dnis); -if (strlen($dnis_clean) > 12) $dnis_clean = substr($dnis_clean, 0, 12); +// Normalize to +1NXXNXXXXXX. The old version prepended '+1' to a number that +// ALREADY had its country code, then truncated — turning +18058371747 into +// +11805837174, which matches no key in $ACCOUNTS. Every fleet call was +// silently falling through to $DEFAULT_ACCOUNT (retail, requires_payment). +$dnis_digits = preg_replace('/[^0-9]/', '', $dnis); +if (strlen($dnis_digits) === 11 && $dnis_digits[0] === '1') $dnis_digits = substr($dnis_digits, 1); +$dnis_clean = (strlen($dnis_digits) === 10) ? '+1' . $dnis_digits : '+' . $dnis_digits; ivr_log("[$sid] action=$action dnis_raw=$dnis dnis_clean=$dnis_clean ani=$ani ForwardedFrom=" . ($_POST["ForwardedFrom"] ?? "none") . " said=" . json_encode($said)); @@ -1562,7 +1874,7 @@ case 'welcome': echo '' . "\n"; echo '' . "\n"; - echo ' ' . "\n"; + echo ' ' . "\n"; echo ' ' . PROMPT_BASE . 'joined/42_1or2.mp3' . "\n"; echo ' ' . "\n"; echo ' ' . htmlspecialchars(next_url('start_fleet', $sid)) . '' . "\n"; @@ -1570,99 +1882,117 @@ exit; case 'got_mode': + // The caller's choice has to actually change $sess['account'] — every + // downstream retail-vs-fleet test reads $acct, so without this the mode + // prompt was cosmetic and DNIS alone decided the flow. $wants_retail = ($digit === '1' || trim($said) === '1' || stripos($said, 'one') !== false); - if ($wants_retail) { twiml_redirect('start_retail', $sid); } - twiml_redirect('start_fleet', $sid); + if ($wants_retail) { + $sess['account'] = $DEFAULT_ACCOUNT; // retail: price quote + payment + } else { + $sess['account'] = $ACCOUNTS[$dnis_clean] ?? $FLEET_ACCOUNT; // fleet: PO + unit, no payment + } + $sess['mode'] = $wants_retail ? 'retail' : 'fleet'; + sess_save($sid, $sess); + $acct = $sess['account']; + ivr_log("[$sid] mode={$sess['mode']} account={$acct['code']} ask_po=" . (int)!empty($acct['ask_po']) + . " requires_payment=" . (int)!empty($acct['requires_payment'])); + + // Both branches ask the same first question, so answer in THIS response + // instead of spending a redirect round-trip to get there. + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather(WELCOME_ZIP_PROMPT, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS); + } + twiml_record(WELCOME_ZIP_PROMPT, 'got_zip', $sid, 6); case 'start_retail': - twiml_record('joined/01_welcome.mp3', 'got_zip', $sid, 6); - case 'start_fleet': - twiml_record('joined/01_welcome.mp3', 'got_zip', $sid, 6); + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather(WELCOME_ZIP_PROMPT, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS); + } + twiml_record(WELCOME_ZIP_PROMPT, 'got_zip', $sid, 6); // ── GOT ZIP ────────────────────────────────────────────────────────────── case 'got_zip': - $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ''; - - // Processing pass - transcribe ZIP - if (!empty($sess['zip_processing'])) { + if (FAST_GATHER_FIELDS) { + // Deepgram returns the answer in THIS webhook — no recording download, + // no Whisper hop, no filler, no redirect. Keypad entry works too. + $zip_transcript = $said; + } elseif (!empty($sess['zip_processing'])) { unset($sess['zip_processing']); - $rec = isset($sess['rec_zip']) ? $sess['rec_zip'] : $recording_url; - $zip_transcript = whisper_transcribe($rec); - ivr_log("[$sid] ZIP transcript: $zip_transcript"); - $zip = preg_replace('/[^0-9]/', '', $zip_transcript); - // Handle spoken zip like "nine three one oh three" - if (strlen($zip) !== 5) { - $zip_words = array('zero'=>'0','one'=>'1','two'=>'2','three'=>'3','four'=>'4','five'=>'5','six'=>'6','seven'=>'7','eight'=>'8','nine'=>'9','oh'=>'0','o'=>'0'); - $spoken = strtolower($zip_transcript); - $zip_digits = ''; - foreach (preg_split('/[\s,]+/', $spoken) as $word) { - if (isset($zip_words[$word])) $zip_digits .= $zip_words[$word]; - elseif (is_numeric($word)) $zip_digits .= $word; - } - if (strlen($zip_digits) === 5) $zip = $zip_digits; - } - if (strlen($zip) !== 5) { - $sess['zip_processing'] = true; - sess_save($sid, $sess); - twiml_record('joined/02_zip_retry.mp3', 'got_zip', $sid, 6); + $rec = !empty($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ($sess['rec_zip'] ?? ''); + $zip_transcript = take_transcript('zip', $rec, $sid, $sess, 'got_zip'); + } else { + $recording_url = $_POST['RecordingUrl'] ?? ''; + if (empty($recording_url)) { + // Re-entry, not a first greeting — ask the question only + twiml_record(ASK_ZIP_MP3, 'got_zip', $sid, 6); } - $location = zip_to_city_state($zip); - $sess['zip'] = $zip; - $sess['city'] = $location['city']; - $sess['state'] = $location['state']; - ivr_log("[$sid] ZIP $zip → {$location['city']}, {$location['state']}"); - sess_save($sid, $sess); - twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3); + capture_and_transcribe($recording_url, 'zip', 'got_zip', $sid, $sess); } - if (empty($recording_url)) { - twiml_record('joined/01_welcome.mp3', 'got_zip', $sid, 6); + ivr_log("[$sid] ZIP transcript: $zip_transcript"); + $zip = parse_zip($zip_transcript); + + if (strlen($zip) !== 5) { + // Clear the stored recording, otherwise the retry re-transcribes the + // SAME audio and loops on the same bad answer. + unset($sess['rec_zip'], $sess['zip_processing'], $sess['zip_transcript']); + sess_save($sid, $sess); + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather(ASK_ZIP_RETRY_MP3, 'got_zip', $sid, GATHER_START_TIMEOUT, 'zip code', ZIP_NUM_DIGITS ? 5 : 0, GATHER_SPEECH_TIMEOUT_DIGITS); + } + twiml_record(ASK_ZIP_RETRY_MP3, 'got_zip', $sid, 6); } - // Store recording and play zipwait while we process - $sess['rec_zip'] = $recording_url; - $sess['zip_processing'] = true; + + $location = zip_to_city_state($zip); + $sess['zip'] = $zip; + $sess['city'] = $location['city']; + $sess['state'] = $location['state']; + ivr_log("[$sid] ZIP $zip → {$location['city']}, {$location['state']}"); 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; + // fall through to the glass question in this same response — no redirect + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/03_ask_glass.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other'); + } + twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3); case 'process_zip': + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/03_ask_glass.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other'); + } twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3); // ── GOT GLASS ──────────────────────────────────────────────────────────── case 'got_glass': - // Processing pass — check FIRST before looking for RecordingUrl - if (!empty($sess['glass_processing'])) { + if (FAST_GATHER_FIELDS) { + $glass_transcript = $said; + } elseif (!empty($sess['glass_processing'])) { unset($sess['glass_processing']); - $recording_url = isset($sess['rec_glass']) ? $sess['rec_glass'] : ''; + $rec = $sess['rec_glass'] ?? ''; + $glass_transcript = take_transcript('glass', $rec, $sid, $sess, 'got_glass'); } else { - $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ''; + $recording_url = $_POST['RecordingUrl'] ?? ''; if (empty($recording_url)) { twiml_record('joined/03_ask_glass.mp3', 'got_glass', $sid, 3); } - // Store and play filler - $sess['rec_glass'] = $recording_url; - $sess['glass_processing'] = true; - sess_save($sid, $sess); - echo '' . "\n"; - echo '' . "\n"; - echo ' ' . PROMPT_BASE . 'joined/silence_filler.mp3' . "\n"; - echo ' ' . htmlspecialchars(next_url('got_glass', $sid)) . '' . "\n"; - echo '' . "\n"; - exit; + capture_and_transcribe($recording_url, 'glass', 'got_glass', $sid, $sess); } - $glass_transcript = whisper_transcribe($recording_url); ivr_log("[$sid] Glass transcript: $glass_transcript"); if (empty(trim($glass_transcript))) { + unset($sess['rec_glass'], $sess['glass_processing'], $sess['glass_transcript']); + sess_save($sid, $sess); + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/03_1_ask_glass_retry.mp3', 'got_glass', $sid, GATHER_START_TIMEOUT, 'windshield other'); + } twiml_record('joined/03_1_ask_glass_retry.mp3', 'got_glass', $sid, 3); } // Route based on windshield or other if (stripos($glass_transcript, 'other') !== false) { + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/04_otherglass.mp3', 'got_other_glass', $sid, GATHER_START_TIMEOUT, + 'driver passenger back rear quarter left front right front'); + } twiml_record('joined/04_otherglass.mp3', 'got_other_glass', $sid, 8); } // "windshield" or anything else = windshield @@ -1672,13 +2002,29 @@ // ── 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 at all — the + // caller heard several seconds of dead silence. + if (FAST_GATHER_FIELDS) { + $other_transcript = $said; + } elseif (!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 = $_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)) { + unset($sess['rec_otherglass'], $sess['otherglass_transcript']); + sess_save($sid, $sess); + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/04_1_otherglass_retry.mp3', 'got_other_glass', $sid, GATHER_START_TIMEOUT, + 'driver passenger back rear quarter left front right front'); + } twiml_record('joined/04_1_otherglass_retry.mp3', 'got_other_glass', $sid, 8); } $other = strtolower($other_transcript); @@ -1707,22 +2053,16 @@ 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); + $recording_url = $sess['rec_yearmake'] ?? ''; + $transcript = take_transcript('yearmake', $recording_url, $sid, $sess, 'process_yearmake'); ivr_log("[$sid] YMM transcript: $transcript"); if (empty($transcript)) { + unset($sess['rec_yearmake'], $sess['yearmake_processing'], $sess['yearmake_transcript']); + sess_save($sid, $sess); twiml_record('joined/06_yearmake_retry.mp3', 'got_yearmake', $sid, 12); } $extracted = extract_vehicle($transcript); @@ -1741,8 +2081,9 @@ $sess['style'] = ''; } sess_save($sid, $sess); - // Query NAGS for available styles - $style_check = get_truck_nags_style($sess['year']??'', $sess['make']??'', $sess['model']??'', $sess['zip']??'90001', $sess['style']??''); + // NOTE: a get_truck_nags_style() call used to sit here. Its result + // ($style_check) was never read anywhere in this file, so it was a + // wasted proxy round-trip (8s timeout) on every call. Removed. // Get all styles for selection $style_url = 'https://autoglasshosting.com/ivr/proxy_nags_lookup.php' . '?year=' . urlencode($sess['year']??'') @@ -1911,7 +2252,7 @@ $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.'; - echo ' ' . "\n"; + echo ' ' . "\n"; if (!empty($config['elevenlabs_price_voice'])) { // Use ElevenLabs custom voice $cache_file = 'price_' . md5($quote_text) . '.mp3'; @@ -1952,25 +2293,19 @@ case 'got_name': if (!empty($sess['name_processing'])) { unset($sess['name_processing']); - $recording_url = isset($sess['rec_name']) ? $sess['rec_name'] : ''; + $recording_url = $sess['rec_name'] ?? ''; + $name_transcript = take_transcript('name', $recording_url, $sid, $sess, 'got_name'); } else { - $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ''; + $recording_url = $_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); ivr_log("[$sid] Name transcript: $name_transcript"); if (empty($name_transcript)) { + unset($sess['rec_name'], $sess['name_transcript']); + sess_save($sid, $sess); twiml_record('joined/08_name_retry.mp3', 'got_name', $sid, 8); } // Strip common greeting phrases @@ -1982,99 +2317,83 @@ $sess['first'] = ucfirst($parts[0] ?? ''); $sess['last'] = ucwords($parts[1] ?? ''); if (empty($sess['first'])) { + unset($sess['rec_name'], $sess['name_transcript']); + sess_save($sid, $sess); twiml_record('joined/08_name_retry.mp3', 'got_name', $sid, 8); } + unset($sess['rec_name'], $sess['name_transcript']); sess_save($sid, $sess); - twiml_redirect('got_street', $sid); + // Ask for the street in THIS response instead of redirecting to got_street + twiml_record('joined/09_street.mp3', 'got_street', $sid, 15); // ── GOT STREET (recording → Whisper → address validation) ──────────────── case 'got_street': // Processing pass — triggered after wait prompt if (!empty($sess['street_processing'])) { unset($sess['street_processing']); - $rec = isset($sess['rec_street']) ? $sess['rec_street'] : ''; + $rec = $sess['rec_street'] ?? ''; 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)) { + unset($sess['rec_street'], $sess['street_validated']); 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; - } - } - $sess['street'] = $street; + // Validation already ran in the background window; only do it here + // if we fell back to a blocking transcribe. + $sess['street'] = $sess['street_validated'] + ?? validate_street($street_transcript, $sess['zip'] ?? '', $sid); + unset($sess['rec_street'], $sess['street_validated']); sess_save($sid, $sess); if (!empty($acct['ask_po'])) { twiml_gather('joined/11_PO.mp3', 'got_po', $sid, 6); } else { // Retail — ask for install date + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/21_install_date.mp3', 'got_install_date', $sid, GATHER_START_TIMEOUT); + } twiml_record('joined/21_install_date.mp3', 'got_install_date', $sid, 8); } } // First pass — check for recording URL - $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ''; + $recording_url = $_POST['RecordingUrl'] ?? ''; if (empty($recording_url)) { 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, &$s) use ($street_zip, $sid) { + $s['street_validated'] = validate_street($text, $street_zip, $sid); + }); // ── GOT INSTALL DATE (retail only) ─────────────────────────────────────── case 'got_install_date': - if (!empty($sess['date_processing'])) { + if (FAST_GATHER_FIELDS) { + $date_transcript = $said; + } elseif (!empty($sess['date_processing'])) { unset($sess['date_processing']); - $rec = isset($sess['rec_date']) ? $sess['rec_date'] : ''; + $rec = $sess['rec_date'] ?? ''; + $date_transcript = take_transcript('date', $rec, $sid, $sess, 'got_install_date'); } else { - $recording_url = isset($_POST['RecordingUrl']) ? $_POST['RecordingUrl'] : ''; + $recording_url = $_POST['RecordingUrl'] ?? ''; 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); ivr_log("[$sid] Date transcript: $date_transcript"); if (empty($date_transcript)) { + unset($sess['rec_date'], $sess['date_transcript']); + sess_save($sid, $sess); + if (FAST_GATHER_FIELDS) { + twiml_play_then_gather('joined/22_install_date_retry.mp3', 'got_install_date', $sid, GATHER_START_TIMEOUT); + } twiml_record('joined/22_install_date_retry.mp3', 'got_install_date', $sid, 8); } // Extract date using Claude-style logic @@ -2138,28 +2457,29 @@ 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; } - $sess['email']=$email; $sess['phone']=$phone; - sess_save($sid,$sess); + if (!$ct) { + unset($sess['rec_contact'], $sess['contact_parsed']); + sess_save($sid, $sess); + twiml_record('joined/16_contact_retry.mp3', 'got_contact', $sid, 30); + } + // Claude extraction already ran in the background window; only redo + // it here if we fell back to a blocking transcribe. + $parsed = $sess['contact_parsed'] ?? parse_contact($ct, $acct); + $sess['email'] = $parsed['email']; + $sess['phone'] = $parsed['phone']; + unset($sess['rec_contact'], $sess['contact_parsed']); + 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; + // Whisper AND the Claude extraction both run while the filler plays + capture_and_transcribe($ru, 'contact', 'got_contact', $sid, $sess, + function ($text, &$s) use ($acct) { + $s['contact_parsed'] = parse_contact($text, $acct); + }); case 'got_cell_confirm': $answer = strtolower(trim($said)); @@ -2194,7 +2514,7 @@ if (empty($acct['ask_po']) && !empty($acct['requires_payment'])) { echo ' ' . PROMPT_BASE . 'joined/24_retail_thanks.mp3' . "\n"; } else { - echo ' ' . PROMPT_BASE . 'joined/20_fleet_thanks.mp3' . "\n"; + echo ' ' . PROMPT_BASE . 'joined/20_fleet_thanks1.mp3' . "\n"; } echo ' ' . htmlspecialchars(next_url('process_joined', $sid)) . '' . "\n"; echo '' . "\n";