/** * GMIIE Analyst Voice — server TTS (institutional voice synthesis) with browser fallback. * POST /api/tts with { text, voice_id?, language?, provider?, smart?, context? } → audio/mpeg */ (function (global) { const DEFAULT_VOICE = 'daniel'; const DEFAULT_LANGUAGE = 'en'; const VOICE_PERSONA = 'GMIIE Analyst Voice'; let currentAudio = null; let currentBtn = null; let statusEl = null; function ensureStatusEl() { if (statusEl && document.body.contains(statusEl)) return statusEl; statusEl = document.getElementById('gmiie-voice-status'); if (statusEl) return statusEl; statusEl = document.createElement('div'); statusEl.id = 'gmiie-voice-status'; statusEl.setAttribute('role', 'status'); statusEl.setAttribute('aria-live', 'polite'); statusEl.style.cssText = 'position:fixed;bottom:64px;left:50%;transform:translateX(-50%);z-index:9997;max-width:min(420px,calc(100vw - 32px));padding:8px 14px;font-family:var(--mono,Consolas,monospace);font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:rgba(26,26,26,.92);color:rgba(255,255,255,.88);border:1px solid rgba(184,149,58,.45);box-shadow:0 8px 24px rgba(0,0,0,.25);display:none;pointer-events:none;'; document.body.appendChild(statusEl); return statusEl; } function showStatus(msg, ms) { const el = ensureStatusEl(); el.textContent = msg; el.style.display = msg ? 'block' : 'none'; if (ms) setTimeout(() => { if (el.textContent === msg) el.style.display = 'none'; }, ms); } function pickFallbackVoice(utterance) { const voices = global.speechSynthesis?.getVoices?.() || []; const pref = voices.find((v) => v.name === 'Daniel') || voices.find((v) => v.name.includes('Google UK English Male')) || voices.find((v) => v.lang === 'en-GB' && !v.name.toLowerCase().includes('female')); if (pref) utterance.voice = pref; } function resetBtn(btn) { if (!btn) return; btn.classList.remove('on', 'speaking', 'playing', 'loading'); btn.removeAttribute('aria-busy'); const orig = btn.getAttribute('data-orig'); if (orig) btn.textContent = orig; } function setLoading(btn) { if (!btn) return; if (!btn.getAttribute('data-orig')) btn.setAttribute('data-orig', btn.textContent); btn.classList.add('loading'); btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading voice…'; } function stop() { if (currentAudio) { currentAudio.pause(); currentAudio = null; } if (global.speechSynthesis?.speaking) global.speechSynthesis.cancel(); if (currentBtn) resetBtn(currentBtn); currentBtn = null; showStatus(''); } function playBlob(blob, btn, provider, onStatus) { const url = URL.createObjectURL(blob); const audio = new Audio(); audio.preload = 'auto'; currentAudio = audio; const cleanup = () => { URL.revokeObjectURL(url); currentAudio = null; resetBtn(btn); currentBtn = null; showStatus(''); }; audio.onended = cleanup; audio.onerror = cleanup; audio.src = url; return new Promise((resolve, reject) => { const onReady = () => { audio.removeEventListener('canplaythrough', onReady); audio.removeEventListener('error', onErr); audio .play() .then(() => { if (provider) showStatus(VOICE_PERSONA + ' · ' + provider, 4000); onStatus?.({ provider }); resolve(); }) .catch(reject); }; const onErr = () => { audio.removeEventListener('canplaythrough', onReady); audio.removeEventListener('error', onErr); cleanup(); reject(new Error('Audio playback failed')); }; audio.addEventListener('canplaythrough', onReady, { once: true }); audio.addEventListener('error', onErr, { once: true }); audio.load(); }); } async function speak(text, btn, opts) { opts = opts || {}; if (!text) return; btn = btn || (typeof global.event !== 'undefined' ? global.event.currentTarget : null); if ( (currentAudio && !currentAudio.paused && currentBtn === btn) || (global.speechSynthesis?.speaking && currentBtn === btn) ) { stop(); return; } stop(); if (btn) { if (!btn.getAttribute('data-orig')) btn.setAttribute('data-orig', btn.textContent); btn.classList.add('on', 'speaking', 'playing'); currentBtn = btn; setLoading(btn); } showStatus('Preparing ' + VOICE_PERSONA + '…'); const payload = { text: String(text).slice(0, 15000), voice_id: opts.voice_id || DEFAULT_VOICE, language: opts.language || DEFAULT_LANGUAGE, provider: opts.provider || 'auto', smart: opts.smart !== false, context: opts.context && typeof opts.context === 'object' ? opts.context : {}, }; try { const r = await fetch(opts.endpoint || '/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'audio/mpeg' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(opts.timeout || 30000), }); const contentType = r.headers.get('content-type') || ''; if (!r.ok || !contentType.includes('audio')) { const detail = contentType.includes('json') ? await r.text() : 'HTTP ' + r.status; throw new Error('TTS ' + r.status + ': ' + detail.slice(0, 120)); } const provider = r.headers.get('X-TTS-Provider') || 'server'; const blob = await r.blob(); if (btn) { btn.classList.remove('loading'); btn.textContent = btn.getAttribute('data-orig') || 'Listen'; btn.classList.add('on', 'speaking', 'playing'); } await playBlob(blob, btn, provider, opts.onStatus); } catch (_) { showStatus('Enhanced voice unavailable — using browser voice', 6000); if (btn) resetBtn(btn); if (btn) { btn.classList.add('on', 'speaking'); currentBtn = btn; } const u = new SpeechSynthesisUtterance(String(text).slice(0, 5000)); u.rate = opts.rate ?? 0.87; u.pitch = opts.pitch ?? 0.95; pickFallbackVoice(u); u.onend = () => { resetBtn(btn); currentBtn = null; showStatus(''); }; u.onerror = () => { resetBtn(btn); currentBtn = null; showStatus(''); }; global.speechSynthesis.speak(u); } } global.GrokVoice = { speak, stop, DEFAULT_VOICE, VOICE_PERSONA, showStatus, }; if (global.speechSynthesis) { global.speechSynthesis.onvoiceschanged = () => {}; } })(typeof window !== 'undefined' ? window : globalThis);