- Потоковая озвучка: ответ режется на фразы, синтез и воспроизведение в отдельных потоках - Подготовка текста: числа словами, без markdown, ссылок и блоков кода - Модель Silero скачивается в LOCALAPPDATA и прогревается при старте - Состояние «говорю», переключатель озвучки в трее, «Стоп» прерывает речь - Тесты нарезки текста и оркестратора озвучки Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54 lines
2.1 KiB
Python
54 lines
2.1 KiB
Python
"""Воспроизведение звука с возможностью быстро прервать."""
|
|
|
|
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
|