first commit
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user