PageSourceSearch

https://www.pro90d.com/voice-ava.js

js pro90d.com collected 2026-10-01 02:43:23 UTC 46,576 bytes, 749 lines download raw bytes

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.