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
+19 -25
View File
@@ -30,22 +30,25 @@ async def voice_status():
"stt": stt_available(),
"tts": tts_available(),
"stt_model": config.get("voice_stt_model", "base.en"),
"tts_backend": "kokoro",
"tts_backend": "piper",
})
@voice_bp.route("/voices", methods=["GET"])
@login_required
async def list_voices():
"""Return available Kokoro voice IDs and labels."""
"""Return available piper voice IDs and metadata.
Scans /opt/piper-voices (bundled) + /data/voices (admin-downloaded)
on every call so newly-downloaded voices show up without a restart.
Does NOT require tts_available() — even if the active voice failed
to load, the catalog is still useful for picking a different one.
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import list_voices, tts_available
if not tts_available():
return jsonify({"error": "TTS not available"}), 503
from fabledassistant.services.tts import list_voices
return jsonify({"voices": list_voices()})
@@ -127,21 +130,18 @@ async def synthesise_speech():
)
return jsonify({"error": "text too long (max 8000 characters)"}), 400
voice = str(data.get("voice", "af_heart"))
# Piper voice file basename (e.g. "en_US-amy-medium"). Default is read
# from user settings if not in the request body.
voice = str(data.get("voice", "")) or "en_US-amy-medium"
try:
speed = float(data.get("speed", 1.0))
except (TypeError, ValueError):
speed = 1.0
voice_blend = data.get("voice_blend")
if not isinstance(voice_blend, list) or len(voice_blend) < 2:
voice_blend = None
# When no explicit voice/blend/speed provided, load all voice settings from the user's profile
if "voice" not in data and "voice_blend" not in data and "speed" not in data:
# Pull saved settings only when caller didn't override.
if "voice" not in data and "speed" not in data:
from fabledassistant.services.settings import get_setting
from fabledassistant.auth import get_current_user_id
import json as _json
try:
uid = get_current_user_id()
saved_voice = await get_setting(uid, "voice_tts_voice", "")
@@ -153,23 +153,17 @@ async def synthesise_speech():
speed = float(saved_speed)
except ValueError:
pass
saved_blend = await get_setting(uid, "voice_tts_blend", "")
if saved_blend:
parsed = _json.loads(saved_blend)
if isinstance(parsed, list) and len(parsed) >= 2:
voice_blend = parsed
except Exception:
pass
blend_desc = f"blend({len(voice_blend)} voices)" if voice_blend else voice
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, blend_desc, speed)
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, voice, speed)
t0 = time.monotonic()
try:
wav_bytes = await synthesise(text, voice=voice, speed=speed, voice_blend=voice_blend)
wav_bytes = await synthesise(text, voice=voice, speed=speed)
except Exception:
logger.exception(
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
char_count, blend_desc, text[:120],
char_count, voice, text[:120],
)
return jsonify({"error": "Synthesis failed"}), 500
@@ -177,12 +171,12 @@ async def synthesise_speech():
if not wav_bytes:
logger.warning(
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
char_count, blend_desc, duration_ms, text[:120],
char_count, voice, duration_ms, text[:120],
)
else:
logger.info(
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
char_count, len(wav_bytes), duration_ms, blend_desc,
char_count, len(wav_bytes), duration_ms, voice,
)
from quart import Response