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:
@@ -628,11 +628,38 @@ export interface VoiceEntry {
|
||||
sample_rate?: number
|
||||
}
|
||||
|
||||
export interface VoiceLibraryEntry {
|
||||
id: string
|
||||
name: string
|
||||
language_code: string
|
||||
language_name: string
|
||||
country: string
|
||||
quality: string
|
||||
num_speakers: number
|
||||
size_bytes: number
|
||||
installed: boolean
|
||||
installed_source: 'user' | 'bundled' | null
|
||||
}
|
||||
|
||||
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
|
||||
|
||||
export const getVoiceList = () =>
|
||||
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
|
||||
|
||||
export const getVoiceLibrary = (refresh = false) =>
|
||||
apiGet<{ voices: VoiceLibraryEntry[]; count: number }>(
|
||||
refresh ? '/api/voice/voices/library?refresh=1' : '/api/voice/voices/library'
|
||||
)
|
||||
|
||||
export const installVoice = (voiceId: string) =>
|
||||
apiPost<{ id: string; size_bytes: number; skipped: boolean }>(
|
||||
'/api/voice/voices/install',
|
||||
{ voice_id: voiceId }
|
||||
)
|
||||
|
||||
export const uninstallVoice = (voiceId: string) =>
|
||||
apiDelete(`/api/voice/voices/${encodeURIComponent(voiceId)}`)
|
||||
|
||||
export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
|
||||
const form = new FormData()
|
||||
form.append('audio', blob, 'audio.webm')
|
||||
|
||||
Reference in New Issue
Block a user