fix(#5435): persist TTS and voice preferences server-side
This commit is contained in:
1 parent
7ee6a12e44
commit
91ae1dede9
4 files changed
+395
-15
No files matched your search
@@ -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)
|
||||
|
||||
+79
-3
@@ -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){
|
||||
|
||||
+78
-12
@@ -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='<option value="">Hermy — ElevenLabs (server-configured)</option>';
|
||||
} 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});}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user