first commit
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user