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
+27
View File
@@ -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')
+244 -1
View File
@@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice, uninstallVoice, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceLibraryEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -469,6 +469,87 @@ const savingVoice = ref(false);
const voiceSaved = ref(false);
const voiceTabLoaded = ref(false);
// Voice library (admin-only): browse + install piper voices from HF
const voiceLibrary = ref<VoiceLibraryEntry[]>([]);
const voiceLibraryLoading = ref(false);
const voiceLibraryError = ref<string | null>(null);
const voiceLibraryFilter = ref(""); // free-text language/name filter
const voiceLibraryExpanded = ref(false);
const installingVoiceIds = ref<Set<string>>(new Set());
const uninstallingVoiceIds = ref<Set<string>>(new Set());
const filteredVoiceLibrary = computed(() => {
const q = voiceLibraryFilter.value.trim().toLowerCase();
if (!q) return voiceLibrary.value;
return voiceLibrary.value.filter(v =>
v.id.toLowerCase().includes(q)
|| v.language_code.toLowerCase().includes(q)
|| v.language_name.toLowerCase().includes(q)
|| v.country.toLowerCase().includes(q)
|| v.name.toLowerCase().includes(q)
);
});
function formatVoiceSize(bytes: number): string {
if (!bytes) return "—";
const mb = bytes / (1024 * 1024);
return mb >= 10 ? `${Math.round(mb)} MB` : `${mb.toFixed(1)} MB`;
}
async function loadVoiceLibrary(refresh = false) {
if (!authStore.isAdmin) return;
voiceLibraryLoading.value = true;
voiceLibraryError.value = null;
try {
const res = await getVoiceLibrary(refresh);
voiceLibrary.value = res.voices;
} catch (e) {
voiceLibraryError.value = e instanceof Error ? e.message : "Failed to load voice library";
} finally {
voiceLibraryLoading.value = false;
}
}
async function refreshInstalledVoices() {
// Reload the active voice list (used after install/uninstall) so the
// user's voice picker sees the change without a full page reload.
try {
availableVoices.value = await getVoiceList();
} catch {
/* voice feature may be disabled — ignore */
}
}
async function installLibraryVoice(voiceId: string) {
if (installingVoiceIds.value.has(voiceId)) return;
installingVoiceIds.value.add(voiceId);
try {
await installVoice(voiceId);
toastStore.show(`Installed ${voiceId}`, "success");
await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
} catch (e) {
const msg = e instanceof Error ? e.message : "Install failed";
toastStore.show(`Failed to install ${voiceId}: ${msg}`, "error");
} finally {
installingVoiceIds.value.delete(voiceId);
}
}
async function uninstallLibraryVoice(voiceId: string) {
if (uninstallingVoiceIds.value.has(voiceId)) return;
uninstallingVoiceIds.value.add(voiceId);
try {
await uninstallVoice(voiceId);
toastStore.show(`Removed ${voiceId}`, "success");
await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
} catch (e) {
const msg = e instanceof Error ? e.message : "Uninstall failed";
toastStore.show(`Failed to remove ${voiceId}: ${msg}`, "error");
} finally {
uninstallingVoiceIds.value.delete(voiceId);
}
}
async function loadVoiceTab() {
if (voiceTabLoaded.value) return;
voiceTabLoaded.value = true;
@@ -2362,6 +2443,96 @@ function formatUserDate(iso: string): string {
<!-- Voice Blend section removed with the kokoro → piper migration
(2026-05-22). Piper has no voice-blending equivalent. -->
<!-- Voice Library (admin only) — browse + install piper voices.
Voices land in /data/voices and are immediately available to
every user's voice picker. Bundled voices in /opt/piper-voices
are read-only and show a "bundled" badge instead of a delete. -->
<section v-if="authStore.isAdmin && voiceStatus?.enabled" class="settings-section full-width">
<div class="voice-library-header">
<h2>Voice Library</h2>
<button
class="btn-secondary"
type="button"
@click="voiceLibraryExpanded = !voiceLibraryExpanded; if (voiceLibraryExpanded && voiceLibrary.length === 0) loadVoiceLibrary()"
>
{{ voiceLibraryExpanded ? 'Hide' : 'Browse voices' }}
</button>
</div>
<p class="section-desc">
Download additional piper voices from the
<a href="https://huggingface.co/rhasspy/piper-voices" target="_blank" rel="noopener">piper-voices catalog</a>.
Installed voices appear in every user's voice picker after a page reload.
</p>
<template v-if="voiceLibraryExpanded">
<div class="voice-library-toolbar">
<input
v-model="voiceLibraryFilter"
type="search"
class="input"
placeholder="Filter by language, country, or name (e.g. 'en', 'fr_FR', 'amy')…"
/>
<button
class="btn-secondary"
type="button"
:disabled="voiceLibraryLoading"
@click="loadVoiceLibrary(true)"
title="Re-fetch catalog from HuggingFace"
>
{{ voiceLibraryLoading ? 'Loading' : 'Refresh' }}
</button>
</div>
<p v-if="voiceLibraryError" class="field-hint-warn">
{{ voiceLibraryError }}
</p>
<div v-if="voiceLibraryLoading && voiceLibrary.length === 0" class="voice-library-empty">
Loading catalog…
</div>
<ul v-else-if="filteredVoiceLibrary.length > 0" class="voice-library-list">
<li v-for="v in filteredVoiceLibrary" :key="v.id" class="voice-library-row">
<div class="voice-library-meta">
<div class="voice-library-id">{{ v.id }}</div>
<div class="voice-library-sub">
{{ v.language_name || v.language_code }}
<span v-if="v.country"> · {{ v.country }}</span>
· {{ v.quality }}
<span v-if="v.num_speakers > 1"> · {{ v.num_speakers }} speakers</span>
· {{ formatVoiceSize(v.size_bytes) }}
</div>
</div>
<div class="voice-library-actions">
<span v-if="v.installed && v.installed_source === 'bundled'" class="voice-badge voice-badge--bundled">
bundled
</span>
<button
v-else-if="v.installed && v.installed_source === 'user'"
class="btn-danger btn-sm"
:disabled="uninstallingVoiceIds.has(v.id)"
@click="uninstallLibraryVoice(v.id)"
>
{{ uninstallingVoiceIds.has(v.id) ? 'Removing' : 'Remove' }}
</button>
<button
v-else
class="btn-secondary btn-sm"
:disabled="installingVoiceIds.has(v.id)"
@click="installLibraryVoice(v.id)"
>
{{ installingVoiceIds.has(v.id) ? 'Installing' : 'Install' }}
</button>
</div>
</li>
</ul>
<div v-else-if="!voiceLibraryLoading" class="voice-library-empty">
No voices match the filter.
</div>
</template>
</section>
</div>
<!-- ── API Keys ── -->
@@ -4185,6 +4356,78 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Voice Library (admin) */
.voice-library-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.voice-library-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 1rem 0;
}
.voice-library-toolbar .input {
flex: 1;
}
.voice-library-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--color-border);
max-height: 480px;
overflow-y: auto;
}
.voice-library-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.voice-library-meta {
min-width: 0;
flex: 1;
}
.voice-library-id {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
font-weight: 500;
}
.voice-library-sub {
color: var(--color-text-muted);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.voice-library-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.voice-library-empty {
padding: 1rem 0;
color: var(--color-text-muted);
text-align: center;
font-style: italic;
}
.voice-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.voice-badge--bundled {
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.status-badge {
font-size: 0.75rem;
font-weight: 500;