Этап 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
+53
View File
@@ -0,0 +1,53 @@
"""Воспроизведение звука с возможностью быстро прервать."""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
import sounddevice as sd
# Как часто проверяем, не пора ли остановиться
_BLOCK_SECONDS = 0.05
class AudioPlayer:
"""Не потокобезопасен: все методы вызываются из одного потока воспроизведения."""
def __init__(self) -> None:
self._stream: sd.OutputStream | None = None
def play(
self, audio: np.ndarray, sample_rate: int, should_continue: Callable[[], bool]
) -> None:
"""Блокирует до конца фрагмента; если should_continue() вернул False — обрывает звук."""
stream = self._open(sample_rate)
samples = np.asarray(audio, dtype=np.float32).reshape(-1, 1)
block = max(1, int(sample_rate * _BLOCK_SECONDS))
for start in range(0, len(samples), block):
if not should_continue():
self.abort()
return
stream.write(samples[start : start + block])
def finish(self) -> None:
"""Дожидается, пока доиграет буфер, и освобождает устройство."""
if self._stream is not None:
stream, self._stream = self._stream, None
stream.stop()
stream.close()
def abort(self) -> None:
"""Немедленно глушит звук и освобождает устройство."""
if self._stream is not None:
stream, self._stream = self._stream, None
stream.abort()
stream.close()
def _open(self, sample_rate: int) -> sd.OutputStream:
if self._stream is not None and self._stream.samplerate != sample_rate:
self.finish()
if self._stream is None:
self._stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32")
self._stream.start()
return self._stream