From ca31bf969bd7b205409137846494e60a4e9e648d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 2 Jul 2026 16:49:45 -0400 Subject: [PATCH] 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 abffc23f..89de4e4b 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 093672a8..f84b68f6 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 fc3a3ca0..4196950e 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