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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user