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
+1
View File
@@ -0,0 +1 @@
# Core package initialization
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# app/core/auth.py
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing import Dict, Optional, Any
import secrets
import logging
from app.core.config import get_settings
logger = logging.getLogger('VADApp')
# Базовая HTTP аутентификация
security = HTTPBasic()
# Хранилище сессий (в реальном приложении лучше использовать Redis или другое хранилище)
sessions: Dict[str, str] = {}
def verify_credentials(credentials: HTTPBasicCredentials) -> bool:
"""Проверка учетных данных пользователя"""
settings = get_settings()
correct_username = settings.auth.username
correct_password = settings.auth.password
# Безопасное сравнение строк для предотвращения timing-атак
is_username_correct = secrets.compare_digest(credentials.username, correct_username)
is_password_correct = secrets.compare_digest(credentials.password, correct_password)
return is_username_correct and is_password_correct
def create_session_token(username: str) -> str:
"""Создание токена сессии"""
token = secrets.token_hex(16)
sessions[token] = username
logger.info(f"Создана новая сессия для пользователя {username}")
return token
def remove_session(token: str) -> None:
"""Удаление сессии"""
if token in sessions:
username = sessions.pop(token)
logger.info(f"Сессия для пользователя {username} удалена")
def get_current_user(request: Request) -> Optional[str]:
"""Получение текущего пользователя из сессии"""
token = request.cookies.get("session_token")
if token and token in sessions:
return sessions[token]
return None
def require_auth(request: Request) -> str:
"""Проверка аутентификации пользователя"""
username = get_current_user(request)
if not username:
logger.warning("Попытка доступа без аутентификации")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация",
headers={"WWW-Authenticate": "Basic"},
)
return username
+181
View File
@@ -0,0 +1,181 @@
# app/core/config.py
import os
import logging
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
from ruamel.yaml import YAML
from functools import lru_cache
# Инициализация логгера
logger = logging.getLogger('VADApp')
# Путь к файлу конфигурации
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.yaml')
class AuthConfig(BaseModel):
username: str = "admin"
password: str = "admin"
class VADConfig(BaseModel):
mode: int = 1
sample_rate: int = 16000
frame_duration_ms: int = 30
ring_buffer_size: int = 20
speech_threshold: float = 0.5
energy_threshold: int = 1000
min_speech_duration_ms: int = 300
record_stop_delay_sec: float = 1.0
prebuffer_duration_sec: float = 1.0
max_record_duration_sec: float = 5.0
target_rms: float = 2000.0
max_gain: float = 5.0
noise_reduction_factor: float = 1.5
noise_update_alpha: float = 0.05
min_rms_threshold_factor: float = 2.0
gain_smoothing_alpha: float = 0.1
adaptive_energy_std_factor: float = 2.5
adaptive_energy: bool = True
noise_reduction_enabled: bool = True
auto_gain_enabled: bool = True
record_on_speech: bool = True
class SystemConfig(BaseModel):
history_maxlen: int = 100
polling_interval_ms: int = 500
logging_level: str = "INFO"
log_file: str = "logs/app.log"
log_save_file: bool = True
class STTConfig(BaseModel):
model_path: str = ""
class CommandsConfig(BaseModel):
commands_file: str = ""
class SessionConfig(BaseModel):
session_timeout_seconds: int = 30
class TTSConfig(BaseModel):
host: str = "192.168.200.81"
port: int = 10200
timeout: int = 30
pause_duration: float = 0.5
post_stop_wait: float = 1.0
voice: str = "ru_RU-ruslan-medium"
save_TTS_files: bool = False
class AudioSettingsConfig(BaseModel):
playback_device: int = 0
capture_device: int = 0
playback_volume: int = 80
capture_volume: int = 90
class Settings(BaseModel):
auth: AuthConfig = Field(default_factory=AuthConfig)
vad: VADConfig = Field(default_factory=VADConfig)
system: SystemConfig = Field(default_factory=SystemConfig)
stt: STTConfig = Field(default_factory=STTConfig)
commands: CommandsConfig = Field(default_factory=CommandsConfig)
session: SessionConfig = Field(default_factory=SessionConfig)
tts: TTSConfig = Field(default_factory=TTSConfig)
audio_settings: AudioSettingsConfig = Field(default_factory=AudioSettingsConfig)
def setup_logging(logging_level='INFO'):
"""Настройка уровня логирования"""
logger.debug(f"Установка уровня логирования: {logging_level}")
try:
logger.setLevel(getattr(logging, logging_level.upper(), logging.INFO))
logger.info(f"Уровень логирования установлен: {logging_level}")
except Exception as e:
logger.error(f"Ошибка настройки логирования: {e}", exc_info=True)
raise
def load_yaml_config() -> Dict[str, Any]:
"""Загрузка конфигурации из YAML файла"""
logger.debug("Загрузка конфигурации из YAML")
try:
yaml = YAML()
yaml.preserve_quotes = True
if not os.path.exists(CONFIG_PATH):
logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}")
raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}")
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = yaml.load(f) or {}
# Проверяем и адаптируем пути для текущей ОС
if 'commands' in config and 'commands_file' in config['commands']:
commands_file = config['commands']['commands_file']
# Преобразуем путь для текущей ОС, если необходимо
commands_file = os.path.normpath(commands_file)
config['commands']['commands_file'] = commands_file
logger.debug(f"Путь к файлу команд: {commands_file}")
if 'stt' in config and 'model_path' in config['stt']:
model_path = config['stt']['model_path']
# Преобразуем путь для текущей ОС, если необходимо
model_path = os.path.normpath(model_path)
config['stt']['model_path'] = model_path
logger.debug(f"Путь к модели STT: {model_path}")
return config
except Exception as e:
logger.error(f"Ошибка загрузки конфигурации: {e}", exc_info=True)
raise
@lru_cache()
def get_settings() -> Settings:
"""Получение настроек приложения (кэшируется)"""
config = load_yaml_config()
# Создаем объект настроек
settings = Settings(
auth=AuthConfig(**config.get('auth', {})),
vad=VADConfig(**config.get('vad', {})),
system=SystemConfig(**config.get('system', {})),
stt=STTConfig(**config.get('stt', {})),
commands=CommandsConfig(**config.get('commands', {})),
tts=TTSConfig(**config.get('tts', {})),
audio_settings=AudioSettingsConfig(**config.get('audio_settings', {}))
)
# Настраиваем логирование
setup_logging(settings.system.logging_level)
return settings
def save_settings(settings: Settings) -> bool:
"""Сохранение настроек в YAML файл"""
logger.debug("Сохранение конфигурации")
try:
# Загружаем текущую конфигурацию
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.preserve_quotes = True
if not os.path.exists(CONFIG_PATH):
logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}")
raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}")
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = yaml.load(f) or {}
# Обновляем конфигурацию
config['auth'] = settings.auth.dict()
config['vad'] = settings.vad.dict()
config['system'] = settings.system.dict()
config['stt'] = settings.stt.dict()
config['commands'] = settings.commands.dict()
config['tts'] = settings.tts.dict()
config['audio_settings'] = settings.audio_settings.dict()
# Сохраняем обновленную конфигурацию
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
yaml.dump(config, f)
# Сбрасываем кэш настроек
get_settings.cache_clear()
logger.info("Конфигурация сохранена")
return True
except Exception as e:
logger.error(f"Ошибка сохранения конфигурации: {e}", exc_info=True)
return False
+41
View File
@@ -0,0 +1,41 @@
# app/core/history.py
from datetime import datetime
from typing import List, Dict, Any
from pydantic import BaseModel
from app.core.config import get_settings
import collections
class RecognizedPhrase(BaseModel):
text: str
timestamp: str
action: str = None
# Хранилище распознанных фраз (в реальном приложении лучше использовать базу данных)
recognition_history: collections.deque = collections.deque(maxlen=100)
def add_recognized_phrase(text: str, action: str = None) -> None:
"""Добавление распознанной фразы в историю"""
global recognition_history
# Обновляем максимальный размер истории из настроек
settings = get_settings()
recognition_history = collections.deque(list(recognition_history),
maxlen=settings.system.history_maxlen)
# Добавляем только фразы с действиями (команды)
if action is not None:
phrase = RecognizedPhrase(
text=text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
action=action
)
recognition_history.append(phrase)
def get_recognition_history(limit: int = 10) -> List[RecognizedPhrase]:
"""Получение истории распознанных фраз"""
# Возвращаем последние N фраз (самые новые в начале списка)
return list(reversed(list(recognition_history)))[:limit]
def clear_recognition_history() -> None:
"""Очистка истории распознанных фраз"""
recognition_history.clear()
+50
View File
@@ -0,0 +1,50 @@
import uuid
from typing import Dict, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
# Простое хранилище активных сессий (в реальном приложении лучше использовать Redis или другое хранилище)
ACTIVE_SESSIONS: Dict[str, str] = {}
# Схема безопасности для проверки токена
security = HTTPBearer(auto_error=False)
def create_session_token(username: str) -> str:
"""Создание простого сессионного токена"""
token = str(uuid.uuid4())
ACTIVE_SESSIONS[token] = username
return token
def remove_session_token(token: str) -> None:
"""Удаление сессионного токена"""
if token in ACTIVE_SESSIONS:
del ACTIVE_SESSIONS[token]
async def validate_session_token(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> Optional[str]:
"""Проверка сессионного токена"""
token_value = None
# Проверяем заголовок Authorization
if credentials:
token_value = credentials.credentials
# Или query-параметр token (для обратной совместимости)
elif 'token' in request.query_params:
token_value = request.query_params['token']
if not token_value or token_value not in ACTIVE_SESSIONS:
return None
return ACTIVE_SESSIONS[token_value]
async def require_auth(token: Optional[str] = Depends(validate_session_token)) -> str:
"""Требование обязательной аутентификации"""
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не авторизован",
headers={"WWW-Authenticate": "Bearer"},
)
return token
+31
View File
@@ -0,0 +1,31 @@
# app/core/status.py
from enum import Enum
import logging
from threading import Lock
logger = logging.getLogger('VADApp')
class SystemStatus(Enum):
INITIALIZATION = "Инициализация"
LISTENING = "Прослушивание"
RECORDING = "Запись"
RECOGNITION = "Распознавание"
COMMAND_PROCESSING = "Обработка команды"
RESPONSE = "Ответ"
class StatusTracker:
def __init__(self):
self.current_status = SystemStatus.INITIALIZATION
self.lock = Lock()
def set_status(self, status: SystemStatus):
with self.lock:
self.current_status = status
logger.info(f"Статус: {status.value}")
def get_status(self) -> str:
with self.lock:
return self.current_status.value
# Global instance (or can be stored in app.state)
status_tracker = StatusTracker()
+40
View File
@@ -0,0 +1,40 @@
# app/core/system_monitor.py
import logging
import psutil
from typing import Dict
logger = logging.getLogger('VADApp')
def get_system_load() -> Dict[str, float]:
"""
Получение текущей нагрузки системы для Raspberry Pi 3B+.
Возвращает словарь с:
- 'cpu_percent': Процент загрузки CPU
- 'memory_percent': Процент использования памяти
- 'temperature': Температура CPU в °C
"""
try:
# Загрузка CPU (с интервалом для более точного измерения)
cpu_percent = psutil.cpu_percent(interval=0.5)
# Использование памяти
memory = psutil.virtual_memory()
memory_percent = memory.percent
# Температура CPU (специфично для Raspberry Pi)
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as temp_file:
temperature = float(temp_file.read().strip()) / 1000.0
return {
'cpu_percent': cpu_percent,
'memory_percent': memory_percent,
'temperature': temperature
}
except Exception as e:
logger.error(f"Ошибка получения нагрузки системы: {e}")
return {
'cpu_percent': 0.0,
'memory_percent': 0.0,
'temperature': 0.0
}
+135
View File
@@ -0,0 +1,135 @@
# app/core/vad.py
from fastapi import Request
import os
from app.core.config import get_settings
from typing import Tuple, Dict, Any, Optional
import logging
from app.core.status import status_tracker, SystemStatus # Добавлено
from app.core.history import add_recognized_phrase
logger = logging.getLogger('VADApp')
def get_vad_instance(request: Request):
"""Получение экземпляра VAD из состояния приложения"""
return getattr(request.app.state, 'vad_instance', None)
def get_stt_instance(request: Request):
"""Получение экземпляра STT из состояния приложения"""
return getattr(request.app.state, 'stt_instance', None)
def get_commands(request: Request):
"""Получение команд из состояния приложения"""
return getattr(request.app.state, 'commands', None)
def init_vad_and_stt(request: Request = None) -> Tuple[Any, Any, Dict[str, Any], Any]:
"""Инициализация VAD и STT"""
logger.debug("Инициализация VAD и STT")
status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено
try:
# Импортируем здесь, чтобы избежать циклических импортов
from vad_core import VADPolling
from vosk_local_STT import VoskSTT # Исправлено с vosk_stt на vosk_local_STT
from commands import handle_command
from session_manager import SessionManager
from volume import set_volume
config = get_settings()
commands = {}
# Инициализация громкости аудиоустройств
try:
# Устанавливаем громкость для устройства воспроизведения
set_volume(config.audio_settings.playback_device, config.audio_settings.playback_volume, mixer_type='Playback')
# Устанавливаем громкость для устройства записи
set_volume(config.audio_settings.capture_device, config.audio_settings.capture_volume, mixer_type='Capture')
logger.info(f"Громкость устройств инициализирована: воспроизведение={config.audio_settings.playback_volume}%, запись={config.audio_settings.capture_volume}%")
except Exception as e:
logger.error(f"Ошибка при инициализации громкости: {e}")
# Загружаем команды из файла
if config.commands.commands_file and os.path.exists(config.commands.commands_file):
from ruamel.yaml import YAML
yaml = YAML()
with open(config.commands.commands_file, 'r', encoding='utf-8') as f:
commands = yaml.load(f) or {}
# Инициализируем SessionManager
session_manager = SessionManager(config.session.session_timeout_seconds)
logger.info(f"SessionManager инициализирован с таймаутом {config.session.session_timeout_seconds} секунд")
# Инициализируем STT
stt_instance = VoskSTT(
model_path=config.stt.model_path,
commands_file=config.commands.commands_file,
sample_rate=config.vad.sample_rate
)
# Функция обработки распознанной речи
def process_speech(audio_data, sample_rate):
try:
status_tracker.set_status(SystemStatus.RECOGNITION) # Добавлено
recognized = stt_instance.recognize(audio_data)
if recognized:
logger.info(f"Команда распознана: {recognized}")
status_tracker.set_status(SystemStatus.COMMAND_PROCESSING) # Добавлено
action = handle_command(recognized, commands, session_manager)
status_tracker.set_status(SystemStatus.RESPONSE) # Добавлено (предполагая, что здесь происходит ответ; если нет, удалите)
# Добавляем распознанную фразу в историю
if action:
add_recognized_phrase(recognized, action=action)
except Exception as e:
logger.error(f"Ошибка обработки речи: {e}")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено (возврат к прослушиванию)
# Инициализируем VAD
vad_config = config.vad.dict()
vad_config['polling_interval_ms'] = config.system.polling_interval_ms
vad_config['history_maxlen'] = config.system.history_maxlen
vad_instance = VADPolling(vad_config, on_speech_end_callback=process_speech)
logger.info("VAD и STT инициализированы")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено
return vad_instance, stt_instance, commands, session_manager
except Exception as e:
logger.error(f"Ошибка инициализации VAD/STT: {e}", exc_info=True)
raise
def cleanup_vad_and_stt(vad_instance, stt_instance):
"""Завершение работы VAD и STT"""
logger.debug("Завершение работы VAD и STT")
try:
if vad_instance:
logger.info("Завершаем VAD...")
vad_instance.close()
if stt_instance:
logger.info("Завершаем STT...")
stt_instance = None
logger.info("VAD и STT завершены")
except Exception as e:
logger.error(f"Ошибка завершения VAD/STT: {e}")
def restart_vad_and_stt(request: Request):
"""Перезапуск VAD и STT с новыми настройками"""
logger.info("Перезапуск VAD и STT")
status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено
try:
# Завершаем текущие экземпляры
vad_instance = get_vad_instance(request)
stt_instance = get_stt_instance(request)
cleanup_vad_and_stt(vad_instance, stt_instance)
# Инициализируем новые экземпляры
vad_instance, stt_instance, commands = init_vad_and_stt()
# Обновляем состояние приложения
request.app.state.vad_instance = vad_instance
request.app.state.stt_instance = stt_instance
request.app.state.commands = commands
logger.info("VAD и STT перезапущены")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено
return True
except Exception as e:
logger.error(f"Ошибка перезапуска VAD/STT: {e}", exc_info=True)
raise