feat: TTS via Home Assistant API, settings panel, remove browser speechSynthesis
This commit is contained in:
+68
-55
@@ -648,6 +648,15 @@ async function loadSettingsUI() {
|
||||
`<span class="mplan-badge" style="opacity:0.85">${t.icon} ${t.label}</span>`
|
||||
).join('');
|
||||
}
|
||||
// TTS settings
|
||||
const ttsEnabledEl = document.getElementById('setting-tts-enabled');
|
||||
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
|
||||
const ttsUrlEl = document.getElementById('setting-tts-url');
|
||||
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak';
|
||||
const ttsTokenEl = document.getElementById('setting-tts-token');
|
||||
if (ttsTokenEl) ttsTokenEl.value = s.tts_token || '';
|
||||
const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key');
|
||||
if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message';
|
||||
|
||||
// Load server-side settings if not already set locally
|
||||
try {
|
||||
@@ -748,6 +757,15 @@ async function saveSettings() {
|
||||
// Meal plan enabled toggle
|
||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||
if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked;
|
||||
// TTS settings
|
||||
const ttsEnabledEl = document.getElementById('setting-tts-enabled');
|
||||
if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked;
|
||||
const ttsUrlEl = document.getElementById('setting-tts-url');
|
||||
if (ttsUrlEl) s.tts_url = ttsUrlEl.value.trim();
|
||||
const ttsTokenEl = document.getElementById('setting-tts-token');
|
||||
if (ttsTokenEl) s.tts_token = ttsTokenEl.value.trim();
|
||||
const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key');
|
||||
if (ttsPayloadKeyEl) s.tts_payload_key = ttsPayloadKeyEl.value.trim() || 'message';
|
||||
// Save spesa AI prompt if the field exists
|
||||
const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt');
|
||||
if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim();
|
||||
@@ -6495,34 +6513,11 @@ function startCookingMode() {
|
||||
document.getElementById('cooking-overlay').style.display = 'flex';
|
||||
document.body.classList.add('cooking-mode-active');
|
||||
try { screen.orientation?.lock('portrait'); } catch (_) { /* ignore */ }
|
||||
|
||||
// Ensure voices are loaded, then render (and speak) the first step
|
||||
if ('speechSynthesis' in window) {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
renderCookingStep();
|
||||
} else {
|
||||
// Voices not yet loaded — render immediately (muted), then re-speak after voices arrive
|
||||
renderCookingStep();
|
||||
window.speechSynthesis.addEventListener('voiceschanged', function _onVoices() {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', _onVoices);
|
||||
if (_cookingTTS && _cookingStep === 0) {
|
||||
const text = (_cookingRecipe.steps[0] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
|
||||
if (text) speakCookingStep(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_cookingTTS = false;
|
||||
document.getElementById('cooking-tts-btn').textContent = '🔇';
|
||||
renderCookingStep();
|
||||
}
|
||||
}
|
||||
|
||||
function closeCookingMode() {
|
||||
document.getElementById('cooking-overlay').style.display = 'none';
|
||||
document.body.classList.remove('cooking-mode-active');
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
|
||||
// so the user can resume from the same step when they reopen
|
||||
try { screen.orientation?.unlock(); } catch (_) { /* ignore */ }
|
||||
@@ -6598,36 +6593,23 @@ function renderCookingStep() {
|
||||
if (_cookingTTS) speakCookingStep(cleanStep);
|
||||
}
|
||||
|
||||
function _bestItalianVoice() {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const it = voices.filter(v => v.lang.startsWith('it'));
|
||||
if (it.length === 0) return null;
|
||||
// Prefer high-quality online voices (Google / Microsoft) over local robotic ones
|
||||
const priority = [
|
||||
v => /google/i.test(v.name),
|
||||
v => /microsoft/i.test(v.name) && !v.localService,
|
||||
v => !v.localService,
|
||||
v => /alice|federica|luca|paola/i.test(v.name),
|
||||
() => true, // any italian
|
||||
];
|
||||
for (const pred of priority) {
|
||||
const match = it.find(pred);
|
||||
if (match) return match;
|
||||
}
|
||||
return it[0];
|
||||
}
|
||||
|
||||
function speakCookingStep(text) {
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
window.speechSynthesis.cancel();
|
||||
const utt = new SpeechSynthesisUtterance(text);
|
||||
utt.lang = 'it-IT';
|
||||
utt.rate = 0.88;
|
||||
utt.pitch = 1.0;
|
||||
utt.volume = 1.0;
|
||||
const voice = _bestItalianVoice();
|
||||
if (voice) utt.voice = voice;
|
||||
window.speechSynthesis.speak(utt);
|
||||
async function speakCookingStep(text) {
|
||||
if (!text) return;
|
||||
const s = getSettings();
|
||||
if (!s.tts_enabled) return;
|
||||
const url = s.tts_url || 'http://192.168.1.133:8123/api/events/noemi_speak';
|
||||
const token = s.tts_token || '';
|
||||
const payloadKey = s.tts_payload_key || 'message';
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ [payloadKey]: text })
|
||||
});
|
||||
} catch(e) { /* silent — TTS is non-critical */ }
|
||||
}
|
||||
|
||||
function replayCookingTTS() {
|
||||
@@ -6637,6 +6619,39 @@ function replayCookingTTS() {
|
||||
if (text) speakCookingStep(text);
|
||||
}
|
||||
|
||||
async function testTTS() {
|
||||
const statusEl = document.getElementById('tts-test-status');
|
||||
// Read values directly from the form (not yet saved settings)
|
||||
const enabled = document.getElementById('setting-tts-enabled')?.checked;
|
||||
const url = (document.getElementById('setting-tts-url')?.value || '').trim() || 'http://192.168.1.133:8123/api/events/noemi_speak';
|
||||
const token = (document.getElementById('setting-tts-token')?.value || '').trim();
|
||||
const payloadKey = (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message';
|
||||
|
||||
if (!enabled) {
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ TTS non attivo — attiva il toggle prima di testare.'; }
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ Bearer Token mancante.'; }
|
||||
return;
|
||||
}
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio in corso…'; }
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [payloadKey]: 'Test vocale Dispensa Manager' })
|
||||
});
|
||||
if (res.ok || res.status === 200) {
|
||||
if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Richiesta inviata! Controlla che il tuo altoparlante abbia parlato.'; }
|
||||
} else {
|
||||
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `⚠️ Risposta HTTP ${res.status}: ${res.statusText}`; }
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ Errore di rete: ${e.message}`; }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== COOKING TIMER SYSTEM =====
|
||||
let _cookingTimers = []; // { id, label, total, seconds, running, interval }
|
||||
let _cookingTimerIdCounter = 0;
|
||||
@@ -6846,8 +6861,6 @@ function toggleCookingTTS() {
|
||||
const steps = _cookingRecipe?.steps || [];
|
||||
const text = (steps[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
|
||||
speakCookingStep(text);
|
||||
} else {
|
||||
window.speechSynthesis?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -966,3 +966,4 @@
|
||||
[2026-04-04 14:20:02] OK — 11 items cached
|
||||
[2026-04-04 14:25:01] OK — 11 items cached
|
||||
[2026-04-04 14:30:02] OK — 11 items cached
|
||||
[2026-04-04 14:35:02] OK — 11 items cached
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"success":true,"items":[{"product_id":151,"name":"Arance Tarocco","brand":"","category":"frutta","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":9,"buy_count":2,"daily_rate":0.55,"uses_per_month":13.6,"days_since_last_use":2,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":70,"name":"Latte Parzialmente Scremato Uht","brand":"Latteria","category":"bevande","unit":"conf","current_qty":0,"default_qty":500,"package_unit":"ml","pct_left":0,"use_count":13,"buy_count":3,"daily_rate":6.74,"uses_per_month":15.6,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (13x)"],"score":135,"on_bring":true,"locations":""},{"product_id":129,"name":"Latte di Montagna","brand":"Mila","category":"en:dairies","unit":"conf","current_qty":0,"default_qty":1000,"package_unit":"ml","pct_left":0,"use_count":9,"buy_count":3,"daily_rate":0.2,"uses_per_month":13.6,"days_since_last_use":1,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (9x)"],"score":135,"on_bring":true,"locations":""},{"product_id":47,"name":"Lenticchie","brand":"Primia","category":"en:plant-based-foods-and-beverages","unit":"g","current_qty":0,"default_qty":400,"package_unit":"","pct_left":0,"use_count":5,"buy_count":3,"daily_rate":35.88,"uses_per_month":6,"days_since_last_use":0,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito","Uso frequente (5x)"],"score":130,"on_bring":true,"locations":""},{"product_id":115,"name":"Aglio rosso","brand":"Duoccio","category":"condimenti","unit":"pz","current_qty":0,"default_qty":0,"package_unit":"","pct_left":0,"use_count":2,"buy_count":2,"daily_rate":0.14,"uses_per_month":2.7,"days_since_last_use":15,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":true,"locations":""},{"product_id":152,"name":"Muesli Frutta Secca","brand":"Crownfield","category":"altro","unit":"g","current_qty":0,"default_qty":750,"package_unit":"","pct_left":0,"use_count":4,"buy_count":2,"daily_rate":34.01,"uses_per_month":6.9,"days_since_last_use":9,"days_left":0,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"critical","reasons":["Esaurito"],"score":100,"on_bring":false,"locations":""},{"product_id":3,"name":"Cracker integrali","brand":"Barilla,Mulino Bianco","category":"en:snacks","unit":"conf","current_qty":2,"default_qty":25,"package_unit":"g","pct_left":12,"use_count":6,"buy_count":1,"daily_rate":0.56,"uses_per_month":7.2,"days_since_last_use":6,"days_left":4,"expiry_date":"2026-04-28","days_to_expiry":23,"is_opened":true,"urgency":"high","reasons":["Quasi finito (12%)"],"score":90,"on_bring":true,"locations":"dispensa"},{"product_id":132,"name":"Noci sgusciate","brand":"Fruttbella","category":"conserve","unit":"g","current_qty":60,"default_qty":200,"package_unit":"","pct_left":30,"use_count":4,"buy_count":1,"daily_rate":7.04,"uses_per_month":6,"days_since_last_use":0,"days_left":9,"expiry_date":"2026-04-29","days_to_expiry":24,"is_opened":true,"urgency":"medium","reasons":["Finisce tra ~9gg"],"score":50,"on_bring":true,"locations":"dispensa"},{"product_id":69,"name":"Cipolla Dorata","brand":"","category":"verdura","unit":"pz","current_qty":4,"default_qty":0,"package_unit":"","pct_left":44,"use_count":12,"buy_count":1,"daily_rate":0.32,"uses_per_month":14.4,"days_since_last_use":0,"days_left":12,"expiry_date":"2026-04-13","days_to_expiry":8,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~12gg"],"score":40,"on_bring":true,"locations":"frigo"},{"product_id":154,"name":"Mela Rossa","brand":"","category":"frutta","unit":"pz","current_qty":7,"default_qty":1,"package_unit":"","pct_left":32,"use_count":5,"buy_count":1,"daily_rate":0.87,"uses_per_month":8.7,"days_since_last_use":0,"days_left":8,"expiry_date":"2026-04-15","days_to_expiry":10,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~8gg"],"score":35,"on_bring":false,"locations":"frigo"},{"product_id":136,"name":"Biscotti Pastefrolle","brand":"Balocco","category":"snack","unit":"g","current_qty":350,"default_qty":700,"package_unit":"","pct_left":67,"use_count":4,"buy_count":2,"daily_rate":35.22,"uses_per_month":6,"days_since_last_use":0,"days_left":10,"expiry_date":null,"days_to_expiry":999,"is_opened":false,"urgency":"low","reasons":["Previsto esaurimento tra ~10gg"],"score":25,"on_bring":false,"locations":"dispensa"}],"cached_at":"2026-04-04T14:35:02+00:00","cached_ts":1775313302}
|
||||
+33
@@ -648,6 +648,7 @@
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-spesa')" data-tab="tab-spesa" title="Spesa Online">🛍️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)">🔊</button>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
<!-- API Keys Tab -->
|
||||
@@ -845,6 +846,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TTS Tab -->
|
||||
<div class="settings-panel" id="tab-tts">
|
||||
<div class="settings-card">
|
||||
<h4>🔊 Voce & TTS</h4>
|
||||
<p class="settings-hint">Configura la sintesi vocale tramite Home Assistant. I passi della ricetta e i timer verranno letti ad alta voce attraverso l'altoparlante configurato.</p>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span>✅ Attiva TTS via Home Assistant</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-tts-enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🌐 URL Evento</label>
|
||||
<input type="url" id="setting-tts-url" class="form-input" placeholder="http://192.168.1.133:8123/api/events/noemi_speak">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🔑 Bearer Token</label>
|
||||
<input type="password" id="setting-tts-token" class="form-input" placeholder="eyJhbGci...">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🗝️ Chiave payload</label>
|
||||
<input type="text" id="setting-tts-payload-key" class="form-input" placeholder="message">
|
||||
<p class="settings-hint">Nome del campo JSON inviato all'evento (default: <code>message</code>).</p>
|
||||
</div>
|
||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button>
|
||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()">💾 Salva Configurazione</button>
|
||||
<div id="settings-status" class="settings-status" style="display:none"></div>
|
||||
|
||||
Reference in New Issue
Block a user