Files
Blind/modules/tts/provider_edge.py
T
2026-09-11 19:47:15 +03:00

208 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Провайдер TTS на edge-tts (онлайн-сервис Microsoft, без ключа и бесплатно).
Плюсы: отличные русские голоса (Svetlana/Dmitry), rate/volume/pitch «из коробки».
Риск: неофициальный эндпоинт — исторически ломался (403). Поэтому в проекте
есть офлайн-фолбэк Piper, а провайдер создаётся через фабрику get_provider().
Скорость речи по ТЗ: −15% (rate="-15%").
Латентность: по умолчанию ВЕСЬ текст синтезируется ОДНИМ запросом (1 сетевой
round-trip, ~1–2 c), паузы между предложениями удлиняются многоточиями
(edge-tts читает «…» как длинную паузу). Режим per_sentence=True синтезирует
каждое предложение отдельным запросом и вставляет точную тишину
sentence_pause_sec — медленнее (N запросов), нужен только для тонкой подгонки пауз.
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import time
from typing import List, Optional, Union
import numpy as np
from ..audio_io.codec import decode_audio_bytes
from .base import SynthResult, TtsProvider
from .text_split import insert_pauses, split_sentences
def _fmt_prosody(value: Union[int, str], unit: str) -> str:
"""int → '+15%' / '-15%' (edge-tts требует знак и единицу)."""
if isinstance(value, int):
return f"{value:+d}{unit}"
return str(value)
def _run_async(coro):
"""Выполнить корутину из синхронного кода.
Если event loop уже крутится (например, поток aiogram в Модуле 6) —
выполняем в отдельном потоке, чтобы не ломать чужой цикл.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(asyncio.run, coro).result()
class EdgeTtsProvider(TtsProvider):
name = "edge"
def __init__(
self,
voice: str = "ru-RU-SvetlanaNeural", # мужской: ru-RU-DmitryNeural
rate: Union[int, str] = -15, # −15% скорости по ТЗ
volume: Union[int, str] = 0, # +0% (громкость задаётся в Player)
pitch: Union[int, str] = 0, # +0 Гц
sentence_pause_sec: float = 0.35, # точная пауза (только per_sentence)
per_sentence: bool = False, # False = один запрос (быстро)
) -> None:
self._voice = voice
self._rate = _fmt_prosody(rate, "%")
self._volume = _fmt_prosody(volume, "%")
self._pitch = _fmt_prosody(pitch, "Hz")
self._pause = float(sentence_pause_sec)
self._per_sentence = bool(per_sentence)
self._cache: dict[str, SynthResult] = {} # повторные фразы (прощание и т.п.) — 0 c
# --- API -------------------------------------------------------------
def warmup(self) -> None:
"""Прогрев: короткий синтез (резолв DNS, TLS-сессия, соединение с сервисом).
Вызывается ассистентом в фоне при старте — первый реальный ответ
синтезируется за ~0.5 c вместо 2.5–4 c холодного соединения.
"""
try:
self.synthesize("Слушаю вас.")
except Exception:
pass
def stream(self, text: str):
"""Потоковая озвучка: чанки-предложения по мере готовности.
Yields (audio: np.ndarray, samplerate: int) — первый чанк приходит
через ~один сетевой round-trip (~0.5 c), не дожидаясь синтеза всего
текста. Пауза sentence_pause_sec уже вставлена В КОНЕЦ каждого чанка.
"""
sentences = split_sentences(text) or [text]
if len(sentences) == 1:
audio, sr = decode_audio_bytes(self._generate_one(text))
yield audio, sr
return
# все запросы параллельно; отдаём в порядке следования предложений
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(len(sentences), 4)) as pool:
for mp3 in pool.map(self._generate_one, sentences):
audio, sr = decode_audio_bytes(mp3)
yield audio, sr
def synthesize(self, text: str) -> SynthResult:
t0 = time.perf_counter()
cached = self._cache.get(text)
if cached is not None:
return SynthResult(
text=cached.text, audio=cached.audio, samplerate=cached.samplerate,
generation_sec=0.0, provider=self.name,
sentence_count=cached.sentence_count,
)
if self._per_sentence:
result = self._synthesize_stitched(text, t0)
else:
result = self._synthesize_single(text, t0)
if len(text) <= 120: # кэшируем только короткие (прощание, отказы)
self._cache[text] = result
return result
def _synthesize_single(self, text: str, t0: float) -> SynthResult:
"""Минимальная латентность: предложения синтезируются ПАРАЛЛЕЛЬНО
(каждый edge-запрос несёт ~1.3 c сетевого round-trip; параллельно —
суммарное время = самый долгий запрос, а не сумма), затем склеиваются
с тишиной sentence_pause_sec между ними."""
sentences = split_sentences(text) or [text]
if len(sentences) == 1:
audio, samplerate = decode_audio_bytes(self._generate_one(text))
return SynthResult(
text=text,
audio=audio,
samplerate=samplerate,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=1,
)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(len(sentences), 4)) as pool:
mp3_list = list(pool.map(self._generate_one, sentences))
pieces: List[np.ndarray] = []
samplerate: Optional[int] = None
pause_samples = int(self._pause * 24000) # уточним после первого декода
for i, mp3 in enumerate(mp3_list):
audio, sr = decode_audio_bytes(mp3)
if samplerate is None:
samplerate = sr
pause_samples = int(self._pause * sr)
pieces.append(audio)
if i < len(mp3_list) - 1 and self._pause > 0:
pieces.append(np.zeros(pause_samples, dtype=np.float32))
combined = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
return SynthResult(
text=text,
audio=combined,
samplerate=samplerate or 24000,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=len(sentences),
)
def _synthesize_stitched(self, text: str, t0: float) -> SynthResult:
"""Каждое предложение отдельным запросом + точная пауза-тишина (медленно)."""
sentences = split_sentences(text) or [text]
pieces: List[np.ndarray] = []
samplerate: Optional[int] = None
for i, sentence in enumerate(sentences):
audio, sr = decode_audio_bytes(self._generate_one(sentence))
if samplerate is None:
samplerate = sr
pieces.append(audio)
if i < len(sentences) - 1 and self._pause > 0:
pieces.append(np.zeros(int(self._pause * sr), dtype=np.float32))
combined = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
return SynthResult(
text=text,
audio=combined,
samplerate=samplerate or 24000,
generation_sec=time.perf_counter() - t0,
provider=self.name,
sentence_count=len(sentences),
)
# --- внутреннее ------------------------------------------------------
def _generate_one(self, sentence: str) -> bytes:
"""mp3-байты одного предложения."""
import edge_tts
async def _inner() -> bytes:
com = edge_tts.Communicate(
sentence,
self._voice,
rate=self._rate,
volume=self._volume,
pitch=self._pitch,
)
chunks: List[bytes] = []
async for item in com.stream():
if item["type"] == "audio":
chunks.append(item["data"])
return b"".join(chunks)
return _run_async(_inner())