File size: 3,700 Bytes
5838d6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab75466
 
 
 
 
 
 
5838d6a
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
"""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)