| """Free, local Piper TTS backend. |
| |
| Voice models are pulled from the ``rhasspy/piper-voices`` repo on the |
| Hugging Face Hub and synthesized with the ``piper`` Python package. No |
| network access is needed at synthesis time and no paid service is used. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import tempfile |
| import wave |
| from pathlib import Path |
| from typing import List, Optional |
|
|
| from ..audio import read_wav_file, resample |
| from ..config import PIPER_VOICES |
| from .base import SynthesisResult, TTSBackend, Voice |
|
|
|
|
| class PiperTTSBackend(TTSBackend): |
| source = "piper_tts" |
|
|
| def __init__( |
| self, |
| max_voices: int, |
| sample_rate_hz: int, |
| cache_dir: Optional[str] = None, |
| ) -> None: |
| self.max_voices = max_voices |
| self.sample_rate_hz = sample_rate_hz |
| self.cache_dir = Path(cache_dir) if cache_dir else Path(tempfile.gettempdir()) / "piper_voices" |
| self._voices: List[Voice] = [] |
| self._models: dict[str, "object"] = {} |
|
|
| |
|
|
| def prepare(self) -> None: |
| from huggingface_hub import hf_hub_download |
| from piper import PiperVoice as PiperModel |
|
|
| self.cache_dir.mkdir(parents=True, exist_ok=True) |
| selected = PIPER_VOICES[: self.max_voices] |
|
|
| for spec in selected: |
| try: |
| onnx_path = hf_hub_download( |
| repo_id="rhasspy/piper-voices", |
| filename=f"{spec.repo_path}.onnx", |
| cache_dir=str(self.cache_dir), |
| ) |
| config_path = hf_hub_download( |
| repo_id="rhasspy/piper-voices", |
| filename=f"{spec.repo_path}.onnx.json", |
| cache_dir=str(self.cache_dir), |
| ) |
| model = PiperModel.load(onnx_path, config_path=config_path) |
| self._models[spec.voice_id] = model |
| self._voices.append( |
| Voice( |
| name=spec.voice_id, |
| language_code=spec.locale, |
| description=spec.description, |
| ) |
| ) |
| except Exception as exc: |
| print(f"[piper] Skipping voice {spec.voice_id}: {exc}") |
|
|
| if not self._voices: |
| raise RuntimeError("No Piper voices could be downloaded or loaded.") |
|
|
| def voices(self) -> List[Voice]: |
| return list(self._voices) |
|
|
| def synthesize(self, text: str, voice: Voice) -> SynthesisResult: |
| model = self._models.get(voice.name) |
| if model is None: |
| raise RuntimeError(f"Piper voice not loaded: {voice.name}") |
|
|
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: |
| tmp_path = Path(tmp.name) |
| try: |
| with wave.open(str(tmp_path), "wb") as wav_file: |
| |
| |
| |
| if hasattr(model, "synthesize_wav"): |
| model.synthesize_wav(text, wav_file) |
| else: |
| model.synthesize(text, wav_file) |
| audio, src_rate = read_wav_file(tmp_path) |
| finally: |
| tmp_path.unlink(missing_ok=True) |
|
|
| audio = resample(audio, src_rate, self.sample_rate_hz) |
| return SynthesisResult(audio=audio, sample_rate_hz=self.sample_rate_hz) |
|
|