79 lines
3.0 KiB
Python
79 lines
3.0 KiB
Python
"""Провайдер TTS на Piper — полностью офлайн-фолбэк.
|
|
|
|
Используется, если edge-tts недоступен (сервис Microsoft ломался в прошлом,
|
|
а интернет у отца может пропасть). Piper работает локально на CPU.
|
|
|
|
Установка и голос (однократно):
|
|
pip install piper-tts
|
|
python -m piper.download_voices ru_RU-dmitri-medium --download-dir models/piper
|
|
|
|
Пайпер медленнее edge-tts и голоса проще, но работает без сети.
|
|
API: PiperVoice.load(path); voice.synthesize(text) -> чанки AudioChunk
|
|
(audio_int16_bytes, sample_rate, sample_width, sample_channels).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import numpy as np
|
|
|
|
from .base import SynthResult, TtsProvider
|
|
from .text_split import split_sentences
|
|
|
|
|
|
class PiperTtsProvider(TtsProvider):
|
|
name = "piper"
|
|
|
|
def __init__(
|
|
self,
|
|
model_path: Union[str, Path],
|
|
length_scale: float = 1.15, # 1.0 = обычная скорость; 1.15 ≈ −15%
|
|
noise_scale: float = 0.667,
|
|
noise_w_scale: float = 0.8,
|
|
sentence_pause_sec: float = 0.35,
|
|
) -> None:
|
|
from piper import PiperVoice, SynthesisConfig # ленивый импорт
|
|
|
|
path = Path(model_path)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(
|
|
f"Модель Piper не найдена: {path}\n"
|
|
f"Скачай голос: python -m piper.download_voices ru_RU-dmitri-medium "
|
|
f"--download-dir {path.parent}"
|
|
)
|
|
self._voice = PiperVoice.load(str(path))
|
|
self._config = SynthesisConfig(
|
|
length_scale=length_scale,
|
|
noise_scale=noise_scale,
|
|
noise_w_scale=noise_w_scale,
|
|
)
|
|
self._pause = float(sentence_pause_sec)
|
|
|
|
def synthesize(self, text: str) -> SynthResult:
|
|
t0 = time.perf_counter()
|
|
sentences = split_sentences(text) or [text]
|
|
|
|
pieces: List[np.ndarray] = []
|
|
samplerate: Optional[int] = None
|
|
|
|
for i, sentence in enumerate(sentences):
|
|
chunks = list(self._voice.synthesize(sentence, syn_config=self._config))
|
|
for ch in chunks:
|
|
if samplerate is None:
|
|
samplerate = ch.sample_rate
|
|
pcm = np.frombuffer(ch.audio_int16_bytes, dtype=np.int16)
|
|
pieces.append(pcm.astype(np.float32) / 32768.0)
|
|
if i < len(sentences) - 1 and self._pause > 0 and samplerate:
|
|
pieces.append(np.zeros(int(self._pause * samplerate), dtype=np.float32))
|
|
|
|
audio = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
|
|
return SynthResult(
|
|
text=text,
|
|
audio=audio,
|
|
samplerate=samplerate or 22050,
|
|
generation_sec=time.perf_counter() - t0,
|
|
provider=self.name,
|
|
sentence_count=len(sentences),
|
|
) |