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:
@@ -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
|
||||
|
||||
+220
-203
@@ -1,270 +1,287 @@
|
||||
"""Text-to-speech service using Kokoro TTS (in-process)."""
|
||||
"""Text-to-speech service using piper-tts (in-process, CPU).
|
||||
|
||||
Voice model files (.onnx + .onnx.json sidecar) live in two directories:
|
||||
|
||||
- /opt/piper-voices/ — bundled at image build time, read-only.
|
||||
- /data/voices/ — admin-downloaded at runtime, persisted across
|
||||
restarts via the same volume as other /data/*.
|
||||
|
||||
A single PiperVoice is kept loaded at a time (one voice per request would
|
||||
be too slow; keeping all warm would use a lot of RAM for a feature most
|
||||
users won't enable). The active voice is whichever one the user picked
|
||||
via Settings → Voice — selection changes trigger reload_tts_model().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kokoro import KPipeline
|
||||
from piper import PiperVoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pipeline: "KPipeline | None" = None
|
||||
_pipeline_lock = asyncio.Lock()
|
||||
# Two directories scanned for voices. Order matters for tie-breaking when
|
||||
# both contain the same voice id — /data wins (admin-downloaded override).
|
||||
_BUNDLED_VOICES_DIR = Path("/opt/piper-voices")
|
||||
_USER_VOICES_DIR = Path("/data/voices")
|
||||
|
||||
_DEFAULT_VOICE_ID = "en_US-amy-medium"
|
||||
|
||||
_voice: "PiperVoice | None" = None
|
||||
_voice_id: str | None = None # id of the currently-loaded voice
|
||||
_voice_lock = asyncio.Lock()
|
||||
_load_error: str | None = None
|
||||
|
||||
# Repo identifier used for HuggingFace update checks
|
||||
KOKORO_REPO = "hexgrad/Kokoro-82M"
|
||||
|
||||
# Persisted across restarts so we can detect model updates and run offline otherwise
|
||||
_COMMIT_HASH_FILE = Path("/data/kokoro_commit_hash.txt")
|
||||
|
||||
# Static list of supported Kokoro voice IDs and display labels
|
||||
_VOICES: list[dict] = [
|
||||
{"id": "af_heart", "label": "Heart (American Female, warm)"},
|
||||
{"id": "af_bella", "label": "Bella (American Female, expressive)"},
|
||||
{"id": "af_nicole", "label": "Nicole (American Female, intimate)"},
|
||||
{"id": "af_sarah", "label": "Sarah (American Female, clear)"},
|
||||
{"id": "af_sky", "label": "Sky (American Female, bright)"},
|
||||
{"id": "am_adam", "label": "Adam (American Male, neutral)"},
|
||||
{"id": "am_michael", "label": "Michael (American Male, deep)"},
|
||||
{"id": "bf_emma", "label": "Emma (British Female)"},
|
||||
{"id": "bf_isabella", "label": "Isabella (British Female, formal)"},
|
||||
{"id": "bm_george", "label": "George (British Male)"},
|
||||
{"id": "bm_lewis", "label": "Lewis (British Male, casual)"},
|
||||
]
|
||||
def _voice_dirs() -> list[Path]:
|
||||
"""Directories scanned for voice files, in precedence order (later wins)."""
|
||||
return [_BUNDLED_VOICES_DIR, _USER_VOICES_DIR]
|
||||
|
||||
|
||||
def _read_stored_commit() -> str | None:
|
||||
def _discover_voice(voice_id: str) -> Path | None:
|
||||
"""Find the .onnx file for `voice_id` across the scanned directories.
|
||||
|
||||
/data wins over /opt if both contain the same id so an admin-downloaded
|
||||
voice can override a bundled one if they want.
|
||||
"""
|
||||
found: Path | None = None
|
||||
for d in _voice_dirs():
|
||||
candidate = d / f"{voice_id}.onnx"
|
||||
if candidate.is_file() and (d / f"{voice_id}.onnx.json").is_file():
|
||||
found = candidate
|
||||
return found
|
||||
|
||||
|
||||
def _scan_voices() -> list[Path]:
|
||||
"""Return all valid voice files (.onnx + .onnx.json) across all dirs.
|
||||
|
||||
Same-id duplicates are deduplicated; later directories override earlier
|
||||
so a /data voice shadows a bundled one with the same name.
|
||||
"""
|
||||
by_id: dict[str, Path] = {}
|
||||
for d in _voice_dirs():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for onnx in sorted(d.glob("*.onnx")):
|
||||
sidecar = onnx.with_suffix(".onnx.json")
|
||||
if not sidecar.is_file():
|
||||
continue
|
||||
by_id[onnx.stem] = onnx
|
||||
return list(by_id.values())
|
||||
|
||||
|
||||
def _voice_metadata(onnx_path: Path) -> dict:
|
||||
"""Read the .onnx.json sidecar and return a UI-shaped metadata dict.
|
||||
|
||||
The sidecar's full schema is large; we surface only what the picker
|
||||
needs: id (filename stem), label (derived), language, quality, sample_rate.
|
||||
"""
|
||||
sidecar = onnx_path.with_suffix(".onnx.json")
|
||||
info: dict = {}
|
||||
try:
|
||||
if _COMMIT_HASH_FILE.exists():
|
||||
return _COMMIT_HASH_FILE.read_text().strip() or None
|
||||
info = json.loads(sidecar.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
logger.warning("Could not parse piper sidecar %s", sidecar, exc_info=True)
|
||||
|
||||
voice_id = onnx_path.stem
|
||||
lang_info = info.get("language") or {}
|
||||
audio_info = info.get("audio") or {}
|
||||
# Filename convention: <lang>-<dataset>-<quality> e.g. en_US-amy-medium
|
||||
parts = voice_id.split("-")
|
||||
dataset = parts[1] if len(parts) >= 2 else voice_id
|
||||
quality = parts[2] if len(parts) >= 3 else "medium"
|
||||
lang_code = lang_info.get("code") or (parts[0] if parts else "")
|
||||
region_name = lang_info.get("name_native") or lang_info.get("region") or lang_code
|
||||
|
||||
def _write_stored_commit(sha: str) -> None:
|
||||
try:
|
||||
_COMMIT_HASH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_COMMIT_HASH_FILE.write_text(sha)
|
||||
except Exception:
|
||||
logger.warning("Failed to write Kokoro commit hash to %s", _COMMIT_HASH_FILE)
|
||||
|
||||
|
||||
def _set_hf_offline(offline: bool) -> None:
|
||||
"""Toggle HuggingFace offline mode in the current process environment."""
|
||||
if offline:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
return {
|
||||
"id": voice_id,
|
||||
"label": f"{dataset.capitalize()} ({region_name}, {quality})",
|
||||
"language": lang_code,
|
||||
"quality": quality,
|
||||
"sample_rate": audio_info.get("sample_rate", 22050),
|
||||
}
|
||||
|
||||
|
||||
async def load_tts_model() -> None:
|
||||
"""Load the Kokoro pipeline. Called once at startup when voice is enabled."""
|
||||
global _pipeline, _load_error
|
||||
"""Load the active piper voice. Called once at startup if voice is enabled.
|
||||
|
||||
The active voice is determined by the per-user `voice_tts_voice` setting
|
||||
when known; falls back to `_DEFAULT_VOICE_ID` if unset or invalid.
|
||||
Because the active voice is per-user but the loaded model is a single
|
||||
in-process resource, this loads the default at startup; per-user voice
|
||||
selection triggers a reload at request time.
|
||||
"""
|
||||
global _voice, _voice_id, _load_error
|
||||
from fabledassistant.services.voice_config import is_voice_enabled
|
||||
|
||||
if not await is_voice_enabled():
|
||||
return
|
||||
|
||||
async with _pipeline_lock:
|
||||
if _pipeline is not None:
|
||||
async with _voice_lock:
|
||||
if _voice is not None:
|
||||
return
|
||||
try:
|
||||
from kokoro import KPipeline
|
||||
target_id = _DEFAULT_VOICE_ID
|
||||
onnx_path = _discover_voice(target_id)
|
||||
if onnx_path is None:
|
||||
# No bundled default? Fall back to whatever is on disk.
|
||||
discovered = _scan_voices()
|
||||
if not discovered:
|
||||
_load_error = (
|
||||
"No piper voice models found in /opt/piper-voices "
|
||||
"or /data/voices"
|
||||
)
|
||||
logger.error(_load_error)
|
||||
return
|
||||
onnx_path = discovered[0]
|
||||
target_id = onnx_path.stem
|
||||
logger.warning(
|
||||
"Default voice %r not found; using %r instead",
|
||||
_DEFAULT_VOICE_ID, target_id,
|
||||
)
|
||||
|
||||
# If we have a stored commit hash the model files are already cached
|
||||
# locally — run offline to skip HuggingFace network validation entirely.
|
||||
already_cached = _read_stored_commit() is not None
|
||||
if already_cached:
|
||||
_set_hf_offline(True)
|
||||
logger.info("Kokoro model previously cached — loading in offline mode")
|
||||
|
||||
logger.info("Loading Kokoro TTS pipeline...")
|
||||
logger.info("Loading piper TTS voice %s from %s", target_id, onnx_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
from piper import PiperVoice
|
||||
|
||||
def _load_and_warm():
|
||||
p = KPipeline(lang_code="a") # "a" = American English
|
||||
# Pre-load all voice tensors so synthesis never hits HuggingFace at request time
|
||||
for v in _VOICES:
|
||||
try:
|
||||
p.load_voice(v["id"])
|
||||
except Exception:
|
||||
pass
|
||||
return p
|
||||
|
||||
_pipeline = await loop.run_in_executor(None, _load_and_warm)
|
||||
|
||||
# Record the current commit hash on first successful load so future
|
||||
# restarts know the model is cached and can skip HF network checks.
|
||||
if not already_cached:
|
||||
asyncio.create_task(_record_initial_commit())
|
||||
|
||||
logger.info("Kokoro TTS pipeline loaded and voices pre-warmed")
|
||||
_voice = await loop.run_in_executor(
|
||||
None, lambda: PiperVoice.load(str(onnx_path))
|
||||
)
|
||||
_voice_id = target_id
|
||||
logger.info("Piper TTS voice %s loaded", target_id)
|
||||
except Exception:
|
||||
_load_error = "Failed to load Kokoro TTS pipeline"
|
||||
_load_error = "Failed to load piper TTS voice"
|
||||
logger.exception(_load_error)
|
||||
|
||||
|
||||
async def _record_initial_commit() -> None:
|
||||
"""Fetch and store the current Kokoro commit hash after first download."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
async def reload_tts_model(voice_id: str | None = None) -> None:
|
||||
"""Unload the current voice and load `voice_id` (or re-load the default).
|
||||
|
||||
def _fetch():
|
||||
from huggingface_hub import model_info
|
||||
return model_info(KOKORO_REPO).sha
|
||||
|
||||
sha = await loop.run_in_executor(None, _fetch)
|
||||
if sha:
|
||||
_write_stored_commit(sha)
|
||||
# Now that files are cached, switch to offline mode for subsequent runs
|
||||
_set_hf_offline(True)
|
||||
logger.info("Kokoro commit hash stored (%s) — future restarts will use offline mode", sha[:8])
|
||||
except Exception:
|
||||
logger.warning("Could not record Kokoro commit hash", exc_info=True)
|
||||
|
||||
|
||||
async def check_for_kokoro_updates() -> None:
|
||||
"""Check HuggingFace for Kokoro model updates.
|
||||
|
||||
Intended to be called on a daily schedule. Temporarily lifts offline mode
|
||||
to query the HF API, compares the latest commit SHA against the locally
|
||||
stored one, and reloads the pipeline only if the model has changed.
|
||||
Safe to call at runtime — used when the user changes voice in Settings.
|
||||
"""
|
||||
from fabledassistant.services.voice_config import is_voice_enabled
|
||||
if not await is_voice_enabled():
|
||||
return
|
||||
|
||||
stored_sha = _read_stored_commit()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _fetch_latest_sha():
|
||||
# Temporarily lift offline mode to reach the HF API
|
||||
_set_hf_offline(False)
|
||||
try:
|
||||
from huggingface_hub import model_info
|
||||
return model_info(KOKORO_REPO).sha
|
||||
finally:
|
||||
# Restore offline mode regardless of outcome
|
||||
_set_hf_offline(True)
|
||||
|
||||
latest_sha = await loop.run_in_executor(None, _fetch_latest_sha)
|
||||
except Exception:
|
||||
logger.warning("Kokoro update check failed — will retry tomorrow", exc_info=True)
|
||||
# Ensure offline mode is restored if the executor raised before the finally
|
||||
_set_hf_offline(bool(stored_sha))
|
||||
return
|
||||
|
||||
if latest_sha == stored_sha:
|
||||
logger.debug("Kokoro model is up to date (sha: %s)", (latest_sha or "")[:8])
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Kokoro model update detected (%s → %s), reloading pipeline",
|
||||
(stored_sha or "none")[:8],
|
||||
(latest_sha or "")[:8],
|
||||
)
|
||||
# Go online for the reload so Kokoro can download the updated files
|
||||
_set_hf_offline(False)
|
||||
try:
|
||||
await reload_tts_model()
|
||||
if latest_sha:
|
||||
_write_stored_commit(latest_sha)
|
||||
logger.info("Kokoro model updated and reloaded successfully")
|
||||
except Exception:
|
||||
logger.exception("Kokoro pipeline reload after update failed")
|
||||
finally:
|
||||
_set_hf_offline(True)
|
||||
|
||||
|
||||
async def reload_tts_model() -> None:
|
||||
"""Unload and reload the TTS pipeline. Safe to call at runtime."""
|
||||
global _pipeline, _load_error
|
||||
async with _pipeline_lock:
|
||||
_pipeline = None
|
||||
global _voice, _voice_id, _load_error
|
||||
async with _voice_lock:
|
||||
_voice = None
|
||||
_voice_id = None
|
||||
_load_error = None
|
||||
await load_tts_model()
|
||||
if voice_id:
|
||||
await _switch_voice(voice_id)
|
||||
else:
|
||||
await load_tts_model()
|
||||
|
||||
|
||||
async def _switch_voice(voice_id: str) -> None:
|
||||
"""Internal: load a specific voice by id. No-op if already loaded."""
|
||||
global _voice, _voice_id, _load_error
|
||||
async with _voice_lock:
|
||||
if _voice is not None and _voice_id == voice_id:
|
||||
return
|
||||
onnx_path = _discover_voice(voice_id)
|
||||
if onnx_path is None:
|
||||
_load_error = f"Voice {voice_id!r} not found in /opt or /data"
|
||||
logger.warning(_load_error)
|
||||
return
|
||||
try:
|
||||
logger.info("Switching piper voice to %s", voice_id)
|
||||
loop = asyncio.get_running_loop()
|
||||
from piper import PiperVoice
|
||||
|
||||
_voice = await loop.run_in_executor(
|
||||
None, lambda: PiperVoice.load(str(onnx_path))
|
||||
)
|
||||
_voice_id = voice_id
|
||||
_load_error = None
|
||||
except Exception:
|
||||
_load_error = f"Failed to load piper voice {voice_id!r}"
|
||||
logger.exception(_load_error)
|
||||
|
||||
|
||||
def tts_available() -> bool:
|
||||
return _pipeline is not None
|
||||
return _voice is not None
|
||||
|
||||
|
||||
def list_voices() -> list[dict]:
|
||||
return _VOICES
|
||||
"""Return metadata for every voice on disk, in stable order."""
|
||||
return [_voice_metadata(p) for p in _scan_voices()]
|
||||
|
||||
|
||||
async def synthesise(
|
||||
text: str,
|
||||
voice: str = "af_heart",
|
||||
voice: str = _DEFAULT_VOICE_ID,
|
||||
speed: float = 1.0,
|
||||
voice_blend: list[dict] | None = None,
|
||||
voice_blend: list[dict] | None = None, # accepted for backcompat; ignored
|
||||
) -> bytes:
|
||||
"""Synthesise text to WAV bytes (24kHz, 16-bit mono). Runs in executor.
|
||||
"""Synthesise text → WAV bytes (PCM 16-bit mono). Runs in executor.
|
||||
|
||||
voice_blend is a list of {"voice": str, "weight": float} dicts.
|
||||
When provided with 2+ entries the voice style tensors are merged as a
|
||||
weighted average before synthesis. Weights are normalised automatically.
|
||||
`voice_blend` is accepted for backward compatibility with callers that
|
||||
still pass it, but piper has no voice-blending equivalent, so it is
|
||||
silently ignored. The first voice in the blend (if any) is used; if
|
||||
that doesn't exist on disk, the request uses the default voice.
|
||||
|
||||
`speed` is the kokoro-shaped multiplier (1.0=normal, 1.5=faster).
|
||||
Piper uses `length_scale` where smaller = faster, so the conversion
|
||||
is `length_scale = 1.0 / speed`.
|
||||
"""
|
||||
if _pipeline is None:
|
||||
raise RuntimeError("TTS pipeline not loaded")
|
||||
# Backcompat: if blend was passed, just use the first entry's voice.
|
||||
if voice_blend and isinstance(voice_blend, list) and voice_blend:
|
||||
first = voice_blend[0]
|
||||
if isinstance(first, dict) and first.get("voice"):
|
||||
voice = str(first["voice"])
|
||||
|
||||
speed = max(0.7, min(1.3, speed))
|
||||
# Lazy-load / switch if needed.
|
||||
if _voice is None or _voice_id != voice:
|
||||
await _switch_voice(voice)
|
||||
if _voice is None:
|
||||
raise RuntimeError(f"TTS voice {voice!r} not loaded: {_load_error}")
|
||||
|
||||
def _build_voice_param():
|
||||
"""Return either a blended style tensor or a single voice ID string."""
|
||||
if not voice_blend or len(voice_blend) < 2:
|
||||
return voice_blend[0]["voice"] if voice_blend else voice
|
||||
import numpy as np
|
||||
total_w = sum(max(0.0, e.get("weight", 1.0)) for e in voice_blend) or 1.0
|
||||
blended = None
|
||||
for entry in voice_blend:
|
||||
vid = entry.get("voice", "af_heart")
|
||||
w = max(0.0, entry.get("weight", 1.0)) / total_w
|
||||
vt = _pipeline.load_voice(vid) # type: ignore[union-attr]
|
||||
blended = vt * w if blended is None else blended + vt * w
|
||||
return blended
|
||||
speed = max(0.5, min(2.0, speed))
|
||||
length_scale = 1.0 / speed
|
||||
|
||||
def _run() -> bytes:
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
# piper.SynthesisConfig is optional; defaults are sensible.
|
||||
# We override length_scale only.
|
||||
from piper import SynthesisConfig
|
||||
|
||||
config = SynthesisConfig(length_scale=length_scale)
|
||||
|
||||
voice_param = _build_voice_param()
|
||||
t0 = time.monotonic()
|
||||
audio_chunks: list = []
|
||||
try:
|
||||
for _, _, audio in _pipeline(text, voice=voice_param, speed=speed): # type: ignore[misc]
|
||||
if audio is not None:
|
||||
audio_chunks.append(audio)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Kokoro pipeline error during synthesis: %d chars, preview=%r",
|
||||
len(text), text[:80],
|
||||
)
|
||||
raise
|
||||
|
||||
if not audio_chunks:
|
||||
logger.warning(
|
||||
"Kokoro produced no audio chunks: %d chars, preview=%r",
|
||||
len(text), text[:80],
|
||||
)
|
||||
return b""
|
||||
|
||||
combined = np.concatenate(audio_chunks)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, combined, samplerate=24000, format="WAV", subtype="PCM_16")
|
||||
sample_rate = _voice.config.sample_rate # type: ignore[union-attr]
|
||||
total_samples = 0
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2) # 16-bit PCM
|
||||
wav.setframerate(sample_rate)
|
||||
try:
|
||||
for chunk in _voice.synthesize(text, config): # type: ignore[union-attr]
|
||||
pcm = chunk.audio_int16_bytes
|
||||
if not pcm:
|
||||
continue
|
||||
wav.writeframes(pcm)
|
||||
total_samples += len(pcm) // 2
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Piper synthesis error: %d chars, preview=%r",
|
||||
len(text), text[:80],
|
||||
)
|
||||
raise
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info(
|
||||
"Kokoro synthesis: %d chars → %d samples (%.2fs, %.0f chars/s)",
|
||||
len(text), len(combined), elapsed, len(text) / elapsed if elapsed > 0 else 0,
|
||||
"Piper synthesis: %d chars → %d samples (%.2fs, %.0f chars/s, voice=%s)",
|
||||
len(text), total_samples, elapsed,
|
||||
len(text) / elapsed if elapsed > 0 else 0,
|
||||
_voice_id,
|
||||
)
|
||||
if not total_samples:
|
||||
return b""
|
||||
return buf.getvalue()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
Reference in New Issue
Block a user