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:
2026-05-22 07:59:09 -04:00
parent c91b9c46ff
commit a28f75994a
6 changed files with 317 additions and 363 deletions
+10 -13
View File
@@ -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', {