208 lines
9.4 KiB
Python
208 lines
9.4 KiB
Python
"""Провайдер 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()) |