"""Recorder — push-to-talk запись в RAM. Схема работы в будущем ассистенте: клавиша «Слушай» нажата → recorder.start() клавиша отпущена → audio = recorder.stop() → STT Особенности: - пробует открыть вход на 16 кГц; если устройство не умеет — пишет в нативной частоте и ресемплирует в 16 кГц при stop() (нужно STT); - поток в фоновом потоке PortAudio, данные копируются в список блоков; - слишком короткие записи (< min_seconds) возвращаются пустыми — защита от случайных кликов; - потоко-безопасность через threading.Lock. """ from __future__ import annotations import threading import time from typing import Callable, List, Optional import numpy as np import sounddevice as sd from .resample import TARGET_RATE, resample_to_16k class Recorder: def __init__( self, samplerate: int = TARGET_RATE, device: Optional[int] = None, channels: int = 1, blocksize: int = 800, # 50 мс при 16 кГц max_seconds: float = 60.0, # защита от «забытой» клавиши min_seconds: float = 0.3, # короче — считаем пустой записью on_overrun: Optional[Callable[[str], None]] = None, # колбэк о проблемах ) -> None: self._target_sr = samplerate self._device = device self._channels = channels self._blocksize = blocksize self._max_seconds = max_seconds self._min_seconds = min_seconds self._on_overrun = on_overrun self._frames: List[np.ndarray] = [] self._stream: Optional[sd.InputStream] = None self._native_sr: int = samplerate self._started_at: float = 0.0 self._lock = threading.Lock() # --- состояние ------------------------------------------------------- @property def is_recording(self) -> bool: return self._stream is not None and self._stream.active @property def duration(self) -> float: """Длительность текущей записи в секундах (0 если не пишем).""" if not self.is_recording: return 0.0 return time.perf_counter() - self._started_at @property def native_samplerate(self) -> int: """Частота, на которой реально шло устройство (до ресемплинга).""" return self._native_sr # --- управление ------------------------------------------------------ def _callback(self, indata, frames, time_info, status) -> None: # noqa: ANN001 if status and self._on_overrun: self._on_overrun(str(status)) self._frames.append(indata.copy()) def start(self) -> None: """Начать запись. Бросает RuntimeError, если запись уже идёт.""" if self.is_recording: raise RuntimeError("Запись уже идёт") with self._lock: self._frames = [] stream = self._try_open(self._target_sr) if stream is None: # Устройство не поддерживает 16 кГц — пишем в нативной и ресемплируем при stop() self._native_sr = int( sd.query_devices(self._device, "input")["default_samplerate"] ) stream = self._try_open(self._native_sr, required=True) else: self._native_sr = self._target_sr stream.start() self._stream = stream self._started_at = time.perf_counter() def stop(self) -> np.ndarray: """Остановить запись и вернуть float32 mono, 16 кГц, [-1..1].""" stream, self._stream = self._stream, None if stream is None: return np.zeros(0, dtype=np.float32) stream.stop() stream.close() with self._lock: blocks = self._frames self._frames = [] audio = np.concatenate(blocks, axis=0) if blocks else np.zeros((0, self._channels)) mono = audio.mean(axis=1) if audio.ndim > 1 else audio mono = np.clip(mono, -1.0, 1.0).astype(np.float32) if self._native_sr != self._target_sr: mono = resample_to_16k(mono, self._native_sr, self._target_sr) min_len = int(self._min_seconds * self._target_sr) if len(mono) < min_len: return np.zeros(0, dtype=np.float32) # слишком коротко — пусто return mono def cancel(self) -> None: """Остановить запись, выбросив данные (например, нажали «Замолчи»).""" stream, self._stream = self._stream, None if stream is not None: stream.stop() stream.close() with self._lock: self._frames = [] # --- внутреннее ------------------------------------------------------ def _try_open(self, samplerate: int, required: bool = False) -> Optional[sd.InputStream]: try: return sd.InputStream( samplerate=samplerate, device=self._device, channels=self._channels, dtype="float32", blocksize=self._blocksize, callback=self._callback, ) except (sd.PortAudioError, OSError) as exc: if required: raise RuntimeError(f"Не удалось открыть входное устройство: {exc}") from exc return None