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
+64 -3
View File
@@ -445,6 +445,29 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false); const savingBaseUrl = ref(false);
const baseUrlSaved = 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) // Search test (SearXNG)
const searxngConfigured = ref(false); const searxngConfigured = ref(false);
const searxngUrl = ref(""); const searxngUrl = ref("");
@@ -565,6 +588,13 @@ onMounted(async () => {
} catch { } catch {
// base URL not configured yet // base URL not configured yet
} }
try {
const vc = await apiGet<Record<string, string>>("/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); _loadTabContent(activeTab.value);
}); });
@@ -1888,9 +1918,8 @@ function formatUserDate(iso: string): string {
</div> </div>
<div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;"> <div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;">
Set <code>VOICE_ENABLED=true</code> in your server environment to activate voice features. Voice is disabled. An administrator can enable it under
Optionally set <code>STT_MODEL</code> (tiny.en / base.en / small.en / medium.en) and <strong>Admin → Config → Voice</strong>.
install <code>pip install fabledassistant[voice]</code>.
</div> </div>
</section> </section>
@@ -2146,6 +2175,38 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
</div> </div>
</section> </section>
<section class="settings-section full-width">
<h2>Voice (Speech-to-Speech)</h2>
<p class="section-desc">
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: <code>pip install faster-whisper kokoro soundfile</code>
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="adminVoiceEnabled" />
Enable Voice
</label>
<p class="field-hint">Shows the PTT button and voice settings to all users.</p>
</div>
<div v-if="adminVoiceEnabled" class="field" style="max-width:280px; margin-top:0.75rem;">
<label for="stt-model">STT Model</label>
<select id="stt-model" v-model="adminVoiceSttModel" class="input">
<option value="tiny.en">tiny.en — fastest, lowest accuracy (~75 MB)</option>
<option value="base.en">base.en — balanced (recommended, ~145 MB)</option>
<option value="small.en">small.en — better accuracy (~465 MB)</option>
<option value="medium.en">medium.en — best accuracy (~1.5 GB)</option>
</select>
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
</div>
<div class="actions" style="margin-top:0.85rem;">
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice">
{{ adminVoiceSaved ? 'Saved ' : savingAdminVoice ? 'Saving' : 'Save' }}
</button>
<span v-if="adminVoiceSaved" class="field-hint">Restart the server to apply model changes.</span>
</div>
</section>
</div> </div>
<!-- ── Users ── --> <!-- ── Users ── -->
+5 -6
View File
@@ -266,12 +266,11 @@ def create_app() -> Quart:
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
await start_briefing_scheduler(asyncio.get_running_loop()) await start_briefing_scheduler(asyncio.get_running_loop())
# Voice model loading (only when feature is enabled) # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
if Config.VOICE_ENABLED: from fabledassistant.services.stt import load_stt_model
from fabledassistant.services.stt import load_stt_model from fabledassistant.services.tts import load_tts_model
from fabledassistant.services.tts import load_tts_model asyncio.create_task(load_stt_model())
asyncio.create_task(load_stt_model()) asyncio.create_task(load_tts_model())
asyncio.create_task(load_tts_model())
@app.after_serving @app.after_serving
async def shutdown(): async def shutdown():
+28
View File
@@ -19,6 +19,7 @@ from fabledassistant.services.backup import (
restore_full_backup, 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.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.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.notifications import send_invitation_email from fabledassistant.services.notifications import send_invitation_email
from fabledassistant.services.settings import set_setting, set_settings_batch from fabledassistant.services.settings import set_setting, set_settings_batch
@@ -200,6 +201,33 @@ async def update_base_url():
return jsonify({"status": "ok"}) 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_bp.route("/invitations", methods=["POST"])
@admin_required @admin_required
async def create_invite(): async def create_invite():
+15 -9
View File
@@ -5,7 +5,6 @@ import time
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required from fabledassistant.auth import login_required
from fabledassistant.config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,18 +15,22 @@ voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice")
@login_required @login_required
async def voice_status(): async def voice_status():
"""Return availability of STT and TTS services.""" """Return availability of STT and TTS services."""
if not Config.VOICE_ENABLED: from fabledassistant.services.voice_config import get_voice_config
return jsonify({"enabled": False, "stt": False, "tts": False})
from fabledassistant.services.stt import stt_available from fabledassistant.services.stt import stt_available
from fabledassistant.services.tts import tts_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({ return jsonify({
"enabled": True, "enabled": True,
"stt": stt_available(), "stt": stt_available(),
"tts": tts_available(), "tts": tts_available(),
"stt_model": Config.STT_MODEL, "stt_model": config.get("voice_stt_model", "base.en"),
"tts_backend": Config.TTS_BACKEND, "tts_backend": "kokoro",
}) })
@@ -35,7 +38,8 @@ async def voice_status():
@login_required @login_required
async def list_voices(): async def list_voices():
"""Return available Kokoro voice IDs and labels.""" """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 return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import list_voices, tts_available 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) Request: multipart/form-data with field 'audio' (WebM/Opus blob)
Response: {"transcript": "...", "duration_ms": 123} 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 return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.stt import stt_available, transcribe 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} Request body: {"text": "...", "voice": "af_heart", "speed": 1.0}
Response: audio/wav bytes 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 return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import synthesise, tts_available from fabledassistant.services.tts import synthesise, tts_available
+8 -7
View File
@@ -16,11 +16,11 @@ _load_error: str | None = None
async def load_stt_model() -> 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 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 return
async with _model_lock: async with _model_lock:
@@ -29,15 +29,16 @@ async def load_stt_model() -> None:
try: try:
from faster_whisper import WhisperModel 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() loop = asyncio.get_running_loop()
_model = await loop.run_in_executor( _model = await loop.run_in_executor(
None, 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: 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) logger.exception(_load_error)
+3 -3
View File
@@ -31,11 +31,11 @@ _VOICES: list[dict] = [
async def load_tts_model() -> None: 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 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 return
async with _pipeline_lock: 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)