Spaces:
Running
Running
| """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"] = {} # voice name -> PiperVoice instance | |
| # ------------------------------------------------------------------ # | |
| def prepare(self) -> None: | |
| from huggingface_hub import hf_hub_download # local import: heavy dep | |
| from piper import PiperVoice as PiperModel # local import: heavy dep | |
| 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: # noqa: BLE001 - skip individual bad voices | |
| 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: | |
| # piper-tts >= 1.3 renames the WAV writer to ``synthesize_wav``; | |
| # the old ``synthesize(text, wav_file)`` no longer sets the WAV | |
| # header (raising "# channels not specified"). Support both. | |
| 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) | |