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:
2026-05-22 08:18:22 -04:00
parent 4a9d8eaa2d
commit 39ab5d69a9
5 changed files with 751 additions and 2 deletions
+76 -1
View File
@@ -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)