Этап 2: озвучка ответов через Silero

- Потоковая озвучка: ответ режется на фразы, синтез и воспроизведение в отдельных потоках
- Подготовка текста: числа словами, без markdown, ссылок и блоков кода
- Модель Silero скачивается в LOCALAPPDATA и прогревается при старте
- Состояние «говорю», переключатель озвучки в трее, «Стоп» прерывает речь
- Тесты нарезки текста и оркестратора озвучки

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 03:20:07 +03:00
co-authored by Claude Opus 5
parent 7933613234
commit fec52dd1c2
19 changed files with 1512 additions and 18 deletions
+126
View File
@@ -0,0 +1,126 @@
import threading
import time
from collections.abc import Callable
import numpy as np
import pytest
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.speech import Speaker
@pytest.fixture(scope="module")
def qapp() -> QCoreApplication:
return QCoreApplication.instance() or QCoreApplication([])
class FakeEngine:
sample_rate = 1000
def __init__(self, fail_on: str | None = None) -> None:
self.fail_on = fail_on
def load(self) -> None:
pass
def synthesize(self, text: str) -> np.ndarray:
if text == self.fail_on:
raise RuntimeError("boom")
return np.zeros(10, dtype=np.float32)
class FakePlayer:
def __init__(self, play_seconds: float = 0.0) -> None:
self.play_seconds = play_seconds
self.played = 0
self.finished = 0
self.aborted = 0
self.lock = threading.Lock()
def play(self, audio: np.ndarray, sample_rate: int, should_continue: Callable[[], bool]) -> None:
deadline = time.monotonic() + self.play_seconds
while time.monotonic() < deadline:
if not should_continue():
self.abort()
return
time.sleep(0.005)
with self.lock:
self.played += 1
def finish(self) -> None:
self.finished += 1
def abort(self) -> None:
self.aborted += 1
def _wait_until(condition: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
def test_reply_is_spoken_sentence_by_sentence(qapp: QCoreApplication) -> None:
player = FakePlayer()
speaker = Speaker(FakeEngine(), player, enabled=True)
events: list[str] = []
speaker.playback_started.connect(lambda: events.append("started"))
speaker.finished.connect(lambda: events.append("finished"))
speaker.begin()
speaker.feed("Раз. Два! ")
speaker.feed("Три")
speaker.end()
_wait_until(lambda: "finished" in events)
assert events == ["started", "finished"]
assert player.played == 3
assert player.finished == 1
assert not speaker.is_active
def test_stop_interrupts_playback(qapp: QCoreApplication) -> None:
player = FakePlayer(play_seconds=0.5)
speaker = Speaker(FakeEngine(), player, enabled=True)
finished: list[bool] = []
speaker.finished.connect(lambda: finished.append(True))
speaker.begin()
speaker.feed("Первая фраза. Вторая фраза. ")
_wait_until(lambda: speaker.is_playing)
speaker.stop()
assert finished == [True]
_wait_until(lambda: player.aborted >= 1)
time.sleep(0.1)
assert player.played == 0
def test_disabled_speaker_ignores_replies(qapp: QCoreApplication) -> None:
player = FakePlayer()
speaker = Speaker(FakeEngine(), player, enabled=False)
speaker.begin()
speaker.feed("Привет. ")
speaker.end()
assert not speaker.is_active
time.sleep(0.1)
assert player.played == 0
def test_synthesis_error_stops_reply_and_reports(qapp: QCoreApplication) -> None:
speaker = Speaker(FakeEngine(fail_on="Плохо."), FakePlayer(), enabled=True)
errors: list[str] = []
speaker.error_occurred.connect(errors.append)
speaker.begin()
speaker.feed("Плохо. Хорошо. Ещё. ")
speaker.end()
_wait_until(lambda: bool(errors))
assert len(errors) == 1
assert "boom" in errors[0]
assert not speaker.is_active
+50
View File
@@ -0,0 +1,50 @@
from agr_assistent.tts.text import SpeechTextStream, normalize_for_speech, split_long_text
def _feed_all(chunks: list[str]) -> list[str]:
stream = SpeechTextStream()
result = []
for chunk in chunks:
result.extend(stream.feed(chunk))
return result + stream.flush()
def test_sentences_are_emitted_as_soon_as_complete() -> None:
stream = SpeechTextStream()
assert stream.feed("Привет! Как ") == ["Привет!"]
assert stream.feed("дела? Хоро") == ["Как дела?"]
assert stream.flush() == ["Хоро"]
def test_decimal_numbers_and_list_markers_do_not_split() -> None:
assert _feed_all(["Будет 3.", "5 градуса.\n", "1. Первый пункт"]) == [
"Будет три целых пять десятых градуса.",
"Первый пункт",
]
def test_code_blocks_are_skipped() -> None:
chunks = ["Пример:\n``", "`python\nprint('Привет')\n", "```\nГотово."]
assert _feed_all(chunks) == ["Пример:", "Готово."]
def test_normalize_removes_markdown_links_and_latin() -> None:
text = "**Важно**: смотри [документацию](https://x.io) и https://y.io, Python тоже 😀"
assert normalize_for_speech(text) == "Важно: смотри документацию и ссылка, тоже"
def test_text_without_cyrillic_is_not_spoken() -> None:
assert normalize_for_speech("Hello, world! 👋") == ""
assert _feed_all(["Hello world.\n"]) == []
def test_long_text_is_split_on_commas_and_spaces() -> None:
text = ", ".join(["слово"] * 100)
parts = split_long_text(text, limit=50)
assert all(len(part) <= 50 for part in parts)
assert " ".join(parts).replace(" ,", ",") == text