1/* Pro90D â Voice Ava bubble (public marketing pages) 2 * Drop-in: <script src="/voice-ava.js" defer></script> 3 * 4 * A floating, Pro90D-branded voice bubble for ANONYMOUS visitors on the public 5 * pages (lander, coaching, VSMC, home). Click â live voice conversation with the 6 * AI Speech Coach (ElevenLabs managed voice + our Sonnet 5 brain), with live 7 * captions, a hard time cap, and a "start your free trial" CTA when the cap hits. 8 * 9 * Safe by design: it renders NOTHING unless /api/voice-coach-config?public=1&probe=1 10 * reports enabled (i.e. VOICE_AGENT_PUBLIC is on and an agent is configured), and 11 * every path is wrapped so a failure can never break the host marketing page. 12 */ 13(function () { 14 "use strict"; 15 if (window.__pro90dVoiceAva) return; // guard against double-include 16 window.__pro90dVoiceAva = true; 17 18 // p90VoiceAvaVariant 2026-09-19 â a host page may set window.P90_VOICE_AVA 19 // before this script loads to re-script the bubble for one page. /room uses 20 // it: same agent, same 5-minute cap, same per-IP limit, different script and 21 // a single CTA instead of the catalogue chips. With nothing set, every value 22 // below is exactly what the six existing pages already get â this is 23 // additive, not a fork, so there is only ever one copy of the engine. 24 var OPT = (window.P90_VOICE_AVA && typeof window.P90_VOICE_AVA === "object") ? window.P90_VOICE_AVA : {}; 25 var VARIANT = OPT.variant ? String(OPT.variant) : ""; 26 var QS = "?public=1" + (VARIANT ? "&variant=" + encodeURIComponent(VARIANT) : ""); 27 var TITLE = OPT.title || "Ask about Pro90D"; 28 // p90RoomTextBrain 2026-09-20 â the TYPED path had its own brain, and it was 29 // the whole-site concierge on every page. On /room that meant a typed 30 // question was answered by something that knows nothing about The Room: 31 // Michael got "I'm a text-based guide... that's what the AI Speech Coach is 32 // built for" on a page whose only job is the free session. The voice half was 33 // already correct; this half was quietly answering from somewhere else. 34 var TEXT_API = OPT.textEndpoint || "/api/voice-ava-chat"; 35 36 // A host page may pre-render a SHORT greeting and have her say it the moment 37 // the panel opens. Michael: "when I click that link, and it opens the dialog 38 // box, she starts talking right then... she just says, hi, something... to 39 // speak with me, click the green mic button, or you can type." 40 // It is a static file, so it costs nothing per visitor and needs no agent 41 // session â connecting the live agent just to say hello would bill per-minute 42 // for every curious tap. 43 // p90RoomGreetAgain 2026-09-21 â Michael: "if I close the dialog box and open 44 // it back up without refreshing the entire page, she should greet you. And if 45 // possible, maybe she just has a different greeting because she already knows 46 // that you had a conversation with her in that same session." 47 // greetPlayed was a one-shot latch, so the second open was silent â the panel 48 // just appeared with a stale transcript and said nothing. 49 var greetPlayed = false, greetEl = null; 50 // Whether they actually TALKED to her this page-load, as opposed to opening 51 // the panel and closing it again. Only a real exchange earns "we've already 52 // been talking"; a curious tap gets the same hello a second time. 53 var hadConversation = false; 54 var CFG = null, convo = null, active = false, capTimer = null, panelOpen = false; 55 var lastSource = null, lastRow = null, chatHistory = []; 56 var bubble, panel, micBtn, statusEl, transcriptEl, timerEl, formWrap, formMsgEl; 57 58 // Quick-link chips shown in the panel so visitors can jump to any program page. 59 var CHIPS = [ 60 // p90RoomEverywhere 2026-09-21 â her prompt now names The Room as a page she 61 // can send people to, so the tappable chip has to exist or she points at a 62 // button that is not there. First, because it is the only free thing here. 63 { label: "The Room â free session", href: "/the-room" }, 64 { label: "AI Speech Coach", href: "/coach-landing" }, 65 { label: "Coaching", href: "/coaching" }, 66 { label: "Small Group (Farrukh)", href: "/coaching-farrukh" }, 67 { label: "Speech Masters Club", href: "/vsmc-group" } 68 ]; 69 70 // Rotating prompts that fade in above the bubble, then auto-dismiss. 71 // p90RoomTips 2026-09-21 â the rotating tip is the LARGEST piece of Ava copy 72 // on the page, and on The Room it was still selling the catalogue: "Ask me: 73 // coaching vs. the AI Speech Coach?" floating over a lander whose only job is 74 // the free session. A host page may supply its own; the six marketing pages 75 // pass nothing and keep exactly these. 76 var SUGGESTIONS = (Array.isArray(OPT.suggestions) && OPT.suggestions.length) 77 ? OPT.suggestions 78 : [ 79 "Ask me: <b>coaching vs. the AI Speech Coach?</b>", 80 "Ask me: <b>which program is right for me?</b>", 81 "Ask me: <b>what's the Speech Masters Club?</b>", 82 "Ask me: <b>about small-group coaching with Farrukh</b>", 83 "Ask me: <b>how does the AI Speech Coach work?</b>" 84 ]; 85 var tipEl = null, tipTimer = null, tipIdx = 0, tipShown = false;
86 87 function css() { 88 var s = document.createElement("style"); 89 s.textContent = [ 90 ".p9av-bubble{position:fixed;right:20px;bottom:20px;z-index:2147483000;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:radial-gradient(circle at 50% 35%,#8ed83a,#6bb31f);box-shadow:0 10px 30px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;transition:transform .15s ease;animation:p9avGlow 2.6s ease-in-out infinite}", 91 ".p9av-bubble.quiet{animation:none}", 92 "@keyframes p9avGlow{0%,100%{box-shadow:0 10px 30px rgba(0,0,0,.35),0 0 0 0 rgba(126,200,42,.55)}50%{box-shadow:0 10px 30px rgba(0,0,0,.35),0 0 0 13px rgba(126,200,42,0)}}", 93 "@media (prefers-reduced-motion:reduce){.p9av-bubble{animation:none}}", 94 ".p9av-bubble:hover{transform:scale(1.06)}", 95 ".p9av-bubble svg{width:28px;height:28px}", 96 ".p9av-badge{position:absolute;top:-6px;left:-6px;background:#5eead4;color:#06121f;font:600 9px/1 'DM Mono',monospace;letter-spacing:.5px;text-transform:uppercase;border-radius:999px;padding:3px 6px}", 97 ".p9av-panel{position:fixed;right:20px;bottom:92px;z-index:2147483000;width:340px;max-width:92vw;background:linear-gradient(180deg,#0b1830,#060f1e);border:1px solid rgba(126,200,42,.25);border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);color:#e8eef7;font-family:'DM Sans',system-ui,sans-serif;font-weight:300;overflow:hidden;display:none}", 98 ".p9av-panel.open{display:block}", 99 ".p9av-head{padding:14px 16px 10px;display:flex;align-items:center;gap:10px;border-bottom:1px solid rgba(255,255,255,.06)}", 100 ".p9av-head b{font-family:'Raleway',sans-serif;font-weight:300;font-size:15px}", 101 ".p9av-head .p9av-x{margin-left:auto;background:none;border:none;color:#93a3bd;font-size:18px;cursor:pointer;line-height:1}", 102 ".p9av-body{padding:14px 16px 16px;text-align:center}", 103 ".p9av-mic{width:78px;height:78px;border-radius:50%;border:none;cursor:pointer;background:radial-gradient(circle at 50% 35%,#8ed83a,#6bb31f);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 8px 22px rgba(126,200,42,.35)}", 104 ".p9av-mic svg{width:34px;height:34px}", 105 ".p9av-mic.listening{background:radial-gradient(circle at 50% 35%,#7df0dc,#36c9b2);animation:p9avPulse 1.6s infinite}", 106 ".p9av-mic.speaking{background:radial-gradient(circle at 50% 35%,#9be24a,#6bb31f);animation:p9avPulse 1.1s infinite}", 107 ".p9av-mic[disabled]{opacity:.6;cursor:default}", 108 "@keyframes p9avPulse{0%{box-shadow:0 0 0 0 rgba(94,234,212,.5)}70%{box-shadow:0 0 0 18px rgba(94,234,212,0)}100%{box-shadow:0 0 0 0 rgba(94,234,212,0)}}", 109 ".p9av-status{color:#5eead4;font-size:13px;min-height:18px;margin:10px 0 2px}", 110 ".p9av-timer{color:#93a3bd;font-family:'DM Mono',monospace;font-size:11px}", 111 ".p9av-tx{margin-top:10px;max-height:200px;overflow-y:auto;text-align:left;display:none;flex-direction:column;gap:8px}", 112 ".p9av-tx.show{display:flex}", 113 ".p9av-row{display:flex}.p9av-row.u{justify-content:flex-end}.p9av-row.c{justify-content:flex-start}", 114 ".p9av-b{max-width:82%;padding:7px 11px;border-radius:12px;font-size:13px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}", 115 ".p9av-row.u .p9av-b{background:rgba(126,200,42,.16);border:1px solid rgba(126,200,42,.3);color:#eaf7d8}", 116 ".p9av-row.c .p9av-b{background:rgba(94,234,212,.12);border:1px solid rgba(94,234,212,.28);color:#dff7f2}", 117 ".p9av-cta{margin-top:12px}", 118 ".p9av-cta a{display:inline-block;background:#7ec82a;color:#06121f;text-decoration:none;font-weight:600;border-radius:999px;padding:9px 18px;font-size:13px}", 119 ".p9av-hint{color:#93a3bd;font-size:11px;margin-top:10px;line-height:1.4}", 120 ".p9av-txtrow{display:flex;gap:6px;margin-top:12px}", 121 ".p9av-input{flex:1;min-width:0;background:rgba(255,255,255,.06);border:1px solid rgba(147,163,189,.32);border-radius:999px;color:#e8eef7;font-family:'DM Sans',system-ui,sans-serif;font-size:13px;padding:9px 14px;outline:none}", 122 ".p9av-input::placeholder{color:#7f90ab}", 123 ".p9av-input:focus{border-color:rgba(126,200,42,.55)}", 124 ".p9av-txtsend{flex:0 0 auto;width:38px;height:38px;border-radius:50%;border:none;background:#7ec82a;color:#06121f;font-size:15px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center}", 125 ".p9av-txtsend[disabled]{opacity:.5;cursor:default}", 126 // p90AvaPills 2026-09-30 â Michael: "add a few prompt pills to her window
127 // so people can just click them after she greets them." Two kinds in one 128 // row: a question pill types itself into the box and sends (so it goes 129 // through the SAME path as typing, including into a live voice session), 130 // and a go pill is a plain link for the free two-minute options, which 131 // are instant and do not need her to answer first. 132 // The button Ava renders under her own message when she has just told 133 // someone about a two-minute option â her "click here". Green, because it 134 // is a go, not a question. 135 ".p9av-go{display:block;box-sizing:border-box;width:100%;margin:6px 0 2px;text-align:center;text-decoration:none;font-family:'DM Sans',system-ui,sans-serif;font-size:13.5px;font-weight:500;color:#06121f;background:linear-gradient(135deg,#8ed83a,#6bb31f);border-radius:10px;padding:11px 14px}", 136 ".p9av-go:hover{filter:brightness(1.06)}", 137 ".p9av-pills{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:2px 0 10px}", 138 ".p9av-pills.used{display:none}", 139 ".p9av-pill{font-family:'DM Sans',system-ui,sans-serif;font-size:12px;line-height:1.35;text-align:left;text-decoration:none;color:#dff7f2;background:rgba(94,234,212,.1);border:1px solid rgba(94,234,212,.3);border-radius:999px;padding:6px 12px;cursor:pointer}", 140 ".p9av-pill:hover{background:rgba(94,234,212,.2)}", 141 ".p9av-pill-go{color:#eaf7d8;background:rgba(126,200,42,.14);border-color:rgba(126,200,42,.45)}", 142 ".p9av-pill-go:hover{background:rgba(126,200,42,.26)}",
143 ".p9av-chips{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:12px}", 144 ".p9av-cta{width:100%;background:linear-gradient(135deg,#8ed83a,#6bb31f);color:#06121f;border:none;border-radius:11px;padding:13px 18px;font-family:'DM Sans';font-size:15px;font-weight:500;cursor:pointer}", 145 ".p9av-cta:hover{filter:brightness(1.06)}", 146 ".p9av-chip{display:inline-block;text-decoration:none;font-size:12px;color:#dff7f2;background:rgba(94,234,212,.1);border:1px solid rgba(94,234,212,.3);border-radius:999px;padding:5px 11px}", 147 ".p9av-chip:hover{background:rgba(94,234,212,.2)}", 148 ".p9av-msgbtn{margin-top:12px;background:transparent;border:1px solid rgba(126,200,42,.4);color:#eaf7d8;border-radius:999px;padding:7px 16px;font-family:'DM Sans';font-size:12px;cursor:pointer}", 149 ".p9av-msgbtn:hover{background:rgba(126,200,42,.12)}", 150 ".p9av-form{display:none;margin-top:10px;text-align:left}", 151 ".p9av-form.open{display:block}", 152 ".p9av-form textarea,.p9av-form input{width:100%;box-sizing:border-box;background:rgba(255,255,255,.05);border:1px solid rgba(147,163,189,.3);border-radius:10px;color:#e8eef7;font-family:'DM Sans';font-size:13px;padding:8px 10px;margin-bottom:7px}", 153 ".p9av-form textarea{min-height:64px;resize:vertical}", 154 ".p9av-send{width:100%;background:#7ec82a;color:#06121f;border:none;border-radius:999px;padding:9px;font-weight:600;font-size:13px;cursor:pointer}", 155 ".p9av-send[disabled]{opacity:.6}", 156 ".p9av-formmsg{font-size:12px;margin-top:6px;color:#5eead4;text-align:center}", 157 // Rotating suggestion tip (above the bubble). Fades in/out; auto-dismisses. 158 ".p9av-tip{position:fixed;right:20px;bottom:92px;z-index:2147482999;max-width:250px;background:linear-gradient(180deg,#0b1830,#060f1e);border:1px solid rgba(126,200,42,.3);color:#e8eef7;font-family:'DM Sans',system-ui,sans-serif;font-size:13px;line-height:1.35;border-radius:14px;padding:9px 30px 9px 13px;box-shadow:0 12px 34px rgba(0,0,0,.45);cursor:pointer;opacity:0;transform:translateY(6px);transition:opacity .4s ease,transform .4s ease}", 159 ".p9av-tip.show{opacity:1;transform:translateY(0)}", 160 ".p9av-tip:after{content:'';position:absolute;right:26px;bottom:-7px;width:12px;height:12px;background:#060f1e;border-right:1px solid rgba(126,200,42,.3);border-bottom:1px solid rgba(126,200,42,.3);transform:rotate(45deg)}", 161 ".p9av-tip .p9av-tipx{position:absolute;top:4px;right:8px;color:#93a3bd;font-size:14px;line-height:1;background:none;border:none;cursor:pointer;padding:2px}", 162 ".p9av-tip b{color:#7ec82a;font-weight:600}", 163 "@media (max-width:520px){.p9av-tip{max-width:62vw;font-size:12px;right:16px;bottom:84px}.p9av-bubble{right:16px}.p9av-panel{right:12px;bottom:86px}}" 164 ].join(""); 165 document.head.appendChild(s); 166 } 167 168 var MIC_SVG = '<svg viewBox="0 0 24 24" fill="#06121f" xmlns="http://www.w3.org/2000/svg"><path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3z"/><path d="M19 11a1 1 0 1 0-2 0 5 5 0 0 1-10 0 1 1 0 1 0-2 0 7 7 0 0 0 6 6.92V20H8a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-3v-2.08A7 7 0 0 0 19 11z"/></svg>'; 169 170 function setStatus(t) { if (statusEl) statusEl.textContent = t || ""; } 171 function setMic(state) { if (micBtn) micBtn.className = "p9av-mic " + (state || ""); } 172 173 function fmt(sec) { var m = Math.floor(sec / 60), s = sec % 60; return m + ":" + (s < 10 ? "0" : "") + s; } 174 175 // A tappable link under Ava's last message. Kept out of addMsg so it can 176 // never be merged into a text bubble the way consecutive coach lines are. 177 function addGo(link) { 178 if (!transcriptEl || !link || !link.href) return; 179 var row = document.createElement("div"); row.className = "p9av-row c"; 180 var a = document.createElement("a"); 181 a.className = "p9av-go"; 182 a.href = link.href; // set as a property, never interpolated 183 a.textContent = link.label || "Open it \u2192"; 184 row.appendChild(a); transcriptEl.appendChild(row); 185 transcriptEl.scrollTop = transcriptEl.scrollHeight; 186 // Force the next addMsg to start a fresh bubble rather than appending to a 187 // row that now holds a button. 188 lastRow = null; lastSource = null; 189 } 190 191 function addMsg(source, text) { 192 if (!transcriptEl || !text) return; 193 text = String(text).trim(); if (!text) return; 194 transcriptEl.classList.add("show"); 195 if (lastSource === source && lastRow) { 196 var b = lastRow.querySelector(".p9av-b"); 197 if (b) { b.textContent = (b.textContent + " " + text).trim(); transcriptEl.scrollTop = transcriptEl.scrollHeight; return; } 198 } 199 var row = document.createElement("div"); row.className = "p9av-row " + (source === "user" ? "u" : "c"); 200 var bub = document.createElement("div"); bub.className = "p9av-b"; bub.textContent = text; 201 row.appendChild(bub); transcriptEl.appendChild(row); transcriptEl.scrollTop = transcriptEl.scrollHeight; 202 lastRow = row; lastSource = source; 203 // Anything THEY said â typed or spoken â makes this a conversation. 204 if (source === "user") hadConversation = true; 205 } 206 207 // p90AvaFastConnect 2026-09-30 â Michael, demoing /room on his phone: she 208 // greeted them, his friend answered straight away, and she was slow to come 209 // back. ElevenLabs' own metrics for that conversation put her replies at 210 // ~1.4s from end-of-speech â the wait was the CONNECT, which ran every step 211 // in single file after the mic tap: settle, mic, config, then this SDK (four 212 // chained module fetches, several hundred KB of LiveKit) over a phone 213 // connection. Memoised, so opening the bubble can start the download while 214 // the greeting plays and the tap finds it already there. 215 var sdkPromise = null; 216 function loadSDK() {
217 if (sdkPromise) return sdkPromise; 218 var urls = ["https://esm.sh/@elevenlabs/[email protected]", "https://esm.sh/@elevenlabs/client", "https://cdn.jsdelivr.net/npm/@elevenlabs/client/+esm"]; 219 sdkPromise = (async function () { 220 for (var i = 0; i < urls.length; i++) { try { var m = await import(urls[i]); if (m && m.Conversation) return m.Conversation; } catch (e) {} } 221 throw new Error("sdk_load_failed"); 222 })(); 223 // A failed load must not be cached â the next tap tries again. 224 sdkPromise.catch(function () { sdkPromise = null; }); 225 return sdkPromise; 226 } 227 228 function showCTA() { 229 if (!transcriptEl) return; 230 var d = document.createElement("div"); d.className = "p9av-cta"; 231 d.innerHTML = '<a href="/coach-landing">Start my free 7-day trial â</a>'; 232 transcriptEl.appendChild(d); transcriptEl.scrollTop = transcriptEl.scrollHeight; 233 } 234 235 async function endSession(reason) { 236 if (capTimer) { clearInterval(capTimer); capTimer = null; } 237 try { if (convo) await convo.endSession(); } catch (e) {} 238 convo = null; active = false; starting = false; setMic(""); micBtn && (micBtn.disabled = false); 239 if (reason === "cap") { setStatus("Thanks for chatting! Explore Pro90D below â"); showCTA(); } 240 else setStatus("Tap the mic to ask more."); 241 lastSource = null; lastRow = null; 242 } 243 244 function startCap() { 245 var left = (CFG && CFG.capSeconds) || 300; 246 if (timerEl) timerEl.textContent = fmt(left); 247 capTimer = setInterval(function () { 248 left -= 1; 249 if (timerEl) timerEl.textContent = fmt(Math.max(0, left)); 250 if (left <= 0) endSession("cap"); 251 }, 1000); 252 } 253 254 // Mirror the in-app coach: on mobile networks / in-app browsers, WebSocket 255 // connects far more reliably than WebRTC (which some carriers and corporate 256 // WiFi block outright). Desktop stays on WebRTC, unchanged. (fix 2026-07-09) 257 function isMobile(){ 258 try { return /Mobi|Android|iPhone|iPod/i.test(navigator.userAgent) || 259 (/iPad|Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1); } catch(e){ return false; } 260 } 261 262 // p90RoomAudioUnlock 2026-09-21 â ported verbatim in shape from coach.html's 263 // beginSession() STEP 1, the demo coach that already works on Michael's 264 // iPhone. On iOS we run the WEBSOCKET transport, and that path plays her voice 265 // through an AudioContext â which iOS keeps suspended until one is created and 266 // started inside a real user gesture. The greeting's <audio> element unlocks 267 // MEDIA ELEMENT playback; it does NOT unlock the AudioContext. Different lock, 268 // and this bubble was picking only the first one. 269 // 270 // It must run SYNCHRONOUSLY in the click handler, before the first await â 271 // once you await, the gesture is spent and iOS will not honour the unlock. 272 var audioCtx = null; 273 function unlockAudio() { 274 try { 275 if (!audioCtx || audioCtx.state === "closed") { 276 audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 277 } 278 // A silent one-sample buffer is what actually completes the unlock. 279 var buf = audioCtx.createBuffer(1, 1, 22050); 280 var src = audioCtx.createBufferSource(); 281 src.buffer = buf; src.connect(audioCtx.destination); src.start(0); 282 if (audioCtx.state === "suspended") audioCtx.resume(); 283 } catch (e) { /* no AudioContext here â nothing to unlock */ } 284 } 285 286 var starting = false; 287 async function startSession(isRetry) { 288 // `active` is only set once onConnect fires, so the entire connect window â 289 // a couple of seconds on a phone â was unguarded. A second tap in there 290 // opened a SECOND session, and two of her talking at once is indistinguish- 291 // able from one of her restarting. Cheap insurance either way. 292 if (active || (starting && !isRetry)) return; 293 starting = true; 294 // He tapped the mic; the canned hello gets out of the way. 295 var cutGreetingShort = stopGreeting(); 296 setMic(""); setStatus("Startingâ¦"); micBtn.disabled = true; 297 // p90AvaFastConnect â the two network steps start NOW, in parallel with the 298 // audio-settle beat and the mic, instead of queuing behind them. 299 var sdkP = loadSDK(); 300 sdkP.catch(function () {}); 301 var cfgP = fetch("/api/voice-coach-config" + QS, { headers: { "Content-Type": "application/json" } }) 302 .then(function (r) { return r.json(); }); 303 cfgP.catch(function () {}); 304 // Safety net: if we never reach onConnect (stalled WebRTC, flaky/again-online 305 // mobile network, hung SDK load), don't leave the user stuck on "Startingâ¦" â 306 // tear down and re-enable the mic so they can retry. Cleared on connect/error. 307 var guard = setTimeout(function () { 308 if (active) return; 309 starting = false;
310 try { if (convo) convo.endSession(); } catch (e) {} 311 convo = null; setMic(""); micBtn.disabled = false; 312 setStatus("Couldn't connect â tap the mic to try again."); 313 }, 18000); 314 try { 315 // A beat for the audio route to settle after the greeting was released. 316 // Every voice surface here that works on a phone leaves a gap of this 317 // shape; taking the mic in the same tick as tearing down playback is what 318 // hands back a dead one. Only waits when a greeting actually played. 319 // 320 // p90RoomMicHandoff 2026-09-21 â Michael: "she greets me the first time, I 321 // tap the mic and I go to speak with her and the text doesn't show up so I 322 // know she's not hearing me." The greeting runs about ten seconds, so the 323 // natural thing to do is tap straight over it â and cutting off LIVE 324 // playback needs a longer settle than letting a finished clip go. 250ms 325 // was tuned for the second case and silently applied to both. 326 if (cutGreetingShort) await new Promise(function (r) { setTimeout(r, 500); }); 327 else if (greetPlayed) await new Promise(function (r) { setTimeout(r, 250); }); 328 329 // Prompt for mic permission up front (friendly message if denied). Do NOT 330 // stop the tracks â on iOS, acquiring then releasing the mic right before the 331 // SDK re-acquires it can hand back a dead/silent stream ("connects but hears 332 // nothing"). Let the SDK share/take over this stream. 333 // p90RoomEchoCancel 2026-09-21 â SHE WAS TALKING OVER HERSELF. Michael, on 334 // an iPhone: "she started to speak. Then she stopped and spoke again, 335 // like she started again." 336 // 337 // That is her own voice coming back in through the microphone. On iOS we 338 // run the WEBSOCKET transport, which plays her audio through an 339 // AudioContext rather than a WebRTC peer connection â so the browser does 340 // no echo cancellation for it unless the CAPTURE stream asks for it. Her 341 // reply leaks into the mic, the agent hears "the user is talking", 342 // interrupts itself, and starts over. 343 // 344 // app.html and coach.html â the two surfaces that work on his phone â 345 // both ask for these three constraints. This bubble asked for a bare 346 // `{ audio: true }` and was the only voice surface in the codebase that 347 // did. We acquire FIRST and hold the stream, so these constraints are the 348 // ones that govern the track the SDK then shares. 349 try { 350 await navigator.mediaDevices.getUserMedia({ 351 audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } 352 }); 353 } 354 catch (e) { clearTimeout(guard); starting = false; micBtn.disabled = false; setStatus("Allow your microphone, then tap again."); return; } 355 356 // Real config call (increments the per-IP limit) â already in flight. 357 var cfg = await cfgP; 358 if (!cfg || !cfg.enabled) { 359 clearTimeout(guard); 360 starting = false; 361 micBtn.disabled = false; 362 setStatus(cfg && cfg.reason === "rate_limited" ? "You've reached today's free chat limit â try the demo or 7-day trial to keep going â (or type a question below)" : "Voice is offline right now â you can still type your question below."); 363 if (cfg && cfg.reason === "rate_limited") showCTA(); 364 return; 365 } 366 CFG = cfg; 367 var Conversation = await sdkP; 368 // p90RoomAudioGuard 2026-09-20 â app.html and coach.html, the two surfaces 369 // that work on Michael's phone, both load /audio-route-guard.js and let it 370 // pick the transport. This file used a cruder rule of its own. The guard 371 // knows things that rule does not: iOS always wants websocket, and a phone 372 // whose WebRTC has failed once gets pinned to websocket from then on. 373 // Where the guard is not loaded (the six pages that never had it) this is 374 // byte-for-byte the old behaviour. 375 var connType = (window.P90AudioGuard && P90AudioGuard.connectionType) 376 ? P90AudioGuard.connectionType() 377 : (isMobile() ? "websocket" : "webrtc"); 378 // THIS bubble is stricter than the shared chooser, deliberately. The guard 379 // sends Android to WebRTC first and only pins websocket after a failure â 380 // fine for a signed-in member who will tap again, wrong for an anonymous 381 // visitor on a carrier network who gets exactly one chance and leaves. A 382 // guardrail test pins this: a mobile visitor is NEVER started on WebRTC. 383 if (isMobile() && connType !== "websocket") connType = "websocket"; 384 // p90RoomToolStubs 2026-09-20 â answer the agent's client tools so a tool 385 // call cannot wedge the turn. A client tool is answered by the PAGE: the 386 // agent emits the call and then WAITS for a result before it will take 387 // another turn. /room borrows the Course Navigator agent, which declares 388 // eleven of them; app.html (the course Ava Michael says "works perfectly") 389 // supplies a handler for every one, this bubble supplied none â so the 390 // first tool call hung forever with the mic still open and the panel still 391 // reading "Listeningâ¦". That is the "she doesn't hear me after the first 392 // one" he has been hitting. The config names the tools to stub; the six 393 // pages that don't send `stubTools` get exactly what they got before. 394 var stubTools; 395 if (cfg.stubTools && cfg.stubTools.length) { 396 stubTools = {};
397 cfg.stubTools.forEach(function (n) { 398 stubTools[String(n)] = function () { 399 return "Not available on this page. Answer in words instead â say the link or the next step out loud â and never mention tools."; 400 }; 401 }); 402 } 403 convo = await Conversation.startSession({ 404 agentId: cfg.agentId, 405 connectionType: connType, 406 dynamicVariables: cfg.dynamicVariables || undefined, 407 clientTools: stubTools, 408 overrides: { 409 agent: { 410 prompt: cfg.promptOverride ? { prompt: cfg.promptOverride } : undefined, 411 // An EMPTY string is a real instruction â "connect without saying 412 // anything" â so it must survive. `cfg.firstMessage || undefined` 413 // turned "" into undefined, which makes the agent fall back to its 414 // own configured greeting: the second hello Michael heard after 415 // the panel had already greeted him. 416 firstMessage: (typeof cfg.firstMessage === "string") ? cfg.firstMessage : undefined 417 }, 418 tts: cfg.voiceId ? { voiceId: cfg.voiceId } : undefined 419 }, 420 onConnect: function () { clearTimeout(guard); starting = false; active = true; hadConversation = true; micBtn.disabled = false; setMic("listening"); setStatus("Listening â just talk."); startCap(); try { fetch("/api/voice-coach-config" + QS + "&commit=1"); } catch (e) {} try { if (convo && convo.setVolume) convo.setVolume({ volume: 1 }); } catch (e) {} try { if (window.P90AudioGuard) P90AudioGuard.noteVoiceConnected(connType); } catch (e) {} }, 421 onDisconnect: function () { clearTimeout(guard); endSession(); }, 422 onError: function (err) { 423 clearTimeout(guard); 424 setMic(""); active = false; starting = false; 425 // A phone whose WebRTC just failed gets pinned to websocket by the 426 // guard; retry ONCE on the new transport rather than making him tap 427 // again to discover the same failure. Same recovery app.html does. 428 var shouldRetry = false; 429 try { shouldRetry = !!(window.P90AudioGuard && P90AudioGuard.noteVoiceFailure(connType)); } catch (e) {} 430 if (shouldRetry && !isRetry) { setStatus("Reconnectingâ¦"); try { if (convo) convo.endSession(); } catch (e) {} convo = null; setTimeout(function () { startSession(true); }, 300); return; } 431 micBtn.disabled = false; 432 setStatus("Connection hiccup â tap to try again."); 433 }, 434 onModeChange: function (m) { var mode = m && (m.mode || m); if (mode === "speaking") { setMic("speaking"); setStatus("Coach is speakingâ¦"); } else { setMic("listening"); setStatus("Listeningâ¦"); } }, 435 onMessage: function (msg) { if (!msg) return; var src = msg.source || (msg.role === "user" ? "user" : "ai"); var t = msg.message != null ? msg.message : (msg.text != null ? msg.text : ""); addMsg(src === "user" ? "user" : "coach", t); } 436 }); 437 try { if (convo && convo.setVolume) convo.setVolume({ volume: 1 }); } catch (e) {} 438 } catch (e) { 439 clearTimeout(guard); 440 starting = false; 441 micBtn.disabled = false; active = false; 442 setStatus(e && e.message === "sdk_load_failed" ? "Couldn't load the voice engine â refresh and retry." : "Couldn't start â tap to try again."); 443 } 444 } 445 446 async function sendRelay(sendBtn) { 447 var msgEl = panel.querySelector(".p9av-msg"), emEl = panel.querySelector(".p9av-email"); 448 var message = (msgEl.value || "").trim(); 449 if (!message) { formMsgEl.textContent = "Type a message first."; return; } 450 sendBtn.disabled = true; formMsgEl.style.color = "#5eead4"; formMsgEl.textContent = "Sendingâ¦"; 451 try { 452 var r = await fetch("/api/voice-ava-relay", { 453 method: "POST", headers: { "Content-Type": "application/json" }, 454 body: JSON.stringify({ message: message, email: (emEl.value || "").trim(), page: location.pathname }) 455 }); 456 var d = await r.json(); 457 if (d && d.ok) { formMsgEl.textContent = "Sent â Michael will get back to you. ð"; msgEl.value = ""; emEl.value = ""; } 458 else { formMsgEl.style.color = "#ffb4b4"; formMsgEl.textContent = d && d.error === "rate_limited" ? "You've sent a few already today â try again tomorrow." : "Couldn't send right now â please try again."; } 459 } catch (e) { formMsgEl.style.color = "#ffb4b4"; formMsgEl.textContent = "Couldn't send right now â please try again."; } 460 sendBtn.disabled = false;
461 } 462 463 function buildPanel() { 464 panel = document.createElement("div"); panel.className = "p9av-panel"; 465 // A page with one job gets one button, not a catalogue of links. 466 // One-tap openers, shown under her greeting and gone the moment they ask 467 // anything (their own question outranks our suggestions). 468 var pillsHtml = (Array.isArray(OPT.pills) && OPT.pills.length) 469 ? '<div class="p9av-pills">' + OPT.pills.map(function (p, i) { 470 return p.href 471 ? '<a class="p9av-pill p9av-pill-go" href="' + p.href + '">' + p.label + '</a>' 472 : '<button class="p9av-pill" type="button" data-pill="' + i + '">' + p.label + '</button>'; 473 }).join("") + '</div>' 474 : ''; 475 var chipsHtml = OPT.cta 476 ? '<button class="p9av-cta" type="button">' + OPT.cta.label + '</button>' 477 : CHIPS.map(function (c) { return '<a class="p9av-chip" href="' + c.href + '">' + c.label + '</a>'; }).join(""); 478 panel.innerHTML = 479 '<div class="p9av-head"><b>' + TITLE + '</b><button class="p9av-x" aria-label="Close">Ã</button></div>' + 480 '<div class="p9av-body">' + 481 '<button class="p9av-mic" aria-label="Talk">' + MIC_SVG + '</button>' + 482 '<div class="p9av-status">Tap the mic to talk, or type your question below.</div>' + 483 '<div class="p9av-timer"></div>' + 484 '<div class="p9av-tx"></div>' + 485 pillsHtml + 486 '<div class="p9av-txtrow"><input class="p9av-input" type="text" placeholder="Type your questionâ¦" autocomplete="off" enterkeyhint="send"><button class="p9av-txtsend" type="button" aria-label="Send">â¤</button></div>' + 487 '<div class="p9av-chips">' + chipsHtml + '</div>' + 488 (OPT.cta ? '' : '<button class="p9av-msgbtn" type="button">â Message Michael</button>') + 489 (OPT.cta ? '' : '<div class="p9av-form">' + 490 '<textarea class="p9av-msg" placeholder="Your question or message for Michaelâ¦"></textarea>' + 491 '<input class="p9av-email" type="email" placeholder="Your email (optional, so he can reply)" />' + 492 '<button class="p9av-send" type="button">Send to Michael</button>' + 493 '<div class="p9av-formmsg"></div>' + 494 '</div>') + 495 '<div class="p9av-hint">' + (OPT.hint || 'Free info chat · for real coaching, try the free demo or 7-day trial.') + '</div>' + 496 '</div>'; 497 document.body.appendChild(panel); 498 micBtn = panel.querySelector(".p9av-mic"); 499 statusEl = panel.querySelector(".p9av-status"); 500 timerEl = panel.querySelector(".p9av-timer"); 501 transcriptEl = panel.querySelector(".p9av-tx"); 502 formWrap = panel.querySelector(".p9av-form"); 503 formMsgEl = panel.querySelector(".p9av-formmsg"); 504 micBtn.addEventListener("click", function () { 505 if (active) { endSession(); return; } 506 unlockAudio(); // SYNCHRONOUS, inside the gesture â see above 507 startSession(false); 508 }); 509 panel.querySelector(".p9av-x").addEventListener("click", function () { togglePanel(false); if (active) endSession(); }); 510 var msgBtn = panel.querySelector(".p9av-msgbtn"); 511 if (msgBtn) msgBtn.addEventListener("click", function () { formWrap.classList.toggle("open"); }); 512 var sendBtn = panel.querySelector(".p9av-send"); 513 if (sendBtn) sendBtn.addEventListener("click", function () { sendRelay(this); }); 514 var ctaBtn = panel.querySelector(".p9av-cta"); 515 if (ctaBtn) ctaBtn.addEventListener("click", function () { 516 // Hand the floor to the form: a live agent talking over a registration 517 // form is the same mistake as two Ava bubbles on one page. 518 if (active) endSession(); 519 togglePanel(false); 520 try { if (typeof window[OPT.cta.action] === "function") window[OPT.cta.action]("ava"); } catch (e) {} 521 }); 522 var inputEl = panel.querySelector(".p9av-input"); 523 var sendEl = panel.querySelector(".p9av-txtsend"); 524 if (sendEl) sendEl.addEventListener("click", sendText); 525 if (inputEl) inputEl.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); sendText(); } }); 526 var pillsWrap = panel.querySelector(".p9av-pills"); 527 if (pillsWrap) pillsWrap.addEventListener("click", function (e) { 528 var b = e.target && e.target.getAttribute && e.target.getAttribute("data-pill") !== null 529 ? e.target 530 : (e.target && e.target.closest ? e.target.closest("[data-pill]") : null); 531 if (!b) return; // a go pill is a link â let it navigate 532 var pill = OPT.pills[Number(b.getAttribute("data-pill"))]; 533 if (!pill) return; 534 if (inputEl) { inputEl.value = pill.ask || pill.label; sendText(); } 535 }); 536 // Written welcome the moment the panel opens, so BOTH voice and text visitors 537 // are greeted instantly (voice also greets aloud once it connects). 538 // The written welcome is the FIRST thing anyone reads, so it has to match 539 // the page. On /room the default line pitched "our programs" â the very 540 // catalogue that variant deliberately removes. A host page can set its own. 541 addMsg("coach", OPT.welcome || "Hi! Iâm Ava, your Pro90D guide â ask me anything about our programs. Tap the mic to talk (itâs a free-flowing conversation, no need to tap again), or just type your question below."); 542 } 543 544 // Type-to-chat: same Ava concierge brain, no mic/voice connection needed. 545 async function sendText() { 546 if (!panel) return; 547 var inputEl = panel.querySelector(".p9av-input"); 548 var sendEl = panel.querySelector(".p9av-txtsend"); 549 if (!inputEl) return; 550 var msg = (inputEl.value || "").trim(); 551 if (!msg) return; 552 inputEl.value = ""; 553 var pw = panel.querySelector(".p9av-pills"); 554 if (pw) pw.classList.add("used"); 555 addMsg("user", msg); 556 // If a voice session is LIVE, feed the typed text into it so Ava answers in 557 // the SAME conversation (talk OR type in one session â vital for someone who 558 // can't keep speaking). Her reply returns via onMessage (spoken + captioned). 559 if (active && convo && typeof convo.sendUserMessage === "function") {
560 try { convo.sendUserMessage(msg); setStatus("Coach is respondingâ¦"); return; } 561 catch (e) { /* fall back to the standalone text brain below */ } 562 } 563 // Otherwise (no live voice), use the standalone text concierge. 564 chatHistory.push({ role: "user", content: msg }); 565 if (sendEl) sendEl.disabled = true; 566 setStatus("Ava is typingâ¦"); 567 try { 568 var r = await fetch(TEXT_API, { 569 method: "POST", headers: { "Content-Type": "application/json" }, 570 body: JSON.stringify({ message: msg, history: chatHistory.slice(0, -1) }) 571 }); 572 var d = await r.json(); 573 var replyText = d && (d.reply || d.text); 574 if (d && d.ok && replyText) { 575 addMsg("coach", replyText); 576 chatHistory.push({ role: "assistant", content: replyText }); 577 setStatus(""); 578 // A Room-aware brain can ask for the form. Honour it the same way her 579 // button does, so typing "how do I start" gets him there too. 580 // She was told to offer a two-minute option with a tag, not a bare path. 581 // Render it as something they can actually tap, under her message. 582 if (d.goLink && d.goLink.href && d.goLink.label) addGo(d.goLink); 583 if (d.openForm && OPT.cta) { 584 setTimeout(function () { 585 togglePanel(false); 586 try { if (typeof window[OPT.cta.action] === "function") window[OPT.cta.action]("ava"); } catch (e) {} 587 }, 900); 588 } 589 } else if (d && d.error === "rate_limited") { 590 addMsg("coach", "You've reached today's free chat limit â start the free demo or 7-day trial to keep going."); 591 showCTA(); setStatus(""); 592 } else { 593 addMsg("coach", "Sorry, I couldn't answer just now â please try again in a moment."); 594 setStatus(""); 595 } 596 } catch (e) { 597 addMsg("coach", "Sorry, I couldn't reach the server â please try again."); 598 setStatus(""); 599 } 600 if (sendEl) sendEl.disabled = false; 601 try { inputEl.focus(); } catch (e) {} 602 } 603 604 function playGreeting(src) { 605 try { 606 if (!greetEl) { 607 greetEl = new Audio(); 608 try { greetEl.setAttribute("playsinline", ""); } catch (e) {} 609 } 610 greetEl.src = src || OPT.greetAudio; 611 var p = greetEl.play(); 612 if (p && p.catch) p.catch(function () {}); // refused = silent, never fatal 613 } catch (e) {} 614 } 615 // p90RoomGreetRelease 2026-09-20 â Michael: "she's not hearing what I'm saying 616 // after the first one, and she basically just says whatever she wants to say." 617 // 618 // This used to be pause() alone, and PAUSE IS NOT RELEASE. On iOS an <audio> 619 // element that is merely paused but still holds a src keeps the media 620 // session; the agent's mic then reports connected and captures nothing, which 621 // is exactly "she can't hear me after the first turn" â the same symptom, one 622 // layer down from where it lived last time. 623 // 624 // The course Ava never hits this because it has no separate greeting element 625 // at all, and where it does stop other audio before the agent starts it does 626 // a HARD stop (p90StopAllAudio). Dropping the source and reloading hands the 627 // audio route back properly. 628 // Returns true when the greeting was ACTUALLY PLAYING when we cut it off. 629 // That distinction decides how long to let the audio route settle before 630 // taking the microphone: tearing down live playback and grabbing the mic in 631 // the same breath is the exact shape that hands back a dead one on iOS. 632 function stopGreeting() { 633 var wasPlaying = false; 634 try { 635 if (!greetEl) return false; 636 try { wasPlaying = !greetEl.paused && !greetEl.ended && greetEl.currentTime > 0; } catch (e) {} 637 greetEl.pause(); 638 greetEl.removeAttribute("src"); 639 greetEl.load(); 640 } catch (e) {} 641 return wasPlaying; 642 } 643 function togglePanel(open) { 644 if (open == null) open = !panelOpen; 645 panelOpen = open; 646 if (open) { stopTip(); if (bubble) bubble.classList.add("quiet"); } 647 else if (bubble) bubble.classList.remove("quiet"); 648 if (open && !panel) buildPanel();
649 if (panel) panel.classList.toggle("open", open); 650 // p90AvaFastConnect â someone who opens the bubble is about to talk. Fetch 651 // the voice SDK during the greeting so the mic tap doesn't wait on it. 652 if (open) { try { loadSDK().catch(function () {}); } catch (e) {} } 653 // Play it INSIDE the tap that opened the panel â that gesture is what iOS 654 // requires, and both openers (the bubble and a host page's own button) are 655 // real clicks, so the call stack is still the gesture's. 656 if (open && OPT.greetAudio) { 657 if (!greetPlayed) { 658 // First open of this page-load: the full hello. 659 greetPlayed = true; 660 playGreeting(); 661 } else if (hadConversation && OPT.greetAgainAudio) { 662 // They have been here and talked. Picking up mid-thread, so she says so 663 // rather than introducing herself to someone she was just speaking to. 664 playGreeting(OPT.greetAgainAudio); 665 if (OPT.welcomeAgain) addMsg("coach", OPT.welcomeAgain); 666 } else { 667 // Opened, closed, came back without saying anything â same hello again. 668 // No second written welcome: it is still sitting in the transcript. 669 playGreeting(); 670 } 671 } 672 if (!open) stopGreeting(); 673 } 674 675 function stopTip() { 676 if (tipTimer) { clearInterval(tipTimer); tipTimer = null; } 677 if (tipEl) { 678 tipEl.classList.remove("show"); 679 var el = tipEl; tipEl = null; 680 setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 450); 681 } 682 } 683 function renderTip() { 684 if (!tipEl) return; 685 var wasShown = tipEl.classList.contains("show"); 686 tipEl.classList.remove("show"); 687 setTimeout(function () { 688 if (!tipEl) return; 689 tipEl.innerHTML = SUGGESTIONS[tipIdx % SUGGESTIONS.length] + '<button class="p9av-tipx" aria-label="Dismiss">Ã</button>'; 690 tipEl.querySelector(".p9av-tipx").addEventListener("click", function (e) { e.stopPropagation(); stopTip(); }); 691 tipEl.classList.add("show"); 692 }, wasShown ? 400 : 0); 693 } 694 function startSuggestions() { 695 // Show once per PAGE LOAD (guarded so it can't double-run on one page), never 696 // while the panel is open. It rotates a few prompts then auto-dismisses, so it 697 // stays a gentle nudge â not a nag â while still being visible every visit. 698 // (Previously gated once-per-browser-session, which hid it from repeat 699 // visitors and anyone who'd already seen it once.) 700 if (tipShown || panelOpen) return; 701 tipShown = true; 702 tipEl = document.createElement("div"); tipEl.className = "p9av-tip"; 703 tipEl.addEventListener("click", function () { stopTip(); togglePanel(true); }); 704 document.body.appendChild(tipEl); 705 setTimeout(function () { renderTip(); }, 900); 706 var cycles = 0; 707 tipTimer = setInterval(function () { 708 tipIdx++; cycles++; 709 renderTip(); 710 if (cycles >= 4) { clearInterval(tipTimer); tipTimer = null; setTimeout(stopTip, 4200); } // ~5 prompts, then fade 711 }, 4200); 712 } 713 714 function buildBubble() { 715 bubble = document.createElement("button"); 716 bubble.className = "p9av-bubble"; bubble.setAttribute("aria-label", TITLE); 717 bubble.innerHTML = '<span class="p9av-badge">Ask</span>' + MIC_SVG; 718 bubble.addEventListener("click", function () { togglePanel(); }); 719 document.body.appendChild(bubble); 720 } 721 722 async function init() { 723 try { 724 // Probe (no rate-limit cost): only show the bubble if public voice is on. 725 var r = await fetch("/api/voice-coach-config" + QS + "&probe=1", { headers: { "Content-Type": "application/json" } }); 726 var d = await r.json(); 727 if (!d || !d.enabled) return; // silent â nothing rendered 728 css(); buildBubble(); startSuggestions(); 729 // p90VoiceAvaOpenApi 2026-09-19 â a public handle so a host page's own 730 // "Ask Ava" button can open the panel, instead of every page inventing 731 // its own opener. /room has two such buttons in the copy; they used to 732 // call a function that no longer exists, so tapping them did nothing. 733 // Only ever set once the bubble really rendered, so a page can test for 734 // it and fall back cleanly when public voice is off. 735 window.P90VoiceAva = { 736 open: function () { togglePanel(true); }, 737 close: function () { togglePanel(false); if (active) endSession(); }, 738 isOpen: function () { return !!panelOpen; } 739 };
740 // Anything that asked to open before the probe came back gets its turn. 741 try { 742 if (window.__p90AvaOpenPending) { window.__p90AvaOpenPending = false; togglePanel(true); } 743 } catch (e) {} 744 } catch (e) { /* never break the host page */ } 745 } 746 747 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); 748 else init(); 749})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.