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 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<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);
});
@@ -1888,9 +1918,8 @@ function formatUserDate(iso: string): string {
</div>
<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.
Optionally set <code>STT_MODEL</code> (tiny.en / base.en / small.en / medium.en) and
install <code>pip install fabledassistant[voice]</code>.
Voice is disabled. An administrator can enable it under
<strong>Admin → Config → Voice</strong>.
</div>
</section>
@@ -2146,6 +2175,38 @@ FABLE_API_KEY=&lt;your-api-key&gt;</pre>
</div>
</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>
<!-- ── Users ── -->
+5 -6
View File
@@ -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():
+28
View File
@@ -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():
+15 -9
View File
@@ -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
+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)