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:
@@ -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=<your-api-key></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 ── -->
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user