From a28f75994a97066fe6d6928dd1550d08fbf78af9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 07:59:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(voice):=20swap=20kokoro=20TTS=20=E2=86=92?= =?UTF-8?q?=20piper-tts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Dockerfile | 22 +- .../versions/0047_reset_voice_tts_settings.py | 38 ++ frontend/src/api/client.ts | 23 +- frontend/src/views/SettingsView.vue | 130 +----- src/fabledassistant/routes/voice.py | 44 +- src/fabledassistant/services/tts.py | 423 +++++++++--------- 6 files changed, 317 insertions(+), 363 deletions(-) create mode 100644 alembic/versions/0047_reset_voice_tts_settings.py diff --git a/Dockerfile b/Dockerfile index 84b5971..2341478 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,11 +21,29 @@ RUN --mount=type=cache,target=/root/.cache/pip \ # Python; the actual inference engine is ctranslate2 (C++) which has # cp314 wheels as of v4.7.2 (2026-05-19). No torch needed — ctranslate2 # does its own CPU inference. Image add: ~150 MB. -# TTS (piper-tts) is installed in a separate later step so STT can -# stand alone if TTS becomes problematic. RUN --mount=type=cache,target=/root/.cache/pip \ pip install faster-whisper soundfile +# Text-to-speech (piper-tts). Replaces kokoro, which has been +# stale upstream since April 2025 (requires_python<3.13). Piper depends +# only on onnxruntime (already pulled in for STT via faster-whisper) and +# pathvalidate — total Python overhead is tiny. Voice models are separate +# .onnx + .onnx.json files bundled below. +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install piper-tts + +# Bundle two default voices in the image so first-run TTS works offline. +# Additional voices can be downloaded at runtime into /data/voices via the +# admin UI (see services/tts.py for the voice-discovery logic). +# Voice catalog: https://huggingface.co/rhasspy/piper-voices +RUN mkdir -p /opt/piper-voices && cd /opt/piper-voices && \ + for VOICE in en_US-amy-medium en_US-ryan-medium; do \ + DATASET="${VOICE#en_US-}"; DATASET="${DATASET%-medium}"; \ + BASE="https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/${DATASET}/medium"; \ + curl -fsSL -o "${VOICE}.onnx" "${BASE}/${VOICE}.onnx"; \ + curl -fsSL -o "${VOICE}.onnx.json" "${BASE}/${VOICE}.onnx.json"; \ + done + # Build the fable-mcp wheel so it can be served for download COPY fable-mcp/ fable-mcp/ RUN --mount=type=cache,target=/root/.cache/pip \ diff --git a/alembic/versions/0047_reset_voice_tts_settings.py b/alembic/versions/0047_reset_voice_tts_settings.py new file mode 100644 index 0000000..98bff53 --- /dev/null +++ b/alembic/versions/0047_reset_voice_tts_settings.py @@ -0,0 +1,38 @@ +"""reset voice_tts_voice and voice_tts_blend settings for piper migration + +Revision ID: 0047 +Revises: 0046 +Create Date: 2026-05-22 + +The TTS backend swapped from kokoro to piper. Kokoro voice IDs +(`af_heart`, `am_adam`, etc.) don't map to piper voice files +(`en_US-amy-medium`, etc.) and there's no sensible auto-translation. +Clear the stored voice selection so every user falls back to the piper +default the next time they synthesize. + +`voice_tts_blend` goes away entirely — piper has no voice-blending +equivalent. The TTS service accepts the field for backward compat but +ignores it; clearing the rows makes the DB match reality. + +Both settings re-default cleanly on read, so this migration just deletes +the rows without backfilling anything. +""" +from alembic import op + + +revision = "0047" +down_revision = "0046" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "DELETE FROM settings WHERE key IN ('voice_tts_voice', 'voice_tts_blend')" + ) + + +def downgrade() -> None: + # No-op. The deleted settings just re-default on read; restoring the + # kokoro IDs wouldn't help anyway since kokoro is gone. + pass diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19607c2..52305a0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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('/api/voice/status') @@ -651,16 +649,15 @@ export async function synthesiseSpeech( text: string, voice?: string, speed?: number, - voiceBlend?: VoiceBlendEntry[] ): Promise { - // 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 = { 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', { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 21a2f8a..772b8fa 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,10 +1,9 @@