feat(voice): swap kokoro TTS → piper-tts
Kokoro has been stale upstream since April 2025 (`requires_python<3.13`), which broke the Python 3.14 build. Piper is the active replacement: maintained by OHF/Home Assistant, depends only on onnxruntime + pathvalidate (no torch, no spacy, no transformers), and has cp314 support today. Dockerfile: - Add `pip install piper-tts` after the STT install. - Bundle two default voices (en_US-amy-medium, en_US-ryan-medium) into /opt/piper-voices at build. Additional voices can be downloaded into /data/voices via the admin UI (separate commit). - Image add over the STT-only baseline: ~150 MB. services/tts.py — full rewrite: - New voice-discovery layer scans /opt/piper-voices + /data/voices for .onnx + .onnx.json pairs. /data wins over /opt for the same id so admin-downloaded voices can override bundled defaults. - Single PiperVoice kept warm; switches via _switch_voice() when the user changes their voice_tts_voice setting. - list_voices() returns metadata read from .onnx.json sidecars (label derived from filename, language, quality, sample_rate). - synthesise() uses piper's SynthesisConfig; converts kokoro-shaped `speed` multiplier to piper's `length_scale` (1.0 / speed). - `voice_blend` parameter accepted but ignored — piper has no blend equivalent; first entry's voice is used if anything is passed. - Dropped: HuggingFace commit-hash tracking (~80 lines), the daily check_for_kokoro_updates task, voice-tensor blending math. routes/voice.py: - tts_backend reports "piper" in /api/voice/status. - /api/voice/voices no longer requires tts_available() — even with the active voice failed to load, the catalog still lets the user pick a different one. - Synthesise request body dropped the voice_blend field; speed and voice still supported. alembic 0047_reset_voice_tts_settings: - Deletes any stored voice_tts_voice (kokoro IDs that don't map to piper) and voice_tts_blend (no piper equivalent) rows. Both re-default cleanly on next read. frontend: - VoiceBlendEntry type removed from api/client.ts. - synthesiseSpeech() signature dropped the voiceBlend parameter. - SettingsView.vue Voice Blend section removed entirely (slider, preview, slot management). voice_tts_blend save path removed. - Default voice id changed from "af_heart" to "en_US-amy-medium". - VoiceEntry gains optional language/quality/sample_rate fields from the richer piper sidecar metadata. Voice paths remain lazily guarded — `VOICE_ENABLED=false` (default) starts the app cleanly regardless of which TTS deps are present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-13
@@ -623,11 +623,9 @@ export interface VoiceStatusResult {
|
||||
export interface VoiceEntry {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface VoiceBlendEntry {
|
||||
voice: string
|
||||
weight: number
|
||||
language?: string
|
||||
quality?: string
|
||||
sample_rate?: number
|
||||
}
|
||||
|
||||
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
|
||||
@@ -651,16 +649,15 @@ export async function synthesiseSpeech(
|
||||
text: string,
|
||||
voice?: string,
|
||||
speed?: number,
|
||||
voiceBlend?: VoiceBlendEntry[]
|
||||
): Promise<Blob> {
|
||||
// Only send voice/speed/blend when explicitly provided — omitting them lets
|
||||
// the server auto-load the user's saved voice settings (voice, speed, blend).
|
||||
// Only send voice/speed when explicitly provided — omitting them lets the
|
||||
// server auto-load the user's saved voice settings.
|
||||
// Voice blending was removed with the kokoro → piper migration (piper has
|
||||
// no blend equivalent); callers that previously passed `voiceBlend` should
|
||||
// pass the first voice's id as `voice` instead.
|
||||
const body: Record<string, unknown> = { text }
|
||||
if (voiceBlend && voiceBlend.length >= 2) {
|
||||
body.voice_blend = voiceBlend
|
||||
if (speed !== undefined) body.speed = speed
|
||||
} else if (voice !== undefined || speed !== undefined) {
|
||||
body.voice = voice ?? 'af_heart'
|
||||
if (voice !== undefined || speed !== undefined) {
|
||||
body.voice = voice ?? 'en_US-amy-medium'
|
||||
body.speed = speed ?? 1.0
|
||||
}
|
||||
const res = await fetch('/api/voice/synthesise', {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
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 VoiceBlendEntry, 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, 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 { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -457,58 +456,19 @@ const searchResults = ref<{ url: string; title: string; snippet: string }[]>([])
|
||||
const searchLoading = ref(false);
|
||||
const searchError = ref("");
|
||||
|
||||
// Voice settings
|
||||
// Voice settings. Default voice id matches the bundled piper voice
|
||||
// (en_US-amy-medium); legacy kokoro IDs in user settings were cleared
|
||||
// by alembic migration 0047, so the first read after upgrade returns "".
|
||||
const voiceStatus = ref<VoiceStatusResult | null>(null);
|
||||
const voiceStatusLoading = ref(false);
|
||||
const availableVoices = ref<VoiceEntry[]>([]);
|
||||
const voiceTtsVoice = ref("af_heart");
|
||||
const voiceTtsVoice = ref("en_US-amy-medium");
|
||||
const voiceTtsSpeed = ref(1.0);
|
||||
const voiceSpeechStyle = ref("conversational");
|
||||
const savingVoice = ref(false);
|
||||
const voiceSaved = ref(false);
|
||||
const voiceTabLoaded = ref(false);
|
||||
|
||||
// Voice blend
|
||||
const blendEnabled = ref(false);
|
||||
const voiceBlend = ref<VoiceBlendEntry[]>([
|
||||
{ voice: "af_heart", weight: 1.0 },
|
||||
{ voice: "af_bella", weight: 1.0 },
|
||||
]);
|
||||
const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?");
|
||||
const blendPreviewing = ref(false);
|
||||
|
||||
function addBlendSlot() {
|
||||
voiceBlend.value.push({ voice: "af_heart", weight: 1.0 });
|
||||
}
|
||||
|
||||
function removeBlendSlot(idx: number) {
|
||||
if (voiceBlend.value.length > 2) voiceBlend.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function previewBlend() {
|
||||
if (blendPreviewing.value || !blendPreviewText.value.trim()) return;
|
||||
blendPreviewing.value = true;
|
||||
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
|
||||
const ctx = new AudioContext();
|
||||
try {
|
||||
const blob = await synthesiseSpeech(
|
||||
blendPreviewText.value,
|
||||
undefined,
|
||||
voiceTtsSpeed.value,
|
||||
voiceBlend.value,
|
||||
);
|
||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||||
} finally {
|
||||
blendPreviewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVoiceTab() {
|
||||
if (voiceTabLoaded.value) return;
|
||||
voiceTabLoaded.value = true;
|
||||
@@ -558,7 +518,6 @@ async function saveVoiceSettings() {
|
||||
voice_tts_voice: voiceTtsVoice.value,
|
||||
voice_tts_speed: String(voiceTtsSpeed.value),
|
||||
voice_speech_style: voiceSpeechStyle.value,
|
||||
voice_tts_blend: blendEnabled.value ? JSON.stringify(voiceBlend.value) : "",
|
||||
});
|
||||
voiceSaved.value = true;
|
||||
setTimeout(() => { voiceSaved.value = false; }, 2000);
|
||||
@@ -805,19 +764,12 @@ onMounted(async () => {
|
||||
// Load journal config (locations, temp unit, prep schedule)
|
||||
await loadJournalConfig();
|
||||
|
||||
// Load voice settings
|
||||
// Load voice settings. Voice blending was removed with the kokoro→piper
|
||||
// migration (piper has no blend equivalent); legacy voice_tts_blend rows
|
||||
// were dropped in migration 0047.
|
||||
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
|
||||
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
|
||||
if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
|
||||
if (allSettings.voice_tts_blend) {
|
||||
try {
|
||||
const parsed = JSON.parse(allSettings.voice_tts_blend);
|
||||
if (Array.isArray(parsed) && parsed.length >= 2) {
|
||||
voiceBlend.value = parsed;
|
||||
blendEnabled.value = true;
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
|
||||
// Load CalDAV settings
|
||||
try {
|
||||
@@ -2407,70 +2359,8 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="voiceStatus?.enabled && voiceStatus?.tts" class="settings-section full-width">
|
||||
<h2>Voice Blend</h2>
|
||||
<p class="section-desc">
|
||||
Mix two or more voice styles by weight. The resulting blended voice is used instead of
|
||||
the single voice above when enabled.
|
||||
</p>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" v-model="blendEnabled" />
|
||||
<span>Enable voice blending</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="blendEnabled">
|
||||
<div class="blend-slots">
|
||||
<div v-for="(entry, idx) in voiceBlend" :key="idx" class="blend-slot">
|
||||
<select class="input blend-voice-select" v-model="entry.voice">
|
||||
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
|
||||
</select>
|
||||
<div class="blend-weight-row">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.05"
|
||||
v-model.number="entry.weight"
|
||||
class="range-input blend-weight-slider"
|
||||
/>
|
||||
<span class="blend-weight-label">{{ entry.weight.toFixed(2) }}</span>
|
||||
<button
|
||||
v-if="voiceBlend.length > 2"
|
||||
class="btn-remove-slot"
|
||||
@click="removeBlendSlot(idx)"
|
||||
title="Remove this voice"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blend-actions">
|
||||
<button class="btn-secondary" @click="addBlendSlot" :disabled="voiceBlend.length >= 5">
|
||||
+ Add voice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="margin-top: 1rem;">
|
||||
<label class="field-label">Preview text</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="blendPreviewText"
|
||||
placeholder="Text to speak for preview…"
|
||||
maxlength="300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="previewBlend" :disabled="blendPreviewing">
|
||||
{{ blendPreviewing ? 'Generating…' : '▶ Preview blend' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<!-- Voice Blend section removed with the kokoro → piper migration
|
||||
(2026-05-22). Piper has no voice-blending equivalent. -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user