From 91ae1dede91bf84ed80c196259a05f8fe2216775 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 15:43:24 -0400 Subject: [PATCH 01/10] 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 58fa1d7a1..e55fd9656 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 7480e3c6e..abffc23fc 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 bf0834605..79c1532d8 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 000000000..2d5765586 --- /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 From 7ee2757afeb058d29eb05b57bf92d38cbd3e07c9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 16:09:15 -0400 Subject: [PATCH 02/10] fix(#5435): preserve zero TTS pitch in settings --- static/panels.js | 4 ++-- tests/test_issue5435_tts_voice_preferences.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/static/panels.js b/static/panels.js index 79c1532d8..093672a80 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8776,7 +8776,7 @@ async function loadSettingsPanel(){ const ttsRateValue=$('settingsTtsRateValue'); if(ttsRateSlider){ const savedRate=_speechSetting('tts_rate','hermes-tts-rate',1); - ttsRateSlider.value=savedRate||'1'; + ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate); if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x'; 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();}; @@ -8785,7 +8785,7 @@ async function loadSettingsPanel(){ const ttsPitchValue=$('settingsTtsPitchValue'); if(ttsPitchSlider){ const savedPitch=_speechSetting('tts_pitch','hermes-tts-pitch',1); - ttsPitchSlider.value=savedPitch||'1'; + ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch); if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1); 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();}; diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py index 2d5765586..fc3a3ca00 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -67,7 +67,7 @@ def test_settings_api_round_trips_speech_preferences(): "tts_engine": "voicevox_local", "tts_voice": "en-US-AriaNeural", "tts_rate": "1.4", - "tts_pitch": "0.8", + "tts_pitch": "0", "voice_mode_button": True, "voice_continuous": True, "voice_silence_ms": "2400", @@ -84,7 +84,7 @@ def test_settings_api_round_trips_speech_preferences(): 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["tts_pitch"] == 0.0 assert saved["voice_mode_button"] is True assert saved["voice_continuous"] is True assert saved["voice_silence_ms"] == 2400 @@ -193,6 +193,10 @@ def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes( ]: assert storage_key in panel_block or storage_key in payload_block assert "_speechSetting('tts_engine','hermes-tts-engine','browser')" in panel_block + assert "savedRate||'1'" not in panel_block + assert "savedPitch||'1'" not in panel_block + assert "ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate)" in panel_block + assert "ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch)" 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 From ca31bf969bd7b205409137846494e60a4e9e648d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 16:49:45 -0400 Subject: [PATCH 03/10] fix(#5435): keep saved default speech prefs authoritative --- static/boot.js | 15 ++--- static/panels.js | 15 +---- tests/test_issue5435_tts_voice_preferences.py | 60 +++++++++++++++++-- 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/static/boot.js b/static/boot.js index abffc23fc..89de4e4bb 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2744,22 +2744,23 @@ function _mirrorSpeechSettingsFromServer(s){ voice_silence_ms:1800, raw_audio_mode:false, }; + const hasServerValue=(settingKey)=>Object.prototype.hasOwnProperty.call(s,settingKey); 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 server=hasServerValue(settingKey)?s[settingKey]:defaults[settingKey]; const cached=cachedValue(storageKey); - if(cached!==null&&boolValue(server)===boolValue(defaults[settingKey])&&boolValue(cached)!==boolValue(defaults[settingKey])){ + if(!hasServerValue(settingKey)&&cached!==null){ return boolValue(cached); } return boolValue(server); }; const resolveScalar=(settingKey,storageKey)=>{ - const server=Object.prototype.hasOwnProperty.call(s,settingKey)?s[settingKey]:defaults[settingKey]; + const server=hasServerValue(settingKey)?s[settingKey]:defaults[settingKey]; const cached=cachedValue(storageKey); - if(cached!==null&&String(server)===String(defaults[settingKey])&&String(cached)!==String(defaults[settingKey])){ + if(!hasServerValue(settingKey)&&cached!==null){ return cached; } return server; @@ -2771,7 +2772,7 @@ function _mirrorSpeechSettingsFromServer(s){ ['voice_continuous','hermes-voice-continuous'], ]; boolKeys.forEach(([settingKey,storageKey])=>{ - if(Object.prototype.hasOwnProperty.call(s,settingKey)){ + if(hasServerValue(settingKey)){ try{localStorage.setItem(storageKey,resolveBool(settingKey,storageKey)?'true':'false');}catch(_){} } }); @@ -2782,11 +2783,11 @@ function _mirrorSpeechSettingsFromServer(s){ ['tts_pitch','hermes-tts-pitch'], ['voice_silence_ms','hermes-voice-silence-ms'], ].forEach(([settingKey,storageKey])=>{ - if(Object.prototype.hasOwnProperty.call(s,settingKey)){ + if(hasServerValue(settingKey)){ try{localStorage.setItem(storageKey,String(resolveScalar(settingKey,storageKey)));}catch(_){} } }); - if(Object.prototype.hasOwnProperty.call(s,'raw_audio_mode')){ + if(hasServerValue('raw_audio_mode')){ const rawAudioMode=resolveBool('raw_audio_mode','hermes-raw-audio-mode'); if(typeof window._applyRawAudioModePreference==='function'){ window._applyRawAudioModePreference(rawAudioMode); diff --git a/static/panels.js b/static/panels.js index 093672a80..f84b68f63 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8640,20 +8640,7 @@ async function loadSettingsPanel(){ 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; - } + if(settings&&Object.prototype.hasOwnProperty.call(settings,key)) return settings[key]; return stored===null?fallback:stored; }; const _speechBool=function(key,storageKey,fallback){ diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py index fc3a3ca00..4196950e1 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -45,6 +45,24 @@ def post(path, body=None): return json.loads(exc.read()), exc.code +def _extract_balanced_block(src, marker): + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + end = None + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = idx + 1 + break + assert end is not None, f"Unbalanced block for {marker!r}" + return src[start:end] + + def _reset_speech_settings(extra=None): payload = dict(SPEECH_DEFAULTS) if extra: @@ -151,8 +169,8 @@ def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fal 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 + assert "const hasServerValue=(settingKey)=>Object.prototype.hasOwnProperty.call(s,settingKey);" in BOOT_JS + assert "if(!hasServerValue(settingKey)&&cached!==null)" in BOOT_JS for storage_key in [ "hermes-tts-enabled", "hermes-tts-auto-read", @@ -169,6 +187,41 @@ def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fal assert "window._applyRawAudioModePreference" in BOOT_JS +def test_server_saved_defaults_win_over_stale_local_cache_in_boot_and_panel_logic(): + mirror_fn = _extract_balanced_block(BOOT_JS, "function _mirrorSpeechSettingsFromServer") + speech_setting_start = PANELS_JS.index("const _speechSetting=function(") + speech_setting_end = PANELS_JS.index("const _speechBool=function", speech_setting_start) + speech_setting_block = PANELS_JS[speech_setting_start:speech_setting_end].strip() + script = f""" +const assert = require('assert'); +const localStorage = {{ + store: new Map([ + ['hermes-tts-enabled', 'true'], + ['hermes-tts-pitch', '0.8'], + ]), + getItem(key) {{ + return this.store.has(key) ? this.store.get(key) : null; + }}, + setItem(key, value) {{ + this.store.set(key, String(value)); + }}, +}}; +const window = {{}}; +{mirror_fn} +_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1}}); +assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'false'); +assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '1'); +let settings = {{tts_enabled: false, tts_pitch: 1}}; +{speech_setting_block} +assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), false); +assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), 1); +""" + + import subprocess + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + 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) @@ -197,8 +250,7 @@ def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes( assert "savedPitch||'1'" not in panel_block assert "ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate)" in panel_block assert "ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch)" in panel_block - assert "serverBool===fallbackBool&&storedBool!==fallbackBool" in PANELS_JS - assert "String(server)===String(fallback)&&String(stored)!==String(fallback)" in PANELS_JS + assert "if(settings&&Object.prototype.hasOwnProperty.call(settings,key)) return settings[key];" in PANELS_JS assert "_schedulePreferencesAutosave()" in panel_block assert "_applyVoiceModePref" in panel_block assert "_populateTtsVoices" in panel_block From 3b344b1d9a083412722a90b3d53c84a068a6e31e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 19:33:13 -0400 Subject: [PATCH 04/10] fix(#5435): preserve local speech prefs until settings.json owns them --- api/config.py | 183 +++++++++++------- api/routes.py | 3 + static/boot.js | 5 +- static/panels.js | 14 +- tests/test_issue5435_tts_voice_preferences.py | 105 +++++++++- 5 files changed, 232 insertions(+), 78 deletions(-) diff --git a/api/config.py b/api/config.py index e55fd9656..8aae52938 100644 --- a/api/config.py +++ b/api/config.py @@ -8075,6 +8075,19 @@ _SETTINGS_DEFAULTS = { "auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk "provider_cost_budget": None, } +_SETTINGS_SPEECH_KEYS = { + "tts_enabled", + "tts_auto_read", + "tts_engine", + "tts_voice", + "tts_rate", + "tts_pitch", + "voice_mode_button", + "voice_continuous", + "voice_silence_ms", + "raw_audio_mode", +} +_SETTINGS_PERSISTED_SPEECH_KEYS_FIELD = "persisted_speech_keys" _SETTINGS_LEGACY_DROP_KEYS = { "assistant_language", "bubble_layout", @@ -8152,75 +8165,93 @@ def _normalize_appearance(theme, skin) -> tuple[str, str]: return next_theme, next_skin +def _read_raw_settings_file() -> dict: + """Read settings.json without applying defaults.""" + try: + if not SETTINGS_FILE.exists(): + return {} + except OSError: + # PermissionError or other OS-level error (e.g. UID mismatch in Docker) + # Treat as missing rather than failing startup. + logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE) + return {} + + try: + loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + logger.debug("Failed to load settings from %s", SETTINGS_FILE) + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _extract_persisted_speech_keys(stored: dict) -> set[str]: + if not isinstance(stored, dict): + return set() + return {key for key in _SETTINGS_SPEECH_KEYS if key in stored} + + +def persisted_speech_settings_keys() -> list[str]: + return sorted(_extract_persisted_speech_keys(_read_raw_settings_file())) + + def load_settings() -> dict: """Load settings from disk, merging with defaults for any missing keys.""" settings = dict(_SETTINGS_DEFAULTS) - stored = None - try: - settings_exists = SETTINGS_FILE.exists() - except OSError: - # PermissionError or other OS-level error (e.g. UID mismatch in Docker) - # Treat as missing — start with defaults rather than crashing. - logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE) - settings_exists = False - if settings_exists: - try: - stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - if isinstance(stored, dict): - if ( - "worklog_details_expanded_default" not in stored - and "activity_feed_expanded_default" in stored - ): - settings["worklog_details_expanded_default"] = bool( - stored.get("activity_feed_expanded_default") - ) - settings.update( - { - k: v - for k, v in stored.items() - if k not in _SETTINGS_LEGACY_DROP_KEYS - } - ) - if ( - "default_message_mode" not in stored - and "busy_input_mode" in stored - ): - settings["default_message_mode"] = stored.get("busy_input_mode") - settings.pop("busy_input_mode", None) - # Grandfather established installs OFF for show_cli_sessions (#3988). - # The default flipped True so NEW users see CLI/TUI/messaging - # sessions without hunting for the toggle — but an existing user - # who never opted in should not have their sidebar silently change. - # Treat the install as established (and pin the old False default) - # when show_cli_sessions is absent AND the file already carries - # real user state — either onboarding was completed, or some - # setting OTHER than a not-yet-completed onboarding flag has been - # persisted. Keying on "has saved user state" (not just - # onboarding_completed) also covers a CLI-configured user who - # tweaked a WebUI setting before running the wizard. A genuinely - # new / still-mid-onboarding file falls through to the True default. - _established_keys = [ - k for k in stored - if k not in ("show_cli_sessions", "onboarding_completed") - ] - if "show_cli_sessions" not in stored and ( - bool(stored.get("onboarding_completed")) or _established_keys - ): - settings["show_cli_sessions"] = False - # Force-off-for-everyone migration for virtualize_transcript (#4343). - # The feature shipped opt-OUT/default-on in #4325, then proved to - # cause scroll-up flicker on long sessions (variable-height anchor - # oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any - # stored virtualize_transcript=True from the #4325 window is a stale - # pre-flip value and must be reset to off, so 100% of existing users - # land on off — re-enabling requires an explicit opt-in made AFTER - # the flip, which writes virtualize_transcript_optin=True alongside. - # Honor a stored True only when that marker is present. - if not bool(stored.get("virtualize_transcript_optin")): - settings["virtualize_transcript"] = False - - except Exception: - logger.debug("Failed to load settings from %s", SETTINGS_FILE) + stored = _read_raw_settings_file() + persisted_speech_keys = _extract_persisted_speech_keys(stored) + if isinstance(stored, dict): + if ( + "worklog_details_expanded_default" not in stored + and "activity_feed_expanded_default" in stored + ): + settings["worklog_details_expanded_default"] = bool( + stored.get("activity_feed_expanded_default") + ) + settings.update( + { + k: v + for k, v in stored.items() + if k not in _SETTINGS_LEGACY_DROP_KEYS + and k != _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD + } + ) + if ( + "default_message_mode" not in stored + and "busy_input_mode" in stored + ): + settings["default_message_mode"] = stored.get("busy_input_mode") + settings.pop("busy_input_mode", None) + # Grandfather established installs OFF for show_cli_sessions (#3988). + # The default flipped True so NEW users see CLI/TUI/messaging + # sessions without hunting for the toggle — but an existing user + # who never opted in should not have their sidebar silently change. + # Treat the install as established (and pin the old False default) + # when show_cli_sessions is absent AND the file already carries + # real user state — either onboarding was completed, or some + # setting OTHER than a not-yet-completed onboarding flag has been + # persisted. Keying on "has saved user state" (not just + # onboarding_completed) also covers a CLI-configured user who + # tweaked a WebUI setting before running the wizard. A genuinely + # new / still-mid-onboarding file falls through to the True default. + _established_keys = [ + k for k in stored + if k not in ("show_cli_sessions", "onboarding_completed") + ] + if "show_cli_sessions" not in stored and ( + bool(stored.get("onboarding_completed")) or _established_keys + ): + settings["show_cli_sessions"] = False + # Force-off-for-everyone migration for virtualize_transcript (#4343). + # The feature shipped opt-OUT/default-on in #4325, then proved to + # cause scroll-up flicker on long sessions (variable-height anchor + # oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any + # stored virtualize_transcript=True from the #4325 window is a stale + # pre-flip value and must be reset to off, so 100% of existing users + # land on off — re-enabling requires an explicit opt-in made AFTER + # the flip, which writes virtualize_transcript_optin=True alongside. + # Honor a stored True only when that marker is present. + if not bool(stored.get("virtualize_transcript_optin")): + settings["virtualize_transcript"] = False settings["theme"], settings["skin"] = _normalize_appearance( stored.get("theme") if isinstance(stored, dict) else settings.get("theme"), stored.get("skin") if isinstance(stored, dict) else settings.get("skin"), @@ -8341,7 +8372,10 @@ def _coerce_provider_cost_budget(value: Any) -> float | None: def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" + raw_settings = _read_raw_settings_file() + persisted_speech_keys = _extract_persisted_speech_keys(raw_settings) current = load_settings() + applied_speech_keys: set[str] = set() if ( "worklog_details_expanded_default" not in settings and "activity_feed_expanded_default" in settings @@ -8384,6 +8418,7 @@ def save_settings(settings: dict) -> dict: current_dash.update({k: bool(v) for k, v in _dashboard_plugins.items() if isinstance(k, str)}) current["dashboard_plugins"] = current_dash for k, v in settings.items(): + key_is_speech = k in _SETTINGS_SPEECH_KEYS # dashboard_plugins is deep-merged above (not a flat allowlisted scalar). if k == "dashboard_plugins": continue @@ -8466,6 +8501,8 @@ def save_settings(settings: dict) -> dict: if k in _SETTINGS_BOOL_KEYS: v = bool(v) current[k] = v + if key_is_speech: + applied_speech_keys.add(k) theme_value = pending_theme skin_value = pending_skin if theme_was_explicit and not skin_was_explicit: @@ -8477,7 +8514,19 @@ def save_settings(settings: dict) -> dict: current["default_workspace"] = str( resolve_default_workspace(current.get("default_workspace")) ) - persisted = {k: v for k, v in current.items() if k != "default_model"} + effective_persisted_speech_keys = set(persisted_speech_keys) + effective_persisted_speech_keys.update(applied_speech_keys) + persisted = { + k: v + for k, v in current.items() + if k not in {"default_model", _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD} + } + # Persist speech keys only if they were already present on disk or explicitly + # submitted in this request. This avoids auto-materializing defaults during + # unrelated saves. + for speech_key in _SETTINGS_SPEECH_KEYS: + if speech_key not in effective_persisted_speech_keys: + persisted.pop(speech_key, None) SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.write_text( json.dumps(persisted, ensure_ascii=False, indent=2), diff --git a/api/routes.py b/api/routes.py index 87d202b8b..2b715358c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2793,6 +2793,7 @@ from api.config import ( SESSION_AGENT_LOCKS_LOCK, CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, load_settings, + persisted_speech_settings_keys, save_settings, SETTINGS_FILE, set_hermes_default_model, @@ -11431,6 +11432,7 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/settings": settings = load_settings() + settings["persisted_speech_keys"] = persisted_speech_settings_keys() # Never expose the stored password hash to clients settings.pop("password_hash", None) settings.setdefault("max_tokens", None) @@ -14302,6 +14304,7 @@ def handle_post(handler, parsed) -> bool: from api.config import get_max_tokens_status, set_max_tokens saved = save_settings(body) + saved["persisted_speech_keys"] = persisted_speech_settings_keys() if max_tokens_provided: max_tokens_status = set_max_tokens(max_tokens_value) saved.pop("password_hash", None) # never expose hash to client diff --git a/static/boot.js b/static/boot.js index 89de4e4bb..19f44b4f3 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2732,6 +2732,10 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; function _mirrorSpeechSettingsFromServer(s){ if(!s||typeof s!=='object') return; + const persistedSpeechKeys = new Set( + Array.isArray(s.persisted_speech_keys) ? s.persisted_speech_keys : [] + ); + const hasServerValue=(settingKey)=>persistedSpeechKeys.has(settingKey); const defaults={ tts_enabled:false, tts_auto_read:false, @@ -2744,7 +2748,6 @@ function _mirrorSpeechSettingsFromServer(s){ voice_silence_ms:1800, raw_audio_mode:false, }; - const hasServerValue=(settingKey)=>Object.prototype.hasOwnProperty.call(s,settingKey); const cachedValue=(storageKey)=>{ try{return localStorage.getItem(storageKey);}catch(_){return null;} }; diff --git a/static/panels.js b/static/panels.js index f84b68f63..dae5762ce 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8067,6 +8067,12 @@ function _preferencesPayloadFromUi(){ if(showBusyPlaceholderHintCb) payload.show_busy_placeholder_hint=showBusyPlaceholderHintCb.checked; const botNameField=$('settingsBotName'); if(botNameField) payload.bot_name=botNameField.value; + Object.assign(payload,_speechPreferencesPayloadFromUi()); + return payload; +} + +function _speechPreferencesPayloadFromUi(){ + const payload={}; const ttsEnabledCb=$('settingsTtsEnabled'); if(ttsEnabledCb) payload.tts_enabled=ttsEnabledCb.checked; const ttsAutoReadCb=$('settingsTtsAutoRead'); @@ -8638,9 +8644,14 @@ async function loadSettingsPanel(){ },{once:false}); } if(typeof window._mirrorSpeechSettingsFromServer==='function') window._mirrorSpeechSettingsFromServer(settings); + const persistedSpeechKeys = new Set( + Array.isArray(settings && settings.persisted_speech_keys) + ? settings.persisted_speech_keys + : [] + ); const _speechSetting=function(key,storageKey,fallback,kind){ const stored=localStorage.getItem(storageKey); - if(settings&&Object.prototype.hasOwnProperty.call(settings,key)) return settings[key]; + if(settings&&persistedSpeechKeys.has(key)) return settings[key]; return stored===null?fallback:stored; }; const _speechBool=function(key,storageKey,fallback){ @@ -11458,6 +11469,7 @@ async function saveSettings(andClose){ const defaultMessageMode=($('settingsDefaultMessageMode')||{}).value||'steer'; const showBusyPlaceholderHint=!!($('settingsShowBusyPlaceholderHint')||{}).checked; const body={}; + Object.assign(body,_speechPreferencesPayloadFromUi()); if(sendKey) body.send_key=sendKey; body.theme=theme; diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py index 4196950e1..3c5782a1f 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -1,6 +1,7 @@ """Regression checks for #5435 TTS and voice preference persistence.""" import json +import importlib import pathlib import urllib.error import urllib.request @@ -24,6 +25,7 @@ SPEECH_DEFAULTS = { "voice_silence_ms": 1800, "raw_audio_mode": False, } +PERSISTED_SPEECH_KEYS_FIELD = "persisted_speech_keys" def get(path): @@ -70,10 +72,27 @@ def _reset_speech_settings(extra=None): post("/api/settings", payload) +def _settings_file_snapshot(): + cfg = importlib.import_module("api.config") + path = cfg.SETTINGS_FILE + original = path.read_text(encoding="utf-8") if path.exists() else None + return path, original + + +def _restore_settings_file(path, original): + if original is None: + if path.exists(): + path.unlink() + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original, encoding="utf-8") + + def test_settings_api_exposes_tts_voice_and_raw_audio_defaults(): data, status = get("/api/settings") assert status == 200 + assert data[PERSISTED_SPEECH_KEYS_FIELD] == [] for key, value in SPEECH_DEFAULTS.items(): assert data[key] == value @@ -107,13 +126,67 @@ def test_settings_api_round_trips_speech_preferences(): assert saved["voice_continuous"] is True assert saved["voice_silence_ms"] == 2400 assert saved["raw_audio_mode"] is True + assert saved[PERSISTED_SPEECH_KEYS_FIELD] == sorted(payload) for key in payload: expected = saved[key] assert reloaded[key] == expected + assert reloaded[PERSISTED_SPEECH_KEYS_FIELD] == sorted(payload) finally: _reset_speech_settings() +def test_settings_api_reports_only_raw_persisted_speech_keys(): + path, original = _settings_file_snapshot() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "show_tps": True, + "tts_pitch": 0.0, + "voice_mode_button": False, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + data, status = get("/api/settings") + + assert status == 200 + assert data[PERSISTED_SPEECH_KEYS_FIELD] == [ + "tts_pitch", + "voice_mode_button", + ] + assert data["tts_pitch"] == 0.0 + assert data["voice_mode_button"] is False + assert data["tts_enabled"] is False + finally: + _restore_settings_file(path, original) + + +def test_unrelated_settings_save_does_not_materialize_absent_speech_defaults(): + path, original = _settings_file_snapshot() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"show_tps": False}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + saved, status = post("/api/settings", {"show_tps": True}) + + assert status == 200 + assert saved["show_tps"] is True + assert saved[PERSISTED_SPEECH_KEYS_FIELD] == [] + + raw = json.loads(path.read_text(encoding="utf-8")) + assert raw["show_tps"] is True + for key in SPEECH_DEFAULTS: + assert key not in raw + finally: + _restore_settings_file(path, original) + + def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings(): data, status = get("/api/settings") original_show_tps = bool(data.get("show_tps")) @@ -169,7 +242,7 @@ def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fal assert success_call_idx < apply_idx assert catch_idx < failure_apply_idx assert "const defaults={" in BOOT_JS - assert "const hasServerValue=(settingKey)=>Object.prototype.hasOwnProperty.call(s,settingKey);" in BOOT_JS + assert "Array.isArray(s.persisted_speech_keys) ? s.persisted_speech_keys : []" in BOOT_JS assert "if(!hasServerValue(settingKey)&&cached!==null)" in BOOT_JS for storage_key in [ "hermes-tts-enabled", @@ -187,9 +260,9 @@ def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fal assert "window._applyRawAudioModePreference" in BOOT_JS -def test_server_saved_defaults_win_over_stale_local_cache_in_boot_and_panel_logic(): +def test_persisted_speech_key_metadata_controls_boot_and_panel_precedence(): mirror_fn = _extract_balanced_block(BOOT_JS, "function _mirrorSpeechSettingsFromServer") - speech_setting_start = PANELS_JS.index("const _speechSetting=function(") + speech_setting_start = PANELS_JS.index("const persistedSpeechKeys = new Set(") speech_setting_end = PANELS_JS.index("const _speechBool=function", speech_setting_start) speech_setting_block = PANELS_JS[speech_setting_start:speech_setting_end].strip() script = f""" @@ -208,13 +281,24 @@ const localStorage = {{ }}; const window = {{}}; {mirror_fn} -_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1}}); +_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: []}}); +assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'true'); +assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '0.8'); +{{ + let settings = {{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: []}}; + {speech_setting_block} + assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), 'true'); + assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), '0.8'); +}} +_mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: ['tts_enabled', 'tts_pitch']}}); assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'false'); assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '1'); -let settings = {{tts_enabled: false, tts_pitch: 1}}; -{speech_setting_block} -assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), false); -assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), 1); +{{ + let settings = {{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: ['tts_enabled', 'tts_pitch']}}; + {speech_setting_block} + assert.strictEqual(_speechSetting('tts_enabled', 'hermes-tts-enabled', false, 'bool'), false); + assert.strictEqual(_speechSetting('tts_pitch', 'hermes-tts-pitch', 1), 1); +}} """ import subprocess @@ -246,11 +330,14 @@ def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes( ]: assert storage_key in panel_block or storage_key in payload_block assert "_speechSetting('tts_engine','hermes-tts-engine','browser')" in panel_block + assert "function _speechPreferencesPayloadFromUi()" in PANELS_JS assert "savedRate||'1'" not in panel_block assert "savedPitch||'1'" not in panel_block assert "ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate)" in panel_block assert "ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch)" in panel_block - assert "if(settings&&Object.prototype.hasOwnProperty.call(settings,key)) return settings[key];" in PANELS_JS + assert "if(settings&&persistedSpeechKeys.has(key)) return settings[key];" in PANELS_JS + assert "Object.assign(payload,_speechPreferencesPayloadFromUi());" in payload_block + assert "Object.assign(body,_speechPreferencesPayloadFromUi());" in PANELS_JS assert "_schedulePreferencesAutosave()" in panel_block assert "_applyVoiceModePref" in panel_block assert "_populateTtsVoices" in panel_block From 8f7b9c1d0dcd50df0b545d5cbf096f2a7016dd1e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 19:38:35 -0400 Subject: [PATCH 05/10] fix(#5435): drop dead persisted-key state from config load --- api/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/config.py b/api/config.py index 8aae52938..cff4fe953 100644 --- a/api/config.py +++ b/api/config.py @@ -8198,7 +8198,6 @@ def load_settings() -> dict: """Load settings from disk, merging with defaults for any missing keys.""" settings = dict(_SETTINGS_DEFAULTS) stored = _read_raw_settings_file() - persisted_speech_keys = _extract_persisted_speech_keys(stored) if isinstance(stored, dict): if ( "worklog_details_expanded_default" not in stored From fdacb4b92203833d790fae0726bf1356ea048a65 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 21:28:46 -0400 Subject: [PATCH 06/10] fix(#5435): stop startup workspace repair from persisting speech defaults --- api/config.py | 35 ++++++++++++------- tests/test_issue5435_tts_voice_preferences.py | 34 ++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/api/config.py b/api/config.py index cff4fe953..639f95bc8 100644 --- a/api/config.py +++ b/api/config.py @@ -8194,6 +8194,18 @@ def persisted_speech_settings_keys() -> list[str]: return sorted(_extract_persisted_speech_keys(_read_raw_settings_file())) +def _settings_payload_for_write(settings: dict, persisted_speech_keys: set[str]) -> dict: + persisted = { + k: v + for k, v in settings.items() + if k not in {"default_model", _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD} + } + for speech_key in _SETTINGS_SPEECH_KEYS: + if speech_key not in persisted_speech_keys: + persisted.pop(speech_key, None) + return persisted + + def load_settings() -> dict: """Load settings from disk, merging with defaults for any missing keys.""" settings = dict(_SETTINGS_DEFAULTS) @@ -8515,17 +8527,7 @@ def save_settings(settings: dict) -> dict: ) effective_persisted_speech_keys = set(persisted_speech_keys) effective_persisted_speech_keys.update(applied_speech_keys) - persisted = { - k: v - for k, v in current.items() - if k not in {"default_model", _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD} - } - # Persist speech keys only if they were already present on disk or explicitly - # submitted in this request. This avoids auto-materializing defaults during - # unrelated saves. - for speech_key in _SETTINGS_SPEECH_KEYS: - if speech_key not in effective_persisted_speech_keys: - persisted.pop(speech_key, None) + persisted = _settings_payload_for_write(current, effective_persisted_speech_keys) SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.write_text( json.dumps(persisted, ensure_ascii=False, indent=2), @@ -8567,8 +8569,17 @@ if _settings_file_exists: if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE): _startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE) try: + startup_persisted_speech_keys = _extract_persisted_speech_keys( + _read_raw_settings_file() + ) SETTINGS_FILE.write_text( - json.dumps(_startup_settings, ensure_ascii=False, indent=2), + json.dumps( + _settings_payload_for_write( + _startup_settings, startup_persisted_speech_keys + ), + ensure_ascii=False, + indent=2, + ), encoding="utf-8", ) except Exception: diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py index 3c5782a1f..a44e59f63 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -187,6 +187,40 @@ def test_unrelated_settings_save_does_not_materialize_absent_speech_defaults(): _restore_settings_file(path, original) +def test_startup_workspace_repair_write_drops_merged_speech_defaults(): + path, original = _settings_file_snapshot() + cfg = importlib.import_module("api.config") + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "show_tps": False, + "tts_pitch": 0.0, + "default_workspace": "C:/stale/workspace", + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + merged = cfg.load_settings() + merged["default_workspace"] = "C:/fixed/workspace" + persisted = cfg._settings_payload_for_write( + merged, cfg._extract_persisted_speech_keys(cfg._read_raw_settings_file()) + ) + + assert persisted["show_tps"] is False + assert persisted["tts_pitch"] == 0.0 + assert persisted["default_workspace"] == "C:/fixed/workspace" + assert PERSISTED_SPEECH_KEYS_FIELD not in persisted + for key in SPEECH_DEFAULTS: + if key != "tts_pitch": + assert key not in persisted + finally: + _restore_settings_file(path, original) + + def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings(): data, status = get("/api/settings") original_show_tps = bool(data.get("show_tps")) From 7a21825722757a0210522664339c2e6dacbefd02 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 00:22:39 -0400 Subject: [PATCH 07/10] fix(#5435): keep speech settings payload sparse --- static/panels.js | 89 ++++++++++++++----- tests/test_issue5435_tts_voice_preferences.py | 65 +++++++++++++- 2 files changed, 132 insertions(+), 22 deletions(-) diff --git a/static/panels.js b/static/panels.js index dae5762ce..fc186fcd6 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7995,6 +7995,49 @@ function _retryAppearanceAutosave(){ // ── Phase 2: Preferences autosave (Issue #1003) ─────────────────────── +const _SETTINGS_SPEECH_STORAGE_KEYS={ + tts_enabled:'hermes-tts-enabled', + tts_auto_read:'hermes-tts-auto-read', + tts_engine:'hermes-tts-engine', + tts_voice:'hermes-tts-voice', + tts_rate:'hermes-tts-rate', + tts_pitch:'hermes-tts-pitch', + voice_mode_button:'hermes-voice-mode-button', + voice_continuous:'hermes-voice-continuous', + voice_silence_ms:'hermes-voice-silence-ms', + raw_audio_mode:'hermes-raw-audio-mode', +}; +let _settingsSpeechPersistedKeys=new Set(); +let _settingsSpeechLocalStorageKeys=new Set(); +let _settingsSpeechChangedKeys=new Set(); + +function _captureSpeechPreferenceOwnership(settings){ + _settingsSpeechPersistedKeys=new Set(Array.isArray(settings&&settings.persisted_speech_keys)?settings.persisted_speech_keys:[]); + _settingsSpeechLocalStorageKeys=new Set(); + _settingsSpeechChangedKeys=new Set(); + Object.entries(_SETTINGS_SPEECH_STORAGE_KEYS).forEach(([settingKey,storageKey])=>{ + try{if(localStorage.getItem(storageKey)!==null) _settingsSpeechLocalStorageKeys.add(settingKey);}catch(_){} + }); +} + +function _speechPreferenceIsOwned(settingKey){ + return _settingsSpeechPersistedKeys.has(settingKey)||_settingsSpeechLocalStorageKeys.has(settingKey)||_settingsSpeechChangedKeys.has(settingKey); +} + +function _markSpeechPreferenceChanged(settingKey){ + _settingsSpeechChangedKeys.add(settingKey); +} + +function _syncSpeechPreferenceCache(settingKey,value){ + if(!_speechPreferenceIsOwned(settingKey)) return; + const storageKey=_SETTINGS_SPEECH_STORAGE_KEYS[settingKey]; + if(storageKey) localStorage.setItem(storageKey,String(value)); +} + +function _setOwnedSpeechPayload(payload,settingKey,value){ + if(_speechPreferenceIsOwned(settingKey)) payload[settingKey]=value; +} + function _preferencesPayloadFromUi(){ const payload={}; const sendKeySel=$('settingsSendKey'); @@ -8074,24 +8117,24 @@ function _preferencesPayloadFromUi(){ function _speechPreferencesPayloadFromUi(){ const payload={}; const ttsEnabledCb=$('settingsTtsEnabled'); - if(ttsEnabledCb) payload.tts_enabled=ttsEnabledCb.checked; + if(ttsEnabledCb) _setOwnedSpeechPayload(payload,'tts_enabled',ttsEnabledCb.checked); const ttsAutoReadCb=$('settingsTtsAutoRead'); - if(ttsAutoReadCb) payload.tts_auto_read=ttsAutoReadCb.checked; + if(ttsAutoReadCb) _setOwnedSpeechPayload(payload,'tts_auto_read',ttsAutoReadCb.checked); const ttsEngineSel=$('settingsTtsEngine'); - if(ttsEngineSel) payload.tts_engine=ttsEngineSel.value||'browser'; + if(ttsEngineSel) _setOwnedSpeechPayload(payload,'tts_engine',ttsEngineSel.value||'browser'); const ttsVoiceSel=$('settingsTtsVoice'); - if(ttsVoiceSel) payload.tts_voice=ttsVoiceSel.value||''; + if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||''); const ttsRateSlider=$('settingsTtsRate'); - if(ttsRateSlider) payload.tts_rate=parseFloat(ttsRateSlider.value); + if(ttsRateSlider) _setOwnedSpeechPayload(payload,'tts_rate',parseFloat(ttsRateSlider.value)); const ttsPitchSlider=$('settingsTtsPitch'); - if(ttsPitchSlider) payload.tts_pitch=parseFloat(ttsPitchSlider.value); + if(ttsPitchSlider) _setOwnedSpeechPayload(payload,'tts_pitch',parseFloat(ttsPitchSlider.value)); const voiceModeCb=$('settingsVoiceModeEnabled'); - if(voiceModeCb) payload.voice_mode_button=voiceModeCb.checked; + if(voiceModeCb) _setOwnedSpeechPayload(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'; + _setOwnedSpeechPayload(payload,'raw_audio_mode',rawAudioCb?rawAudioCb.checked:localStorage.getItem('hermes-raw-audio-mode')==='true'); + _setOwnedSpeechPayload(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; + _setOwnedSpeechPayload(payload,'voice_silence_ms',(Number.isFinite(voiceSilence)&&voiceSilence>=200)?voiceSilence:1800); return payload; } @@ -8649,6 +8692,7 @@ async function loadSettingsPanel(){ ? settings.persisted_speech_keys : [] ); + _captureSpeechPreferenceOwnership(settings); const _speechSetting=function(key,storageKey,fallback,kind){ const stored=localStorage.getItem(storageKey); if(settings&&persistedSpeechKeys.has(key)) return settings[key]; @@ -8662,20 +8706,21 @@ async function loadSettingsPanel(){ if(rawAudioCb){ rawAudioCb.checked=_speechBool('raw_audio_mode','hermes-raw-audio-mode',false); rawAudioCb.onchange=function(){ + _markSpeechPreferenceChanged('raw_audio_mode'); 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'); + _syncSpeechPreferenceCache('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'); + _syncSpeechPreferenceCache('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=_speechBool('tts_enabled','hermes-tts-enabled',false);ttsEnabledCb.onchange=function(){localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);_schedulePreferencesAutosave();};} + if(ttsEnabledCb){ttsEnabledCb.checked=_speechBool('tts_enabled','hermes-tts-enabled',false);ttsEnabledCb.onchange=function(){_markSpeechPreferenceChanged('tts_enabled');localStorage.setItem('hermes-tts-enabled',this.checked?'true':'false');_applyTtsEnabled(this.checked);_schedulePreferencesAutosave();};} const ttsAutoReadCb=$('settingsTtsAutoRead'); - 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();};} + if(ttsAutoReadCb){ttsAutoReadCb.checked=_speechBool('tts_auto_read','hermes-tts-auto-read',false);ttsAutoReadCb.onchange=function(){_markSpeechPreferenceChanged('tts_auto_read');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. @@ -8683,6 +8728,7 @@ async function loadSettingsPanel(){ if(voiceModeCb){ voiceModeCb.checked=_speechBool('voice_mode_button','hermes-voice-mode-button',false); voiceModeCb.onchange=function(){ + _markSpeechPreferenceChanged('voice_mode_button'); localStorage.setItem('hermes-voice-mode-button',this.checked?'true':'false'); if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref(); _schedulePreferencesAutosave(); @@ -8710,8 +8756,9 @@ async function loadSettingsPanel(){ ttsEngineSel.appendChild(savedOpt); } ttsEngineSel.value=saved; - localStorage.setItem('hermes-tts-engine',saved); + _syncSpeechPreferenceCache('tts_engine',saved); ttsEngineSel.onchange=function(){ + _markSpeechPreferenceChanged('tts_engine'); localStorage.setItem('hermes-tts-engine',this.value); window._populateTtsVoices(); _schedulePreferencesAutosave(); @@ -8723,7 +8770,7 @@ async function loadSettingsPanel(){ if(!ttsVoiceSel) return; const engine=localStorage.getItem('hermes-tts-engine')||'browser'; const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||''); - localStorage.setItem('hermes-tts-voice',current); + _syncSpeechPreferenceCache('tts_voice',current); if(engine==='elevenlabs'){ ttsVoiceSel.innerHTML=''; } else if(engine==='openai'){ @@ -8767,7 +8814,7 @@ 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);_schedulePreferencesAutosave();}; + ttsVoiceSel.onchange=function(){_markSpeechPreferenceChanged('tts_voice');localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();}; } // TTS rate/pitch sliders const ttsRateSlider=$('settingsTtsRate'); @@ -8776,8 +8823,8 @@ async function loadSettingsPanel(){ const savedRate=_speechSetting('tts_rate','hermes-tts-rate',1); ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate); if(ttsRateValue) ttsRateValue.textContent=parseFloat(ttsRateSlider.value).toFixed(1)+'x'; - 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();}; + _syncSpeechPreferenceCache('tts_rate',ttsRateSlider.value); + ttsRateSlider.oninput=function(){_markSpeechPreferenceChanged('tts_rate');if(ttsRateValue)ttsRateValue.textContent=parseFloat(this.value).toFixed(1)+'x';localStorage.setItem('hermes-tts-rate',this.value);_schedulePreferencesAutosave();}; } const ttsPitchSlider=$('settingsTtsPitch'); const ttsPitchValue=$('settingsTtsPitchValue'); @@ -8785,8 +8832,8 @@ async function loadSettingsPanel(){ const savedPitch=_speechSetting('tts_pitch','hermes-tts-pitch',1); ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch); if(ttsPitchValue) ttsPitchValue.textContent=parseFloat(ttsPitchSlider.value).toFixed(1); - 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();}; + _syncSpeechPreferenceCache('tts_pitch',ttsPitchSlider.value); + ttsPitchSlider.oninput=function(){_markSpeechPreferenceChanged('tts_pitch');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 index a44e59f63..32a2f527c 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -296,6 +296,9 @@ def test_boot_mirrors_server_settings_before_tts_apply_and_preserves_failure_fal def test_persisted_speech_key_metadata_controls_boot_and_panel_precedence(): mirror_fn = _extract_balanced_block(BOOT_JS, "function _mirrorSpeechSettingsFromServer") + speech_helpers_start = PANELS_JS.index("const _SETTINGS_SPEECH_STORAGE_KEYS=") + speech_helpers_end = PANELS_JS.index("function _preferencesPayloadFromUi", speech_helpers_start) + speech_helpers_block = PANELS_JS[speech_helpers_start:speech_helpers_end].strip() speech_setting_start = PANELS_JS.index("const persistedSpeechKeys = new Set(") speech_setting_end = PANELS_JS.index("const _speechBool=function", speech_setting_start) speech_setting_block = PANELS_JS[speech_setting_start:speech_setting_end].strip() @@ -314,6 +317,7 @@ const localStorage = {{ }}, }}; const window = {{}}; +{speech_helpers_block} {mirror_fn} _mirrorSpeechSettingsFromServer({{tts_enabled: false, tts_pitch: 1, persisted_speech_keys: []}}); assert.strictEqual(localStorage.getItem('hermes-tts-enabled'), 'true'); @@ -340,6 +344,60 @@ assert.strictEqual(localStorage.getItem('hermes-tts-pitch'), '1'); subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_settings_panel_speech_payload_is_sparse_by_ownership(): + speech_helpers_start = PANELS_JS.index("const _SETTINGS_SPEECH_STORAGE_KEYS=") + speech_helpers_end = PANELS_JS.index("function _setPreferencesAutosaveStatus", speech_helpers_start) + speech_helpers_block = PANELS_JS[speech_helpers_start:speech_helpers_end].strip() + script = f""" +const assert = require('assert'); +const localStorage = {{ + store: new Map(), + getItem(key) {{ + return this.store.has(key) ? this.store.get(key) : null; + }}, + setItem(key, value) {{ + this.store.set(key, String(value)); + }}, + clear() {{ + this.store.clear(); + }}, +}}; +const controls = {{ + settingsTtsEnabled: {{checked: false}}, + settingsTtsAutoRead: {{checked: false}}, + settingsTtsEngine: {{value: 'browser'}}, + settingsTtsVoice: {{value: ''}}, + settingsTtsRate: {{value: '1'}}, + settingsTtsPitch: {{value: '1'}}, + settingsVoiceModeEnabled: {{checked: false}}, + settingsRawAudio: {{checked: false}}, +}}; +function $(id) {{ return controls[id] || null; }} +{speech_helpers_block} +_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}}); +assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{}}); + +_captureSpeechPreferenceOwnership({{persisted_speech_keys: ['tts_enabled']}}); +assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_enabled: false}}); + +localStorage.clear(); +localStorage.setItem('hermes-tts-pitch', '0.8'); +controls.settingsTtsPitch.value = '0.8'; +_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}}); +assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_pitch: 0.8}}); + +localStorage.clear(); +controls.settingsTtsRate.value = '1.4'; +_captureSpeechPreferenceOwnership({{persisted_speech_keys: []}}); +_markSpeechPreferenceChanged('tts_rate'); +assert.deepStrictEqual(_speechPreferencesPayloadFromUi(), {{tts_rate: 1.4}}); +""" + + import subprocess + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + 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) @@ -349,7 +407,7 @@ def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes( panel_block = PANELS_JS[panel_idx:panel_end] for field in SPEECH_DEFAULTS: - assert f"payload.{field}=" in payload_block + assert f"_setOwnedSpeechPayload(payload,'{field}'" in payload_block for storage_key in [ "hermes-tts-enabled", "hermes-tts-auto-read", @@ -370,6 +428,11 @@ def test_settings_panel_persists_speech_fields_and_keeps_immediate_cache_writes( assert "ttsRateSlider.value=(savedRate===null||savedRate===undefined)?'1':String(savedRate)" in panel_block assert "ttsPitchSlider.value=(savedPitch===null||savedPitch===undefined)?'1':String(savedPitch)" in panel_block assert "if(settings&&persistedSpeechKeys.has(key)) return settings[key];" in PANELS_JS + assert "function _captureSpeechPreferenceOwnership(settings)" in PANELS_JS + assert "function _speechPreferenceIsOwned(settingKey)" in PANELS_JS + assert "_markSpeechPreferenceChanged('tts_rate')" in panel_block + assert "_syncSpeechPreferenceCache('tts_rate',ttsRateSlider.value)" in panel_block + assert "_syncSpeechPreferenceCache('tts_pitch',ttsPitchSlider.value)" in panel_block assert "Object.assign(payload,_speechPreferencesPayloadFromUi());" in payload_block assert "Object.assign(body,_speechPreferencesPayloadFromUi());" in PANELS_JS assert "_schedulePreferencesAutosave()" in panel_block From 8c82ed8f11280a5945289df3f818c90ffc065d1c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 08:57:44 -0400 Subject: [PATCH 08/10] fix(#5435): read voice silence timeout at use time --- static/boot.js | 14 +++++----- tests/test_issue4761_voice_mode_config.py | 32 ++++++++++++----------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/static/boot.js b/static/boot.js index 19f44b4f3..529b12d68 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1221,12 +1221,12 @@ window._hermesTtsSynth=function(id, text, opts){ let _browserTtsWatchdog=null; let _browserTtsSuppressNextErrorRearm=false; // Configurable via localStorage keys (set from dev console or a future settings panel). - // hermes-voice-silence-ms — pause duration before auto-send (ms, default 1800) - // hermes-voice-continuous — keep mic open across natural pauses ("true"/"false", default false) - const _silenceMsRaw=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10); - // Fall back to 1800 for missing/NaN/non-positive values, and floor at 200ms so a - // mistyped tiny/negative value can't make the recognizer auto-send instantly. - const SILENCE_MS=(Number.isFinite(_silenceMsRaw)&&_silenceMsRaw>0)?Math.max(200,_silenceMsRaw):1800; + // hermes-voice-silence-ms, pause duration before auto-send (ms, default 1800) + // hermes-voice-continuous, keep mic open across natural pauses ("true"/"false", default false) + function _voiceSilenceMs(){ + const _silenceMsRaw=parseInt(localStorage.getItem('hermes-voice-silence-ms'),10); + return (Number.isFinite(_silenceMsRaw)&&_silenceMsRaw>0)?Math.max(200,_silenceMsRaw):1800; + } function _clearBrowserTtsRecovery(){ if(_browserTtsKeepAlive){ @@ -1311,7 +1311,7 @@ window._hermesTtsSynth=function(id, text, opts){ if(_finalText){ _silenceTimer=setTimeout(()=>{ _voiceModeSend(); - },SILENCE_MS); + },_voiceSilenceMs()); } }; diff --git a/tests/test_issue4761_voice_mode_config.py b/tests/test_issue4761_voice_mode_config.py index 6e20ddaab..c78cdf2aa 100644 --- a/tests/test_issue4761_voice_mode_config.py +++ b/tests/test_issue4761_voice_mode_config.py @@ -1,7 +1,7 @@ -"""Tests for #4761 — configurable voice-mode silence timeout and continuous recognition. +"""Tests for #4761, configurable voice-mode silence timeout and continuous recognition. -The voice-mode loop currently hardcodes: - - SILENCE_MS = 1800 (1.8s pause before auto-send) +The voice-mode loop used to hardcode: + - a startup silence timeout constant (1.8s pause before auto-send) - _recognition.continuous = false (mic closes after each utterance) This module pins the fix: both values are now configurable via localStorage keys @@ -19,7 +19,7 @@ def _boot_src() -> str: class TestVoiceModeSilenceMsConfig: - """SILENCE_MS must read from localStorage with 1800 fallback.""" + """The silence timeout must read from localStorage with 1800 fallback.""" def test_silence_ms_reads_local_storage_with_fallback(self): src = _boot_src() @@ -29,23 +29,25 @@ class TestVoiceModeSilenceMsConfig: assert re.search( r"parseInt\s*\(\s*localStorage\.getItem\s*\(\s*'hermes-voice-silence-ms'\s*\)", src, - ), "SILENCE_MS must read the 'hermes-voice-silence-ms' localStorage key via parseInt." + ), "Voice mode must read the 'hermes-voice-silence-ms' localStorage key via parseInt." # The 1800 default must remain the fallback for missing/invalid values. - assert re.search(r"SILENCE_MS\s*=.*\b1800\b", src), ( - "SILENCE_MS must keep 1800 as the default fallback so behavior is " + assert re.search(r"return\s*\(.*\)\?Math\.max\(200,_silenceMsRaw\):1800", src), ( + "Voice mode must keep 1800 as the default fallback so behavior is " "unchanged when the key is unset or invalid." ) - # A non-positive / mistyped value must not be honored verbatim (no instant - # auto-send): the value is guarded by a positivity check and/or a min floor. + # A non-positive or mistyped value must not be honored verbatim. assert "_silenceMsRaw>0" in src or "Math.max(" in src or "> 0" in src, ( - "SILENCE_MS must guard against non-positive values (positivity check " + "Voice mode must guard against non-positive values (positivity check " "or a Math.max floor) so a mistyped tiny/negative value can't make the " "recognizer auto-send instantly." ) - def test_silence_ms_used_in_timeout(self): + def test_silence_ms_read_at_timeout_use_time(self): src = _boot_src() - assert "SILENCE_MS" in src, "SILENCE_MS must still be referenced in the timeout call." + assert "_voiceSilenceMs()" in src, "The silence timeout must be read when scheduling auto-send." + assert re.search(r"setTimeout\s*\(\s*\(\)\s*=>\s*\{\s*_voiceModeSend\(\);\s*\}\s*,\s*_voiceSilenceMs\(\)\s*\)", src, re.S), ( + "Voice mode must call _voiceSilenceMs() inside the setTimeout path so mirrored server settings apply without reload." + ) class TestVoiceModeContinuousConfig: @@ -73,11 +75,11 @@ class TestVoiceModeContinuousConfig: class TestBootJsVoiceSectionIntegrity: - """Smoke checks — the surrounding voice-mode infrastructure is intact.""" + """Smoke checks, the surrounding voice-mode infrastructure is intact.""" - def test_voice_mode_declares_silence_ms(self): + def test_voice_mode_declares_silence_helper(self): src = _boot_src() - assert "SILENCE_MS" in src, "SILENCE_MS constant must exist in boot.js" + assert "function _voiceSilenceMs()" in src, "The voice silence timeout helper must exist in boot.js" def test_voice_mode_declares_recognition(self): src = _boot_src() From a9eb90a0d0784d2054a6eeb367a7b0a9ecdca5de Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 09:22:50 -0400 Subject: [PATCH 09/10] test(#5435): preserve raw speech settings between API cases --- tests/test_issue5435_tts_voice_preferences.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/test_issue5435_tts_voice_preferences.py b/tests/test_issue5435_tts_voice_preferences.py index 32a2f527c..20e0302cb 100644 --- a/tests/test_issue5435_tts_voice_preferences.py +++ b/tests/test_issue5435_tts_voice_preferences.py @@ -65,13 +65,6 @@ def _extract_balanced_block(src, marker): return src[start:end] -def _reset_speech_settings(extra=None): - payload = dict(SPEECH_DEFAULTS) - if extra: - payload.update(extra) - post("/api/settings", payload) - - def _settings_file_snapshot(): cfg = importlib.import_module("api.config") path = cfg.SETTINGS_FILE @@ -98,6 +91,7 @@ def test_settings_api_exposes_tts_voice_and_raw_audio_defaults(): def test_settings_api_round_trips_speech_preferences(): + path, original = _settings_file_snapshot() payload = { "tts_enabled": True, "tts_auto_read": True, @@ -132,7 +126,7 @@ def test_settings_api_round_trips_speech_preferences(): assert reloaded[key] == expected assert reloaded[PERSISTED_SPEECH_KEYS_FIELD] == sorted(payload) finally: - _reset_speech_settings() + _restore_settings_file(path, original) def test_settings_api_reports_only_raw_persisted_speech_keys(): @@ -222,6 +216,7 @@ def test_startup_workspace_repair_write_drops_merged_speech_defaults(): def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings(): + path, original = _settings_file_snapshot() data, status = get("/api/settings") original_show_tps = bool(data.get("show_tps")) valid = { @@ -253,7 +248,7 @@ def test_invalid_speech_settings_preserve_previous_values_and_unrelated_settings assert invalid[key] == value assert invalid["show_tps"] is (not original_show_tps) finally: - _reset_speech_settings({"show_tps": original_show_tps}) + _restore_settings_file(path, original) def test_backend_schema_contains_typed_speech_validation(): From eb59ac8fb38c5594cf21992806de1e65a1a975cd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 3 Jul 2026 20:14:49 +0000 Subject: [PATCH 10/10] docs(changelog): #5442 persist TTS/voice preferences server-side (#5435) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 091fc9851..e7f6f961a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- **Your text-to-speech and voice-mode preferences now follow you across devices and browsers.** TTS settings (enabled, auto-read, engine, voice, rate, pitch) and voice-mode settings (voice button, raw-audio mode, continuous listening, silence timeout) were stored only in the browser's localStorage, so they were lost on a new device, a cleared browser, or a fresh profile — and never matched what the server knew. They're now persisted server-side in your settings file and mirrored back to the client. Only preferences you've actually set are written (a never-configured key stays absent and falls back to its default rather than being pinned to a value), and no save path clobbers your other settings. The voice-mode silence timeout is read at the moment auto-send is scheduled, so a persisted value takes effect on first load without a reload. Thanks @rodboev. (#5442, #5435) - **A profile whose default model lives in a large provider's picker "overflow" list no longer fails validation.** When a provider has more models than the visible picker cap (15 entries), the extras are moved into an overflow bucket that still renders in the dropdown. Profile model validation only searched the visible list, so selecting one of those overflow models as a profile default was rejected with a spurious "not available for provider" error even though it appeared (and worked) in the picker. Validation now searches both the visible and overflow buckets, matching what the dropdown shows. Thanks @WallaceWebster. (#5453) - **Read-only sessions (cron runs, subagent views) no longer show a broken "fork from here" affordance.** Forking is only meaningful for sessions you own, but the fork/branch controls were shown on read-only cron and subagent sessions too — clicking them just produced a confusing "Session not found" error. Those controls are now hidden for read-only sessions on the frontend, and the branch endpoint refuses a read-only source with a clear `403` instead of a misleading `404`. Thanks @rodboev. (#5449, #5439) - **Inline Mermaid diagrams stay readable instead of collapsing to a sliver.** The inline diagram viewer shared the lightbox's fit-to-screen scale, so on mobile (and other short viewports) a diagram could shrink to an unreadable strip. Inline rendering now uses its own width-based sizing with a minimum height floor (and its own minimum scale), decoupled from the full-screen lightbox fit, so inline diagrams keep a usable size while the lightbox zoom/pan behavior is unchanged. Thanks @rodboev. (#5434, #5413)