89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""Player — воспроизведение с мгновенной остановкой.
|
||
|
||
Клавиша «Замолчи» = player.stop(): sounddevice.stop() прерывает вывод
|
||
в течение ~миллисекунд, очередь очищается.
|
||
|
||
Воспроизведение идёт в фоновом потоке; is_playing отражает состояние.
|
||
last_start_latency — сколько времени занял вызов sd.play() (открытие потока
|
||
вывода до первого блока): замеряется для диагностики латентности ответа.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional, Union
|
||
|
||
import numpy as np
|
||
import sounddevice as sd
|
||
import soundfile as sf
|
||
|
||
|
||
class Player:
|
||
def __init__(self, device: Optional[int] = None, volume: float = 1.0) -> None:
|
||
self._device = device
|
||
self.volume = volume # 0.0 .. 2.0
|
||
self._lock = threading.Lock()
|
||
self._playing = False
|
||
self._thread: Optional[threading.Thread] = None
|
||
self.last_start_latency = 0.0 # сек: длительность вызова sd.play()
|
||
|
||
@property
|
||
def is_playing(self) -> bool:
|
||
return self._playing
|
||
|
||
def play(self, data: np.ndarray, samplerate: int, blocking: bool = False,
|
||
lead_silence_sec: float = 0.1) -> None:
|
||
"""Воспроизвести float32 mono/stereo. Не блокирует (если blocking=False).
|
||
|
||
lead_silence_sec — тишина в начале: защита от «съедания» первого слова
|
||
при открытии потока вывода (недозаполненный буфер на некоторых ALSA/Pulse).
|
||
"""
|
||
audio = np.asarray(data, dtype=np.float32)
|
||
if audio.ndim == 1:
|
||
audio = audio[:, np.newaxis] # (frames,) → (frames, 1)
|
||
if lead_silence_sec > 0:
|
||
pad = np.zeros((int(lead_silence_sec * samplerate), audio.shape[1]),
|
||
dtype=np.float32)
|
||
audio = np.concatenate([pad, audio])
|
||
gain = float(np.clip(self.volume, 0.0, 2.0))
|
||
if gain != 1.0:
|
||
audio = np.clip(audio * gain, -1.0, 1.0)
|
||
|
||
with self._lock:
|
||
# Новая команда play отменяет предыдущую (накладывать нельзя)
|
||
sd.stop()
|
||
|
||
def _run() -> None:
|
||
try:
|
||
t0 = time.perf_counter()
|
||
sd.play(audio, samplerate, device=self._device)
|
||
self.last_start_latency = time.perf_counter() - t0
|
||
sd.wait() # вернётся сразу после stop() или конца аудио
|
||
finally:
|
||
self._playing = False
|
||
|
||
self._playing = True
|
||
if blocking:
|
||
_run()
|
||
else:
|
||
self._thread = threading.Thread(target=_run, daemon=True)
|
||
self._thread.start()
|
||
|
||
def play_file(self, path: Union[str, Path], blocking: bool = False) -> None:
|
||
"""Воспроизвести wav/flac/ogg с диска."""
|
||
path = Path(path)
|
||
data, sr = sf.read(str(path), dtype="float32", always_2d=False)
|
||
self.play(data, sr, blocking=blocking)
|
||
|
||
def stop(self) -> None:
|
||
"""Мгновенно заглушить воспроизведение (клавиша «Замолчи»)."""
|
||
with self._lock:
|
||
sd.stop()
|
||
self._playing = False
|
||
|
||
def wait(self) -> None:
|
||
"""Дождаться конца текущего воспроизведения."""
|
||
t = self._thread
|
||
if t is not None and t.is_alive():
|
||
t.join() |