feat: ElevenLabs TTS George voice + fix HA toggle optimistic update

This commit is contained in:
2026-05-31 05:58:22 +00:00
parent b080ecb4bd
commit a96f8a3f85
4 changed files with 103 additions and 14 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
// ElevenLabs TTS proxy — keeps API key server-side, streams MP3 back to browser.
// POST body: {"text":"..."} Returns: audio/mpeg or JSON error
$text = trim((json_decode(file_get_contents('php://input'), true) ?? [])['text'] ?? '');
if (!$text) {
http_response_code(400);
echo json_encode(['error' => '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]);
}
+15 -2
View File
@@ -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');
}
});
}
+3
View File
@@ -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;
+32 -12
View File
@@ -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);
}