"""Провайдер 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())