From f146485df3818b2dd1f09a1e3a718c1cc02f153a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 20:52:07 -0400 Subject: [PATCH] feat: hot-reload voice models without server restart Voice enabled/STT model are now DB-backed (admin settings), not env vars. Added reload_stt_model()/reload_tts_model() that clear singletons under lock and re-trigger loading. POST /api/admin/voice/reload triggers both in background tasks. Settings UI polls /api/voice/status every 2.5s until models are ready, with spinner feedback. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 +- frontend/src/views/SettingsView.vue | 69 +++++++++++++++++++++++++++-- src/fabledassistant/routes/admin.py | 11 +++++ src/fabledassistant/services/stt.py | 9 ++++ src/fabledassistant/services/tts.py | 9 ++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5cf9bb6..7be1606 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" + OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" # Uncomment and set to enable web research and image search via SearXNG: # SEARXNG_URL: "http://searxng:8080" diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 10df3ca..68059bc 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -450,6 +450,7 @@ const adminVoiceEnabled = ref(false); const adminVoiceSttModel = ref("base.en"); const savingAdminVoice = ref(false); const adminVoiceSaved = ref(false); +const voiceLoadingModels = ref(false); async function saveAdminVoice() { savingAdminVoice.value = true; @@ -461,13 +462,39 @@ async function saveAdminVoice() { }); adminVoiceSaved.value = true; setTimeout(() => { adminVoiceSaved.value = false; }, 2000); - // Refresh the user-facing voice status after toggling voiceTabLoaded.value = false; + if (adminVoiceEnabled.value) { + await reloadVoiceModels(); + } } finally { savingAdminVoice.value = false; } } +async function reloadVoiceModels() { + voiceLoadingModels.value = true; + try { + await apiPost("/api/admin/voice/reload", {}); + // Poll /api/voice/status until both STT and TTS are ready (max 3 min) + const deadline = Date.now() + 3 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2500)); + try { + const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status"); + if (status.stt && status.tts) { + voiceStatus.value = status; + voiceTabLoaded.value = true; + break; + } + } catch { /* keep polling */ } + } + } catch { + toastStore.show("Failed to trigger model reload", "error"); + } finally { + voiceLoadingModels.value = false; + } +} + // Search test (SearXNG) const searxngConfigured = ref(false); const searxngUrl = ref(""); @@ -2200,10 +2227,22 @@ FABLE_API_KEY=<your-api-key>

Larger models are more accurate but use more RAM and take longer to load.

- - Restart the server to apply model changes. + + + Loading models… this may take a minute. + + + +
@@ -3793,4 +3832,26 @@ FABLE_API_KEY=<your-api-key> .form-actions { margin-top: 1rem; } + +.voice-admin-spinner { + display: inline-flex; + gap: 3px; + align-items: center; + margin-left: 0.4rem; + vertical-align: middle; +} +.voice-admin-spinner span { + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-text-muted); + animation: va-dot-bounce 1.2s ease-in-out infinite; +} +.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; } +.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; } +@keyframes va-dot-bounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index abd20cd..e891ae4 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -228,6 +228,17 @@ async def update_voice_config(): return jsonify({"status": "ok"}) +@admin_bp.route("/voice/reload", methods=["POST"]) +@admin_required +async def reload_voice_models(): + """Reload STT and TTS models in the background without a server restart.""" + from fabledassistant.services.stt import reload_stt_model + from fabledassistant.services.tts import reload_tts_model + asyncio.create_task(reload_stt_model()) + asyncio.create_task(reload_tts_model()) + return jsonify({"status": "loading"}) + + @admin_bp.route("/invitations", methods=["POST"]) @admin_required async def create_invite(): diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py index 91969c9..2de2f41 100644 --- a/src/fabledassistant/services/stt.py +++ b/src/fabledassistant/services/stt.py @@ -42,6 +42,15 @@ async def load_stt_model() -> None: logger.exception(_load_error) +async def reload_stt_model() -> None: + """Unload the current model and reload with the current config. Safe to call at runtime.""" + global _model, _load_error + async with _model_lock: + _model = None + _load_error = None + await load_stt_model() + + def stt_available() -> bool: return _model is not None diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py index 5f7b2af..cf5495c 100644 --- a/src/fabledassistant/services/tts.py +++ b/src/fabledassistant/services/tts.py @@ -56,6 +56,15 @@ async def load_tts_model() -> None: logger.exception(_load_error) +async def reload_tts_model() -> None: + """Unload and reload the TTS pipeline. Safe to call at runtime.""" + global _pipeline, _load_error + async with _pipeline_lock: + _pipeline = None + _load_error = None + await load_tts_model() + + def tts_available() -> bool: return _pipeline is not None