From 91ae1dede91bf84ed80c196259a05f8fe2216775 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 15:43:24 -0400 Subject: [PATCH] fix(#5435): persist TTS and voice preferences server-side --- api/config.py | 38 ++++ static/boot.js | 82 ++++++- static/panels.js | 90 ++++++-- tests/test_issue5435_tts_voice_preferences.py | 200 ++++++++++++++++++ 4 files changed, 395 insertions(+), 15 deletions(-) create mode 100644 tests/test_issue5435_tts_voice_preferences.py diff --git a/api/config.py b/api/config.py index 58fa1d7a..e55fd965 100644 --- a/api/config.py +++ b/api/config.py @@ -8007,6 +8007,16 @@ _SETTINGS_DEFAULTS = { "check_for_updates": True, # check if webui/agent repos are behind upstream "ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links + "tts_enabled": False, + "tts_auto_read": False, + "tts_engine": "browser", + "tts_voice": "", + "tts_rate": 1.0, + "tts_pitch": 1.0, + "voice_mode_button": False, + "voice_continuous": False, + "voice_silence_ms": 1800, + "raw_audio_mode": False, "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | graphite | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous "font_size": "default", # small | default | large | xlarge @@ -8247,6 +8257,11 @@ _SETTINGS_INT_RANGES = { "inflight_state_max_string_chars": (1000, 500000), "inflight_state_max_json_chars": (100000, 4000000), "structured_code_auto_tree_lines": (1, 1000), + "voice_silence_ms": (200, 60000), +} +_SETTINGS_FLOAT_RANGES = { + "tts_rate": (0.5, 2.0), + "tts_pitch": (0.0, 2.0), } _SETTINGS_BOOL_KEYS = { "onboarding_completed", @@ -8268,6 +8283,11 @@ _SETTINGS_BOOL_KEYS = { "check_for_updates", "ignore_agent_updates", "whats_new_summary_enabled", + "tts_enabled", + "tts_auto_read", + "voice_mode_button", + "voice_continuous", + "raw_audio_mode", "sound_enabled", "rtl", "notifications_enabled", @@ -8302,6 +8322,7 @@ _SETTINGS_BOOL_KEYS = { } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") +_SETTINGS_TTS_ENGINE_RE = __import__("re").compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") _SETTINGS_WRITE_VERSION = 0 _SETTINGS_WRITE_LOCK = __import__("threading").Lock() @@ -8389,6 +8410,23 @@ def save_settings(settings: dict) -> dict: min_value, max_value = _SETTINGS_INT_RANGES[k] if v < min_value or v > max_value: continue + if k in _SETTINGS_FLOAT_RANGES: + try: + v = float(v) + except (TypeError, ValueError): + continue + min_value, max_value = _SETTINGS_FLOAT_RANGES[k] + if not math.isfinite(v) or v < min_value or v > max_value: + continue + if k == "tts_engine": + if not isinstance(v, str): + continue + v = v.strip() + if not _SETTINGS_TTS_ENGINE_RE.match(v): + continue + if k == "tts_voice": + if not isinstance(v, str) or len(v) > 200 or "\x00" in v: + continue # Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN') if k == "language" and ( not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v) diff --git a/static/boot.js b/static/boot.js index 7480e3c6..abffc23f 100644 --- a/static/boot.js +++ b/static/boot.js @@ -679,6 +679,15 @@ function _micToastKeyForRecognitionError(error){ } } + function _applyRawAudioModePreference(enabled){ + _rawAudioMode=!!enabled; + try{localStorage.setItem('hermes-raw-audio-mode',_rawAudioMode?'true':'false');}catch(_){} + const rawAudioCheckbox=document.getElementById('settingsRawAudio'); + if(rawAudioCheckbox) rawAudioCheckbox.checked=_rawAudioMode; + _updateMicTooltip(); + } + window._applyRawAudioModePreference=_applyRawAudioModePreference; + async function _sendRawAudio(blob){ const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm'; const file=new File([blob],`voice-input-${Date.now()}.${ext}`,{type:blob.type||`audio/${ext}`}); @@ -1054,9 +1063,7 @@ function _micToastKeyForRecognitionError(error){ if(rawAudioCheckbox){ rawAudioCheckbox.checked = _rawAudioMode; rawAudioCheckbox.addEventListener('change', function(){ - _rawAudioMode = this.checked; - localStorage.setItem('hermes-raw-audio-mode', _rawAudioMode ? 'true' : 'false'); - _updateMicTooltip(); + _applyRawAudioModePreference(this.checked); }); } _updateMicTooltip(); @@ -2723,6 +2730,73 @@ function _applyTitlebarProfileVisibility(){ } window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; +function _mirrorSpeechSettingsFromServer(s){ + if(!s||typeof s!=='object') return; + const defaults={ + tts_enabled:false, + tts_auto_read:false, + tts_engine:'browser', + tts_voice:'', + tts_rate:1, + tts_pitch:1, + voice_mode_button:false, + voice_continuous:false, + voice_silence_ms:1800, + raw_audio_mode:false, + }; + const cachedValue=(storageKey)=>{ + try{return localStorage.getItem(storageKey);}catch(_){return null;} + }; + const boolValue=(value)=>value===true||value==='true'; + const resolveBool=(settingKey,storageKey)=>{ + const server=Object.prototype.hasOwnProperty.call(s,settingKey)?s[settingKey]:defaults[settingKey]; + const cached=cachedValue(storageKey); + if(cached!==null&&boolValue(server)===boolValue(defaults[settingKey])&&boolValue(cached)!==boolValue(defaults[settingKey])){ + return boolValue(cached); + } + return boolValue(server); + }; + const resolveScalar=(settingKey,storageKey)=>{ + const server=Object.prototype.hasOwnProperty.call(s,settingKey)?s[settingKey]:defaults[settingKey]; + const cached=cachedValue(storageKey); + if(cached!==null&&String(server)===String(defaults[settingKey])&&String(cached)!==String(defaults[settingKey])){ + return cached; + } + return server; + }; + const boolKeys=[ + ['tts_enabled','hermes-tts-enabled'], + ['tts_auto_read','hermes-tts-auto-read'], + ['voice_mode_button','hermes-voice-mode-button'], + ['voice_continuous','hermes-voice-continuous'], + ]; + boolKeys.forEach(([settingKey,storageKey])=>{ + if(Object.prototype.hasOwnProperty.call(s,settingKey)){ + try{localStorage.setItem(storageKey,resolveBool(settingKey,storageKey)?'true':'false');}catch(_){} + } + }); + [ + ['tts_engine','hermes-tts-engine'], + ['tts_voice','hermes-tts-voice'], + ['tts_rate','hermes-tts-rate'], + ['tts_pitch','hermes-tts-pitch'], + ['voice_silence_ms','hermes-voice-silence-ms'], + ].forEach(([settingKey,storageKey])=>{ + if(Object.prototype.hasOwnProperty.call(s,settingKey)){ + try{localStorage.setItem(storageKey,String(resolveScalar(settingKey,storageKey)));}catch(_){} + } + }); + if(Object.prototype.hasOwnProperty.call(s,'raw_audio_mode')){ + const rawAudioMode=resolveBool('raw_audio_mode','hermes-raw-audio-mode'); + if(typeof window._applyRawAudioModePreference==='function'){ + window._applyRawAudioModePreference(rawAudioMode); + }else{ + try{localStorage.setItem('hermes-raw-audio-mode',rawAudioMode?'true':'false');}catch(_){} + } + } +} +window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; + (async()=>{ // Load send key preference let _bootSettings={}; @@ -2872,7 +2946,9 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; setLocale(_lang); if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); } + _mirrorSpeechSettingsFromServer(s); _applyComposerFooterVisibilitySettings(); + if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref(); // TTS: apply enabled state on boot so buttons show/hide correctly (#499) if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true'); }catch(e){ diff --git a/static/panels.js b/static/panels.js index bf083460..79c1532d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8067,6 +8067,25 @@ function _preferencesPayloadFromUi(){ if(showBusyPlaceholderHintCb) payload.show_busy_placeholder_hint=showBusyPlaceholderHintCb.checked; const botNameField=$('settingsBotName'); if(botNameField) payload.bot_name=botNameField.value; + const ttsEnabledCb=$('settingsTtsEnabled'); + if(ttsEnabledCb) payload.tts_enabled=ttsEnabledCb.checked; + const ttsAutoReadCb=$('settingsTtsAutoRead'); + if(ttsAutoReadCb) payload.tts_auto_read=ttsAutoReadCb.checked; + const ttsEngineSel=$('settingsTtsEngine'); + if(ttsEngineSel) payload.tts_engine=ttsEngineSel.value||'browser'; + const ttsVoiceSel=$('settingsTtsVoice'); + if(ttsVoiceSel) payload.tts_voice=ttsVoiceSel.value||''; + const ttsRateSlider=$('settingsTtsRate'); + if(ttsRateSlider) payload.tts_rate=parseFloat(ttsRateSlider.value); + const ttsPitchSlider=$('settingsTtsPitch'); + if(ttsPitchSlider) payload.tts_pitch=parseFloat(ttsPitchSlider.value); + const voiceModeCb=$('settingsVoiceModeEnabled'); + if(voiceModeCb) payload.voice_mode_button=voiceModeCb.checked; + const rawAudioCb=$('settingsRawAudio'); + payload.raw_audio_mode=rawAudioCb?rawAudioCb.checked:localStorage.getItem('hermes-raw-audio-mode')==='true'; + payload.voice_continuous=localStorage.getItem('hermes-voice-continuous')==='true'; + const voiceSilence=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10); + payload.voice_silence_ms=(Number.isFinite(voiceSilence)&&voiceSilence>=200)?voiceSilence:1800; return payload; } @@ -8618,20 +8637,57 @@ async function loadSettingsPanel(){ _schedulePreferencesAutosave(); },{once:false}); } - // TTS settings (localStorage-only, no server round-trip needed) + if(typeof window._mirrorSpeechSettingsFromServer==='function') window._mirrorSpeechSettingsFromServer(settings); + const _speechSetting=function(key,storageKey,fallback,kind){ + const stored=localStorage.getItem(storageKey); + if(settings&&Object.prototype.hasOwnProperty.call(settings,key)){ + const server=settings[key]; + if(stored!==null){ + if(kind==='bool'){ + const serverBool=server===true||server==='true'; + const fallbackBool=fallback===true||fallback==='true'; + const storedBool=stored==='true'; + if(serverBool===fallbackBool&&storedBool!==fallbackBool) return storedBool; + }else if(String(server)===String(fallback)&&String(stored)!==String(fallback)){ + return stored; + } + } + return server; + } + return stored===null?fallback:stored; + }; + const _speechBool=function(key,storageKey,fallback){ + const value=_speechSetting(key,storageKey,fallback,'bool'); + return value===true||value==='true'; + }; + const rawAudioCb=$('settingsRawAudio'); + if(rawAudioCb){ + rawAudioCb.checked=_speechBool('raw_audio_mode','hermes-raw-audio-mode',false); + rawAudioCb.onchange=function(){ + if(typeof window._applyRawAudioModePreference==='function') window._applyRawAudioModePreference(this.checked); + else localStorage.setItem('hermes-raw-audio-mode',this.checked?'true':'false'); + _schedulePreferencesAutosave(); + }; + } + const voiceContinuous=_speechBool('voice_continuous','hermes-voice-continuous',false); + localStorage.setItem('hermes-voice-continuous',voiceContinuous?'true':'false'); + const voiceSilence=parseInt(_speechSetting('voice_silence_ms','hermes-voice-silence-ms',1800),10); + localStorage.setItem('hermes-voice-silence-ms',Number.isFinite(voiceSilence)&&voiceSilence>=200?String(voiceSilence):'1800'); + // TTS settings use /api/settings as the durable source and localStorage as the runtime cache. const ttsEnabledCb=$('settingsTtsEnabled'); - if(ttsEnabledCb){ttsEnabledCb.checked=localStorage.getItem('hermes-tts-enabled')==='true';ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);};} + if(ttsEnabledCb){ttsEnabledCb.checked=_speechBool('tts_enabled','hermes-tts-enabled',false);ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);_schedulePreferencesAutosave();};} const ttsAutoReadCb=$('settingsTtsAutoRead'); - if(ttsAutoReadCb){ttsAutoReadCb.checked=localStorage.getItem('hermes-tts-auto-read')==='true';ttsAutoReadCb.onchange=function(){localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');};} - // Voice-mode button visibility (#1488). localStorage-only; no server round-trip. + if(ttsAutoReadCb){ttsAutoReadCb.checked=_speechBool('tts_auto_read','hermes-tts-auto-read',false);ttsAutoReadCb.onchange=function(){localStorage.setItem('hermes-tts-auto-read',this.checked?'true':'false');_schedulePreferencesAutosave();};} + // Voice-mode button visibility (#1488). // Toggling re-applies immediately via the boot.js helper so the user sees // the audio-waveform button appear/disappear without a reload. const voiceModeCb=$('settingsVoiceModeEnabled'); if(voiceModeCb){ - voiceModeCb.checked=localStorage.getItem('hermes-voice-mode-button')==='true'; + voiceModeCb.checked=_speechBool('voice_mode_button','hermes-voice-mode-button',false); voiceModeCb.onchange=function(){ localStorage.setItem('hermes-voice-mode-button',this.checked?'true':'false'); if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref(); + _schedulePreferencesAutosave(); }; } // TTS engine selector @@ -8649,11 +8705,18 @@ async function loadSettingsPanel(){ } }); } - const saved=localStorage.getItem('hermes-tts-engine')||'browser'; + const saved=String(_speechSetting('tts_engine','hermes-tts-engine','browser')||'browser'); + if(!ttsEngineSel.querySelector('option[value="'+saved+'"]')){ + var savedOpt=document.createElement('option'); + savedOpt.value=saved; savedOpt.textContent=saved; + ttsEngineSel.appendChild(savedOpt); + } ttsEngineSel.value=saved; + localStorage.setItem('hermes-tts-engine',saved); ttsEngineSel.onchange=function(){ localStorage.setItem('hermes-tts-engine',this.value); window._populateTtsVoices(); + _schedulePreferencesAutosave(); }; } // Populate voice selector based on engine @@ -8661,7 +8724,8 @@ async function loadSettingsPanel(){ window._populateTtsVoices=function(){ if(!ttsVoiceSel) return; const engine=localStorage.getItem('hermes-tts-engine')||'browser'; - const current=localStorage.getItem('hermes-tts-voice')||''; + const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||''); + localStorage.setItem('hermes-tts-voice',current); if(engine==='elevenlabs'){ ttsVoiceSel.innerHTML=''; } else if(engine==='openai'){ @@ -8705,24 +8769,26 @@ async function loadSettingsPanel(){ const engine=localStorage.getItem('hermes-tts-engine')||'browser'; if(engine==='browser') window._populateTtsVoices(); },{once:false}); - ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);}; + ttsVoiceSel.onchange=function(){localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();}; } // TTS rate/pitch sliders const ttsRateSlider=$('settingsTtsRate'); const ttsRateValue=$('settingsTtsRateValue'); if(ttsRateSlider){ - const savedRate=localStorage.getItem('hermes-tts-rate'); + const savedRate=_speechSetting('tts_rate','hermes-tts-rate',1); ttsRateSlider.value=savedRate||'1'; if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x'; - ttsRateSlider.oninput=function(){if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);}; + localStorage.setItem('hermes-tts-rate',ttsRateSlider.value); + ttsRateSlider.oninput=function(){if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);_schedulePreferencesAutosave();}; } const ttsPitchSlider=$('settingsTtsPitch'); const ttsPitchValue=$('settingsTtsPitchValue'); if(ttsPitchSlider){ - const savedPitch=localStorage.getItem('hermes-tts-pitch'); + const savedPitch=_speechSetting('tts_pitch','hermes-tts-pitch',1); ttsPitchSlider.value=savedPitch||'1'; if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1); - ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);}; + localStorage.setItem('hermes-tts-pitch',ttsPitchSlider.value); + ttsPitchSlider.oninput=function(){if(ttsPitchValue)ttsPitchValue.textContent=parseFloat(this.value).toFixed(1);localStorage.setItem('hermes-tts-pitch',this.value);_schedulePreferencesAutosave();}; } const notifCb=$('settingsNotificationsEnabled'); if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});} diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py new file mode 100644 index 00000000..2d576558 --- /dev/null +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -0,0 +1,200 @@ +"""Regression checks for #5435 TTS and voice preference persistence.""" + +import json +import pathlib +import urllib.error +import urllib.request + +from tests._pytest_port import BASE + +ROOT = pathlib.Path(__file__).resolve().parent.parent +CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + +SPEECH_DEFAULTS = { + "tts_enabled": False, + "tts_auto_read": False, + "tts_engine": "browser", + "tts_voice": "", + "tts_rate": 1.0, + "tts_pitch": 1.0, + "voice_mode_button": False, + "voice_continuous": False, + "voice_silence_ms": 1800, + "raw_audio_mode": False, +} + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as response: + return json.loads(response.read()), response.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + BASE + path, + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read()), response.status + except urllib.error.HTTPError as exc: + return json.loads(exc.read()), exc.code + + +def _reset_speech_settings(extra=None): + payload = dict(SPEECH_DEFAULTS) + if extra: + payload.update(extra) + post("/api/settings", payload) + + +def test_settings_api_exposes_tts_voice_and_raw_audio_defaults(): + data, status = get("/api/settings") + + assert status == 200 + for key, value in SPEECH_DEFAULTS.items(): + assert data[key] == value + + +def test_settings_api_round_trips_speech_preferences(): + payload = { + "tts_enabled": True, + "tts_auto_read": True, + "tts_engine": "voicevox_local", + "tts_voice": "en-US-AriaNeural", + "tts_rate": "1.4", + "tts_pitch": "0.8", + "voice_mode_button": True, + "voice_continuous": True, + "voice_silence_ms": "2400", + "raw_audio_mode": True, + } + try: + saved, status = post("/api/settings", payload) + reloaded, reload_status = get("/api/settings") + + assert status == 200 + assert reload_status == 200 + assert saved["tts_enabled"] is True + assert saved["tts_auto_read"] is True + assert saved["tts_engine"] == "voicevox_local" + assert saved["tts_voice"] == "en-US-AriaNeural" + assert saved["tts_rate"] == 1.4 + assert saved["tts_pitch"] == 0.8 + assert saved["voice_mode_button"] is True + assert saved["voice_continuous"] is True + assert saved["voice_silence_ms"] == 2400 + assert saved["raw_audio_mode"] is True + for key in payload: + expected = saved[key] + assert reloaded[key] == expected + finally: + _reset_speech_settings() + + +def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings(): + data, status = get("/api/settings") + original_show_tps = bool(data.get("show_tps")) + valid = { + "tts_engine": "edge", + "tts_voice": "zh-CN-XiaoxiaoNeural", + "tts_rate": 1.2, + "tts_pitch": 1.1, + "voice_silence_ms": 2200, + } + try: + saved, status = post("/api/settings", valid) + assert status == 200 + assert saved["tts_engine"] == "edge" + + invalid, status = post( + "/api/settings", + { + "tts_engine": "", + "tts_voice": "x" * 201, + "tts_rate": "nan", + "tts_pitch": 3, + "voice_silence_ms": 199, + "show_tps": not original_show_tps, + }, + ) + + assert status == 200 + for key, value in valid.items(): + assert invalid[key] == value + assert invalid["show_tps"] is (not original_show_tps) + finally: + _reset_speech_settings({"show_tps": original_show_tps}) + + +def test_backend_schema_contains_typed_speech_validation(): + for key in SPEECH_DEFAULTS: + assert f'"{key}"' in CONFIG_PY + assert '"voice_silence_ms": (200, 60000)' in CONFIG_PY + assert '"tts_rate": (0.5, 2.0)' in CONFIG_PY + assert '"tts_pitch": (0.0, 2.0)' in CONFIG_PY + assert "_SETTINGS_TTS_ENGINE_RE" in CONFIG_PY + assert 'k == "tts_voice"' in CONFIG_PY + + +def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fallback(): + mirror_idx = BOOT_JS.index("function _mirrorSpeechSettingsFromServer") + success_call_idx = BOOT_JS.index("_mirrorSpeechSettingsFromServer(s);", mirror_idx) + apply_idx = BOOT_JS.index("_applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true')", success_call_idx) + catch_idx = BOOT_JS.index("}catch(e){", success_call_idx) + failure_apply_idx = BOOT_JS.index("_applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true')", catch_idx) + + assert success_call_idx < apply_idx + assert catch_idx < failure_apply_idx + assert "const defaults={" in BOOT_JS + assert "cached!==null&&boolValue(server)===boolValue(defaults[settingKey])" in BOOT_JS + assert "String(server)===String(defaults[settingKey])" in BOOT_JS + for storage_key in [ + "hermes-tts-enabled", + "hermes-tts-auto-read", + "hermes-tts-engine", + "hermes-tts-voice", + "hermes-tts-rate", + "hermes-tts-pitch", + "hermes-voice-mode-button", + "hermes-voice-continuous", + "hermes-voice-silence-ms", + "hermes-raw-audio-mode", + ]: + assert storage_key in BOOT_JS + assert "window._applyRawAudioModePreference" in BOOT_JS + + +def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes(): + payload_idx = PANELS_JS.index("function _preferencesPayloadFromUi") + payload_end = PANELS_JS.index("function _setPreferencesAutosaveStatus", payload_idx) + payload_block = PANELS_JS[payload_idx:payload_end] + panel_idx = PANELS_JS.index("TTS settings use /api/settings as the durable source") + panel_end = PANELS_JS.index("const notifCb=$('settingsNotificationsEnabled')", panel_idx) + panel_block = PANELS_JS[panel_idx:panel_end] + + for field in SPEECH_DEFAULTS: + assert f"payload.{field}=" in payload_block + for storage_key in [ + "hermes-tts-enabled", + "hermes-tts-auto-read", + "hermes-tts-engine", + "hermes-tts-voice", + "hermes-tts-rate", + "hermes-tts-pitch", + "hermes-voice-mode-button", + "hermes-voice-continuous", + "hermes-voice-silence-ms", + "hermes-raw-audio-mode", + ]: + assert storage_key in panel_block or storage_key in payload_block + assert "_speechSetting('tts_engine','hermes-tts-engine','browser')" in panel_block + assert "serverBool===fallbackBool&&storedBool!==fallbackBool" in PANELS_JS + assert "String(server)===String(fallback)&&String(stored)!==String(fallback)" in PANELS_JS + assert "_schedulePreferencesAutosave()" in panel_block + assert "_applyVoiceModePref" in panel_block + assert "_populateTtsVoices" in panel_block