39ab5d69a9
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>
154 lines
5.2 KiB
Python
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")
|