feat: move voice enable/model config to admin UI

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:22:18 -04:00
parent 6f84d90dff
commit eaf70500b8
7 changed files with 163 additions and 28 deletions
+8 -7
View File
@@ -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)
+3 -3
View File
@@ -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:
@@ -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)