Files
FabledScribe/tests/test_voice_library.py
T
bvandeusen 39ab5d69a9 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>
2026-05-22 08:18:22 -04:00

154 lines
5.2 KiB
Python

"""Unit tests for the voice library catalog shaper + ID validation.
DB- and network-touching paths (fetch_catalog, install_voice) need a
real environment, so we test only the pure transformations here:
- Voice ID regex accepts valid piper IDs and rejects path-traversal attempts.
- shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list.
- _resolve_file_urls picks the right .onnx and .onnx.json paths.
"""
from __future__ import annotations
import pytest
from fabledassistant.services import voice_library as lib
def test_voice_id_accepts_real_piper_ids():
for vid in [
"en_US-amy-medium",
"en_US-ryan-medium",
"en_GB-alan-low",
"fr_FR-siwis-medium",
"de_DE-thorsten-high",
"es_AR-daniela-high",
"zh_CN-huayan-medium",
"ar_JO-kareem-low",
"ca_ES-upc_ona-x_low",
]:
assert lib._is_valid_voice_id(vid), f"should accept {vid!r}"
def test_voice_id_rejects_path_traversal_and_garbage():
for vid in [
"../../etc/passwd",
"en_US-amy-medium/../something",
"../en_US-amy-medium",
"",
"en_US-amy", # missing quality
"amy-medium", # missing language
"en_US--medium", # empty dataset
"EN_US-amy-medium", # uppercase language prefix not in catalog convention
"en_US-amy-large", # invalid quality
".onnx",
"en_US-amy-medium.onnx", # has extension; we want the bare id
]:
assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}"
def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path):
"""Catalog dict → list of UI cards, with install state from disk scan."""
# Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium
# as installed under /data so we can verify the field flips.
user_dir = tmp_path / "data_voices"
bundled_dir = tmp_path / "opt_voices"
user_dir.mkdir()
bundled_dir.mkdir()
(user_dir / "en_US-amy-medium.onnx").write_text("model")
(user_dir / "en_US-amy-medium.onnx.json").write_text("{}")
monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir)
monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir)
catalog = {
"en_US-amy-medium": {
"name": "amy",
"language": {
"code": "en_US",
"name_native": "English",
"country_english": "United States",
},
"quality": "medium",
"num_speakers": 1,
"files": {
"en/en_US/amy/medium/en_US-amy-medium.onnx": {
"size_bytes": 63_500_000,
},
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {
"size_bytes": 4_300,
},
},
},
"fr_FR-siwis-medium": {
"name": "siwis",
"language": {
"code": "fr_FR",
"name_native": "Français",
"country_english": "France",
},
"quality": "medium",
"num_speakers": 1,
"files": {
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": {
"size_bytes": 60_000_000,
},
"fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": {
"size_bytes": 3_900,
},
},
},
}
out = lib.shape_catalog_for_ui(catalog)
assert len(out) == 2
by_id = {v["id"]: v for v in out}
amy = by_id["en_US-amy-medium"]
assert amy["name"] == "amy"
assert amy["language_code"] == "en_US"
assert amy["quality"] == "medium"
assert amy["size_bytes"] == 63_500_000 + 4_300
assert amy["installed"] is True
assert amy["installed_source"] == "user"
siwis = by_id["fr_FR-siwis-medium"]
assert siwis["language_code"] == "fr_FR"
assert siwis["installed"] is False
assert siwis["installed_source"] is None
# Sorted by language_code, then name — en_US before fr_FR.
assert out[0]["id"] == "en_US-amy-medium"
assert out[1]["id"] == "fr_FR-siwis-medium"
def test_resolve_file_urls_picks_onnx_and_sidecar():
catalog = {
"en_US-amy-medium": {
"files": {
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
"en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2},
},
}
}
urls = lib._resolve_file_urls(catalog, "en_US-amy-medium")
assert urls["onnx"].endswith("en_US-amy-medium.onnx")
assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json")
assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/")
def test_resolve_file_urls_raises_when_voice_missing():
with pytest.raises(KeyError):
lib._resolve_file_urls({}, "en_US-amy-medium")
def test_resolve_file_urls_raises_when_files_incomplete():
catalog = {
"en_US-amy-medium": {
"files": {
# Only the .onnx, missing the sidecar — that's malformed.
"en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
}
}
}
with pytest.raises(ValueError, match="missing"):
lib._resolve_file_urls(catalog, "en_US-amy-medium")