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