Initial commit: полный проект my_speaker

This commit is contained in:
2025-12-25 12:16:19 +03:00
commit 8279fbb35f
85 changed files with 6027 additions and 0 deletions
+439
View File
@@ -0,0 +1,439 @@
# vad_core.py
import pyaudio
import webrtcvad # type: ignore
import collections
import threading
import wave
import time
import os
import struct
import numpy as np
import logging
import sys
from datetime import datetime
from app.core.status import status_tracker, SystemStatus
class VADPolling:
def __init__(self, config, on_speech_end_callback=None):
self.config = config
self.vad = webrtcvad.Vad()
self.vad.set_mode(config['mode'])
self.ring_buffer = collections.deque(maxlen=config['ring_buffer_size'])
self.history = collections.deque(maxlen=config.get('history_maxlen', 100))
self.lock = threading.Lock()
# Параметры шумоподавления и автоусиления
self.noise_reduction_enabled = config.get('noise_reduction_enabled', True)
self.noise_floor_buffer = collections.deque(maxlen=50) # Буфер для оценки уровня шума
self.noise_floor = 0.0
self.auto_gain_enabled = config.get('auto_gain_enabled', True)
self.target_rms = config.get('target_rms', 2000.0) # Целевой уровень сигнала
self.current_gain = 1.0
self.frame_size = int(config['sample_rate'] * config['frame_duration_ms'] / 1000)
# 🔹 Размер предбуфера (в фреймах)
self.prebuffer_size = int(config.get('prebuffer_duration_sec', 1.0) * config['sample_rate'] / self.frame_size)
self.prebuffer = collections.deque(maxlen=self.prebuffer_size)
# 🔹 Основной буфер записи (используется только во время записи)
self.recording_buffer = []
# 🔹 Максимальная длина записи
self.max_record_duration_sec = config.get('max_record_duration_sec', 5.0)
self.max_record_frames = int(self.max_record_duration_sec * config['sample_rate'] / self.frame_size)
# 🔹 Фильтрация по энергии и длительности
self.energy_threshold = config.get('energy_threshold', 1000)
self.min_speech_duration_ms = config.get('min_speech_duration_ms', 300)
self.adaptive_energy = config.get('adaptive_energy', True)
# 🔹 Адаптивный порог энергии
self.background_energy_buffer = collections.deque(maxlen=100)
self.current_energy_threshold = self.energy_threshold
self.adaptive_energy_std_factor = config.get('adaptive_energy_std_factor', 2.5) # Множитель для стандартного отклонения
# 🔹 Счетчики для фильтрации
self.speech_start_time = None
self.continuous_speech_frames = 0
self.min_speech_frames = int(self.min_speech_duration_ms / config['frame_duration_ms'])
# 🔹 Хранение последнего состояния
self.last_result = {
'speech_ratio': 0.0,
'speech_detected': False,
'energy': 0.0,
'energy_threshold': float(self.current_energy_threshold),
'speech_threshold': float(self.config['speech_threshold']),
'continuous_frames': 0,
}
# 🔹 Состояния записи
self.last_speech_detected = False
self.prev_speech_detected = False
self.is_recording = False
# 🔹 Настройка записи
self.record_enabled = config.get('record_on_speech', False)
self.record_stop_delay_sec = config.get('record_stop_delay_sec', 1.0)
self.output_filename = "recordings/VAD/debug_recording.wav"
# 🔹 Таймер остановки записи
self.stop_timer = None
# 🔹 Callback для обработки речи после завершения записи
self.on_speech_end_callback = on_speech_end_callback
# 🔹 Удаляем старый файл при запуске, если запись включена
if self.record_enabled:
os.makedirs("recordings", exist_ok=True)
if os.path.exists(self.output_filename):
os.remove(self.output_filename)
logging.getLogger('VADApp').info(f"Старый файл записей удалён: {self.output_filename}")
# 🔹 Запуск фонового потока
self.audio = pyaudio.PyAudio()
self.running = True
self.vad_thread = threading.Thread(target=self._vad_loop, daemon=True)
self.vad_thread.start()
def _calculate_energy(self, frame):
"""Вычисляет RMS энергию аудио фрейма с шумоподавлением и автоусилением.
Возвращает кортеж: (обработанный_фрейм, исходная_энергия)
"""
logger = logging.getLogger('VADApp')
try:
# Преобразуем байты в массив int16
audio_data = struct.unpack(f'{len(frame)//2}h', frame)
audio_array = np.array(audio_data, dtype=np.float32)
# Вычисляем энергию ИСХОДНОГО сигнала (для VAD)
base_energy = np.sqrt(np.mean(audio_array**2))
# Обновляем оценку уровня шума
if self.noise_reduction_enabled and base_energy < self.current_energy_threshold * 0.5:
current_noise = np.mean(np.abs(audio_array))
if self.noise_floor == 0.0:
# Первая инициализация
self.noise_floor = current_noise
else:
# Плавное обновление с настраиваемой скоростью
alpha = float(self.config.get('noise_update_alpha', 0.05))
self.noise_floor = (1 - alpha) * self.noise_floor + alpha * current_noise
# ========== ШУМОПОДАВЛЕНИЕ ==========
if self.noise_reduction_enabled and self.noise_floor > 0:
reduction_factor = float(self.config.get('noise_reduction_factor', 1.5))
noise_threshold = self.noise_floor * reduction_factor
audio_array = np.where(
np.abs(audio_array) > noise_threshold,
np.sign(audio_array) * (np.abs(audio_array) - self.noise_floor),
0
)
# ========== АВТОУСИЛЕНИЕ ==========
if self.auto_gain_enabled:
current_rms = np.sqrt(np.mean(audio_array**2))
# Вычисляем минимальный порог RMS относительно уровня шума
min_rms_factor = float(self.config.get('min_rms_threshold_factor', 2.0))
min_rms_threshold = self.noise_floor * min_rms_factor if self.noise_floor > 0 else 100.0
if current_rms > min_rms_threshold:
# Вычисляем целевой коэффициент усиления
target_gain = self.target_rms / current_rms
# Ограничиваем максимальное усиление
max_gain = float(self.config.get('max_gain', 5.0))
target_gain = min(max_gain, max(0.1, target_gain))
# Плавное изменение усиления с настраиваемой скоростью
alpha = float(self.config.get('gain_smoothing_alpha', 0.1))
self.current_gain = (1 - alpha) * self.current_gain + alpha * target_gain
# Применяем усиление
audio_array = audio_array * self.current_gain
# Преобразуем обратно в байты
audio_array = np.clip(audio_array, -32768, 32767).astype(np.int16)
processed_frame = struct.pack(f'{len(audio_array)}h', *audio_array)
# Возвращаем обработанный фрейм И исходную энергию
return processed_frame, base_energy
except Exception as e:
logger.error(f"_calculate_energy: {e}")
return frame, 0.0 # В случае ошибки возвращаем исходный фрейм
def _update_adaptive_threshold(self, energy, is_speech_candidate):
"""Обновляет адаптивный порог энергии на основе фонового шума"""
if not self.adaptive_energy:
return
# Если не похоже на речь, добавляем в буфер фоновой энергии
if not is_speech_candidate:
self.background_energy_buffer.append(energy)
# Обновляем порог как среднее + отклонение фонового шума
if len(self.background_energy_buffer) >= 10:
bg_mean = np.mean(self.background_energy_buffer)
bg_std = np.std(self.background_energy_buffer)
# Порог = среднее + adaptive_energy_std_factor стандартных отклонения
self.current_energy_threshold = max(bg_mean + self.adaptive_energy_std_factor * bg_std, self.energy_threshold)
def _vad_loop(self):
logger = logging.getLogger('VADApp')
stream = None
try:
stream = self.audio.open(
format=pyaudio.paInt16,
channels=1,
rate=self.config['sample_rate'],
input=True,
frames_per_buffer=self.frame_size
)
logger.info("Инициализация VAD завершена. Начинаем обработку...")
while self.running:
try:
frame = stream.read(self.frame_size, exception_on_overflow=False)
# Базовая VAD проверка
is_speech_vad = self.vad.is_speech(frame, self.config['sample_rate'])
# Вычисляем энергию сигнала
processed_frame, energy = self._calculate_energy(frame)
# Обновляем адаптивный порог
self._update_adaptive_threshold(energy, is_speech_vad)
with self.lock:
# Комбинированная проверка речи (VAD + энергия)
is_speech = is_speech_vad and (energy > self.current_energy_threshold)
# Обновляем кольцевой буфер и историю
self.ring_buffer.append(1 if is_speech else 0)
speech_ratio = sum(self.ring_buffer) / len(self.ring_buffer)
speech_confirmed = speech_ratio > self.config['speech_threshold']
self.history.append(speech_ratio)
# Обновляем счетчик連続ных фреймов речи
if speech_confirmed:
self.continuous_speech_frames += 1
else:
self.continuous_speech_frames = 0
# Финальное подтверждение речи (фильтр по длительности)
speech_detected = self.continuous_speech_frames >= self.min_speech_frames
# Обновляем last_result
self.last_result = {
'speech_ratio': speech_ratio,
'speech_detected': speech_detected,
'energy': energy,
'energy_threshold': self.current_energy_threshold,
'speech_threshold': self.config['speech_threshold'],
'continuous_frames': self.continuous_speech_frames,
}
# Логика начала/окончания записи
if speech_detected:
if not self.is_recording:
self._start_recording()
self._cancel_stop_recording() # Отменяем таймер, если речь продолжается
else:
if self.is_recording and not self.stop_timer:
self._schedule_stop_recording()
# Добавляем фрейм в буфер
if not self.is_recording:
self.prebuffer.append(processed_frame)
else:
self.recording_buffer.append(processed_frame)
# Проверка на максимальную длину записи
if len(self.recording_buffer) >= self.max_record_frames:
logger.info(f"Максимальная длина записи достигнута ({self.max_record_duration_sec} сек)")
self._stop_recording()
# Обновляем состояния
self.prev_speech_detected = speech_confirmed
self.last_speech_detected = speech_confirmed
# Вывод статуса
recording_status = "🔴 ЗАПИСЬ" if self.is_recording else "⚪ ПРЕДБУФЕР"
timer_status = " [⏱️]" if self.stop_timer else ""
prebuf_len = len(self.prebuffer) if not self.is_recording else 0
rec_len = len(self.recording_buffer) if self.is_recording else 0
status_line = ( f"Статус: {status_tracker.get_status()} |"
f"VAD: {recording_status}{timer_status} |"
f"Речь: {speech_ratio:.2f} | Энергия: {energy:.0f}/{self.current_energy_threshold:.0f} | "
f"Gain: {self.current_gain:.2f}x | Шум: {self.noise_floor:.0f} | "
f"Кадры: {self.continuous_speech_frames:>3} | Буферы: П{prebuf_len:>3}/З{rec_len:>4}")
if logger.isEnabledFor(logging.DEBUG):
sys.stdout.write(f"\r{status_line}")
sys.stdout.flush()
else:
logger.debug(status_line.replace('\n', ' '))
except Exception as e:
if self.running:
logger.error(f"VAD Read Error: {e}")
break
except Exception as e:
logger.error(f"VAD Stream Error: {e}")
finally:
if stream:
try:
stream.stop_stream()
stream.close()
except:
pass
def _start_recording(self):
"""Начинаем запись: копируем предбуфер в основной буфер и переключаемся в режим записи"""
logger = logging.getLogger('VADApp')
self.is_recording = True
self.recording_buffer = list(self.prebuffer)
self.prebuffer.clear()
logger.info(f"Запись начата (предбуфер: {len(self.recording_buffer)} фреймов)")
status_tracker.set_status(SystemStatus.RECORDING)
def _schedule_stop_recording(self):
"""Запускает таймер на остановку записи через N секунд (только если таймер еще не запущен)"""
logger = logging.getLogger('VADApp')
if self.stop_timer is not None:
return
def delayed_stop():
with self.lock:
if self.is_recording:
self._stop_recording()
self.stop_timer = threading.Timer(self.record_stop_delay_sec, delayed_stop)
self.stop_timer.start()
logger.info(f"Таймер остановки запущен ({self.record_stop_delay_sec} сек)")
def _cancel_stop_recording(self):
"""Отменяет таймер остановки записи"""
logger = logging.getLogger('VADApp')
if self.stop_timer:
self.stop_timer.cancel()
self.stop_timer = None
logger.info("Таймер остановки отменён (речь возобновилась)")
def _stop_recording(self):
"""Останавливаем запись: обрабатываем буфер для распознавания и/или сохранения в файл"""
logger = logging.getLogger('VADApp')
if not self.is_recording:
return
if self.on_speech_end_callback and self.recording_buffer:
try:
audio_data = b''.join(self.recording_buffer)
self.on_speech_end_callback(audio_data, self.config['sample_rate'])
except Exception as e:
logger.error(f"Ошибка вызова callback: {e}")
if self.recording_buffer and self.record_enabled:
try:
with wave.open(self.output_filename, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(self.config['sample_rate'])
wf.writeframes(b''.join(self.recording_buffer))
duration = len(self.recording_buffer) * self.config['frame_duration_ms'] / 1000
file_size = os.path.getsize(self.output_filename) / 1024
logger.info(f"Запись сохранена: {self.output_filename}")
logger.info(f"Длительность: {duration:.2f} сек, Размер: {file_size:.1f} KB, Фреймов: {len(self.recording_buffer)}")
except Exception as e:
logger.error(f"Ошибка сохранения записи: {e}")
self.recording_buffer.clear()
status_tracker.set_status(SystemStatus.LISTENING)
self.is_recording = False
self.stop_timer = None
logger.info("Возврат к режиму предбуфера")
def get_latest_status(self):
"""Возвращает последнее состояние VAD (для polling)"""
with self.lock:
return self.last_result.copy()
def update_config(self, new_config):
logger = logging.getLogger('VADApp')
with self.lock:
self.config = new_config
self.vad.set_mode(new_config['mode'])
self.ring_buffer = collections.deque(maxlen=new_config['ring_buffer_size'])
self.history = collections.deque(maxlen=new_config.get('history_maxlen', 100))
self.frame_size = int(new_config['sample_rate'] * new_config['frame_duration_ms'] / 1000)
# Обновляем параметры фильтрации
self.energy_threshold = new_config.get('energy_threshold', 1000)
self.min_speech_duration_ms = new_config.get('min_speech_duration_ms', 300)
self.adaptive_energy = new_config.get('adaptive_energy', True)
self.adaptive_energy_std_factor = new_config.get('adaptive_energy_std_factor', 2.5)
self.min_speech_frames = int(self.min_speech_duration_ms / new_config['frame_duration_ms'])
# Обновляем параметры шумоподавления и автоусиления
self.noise_reduction_enabled = new_config.get('noise_reduction_enabled', True)
self.auto_gain_enabled = new_config.get('auto_gain_enabled', True)
self.target_rms = new_config.get('target_rms', 2000.0)
# Обновляем размер предбуфера и пересоздаем его
old_prebuffer_data = list(self.prebuffer) if not self.is_recording else []
self.prebuffer_size = int(new_config.get('prebuffer_duration_sec', 1.0) * new_config['sample_rate'] / self.frame_size)
self.prebuffer = collections.deque(old_prebuffer_data, maxlen=self.prebuffer_size)
# Обновляем максимальную длину записи
self.max_record_duration_sec = new_config.get('max_record_duration_sec', 5.0)
self.max_record_frames = int(self.max_record_duration_sec * new_config['sample_rate'] / self.frame_size)
# Обновляем настройки записи
self.record_enabled = new_config.get('record_on_speech', False)
self.record_stop_delay_sec = new_config.get('record_stop_delay_sec', 1.0)
logger.info("Конфигурация VAD обновлена")
def close(self):
"""Корректное завершение работы с очисткой всех ресурсов"""
logger = logging.getLogger('VADApp')
logger.info("Начинаем корректное завершение VAD...")
self.running = False
# Даем время потоку завершиться
if hasattr(self, 'vad_thread') and self.vad_thread.is_alive():
self.vad_thread.join(timeout=3.0)
with self.lock:
# Отменяем таймер, если активен
if self.stop_timer:
self.stop_timer.cancel()
self.stop_timer = None
# Сохраняем текущую запись, если активна
if self.is_recording:
logger.info("Сохраняем активную запись...")
self._stop_recording()
# Очищаем все буферы для освобождения памяти
self.prebuffer.clear()
self.recording_buffer.clear()
self.ring_buffer.clear()
self.history.clear()
self.background_energy_buffer.clear()
# Завершаем audio
if hasattr(self, 'audio'):
try:
self.audio.terminate()
except:
pass # Игнорируем ошибки при завершении
logger.info("Корректное завершение VAD завершено")