Этап 2: озвучка ответов через Silero
- Потоковая озвучка: ответ режется на фразы, синтез и воспроизведение в отдельных потоках - Подготовка текста: числа словами, без markdown, ссылок и блоков кода - Модель Silero скачивается в LOCALAPPDATA и прогревается при старте - Состояние «говорю», переключатель озвучки в трее, «Стоп» прерывает речь - Тесты нарезки текста и оркестратора озвучки Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7933613234
commit
fec52dd1c2
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user