first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
"""Провайдер 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())