first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
"""HandsFreeRecorder — свободный голосовой режим БЕЗ клавиш (М5.2).
Постоянно слушает микрофон; решение «речь/тишина» принимает Silero-VAD:
- началась речь → копим аудио (с pre-buffer 0.4 c, чтобы не терять первые слова);
- тишина silence_sec (по умолчанию 2.0 — «пауза 2 секунды» юзера) → фраза
закончена → вызов self.on_phrase(audio, samplerate);
- короче min_speech_sec — шум, отбраковка (событие on_state("discard"));
- длиннее max_seconds — принудительный срез;
- на время ответа ассистента прослушивание ставится на паузу (suppress),
чтобы микрофон не слышал её из динамиков.
Колбэки — публичные атрибуты: их можно переприсвоить и извне
(Assistant присваивает recorder.on_phrase = ...).
Фильтр шума: голосом считается окно, у которого prob VAD выше порога И
RMS выше noise_gate (Silero на белом шуме даёт ~0.85 — одной вероятности мало).
"""
from __future__ import annotations
import time
import warnings
from typing import Callable, List, Optional
import numpy as np
import sounddevice as sd
from .resample import TARGET_RATE, resample_to_16k
_VAD_WINDOW = 512 # 32 мс при 16 кГц — родной размер окна Silero
_PRE_BUFFER_SEC = 0.4 # буфер ДО начала речи (не терять начало фразы)
class HandsFreeRecorder:
def __init__(
self,
samplerate: int = TARGET_RATE,
device: Optional[int] = None,
silence_sec: float = 2.0,
speech_threshold: float = 0.5,
min_speech_sec: float = 0.3,
max_seconds: float = 30.0,
noise_gate_rms: float = 0.006, # ниже RMS — окно считается тишиной, как бы VAD ни хотел
on_phrase: Optional[Callable[[np.ndarray, int], None]] = None,
on_state: Optional[Callable[[str], None]] = None, # listening|speech_start|speech_end|too_short|paused
on_error: Optional[Callable[[str], None]] = None,
debug: bool = False,
) -> None:
self._sr = samplerate
self._device = device
self._silence_sec = silence_sec
self._threshold = speech_threshold
self._min_speech_sec = min_speech_sec
self._max_seconds = max_seconds
self._noise_gate = noise_gate_rms
# Публичные колбэки: Assistant переприсваивает on_phrase — так и задумано
self.on_phrase: Callable[[np.ndarray, int], None] = on_phrase or (lambda a, s: None)
self.on_state: Callable[[str], None] = on_state or (lambda s: None)
self.on_error: Callable[[str], None] = on_error or (lambda m: print(f" [VAD] {m}"))
self._vad = None
self._stream = None
self._running = False
self._suppress = False
self._recording = False
self._chunks: List[np.ndarray] = []
self._pre_buffer: List[np.ndarray] = []
self._speech_frames = 0
self._silence_frames = 0
self._native_sr = samplerate
self._carry: np.ndarray = np.zeros(0, dtype=np.float32) # хвост между колбэками
# ---------------------------------------------------------------- lifecycle
def start(self) -> None:
"""Открыть микрофон и слушать. Ошибки уходят в on_error (не роняют программу)."""
try:
warnings.filterwarnings("ignore", category=Warning)
from silero_vad import load_silero_vad
self._vad = load_silero_vad()
stream = self._open(self._sr)
if stream is None:
self._native_sr = int(
sd.query_devices(self._device, "input")["default_samplerate"]
)
stream = self._open(self._native_sr, required=True)
else:
self._native_sr = self._sr
stream.start()
self._stream = stream
self._running = True
self.on_state("listening")
except Exception as exc:
self.on_error(f"Свободный режим не запустился: {exc}")
def stop(self) -> None:
self._running = False
stream, self._stream = self._stream, None
if stream is not None:
try:
stream.stop()
stream.close()
except Exception:
pass
# ---------------------------------------------------------------- режим
@property
def is_listening(self) -> bool:
return self._running and not self._suppress
def suppress(self, on: bool) -> None:
"""Пауза прослушивания (пока сами говорим) + сброс недозаписи."""
if self._suppress == on:
return
self._suppress = on
self.on_state("paused" if on else "listening")
if on:
self._recording = False
self._chunks = []
self._pre_buffer = []
self._carry = np.zeros(0, dtype=np.float32)
# ---------------------------------------------------------------- внутреннее
def _open(self, samplerate: int, required: bool = False):
try:
return sd.InputStream(
samplerate=samplerate,
device=self._device,
channels=1,
dtype="float32",
blocksize=512 if samplerate == 16000 else 0,
callback=self._on_audio,
)
except Exception as exc:
if required:
raise RuntimeError(f"Не удалось открыть микрофон: {exc}") from exc
return None
def _vad_prob(self, window: np.ndarray) -> float:
"""Уверенность VAD для окна (0..1). JIT-модель требует torch.Tensor."""
import torch
with torch.no_grad():
return float(self._vad(torch.from_numpy(window), 16000))
def _on_audio(self, indata, frames, time_info, status) -> None: # noqa: ANN001
if status:
self.on_error(str(status))
if self._suppress or self._vad is None:
return
mono = indata[:, 0].copy()
if self._native_sr != 16000:
mono16 = resample_to_16k(mono, self._native_sr)
else:
mono16 = mono
# накопитель между колбэками: окно 512 всегда полное, хвост не теряется
buf = np.concatenate([self._carry, mono16])
n = len(buf) // _VAD_WINDOW
for i in range(n):
self._process_window(buf[i * _VAD_WINDOW:(i + 1) * _VAD_WINDOW])
self._carry = buf[n * _VAD_WINDOW:]
def _process_window(self, window: np.ndarray) -> None:
rms = float(np.sqrt(np.mean(np.square(window))))
voice = self._vad_prob(window) > self._threshold and rms > self._noise_gate
if voice:
if not self._recording:
self._recording = True
self._chunks = list(self._pre_buffer) if self._pre_buffer else []
self._pre_buffer = []
self._speech_frames = 0
self.on_state("speech_start")
self._chunks.append(window)
self._speech_frames += len(window)
self._silence_frames = 0
if self._speech_frames >= int(self._max_seconds * 16000):
self._finalize()
return
if self._recording:
self._chunks.append(window)
self._silence_frames += len(window)
if self._silence_frames >= int(self._silence_sec * 16000):
self._finalize()
else:
self._pre_buffer.append(window)
if len(self._pre_buffer) > int(_PRE_BUFFER_SEC * 16000 / _VAD_WINDOW):
self._pre_buffer.pop(0)
def _finalize(self) -> None:
chunks, self._chunks = self._chunks, []
self._recording = False
self._pre_buffer = []
speech_samples = self._speech_frames
self._speech_frames = 0
self._silence_frames = 0
total = sum(len(c) for c in chunks)
# Валидна именно ДОЛЯ голоса: суммарный буфер всегда ≥ паузе тишины
if speech_samples < int(self._min_speech_sec * 16000):
self.on_state("too_short") # видимая отбраковка, не тихая
return
self.on_state("speech_end")
self.on_phrase(np.concatenate(chunks).astype(np.float32), 16000)