feat(voice): admin UI to browse + install piper voices from HuggingFace
Building on the kokoro→piper swap (B1), this adds the admin-side voice management story so additional voices can be installed without rebuilding the image. The bundled two voices stay as immediate defaults; everything else is opt-in via a one-click install from the catalog. Backend (services/voice_library.py): - fetch_catalog() pulls voices.json from the piper-voices HF repo with a 24h in-memory TTL. Manual refresh available via ?refresh=1 on the library endpoint. - shape_catalog_for_ui() projects the raw HF dict (~250 voices, lots of nesting) into UI-friendly cards: id, name, language, country, quality, size, install state. Sorted by language_code then name for stable display. Install state distinguishes bundled (read-only) from user (admin-installed, can be removed). - install_voice() downloads .onnx + .onnx.json into /data/voices with atomic .tmp → rename so a failed partial download can't leave a corrupt model around. Idempotent — re-installing an already-present voice is a no-op. - uninstall_voice() removes /data voices; bundled /opt voices raise PermissionError (403 at the route layer). - Strict voice-id regex prevents path traversal in install/uninstall. Routes (admin-only, since these write to shared /data and affect all users on the instance): - GET /api/voice/voices/library - POST /api/voice/voices/install - DELETE /api/voice/voices/<voice_id> Frontend: - New "Voice Library" section in Settings → Voice, visible only to admin users. Collapsed by default; expand to load the catalog on-demand (doesn't hammer HF for non-admins). - Free-text filter across id, language code, language name, country, and dataset name. Refresh button forces a catalog re-fetch. - Per-voice row shows id, language/country/quality/speaker count, size, and either an Install button, a Remove button (user voices), or a "bundled" badge (read-only voices in /opt/piper-voices). - Installs and uninstalls refresh both the library list AND the active voice picker so the new voice is immediately selectable. - VoiceLibraryEntry exported from api/client.ts; new client helpers getVoiceLibrary/installVoice/uninstallVoice. Tests: - Pure-transformation unit tests for shape_catalog_for_ui, _resolve_file_urls, and the voice-id regex (path-traversal coverage). - DB/network paths (fetch_catalog, install_voice) need a real environment — left to CI integration tests or device verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import time
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.auth import admin_required, login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -181,3 +181,78 @@ async def synthesise_speech():
|
||||
|
||||
from quart import Response
|
||||
return Response(wav_bytes, mimetype="audio/wav")
|
||||
|
||||
|
||||
# ── Voice library (admin only) ──────────────────────────────────────────────
|
||||
# Browse the piper-voices catalog, download new voices into /data/voices,
|
||||
# remove user-installed voices. Bundled voices in /opt/piper-voices are
|
||||
# read-only and cannot be touched via these endpoints.
|
||||
# These are admin-only because installs consume shared disk and affect
|
||||
# every user on the instance (voices are picked per-user, but the files
|
||||
# themselves are shared).
|
||||
|
||||
|
||||
@voice_bp.route("/voices/library", methods=["GET"])
|
||||
@admin_required
|
||||
async def voice_library():
|
||||
"""Return the piper-voices catalog with install state annotations.
|
||||
|
||||
Query params:
|
||||
?refresh=1 — force a fresh fetch from HuggingFace (bypass the 24h
|
||||
in-memory cache). Use sparingly; HF doesn't appreciate hammering.
|
||||
"""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
force = (request.args.get("refresh") or "").lower() in ("1", "true", "yes")
|
||||
try:
|
||||
catalog = await lib.fetch_catalog(force_refresh=force)
|
||||
except Exception:
|
||||
logger.exception("Voice catalog fetch failed")
|
||||
return jsonify({"error": "Failed to fetch voice catalog"}), 502
|
||||
voices = lib.shape_catalog_for_ui(catalog)
|
||||
return jsonify({"voices": voices, "count": len(voices)})
|
||||
|
||||
|
||||
@voice_bp.route("/voices/install", methods=["POST"])
|
||||
@admin_required
|
||||
async def install_voice_route():
|
||||
"""Download a voice into /data/voices.
|
||||
|
||||
Body: {"voice_id": "en_US-amy-medium"}
|
||||
Idempotent — already-installed voices return {"skipped": true} without
|
||||
re-downloading.
|
||||
"""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
data = await request.get_json()
|
||||
voice_id = str((data or {}).get("voice_id") or "").strip()
|
||||
if not voice_id:
|
||||
return jsonify({"error": "voice_id is required"}), 400
|
||||
try:
|
||||
result = await lib.install_voice(voice_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except KeyError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
except Exception:
|
||||
logger.exception("Voice install failed: %s", voice_id)
|
||||
return jsonify({"error": "Voice install failed"}), 500
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@voice_bp.route("/voices/<voice_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def uninstall_voice_route(voice_id: str):
|
||||
"""Remove a /data/voices voice. Bundled voices return 403."""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
try:
|
||||
result = await lib.uninstall_voice(voice_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except PermissionError as e:
|
||||
return jsonify({"error": str(e)}), 403
|
||||
except Exception:
|
||||
logger.exception("Voice uninstall failed: %s", voice_id)
|
||||
return jsonify({"error": "Voice uninstall failed"}), 500
|
||||
return jsonify(result)
|
||||
|
||||
Reference in New Issue
Block a user