1/** 2 * True AI â Front-End Voice Interface Engine v1.3.0 (fixed) 3 * Author: Rev. Futurist Erskine Matthews 4 * Site: https://newbeings1619.us 5 * 6 * FIXES APPLIED: 7 * 1. Config key names corrected â CFG.display_name not CFG.true_ai_display_name 8 * 2. Audio element stored and cleaned up properly (no leak) 9 * 3. Keyboard handler removed on destroy() to prevent accumulation 10 * 4. getUserMedia monkey-patch removed â stream passed directly in standalone mode 11 * 5. Login-gate shown to unauthenticated users when require_login is enabled 12 * 6. setupFloatingWidget called only once (from the first instance) 13 * 14 * Modes: 15 * 1. Meow mode â detects .mwai-realtime, hides it, injects True AI UI beside it, 16 * proxies Meow's play/stop/pause buttons. 17 * 2. Standalone â renders inside .true-ai-shortcode-container, connects directly 18 * to OpenAI Realtime API via ephemeral token from our REST endpoint. 19 */ 20(function () { 21 'use strict'; 22 23 if (typeof trueAiConfig === 'undefined') return; 24 25 const CFG = trueAiConfig.settings; // bare keys: display_name, status_idle, etc. 26 const CSS_VARS = trueAiConfig.cssVars; 27 const REST_URL = trueAiConfig.restUrl; 28 const NONCE = trueAiConfig.nonce; 29 const HAS_MEOW = trueAiConfig.hasMeow === '1'; 30 const REQUIRE_LOGIN = trueAiConfig.requireLogin === '1'; 31 const IS_LOGGED_IN = trueAiConfig.isLoggedIn === '1'; 32 33 /* ââ SVG Icons ââ */ 34 const SVG = { 35 mic: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`, 36 micOff: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/></svg>`, 37 transcript: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, 38 copy: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`, 39 sun: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>`, 40 moon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`, 41 stop: `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>`, 42 close: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>`, 43 spinner: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:tai-mic-spin 1s linear infinite"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`, 44 lock: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`, 45 }; 46 47 // FIX: Use bare key names â CFG.status_idle not CFG.true_ai_status_idle 48 const LABELS = { 49 idle: CFG.status_idle || 'Tap the mic to start', 50 connecting: CFG.status_connecting || 'Connecting...', 51 listening: CFG.status_listening || 'Listening...', 52 thinking: CFG.status_thinking || 'Thinking...', 53 speaking: CFG.status_speaking || 'Assistant is speaking...', 54 muted: CFG.status_muted || 'Microphone muted', 55 }; 56 57 /* ââ Inject CSS variables ââ */ 58 function injectCssVars() { 59 let el = document.getElementById('tai-css-vars'); 60 if (!el) { el = document.createElement('style'); el.id = 'tai-css-vars'; document.head.appendChild(el); } 61 el.textContent = ':root{' + Object.entries(CSS_VARS).map(([k, v]) => k + ':' + v).join(';') + '}'; 62 } 63 64 /* ââ Build UI HTML ââ */ 65 function buildUI(overrides) { 66 // FIX: Use bare key names to match what PHP sends 67 const o = Object.assign({ 68 name: CFG.display_name || 'True AI', 69 theme: CFG.default_theme || 'dark', 70 visualizer: CFG.visualizer_type || 'orb', 71 greeting: CFG.greeting_text || 'Hi! Ask me anything.', 72 transcript: CFG.transcript_default_open === '1' ? 'open' : '', 73 }, overrides || {}); 74 75 const eqMode = o.visualizer === 'equalizer'; 76 const showHeader = CFG.show_header !== '0'; 77 const showTimer = CFG.show_session_timer !== '0'; 78 const showTheme = CFG.theme_toggle !== '0'; 79 const showKbd = CFG.show_keyboard_hints !== '0'; 80 const tOpen = o.transcript === 'open'; 81 const wakeEnabled = CFG.wake_word_enabled === '1'; 82 const wakeWord = CFG.wake_word || 'Hey True AI'; 83 84 // Login gate â shown in place of the UI when access is restricted 85 if (REQUIRE_LOGIN && !IS_LOGGED_IN) { 86 return ` 87<div class="tai-voice-ui tai-${o.theme}" id="tai-voice-ui" role="region" aria-label="Voice Interface"> 88 <div class="tai-login-gate"> 89 <div class="tai-login-gate-icon">${SVG.lock}</div> 90 <p class="tai-login-gate-msg">Please <a href="${escHtml(window.location.origin)}/wp-login.php?redirect_to=${encodeURIComponent(window.location.href)}">log in</a> to use the voice assistant.</p> 91 </div> 92</div>`; 93 } 94 95 let rings = ''; 96 for (let i = 0; i < 5; i++) rings += '<div class="tai-ring"></div>'; 97 98 let eqBars = ''; 99 for (let i = 0; i < 16; i++) eqBars += '<div class="tai-eq-bar"></div>'; 100 101 return ` 102<div class="tai-voice-ui${eqMode ? ' tai-eq-mode' : ''} tai-${o.theme}
102" id="tai-voice-ui" role="region" aria-label="${escHtml(o.name)} Voice Interface"> 103 104 ${showHeader ? ` 105 <div class="tai-header"> 106 <span class="tai-header-title">${escHtml(o.name)}</span> 107 <div class="tai-header-right"> 108 ${showTimer ? `<span class="tai-session-timer" id="tai-timer" aria-live="off">0:00</span>` : ''} 109 ${showTheme ? `<button class="tai-theme-toggle-btn" id="tai-theme-btn" aria-label="Toggle theme">${o.theme === 'dark' ? SVG.sun : SVG.moon}</button>` : ''} 110 </div> 111 </div>` : ''} 112 113 <div class="tai-visualizer-stage"> 114 ${wakeEnabled ? `<div class="tai-wake-word-indicator"><span class="tai-wake-dot"></span><span>Say "${escHtml(wakeWord)}"</span></div>` : ''} 115 <div class="tai-orb-container">${rings}<div class="tai-orb" id="tai-orb" aria-hidden="true"></div></div> 116 <div class="tai-eq-container" id="tai-eq" aria-hidden="true">${eqBars}</div> 117 </div> 118 119 <div class="tai-status-label" id="tai-status" aria-live="polite">${LABELS.idle}</div> 120 121 <div class="tai-speech-bubble-wrap"> 122 <div class="tai-speech-bubble" id="tai-bubble">${escHtml(o.greeting)}</div> 123 </div> 124 125 <div class="tai-error-banner" id="tai-error" role="alert"></div> 126 127 <div class="tai-controls" role="toolbar" aria-label="Voice controls"> 128 <button class="tai-ctrl-btn tai-mute-btn" id="tai-mute-btn" aria-label="Mute microphone" title="Mute / Unmute">${SVG.mic}</button> 129 <button class="tai-ctrl-btn tai-mic-btn" id="tai-mic-btn" aria-label="Start voice session" title="Start / Stop">${SVG.mic}</button> 130 <button class="tai-ctrl-btn tai-transcript-btn${tOpen ? ' tai-active' : ''}" id="tai-transcript-btn" aria-label="Toggle transcript" aria-expanded="${tOpen}">${SVG.transcript}</button> 131 </div> 132 133 ${showKbd ? ` 134 <div class="tai-keyboard-hints" aria-hidden="true"> 135 <span class="tai-kbd"><kbd>Space</kbd> talk</span> 136 <span class="tai-kbd"><kbd>M</kbd> mute</span> 137 <span class="tai-kbd"><kbd>T</kbd> transcript</span> 138 <span class="tai-kbd"><kbd>Esc</kbd> stop</span> 139 </div>` : ''} 140 141 <div class="tai-transcript-panel${tOpen ? ' tai-open' : ''}" id="tai-transcript-panel"> 142 <div class="tai-transcript-inner"> 143 <div class="tai-transcript-header"> 144 <span class="tai-transcript-title">Transcript</span> 145 <button class="tai-copy-all-btn" id="tai-copy-all">${SVG.copy} Copy All</button> 146 </div> 147 <div class="tai-transcript-messages" id="tai-transcript-messages"> 148 <div class="tai-transcript-empty">Conversation will appear here</div> 149 </div> 150 </div> 151 </div> 152 153</div>`; 154 } 155 156 function escHtml(str) { 157 return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 158 } 159 160 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 161 INSTANCE â one per voice widget on the page 162 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 163 function TrueAIInstance(container, meowEl, overrides) { 164 this.container = container; 165 this.meowEl = meowEl; 166 this.overrides = overrides || {}; 167 this.state = 'idle'; 168 this.isMuted = false; 169 this.isActive = false; 170 this.transcript = []; 171 this.timerSec = 0; 172 this.timerInt = null; 173 this.animFrame = null; 174 this.audioCtx = null; 175 this.analyser = null; 176 this.eqBars = []; 177 this.rtcPeer = null; 178 this.rtcDC = null; 179 this.audioEl = null; 180 this.wakeRecog = null; 181 this._kbHandler = null; 182 // Resolve display name from shortcode override or global config 183 this.displayName = (overrides && overrides.name) || CFG.display_name || 'True AI'; 184 this._init(); 185 } 186 187 TrueAIInstance.prototype._init = function () { 188 this.container.innerHTML = buildUI(this.overrides); 189 this.ui = this.container.querySelector('#tai-voice-ui'); 190 191 // If login gate is shown, do nothing further 192 if (REQUIRE_LOGIN && !IS_LOGGED_IN) return; 193 194 this._wire(); 195 196 if (CFG.greeting_animation !== '0') { 197 const orb = this.container.querySelector('#tai-orb'); 198 if (orb) { 199 setTimeout(() => { 200 orb.classList.add('tai-greeting-anim'); 201 orb.addEventListener('animationend', () => orb.classList.remove('tai-greeting-anim'), { once: true }); 202 }, 300); 203 } 204 } 205 206 if (this.meowEl) this._observeMeow(); 207 208 if (CFG.wake_word_enabled === '1') this._initWakeWord(); 209 210 // FIX: Store handler reference so we can remove it later 211 this._kbHandler = this._handleKeyboard.bind(this); 212 document.addEventListener('keydown', this._kbHandler); 213 214 this.eqBars = Array.from(this.container.querySelectorAll('#tai-eq .tai-eq-bar')); 215 }; 216 217 /* FIX: destroy() method cleans up all listeners and resources */ 218 TrueAIInstance.prototype.destroy = function () { 219 if (this._kbHandler) { 220 document.removeEventListener('keydown', this._kbHandler); 221 this._kbHandler = null; 222 } 223 this._stopRTC(); 224 this._stopTimer(); 225 this._stopAnalysis(); 226 if (this.wakeRecog) { 227 try { this.wakeRecog.stop(); } catch (_) {} 228 this.wakeRecog = null; 229 } 230 }; 231 232 TrueAIInstance.prototype._wire = function () { 233 const self = this; 234 const q = (id) => this.container.querySelector('#' + id); 235 236 const micBtn = q('tai-mic-btn'); 237 const muteBtn = q('tai-mute-btn'); 238 const transcriptBtn = q('tai-transcript-btn'); 239 const transcriptPanel = q('tai-transcript-panel'); 240 const themeBtn = q('tai-theme-btn'); 241 const copyAllBtn = q('tai-copy-all'); 242 243 if (micBtn) micBtn.addEventListener('click', () => self.isActive ? self._stop() : self._start()); 244 if (muteBtn) muteBtn.addEventListener('click', () => self._toggleMute()); 245 if (transcriptBtn && transcriptPanel) { 246 transcriptBtn.addEventListener('click', () => { 247 const open = transcriptPanel.classList.toggle('tai-open'); 248 transcriptBtn.classList.toggle('tai-active', open); 249 transcriptBtn.setAttribute('aria-expanded', open); 250 }); 251 } 252 if (themeBtn) { 253 themeBtn.addEventListener('click', () => { 254 const isLight = this.ui.classList.toggle('tai-light'); 255 this.ui.classList.toggle('tai-dark', !isLight); 256 themeBtn.innerHTML = isLight ? SVG.moon : SVG.sun; 257 }); 258 } 259 if (copyAllBtn) { 260 copyAllBtn.addEventListener('click', () => { 261 const text = self.transcript.map(m =>
261 '[' + (m.role === 'assistant' ? self.displayName : 'You') + '] ' + m.text).join('\n\n'); 262 text ? self._copy(text) : self._toast('No transcript yet'); 263 }); 264 } 265 }; 266 267 /* ââ State machine ââ */ 268 TrueAIInstance.prototype._setState = function (state, sub) { 269 this.state = state; 270 const ui = this.ui; 271 if (!ui) return; 272 ui.classList.remove('tai-active', 'tai-listening', 'tai-speaking', 'tai-thinking', 'tai-connecting'); 273 274 const micBtn = this.container.querySelector('#tai-mic-btn'); 275 276 switch (state) { 277 case 'idle': 278 this._setStatus(LABELS.idle); 279 if (micBtn) { micBtn.innerHTML = SVG.mic; micBtn.classList.remove('tai-active', 'tai-connecting'); } 280 this._stopTimer(); 281 this._stopAnalysis(); 282 break; 283 case 'connecting': 284 ui.classList.add('tai-connecting'); 285 this._setStatus(LABELS.connecting); 286 if (micBtn) { micBtn.innerHTML = SVG.spinner; micBtn.classList.add('tai-connecting'); } 287 break; 288 case 'active': 289 ui.classList.add('tai-active'); 290 if (sub === 'listening') { ui.classList.add('tai-listening'); this._setStatus(LABELS.listening); } 291 else if (sub === 'speaking') { ui.classList.add('tai-speaking'); this._setStatus(LABELS.speaking); } 292 else if (sub === 'thinking') { ui.classList.add('tai-thinking'); this._setStatus('<span class="tai-thinking-dots"><span></span><span></span><span></span></span> ' + LABELS.thinking); } 293 else { this._setStatus(LABELS.listening); } 294 if (micBtn) { micBtn.innerHTML = SVG.stop; micBtn.classList.remove('tai-connecting'); micBtn.classList.add('tai-active'); } 295 break; 296 case 'muted': 297 ui.classList.add('tai-active'); 298 this._setStatus(LABELS.muted); 299 break; 300 } 301 }; 302 303 TrueAIInstance.prototype._setStatus = function (html) { 304 const el = this.container.querySelector('#tai-status'); 305 if (el) el.innerHTML = html; 306 }; 307 308 TrueAIInstance.prototype._showError = function (msg) { 309 const el = this.container.querySelector('#tai-error'); 310 if (!el) return; 311 el.textContent = msg; 312 el.classList.add('tai-visible'); 313 setTimeout(() => el.classList.remove('tai-visible'), 8000); 314 }; 315 316 TrueAIInstance.prototype._updateBubble = function (text) { 317 const el = this.container.querySelector('#tai-bubble'); 318 if (!el || !text) return; 319 el.textContent = text.length > 120 ? text.slice(0, 120) + 'â¦' : text; 320 el.classList.remove('tai-fade-in'); 321 void el.offsetWidth; 322 el.classList.add('tai-fade-in'); 323 }; 324 325 /* ââ Start / Stop ââ */ 326 TrueAIInstance.prototype._start = function () { 327 this._setState('connecting'); 328 if (this.meowEl) { 329 this._startMeow(); 330 } else { 331 this._startStandalone(); 332 } 333 }; 334 335 TrueAIInstance.prototype._stop = function () { 336 if (this.meowEl) { 337 const btn = this.meowEl.querySelector('.mwai-stop') || document.querySelector('button.mwai-stop'); 338 if (btn) btn.click(); 339 } else { 340 this._stopRTC(); 341 } 342 this.isActive = false; 343 this.isMuted = false; 344 this._updateMuteUI(); 345 this._setState('idle'); 346 this._clearTranscript(); 347 }; 348 349 /* ââ Meow mode ââ */ 350 TrueAIInstance.prototype._startMeow = function () { 351 const btn = this.meowEl.querySelector('.mwai-play') || document.querySelector('button.mwai-play'); 352 if (btn) { 353 btn.click(); 354 this.isActive = true; 355 this._startTimer(); 356 } else { 357 this._showError('AI Engine realtime button not found. Make sure a realtime chatbot shortcode is on this page.'); 358 this._setState('idle'); 359 } 360 }; 361 362 TrueAIInstance.prototype._observeMeow = function () { 363 const self = this; 364 const obs = new MutationObserver(() => { 365 const stopBtn = self.meowEl.querySelector('.mwai-stop'); 366 if (stopBtn && self.state === 'connecting') { 367 self.isActive = true; 368 self._setState('active', 'listening'); 369 self._startTimer(); 370 } else if (!stopBtn && self.isActive) { 371 self.isActive = false;
372 self._setState('idle'); 373 self._stopTimer(); 374 } 375 const lastTx = self.meowEl.querySelector('.mwai-last-transcript'); 376 if (lastTx && lastTx.textContent.trim()) self._updateBubble(lastTx.textContent.trim()); 377 }); 378 obs.observe(this.meowEl, { childList: true, subtree: true, attributes: true, characterData: true }); 379 380 const chatbot = this.meowEl.closest('.mwai-chatbot'); 381 if (chatbot) { 382 const msgObs = new MutationObserver((muts) => { 383 muts.forEach(m => m.addedNodes.forEach(node => { 384 if (node.nodeType !== 1 || !node.classList.contains('mwai-message')) return; 385 const role = node.classList.contains('mwai-ai') ? 'assistant' : 'user'; 386 const text = (node.querySelector('.mwai-text') || node).textContent.trim(); 387 if (text && text !== '[Audio]') { 388 self._addTranscript(role, text); 389 if (role === 'assistant') { self._updateBubble(text); self._setState('active', 'speaking'); } 390 else { self._setState('active', 'thinking'); } 391 } 392 })); 393 }); 394 msgObs.observe(chatbot, { childList: true, subtree: true }); 395 } 396 397 // FIX: Removed getUserMedia monkey-patch entirely. 398 // Meow mode does not need audio analysis via patched getUserMedia. 399 // Analysis is started directly in standalone mode via the stream reference. 400 }; 401 402 TrueAIInstance.prototype._toggleMute = function () { 403 if (!this.isActive) return; 404 this.isMuted = !this.isMuted; 405 if (this.meowEl) { 406 const btn = this.meowEl.querySelector('.mwai-pause') || document.querySelector('button.mwai-pause'); 407 if (btn) btn.click(); 408 } else if (this.rtcPeer) { 409 const senders = this.rtcPeer.getSenders ? this.rtcPeer.getSenders() : []; 410 senders.forEach(s => { if (s.track && s.track.kind === 'audio') s.track.enabled = !this.isMuted; }); 411 } 412 this._updateMuteUI(); 413 this._setState(this.isMuted ? 'muted' : 'active', 'listening'); 414 }; 415 416 TrueAIInstance.prototype._updateMuteUI = function () { 417 const btn = this.container.querySelector('#tai-mute-btn'); 418 if (!btn) return; 419 btn.innerHTML = this.isMuted ? SVG.micOff : SVG.mic; 420 btn.classList.toggle('tai-muted', this.isMuted); 421 btn.setAttribute('aria-label', this.isMuted ? 'Unmute microphone' : 'Mute microphone'); 422 }; 423 424 /* ââ Standalone WebRTC mode ââ */ 425 TrueAIInstance.prototype._startStandalone = function () { 426 const self = this; 427 fetch(REST_URL + 'session', { 428 method: 'POST', 429 headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' }, 430 }) 431 .then(r => r.json()) 432 .then(data => { 433 if (data.code) throw new Error(data.message || 'Session error'); 434 self._connectRTC(data.client_secret.value, data.model); 435 }) 436 .catch(err => { 437 self._showError('Could not start session: ' + err.message); 438 self._setState('idle'); 439 }); 440 }; 441 442 TrueAIInstance.prototype._connectRTC = function (ephemeralKey, model) { 443 const self = this; 444 const pc = new RTCPeerConnection(); 445 this.rtcPeer = pc; 446 447 // FIX: Store audio element on instance so it can be cleaned up in _stopRTC 448 const audioEl = document.createElement('audio'); 449 audioEl.autoplay = true; 450 audioEl.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;'; 451 document.body.appendChild(audioEl); 452 this.audioEl = audioEl; 453 454 pc.ontrack = (e) => { audioEl.srcObject = e.streams[0]; }; 455 456 const dc = pc.createDataChannel('oai-events'); 457 this.rtcDC = dc; 458 459 dc.onmessage = (e) => { 460 try { 461 const evt = JSON.parse(e.data); 462 self._handleRTCEvent(evt); 463 } catch (_) {} 464 }; 465 466 // FIX: Stream passed directly to _startAnalysis â no monkey-patching needed 467 navigator.mediaDevices.getUserMedia({ audio: true }) 468 .then(stream => {
469 stream.getTracks().forEach(t => pc.addTrack(t, stream)); 470 self._startAnalysis(stream); // direct reference, clean and reliable 471 return pc.createOffer(); 472 }) 473 .then(offer => { 474 pc.setLocalDescription(offer); 475 return fetch('https://api.openai.com/v1/realtime?model=' + encodeURIComponent(model), { 476 method: 'POST', 477 headers: { 478 'Authorization': 'Bearer ' + ephemeralKey, 479 'Content-Type': 'application/sdp', 480 }, 481 body: offer.sdp, 482 }); 483 }) 484 .then(r => r.text()) 485 .then(sdp => { 486 pc.setRemoteDescription({ type: 'answer', sdp }); 487 self.isActive = true; 488 self._startTimer(); 489 self._setState('active', 'listening'); 490 }) 491 .catch(err => { 492 self._showError('WebRTC error: ' + err.message); 493 self._setState('idle'); 494 }); 495 }; 496 497 TrueAIInstance.prototype._handleRTCEvent = function (evt) { 498 switch (evt.type) { 499 case 'response.audio_transcript.delta': 500 this._setState('active', 'speaking'); 501 break; 502 case 'response.audio_transcript.done': 503 if (evt.transcript) { 504 this._addTranscript('assistant', evt.transcript); 505 this._updateBubble(evt.transcript); 506 } 507 this._setState('active', 'listening'); 508 break; 509 case 'input_audio_buffer.speech_started': 510 this._setState('active', 'listening'); 511 break; 512 case 'input_audio_buffer.speech_stopped': 513 this._setState('active', 'thinking'); 514 break; 515 case 'conversation.item.input_audio_transcription.completed': 516 if (evt.transcript) this._addTranscript('user', evt.transcript); 517 break; 518 case 'error': 519 this._showError((evt.error && evt.error.message) ? evt.error.message : 'Session error.'); 520 break; 521 } 522 }; 523 524 TrueAIInstance.prototype._stopRTC = function () { 525 if (this.rtcDC) { try { this.rtcDC.close(); } catch (_) {} this.rtcDC = null; } 526 if (this.rtcPeer) { try { this.rtcPeer.close(); } catch (_) {} this.rtcPeer = null; } 527 // FIX: Remove the audio element from the DOM and release stream 528 if (this.audioEl) { 529 this.audioEl.srcObject = null; 530 if (this.audioEl.parentNode) this.audioEl.parentNode.removeChild(this.audioEl); 531 this.audioEl = null; 532 } 533 this._stopAnalysis(); 534 }; 535 536 /* ââ Audio Analysis ââ */ 537 TrueAIInstance.prototype._startAnalysis = function (stream) { 538 if (!stream) return; 539 try { 540 if (!this.audioCtx) this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 541 const src = this.audioCtx.createMediaStreamSource(stream); 542 this.analyser = this.audioCtx.createAnalyser(); 543 this.analyser.fftSize = 64; 544 src.connect(this.analyser); 545 const bufLen = this.analyser.frequencyBinCount; 546 const data = new Uint8Array(bufLen); 547 const self = this; 548 const step = Math.max(1, Math.floor(bufLen / 16)); 549 550 const draw = () => { 551 self.animFrame = requestAnimationFrame(draw); 552 self.analyser.getByteFrequencyData(data); 553 const avg = data.reduce((a, b) => a + b, 0) / bufLen; 554 const orb = self.container.querySelector('#tai-orb'); 555 if (orb) orb.style.transform = 'scale(' + (1 + Math.min(avg / 255, 1) * 0.25) + ')'; 556 self.eqBars.forEach((bar, i) => { 557 const val = data[i * step] || 0; 558 bar.style.height = (4 + (val / 255) * 120) + 'px'; 559 }); 560 }; 561 draw(); 562 } catch (e) { /* silent */ } 563 }; 564 565 TrueAIInstance.prototype._stopAnalysis = function () { 566 if (this.animFrame) { cancelAnimationFrame(this.animFrame); this.animFrame = null; } 567 const orb = this.container.querySelector('#tai-orb'); 568 if (orb) orb.style.transform = '';
569 this.eqBars.forEach(b => { b.style.height = ''; }); 570 }; 571 572 /* ââ Timer ââ */ 573 TrueAIInstance.prototype._startTimer = function () { 574 this.timerSec = 0; 575 clearInterval(this.timerInt); 576 const el = this.container.querySelector('#tai-timer'); 577 this.timerInt = setInterval(() => { 578 this.timerSec++; 579 if (el) el.textContent = Math.floor(this.timerSec / 60) + ':' + String(this.timerSec % 60).padStart(2, '0'); 580 }, 1000); 581 }; 582 583 TrueAIInstance.prototype._stopTimer = function () { 584 clearInterval(this.timerInt); 585 const el = this.container.querySelector('#tai-timer'); 586 if (el) el.textContent = '0:00'; 587 }; 588 589 /* ââ Transcript ââ */ 590 TrueAIInstance.prototype._addTranscript = function (role, text) { 591 if (!text || !text.trim()) return; 592 const msg = { role, text: text.trim(), time: new Date() }; 593 this.transcript.push(msg); 594 const container = this.container.querySelector('#tai-transcript-messages'); 595 if (!container) return; 596 const empty = container.querySelector('.tai-transcript-empty'); 597 if (empty) empty.remove(); 598 const timeStr = msg.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 599 const el = document.createElement('div'); 600 el.className = 'tai-transcript-msg'; 601 el.dataset.role = role; 602 el.innerHTML = ` 603 <div class="tai-transcript-msg-meta"> 604 <span class="tai-transcript-msg-role">${role === 'assistant' ? escHtml(this.displayName) : 'You'}</span> 605 <span style="display:flex;align-items:center;gap:6px"> 606 <span>${timeStr}</span> 607 <button class="tai-transcript-msg-copy" aria-label="Copy">${SVG.copy}</button> 608 </span> 609 </div> 610 <div class="tai-transcript-msg-text">${escHtml(msg.text)}</div>`; 611 el.querySelector('.tai-transcript-msg-copy').addEventListener('click', () => this._copy(msg.text)); 612 container.appendChild(el); 613 container.parentElement.scrollTop = container.parentElement.scrollHeight; 614 }; 615 616 TrueAIInstance.prototype._clearTranscript = function () { 617 this.transcript = []; 618 const c = this.container.querySelector('#tai-transcript-messages'); 619 if (c) c.innerHTML = '<div class="tai-transcript-empty">Conversation will appear here</div>'; 620 }; 621 622 /* ââ Copy ââ */ 623 TrueAIInstance.prototype._copy = function (text) { 624 if (navigator.clipboard && navigator.clipboard.writeText) { 625 navigator.clipboard.writeText(text).catch(() => this._fallbackCopy(text)); 626 } else { 627 this._fallbackCopy(text); 628 } 629 this._toast('Copied!'); 630 }; 631 632 TrueAIInstance.prototype._fallbackCopy = function (text) { 633 const ta = document.createElement('textarea'); 634 ta.value = text; 635 ta.style.cssText = 'position:fixed;top:-9999px;opacity:0'; 636 document.body.appendChild(ta); 637 ta.select(); 638 document.execCommand('copy'); 639 document.body.removeChild(ta); 640 }; 641 642 TrueAIInstance.prototype._toast = function (msg) { 643 let t = document.getElementById('tai-copy-toast'); 644 if (!t) { t = document.createElement('div'); t.id = 'tai-copy-toast'; t.className = 'tai-copy-toast'; document.body.appendChild(t); } 645 t.textContent = msg; 646 t.classList.add('tai-show'); 647 clearTimeout(this._toastTimer); 648 this._toastTimer = setTimeout(() => t.classList.remove('tai-show'), 2000); 649 }; 650 651 /* ââ Keyboard ââ */ 652 TrueAIInstance.prototype._handleKeyboard = function (e) { 653 const tag = document.activeElement ? document.activeElement.tagName : ''; 654 if (tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable)) return; 655 switch (e.code) { 656 case 'Space': e.preventDefault(); this.isActive ? this._toggleMute() : this._start(); break; 657 case 'KeyM': this._toggleMute(); break; 658 case 'KeyT': this.container.querySelector('#tai-transcript-btn')?.click(); break; 659 case 'Escape': if (this.isActive) this._stop(); break; 660 } 661 }; 662 663 /* ââ Wake Word ââ */ 664 TrueAIInstance.prototype._initWakeWord = function () { 665 const SR = window.SpeechRecognition || window.webkitSpeechRecognition; 666 if (!SR) return; 667 const self = this; 668 const ww = (CFG.wake_word || 'Hey True AI').toLowerCase(); 669 const rec = new SR(); 670 rec.continuous = true; 671 rec.interimResults = false;
672 rec.onresult = (e) => { 673 const t = e.results[e.results.length - 1][0].transcript.toLowerCase().trim(); 674 if (t.includes(ww) && !self.isActive) self._start(); 675 }; 676 rec.onend = () => { if (!self.isActive) { try { rec.start(); } catch (_) {} } }; 677 try { rec.start(); } catch (_) {} 678 this.wakeRecog = rec; 679 }; 680 681 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 682 FLOATING WIDGET 683 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 684 // FIX: Accepts instance and a flag â only one floating overlay is created globally 685 let floatingWidgetSetup = false; 686 687 function setupFloatingWidget(instance) { 688 if (floatingWidgetSetup) return; 689 const widgetEl = document.getElementById('true-ai-floating-widget'); 690 if (!widgetEl || widgetEl.dataset.enabled !== '1') return; 691 floatingWidgetSetup = true; 692 693 const overlay = document.createElement('div'); 694 overlay.className = 'tai-floating-overlay'; 695 overlay.id = 'tai-floating-overlay'; 696 697 const closeBtn = document.createElement('button'); 698 closeBtn.className = 'tai-floating-close'; 699 closeBtn.innerHTML = SVG.close; 700 closeBtn.setAttribute('aria-label', 'Close voice interface'); 701 702 overlay.appendChild(closeBtn); 703 overlay.appendChild(instance.container); 704 document.body.appendChild(overlay); 705 706 widgetEl.addEventListener('click', () => { overlay.classList.add('tai-open'); widgetEl.style.display = 'none'; }); 707 widgetEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); widgetEl.click(); } }); 708 709 const close = () => { 710 overlay.classList.remove('tai-open'); 711 widgetEl.style.display = ''; 712 if (instance.isActive) instance._stop(); 713 }; 714 closeBtn.addEventListener('click', close); 715 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 716 } 717 718 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 719 BOOTSTRAP 720 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 721 function bootstrap() { 722 injectCssVars(); 723 724 // ââ Mode 1: Meow integration ââââââââââââââââââââââââââââââ 725 if (HAS_MEOW) { 726 const initMeow = (meowEl) => { 727 if (meowEl.dataset.taiInit) return; 728 meowEl.dataset.taiInit = 'true'; 729 meowEl.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;'; 730 const chatbot = meowEl.closest('.mwai-chatbot') || meowEl.parentElement; 731 const wrapper = document.createElement('div'); 732 wrapper.className = 'tai-instance-wrapper'; 733 chatbot.insertBefore(wrapper, meowEl); 734 const inst = new TrueAIInstance(wrapper, meowEl, {}); 735 setupFloatingWidget(inst); 736 }; 737 738 document.querySelectorAll('.mwai-realtime').forEach(initMeow); 739 new MutationObserver(() => document.querySelectorAll('.mwai-realtime').forEach(initMeow)) 740 .observe(document.body, { childList: true, subtree: true }); 741 } 742 743 // ââ Mode 2: Shortcode containers âââââââââââââââââââââââââ 744 document.querySelectorAll('.true-ai-shortcode-container').forEach(container => { 745 if (container.dataset.taiInit) return; 746 container.dataset.taiInit = 'true'; 747 const d = container.dataset; 748 const overrides = { 749 name: d.taiName || undefined, 750 theme: d.taiTheme || undefined, 751 visualizer: d.taiVisualizer || undefined, 752 greeting: d.taiGreeting || undefined, 753 transcript: d.taiTranscript || undefined, 754 };
755 Object.keys(overrides).forEach(k => overrides[k] === undefined && delete overrides[k]); 756 const inst = new TrueAIInstance(container, null, overrides); 757 setupFloatingWidget(inst); 758 }); 759 760 // Watch for shortcode containers added dynamically 761 new MutationObserver(() => { 762 document.querySelectorAll('.true-ai-shortcode-container').forEach(container => { 763 if (container.dataset.taiInit) return; 764 container.dataset.taiInit = 'true'; 765 const d = container.dataset; 766 const overrides = { name: d.taiName, theme: d.taiTheme, visualizer: d.taiVisualizer, greeting: d.taiGreeting, transcript: d.taiTranscript }; 767 Object.keys(overrides).forEach(k => !overrides[k] && delete overrides[k]); 768 new TrueAIInstance(container, null, overrides); 769 }); 770 }).observe(document.body, { childList: true, subtree: true }); 771 } 772 773 if (document.readyState === 'loading') { 774 document.addEventListener('DOMContentLoaded', bootstrap); 775 } else { 776 bootstrap(); 777 } 778 779})();
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.