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)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Piper voice library — browse + install voices from the HuggingFace catalog.
|
||||
|
||||
Piper voices live in https://huggingface.co/rhasspy/piper-voices, with a
|
||||
machine-readable catalog at:
|
||||
|
||||
https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json
|
||||
|
||||
That catalog enumerates every available voice (~250+ across many languages)
|
||||
with file sizes and download paths. We cache it in memory with a TTL so
|
||||
the Settings UI's "browse voices" panel doesn't hit HuggingFace every time
|
||||
the page opens, but a manual "refresh" button can force a re-fetch.
|
||||
|
||||
Downloads land in /data/voices so they persist across container restarts.
|
||||
Bundled voices in /opt/piper-voices are read-only and cannot be deleted
|
||||
via the admin UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_URL = (
|
||||
"https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json"
|
||||
)
|
||||
VOICE_BASE_URL = (
|
||||
"https://huggingface.co/rhasspy/piper-voices/resolve/main"
|
||||
)
|
||||
_USER_VOICES_DIR = Path("/data/voices")
|
||||
_BUNDLED_VOICES_DIR = Path("/opt/piper-voices")
|
||||
|
||||
# Voice IDs are like `en_US-amy-medium`, `fr_FR-siwis-low` — strict
|
||||
# allowlist to prevent path traversal in download/delete handlers.
|
||||
_VOICE_ID_RE = re.compile(r"^[a-zA-Z]{2,3}(?:_[a-zA-Z]{2,4})?-[a-zA-Z0-9_+]+-(?:x_low|low|medium|high)$")
|
||||
|
||||
# In-memory cache. Catalog rarely changes (kokoro-style "model update"
|
||||
# checks would be overkill — admin can force-refresh from the UI).
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
_CATALOG_FETCHED_AT: float = 0.0
|
||||
_CATALOG_TTL_SECS = 24 * 3600 # 24h — long, but the manual refresh button overrides
|
||||
_CATALOG_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
def _is_valid_voice_id(voice_id: str) -> bool:
|
||||
return bool(_VOICE_ID_RE.match(voice_id))
|
||||
|
||||
|
||||
async def fetch_catalog(force_refresh: bool = False) -> dict:
|
||||
"""Return the piper-voices catalog dict, fetching from HF if stale.
|
||||
|
||||
Cached in memory across requests. force_refresh=True bypasses the TTL.
|
||||
Raises on network error so the UI can surface the failure.
|
||||
"""
|
||||
global _CATALOG_CACHE, _CATALOG_FETCHED_AT
|
||||
async with _CATALOG_LOCK:
|
||||
now = time.monotonic()
|
||||
fresh_enough = (
|
||||
_CATALOG_CACHE is not None
|
||||
and (now - _CATALOG_FETCHED_AT) < _CATALOG_TTL_SECS
|
||||
)
|
||||
if fresh_enough and not force_refresh:
|
||||
return _CATALOG_CACHE # type: ignore[return-value]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _do_fetch() -> dict:
|
||||
with urllib.request.urlopen(CATALOG_URL, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
try:
|
||||
catalog = await loop.run_in_executor(None, _do_fetch)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch piper voices catalog from %s", CATALOG_URL)
|
||||
raise
|
||||
|
||||
_CATALOG_CACHE = catalog
|
||||
_CATALOG_FETCHED_AT = now
|
||||
logger.info("Piper voices catalog refreshed: %d entries", len(catalog))
|
||||
return catalog
|
||||
|
||||
|
||||
def _is_installed(voice_id: str) -> tuple[bool, str | None]:
|
||||
"""Return (installed, source) where source is 'bundled' / 'user' / None."""
|
||||
if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file():
|
||||
return True, "bundled"
|
||||
if (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file():
|
||||
return True, "user"
|
||||
return False, None
|
||||
|
||||
|
||||
def shape_catalog_for_ui(catalog: dict) -> list[dict]:
|
||||
"""Project the raw HF catalog into a list of UI-friendly voice cards.
|
||||
|
||||
The HF catalog uses voice IDs as keys with nested file-size metadata.
|
||||
The UI just needs id, language, dataset, quality, size, install state.
|
||||
Sorted by language then dataset for stable display.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for voice_id, info in catalog.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
installed, source = _is_installed(voice_id)
|
||||
lang_info = info.get("language") or {}
|
||||
files = info.get("files") or {}
|
||||
# Sum the file sizes for the .onnx + .onnx.json pair so the UI
|
||||
# can show "≈ 62 MB" before the user clicks Download.
|
||||
total_bytes = 0
|
||||
for f_info in files.values():
|
||||
if isinstance(f_info, dict):
|
||||
total_bytes += int(f_info.get("size_bytes", 0) or 0)
|
||||
out.append({
|
||||
"id": voice_id,
|
||||
"name": info.get("name") or voice_id.split("-")[1],
|
||||
"language_code": lang_info.get("code") or voice_id.split("-")[0],
|
||||
"language_name": lang_info.get("name_native")
|
||||
or lang_info.get("name_english")
|
||||
or lang_info.get("code", ""),
|
||||
"country": lang_info.get("country_english") or lang_info.get("region", ""),
|
||||
"quality": info.get("quality") or voice_id.split("-")[-1],
|
||||
"num_speakers": int(info.get("num_speakers") or 1),
|
||||
"size_bytes": total_bytes,
|
||||
"installed": installed,
|
||||
"installed_source": source,
|
||||
})
|
||||
out.sort(key=lambda v: (v["language_code"], v["name"], v["quality"]))
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_file_urls(catalog: dict, voice_id: str) -> dict[str, str]:
|
||||
"""Return absolute URLs for the .onnx + .onnx.json files of `voice_id`.
|
||||
|
||||
Walks the catalog entry's `files` dict; that's the authoritative path
|
||||
list (the layout in the repo can drift, e.g. some voices live under
|
||||
different sub-paths). Returns {extension: url}.
|
||||
"""
|
||||
info = catalog.get(voice_id)
|
||||
if not isinstance(info, dict):
|
||||
raise KeyError(f"Voice {voice_id!r} not in catalog")
|
||||
files = info.get("files") or {}
|
||||
urls: dict[str, str] = {}
|
||||
for rel_path in files.keys():
|
||||
if rel_path.endswith(".onnx"):
|
||||
urls["onnx"] = f"{VOICE_BASE_URL}/{rel_path}"
|
||||
elif rel_path.endswith(".onnx.json"):
|
||||
urls["onnx.json"] = f"{VOICE_BASE_URL}/{rel_path}"
|
||||
if "onnx" not in urls or "onnx.json" not in urls:
|
||||
raise ValueError(
|
||||
f"Catalog entry for {voice_id!r} is missing .onnx or .onnx.json file"
|
||||
)
|
||||
return urls
|
||||
|
||||
|
||||
async def install_voice(voice_id: str) -> dict:
|
||||
"""Download a voice's .onnx + .onnx.json into /data/voices.
|
||||
|
||||
Idempotent — if already installed under /data, returns success without
|
||||
re-downloading. Bundled voices in /opt are not touched; the same voice
|
||||
can also live under /data if the admin wants to override it.
|
||||
Returns {"id", "size_bytes", "skipped"}.
|
||||
"""
|
||||
if not _is_valid_voice_id(voice_id):
|
||||
raise ValueError(f"Invalid voice id: {voice_id!r}")
|
||||
|
||||
_USER_VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
onnx_path = _USER_VOICES_DIR / f"{voice_id}.onnx"
|
||||
sidecar_path = _USER_VOICES_DIR / f"{voice_id}.onnx.json"
|
||||
|
||||
if onnx_path.is_file() and sidecar_path.is_file():
|
||||
return {
|
||||
"id": voice_id,
|
||||
"size_bytes": onnx_path.stat().st_size + sidecar_path.stat().st_size,
|
||||
"skipped": True,
|
||||
}
|
||||
|
||||
catalog = await fetch_catalog()
|
||||
urls = _resolve_file_urls(catalog, voice_id)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _download() -> int:
|
||||
total = 0
|
||||
# Use atomic write — download to .tmp then rename. Means a failed
|
||||
# partial download doesn't leave a corrupt model in place that
|
||||
# would later cause PiperVoice.load() to throw.
|
||||
for ext, url in urls.items():
|
||||
target = _USER_VOICES_DIR / f"{voice_id}.{ext}"
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
logger.info("Downloading piper voice file %s → %s", url, target)
|
||||
with urllib.request.urlopen(url, timeout=120) as resp:
|
||||
with open(tmp, "wb") as fh:
|
||||
shutil.copyfileobj(resp, fh, length=1024 * 1024)
|
||||
tmp.replace(target)
|
||||
total += target.stat().st_size
|
||||
return total
|
||||
|
||||
try:
|
||||
size = await loop.run_in_executor(None, _download)
|
||||
except Exception:
|
||||
# Clean up partials.
|
||||
for ext in ("onnx", "onnx.json"):
|
||||
for p in (
|
||||
_USER_VOICES_DIR / f"{voice_id}.{ext}",
|
||||
_USER_VOICES_DIR / f"{voice_id}.{ext}.tmp",
|
||||
):
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("Voice install failed for %s — cleaned up partials", voice_id)
|
||||
raise
|
||||
|
||||
logger.info("Installed piper voice %s (%d bytes)", voice_id, size)
|
||||
return {"id": voice_id, "size_bytes": size, "skipped": False}
|
||||
|
||||
|
||||
async def uninstall_voice(voice_id: str) -> dict:
|
||||
"""Remove a /data voice. Bundled voices in /opt cannot be removed via API.
|
||||
|
||||
Returns {"id", "removed": bool}.
|
||||
"""
|
||||
if not _is_valid_voice_id(voice_id):
|
||||
raise ValueError(f"Invalid voice id: {voice_id!r}")
|
||||
|
||||
# Refuse to delete a bundled voice. If a /data override exists alongside
|
||||
# a bundled one with the same id, only the /data copy is removed.
|
||||
if not (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file() and not (
|
||||
_USER_VOICES_DIR / f"{voice_id}.onnx.json"
|
||||
).is_file():
|
||||
if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file():
|
||||
raise PermissionError(
|
||||
f"Voice {voice_id!r} is bundled with the image — cannot be removed"
|
||||
)
|
||||
return {"id": voice_id, "removed": False}
|
||||
|
||||
removed_any = False
|
||||
for ext in ("onnx", "onnx.json"):
|
||||
p = _USER_VOICES_DIR / f"{voice_id}.{ext}"
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
removed_any = True
|
||||
logger.info("Uninstalled piper voice %s (files removed: %s)", voice_id, removed_any)
|
||||
return {"id": voice_id, "removed": removed_any}
|
||||
Reference in New Issue
Block a user