From eaf70500b8421435efe44cc291a2fd96c7ab125b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 20:22:18 -0400 Subject: [PATCH] feat: move voice enable/model config to admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace VOICE_ENABLED env var gate with DB-backed admin setting. - services/voice_config.py: reads voice_enabled + voice_stt_model from admin user's settings row (falls back to env var defaults) - routes/admin.py: GET/PUT /api/admin/voice for admin configuration - routes/voice.py, services/stt.py, services/tts.py: read enabled/model from DB via voice_config instead of Config directly - app.py: always schedule model loaders at startup; they self-gate on the DB setting so no conditional needed at the call site - SettingsView.vue: Voice section in Admin → Config tab (enable toggle + STT model dropdown); user Voice tab now points to admin panel when disabled No env var required to test — enable via Settings → Admin → Config → Voice. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 67 +++++++++++++++++++- src/fabledassistant/app.py | 11 ++-- src/fabledassistant/routes/admin.py | 28 ++++++++ src/fabledassistant/routes/voice.py | 24 ++++--- src/fabledassistant/services/stt.py | 15 +++-- src/fabledassistant/services/tts.py | 6 +- src/fabledassistant/services/voice_config.py | 40 ++++++++++++ 7 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 src/fabledassistant/services/voice_config.py diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index fa4a011..10df3ca 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -445,6 +445,29 @@ const baseUrl = ref(""); const savingBaseUrl = ref(false); const baseUrlSaved = ref(false); +// Voice config (admin only) +const adminVoiceEnabled = ref(false); +const adminVoiceSttModel = ref("base.en"); +const savingAdminVoice = ref(false); +const adminVoiceSaved = ref(false); + +async function saveAdminVoice() { + savingAdminVoice.value = true; + adminVoiceSaved.value = false; + try { + await apiPut("/api/admin/voice", { + voice_enabled: adminVoiceEnabled.value, + voice_stt_model: adminVoiceSttModel.value, + }); + adminVoiceSaved.value = true; + setTimeout(() => { adminVoiceSaved.value = false; }, 2000); + // Refresh the user-facing voice status after toggling + voiceTabLoaded.value = false; + } finally { + savingAdminVoice.value = false; + } +} + // Search test (SearXNG) const searxngConfigured = ref(false); const searxngUrl = ref(""); @@ -565,6 +588,13 @@ onMounted(async () => { } catch { // base URL not configured yet } + try { + const vc = await apiGet>("/api/admin/voice"); + adminVoiceEnabled.value = vc.voice_enabled === "true"; + adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en"; + } catch { + // voice not configured yet + } } _loadTabContent(activeTab.value); }); @@ -1888,9 +1918,8 @@ function formatUserDate(iso: string): string {
- Set VOICE_ENABLED=true in your server environment to activate voice features. - Optionally set STT_MODEL (tiny.en / base.en / small.en / medium.en) and - install pip install fabledassistant[voice]. + Voice is disabled. An administrator can enable it under + Admin → Config → Voice.
@@ -2146,6 +2175,38 @@ FABLE_API_KEY=<your-api-key> +
+

Voice (Speech-to-Speech)

+

+ Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS). + Models load at startup — a server restart is required after changing these settings. + Install dependencies first: pip install faster-whisper kokoro soundfile +

+
+ +

Shows the PTT button and voice settings to all users.

+
+
+ + +

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

+
+
+ + Restart the server to apply model changes. +
+
+ diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 3d9062b..8ab854f 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -266,12 +266,11 @@ def create_app() -> Quart: from fabledassistant.services.briefing_scheduler import start_briefing_scheduler await start_briefing_scheduler(asyncio.get_running_loop()) - # Voice model loading (only when feature is enabled) - if Config.VOICE_ENABLED: - from fabledassistant.services.stt import load_stt_model - from fabledassistant.services.tts import load_tts_model - asyncio.create_task(load_stt_model()) - asyncio.create_task(load_tts_model()) + # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var) + from fabledassistant.services.stt import load_stt_model + from fabledassistant.services.tts import load_tts_model + asyncio.create_task(load_stt_model()) + asyncio.create_task(load_tts_model()) @app.after_serving async def shutdown(): diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index cdd23fc..abd20cd 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -19,6 +19,7 @@ from fabledassistant.services.backup import ( restore_full_backup, ) from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email +from fabledassistant.services.voice_config import get_voice_config from fabledassistant.services.logging import get_logs, get_log_stats, log_audit from fabledassistant.services.notifications import send_invitation_email from fabledassistant.services.settings import set_setting, set_settings_batch @@ -200,6 +201,33 @@ async def update_base_url(): return jsonify({"status": "ok"}) +@admin_bp.route("/voice", methods=["GET"]) +@admin_required +async def get_voice_config_route(): + config = await get_voice_config() + return jsonify(config) + + +@admin_bp.route("/voice", methods=["PUT"]) +@admin_required +async def update_voice_config(): + data = await request.get_json() + uid = get_current_user_id() + valid_models = {"tiny.en", "base.en", "small.en", "medium.en"} + settings: dict[str, str] = {} + if "voice_enabled" in data: + settings["voice_enabled"] = "true" if data["voice_enabled"] else "false" + if "voice_stt_model" in data: + model = str(data["voice_stt_model"]) + if model not in valid_models: + return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400 + settings["voice_stt_model"] = model + if settings: + await set_settings_batch(uid, settings) + await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings) + return jsonify({"status": "ok"}) + + @admin_bp.route("/invitations", methods=["POST"]) @admin_required async def create_invite(): diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py index 10e18b5..377f6a6 100644 --- a/src/fabledassistant/routes/voice.py +++ b/src/fabledassistant/routes/voice.py @@ -5,7 +5,6 @@ import time from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required -from fabledassistant.config import Config logger = logging.getLogger(__name__) @@ -16,18 +15,22 @@ voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice") @login_required async def voice_status(): """Return availability of STT and TTS services.""" - if not Config.VOICE_ENABLED: - return jsonify({"enabled": False, "stt": False, "tts": False}) - + from fabledassistant.services.voice_config import get_voice_config from fabledassistant.services.stt import stt_available from fabledassistant.services.tts import tts_available + config = await get_voice_config() + enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes") + + if not enabled: + return jsonify({"enabled": False, "stt": False, "tts": False}) + return jsonify({ "enabled": True, "stt": stt_available(), "tts": tts_available(), - "stt_model": Config.STT_MODEL, - "tts_backend": Config.TTS_BACKEND, + "stt_model": config.get("voice_stt_model", "base.en"), + "tts_backend": "kokoro", }) @@ -35,7 +38,8 @@ async def voice_status(): @login_required async def list_voices(): """Return available Kokoro voice IDs and labels.""" - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.tts import list_voices, tts_available @@ -54,7 +58,8 @@ async def transcribe_audio(): Request: multipart/form-data with field 'audio' (WebM/Opus blob) Response: {"transcript": "...", "duration_ms": 123} """ - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.stt import stt_available, transcribe @@ -95,7 +100,8 @@ async def synthesise_speech(): Request body: {"text": "...", "voice": "af_heart", "speed": 1.0} Response: audio/wav bytes """ - if not Config.VOICE_ENABLED: + from fabledassistant.services.voice_config import is_voice_enabled + if not await is_voice_enabled(): return jsonify({"error": "Voice feature is disabled"}), 503 from fabledassistant.services.tts import synthesise, tts_available diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py index 38283be..91969c9 100644 --- a/src/fabledassistant/services/stt.py +++ b/src/fabledassistant/services/stt.py @@ -16,11 +16,11 @@ _load_error: str | None = None async def load_stt_model() -> None: - """Load the Whisper model. Called once at startup when VOICE_ENABLED=true.""" + """Load the Whisper model. Called once at startup when voice is enabled.""" global _model, _load_error - from fabledassistant.config import Config + from fabledassistant.services.voice_config import get_stt_model, is_voice_enabled - if not Config.VOICE_ENABLED: + if not await is_voice_enabled(): return async with _model_lock: @@ -29,15 +29,16 @@ async def load_stt_model() -> None: try: from faster_whisper import WhisperModel - logger.info("Loading Whisper STT model '%s'...", Config.STT_MODEL) + model_name = await get_stt_model() + logger.info("Loading Whisper STT model '%s'...", model_name) loop = asyncio.get_running_loop() _model = await loop.run_in_executor( None, - lambda: WhisperModel(Config.STT_MODEL, device="cpu", compute_type="int8"), + lambda: WhisperModel(model_name, device="cpu", compute_type="int8"), ) - logger.info("Whisper STT model '%s' loaded", Config.STT_MODEL) + logger.info("Whisper STT model '%s' loaded", model_name) except Exception: - _load_error = f"Failed to load Whisper model '{Config.STT_MODEL}'" + _load_error = "Failed to load Whisper STT model" logger.exception(_load_error) diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py index 3a20a4a..5f7b2af 100644 --- a/src/fabledassistant/services/tts.py +++ b/src/fabledassistant/services/tts.py @@ -31,11 +31,11 @@ _VOICES: list[dict] = [ async def load_tts_model() -> None: - """Load the Kokoro pipeline. Called once at startup when VOICE_ENABLED=true.""" + """Load the Kokoro pipeline. Called once at startup when voice is enabled.""" global _pipeline, _load_error - from fabledassistant.config import Config + from fabledassistant.services.voice_config import is_voice_enabled - if not Config.VOICE_ENABLED: + if not await is_voice_enabled(): return async with _pipeline_lock: diff --git a/src/fabledassistant/services/voice_config.py b/src/fabledassistant/services/voice_config.py new file mode 100644 index 0000000..546a792 --- /dev/null +++ b/src/fabledassistant/services/voice_config.py @@ -0,0 +1,40 @@ +"""System-wide voice configuration stored in the admin user's settings row. + +Falls back to Config env vars so operators can still seed defaults via the +environment without touching the UI. +""" +from sqlalchemy import select + +from fabledassistant.config import Config +from fabledassistant.models import async_session +from fabledassistant.models.setting import Setting +from fabledassistant.models.user import User + +VOICE_SETTING_KEYS = ("voice_enabled", "voice_stt_model") + + +async def get_voice_config() -> dict[str, str]: + """Return voice config, preferring DB values over env-var defaults.""" + config: dict[str, str] = { + "voice_enabled": "true" if Config.VOICE_ENABLED else "false", + "voice_stt_model": Config.STT_MODEL, + } + async with async_session() as session: + result = await session.execute( + select(Setting) + .join(User, Setting.user_id == User.id) + .where(User.role == "admin", Setting.key.in_(VOICE_SETTING_KEYS)) + ) + for setting in result.scalars().all(): + config[setting.key] = setting.value + return config + + +async def is_voice_enabled() -> bool: + config = await get_voice_config() + return config.get("voice_enabled", "false").lower() in ("1", "true", "yes") + + +async def get_stt_model() -> str: + config = await get_voice_config() + return config.get("voice_stt_model", Config.STT_MODEL)