first commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Модуль 3: TTS (текст → звук)
|
||||
|
||||
Два провайдера за общим интерфейсом `TtsProvider`: переключение = выбор в конфиге.
|
||||
|
||||
| Провайдер | Сеть | Латентность (110 симв.) | Голоса | Роль |
|
||||
|---|---|---|---|---|
|
||||
| **edge** (`provider_edge.py`) | онлайн, бесплатный сервис MS, без ключа | 3.0 c (4 предл.), 1.7 c (короткая) | Svetlana/Dmitry, отличное качество | основной |
|
||||
| **piper** (`provider_piper.py`) | офлайн, локально на CPU | 0.47 c | dmitri/irina/ruslan, проще | фолбэк без сети |
|
||||
|
||||
## Режимы edge-провайдера
|
||||
|
||||
- **Один запрос** (по умолчанию): весь текст → один round-trip (~1.7–3 c). Паузы между
|
||||
предложениями удлиняются многоточиями («точка» → «…» — edge-tts читает как длинную паузу).
|
||||
- **per_sentence=True**: каждое предложение отдельным запросом + точная тишина
|
||||
`sentence_pause_sec` между ними. Медленнее (N запросов), нужен для тонкой подгонки пауз.
|
||||
|
||||
Замер 2026-09-11 (свежие соединения в каждом процессе): один запрос 110 симв = 3.0 c,
|
||||
короткая фраза 36 симв = 1.7 c, Piper 110 симв = 0.47 c. В живой программе соединение
|
||||
переиспользуется — будет быстрее. Цепочка целиком: STT ~1.2 c + LLM ~1–2 c + TTS ~1.7 c ≈ 4–5 c
|
||||
от отпускания клавиши до голоса (маскируется earcon «думаю»).
|
||||
|
||||
## Использование из кода
|
||||
|
||||
```python
|
||||
from modules.tts import get_provider
|
||||
|
||||
tts = get_provider("edge") # voice="ru-RU-DmitryNeural" — мужской
|
||||
result = tts.synthesize("Привет! Как дела?")
|
||||
# result.audio (float32), result.samplerate, result.generation_sec, result.duration_sec
|
||||
```
|
||||
|
||||
Фолбэк при обрыве сети (switch = одна строка):
|
||||
|
||||
```python
|
||||
tts = get_provider("piper", model_path="models/piper/ru_RU-dmitri-medium.onnx")
|
||||
```
|
||||
|
||||
## Потоковая озвучка (стриминг, 2026-09-11)
|
||||
|
||||
`tts.stream(text)` — генератор: отдаёт чанки-предложения **по мере готовности**
|
||||
(параллельные запросы, отдача в порядке следования). `Assistant` играёт чанк
|
||||
сразу, не дожидаясь синтеза всего текста:
|
||||
|
||||
- первое предложение звучит через ~0.5 c после готовности LLM (один round-trip);
|
||||
- длинные ответы больше не пропорционально тормозят (2-е/3-е предложение
|
||||
синтезируются, пока играет 1-е);
|
||||
- между чанками вставляется пауза sentence_pause_sec;
|
||||
- перебивание отцом гасит поток и синтез (счётчик поколений).
|
||||
|
||||
Плюс в `Player.play()` добавлен префикс тишины 0.1 c (`lead_silence_sec`) —
|
||||
защита от «съедания» первого слова при открытии аудио-потока (недозаполненный
|
||||
буфер ALSA/Pulse).
|
||||
|
||||
## Настройка из .env (2026-09-11)
|
||||
|
||||
```
|
||||
TTS_PROVIDER=edge # edge | piper
|
||||
TTS_VOICE=ru-RU-SvetlanaNeural # мужской: ru-RU-DmitryNeural
|
||||
TTS_RATE=+15 # темп речи, % (+ = быстрее; ≈+30% к исходному −15)
|
||||
PIPER_MODEL=models/piper/ru_RU-dmitri-medium.onnx
|
||||
```
|
||||
|
||||
Выбранный конфиг юзером: **edge + Svetlana, rate +15** («женский голос лучше и темп
|
||||
нормальный»). Piper остаётся офлайн-фолбэком (0.22 c синтез против 0.49 c у edge),
|
||||
при желании ускориться — одна строка в .env.
|
||||
|
||||
Фабрика: `make_tts()` из `modules/tts` — тесты ассистента читают эти параметры
|
||||
автоматически, смену голоса/скорости/провайдера можно делать без правки кода.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `base.py` | Интерфейс `TtsProvider`, результат `SynthResult` |
|
||||
| `provider_edge.py` | edge-tts: один запрос / сшивание предложений |
|
||||
| `provider_piper.py` | Piper офлайн (`length_scale` из rate автоматом в config) |
|
||||
| `config.py` | `make_tts()` — фабрика из .env (провайдер, голос, скорость) |
|
||||
| `text_split.py` | Разбивка на предложения; защита сокращений («т.д.», «т.к.») |
|
||||
| `test_tts.py` | Тест-скрипт (синтез / воспроизведение / интерактив / провайдеры) |
|
||||
|
||||
## Тест-скрипт
|
||||
|
||||
```bash
|
||||
.venv/bin/python modules/tts/test_tts.py --play # фраза по умолчанию + звук
|
||||
.venv/bin/python modules/tts/test_tts.py --text "Своё предложение"
|
||||
.venv/bin/python modules/tts/test_tts.py --voice ru-RU-DmitryNeural --play # мужской
|
||||
.venv/bin/python modules/tts/test_tts.py --listen # вводишь текст — слышишь
|
||||
.venv/bin/python modules/tts/test_tts.py --per-sentence # медленный режим (сравнение)
|
||||
.venv/bin/python modules/tts/test_tts.py --provider piper --model models/piper/ru_RU-dmitri-medium.onnx --play
|
||||
```
|
||||
|
||||
## Критерии приёмки Модуля 3
|
||||
|
||||
1. ✅ Русский голос звучит естественно, скорость −15% — принято юзером на слух (Svetlana).
|
||||
2. ✅ Паузы между предложениями слышны (многоточия / точная тишина).
|
||||
3. ✅ Синтез короткой фразы ≤ 3 c: 1.7 c (edge), 0.47 c (piper).
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- edge-tts — неофициальный API Microsoft: исторически ломался (403). План Б — Piper, он уже в проекте.
|
||||
- Piper: голос скачивается один раз в `models/piper/` (команда в docstring провайдера);
|
||||
качество проще edge, зато 0.5 c и полностью офлайн.
|
||||
- В `provider_edge._run_async` учтён будущий asyncio-контекст (aiogram в Модуле 6):
|
||||
синтез не сломает чужой event loop.
|
||||
@@ -0,0 +1,44 @@
|
||||
"""TTS-модуль: текст → звук.
|
||||
|
||||
Использование из других модулей:
|
||||
|
||||
from modules.tts import get_provider
|
||||
|
||||
tts = get_provider("edge") # онлайн, голос MS
|
||||
tts = get_provider("piper", model_path="...") # офлайн-фолбэк
|
||||
result = tts.synthesize("Привет! Как дела?")
|
||||
# result.audio (float32), result.samplerate, result.generation_sec
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import SynthResult, TtsProvider
|
||||
from .config import make_tts
|
||||
from .provider_edge import EdgeTtsProvider
|
||||
from .provider_piper import PiperTtsProvider
|
||||
from .text_split import split_sentences
|
||||
|
||||
__all__ = [
|
||||
"SynthResult",
|
||||
"TtsProvider",
|
||||
"EdgeTtsProvider",
|
||||
"PiperTtsProvider",
|
||||
"make_tts",
|
||||
"split_sentences",
|
||||
"get_provider",
|
||||
]
|
||||
|
||||
_PROVIDERS = {
|
||||
EdgeTtsProvider.name: EdgeTtsProvider,
|
||||
PiperTtsProvider.name: PiperTtsProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> TtsProvider:
|
||||
"""Фабрика провайдеров по имени (выбор из конфига)."""
|
||||
try:
|
||||
provider_cls = _PROVIDERS[name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Неизвестный TTS-провайдер: {name!r}. Доступны: {sorted(_PROVIDERS)}"
|
||||
) from None
|
||||
return provider_cls(**kwargs)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Базовый интерфейс TTS-модуля (текст → звук).
|
||||
|
||||
Провайдеры (edge-tts онлайн, piper офлайн) реализуют TtsProvider,
|
||||
чтобы остальной проект не зависел от конкретного синтезатора:
|
||||
если edge-tts отвалится (неофициальный эндпоинт Microsoft), переключение
|
||||
на Piper — одна строка в конфиге.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class SynthResult:
|
||||
"""Результат синтеза одной фразы."""
|
||||
|
||||
text: str
|
||||
audio: np.ndarray # float32 mono [-1..1]
|
||||
samplerate: int
|
||||
generation_sec: float # время генерации (без воспроизведения)
|
||||
provider: str = ""
|
||||
sentence_count: int = 1
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> float:
|
||||
if self.samplerate <= 0:
|
||||
return 0.0
|
||||
return len(self.audio) / self.samplerate
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (f"[{self.provider}] текст {len(self.text)} симв. → "
|
||||
f"аудио {self.duration_sec:.1f} c, синтез {self.generation_sec:.2f} c, "
|
||||
f"{self.samplerate} Гц, предложений: {self.sentence_count}")
|
||||
|
||||
|
||||
class TtsProvider(ABC):
|
||||
"""Интерфейс провайдера синтеза речи."""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def synthesize(self, text: str) -> SynthResult:
|
||||
"""Синтезировать речь. text — уже очищенный от Markdown текст."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Освободить ресурсы (по умолчанию — ничего)."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Настройки TTS из .env — единая точка выбора провайдера и скорости речи.
|
||||
|
||||
Параметры .env (все опциональны):
|
||||
TTS_PROVIDER=edge | piper (по умолчанию edge — лучше качество)
|
||||
TTS_VOICE=ru-RU-SvetlanaNeural (голос edge; мужской: ru-RU-DmitryNeural)
|
||||
TTS_RATE=+15 (скорость речи, %; +быстрее, −медленнее)
|
||||
PIPER_MODEL=models/piper/ru_RU-dmitri-medium.onnx
|
||||
|
||||
Про скорость: −15 был «для пожилых медленно» по исходному ТЗ; живой тест показал,
|
||||
что хочется быстрее (~+30% к этому темпу) → TTS_RATE=+15.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..brain.config import get_secret
|
||||
from .base import TtsProvider
|
||||
from .provider_edge import EdgeTtsProvider
|
||||
from .provider_piper import PiperTtsProvider
|
||||
|
||||
|
||||
def make_tts(provider: str | None = None, **overrides) -> TtsProvider:
|
||||
"""Создать TTS-провайдера по .env (+ переопределения в коде для тестов)."""
|
||||
name = (provider or get_secret("TTS_PROVIDER") or "edge").lower()
|
||||
rate = int(get_secret("TTS_RATE") or -15)
|
||||
|
||||
if name == "edge":
|
||||
kwargs = {
|
||||
"voice": get_secret("TTS_VOICE") or "ru-RU-SvetlanaNeural",
|
||||
"rate": rate,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return EdgeTtsProvider(**kwargs)
|
||||
|
||||
if name == "piper":
|
||||
# Piper: rate % → length_scale (1.15 ≈ −15%, 0.77 ≈ +30%)
|
||||
length_scale = round(100.0 / (100.0 + rate), 3)
|
||||
kwargs = {
|
||||
"model_path": get_secret("PIPER_MODEL") or "models/piper/ru_RU-dmitri-medium.onnx",
|
||||
"length_scale": length_scale,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return PiperTtsProvider(**kwargs)
|
||||
|
||||
raise ValueError(f"Неизвестный TTS_PROVIDER: {name!r} (доступны: edge, piper)")
|
||||
@@ -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())
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Провайдер TTS на Piper — полностью офлайн-фолбэк.
|
||||
|
||||
Используется, если edge-tts недоступен (сервис Microsoft ломался в прошлом,
|
||||
а интернет у отца может пропасть). Piper работает локально на CPU.
|
||||
|
||||
Установка и голос (однократно):
|
||||
pip install piper-tts
|
||||
python -m piper.download_voices ru_RU-dmitri-medium --download-dir models/piper
|
||||
|
||||
Пайпер медленнее edge-tts и голоса проще, но работает без сети.
|
||||
API: PiperVoice.load(path); voice.synthesize(text) -> чанки AudioChunk
|
||||
(audio_int16_bytes, sample_rate, sample_width, sample_channels).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .base import SynthResult, TtsProvider
|
||||
from .text_split import split_sentences
|
||||
|
||||
|
||||
class PiperTtsProvider(TtsProvider):
|
||||
name = "piper"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: Union[str, Path],
|
||||
length_scale: float = 1.15, # 1.0 = обычная скорость; 1.15 ≈ −15%
|
||||
noise_scale: float = 0.667,
|
||||
noise_w_scale: float = 0.8,
|
||||
sentence_pause_sec: float = 0.35,
|
||||
) -> None:
|
||||
from piper import PiperVoice, SynthesisConfig # ленивый импорт
|
||||
|
||||
path = Path(model_path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Модель Piper не найдена: {path}\n"
|
||||
f"Скачай голос: python -m piper.download_voices ru_RU-dmitri-medium "
|
||||
f"--download-dir {path.parent}"
|
||||
)
|
||||
self._voice = PiperVoice.load(str(path))
|
||||
self._config = SynthesisConfig(
|
||||
length_scale=length_scale,
|
||||
noise_scale=noise_scale,
|
||||
noise_w_scale=noise_w_scale,
|
||||
)
|
||||
self._pause = float(sentence_pause_sec)
|
||||
|
||||
def synthesize(self, text: str) -> SynthResult:
|
||||
t0 = time.perf_counter()
|
||||
sentences = split_sentences(text) or [text]
|
||||
|
||||
pieces: List[np.ndarray] = []
|
||||
samplerate: Optional[int] = None
|
||||
|
||||
for i, sentence in enumerate(sentences):
|
||||
chunks = list(self._voice.synthesize(sentence, syn_config=self._config))
|
||||
for ch in chunks:
|
||||
if samplerate is None:
|
||||
samplerate = ch.sample_rate
|
||||
pcm = np.frombuffer(ch.audio_int16_bytes, dtype=np.int16)
|
||||
pieces.append(pcm.astype(np.float32) / 32768.0)
|
||||
if i < len(sentences) - 1 and self._pause > 0 and samplerate:
|
||||
pieces.append(np.zeros(int(self._pause * samplerate), dtype=np.float32))
|
||||
|
||||
audio = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32)
|
||||
return SynthResult(
|
||||
text=text,
|
||||
audio=audio,
|
||||
samplerate=samplerate or 22050,
|
||||
generation_sec=time.perf_counter() - t0,
|
||||
provider=self.name,
|
||||
sentence_count=len(sentences),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Тест TTS-модуля (Модуль 3).
|
||||
|
||||
Запуск:
|
||||
python modules/tts/test_tts.py # синтез фразы по умолчанию, сохранить wav
|
||||
python modules/tts/test_tts.py --play # то же + воспроизвести
|
||||
python modules/tts/test_tts.py --text "Своё предложение"
|
||||
python modules/tts/test_tts.py --voice ru-RU-DmitryNeural --play
|
||||
python modules/tts/test_tts.py --rate -10 # другая скорость
|
||||
python modules/tts/test_tts.py --provider piper --model models/piper/ru_RU-dmitri-medium.onnx
|
||||
python modules/tts/test_tts.py --listen # интерактивно: вводишь текст — слышишь
|
||||
|
||||
Критерии приёмки Модуля 3:
|
||||
1. Русская фраза звучит естественно, скорость на ~15% медленнее обычной.
|
||||
2. Несколько предложений — с заметными паузами между ними.
|
||||
3. Синтез короткой фразы (до 100 символов) — <= 3 c.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from modules.audio_io import Player
|
||||
from modules.audio_io.codec import decode_audio_bytes # noqa: F401 (использование ниже)
|
||||
from modules.tts import get_provider
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from modules.audio_io import Player
|
||||
from modules.audio_io.codec import decode_audio_bytes # noqa: F401
|
||||
from modules.tts import get_provider
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
|
||||
DEFAULT_TEXT = (
|
||||
"Здравствуйте! Это проверка голоса ассистента. "
|
||||
"Сейчас проверим, насколько понятна речь. Погода сегодня хорошая."
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Тест TTS-модуля")
|
||||
p.add_argument("--text", default=DEFAULT_TEXT, help="текст для синтеза")
|
||||
p.add_argument("--provider", default="edge", choices=["edge", "piper"])
|
||||
p.add_argument("--voice", default="ru-RU-SvetlanaNeural",
|
||||
help="голос edge-tts (ru-RU-DmitryNeural — мужской)")
|
||||
p.add_argument("--rate", default=-15, help="скорость речи, %% (−15 по ТЗ)")
|
||||
p.add_argument("--model", default="models/piper/ru_RU-dmitri-medium.onnx",
|
||||
help="путь к .onnx модели Piper")
|
||||
p.add_argument("--pause", type=float, default=0.35, help="пауза между предложениями, с")
|
||||
p.add_argument("--per-sentence", action="store_true",
|
||||
help="режим «предложение = запрос» (сравнение латентности)")
|
||||
p.add_argument("--play", action="store_true", help="воспроизвести результат")
|
||||
p.add_argument("--listen", action="store_true",
|
||||
help="интерактивно: вводишь текст — слышишь звук")
|
||||
p.add_argument("--save", default=str(SAMPLES_DIR / "tts_out.wav"))
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def speak_once(tts, text: str, play: bool, save: Path) -> None:
|
||||
result = tts.synthesize(text)
|
||||
print(result)
|
||||
|
||||
save = Path(save)
|
||||
save.parent.mkdir(parents=True, exist_ok=True)
|
||||
import soundfile as sf
|
||||
sf.write(str(save), result.audio, result.samplerate)
|
||||
print(f"Сохранено: {save} ({len(result.audio) / result.samplerate:.1f} c)")
|
||||
|
||||
if play:
|
||||
player = Player()
|
||||
print("Воспроизвожу...")
|
||||
player.play(result.audio, result.samplerate, blocking=True)
|
||||
|
||||
|
||||
def listen_loop(tts) -> None:
|
||||
player = Player()
|
||||
print("Режим «слушать»: вводи текст, Enter — озвучить, пустая строка — выход.")
|
||||
while True:
|
||||
text = input("текст> ").strip()
|
||||
if not text:
|
||||
return
|
||||
result = tts.synthesize(text)
|
||||
print(f" синтез {result.generation_sec:.2f} c, аудио {result.duration_sec:.1f} c")
|
||||
player.play(result.audio, result.samplerate, blocking=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
kwargs = {"sentence_pause_sec": args.pause}
|
||||
if args.provider == "edge":
|
||||
kwargs.update(voice=args.voice, rate=int(args.rate), per_sentence=args.per_sentence)
|
||||
else:
|
||||
kwargs.update(model_path=args.model)
|
||||
|
||||
tts = get_provider(args.provider, **kwargs)
|
||||
|
||||
if args.listen:
|
||||
listen_loop(tts)
|
||||
else:
|
||||
speak_once(tts, args.text, args.play, args.save)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Разбиение текста на предложения — для вставки увеличенных пауз.
|
||||
|
||||
Пожилой человек воспринимает речь медленнее: между предложениями
|
||||
добавляем тишину (задаётся в провайдере как sentence_pause_sec).
|
||||
Минимальная защита от сокращений («т.д.», «т.п.» и т.п.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
# Сокращения, где точка — не конец предложения (после точки может быть «д»)
|
||||
_ABBREV = ["т.д", "т.п", "т.е", "т.к", "т.н", "др", "пр", "г", "ул", "кв", "руб", "мин"]
|
||||
_SENT_SPLIT = re.compile(r"(?<=[.!?…])\s+")
|
||||
|
||||
|
||||
def split_sentences(text: str) -> List[str]:
|
||||
"""Разбить текст на предложения (простая эвристика, без NLP)."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# Временно прячем точки в сокращениях
|
||||
protected = text
|
||||
for abbr in _ABBREV:
|
||||
protected = protected.replace(f" {abbr}.", f" {abbr}\x01")
|
||||
|
||||
parts = [p.strip() for p in _SENT_SPLIT.split(protected) if p.strip()]
|
||||
|
||||
# Возвращаем точки на место
|
||||
for abbr in _ABBREV:
|
||||
parts = [p.replace(f"{abbr}\x01", f"{abbr}.") for p in parts]
|
||||
return parts
|
||||
|
||||
|
||||
def insert_pauses(sentences: List[str]) -> str:
|
||||
"""Склеить предложения с «длинными» точками (… ) для естественных пауз в одном запросе."""
|
||||
return "… ".join(sentences) if len(sentences) > 1 else (sentences[0] if sentences else "")
|
||||
Reference in New Issue
Block a user