diff --git a/api/endpoints/tts.php b/api/endpoints/tts.php new file mode 100644 index 0000000..335cd2e --- /dev/null +++ b/api/endpoints/tts.php @@ -0,0 +1,53 @@ + 'No text']); + exit; +} + +// Cap at 400 chars to protect free-tier quota +$text = mb_substr($text, 0, 400); + +if (!defined('ELEVENLABS_API_KEY') || !ELEVENLABS_API_KEY) { + http_response_code(503); + echo json_encode(['error' => 'ElevenLabs not configured']); + exit; +} + +$payload = json_encode([ + 'text' => $text, + 'model_id' => ELEVENLABS_MODEL, + 'voice_settings' => ['stability' => 0.45, 'similarity_boost' => 0.80, 'style' => 0.10], +]); + +$ch = curl_init('https://api.elevenlabs.io/v1/text-to-speech/' . ELEVENLABS_VOICE_ID); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'xi-api-key: ' . ELEVENLABS_API_KEY, + 'Content-Type: application/json', + 'Accept: audio/mpeg', + ], + CURLOPT_POSTFIELDS => $payload, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 5, +]); + +$audio = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($code === 200 && $audio) { + header('Content-Type: audio/mpeg'); + header('Cache-Control: no-store'); + echo $audio; +} else { + http_response_code(502); + echo json_encode(['error' => 'ElevenLabs error', 'code' => $code]); +} diff --git a/public_html/admin/index.php b/public_html/admin/index.php index 8980f78..68be20d 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -1322,10 +1322,23 @@ function renderHATable(entities) { } function haToggle(entityId, currentState, el) { + const ON_STATES = ['on','home','open','playing','mowing','armed_home','armed_away','armed_night','active']; + const wasOn = ON_STATES.includes(currentState); el.style.opacity = '0.5'; - apiPost('ha_toggle', {entity_id: entityId, state: currentState}, () => { + apiPost('ha_toggle', {entity_id: entityId, state: currentState}, (res) => { el.style.opacity = '1'; - loadHA(); + if (res.ok) { + // Optimistic update — flip state in cache so re-render shows new state immediately + const ent = _haEntities.find(e => e.entity_id === entityId); + if (ent) { + ent.state = wasOn ? 'off' : 'on'; + filterHATable(); + } + // Also sync from HA after 3s (actual state confirmation) + setTimeout(loadHA, 3000); + } else { + toast('Toggle failed (code ' + (res.code||'?') + ')', 'err'); + } }); } diff --git a/public_html/api.php b/public_html/api.php index 15a9b6e..6326865 100644 --- a/public_html/api.php +++ b/public_html/api.php @@ -69,6 +69,9 @@ switch ($endpoint) { case 'ha': require __DIR__ . '/../api/endpoints/ha.php'; break; + case 'tts': + require __DIR__ . '/../api/endpoints/tts.php'; + break; case 'do': require __DIR__ . '/../api/endpoints/do_server.php'; break; diff --git a/public_html/index.html b/public_html/index.html index 3d696a0..8e17b38 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -1781,22 +1781,42 @@ function loadVoices() { synth.onvoiceschanged = set; } -function speak(text) { +let _ttsAudio = null; + +async function speak(text) { + if (!text) return; + if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; } + synth?.cancel(); + const reactor = document.getElementById('arcReactor'); + reactor?.classList.add('speaking'); + try { + const res = await fetch('/api/tts', { + method: 'POST', + headers: {'Content-Type':'application/json','X-Session-Token': sessionToken}, + body: JSON.stringify({text: text.substring(0, 400)}), + }); + if (!res.ok) throw new Error('tts'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + _ttsAudio = new Audio(url); + _ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; reactor?.classList.remove('speaking'); }; + _ttsAudio.onerror = () => { reactor?.classList.remove('speaking'); _ttsAudio = null; }; + await _ttsAudio.play(); + } catch(e) { + reactor?.classList.remove('speaking'); + _speakFallback(text); + } +} + +function _speakFallback(text) { if (!synth || !text) return; synth.cancel(); const utter = new SpeechSynthesisUtterance(text); if (selectedVoice) utter.voice = selectedVoice; - utter.rate = 0.92; - utter.pitch = 0.85; - utter.volume = 1; - - utter.onstart = () => { - document.getElementById('arcReactor').classList.add('speaking'); - }; - utter.onend = () => { - document.getElementById('arcReactor').classList.remove('speaking'); - }; - + utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1; + const reactor = document.getElementById('arcReactor'); + utter.onstart = () => reactor?.classList.add('speaking'); + utter.onend = () => reactor?.classList.remove('speaking'); synth.speak(utter); }