PageSourceSearch

https://interspiritual.ai/wp-content/plugins/true-ai-fixed/assets/js/true-ai.js?ver=1.3.1

js interspiritual.ai collected 2026-10-05 15:18:34 UTC 37,803 bytes, 779 lines download raw bytes

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.