feat: offline browser TTS engine with voice selector

Add Web Speech API as alternative TTS engine (fully offline, no config needed).
- Engine selector in settings: 'browser' (offline) or 'server' (HTTP endpoint)
- Voice picker populated from speechSynthesis.getVoices(), Italian voices first
- Auto-selects Paola voice on macOS/iOS if available
- Rate and pitch sliders (0.5x-2x, 0-2)
- testTTS() and speakCookingStep() branch on selected engine
- Existing users with tts_url keep 'server' as default engine
This commit is contained in:
dadaloop82
2026-04-10 10:19:02 +00:00
parent 4b5979333e
commit da962581c0
2 changed files with 137 additions and 9 deletions
+105 -8
View File
@@ -850,11 +850,23 @@ async function loadSettingsUI() {
s.tts_auth_type = s.tts_auth_type || 'bearer'; s.tts_auth_type = s.tts_auth_type || 'bearer';
s.tts_content_type = s.tts_content_type || 'application/json'; s.tts_content_type = s.tts_content_type || 'application/json';
s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : false; s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : false;
// Default engine: 'server' if a URL was already configured, else 'browser'
if (!s.tts_engine) s.tts_engine = s.tts_url ? 'server' : 'browser';
s.tts_voice = s.tts_voice || '';
s.tts_rate = s.tts_rate !== undefined ? s.tts_rate : 1;
s.tts_pitch = s.tts_pitch !== undefined ? s.tts_pitch : 1;
s._tts_initialized = true; s._tts_initialized = true;
saveSettingsToStorage(s); saveSettingsToStorage(s);
} }
const ttsEnabledEl = document.getElementById('setting-tts-enabled'); const ttsEnabledEl = document.getElementById('setting-tts-enabled');
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true; if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
const ttsEngineEl = document.getElementById('setting-tts-engine');
if (ttsEngineEl) { ttsEngineEl.value = s.tts_engine || 'browser'; onTtsEngineChange(ttsEngineEl.value); }
const ttsRateEl = document.getElementById('setting-tts-rate');
if (ttsRateEl) { ttsRateEl.value = s.tts_rate || 1; document.getElementById('tts-rate-label').textContent = parseFloat(s.tts_rate || 1).toFixed(1); }
const ttsPitchEl = document.getElementById('setting-tts-pitch');
if (ttsPitchEl) { ttsPitchEl.value = s.tts_pitch || 1; document.getElementById('tts-pitch-label').textContent = parseFloat(s.tts_pitch || 1).toFixed(1); }
_initBrowserTtsVoices(s.tts_voice || '');
const ttsUrlEl = document.getElementById('setting-tts-url'); const ttsUrlEl = document.getElementById('setting-tts-url');
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || ''; if (ttsUrlEl) ttsUrlEl.value = s.tts_url || '';
const ttsMethEl = document.getElementById('setting-tts-method'); const ttsMethEl = document.getElementById('setting-tts-method');
@@ -993,6 +1005,14 @@ async function saveSettings() {
if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked; if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked;
const ttsUrlEl2 = document.getElementById('setting-tts-url'); const ttsUrlEl2 = document.getElementById('setting-tts-url');
if (ttsUrlEl2) s.tts_url = ttsUrlEl2.value.trim(); if (ttsUrlEl2) s.tts_url = ttsUrlEl2.value.trim();
const ttsEngineEl2 = document.getElementById('setting-tts-engine');
if (ttsEngineEl2) s.tts_engine = ttsEngineEl2.value;
const ttsVoiceEl2 = document.getElementById('setting-tts-voice');
if (ttsVoiceEl2) s.tts_voice = ttsVoiceEl2.value;
const ttsRateEl2 = document.getElementById('setting-tts-rate');
if (ttsRateEl2) s.tts_rate = parseFloat(ttsRateEl2.value) || 1;
const ttsPitchEl2 = document.getElementById('setting-tts-pitch');
if (ttsPitchEl2) s.tts_pitch = parseFloat(ttsPitchEl2.value) || 1;
const ttsMethEl2 = document.getElementById('setting-tts-method'); const ttsMethEl2 = document.getElementById('setting-tts-method');
if (ttsMethEl2) s.tts_method = ttsMethEl2.value; if (ttsMethEl2) s.tts_method = ttsMethEl2.value;
const ttsAuthTypeEl2 = document.getElementById('setting-tts-auth-type'); const ttsAuthTypeEl2 = document.getElementById('setting-tts-auth-type');
@@ -7597,8 +7617,12 @@ async function speakCookingStep(text) {
const s = getSettings(); const s = getSettings();
if (!s.tts_enabled) return; if (!s.tts_enabled) return;
try { try {
const req = _buildTtsRequest(text, s); if ((s.tts_engine || 'browser') === 'browser') {
await _ttsViaProxy(req); _speakBrowser(text);
} else {
const req = _buildTtsRequest(text, s);
await _ttsViaProxy(req);
}
} catch(e) { /* silent — TTS is non-critical */ } } catch(e) { /* silent — TTS is non-critical */ }
} }
@@ -7616,9 +7640,87 @@ function onTtsAuthTypeChange(type) {
if (headerGroup) headerGroup.style.display = type === 'header' ? '' : 'none'; if (headerGroup) headerGroup.style.display = type === 'header' ? '' : 'none';
} }
function onTtsEngineChange(engine) {
const browserSect = document.getElementById('tts-browser-section');
const serverSect = document.getElementById('tts-server-section');
if (browserSect) browserSect.style.display = engine === 'browser' ? '' : 'none';
if (serverSect) serverSect.style.display = engine === 'server' ? '' : 'none';
}
/** Populate voice selector from Web Speech API. Called on settings load and on voiceschanged. */
function _initBrowserTtsVoices(selectedVoice) {
const sel = document.getElementById('setting-tts-voice');
if (!sel || !window.speechSynthesis) return;
const populate = () => {
const voices = window.speechSynthesis.getVoices();
if (!voices.length) return;
// Italian voices first, then others
const it = voices.filter(v => v.lang.startsWith('it'));
const others = voices.filter(v => !v.lang.startsWith('it'));
const sorted = [...it, ...others];
sel.innerHTML = sorted.map(v =>
`<option value="${v.name}" ${v.name === selectedVoice ? 'selected' : ''}>${v.name} (${v.lang})${v.localService ? '' : ' ☁️'}</option>`
).join('');
// Auto-select Paola if no preference and it exists
if (!selectedVoice) {
const paola = sorted.find(v => v.name === 'Paola');
const firstIt = sorted.find(v => v.lang.startsWith('it'));
if (paola) sel.value = paola.name;
else if (firstIt) sel.value = firstIt.name;
}
};
populate();
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = populate;
}
}
/** Speak text using the browser Web Speech API (offline). */
function _speakBrowser(text) {
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const s = getSettings();
const utt = new SpeechSynthesisUtterance(text);
utt.rate = parseFloat(s.tts_rate) || 1;
utt.pitch = parseFloat(s.tts_pitch) || 1;
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v => v.name === s.tts_voice);
if (preferred) {
utt.voice = preferred;
utt.lang = preferred.lang;
} else {
utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT';
}
window.speechSynthesis.speak(utt);
}
async function testTTS() { async function testTTS() {
const statusEl = document.getElementById('tts-test-status'); const statusEl = document.getElementById('tts-test-status');
// Build settings from current form values (before saving) const enabled = document.getElementById('setting-tts-enabled')?.checked;
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;
}
const engine = document.getElementById('setting-tts-engine')?.value || 'browser';
if (engine === 'browser') {
if (!window.speechSynthesis) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Web Speech API non supportata da questo browser.'; }
return;
}
// Temporarily apply form values for the test
const s = getSettings();
const voiceName = document.getElementById('setting-tts-voice')?.value;
s.tts_voice = voiceName || s.tts_voice;
s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1;
s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1;
saveSettingsToStorage(s);
_speakBrowser('Test vocale Dispensa Manager. La sintesi vocale funziona correttamente.');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; }
return;
}
// Server engine
let extraFields = {}; let extraFields = {};
try { extraFields = JSON.parse((document.getElementById('setting-tts-extra-fields')?.value || '{}').trim() || '{}'); } catch(e) { /* ignore */ } try { extraFields = JSON.parse((document.getElementById('setting-tts-extra-fields')?.value || '{}').trim() || '{}'); } catch(e) { /* ignore */ }
const formSettings = { const formSettings = {
@@ -7632,11 +7734,6 @@ async function testTTS() {
tts_payload_key: (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message', tts_payload_key: (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message',
tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || '' tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || ''
}; };
const enabled = document.getElementById('setting-tts-enabled')?.checked;
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 (!formSettings.tts_url) { if (!formSettings.tts_url) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; } if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; }
return; return;
+32 -1
View File
@@ -868,7 +868,7 @@
<div class="settings-panel" id="tab-tts"> <div class="settings-panel" id="tab-tts">
<div class="settings-card"> <div class="settings-card">
<h4 data-i18n="settings.tts.title">🔊 Voce & TTS</h4> <h4 data-i18n="settings.tts.title">🔊 Voce & TTS</h4>
<p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.</p> <p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p>
<div class="form-group" style="margin-bottom:10px"> <div class="form-group" style="margin-bottom:10px">
<label class="toggle-row"> <label class="toggle-row">
<span>✅ Attiva TTS</span> <span>✅ Attiva TTS</span>
@@ -878,6 +878,35 @@
</span> </span>
</label> </label>
</div> </div>
<div class="form-group">
<label>⚙️ Motore TTS</label>
<select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)">
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option>
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option>
</select>
</div>
<!-- Browser TTS section -->
<div id="tts-browser-section">
<div class="form-group">
<label>🗣️ Voce</label>
<select id="setting-tts-voice" class="form-input">
<option value="">— Caricamento voci… —</option>
</select>
<p class="settings-hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano).</p>
</div>
<div class="form-group">
<label>⚡ Velocità: <span id="tts-rate-label">1.0</span>×</label>
<input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<label>🎵 Tono: <span id="tts-pitch-label">1.0</span></label>
<input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Server TTS section -->
<div id="tts-server-section" style="display:none">
<div class="form-group"> <div class="form-group">
<label>🌐 URL Endpoint</label> <label>🌐 URL Endpoint</label>
<input type="url" id="setting-tts-url" class="form-input" placeholder="https://..."> <input type="url" id="setting-tts-url" class="form-input" placeholder="https://...">
@@ -932,6 +961,8 @@
<textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea> <textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea>
<p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p> <p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
</div> </div>
</div><!-- /tts-server-section -->
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button> <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 id="tts-test-status" style="display:none;margin-top:8px"></div>
</div> </div>