commit 8279fbb35fa3bdc19dbda06052941b3fef9281f2 Author: sirmrak Date: Thu Dec 25 12:16:19 2025 +0300 Initial commit: полный проект my_speaker diff --git a/Hello.wav b/Hello.wav new file mode 100644 index 0000000..60078eb Binary files /dev/null and b/Hello.wav differ diff --git a/__pycache__/commands.cpython-311.pyc b/__pycache__/commands.cpython-311.pyc new file mode 100644 index 0000000..f9bd57a Binary files /dev/null and b/__pycache__/commands.cpython-311.pyc differ diff --git a/__pycache__/piper_TTS.cpython-311.pyc b/__pycache__/piper_TTS.cpython-311.pyc new file mode 100644 index 0000000..93faf54 Binary files /dev/null and b/__pycache__/piper_TTS.cpython-311.pyc differ diff --git a/__pycache__/run_server.cpython-311.pyc b/__pycache__/run_server.cpython-311.pyc new file mode 100644 index 0000000..16641f8 Binary files /dev/null and b/__pycache__/run_server.cpython-311.pyc differ diff --git a/__pycache__/session_manager.cpython-311.pyc b/__pycache__/session_manager.cpython-311.pyc new file mode 100644 index 0000000..76add6b Binary files /dev/null and b/__pycache__/session_manager.cpython-311.pyc differ diff --git a/__pycache__/vad_core.cpython-311.pyc b/__pycache__/vad_core.cpython-311.pyc new file mode 100644 index 0000000..767e095 Binary files /dev/null and b/__pycache__/vad_core.cpython-311.pyc differ diff --git a/__pycache__/volume.cpython-311.pyc b/__pycache__/volume.cpython-311.pyc new file mode 100644 index 0000000..e9d44b3 Binary files /dev/null and b/__pycache__/volume.cpython-311.pyc differ diff --git a/__pycache__/vosk_local_STT.cpython-311.pyc b/__pycache__/vosk_local_STT.cpython-311.pyc new file mode 100644 index 0000000..1bda0d3 Binary files /dev/null and b/__pycache__/vosk_local_STT.cpython-311.pyc differ diff --git a/__pycache__/vosk_stt.cpython-311.pyc b/__pycache__/vosk_stt.cpython-311.pyc new file mode 100644 index 0000000..9e1f27e Binary files /dev/null and b/__pycache__/vosk_stt.cpython-311.pyc differ diff --git a/alsa.sh b/alsa.sh new file mode 100644 index 0000000..eb092f2 --- /dev/null +++ b/alsa.sh @@ -0,0 +1,28 @@ +#!/bin/bash +CARD="2" # Проверьте: aplay -l + +echo "Увеличение чувствительности ReSpeaker 2-Mics для 2 м..." + +# Усиление микрофонов +amixer -c $CARD cset numid=9 1 # Left Boost +29dB +amixer -c $CARD cset numid=8 1 # Right Boost +29dB +amixer -c $CARD cset numid=1 120,120 # Capture Volume ~0dB +amixer -c $CARD cset numid=36 200,200 # ADC Capture ~+1.5dB + +# Шумоподавление +amixer -c $CARD cset numid=35 1 # Noise Gate on +amixer -c $CARD cset numid=34 10 # Noise Gate Threshold +amixer -c $CARD cset numid=19 1 # HPF on + +# ALC для адаптации к тихой речи +amixer -c $CARD cset numid=26 3 # ALC Stereo +amixer -c $CARD cset numid=27 7 # ALC Max Gain +amixer -c $CARD cset numid=28 10 # ALC Target + +# Стерео +amixer -c $CARD cset numid=41 0 # Left=Left, Right=Right + +# Сохранить +alsactl store + +echo "Настройки применены. Тест: arecord -D plughw:$CARD,0 -d 10 -f S16_LE -r 16000 -c 2 test.wav" \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3c9811d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,96 @@ +# app/__init__.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +from fastapi.staticfiles import StaticFiles +import os + +# Настройка логгера +logger = logging.getLogger('VADApp') + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Управление жизненным циклом приложения""" + from app.core.config import get_settings + from app.core.vad import init_vad_and_stt, cleanup_vad_and_stt + from app.api.session import set_session_manager + + logger.info("Инициализация приложения...") + try: + # Загружаем настройки + settings = get_settings() + + # Инициализируем аудиоподсистему для TTS + import sys + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import piper_TTS + logger.info("Инициализация аудиоподсистемы...") + piper_TTS.initialize_audio() + + # Инициализируем VAD и STT + vad_instance, stt_instance, commands, session_manager = init_vad_and_stt() + app.state.vad_instance = vad_instance + app.state.stt_instance = stt_instance + app.state.commands = commands + app.state.session_manager = session_manager + + # Передаем session_manager в API модуль + set_session_manager(session_manager) + + from app.core.status import status_tracker, SystemStatus # Добавлено + app.state.status_tracker = status_tracker # Добавлено + status_tracker.set_status(SystemStatus.LISTENING) # Добавлено + + logger.info("Сервер запущен") + yield + except Exception as e: + logger.error(f"Ошибка в lifespan: {e}", exc_info=True) + raise + finally: + logger.info("Завершение работы сервера...") + cleanup_vad_and_stt(app.state.vad_instance, app.state.stt_instance) + logger.info("Сервер завершил работу") + +def create_app() -> FastAPI: + """Создание и настройка приложения FastAPI""" + app = FastAPI(title="My Speaker API", lifespan=lifespan) + + # Настройка CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # В продакшене указать конкретные домены + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Подключение маршрутов API + from app.api.router import api_router + app.include_router(api_router) + + # Монтирование статических файлов на корень + app.mount("/", StaticFiles(directory="webapp", html=True), name="static") + + # Удаляем лишние маршруты, так как StaticFiles обрабатывает всё + # @app.get("/") + # async def read_root(): + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.get("/index.html") + # async def read_index(): + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.get("/{full_path:path}") + # async def catch_all(full_path: str): + # if full_path.startswith("api/") or full_path.startswith("static/"): + # raise HTTPException(status_code=404, detail="Not Found") + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.exception_handler(StarletteHTTPException) + # async def custom_http_exception_handler(request, exc): + # if exc.status_code == 404: + # return FileResponse(os.path.join("webapp", "index.html")) + # raise exc + + return app \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..20dd767 Binary files /dev/null and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..20d30f8 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/routes.cpython-311.pyc b/app/__pycache__/routes.cpython-311.pyc new file mode 100644 index 0000000..8e51b13 Binary files /dev/null and b/app/__pycache__/routes.cpython-311.pyc differ diff --git a/app/__pycache__/routes.cpython-313.pyc b/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..f712270 Binary files /dev/null and b/app/__pycache__/routes.cpython-313.pyc differ diff --git a/app/__pycache__/utils.cpython-311.pyc b/app/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..b851f13 Binary files /dev/null and b/app/__pycache__/utils.cpython-311.pyc differ diff --git a/app/__pycache__/utils.cpython-313.pyc b/app/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..5549038 Binary files /dev/null and b/app/__pycache__/utils.cpython-313.pyc differ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..80fb947 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API package initialization \ No newline at end of file diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..a469a7b Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/api/__pycache__/auth.cpython-311.pyc b/app/api/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..d02cd99 Binary files /dev/null and b/app/api/__pycache__/auth.cpython-311.pyc differ diff --git a/app/api/__pycache__/dashboard.cpython-311.pyc b/app/api/__pycache__/dashboard.cpython-311.pyc new file mode 100644 index 0000000..e5cf2a7 Binary files /dev/null and b/app/api/__pycache__/dashboard.cpython-311.pyc differ diff --git a/app/api/__pycache__/router.cpython-311.pyc b/app/api/__pycache__/router.cpython-311.pyc new file mode 100644 index 0000000..62cc93f Binary files /dev/null and b/app/api/__pycache__/router.cpython-311.pyc differ diff --git a/app/api/__pycache__/session.cpython-311.pyc b/app/api/__pycache__/session.cpython-311.pyc new file mode 100644 index 0000000..0394d47 Binary files /dev/null and b/app/api/__pycache__/session.cpython-311.pyc differ diff --git a/app/api/__pycache__/settings.cpython-311.pyc b/app/api/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000..d8778d7 Binary files /dev/null and b/app/api/__pycache__/settings.cpython-311.pyc differ diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..783cdf9 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,92 @@ +# app/api/auth.py +from fastapi import APIRouter, Header, Query, HTTPException, Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from pydantic import BaseModel +from app.core.config import get_settings +from app.core.security import create_session_token, validate_session_token, remove_session_token, ACTIVE_SESSIONS +from typing import Optional + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +security = HTTPBasic() + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + +class UserResponse(BaseModel): + username: str + is_authenticated: bool + +class LoginRequest(BaseModel): + username: str + password: str + +@router.post("/login", response_model=LoginResponse) +async def login(login_data: LoginRequest): + """Базовая авторизация по логину и паролю""" + settings = get_settings() + + if login_data.username != settings.auth.username or login_data.password != settings.auth.password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверные учетные данные", + headers={"WWW-Authenticate": "Basic"}, + ) + + # Создаем сессионный токен (без JWT, просто уникальный идентификатор) + token = create_session_token(login_data.username) + + return LoginResponse(access_token=token) +@router.get("/me", response_model=UserResponse) +async def get_current_user( + authorization: Optional[str] = Header(None), # ← Указываем что это Header + token_param: Optional[str] = Query(None, alias="token") # ← Указываем что это Query +): + """Получение информации о текущем пользователе""" + token_value = None + + # Проверяем заголовок Authorization + if authorization and authorization.startswith("Bearer "): + token_value = authorization.replace("Bearer ", "") + print(f"✓ Токен из заголовка: {token_value}") + # Если токен не найден в заголовке, проверяем параметр запроса + elif token_param: + token_value = token_param + print(f"✓ Токен из query параметра: {token_value}") + # Для обратной совместимости проверяем, если authorization передан как параметр запроса + elif authorization and not authorization.startswith("Bearer "): + token_value = authorization + print(f"✓ Токен из authorization (без Bearer): {token_value}") + + print(f"Проверка токена: {token_value}") + print(f"Активные сессии: {list(ACTIVE_SESSIONS.keys())}") + + # Проверяем токен в активных сессиях + if token_value and token_value in ACTIVE_SESSIONS: + username = ACTIVE_SESSIONS[token_value] + print(f"✓ Токен найден! Пользователь: {username}") + return UserResponse(username=username, is_authenticated=True) + + print(f"✗ Токен не найден в активных сессиях") + return UserResponse(username="", is_authenticated=False) + +@router.post("/logout") +async def logout(authorization: Optional[str] = None, token_param: Optional[str] = None): + """Выход из системы""" + token_value = None + + # Проверяем заголовок Authorization + if authorization and authorization.startswith("Bearer "): + token_value = authorization.replace("Bearer ", "") + # Если токен не найден в заголовке, проверяем параметр запроса + elif token_param: + token_value = token_param + # Для обратной совместимости проверяем, если authorization передан как параметр запроса + elif authorization and not authorization.startswith("Bearer "): + token_value = authorization + + if token_value and token_value in ACTIVE_SESSIONS: + remove_session_token(token_value) + return {"status": "success", "message": "Выход выполнен успешно"} + + return {"status": "success", "message": "Нет активной сессии"} \ No newline at end of file diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..5ee638b --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,110 @@ +# app/api/dashboard.py +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import List, Dict, Any, Optional +from app.core.security import require_auth +from app.core.vad import get_vad_instance, get_stt_instance +from app.core.status import status_tracker +from app.core.system_monitor import get_system_load + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + +class SpeechData(BaseModel): + state: str + speech_prob: float + speech_threshold: float # Добавлено: порог обнаружения речи + energy: float + energy_threshold: float + speech_detected: bool # Добавлено: признак обнаружения речи + continuous_frames: int # Добавлено: количество непрерывных кадров речи + gain: float # Добавлено: текущий коэффициент усиления + noise_floor: float # Добавлено: уровень шума + +class RecognizedPhrase(BaseModel): + text: str + timestamp: str + action: str = None + +class SystemLoad(BaseModel): # Добавлено: модель для системной нагрузки + cpu_percent: float + memory_percent: float + temperature: float + +@router.get("/speech-data", response_model=SpeechData) +async def get_speech_data(request: Request, token: str = Depends(require_auth)): + """Получение текущих данных о речи и энергии, включая расширенные метрики VAD и STT""" + try: + vad = get_vad_instance(request) + + if not vad: + return SpeechData( + state=status_tracker.get_status(), + speech_prob=0.0, + speech_threshold=0.0, + energy=0, + energy_threshold=0, + speech_detected=False, + continuous_frames=0, + gain=1.0, + noise_floor=0.0, + ) + + # Получаем данные из last_result VAD + last_result = vad.last_result + # Дополнительные атрибуты VAD (на основе класса VADPolling) + gain = vad.current_gain + noise_floor = vad.noise_floor + continuous_frames = last_result.get('continuous_frames', vad.continuous_speech_frames) # Если есть в last_result или атрибуте + + + return SpeechData( + state=status_tracker.get_status(), + speech_prob=float(last_result.get('speech_ratio', 0.0)), + speech_threshold=float(last_result.get('speech_threshold', 0.0)), + energy=float(last_result.get('energy', 0)), + energy_threshold=float(last_result.get('energy_threshold', 0)), + speech_detected=bool(last_result.get('speech_detected', False)), + continuous_frames=int(continuous_frames), + gain=float(gain), + noise_floor=float(noise_floor), + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/sys-monitor", response_model=SystemLoad) # Добавлено: новый эндпоинт +async def get_sys_monitor(token: str = Depends(require_auth)): + """Получение данных о нагрузке системы (CPU, память, температура)""" + try: + load_data = get_system_load() + return SystemLoad(**load_data) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/recent-phrases", response_model=List[RecognizedPhrase]) +async def get_recent_phrases(request: Request, limit: int = 10, token: str = Depends(require_auth)): + """Получение последних распознанных фраз""" + try: + stt = get_stt_instance(request) + if not stt or not hasattr(stt, 'get_history'): + from app.core.history import get_recognition_history + phrases = get_recognition_history(limit) + else: + phrases = stt.get_history(limit) + return phrases + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/stats", response_model=Dict[str, Any]) +async def get_stats(request: Request, token: str = Depends(require_auth)): + """Получение статистики работы системы""" + try: + # Здесь будет логика получения статистики работы системы + # Например, количество распознанных команд, время работы и т.д. + return { + "uptime": "0:00:00", # Будет заменено на реальное время работы + "recognized_phrases_count": 0, + "commands_executed_count": 0, + "system_load": 0.0 + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..43e2427 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,11 @@ +# app/api/router.py +from fastapi import APIRouter +from app.api import auth, dashboard, settings, session + +api_router = APIRouter() + +# Подключаем все маршруты API +api_router.include_router(auth.router) +api_router.include_router(dashboard.router) +api_router.include_router(settings.router) +api_router.include_router(session.router) \ No newline at end of file diff --git a/app/api/session.py b/app/api/session.py new file mode 100644 index 0000000..aa76c6c --- /dev/null +++ b/app/api/session.py @@ -0,0 +1,35 @@ +# app/api/session.py +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from typing import Optional +from app.core.security import require_auth + +router = APIRouter(prefix="/session", tags=["session"]) + +# Глобальная переменная для хранения экземпляра SessionManager +_session_manager = None + +def set_session_manager(session_manager): + """Устанавливает глобальный экземпляр SessionManager""" + global _session_manager + _session_manager = session_manager + +def get_session_manager(): + """Возвращает глобальный экземпляр SessionManager""" + return _session_manager + +class SessionStatus(BaseModel): + """Модель для статуса сессии""" + active: bool + time_left: float + start_time: Optional[str] = None + end_time: Optional[str] = None + +@router.get("/status", response_model=SessionStatus) +async def get_session_status(request: Request, token: str = Depends(require_auth)): + """Получить текущий статус сессии""" + session_manager = get_session_manager() + if not session_manager: + return SessionStatus(active=False, time_left=0) + + return session_manager.get_session_status() \ No newline at end of file diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..d61fa4c --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,204 @@ +# app/api/settings.py +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Dict, Any, Optional +from app.core.security import require_auth +from app.core.config import get_settings, save_settings +from app.core.vad import restart_vad_and_stt + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +class VADSettings(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 SystemSettings(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 STTSettings(BaseModel): + model_path: str = "" + +class CommandsSettings(BaseModel): + commands_file: str = "" + +class AuthSettings(BaseModel): + username: str = "admin" + password: str = "admin" + +class TTSSettings(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 AudioSettings(BaseModel): + playback_device: int = 2 + capture_device: int = 2 + playback_volume: int = 0 + capture_volume: int = 90 + + +class AllSettings(BaseModel): + vad: VADSettings + system: SystemSettings + stt: STTSettings + commands: CommandsSettings + auth: AuthSettings + tts: TTSSettings + audio_settings: AudioSettings + +@router.get("", response_model=AllSettings) +async def get_all_settings(token: str = Depends(require_auth)): + """Получение всех настроек""" + try: + config = get_settings() + return AllSettings( + vad=VADSettings(**config.vad.dict()), + system=SystemSettings(**config.system.dict()), + stt=STTSettings(**config.stt.dict()), + commands=CommandsSettings(**config.commands.dict()), + auth=AuthSettings(**config.auth.dict()), + tts=TTSSettings(**config.tts.dict()), + audio_settings=AudioSettings(**config.audio_settings.dict()) + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/vad", response_model=VADSettings) +async def update_vad_settings(settings: VADSettings, token: str = Depends(require_auth)): + """Обновление настроек VAD""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.vad, key, value) + save_settings(config) + return VADSettings(**config.vad.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/system", response_model=SystemSettings) +async def update_system_settings(settings: SystemSettings, token: str = Depends(require_auth)): + """Обновление системных настроек""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.system, key, value) + save_settings(config) + return SystemSettings(**config.system.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/stt", response_model=STTSettings) +async def update_stt_settings(settings: STTSettings, token: str = Depends(require_auth)): + """Обновление настроек STT""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.stt, key, value) + save_settings(config) + return STTSettings(**config.stt.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/commands", response_model=CommandsSettings) +async def update_commands_settings(settings: CommandsSettings, token: str = Depends(require_auth)): + """Обновление настроек команд""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.commands, key, value) + save_settings(config) + return CommandsSettings(**config.commands.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/auth", response_model=AuthSettings) +async def update_auth_settings(settings: AuthSettings, token: str = Depends(require_auth)): + """Обновление настроек авторизации""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.auth, key, value) + save_settings(config) + return AuthSettings(**config.auth.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/tts", response_model=TTSSettings) +async def update_tts_settings(settings: TTSSettings, token: str = Depends(require_auth)): + """Обновление настроек TTS""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.tts, key, value) + save_settings(config) + return TTSSettings(**config.tts.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/audio_settings", response_model=AudioSettings) +async def update_audio_settings(settings: AudioSettings, token: str = Depends(require_auth)): + """Обновление настроек аудио""" + try: + from volume import set_volume + + config = get_settings() + old_settings = config.audio_settings.dict() + settings_dict = settings.dict() + + # Обновляем настройки в конфигурации + for key, value in settings_dict.items(): + setattr(config.audio_settings, key, value) + + # Применяем настройки громкости, если они изменились + if old_settings['playback_volume'] != settings_dict['playback_volume']: + set_volume(settings_dict['playback_device'], settings_dict['playback_volume'], mixer_type='Playback') + + if old_settings['capture_volume'] != settings_dict['capture_volume']: + set_volume(settings_dict['capture_device'], settings_dict['capture_volume'], mixer_type='Capture') + + save_settings(config) + return AudioSettings(**config.audio_settings.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/restart") +async def restart_vad(request: Request, token: str = Depends(require_auth)): + """Перезапуск VAD и STT с новыми настройками""" + try: + restart_vad_and_stt(request) + return {"status": "success", "message": "VAD и STT перезапущены"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..b9a7cf7 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core package initialization \ No newline at end of file diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8ccceeb Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..50333b4 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/config.cpython-311.pyc b/app/core/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..58c36bf Binary files /dev/null and b/app/core/__pycache__/config.cpython-311.pyc differ diff --git a/app/core/__pycache__/config.cpython-313.pyc b/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..aef4467 Binary files /dev/null and b/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/app/core/__pycache__/history.cpython-311.pyc b/app/core/__pycache__/history.cpython-311.pyc new file mode 100644 index 0000000..ccbcd06 Binary files /dev/null and b/app/core/__pycache__/history.cpython-311.pyc differ diff --git a/app/core/__pycache__/security.cpython-311.pyc b/app/core/__pycache__/security.cpython-311.pyc new file mode 100644 index 0000000..4711cbc Binary files /dev/null and b/app/core/__pycache__/security.cpython-311.pyc differ diff --git a/app/core/__pycache__/status.cpython-311.pyc b/app/core/__pycache__/status.cpython-311.pyc new file mode 100644 index 0000000..3feacc4 Binary files /dev/null and b/app/core/__pycache__/status.cpython-311.pyc differ diff --git a/app/core/__pycache__/system_monitor.cpython-311.pyc b/app/core/__pycache__/system_monitor.cpython-311.pyc new file mode 100644 index 0000000..1eec924 Binary files /dev/null and b/app/core/__pycache__/system_monitor.cpython-311.pyc differ diff --git a/app/core/__pycache__/vad.cpython-311.pyc b/app/core/__pycache__/vad.cpython-311.pyc new file mode 100644 index 0000000..6a069b6 Binary files /dev/null and b/app/core/__pycache__/vad.cpython-311.pyc differ diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..7e26bd8 --- /dev/null +++ b/app/core/auth.py @@ -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 \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..c3e2630 --- /dev/null +++ b/app/core/config.py @@ -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 \ No newline at end of file diff --git a/app/core/history.py b/app/core/history.py new file mode 100644 index 0000000..4279b53 --- /dev/null +++ b/app/core/history.py @@ -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() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..196d59f --- /dev/null +++ b/app/core/security.py @@ -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 \ No newline at end of file diff --git a/app/core/status.py b/app/core/status.py new file mode 100644 index 0000000..86c9856 --- /dev/null +++ b/app/core/status.py @@ -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() \ No newline at end of file diff --git a/app/core/system_monitor.py b/app/core/system_monitor.py new file mode 100644 index 0000000..4f4d80e --- /dev/null +++ b/app/core/system_monitor.py @@ -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 + } \ No newline at end of file diff --git a/app/core/vad.py b/app/core/vad.py new file mode 100644 index 0000000..d1af744 --- /dev/null +++ b/app/core/vad.py @@ -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 \ No newline at end of file diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..9ec1abe --- /dev/null +++ b/app/utils.py @@ -0,0 +1,152 @@ +# app/utils.py +import os +import sys +import logging +from ruamel.yaml import YAML +from vad_core import VADPolling +from vosk_local_STT import VoskSTT +from commands import handle_command +from volume import set_volume + +# Инициализация логгера +logger = logging.getLogger('VADApp') +logger.setLevel(logging.DEBUG) +logger.handlers.clear() +log_format = logging.Formatter('[%(asctime)s] %(levelname)s] %(message)s', datefmt='%H:%M:%S') +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(log_format) +logger.addHandler(console_handler) +logger.debug("Логгер VADApp инициализирован") + +CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml') + +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_config(): + logger.debug("Загрузка конфигурации") + 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 {} + + commands_file = config.get('commands', {}).get('commands_file', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'commands', 'commands.yaml')) + if commands_file: + if not os.path.exists(commands_file): + logger.error(f"Файл команд не найден: {commands_file}") + raise FileNotFoundError(f"Файл команд не найден: {commands_file}") + with open(commands_file, 'r', encoding='utf-8') as cmd_file: + config['commands'] = yaml.load(cmd_file) or {} + config['commands_file'] = commands_file + logger.debug(f"Команды загружены: {commands_file}") + + config.setdefault('system', {}) + config['system'].setdefault('history_maxlen', 100) + config['system'].setdefault('polling_interval_ms', 500) + config['system'].setdefault('logging_level', 'INFO') + + config.setdefault('vad', {}) + config['vad'].setdefault('mode', 1) + config['vad'].setdefault('sample_rate', 16000) + config['vad'].setdefault('frame_duration_ms', 30) + config['vad'].setdefault('ring_buffer_size', 20) + config['vad'].setdefault('speech_threshold', 0.5) + config['vad'].setdefault('energy_threshold', 1000) + config['vad'].setdefault('min_speech_duration_ms', 300) + config['vad'].setdefault('record_stop_delay_sec', 1.0) + config['vad'].setdefault('prebuffer_duration_sec', 1.0) + config['vad'].setdefault('max_record_duration_sec', 5.0) + config['vad'].setdefault('target_rms', 2000.0) + config['vad'].setdefault('max_gain', 5.0) + config['vad'].setdefault('noise_reduction_factor', 1.5) + config['vad'].setdefault('noise_update_alpha', 0.05) + config['vad'].setdefault('min_rms_threshold_factor', 2.0) + config['vad'].setdefault('gain_smoothing_alpha', 0.1) + config['vad'].setdefault('adaptive_energy_std_factor', 2.5) + config['vad'].setdefault('adaptive_energy', True) + config['vad'].setdefault('noise_reduction_enabled', True) + config['vad'].setdefault('auto_gain_enabled', True) + config['vad'].setdefault('record_on_speech', True) + + config.setdefault('auth', {}) + config['auth'].setdefault('username', 'admin') + config['auth'].setdefault('password', 'admin') + + setup_logging(config['system']['logging_level']) + logger.info("Конфигурация загружена") + return config + except Exception as e: + logger.error(f"Ошибка загрузки конфигурации: {e}", exc_info=True) + raise + +def save_config(config): + logger.debug("Сохранение конфигурации") + try: + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.preserve_quotes = True + config_to_save = config.copy() + if 'commands_file' in config_to_save: + del config_to_save['commands_file'] + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + yaml.dump(config_to_save, f) + logger.info("Конфигурация сохранена") + return True + except Exception as e: + logger.error(f"Ошибка сохранения конфигурации: {e}", exc_info=True) + return False + +def init_vad_and_stt(): + logger.debug("Инициализация VAD и STT") + try: + config = load_config() + commands = config.get('commands', {}) + + stt_instance = VoskSTT( + model_path=config.get('stt', {}).get('model_path', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'models', 'vosk-model-small-ru-0.22')), + commands_file=config.get('commands_file', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'commands', 'commands.yaml')), + sample_rate=config.get('vad', {}).get('sample_rate', 16000) + ) + + def process_speech(audio_data, sample_rate): + try: + recognized = stt_instance.recognize(audio_data) + if recognized: + logger.info(f"Команда распознана: {recognized}") + handle_command(recognized, commands) + except Exception as e: + logger.error(f"Ошибка обработки речи: {e}") + + vad_config = config.get('vad', {}) + 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 инициализированы") + return vad_instance, stt_instance, commands + except Exception as e: + logger.error(f"Ошибка инициализации VAD/STT: {e}", exc_info=True) + raise + +def cleanup_vad_and_stt(vad_instance, stt_instance): + 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}") diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..958cd64 --- /dev/null +++ b/commands.py @@ -0,0 +1,168 @@ +# commands.py +import logging +import time +from datetime import datetime +from piper_TTS import synthesize_text +from app.core.config import get_settings +from volume import get_volume + +def speak_text(text, voice_name=None): + """ + Синтезирует речь из текста и возвращает путь к аудиофайлу + + Args: + text (str): Текст для синтеза + voice_name (str, optional): Имя голоса + + Returns: + str: Путь к созданному аудиофайлу или None в случае ошибки + """ + logger = logging.getLogger('VADApp') + try: + logger.info(f"Синтезирую текст: '{text}'") + audio_path = synthesize_text(text, voice_name) + logger.info(f"Синтез завершен, файл: {audio_path}") + return audio_path + except Exception as e: + logger.error(f"Ошибка синтеза речи: {e}") + return None + +def handle_command(command, commands, session_manager=None): + """ + Обрабатывает распознанную команду на основе commands.yaml и возвращает action + + Args: + command (str): Распознанная команда + commands (dict): Словарь команд из commands.yaml + session_manager (SessionManager, optional): Менеджер сессий + + Returns: + str: Действие, которое нужно выполнить, или None + """ + logger = logging.getLogger('VADApp') + try: + if not commands: + logger.warning("Команды не загружены") + return None + + # Создаем список всех возможных совпадений с их приоритетами + matches = [] + session_active = session_manager.is_session_active() if session_manager else True + min_priority = 0 if session_active else 90 + + # Проверяем wakewords (всегда проверяем, независимо от статуса сессии) + for wakeword_group in commands.get('Wakewords', []): + if isinstance(wakeword_group, dict): + phrases = wakeword_group.get('phrases', []) + action = wakeword_group.get('action', 'wake_assistant') + priority = wakeword_group.get('priority', 0) + if command.lower() in [p.lower() for p in phrases]: + matches.append({ + 'action': action, + 'priority': priority + }) + # Если обнаружено wakeword и сессия не активна, активируем её + if session_manager and not session_active and action == 'wake_assistant': + logger.info("Обнаружено wakeword, активируем сессию") + session_manager.start_session() + + # Проверяем команды громкости (всегда проверяем, независимо от статуса сессии) + for volume_group in commands.get('Volume', []): + if isinstance(volume_group, dict): + volume_commands = volume_group.get('commands', {}) + priority = volume_group.get('priority', 0) + if command.lower() in volume_commands: + action = volume_commands[command.lower()] + matches.append({ + 'action': action, + 'priority': priority + }) + + # Обработка команды "какая громкость" + if action == 'get_volume': + config = get_settings() + playback_device = config.audio_settings.playback_device + capture_device = config.audio_settings.capture_device + playback_volume = get_volume(playback_device, mixer_type='Playback') + capture_volume = get_volume(capture_device, mixer_type='Capture') + + if playback_volume is not None and capture_volume is not None: + logger.info(f"Текущая громкость: воспроизведение={playback_volume}%, запись={capture_volume}%") + # Здесь можно добавить код для озвучивания громкости, если есть такая возможность + else: + logger.error("Не удалось получить информацию о громкости") + + # Если сессия активна или команда имеет высокий приоритет, проверяем остальные команды + if session_active: + # Проверяем команды сессии + for session_group in commands.get('Session', []): + if isinstance(session_group, dict): + phrases = session_group.get('phrases', []) + action = session_group.get('action', 'session_stop') + priority = session_group.get('priority', 0) + if command.lower() in [p.lower() for p in phrases]: + matches.append({ + 'action': action, + 'priority': priority + }) + # Если обнаружена команда завершения сессии + if session_manager and action == 'session_stop': + logger.info("Обнаружена команда завершения сессии") + session_manager.end_session() + + # Проверяем текстовые команды + for text_group in commands.get('Text', []): + if isinstance(text_group, dict): + text_commands = text_group.get('commands', {}) + priority = text_group.get('priority', 0) + if command.lower() in text_commands: + matches.append({ + 'action': text_commands[command.lower()], + 'priority': priority + }) + + # Проверяем плагины + for plugin_group in commands.get('Plugin', []): + if isinstance(plugin_group, dict): + priority = plugin_group.get('priority', 0) + action = plugin_group.get('action', 'speak_plugins') + # Добавляем действие плагина только если не нашли других совпадений + # и сессия активна + if not matches and session_active: + matches.append({ + 'action': action, + 'priority': priority + }) + + # Фильтруем совпадения по минимальному приоритету + matches = [m for m in matches if m['priority'] >= min_priority] + + # Если есть совпадения, выбираем действие с наивысшим приоритетом + if matches: + # Сортируем по приоритету (от высшего к низшему) + matches.sort(key=lambda x: x['priority'], reverse=True) + action = matches[0]['action'] + logger.info(f"Выполняется действие: {action} с приоритетом {matches[0]['priority']}") + # Тестовая пауза на выполение действия + session_manager.pause_session() + + # Имитируем выполнение действия с использованием TTS + + print("Выполняем синтез речи...") + audio_path = speak_text(f"Выполняю команду: {action}") + print(f"Синтез завершен, файл: {audio_path}") + time.sleep(2) # Пауза на 2 секунды + + # Возобновляем сессию после паузы + session_manager.resume_session() + # Сбрасываем таймер сессии, если действие не завершает сессию + if session_manager and session_manager.is_session_active() and action != 'session_stop': + + session_manager.reset_timer() + return action + else: + logger.warning(f"Команда не найдена в commands.yaml: {command}") + return None + except Exception as e: + logger.error(f"Ошибка обработки команды: {e}") + return None diff --git a/commands/commands.yaml b/commands/commands.yaml new file mode 100644 index 0000000..8680493 --- /dev/null +++ b/commands/commands.yaml @@ -0,0 +1,46 @@ +Wakewords: + - phrases: + - "привет дройд" + - "привет придурок" + - "привет говнюк" + - "привет жопа с ушами" + - "привет дурик" + action: wake_assistant + priority: 100 + +Volume: + - priority: 95 + commands: + "какая громкость": get_volume + "сделай громче": volume_up + "сделай тише": volume_down + "громкость ноль": set_volume_0 + "громкость один": set_volume_1 + "громкость два": set_volume_2 + "громкость три": set_volume_3 + "громкость четыре": set_volume_4 + "громкость пять": set_volume_5 + "громкость шесть": set_volume_6 + "громкость семь": set_volume_7 + "громкость восемь": set_volume_8 + "громкость девять": set_volume_9 + "громкость десять": set_volume_10 + +Session: + - phrases: + - "стоп" + - "хватит" + - "заткнись" + - "спасибо" + action: session_stop + priority: 80 + +Text: + - priority: 50 + commands: + "что ты умеешь": speak_about + "какие у тебя есть команды": speak_commands + +Plugin: + - priority: 10 + action: speak_plugins diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..043ee75 --- /dev/null +++ b/config.yaml @@ -0,0 +1,60 @@ +# Настройки системы +system: + # Максимальное количество записей в истории + history_maxlen: 100 + polling_interval_ms: 1000 + logging_level: debug + log_file: logs/app.log + log_save_file: false +vad: + # Режим VAD (0-3) + mode: 3 + sample_rate: 16000 + frame_duration_ms: 10 + ring_buffer_size: 20 + speech_threshold: 0.6 + energy_threshold: 400 + min_speech_duration_ms: 100 + record_stop_delay_sec: 1.0 + prebuffer_duration_sec: 1.0 + max_record_duration_sec: 5.0 + target_rms: 2000.0 + max_gain: 5.0 + noise_reduction_factor: 1.2 + noise_update_alpha: 0.05 + min_rms_threshold_factor: 2.0 + gain_smoothing_alpha: 0.1 + adaptive_energy_std_factor: 1.5 + adaptive_energy: true + noise_reduction_enabled: true + auto_gain_enabled: false + record_on_speech: false +stt: + # Путь к модели Vosk + model_path: /home/sirmrak/models/vosk-model-small-ru-0.22 +commands: + # Путь к файлу с командами + commands_file: /mnt/Project/my_speaker/commands/commands.yaml +auth: + # Имя пользователя + username: string + password: string +tts: + # Настройки TTS + host: 192.168.200.81 + port: 10200 + timeout: 30 + pause_duration: 0.5 + post_stop_wait: 1.0 + voice: ru_RU-ruslan-medium + save_TTS_files: false +session: + # Время сессии в секундах + session_timeout_seconds: 30 + +audio_settings: + # Настройки аудио системы + playback_device: 2 + capture_device: 2 + playback_volume: 80 + capture_volume: 100 diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md new file mode 100644 index 0000000..792c391 --- /dev/null +++ b/docs/API_DOCS.md @@ -0,0 +1,318 @@ +# Документация API + +Эта документация описывает все доступные эндпоинты API для приложения, включая методы, параметры, ответы и примеры использования. API разделено на три группы маршрутов: /api/auth, /api/settings и /api/dashboard. Большинство эндпоинтов, кроме /api/auth/login, требуют авторизации через Bearer-токен, который можно получить через эндпоинт логина. + +## Общая информация + +- **Базовый URL**: http://192.168.200.111:8000 +- **Авторизация**: Большинство эндпоинтов требуют заголовок `Authorization: Bearer ` или query-параметр `token=`. Токен получается через /api/auth/login. +- **Формат ответов**: JSON. +- **Коды ошибок**: + - 401 Unauthorized: Требуется авторизация или токен недействителен. + - 500 Internal Server Error: Ошибка на сервере. + +## Получение токена авторизации + +Для доступа к защищённым эндпоинтам необходимо получить Bearer-токен через эндпоинт /api/auth/login. + +- **Эндпоинт**: POST /api/auth/login +- **Описание**: Аутентифицирует пользователя и возвращает сессионный токен. +- **Тело запроса (JSON)**: + ``` + { + "username": "string", + "password": "string" + } + ``` +- **Ответ**: + + - Успех (200): + ``` + { + "access_token": "string", + "token_type": "bearer" + } + ``` + - Ошибка (401): + ``` + { + "detail": "Неверные учетные данные" + } + ``` + +- **Пример**: + + ``` + curl -X POST http://192.168.200.111:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin"}' + ``` + + Результат (пример): + + ``` + { + "access_token": "123e4567-e89b-12d3-a456-426614174000", + "token_type": "bearer" + } + ``` + +Сохраните `access_token` для использования в других запросах. + +## Эндпоинты /api/auth + +1. **Получение информации о текущем пользователе** + + - **Эндпоинт**: GET /api/auth/me + - **Описание**: Возвращает информацию о текущем пользователе на основе предоставленного токена. Если токен отсутствует или недействителен, возвращает неаутентифицированный статус. + - **Параметры запроса**: + - authorization (опционально, строка): Заголовок Authorization: Bearer . + - token_param (опционально, строка): Query-параметр token=. + - **Ответ**: + + - Успех (200): + ``` + { + "username": "string", + "is_authenticated": true + } + ``` + - Неаутентифицирован (200): + ``` + { + "username": "", + "is_authenticated": false + } + ``` + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/auth/me \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Выход из системы** + + - **Эндпоинт**: POST /api/auth/logout + - **Описание**: Завершает сессию пользователя, удаляя токен. + - **Параметры запроса**: + - authorization (опционально, строка): Заголовок Authorization: Bearer . + - token_param (опционально, строка): Query-параметр token=. + - **Ответ**: + + - Успех (200): + ``` + { + "status": "success", + "message": "Выход выполнен успешно" + } + ``` + - Нет сессии (200): + ``` + { + "status": "success", + "message": "Нет активной сессии" + } + ``` + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X POST http://192.168.200.111:8000/api/auth/logout \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Эндпоинты /api/settings + +1. **Получение всех настроек** + + - **Эндпоинт**: GET /api/settings + - **Описание**: Возвращает все настройки приложения (VAD, system, STT, commands, auth). + - **Ответ**: + + - Успех (200): Объект с полями vad, system, stt, commands, auth (см. модели в коде). + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/settings \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Обновление настроек VAD** + + - **Эндпоинт**: PUT /api/settings/vad + - **Описание**: Обновляет настройки VAD. + - **Тело запроса (JSON)**: Объект VADSettings (см. модель в коде). + - **Ответ**: + + - Успех (200): Обновлённый объект VADSettings. + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X PUT http://192.168.200.111:8000/api/settings/vad \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "Content-Type: application/json" \ + -d '{"mode": 1, "sample_rate": 16000, ...}' + ``` + +(Аналогично для /system, /stt, /commands, /auth — обновление соответствующих настроек.) + +3. **Перезапуск VAD и STT** + + - **Эндпоинт**: POST /api/settings/restart + - **Описание**: Перезапускает VAD и STT с новыми настройками. + - **Ответ**: + + - Успех (200): + ``` + { + "status": "success", + "message": "VAD и STT перезапущены" + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X POST http://192.168.200.111:8000/api/settings/restart \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Эндпоинты /api/dashboard + +1. **Получение данных о речи и энергии** + + - **Эндпоинт**: GET /api/dashboard/speech-data + - **Описание**: Возвращает текущие данные о речи и энергии, включая расширенные метрики VAD и STT. + - **Ответ**: + + - Успех (200): + ``` + { + "state": "string", + "speech_prob": 0.0, + "energy": 0.0, + "energy_threshold": 0.0, + "speech_detected": false, + "continuous_frames": 0, + "gain": 1.0, + "noise_floor": 0.0, + "stt_model_path": "string", + "stt_commands_count": 0 + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/speech-data \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Получение последних распознанных фраз** + + - **Эндпоинт**: GET /api/dashboard/recent-phrases + - **Описание**: Возвращает список последних распознанных фраз (до указанного лимита). + - **Параметры запроса**: + - limit (опционально, целое число, по умолчанию 10): Максимальное количество фраз. + - **Ответ**: + + - Успех (200): + ``` + [ + { + "text": "Пример фразы", + "timestamp": "2025-10-13T11:13:00", + "is_command": true + }, + ... + ] + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/recent-phrases?limit=5 \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +3. **Получение статистики системы** + + - **Эндпоинт**: GET /api/dashboard/stats + - **Описание**: Возвращает расширенную статистику работы системы (время работы, количество распознанных фраз, системная нагрузка и т.д.). + - **Ответ**: + + - Успех (200): + ``` + { + "uptime": "0:00:00", + "recognized_phrases_count": 0, + "commands_executed_count": 0, + "system_load": { + "cpu_percent": 0.0, + "memory_percent": 0.0, + "temperature": 0.0 + }, + "total_speech_frames": 0, + "avg_speech_prob": 0.0, + "vad_initialized": true, + "stt_initialized": true + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/stats \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +4. **Получение данных о нагрузке системы** + + - **Эндпоинт**: GET /api/dashboard/sys-monitor + - **Описание**: Возвращает данные о нагрузке системы (CPU, память, температура). + - **Ответ**: + + - Успех (200): + ``` + { + "cpu_percent": 0.0, + "memory_percent": 0.0, + "temperature": 0.0 + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/sys-monitor \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Замечания + +- **Авторизация**: Все эндпоинты, кроме /api/auth/login, требуют токен, полученный через /api/auth/login. Токен можно передать: + - В заголовке: `Authorization: Bearer ` + - В query-параметре: `?token=` +- **Безопасность**: Пароли в настройках хранятся в открытом виде. Рекомендуется использовать хэширование (например, bcrypt) для auth.password. +- **Хранение сессий**: Токены хранятся в памяти (ACTIVE_SESSIONS). Для продакшена используйте Redis или другую базу данных. +- **Ограничения**: Токены не имеют срока действия. Рекомендуется добавить expiration (например, хранить timestamp в ACTIVE_SESSIONS). +- **Обновления**: Документация актуализирована на 13 октября 2025 года. Добавлены новые поля в /speech-data, новый эндпоинт /sys-monitor и расширенная структура для /stats. diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..f972134 --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,377 @@ +# Документация API My Speaker + +## Общая информация + +API My Speaker предоставляет интерфейс для взаимодействия с системой распознавания речи, управления настройками и получения данных о работе системы. API построено на FastAPI и использует REST-подход. + +Базовый URL: `/api` + +## Аутентификация + +Все запросы к API (кроме авторизации) требуют аутентификации. Аутентификация осуществляется с помощью токена доступа, который можно получить через эндпоинт `/api/auth/login`. + +Токен можно передавать одним из следующих способов: + +1. В заголовке `Authorization: Bearer ` +2. В параметре запроса `?token=` + +### Эндпоинты аутентификации + +#### Авторизация + +``` +POST /api/auth/login +``` + +**Запрос:** + +```json +{ + "username": "admin", + "password": "your_password" +} +``` + +**Ответ:** + +```json +{ + "access_token": "unique_session_token", + "token_type": "bearer" +} +``` + +#### Получение информации о текущем пользователе + +``` +GET /api/auth/me +``` + +**Ответ:** + +```json +{ + "username": "admin", + "is_authenticated": true +} +``` + +#### Выход из системы + +``` +POST /api/auth/logout +``` + +**Ответ:** + +```json +{ + "status": "success", + "message": "Выход выполнен успешно" +} +``` + +## Панель управления (Dashboard) + +Все запросы к эндпоинтам панели управления выполняются с использованием токена полученного при авторизации. + +### Получение данных о речи + +```` +GET /api/dashboard/speech-data +curl -X 'GET' \ + 'http://192.168.200.111:8000/api/dashboard/sys-monitor' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer dc1460d1-6e67-4730-afb2-c94de7d243b4' + +**Ответ:** +```json +{ + "state": "listening", + "speech_prob": 0.85, + "speech_threshold": 0.5, + "energy": 1200, + "energy_threshold": 1000, + "speech_detected": true, + "continuous_frames": 5, + "gain": 1.2, + "noise_floor": 300 +} +```` + +### Получение данных о нагрузке системы + +``` +GET /api/dashboard/sys-monitor +``` + +**Ответ:** + +```json +{ + "cpu_percent": 25.5, + "memory_percent": 40.2, + "temperature": 45.3 +} +``` + +### Получение последних распознанных фраз + +``` +GET /api/dashboard/recent-phrases?limit=10 +``` + +**Ответ:** + +```json +[ + { + "text": "включи свет", + "timestamp": "2023-06-15T14:30:45", + "is_command": true + }, + { + "text": "какая погода сегодня", + "timestamp": "2023-06-15T14:29:30", + "is_command": false + } +] +``` + +### Получение статистики работы системы + +``` +GET /api/dashboard/stats +``` + +**Ответ:** + +```json +{ + "uptime": "2:30:15", + "recognized_commands": 15, + "total_phrases": 45 +} +``` + +## Настройки (Settings) + +Все запросы к эндпоинтам настроек выполняются с использованием токена полученного при авторизации. + +### Получение всех настроек + +``` +GET /api/settings +``` + +**Ответ:** + +```json +{ + "vad": { + "mode": 3, + "sample_rate": 16000, + "frame_duration_ms": 30, + "ring_buffer_size": 20, + "speech_threshold": 0.5, + "energy_threshold": 1000, + "min_speech_duration_ms": 300, + "record_stop_delay_sec": 1.0, + "prebuffer_duration_sec": 1.0, + "max_record_duration_sec": 5.0, + "target_rms": 2000.0, + "max_gain": 5.0, + "noise_reduction_factor": 1.5, + "noise_update_alpha": 0.05, + "min_rms_threshold_factor": 2.0, + "gain_smoothing_alpha": 0.1, + "adaptive_energy_std_factor": 2.5, + "adaptive_energy": true, + "noise_reduction_enabled": true, + "auto_gain_enabled": true, + "record_on_speech": true + }, + "system": { + "history_maxlen": 100, + "polling_interval_ms": 500, + "logging_level": "INFO", + "log_file": "logs/app.log", + "log_save_file": true + }, + "stt": { + "model_path": "/path/to/vosk/model" + }, + "commands": { + "commands_file": "commands/commands.yaml" + }, + "auth": { + "username": "admin", + "password": "admin" + } +} +``` + +### Обновление настроек VAD + +``` +PUT /api/settings/vad +``` + +**Запрос:** + +```json +{ + "mode": 3, + "speech_threshold": 0.7, + "energy_threshold": 1200 +} +``` + +**Ответ:** + +```json +{ + "mode": 3, + "sample_rate": 16000, + "frame_duration_ms": 30, + "ring_buffer_size": 20, + "speech_threshold": 0.7, + "energy_threshold": 1200, + "min_speech_duration_ms": 300, + "record_stop_delay_sec": 1.0, + "prebuffer_duration_sec": 1.0, + "max_record_duration_sec": 5.0, + "target_rms": 2000.0, + "max_gain": 5.0, + "noise_reduction_factor": 1.5, + "noise_update_alpha": 0.05, + "min_rms_threshold_factor": 2.0, + "gain_smoothing_alpha": 0.1, + "adaptive_energy_std_factor": 2.5, + "adaptive_energy": true, + "noise_reduction_enabled": true, + "auto_gain_enabled": true, + "record_on_speech": true +} +``` + +### Обновление системных настроек + +``` +PUT /api/settings/system +``` + +**Запрос:** + +```json +{ + "history_maxlen": 200, + "polling_interval_ms": 1000 +} +``` + +**Ответ:** + +```json +{ + "history_maxlen": 200, + "polling_interval_ms": 1000, + "logging_level": "INFO", + "log_file": "logs/app.log", + "log_save_file": true +} +``` + +### Обновление настроек STT + +``` +PUT /api/settings/stt +``` + +**Запрос:** + +```json +{ + "model_path": "/new/path/to/vosk/model" +} +``` + +**Ответ:** + +```json +{ + "model_path": "/new/path/to/vosk/model" +} +``` + +### Обновление настроек команд + +``` +PUT /api/settings/commands +``` + +**Запрос:** + +```json +{ + "commands_file": "commands/new_commands.yaml" +} +``` + +**Ответ:** + +```json +{ + "commands_file": "commands/new_commands.yaml" +} +``` + +### Обновление настроек авторизации + +``` +PUT /api/settings/auth +``` + +**Запрос:** + +```json +{ + "username": "new_admin", + "password": "new_password" +} +``` + +**Ответ:** + +```json +{ + "username": "new_admin", + "password": "new_password" +} +``` + +### Перезапуск VAD и STT + +``` +POST /api/settings/restart +``` + +**Ответ:** + +```json +{ + "status": "success", + "message": "VAD и STT перезапущены" +} +``` + +## Коды ошибок + +- `401 Unauthorized` - Ошибка авторизации +- `500 Internal Server Error` - Внутренняя ошибка сервера + +## Примечания + +- Все запросы и ответы используют формат JSON +- Для всех запросов, требующих авторизации, необходимо передавать токен доступа +- Параметры в запросах на обновление настроек являются опциональными, можно передавать только те параметры, которые нужно изменить diff --git a/docs/FRONTEND_REQUIREMENTS.md b/docs/FRONTEND_REQUIREMENTS.md new file mode 100644 index 0000000..054e4d4 --- /dev/null +++ b/docs/FRONTEND_REQUIREMENTS.md @@ -0,0 +1,343 @@ +# Техническое задание на разработку фронтенда My Speaker + +## 1. Общая информация и структура проекта + +webapp/ +├── index.html # Основная страница приложения (SPA) +├── style.css # Основные стили в ретро-стилистике +├── api.js # Модуль для работы с API (уже существует) +├── app.js # Точка входа: инициализация приложения и маршрутизация +├── modules/ # Папка для модулей приложения +│ ├── router.js # Модуль маршрутизации (загрузка страниц) +│ ├── login.js # Логика для страницы авторизации +│ ├── dashboard.js # Логика для панели мониторинга +│ ├── settings.js # Логика для страницы настроек +│ ├── commands.js # Логика для страницы команд (зарезервировано на будущее) +│ ├── header.js # Логика для верхней панели (имя пользователя, выход) +│ ├── nav.js # Логика для навигационной панели +│ └── utils.js # Общие утилиты (например, обработка ошибок, форматирование) +├── pages/ # Папка для HTML-страниц +│ ├── dashboard.html # Разметка для панели мониторинга +│ ├── settings.html # Разметка для настроек +│ ├── commands.html # Разметка для команд +│ └── login.html # Разметка для авторизации +└── components/ # Папка для общих компонентов +├── header.html # Разметка верхней панели +└── nav.html # Разметка навигационной панели + +### 1.1. Цель проекта + +Разработка веб-интерфейса для системы распознавания речи My Speaker, позволяющего управлять настройками системы, отслеживать состояние распознавания речи и просматривать историю распознанных фраз. + +### 1.2. Технологический стек + +- HTML5 +- CSS3 +- JavaScript (ES6+) +- графики chart.js +- Без использования фреймворков (чистый JavaScript) + +## 2. Функциональные требования + +### 2.1. Авторизация + +- Кнопка "шестеренка" для настройки основного цвета интерфейса (по умолчанию #0f0) и поле для ввода адреса бекенда (по умолчанию http://localhost:8000) +- Форма входа с полями для ввода логина и пароля +- Сохранение токена авторизации в localStorage +- Автоматический выход при истечении срока действия токена +- Кнопка выхода из системы, которая удаляет токен из localStorage + +### 2.2. Панель мониторинга (Dashboard) не обновляется пока нет авторизации + +- Отображение текущего состояния системы распознавания речи +- Визуализация уровня речи и энергии звука в реальном времени +- Два ползунка громкости для микрофона и колонок (набудущее) +- График изменения вероятности речи ("speech_prob" и "speech_threshold") и энергии звука ("energy" и "energy_threshold") +- Данные обновляются с частотой из настроек системы (polling_interval_ms) +- Отображение системной информации (загрузка CPU, память, температура) +- Список последних распознанных фраз с выделением команд +- Индикация состояния системы (прослушивание, запись, обработка) + +### 2.3. Настройки системы + +- Формы должны быть свернуты и при развертывании новой, старая сворачивается. +- Формы должны быть заполнены согласно описанию config.yaml.md (использоватся выпадающие списки для выбора определенных значений, кнопки больше и меньше должны иметь нужный шаг) +- Кнопка сохранения для каждой формы, которая сохраняет введенные пользователем данные на сервер. +- Форма для настройки параметров VAD (Voice Activity Detection) +- Форма для настройки системных параметров +- Форма для настройки параметров STT (Speech-to-Text) +- Форма для настройки параметров команд +- Форма для изменения учетных данных +- Кнопка для перезапуска VAD и STT + +## 3. Требования к интерфейсу + +### 3.1. Общие требования + +- Адаптивный дизайн (поддержка мобильных устройств) +- Максимальное использование пространства экрана без прокрутки как по вертикали, так и по горизонтали (в десктоп версии) +- Темная цветовая схема в стиле ретро-игр (8-bit) +- Использование шрифта "Press Start 2P" для создания пиксельной эстетики +- Зеленая цветовая гамма на темном фоне (основной цвет текста #0f0) +- Пиксельные элементы интерфейса (кнопки, индикаторы, рамки) +- Интуитивно понятный интерфейс с игровой стилистикой + +### 3.2. Структура интерфейса + +- Экран авторизации в стиле игрового меню +- Основной экран с двумя основными панелями (информационная и контентная) +- Верхняя панель с именем пользователя и кнопкой выхода +- Навигационная панель для переключения между разделами (графики/настройки/команды) +- Пиксельные рамки вокруг всех блоков и элементов интерфейса + +### 3.3. Разделы интерфейса + +1. **Авторизация** + + - Форма входа в стиле игрового меню с пиксельной рамкой + - Поля для ввода логина (PLAYER NAME) и пароля (SECRET CODE) + - Кнопка "START" в пиксельном стиле + - Сообщение об ошибке в красном цвете + - Подсказка с дефолтными учетными данными + +2. **Панель мониторинга** + + - Информационная панель с основными параметрами (MAIN PARAMETERS) + - Индикатор состояния системы с цветовой индикацией + - Метрики с процентом речи и уровнем энергии с визуальными индикаторами + - Системная статистика (CPU, MEM, TEMP) с графическими индикаторами + - Игровая статистика (UPTIME, VAD, STT, AVG) + - Окно лога с последними распознанными фразами + - Графики активности речи и энергии звука + +3. **Настройки** + - Кнопка "REBOOT" для перезапуска системы + - Раскрывающиеся группы настроек (details/summary) + - Формы с полями ввода для каждого параметра в стиле ретро-игры + - Выпадающие списки и числовые поля с пиксельными рамками + +## 4. Взаимодействие с API + +### 4.1. Авторизация + +- `POST /api/auth/login` - авторизация пользователя +- `GET /api/auth/me` - получение информации о текущем пользователе +- `POST /api/auth/logout` - выход из системы + +### 4.2. Панель мониторинга + +- `GET /api/dashboard/speech-data` - получение данных о речи +- `GET /api/dashboard/sys-monitor` - получение данных о нагрузке системы +- `GET /api/dashboard/recent-phrases` - получение последних распознанных фраз +- `GET /api/dashboard/stats` - получение статистики работы системы + +### 4.3. Настройки + +- `GET /api/settings` - получение всех настроек +- `PUT /api/settings/vad` - обновление настроек VAD +- `PUT /api/settings/system` - обновление системных настроек +- `PUT /api/settings/stt` - обновление настроек STT +- `PUT /api/settings/commands` - обновление настроек команд +- `PUT /api/settings/auth` - обновление настроек авторизации +- `POST /api/settings/restart` - перезапуск VAD и STT + +## 5. Описание ключевых параметров настройки + +### 5.1. Параметры VAD (Voice Activity Detection) + +| Параметр | Описание | Тип элемента управления | +| ----------------------- | ----------------------------------------------------- | ----------------------- | +| mode | Режим работы VAD (агрессивность фильтрации) от 0 до 3 | Выпадающий список | +| speech_threshold | Порог вероятности речи (0.0 - 1.0) | Ползунок | +| energy_threshold | Порог энергии звука | Числовое поле | +| min_speech_duration_ms | Минимальная длительность речи в мс | Числовое поле | +| record_stop_delay_sec | Задержка остановки записи после окончания речи в сек | Числовое поле | +| adaptive_energy | Использовать ли адаптивный порог энергии | Переключатель | +| noise_reduction_enabled | Включить подавление шума | Переключатель | +| auto_gain_enabled | Включить автоматическую регулировку усиления | Переключатель | + +### 5.2. Системные параметры + +| Параметр | Описание | Тип элемента управления | +| ------------------- | ------------------------------------------------------- | ----------------------- | +| history_maxlen | Максимальное количество записей в истории распознавания | Числовое поле | +| polling_interval_ms | Интервал опроса состояния системы в миллисекундах | Числовое поле | +| logging_level | Уровень логирования | Выпадающий список | +| log_save_file | Сохранять ли логи в файл | Переключатель | + +## 6. Требования к реализации + +### 6.1. Структура проекта + +``` +webapp/ +├── index.html # Единая страница приложения (SPA) +├── style.css # Основные стили в ретро-стилистике +├── api.js # Модуль для работы с API +└── app.js # Основной модуль приложения с логикой +``` + +### 6.2. Требования к коду + +- Модульная структура JavaScript-кода +- Использование современных возможностей JavaScript (ES6+) +- Комментирование кода +- Обработка ошибок и исключений +- Валидация вводимых данных на стороне клиента + +### 6.3. Требования к производительности + +- Быстрая загрузка страниц (оптимизация размера файлов) +- Эффективное обновление данных без перезагрузки страницы +- Оптимизация для работы на устройствах с ограниченными ресурсами + +## 7. Примеры реализации ключевых компонентов + +### 7.1. Авторизация + +```javascript +// Пример функции авторизации +async function login(username, password) { + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + throw new Error("Ошибка авторизации"); + } + + const data = await response.json(); + localStorage.setItem("token", data.access_token); + window.location.href = "index.html"; + } catch (error) { + showError(error.message); + } +} +``` + +### 7.2. Получение данных о речи + +```javascript +// Пример функции получения данных о речи +async function getSpeechData() { + try { + const token = localStorage.getItem("token"); + + if (!token) { + window.location.href = "login.html"; + return; + } + + const response = await fetch("/api/dashboard/speech-data", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.href = "login.html"; + return; + } + throw new Error("Ошибка получения данных"); + } + + const data = await response.json(); + updateSpeechDataUI(data); + } catch (error) { + showError(error.message); + } +} + +// Обновление интерфейса с полученными данными +function updateSpeechDataUI(data) { + document.getElementById("speech-prob").textContent = + (data.speech_prob * 100).toFixed(1) + "%"; + document.getElementById("energy-level").textContent = data.energy; + + // Обновление индикаторов + const speechIndicator = document.getElementById("speech-indicator"); + speechIndicator.style.width = data.speech_prob * 100 + "%"; + speechIndicator.classList.toggle("active", data.speech_detected); + + const energyIndicator = document.getElementById("energy-indicator"); + const energyPercent = Math.min( + 100, + (data.energy / data.energy_threshold) * 100 + ); + energyIndicator.style.width = energyPercent + "%"; + + // Добавление данных в график + addDataToChart(speechChart, data.speech_prob, data.energy); +} +``` + +### 7.3. Обновление настроек + +```javascript +// Пример функции обновления настроек VAD +async function updateVADSettings(settings) { + try { + const token = localStorage.getItem("token"); + + if (!token) { + window.location.href = "login.html"; + return; + } + + const response = await fetch("/api/settings/vad", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.href = "login.html"; + return; + } + throw new Error("Ошибка обновления настроек"); + } + + const data = await response.json(); + showSuccess("Настройки успешно обновлены"); + return data; + } catch (error) { + showError(error.message); + return null; + } +} +``` + +## 8. Сроки и этапы разработки + +### 8.1. Этапы разработки + +1. Разработка структуры и базовых компонентов (3 дня) +2. Реализация авторизации (2 дня) +3. Разработка панели мониторинга (5 дней) +4. Разработка страницы настроек (4 дня) +5. Тестирование и отладка (3 дня) +6. Оптимизация и финальные доработки (2 дня) + +### 8.2. Общий срок разработки + +19 рабочих дней + +## 9. Критерии приемки + +1. Корректная работа всех функциональных элементов +2. Соответствие дизайна требованиям +3. Корректное отображение на различных устройствах +4. Отсутствие критических ошибок +5. Соответствие требованиям производительности diff --git a/docs/PAGE_LAYOUTS.md b/docs/PAGE_LAYOUTS.md new file mode 100644 index 0000000..572bb56 --- /dev/null +++ b/docs/PAGE_LAYOUTS.md @@ -0,0 +1,284 @@ +# Макеты страниц веб-приложения My Speaker + +Ниже представлены псевдографические макеты страниц приложения My Speaker в стиле ретро-игры (8-bit, шрифт "Press Start 2P", зеленая гамма `#0f0` на темном фоне). Макеты созданы для альбомной ориентации десктопных экранов и адаптивной верстки для мобильных устройств. Структура главного экрана включает: + +1. **Шапка**: Верхняя панель (`header.html`) с именем пользователя и кнопкой выхода, навигационная панель (`nav.html`) с переключением разделов. +2. **Основной экран**: + - **Левая часть (Main Parameters)**: 40% ширины, постоянно отображает статус системы, индикаторы речи/энергии и системную статистику (`main-params.html`). + - **Правая часть (Контент)**: 60% ширины, динамически подгружает `graphics.html`, `settings.html`, `commands.html`. +3. **Футер**: Логи с последними распознанными фразами (`footer.html`). + +Страница авторизации (`login.html`) отображается отдельно, без шапки, навигации и футера. + +## 1. Страница авторизации (`login.html`) + +**Описание**: Экран входа в стиле игрового меню с пиксельной рамкой. Содержит форму для логина (`PLAYER NAME`) и пароля (`SECRET CODE`), кнопку `[ START ]`, сообщение об ошибке (в красном цвете) и настройки интерфейса (цвет, URL бэкенда), открываемые по кнопке `[⚙️ Settings]`. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| | +| == MY SPEAKER LOGIN == | +| | +| +-----------------------------+ | +| | PLAYER NAME: [==========] | | +| | SECRET CODE: [==========] | | +| | [ START ] | | +| +-----------------------------+ | +| [ ERROR: Invalid login ] | +| Default: admin / admin | +| | +| [⚙️ Settings] | +| +-----------------------------+ | +| | Main Color: [####] | | +| | Backend URL: [==========] | | +| +-----------------------------+ | +| | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| | +| == MY SPEAKER LOGIN == +| | +| +-----------------+ | +| | PLAYER NAME: | | +| | [============] | | +| | SECRET CODE: | | +| | [============] | | +| | [ START ] | | +| +-----------------+ | +| [ ERROR: Invalid ] | +| Default: admin/admin| +| | +| [⚙️ Settings] | +| +-----------------+ | +| | Main Color: [##]| | +| | Backend: [====] | | +| +-----------------+ | +| | ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Форма центрирована, занимает ~30-40% ширины для альбомной ориентации. Пиксельная рамка (`+---+`) и ретро-стиль. +- **Мобильный**: Форма растягивается на всю ширину, поля ввода и кнопка увеличены. +- **Адаптивность**: Элементы масштабируются, текст остается читаемым. + +## 2. Главный экран с `graphics.html` + +**Описание**: Главный экран с `graphics.html` в правой части (60%). Левая часть (40%) показывает статус системы, индикаторы речи/энергии и статистику (`main-params.html`). Футер отображает последние фразы (`footer.html`). + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | SPEACH | +| | | | | +| | Status: Listening | | ^ +| | [============] 85% | | * | +| | | | | * | +| | Speech Prob: [===] | | | * | +| | Energy: [==] | | +-------------------+ +| | | | Time | +| | CPU: 25% | | | +| | MEM: 40% | | | +| | TEMP: 45°C | | | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == GRAPHICS == | +| ^ * | +| | * | +| | * | +| | * | +| +-----------------+ +| Time | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Шапка и навигация во всю ширину. Левая часть (40%) с индикаторами и статистикой, правая (60%) с линейным графиком (`speech_prob`, `energy`). Футер с фразами. +- **Мобильный**: Карточки располагаются вертикально, график уменьшен. +- **Адаптивность**: Альбомная ориентация на десктопе использует ширину экрана, на мобильных — вертикальная компоновка. + +## 3. Главный экран с `settings.html` + +**Описание**: Правая часть (60%) подгружает `settings.html` с раскрывающимися формами для настройки VAD, системных параметров и т.д. Левая часть и футер неизменны. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | SETTINGS | +| | | | | +| | Status: Listening | | + VAD Settings v | +| | [============] 85% | | +---------------------+ | +| | | | | Mode: [0-3] | | +| | Speech Prob: [===] | | | Speech Thr: [====] | | +| | Energy: [==] | | | Energy Thr: [####] | | +| | | | | [ SAVE ] | | +| | CPU: 25% | | +---------------------+ | +| | MEM: 40% | | - System Settings | +| | TEMP: 45°C | | [ REBOOT ] | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == SETTINGS == | +| + VAD Settings v | +| +-----------------+ | +| | Mode: [0-3] | | +| | Speech Thr: [==]| | +| | Energy Thr: [##]| | +| | [ SAVE ] | | +| +-----------------+ | +| - System Settings | +| [ REBOOT ] | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Правая часть показывает формы (`
`), например, VAD с выпадающим списком и ползунком. Кнопка `[ REBOOT ]` внизу. +- **Мобильный**: Формы растягиваются на всю ширину, поля ввода увеличены. +- **Адаптивность**: Элементы управления масштабируются, формы сворачиваются/разворачиваются. + +## 4. Главный экран с `commands.html` + +**Описание**: Правая часть (60%) подгружает `commands.html` с минимальной реализацией (путь к файлу команд и список). Левая часть и футер неизменны. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | COMMANDS | +| | | | | +| | Status: Listening | | Commands File: [file] | +| | [============] 85% | | [ EDIT ] [ SAVE ] | +| | | | | +| | Speech Prob: [===] | | "Turn on light" | +| | Energy: [==] | | "Play music" | +| | | | | +| | CPU: 25% | | | +| | MEM: 40% | | | +| | TEMP: 45°C | | | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == COMMANDS == | +| Commands File: [f] | +| [ EDIT ] [ SAVE ] | +| "Turn on light" | +| "Play music" | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Правая часть показывает поле для `commands_file` и список команд. Кнопки `[ EDIT ]` и `[ SAVE ]` для редактирования. +- **Мобильный**: Элементы растягиваются на всю ширину, список команд вертикальный. +- **Адаптивность**: Компактное расположение, текст читаемый. + +## Пояснения к макетам + +- **Ретро-стиль**: Используются символы `+`, `=`, `*` для пиксельных рамок и индикаторов, отражая 8-bit эстетику и зеленую гамму (`#0f0`). +- **Альбомная ориентация**: Десктопные макеты оптимизированы для широкого экрана, с разделением на 40% (Main Parameters) и 60% (контент). +- **Адаптивность**: Мобильные макеты используют вертикальное расположение, избегая горизонтальной прокрутки, как указано в `FRONTEND_REQUIREMENTS.md`. +- **Структура**: Шапка, основной экран (две карточки) и футер соответствуют запросу. Страница авторизации отдельная. +- **Функциональность**: Макеты включают все элементы из требований (индикаторы, графики, формы, логи). diff --git a/docs/config.yaml.md b/docs/config.yaml.md new file mode 100644 index 0000000..140ffb8 --- /dev/null +++ b/docs/config.yaml.md @@ -0,0 +1,86 @@ +# Документация по конфигурационному файлу config.yaml + +## Секция system +Основные настройки системы + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| history_maxlen | Максимальное количество записей в истории распознавания | Целое число > 0 (рекомендуется: 50-1000) | +| polling_interval_ms | Интервал опроса состояния системы в миллисекундах | Целое число > 100 (рекомендуется: 1000-5000) | +| logging_level | Уровень логирования | INFO, DEBUG, WARNING, ERROR, CRITICAL | +| log_file | Путь к файлу логов | Строка, путь к файлу | +| log_save_file | Сохранять ли логи в файл | true/false | + +## Секция vad +Настройки системы определения голосовой активности (Voice Activity Detection) + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| mode | Режим работы VAD (агрессивность фильтрации) | 0 (наименее агрессивный) - 3 (самый агрессивный) | +| sample_rate | Частота дискретизации аудио в герцах | 8000, 16000, 32000, 48000 | +| frame_duration_ms | Длительность фрейма в миллисекундах | 10, 20, 30 | +| ring_buffer_size | Размер кольцевого буфера для фреймов | Целое число > 0 (рекомендуется: 10-30) | +| speech_threshold | Порог вероятности речи | 0.0 - 1.0 (рекомендуется: 0.5-0.9) | +| energy_threshold | Порог энергии звука | Целое число > 0 (рекомендуется: 300-1000) | +| min_speech_duration_ms | Минимальная длительность речи в мс | Целое число > 0 (рекомендуется: 100-500) | +| record_stop_delay_sec | Задержка остановки записи после окончания речи в сек | Число > 0.0 (рекомендуется: 0.5-2.0) | +| prebuffer_duration_sec | Длительность предварительного буфера в сек | Число > 0.0 (рекомендуется: 0.5-2.0) | +| max_record_duration_sec | Максимальная длительность записи в сек | Число > 0.0 (рекомендуется: 5.0-30.0) | +| target_rms | Целевое среднеквадратичное значение громкости | Число > 0.0 (рекомендуется: 1000-3000) | +| max_gain | Максимальное усиление звука | Число > 0.0 (рекомендуется: 1.0-10.0) | +| noise_reduction_factor | Коэффициент подавления шума | Число > 1.0 (рекомендуется: 1.2-2.0) | +| noise_update_alpha | Коэффициент обновления оценки шума | 0.0 - 1.0 (рекомендуется: 0.01-0.1) | +| min_rms_threshold_factor | Минимальный коэффициент порога RMS | Число > 1.0 (рекомендуется: 1.5-3.0) | +| gain_smoothing_alpha | Коэффициент сглаживания усиления | 0.0 - 1.0 (рекомендуется: 0.05-0.2) | +| adaptive_energy_std_factor | Коэффициент стандартного отклонения для адаптивной энергии | Число > 0.0 (рекомендуется: 1.0-2.0) | +| adaptive_energy | Использовать ли адаптивный порог энергии | true/false | +| noise_reduction_enabled | Включить подавление шума | true/false | +| auto_gain_enabled | Включить автоматическую регулировку усиления | true/false | +| record_on_speech | Записывать ли аудио при обнаружении речи | true/false | + +## Секция stt +Настройки системы распознавания речи (Speech-to-Text) + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| model_path | Путь к модели Vosk | Строка, абсолютный путь к директории с моделью | + +## Секция commands +Настройки системы команд + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| commands_file | Путь к файлу с определениями команд | Строка, путь к YAML-файлу | + +## Секция auth +Настройки аутентификации + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| username | Имя пользователя для входа | Строка | +| password | Пароль для входа | Строка | + +## Пример использования + +```yaml +system: + history_maxlen: 50 + polling_interval_ms: 1000 + logging_level: INFO + log_file: logs/app.log + log_save_file: false + +vad: + mode: 3 + sample_rate: 16000 + frame_duration_ms: 30 + speech_threshold: 0.7 + energy_threshold: 400 + +stt: + model_path: /path/to/vosk/model + +auth: + username: admin + password: your_secure_password +``` diff --git a/docs/project_structure.md b/docs/project_structure.md new file mode 100644 index 0000000..215e66d --- /dev/null +++ b/docs/project_structure.md @@ -0,0 +1,64 @@ +# Файловая структура проекта My Speaker + +Ниже представлена скорректированная файловая структура проекта веб-приложения My Speaker. Корректировки включают: +- Добавление `main-params.html` в папку `components/` для постоянной левой части (Main Parameters) на главном экране. +- Добавление `footer.html` в папку `components/` для футера с логами. +- Добавление `graphics.html` в папку `pages/` для правой части с графиками (как указано в макетах). +- Обновление модулей JavaScript для соответствия новым компонентам (например, добавление `graphics.js` для логики графиков). +- Уточнение папок для лучшей организации: `pages/` для динамически подгружаемых правых частей, `components/` для общих элементов (шапка, навигация, Main Parameters, футер). +- Обеспечение модульности в соответствии с `FRONTEND_REQUIREMENTS.md`: использование чистого JavaScript (ES6+), без фреймворков. +- Учёт ретро-стиля и адаптивности в `style.css`. + +Структура проекта оптимизирована для SPA (Single Page Application), где `index.html` служит точкой входа, а контент подгружается динамически через `router.js`. + +## Полная структура + +``` +webapp/ +├── index.html # Основная страница приложения (SPA): содержит контейнеры для шапки, основного экрана и футера +├── style.css # Основные стили в ретро-стилистике (пиксельные рамки, шрифт "Press Start 2P", адаптивность) +├── api.js # Модуль для работы с API (авторизация, получение данных, обновление настроек) +├── app.js # Точка входа: инициализация приложения, подключение модулей и запуск маршрутизации +├── modules/ # Папка для JavaScript-модулей (логика страниц и компонентов) +│ ├── router.js # Маршрутизация: динамическая загрузка HTML в правую часть основного экрана +│ ├── login.js # Логика страницы авторизации (обработка формы, настройки интерфейса) +│ ├── dashboard.js # Логика главного экрана: инициализация Main Parameters, футера и общей структуры +│ ├── graphics.js # Логика для `graphics.html` (инициализация и обновление графиков с использованием Chart.js или Canvas) +│ ├── settings.js # Логика для `settings.html` (формы настроек, валидация, отправка на API) +│ ├── commands.js # Логика для `commands.html` (список команд, редактирование файла команд) +│ ├── header.js # Логика для `header.html` (имя пользователя, кнопка выхода, запрос на /api/auth/me) +│ ├── nav.js # Логика для `nav.html` (переключение разделов, подсветка активного) +│ └── utils.js # Общие утилиты (обработка ошибок, валидация форм, форматирование данных, DOM-манипуляции) +├── pages/ # Папка для динамически подгружаемых HTML-страниц (правая часть основного экрана) +│ ├── login.html # Разметка страницы авторизации (форма входа, настройки интерфейса) +│ ├── graphics.html # Разметка для графиков (canvas для speech_prob и energy) +│ ├── settings.html # Разметка для настроек (раскрывающиеся формы с
, поля ввода, кнопки сохранения) +│ └── commands.html # Разметка для команд (поле для commands_file, список команд, кнопки редактирования) +└── components/ # Папка для общих HTML-компонентов (шапка, навигация, левая часть, футер) + ├── header.html # Разметка верхней панели (имя пользователя, кнопка выхода) + ├── nav.html # Разметка навигационной панели (кнопки Dashboard, Settings, Commands) + ├── main-params.html# Разметка левой части (Main Parameters: статус, индикаторы, статистика CPU/MEM/TEMP) + └── footer.html # Разметка футера (список последних фраз из recent-phrases) + +``` + +## Объяснение корректировок +- **Добавления**: + - `graphics.html` и `graphics.js` — для отдельного раздела с графиками, как в макетах PAGE_LAYOUTS.md. + - `main-params.html` — для постоянной левой части (40%), чтобы избежать дублирования разметки в каждом разделе. + - `footer.html` — для футера с логами, чтобы сделать его переиспользуемым и легко обновляемым. +- **Изменения**: + - `dashboard.js` теперь отвечает за инициализацию всего главного экрана (загрузка `main-params.html`, футера и динамической правой части). + - `pages/login.html` — остается отдельной, но теперь явно отделена от динамических страниц главного экрана. + - Убраны ненужные файлы (если были ранее); структура оптимизирована для модульности (каждый модуль экспортирует функции для ES6 import). +- **Соответствие требованиям**: + - Структура соответствует `FRONTEND_REQUIREMENTS.md`: модульный JS-код, отдельные файлы для логики (app.js, api.js), стили в style.css. + - Поддержка адаптивности: в style.css используются медиа-запросы для мобильных устройств (вертикальное расположение карточек). + - Масштабируемость: Легко добавить новые страницы (например, `analytics.html` и `analytics.js`) в `pages/` и зарегистрировать в `router.js`. +- **Рекомендации по реализации**: + - В `index.html` добавить контейнеры: ``, ``, `
`, `
`. + - В `router.js` загружать контент в `#content` на основе маршрута (например, `#/graphics` → `graphics.html`). + - В `utils.js` добавить функции для показа ошибок в ретро-стиле (красный текст на темном фоне). + - Для графиков в `graphics.js` использовать Chart.js (подключить через CDN в `index.html`) или Canvas API. + +Эта структура готова к реализации. Если нужны дополнительные корректировки (например, добавление папок для ассетов или тестов), уточните. \ No newline at end of file diff --git a/piper_TTS.py b/piper_TTS.py new file mode 100644 index 0000000..313c037 --- /dev/null +++ b/piper_TTS.py @@ -0,0 +1,362 @@ +import socket +import json +import time +import wave +import sys +import select +import os +import logging +import pyaudio +import io +from app.core.config import get_settings + +# Получаем настройки из конфигурации +config = get_settings() +tts_config = config.tts +audio_settings = config.audio_settings + +# Настройки из конфигурации +HOST = tts_config.host +PORT = tts_config.port +MAX_WAIT = tts_config.timeout +PAUSE_DURATION = tts_config.pause_duration +POST_STOP_WAIT = tts_config.post_stop_wait +SAVE_TTS_FILES = tts_config.save_TTS_files +PLAYBACK_DEVICE = audio_settings.playback_device + +# Директория для сохранения TTS файлов +TTS_SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings", "TTS") +if not os.path.exists(TTS_SAVE_DIR): + os.makedirs(TTS_SAVE_DIR, exist_ok=True) + +# Список доступных голосов +VOICES = [ + "ru_RU-denis-medium", + "ru_RU-irina-medium", + "ru_RU-dmitri-medium", + "ru_RU-ruslan-medium" +] +DEFAULT_VOICE = tts_config.voice + +# Глобальные переменные для аудиопотока +PAUDIO = None +AUDIO_STREAM = None +DEFAULT_AUDIO_PARAMS = {"rate": 22050, "width": 2, "channels": 1} + +# Используем логгер из основного приложения +logger = logging.getLogger('VADApp') + +def initialize_audio(device_index=PLAYBACK_DEVICE): + """Инициализирует аудиоустройство и воспроизводит Hello.wav.""" + global PAUDIO, AUDIO_STREAM + try: + PAUDIO = pyaudio.PyAudio() + logger.info("Доступные аудиоустройства:") + for i in range(PAUDIO.get_device_count()): + device_info = PAUDIO.get_device_info_by_index(i) + logger.info("Устройство %d: %s (выходные каналы: %d)", i, device_info['name'], device_info['maxOutputChannels']) + + AUDIO_STREAM = PAUDIO.open( + format=PAUDIO.get_format_from_width(DEFAULT_AUDIO_PARAMS["width"]), + channels=DEFAULT_AUDIO_PARAMS["channels"], + rate=DEFAULT_AUDIO_PARAMS["rate"], + output=True, + output_device_index=device_index, + frames_per_buffer=4096 + ) + logger.info("Открыт аудиопоток: частота=%d, ширина=%d, каналы=%d, устройство=%d", + DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], + DEFAULT_AUDIO_PARAMS["channels"], device_index) + + # Воспроизведение Hello.wav + try: + with wave.open("Hello.wav", 'rb') as wf: + if (wf.getframerate() != DEFAULT_AUDIO_PARAMS["rate"] or + wf.getsampwidth() != DEFAULT_AUDIO_PARAMS["width"] or + wf.getnchannels() != DEFAULT_AUDIO_PARAMS["channels"]): + logger.warning("Параметры Hello.wav не соответствуют: частота=%d, ширина=%d, каналы=%d", + wf.getframerate(), wf.getsampwidth(), wf.getnchannels()) + else: + logger.info("Воспроизведение Hello.wav") + data = wf.readframes(1024) + while data: + AUDIO_STREAM.write(data) + data = wf.readframes(1024) + # Ожидание завершения воспроизведения + while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: + time.sleep(0.01) + logger.info("Завершено воспроизведение Hello.wav") + except FileNotFoundError: + logger.error("Файл Hello.wav не найден") + except Exception as e: + logger.error("Ошибка воспроизведения Hello.wav: %s", e) + + except Exception as e: + logger.error("Не удалось инициализировать аудио: %s", e) + if AUDIO_STREAM: + AUDIO_STREAM.stop_stream() + AUDIO_STREAM.close() + AUDIO_STREAM = None + if PAUDIO: + PAUDIO.terminate() + PAUDIO = None + +def close_audio(): + """Закрывает аудиопоток и PyAudio.""" + global PAUDIO, AUDIO_STREAM + if AUDIO_STREAM: + try: + AUDIO_STREAM.stop_stream() + AUDIO_STREAM.close() + logger.debug("Аудиопоток закрыт") + except Exception as e: + logger.error("Ошибка закрытия аудиопотока: %s", e) + AUDIO_STREAM = None + if PAUDIO: + try: + PAUDIO.terminate() + logger.debug("PyAudio завершен") + except Exception as e: + logger.error("Ошибка завершения PyAudio: %s", e) + PAUDIO = None + +def play_audio_stream(stream, audio_data): + """Воспроизводит аудиоданные через существующий аудиопоток. + + Args: + stream: Открытый аудиопоток PyAudio. + audio_data: Бинарные аудиоданные для воспроизведения. + """ + if not audio_data: + logger.warning("Пустые аудиоданные, пропуск воспроизведения") + return + try: + stream.write(audio_data) + logger.debug("Воспроизведен аудиофрагмент: %d байт", len(audio_data)) + except Exception as e: + logger.error("Ошибка воспроизведения: %s", e) + +def send_msg(sock, typ, data_obj=None, payload=b""): + """Отправляет сообщение в формате Wyoming Protocol.""" + try: + data_bytes = json.dumps(data_obj, ensure_ascii=False).encode('utf-8') if data_obj else b'' + header = {"type": typ, "data_length": len(data_bytes), "payload_length": len(payload)} + sock.sendall(json.dumps(header, separators=(',', ':')).encode('utf-8') + b'\n') + if data_bytes: + sock.sendall(data_bytes) + if payload: + sock.sendall(payload) + logger.debug("Отправлено: %s (длина_данных=%d, длина_полезной_нагрузки=%d)", typ, len(data_bytes), len(payload)) + except socket.error as e: + logger.error("Ошибка отправки сообщения: %s", e) + raise + +def recv_into_buffer(sock, buf, timeout): + """Читает данные из сокета в буфер.""" + r, _, _ = select.select([sock], [], [], timeout) + if not r: + return 0 # Нет данных, но не ошибка + data = sock.recv(8192) + if not data: + return -1 # EOF + buf.extend(data) + logger.debug("Получено %d байт (размер буфера: %d)", len(data), len(buf)) + return len(data) + +def pop_line_from_buffer(buf): + """Извлекает JSON-линию из буфера.""" + idx = buf.find(b'\n') + if idx == -1: + return None + line = bytes(buf[:idx]) + del buf[:idx+1] + try: + return line.decode('utf-8') + except UnicodeDecodeError as e: + logger.warning("Ошибка декодирования UTF-8: %s", e) + return None + +def read_exact_from_buffer(sock, buf, n, deadline): + """Читает ровно n байт из буфера.""" + while len(buf) < n: + timeout = max(0.0, deadline - time.time()) + if timeout <= 0: + raise TimeoutError(f"Таймаут чтения (требуется %d, получено %d)", n, len(buf)) + recv_len = recv_into_buffer(sock, buf, timeout) + if recv_len == -1: + raise ConnectionError("Соединение закрыто сервером") + data = bytes(buf[:n]) + del buf[:n] + return data + +def add_pause(audio_buf, rate, width=2, duration=PAUSE_DURATION): + """Добавляет паузу (нулевые байты) в аудио буфер.""" + samples = int(rate * duration) + bytes_per_sample = width + pause_bytes = b'\x00' * (samples * bytes_per_sample) + audio_buf.extend(pause_bytes) + logger.debug("Добавлена пауза: %d байт (%s с при %d Гц, ширина=%d)", len(pause_bytes), duration, rate, width) + +def get_next_filename(prefix="tts_"): + """Получает уникальное имя файла для сохранения TTS вывода.""" + os.makedirs(TTS_SAVE_DIR, exist_ok=True) + i = 0 + while True: + filename = os.path.join(TTS_SAVE_DIR, f"{prefix}{i:03d}.wav") + if not os.path.exists(filename): + return filename + i += 1 + +def synthesize_stream(text, voice_name=None, out_filename="out.wav"): + """Отправляет текст на сервер Piper TTS, сохраняет и воспроизводит аудио.""" + sock = socket.create_connection((HOST, PORT), timeout=10) + sock.settimeout(None) + buf = bytearray() + try: + # Отправляем потоковый запрос + send_msg(sock, "synthesize-start", {"voice": {"name": voice_name}} if voice_name else {}) + send_msg(sock, "synthesize-chunk", {"text": text}) + send_msg(sock, "synthesize", {"text": text}) # Для обратной совместимости + send_msg(sock, "synthesize-stop", None) + + audio_buf = bytearray() + meta = {"rate": 22050, "width": 2, "channels": 1} + deadline = time.time() + MAX_WAIT + chunk_count = 0 + last_activity = time.time() + + while time.time() < deadline: + line = pop_line_from_buffer(buf) + if line is None: + recv_len = recv_into_buffer(sock, buf, 0.1) + if recv_len == 0: + if time.time() - last_activity > POST_STOP_WAIT: + logger.debug("Больше нет данных, завершение") + break + continue + elif recv_len == -1: + logger.debug("Соединение закрыто сервером") + break + last_activity = time.time() + continue + + last_activity = time.time() + logger.debug("Заголовок сервера: %s", line) + try: + hdr = json.loads(line) + except json.JSONDecodeError as e: + logger.warning("Ошибка разбора JSON: %s", e) + continue + + typ = hdr.get("type") + dl = int(hdr.get("data_length", 0)) + pl = int(hdr.get("payload_length", 0)) + + data_json = None + if dl: + try: + data_bytes = read_exact_from_buffer(sock, buf, dl, deadline) + data_json = json.loads(data_bytes.decode('utf-8')) + logger.debug("Данные: %s", data_json) + except Exception as e: + logger.warning("Ошибка чтения данных: %s", e) + data_json = None + + if typ == "audio-start" and data_json: + meta["rate"] = int(data_json.get("rate", meta["rate"])) + meta["width"] = int(data_json.get("width", meta["width"])) + meta["channels"] = int(data_json.get("channels", meta["channels"])) + logger.info("Получены аудио метаданные: частота=%d, ширина=%d, каналы=%d", + meta["rate"], meta["width"], meta["channels"]) + # Проверка совместимости параметров с открытым потоком + if AUDIO_STREAM: + if (meta["rate"] != DEFAULT_AUDIO_PARAMS["rate"] or + meta["width"] != DEFAULT_AUDIO_PARAMS["width"] or + meta["channels"] != DEFAULT_AUDIO_PARAMS["channels"]): + logger.warning("Несоответствие аудио параметров: ожидалось частота=%d, ширина=%d, каналы=%d; получено частота=%d, ширина=%d, каналы=%d", + DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], + DEFAULT_AUDIO_PARAMS["channels"], meta["rate"], meta["width"], meta["channels"]) + return None # Прерываем, так как поток несовместим + + if pl: + try: + chunk = read_exact_from_buffer(sock, buf, pl, deadline) + audio_buf.extend(chunk) + chunk_count += 1 + logger.debug("Получен аудио фрагмент %d (%d байт, всего %d)", chunk_count, len(chunk), len(audio_buf)) + if AUDIO_STREAM: + play_audio_stream(AUDIO_STREAM, chunk) + except Exception as e: + logger.warning("Ошибка чтения полезной нагрузки: %s", e) + + if typ == "audio-stop": + logger.debug("Audio-stop: добавление паузы между предложениями") + add_pause(audio_buf, meta["rate"], meta["width"]) + post_deadline = time.time() + POST_STOP_WAIT + while time.time() < post_deadline: + recv_len = recv_into_buffer(sock, buf, 0.1) + if recv_len == 0: + continue + elif recv_len == -1: + break + break + last_activity = time.time() + + elif typ == "error": + logger.error("Ошибка сервера: %s", data_json) + break + + send_msg(sock, "synthesize-stopped", None) + + # Ожидание завершения воспроизведения + if AUDIO_STREAM: + try: + while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: + time.sleep(0.01) + logger.debug("Буфер воспроизведения очищен") + except Exception as e: + logger.error("Ошибка очистки буфера воспроизведения: %s", e) + + if audio_buf and SAVE_TTS_FILES: + try: + with wave.open(out_filename, "wb") as wf: + wf.setnchannels(int(meta["channels"])) + wf.setsampwidth(int(meta["width"])) + wf.setframerate(int(meta["rate"])) + wf.writeframes(bytes(audio_buf)) + logger.info("Сохранено: %s (всего аудио байт: %d, фрагментов: %d)", out_filename, len(audio_buf), chunk_count) + return out_filename + except Exception as e: + logger.error("Не удалось сохранить аудио: %s", e) + return None + else: + logger.debug("Аудио не сохранено (SAVE_TTS_FILES=%s)", SAVE_TTS_FILES) + return None + + except Exception as e: + logger.error("Error: %s", e) + return None + + finally: + sock.close() + +def synthesize_text(text, voice_name=None, out_filename=None): + """ + Синтезирует речь из текста, воспроизводит и возвращает путь к аудиофайлу. + + Args: + text (str): Текст для синтеза + voice_name (str, optional): Имя голоса. По умолчанию используется голос из конфигурации. + out_filename (str, optional): Имя выходного файла. По умолчанию генерируется автоматически. + + Returns: + str: Путь к созданному аудиофайлу или None в случае ошибки + """ + if not voice_name: + voice_name = DEFAULT_VOICE + + if not out_filename: + out_filename = get_next_filename() + + logger.info(f"Синтезирую текст: '{text}' с голосом {voice_name} в файл {out_filename}") + return synthesize_stream(text, voice_name=voice_name, out_filename=out_filename) \ No newline at end of file diff --git a/plugins.py b/plugins.py new file mode 100644 index 0000000..bd7c451 --- /dev/null +++ b/plugins.py @@ -0,0 +1,19 @@ +# plugins.py +import logging + +def speak_plugins(): + """ + Обработчик для команды "в разработке" + + Returns: + dict: Результат выполнения команды + """ + logger = logging.getLogger('VADApp') + logger.info("Вызвана команда 'в разработке'") + + return { + "status": "success", + "message": "в разработке", + "action": "speak", + "text": "Эта функция находится в разработке" + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2dbe9b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +python-multipart==0.0.6 +ruamel.yaml==0.18.5 +webrtcvad==2.0.10 +PyAudio==0.2.14 +numpy==1.26.3 \ No newline at end of file diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..53cd61b --- /dev/null +++ b/run_server.py @@ -0,0 +1,33 @@ +# run_server.py +import uvicorn +import logging +from app import create_app +from app.core.config import get_settings +from volume import set_volume # Импортируем функцию установки громкости + +# Получение настроек +config = get_settings() + +# Настройка логирования +log_handlers = [logging.StreamHandler()] +if config.system.log_save_file: + log_handlers.append(logging.FileHandler(config.system.log_file, encoding='utf-8')) + +logging.basicConfig( + level=getattr(logging, config.system.logging_level.upper(), logging.INFO), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=log_handlers +) + +# Инициализация громкости устройств теперь выполняется в app/core/vad.py + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "run_server:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="warning" + ) diff --git a/session_manager.py b/session_manager.py new file mode 100644 index 0000000..97e8478 --- /dev/null +++ b/session_manager.py @@ -0,0 +1,169 @@ +# session_manager.py +import time +import logging +import threading +from datetime import datetime + +class SessionManager: + """Класс для управления сессией голосового помощника""" + + def __init__(self, timeout_seconds=30): + """ + Инициализация менеджера сессий + + Args: + timeout_seconds (int): Время таймаута сессии в секундах + """ + self.logger = logging.getLogger('VADApp') + self.timeout_seconds = timeout_seconds + self.session_active = False + self.session_paused = False + self.pause_start_time = None + self.session_start_time = None + self.session_end_time = None + self.last_reset_time = None + self.timer = None + self.lock = threading.Lock() + self.logger.info(f"SessionManager инициализирован с таймаутом {timeout_seconds} секунд") + + def start_session(self): + """Начать новую сессию""" + with self.lock: + if self.session_active: + self.logger.info("Сессия уже активна, сбрасываем таймер") + self.last_reset_time = datetime.now() + self._reset_timer() + else: + self.session_active = True + self.session_start_time = datetime.now() + self.last_reset_time = self.session_start_time + self.session_end_time = None + self._start_timer() + self.logger.info(f"Сессия начата в {self.session_start_time}") + + def end_session(self): + """Завершить текущую сессию""" + with self.lock: + if self.session_active: + self.session_active = False + self.session_paused = False + self.pause_start_time = None + self.session_end_time = datetime.now() + if self.timer: + self.timer.cancel() + self.timer = None + self.logger.info(f"Сессия завершена в {self.session_end_time}") + return True + return False + + def pause_session(self): + """Поставить текущую сессию на паузу""" + with self.lock: + if self.session_active and not self.session_paused: + self.session_paused = True + self.pause_start_time = datetime.now() + # Останавливаем таймер на время паузы + if self.timer: + self.timer.cancel() + self.timer = None + self.logger.info(f"Сессия поставлена на паузу в {self.pause_start_time}") + return True + return False + + def resume_session(self): + """Возобновить сессию после паузы""" + with self.lock: + if self.session_active and self.session_paused: + pause_duration = 0 + if self.pause_start_time: + # Вычисляем длительность паузы + pause_duration = (datetime.now() - self.pause_start_time).total_seconds() + + # Обновляем время последнего сброса с учетом паузы + if self.last_reset_time: + self.last_reset_time = datetime.now() + + self.session_paused = False + self.pause_start_time = None + + # Запускаем таймер заново + self._start_timer() + + self.logger.info(f"Сессия возобновлена после паузы длительностью {pause_duration:.1f} секунд") + return True + return False + + def reset_timer(self): + """Сбросить таймер сессии""" + with self.lock: + if self.session_active: + self.last_reset_time = datetime.now() + self._reset_timer() + self.logger.info("Таймер сессии сброшен") + return True + return False + + def _start_timer(self): + """Запустить таймер сессии""" + if self.timer: + self.timer.cancel() + self.timer = None + self.timer = threading.Timer(self.timeout_seconds, self._timeout_callback) + self.timer.daemon = True + self.timer.start() + + def _reset_timer(self): + """Сбросить и перезапустить таймер сессии""" + self._start_timer() + + def _timeout_callback(self): + """Обработчик истечения таймера сессии""" + with self.lock: + if self.session_active: + self.session_active = False + self.session_end_time = datetime.now() + self.logger.info(f"Сессия завершена по таймауту в {self.session_end_time}") + + def is_session_active(self): + """Проверить, активна ли сессия""" + with self.lock: + return self.session_active + + def get_session_status(self): + """Получить статус сессии""" + with self.lock: + if not self.session_active: + return { + "active": False, + "paused": False, + "time_left": 0, + "start_time": self.session_start_time.isoformat() if self.session_start_time else None, + "end_time": self.session_end_time.isoformat() if self.session_end_time else None + } + + # Вычисляем оставшееся время + # Используем время последнего сброса таймера, если оно есть + reference_time = self.last_reset_time if self.last_reset_time else self.session_start_time + + # Если сессия на паузе, не вычисляем оставшееся время + if self.session_paused: + time_left = self.timeout_seconds + if reference_time and self.pause_start_time: + # Вычисляем время, прошедшее до паузы + elapsed_before_pause = (self.pause_start_time - reference_time).total_seconds() + time_left = max(0, self.timeout_seconds - elapsed_before_pause) + else: + if reference_time: + elapsed = (datetime.now() - reference_time).total_seconds() + time_left = max(0, self.timeout_seconds - elapsed) + else: + time_left = 0 + + return { + "active": True, + "paused": self.session_paused, + "time_left": time_left, + "start_time": self.session_start_time.isoformat() if self.session_start_time else None, + "pause_time": self.pause_start_time.isoformat() if self.pause_start_time else None, + "end_time": None + } diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c5ceb91 --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +/home/sirmrak/vad_env/bin/python run_server.py \ No newline at end of file diff --git a/vad_core.py b/vad_core.py new file mode 100644 index 0000000..c37e4c0 --- /dev/null +++ b/vad_core.py @@ -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 завершено") diff --git a/volume.py b/volume.py new file mode 100644 index 0000000..1746721 --- /dev/null +++ b/volume.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +import subprocess +import sys + +CARD_INDEX = 2 # Можно изменить при необходимости + +def _get_mixer_name(card_index, mixer_type=None): + """ + Вспомогательная функция: находит микшер указанного типа или первый из списка. + + Args: + card_index: Индекс звуковой карты + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, вернет первый найденный. + """ + try: + result = subprocess.run( + ['amixer', '-c', str(card_index), 'scontrols'], + capture_output=True, text=True, check=True + ) + + # Если указан конкретный тип микшера, ищем его + if mixer_type in ['Playback', 'Capture']: + for line in result.stdout.splitlines(): + name = line.strip().replace("Simple mixer control '", "").replace("',0", "") + if name == mixer_type: + return name + print(f"Mixer type '{mixer_type}' not found for card {card_index}.", file=sys.stderr) + return None + + # Если тип не указан, возвращаем первый подходящий + for line in result.stdout.splitlines(): + name = line.strip().replace("Simple mixer control '", "").replace("',0", "") + if name in ['Playback', 'Capture']: + return name + + print(f"No valid mixer (Playback/Capture) found for card {card_index}.", file=sys.stderr) + return None + except subprocess.CalledProcessError as e: + print(f"Error listing mixers: {e}", file=sys.stderr) + return None + + +def get_volume(card_index, mixer_type=None): + """ + Получить текущую громкость для устройства (card_index). + + Args: + card_index: Индекс звуковой карты + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, используется первый найденный. + + Возвращает: int (0-100) или None при ошибке. + """ + mixer_name = _get_mixer_name(card_index, mixer_type) + if not mixer_name: + print(f"No valid mixer found for card {card_index} with type {mixer_type}.", file=sys.stderr) + return None + + try: + result = subprocess.run( + ['amixer', '-c', str(card_index), '-M', 'sget', mixer_name], + capture_output=True, text=True, check=True + ) + for line in result.stdout.splitlines(): + if '[' in line and ']' in line and '%' in line: + percentage = line.split('[')[1].split(']')[0].strip('%') + return int(percentage) + return None + except subprocess.CalledProcessError as e: + print(f"Failed to get volume: {e}", file=sys.stderr) + return None + except ValueError: + print("Failed to parse volume percentage.", file=sys.stderr) + return None + + +def set_volume(card_index, volume, mixer_type=None): + """ + Установить громкость для устройства (card_index). + + Args: + card_index: Индекс звуковой карты + volume: Громкость (int от 0 до 100) + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, используется первый найденный. + + Возвращает: True при успехе, False при ошибке. + """ + if not (0 <= volume <= 100): + print("Volume must be between 0 and 100.", file=sys.stderr) + return False + + mixer_name = _get_mixer_name(card_index, mixer_type) + if not mixer_name: + print(f"No valid mixer found for card {card_index} with type {mixer_type}.", file=sys.stderr) + return False + + try: + subprocess.run( + ['amixer', '-c', str(card_index), '-M', 'sset', mixer_name, f'{volume}%'], + check=True + ) + print(f"Volume set to {volume}% for card {card_index}, mixer '{mixer_name}'") + return True + except subprocess.CalledProcessError as e: + print(f"Failed to set volume: {e}", file=sys.stderr) + return False + + +# === Пример использования (раскомментировать для теста) === +# if __name__ == "__main__": +# print("Current volume:", get_volume(CARD_INDEX)) +# set_volume(CARD_INDEX, 75) +# print("New volume:", get_volume(CARD_INDEX)) \ No newline at end of file diff --git a/vosk_local_STT.py b/vosk_local_STT.py new file mode 100644 index 0000000..4e7270b --- /dev/null +++ b/vosk_local_STT.py @@ -0,0 +1,102 @@ +# vosk_stt.py +import json +import yaml +import logging +from datetime import datetime +from vosk import Model, KaldiRecognizer + +class VoskSTT: + def __init__(self, model_path="/home/sirmrak/models/vosk-model-small-ru-0.22", commands_file="/mnt/Project/my_speaker/commands/commands.yaml", sample_rate=16000): + """ + Инициализирует Vosk STT с моделью и словарем команд из YAML файла. + + :param model_path: Путь к модели Vosk (vosk-model-small-ru-0.22) + :param commands_file: Путь к файлу commands.yaml + :param sample_rate: Частота дискретизации аудио (по умолчанию 16000) + """ + self.model_path = model_path + self.commands_file = commands_file + self.sample_rate = sample_rate + self.model = None + self.recognizer = None + self._load_model() + self._load_commands() + + def _load_model(self): + """Загружает модель Vosk.""" + logger = logging.getLogger('VADApp') + try: + self.model = Model(self.model_path) + logger.info(f"Модель Vosk загружена: {self.model_path}") + except Exception as e: + logger.error(f"Ошибка загрузки модели: {e}") + raise + + def _load_commands(self): + """Формирует словарь (список фраз) из commands.yaml и инициализирует recognizer.""" + logger = logging.getLogger('VADApp') + try: + with open(self.commands_file, 'r', encoding='utf-8') as f: + commands = yaml.safe_load(f) + + phrases = [] + + # Собираем все фразы из всех разделов + for section, content in commands.items(): + if section in ['Wakewords', 'Session']: + # Обрабатываем списки с вложенными phrases + for item in content: + if isinstance(item, dict) and 'phrases' in item: + phrases.extend(item['phrases']) + elif section == 'Volume': + # Обрабатываем новую структуру Volume с приоритетом + for item in content: + if isinstance(item, dict) and 'commands' in item: + phrases.extend(list(item['commands'].keys())) + elif section == 'Text': + # Обрабатываем новую структуру Text с приоритетом + for item in content: + if isinstance(item, dict) and 'commands' in item: + phrases.extend(list(item['commands'].keys())) + + # Удаляем дубликаты и сортируем + phrases = sorted(set(phrases)) + + # Инициализируем recognizer с грамматикой (списком фраз) + grammar = json.dumps(phrases, ensure_ascii=False) + self.recognizer = KaldiRecognizer(self.model, self.sample_rate, grammar) + + logger.info(f"Словарь команд загружен: {len(phrases)} фраз") + logger.debug(f"Фразы: {phrases}") + except Exception as e: + logger.error(f"Ошибка загрузки команд: {e}") + raise + + def recognize(self, audio_data): + """ + Распознает речь из аудио данных (bytes). + + :param audio_data: Bytes аудио (16-bit PCM, mono) + :return: Распознанный текст или None если ничего не распознано + """ + logger = logging.getLogger('VADApp') + try: + if not self.recognizer.AcceptWaveform(audio_data): + # Частичный результат (если нужно, но для коротких команд используем Final) + partial = json.loads(self.recognizer.PartialResult()).get("partial", "") + if partial: + logger.debug(f"Частичный результат: {partial}") + result = json.loads(self.recognizer.FinalResult()).get("text", "") + if result: + logger.info(f"Распознанная команда: {result}") + return result + else: + logger.info("Ничего не распознано") + return None + except Exception as e: + logger.error(f"Ошибка распознавания: {e}") + return None + + def reload_commands(self): + """Перезагружает словарь команд из файла.""" + self._load_commands() diff --git a/webapp/api.js b/webapp/api.js new file mode 100644 index 0000000..2b96a07 --- /dev/null +++ b/webapp/api.js @@ -0,0 +1,72 @@ +const DEFAULT_BACKEND_URL = "http://192.268.200.111:8000"; +let backendUrl = localStorage.getItem("backendUrl") || DEFAULT_BACKEND_URL; + +export function setBackendUrl(url) { + backendUrl = url; + localStorage.setItem("backendUrl", url); +} + +export async function apiFetch(endpoint, options = {}) { + const token = localStorage.getItem("token"); + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }; + + const response = await fetch(`${backendUrl}/api${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.reload(); // Редирект на login + } + throw new Error(`API error: ${response.statusText}`); + } + + return response.json(); +} + +export async function login(username, password) { + return apiFetch("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function logout() { + await apiFetch("/auth/logout", { method: "POST" }); + localStorage.removeItem("token"); + window.location.reload(); +} + +// Другие методы по API_DOCUMENTATION.md +export async function getSpeechData() { + return apiFetch("/dashboard/speech-data"); +} + +export async function getSysMonitor() { + return apiFetch("/dashboard/sys-monitor"); +} + +export async function getRecentPhrases(limit = 10) { + return apiFetch(`/dashboard/recent-phrases?limit=${limit}`); +} + +export async function getSettings() { + return apiFetch("/settings"); +} + +export async function updateSettings(section, data) { + return apiFetch(`/settings/${section}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function restart() { + return apiFetch("/settings/restart", { method: "POST" }); +} diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..bf064a5 --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,23 @@ +import { initRouter } from "./modules/router.js"; +import { loadLogin } from "./modules/login.js"; +import { setBgColor, setMainColor, setBorderColor } from "./modules/utils.js"; +import { setBackendUrl } from "./api.js"; + +document.addEventListener("DOMContentLoaded", () => { + const color = localStorage.getItem("mainColor") || "#0f0"; + setMainColor(color); + const borderColor = localStorage.getItem("borderColor") || "#0f0"; + setBorderColor(borderColor); + const bgColor = localStorage.getItem("bgColor") || "#000"; + setBgColor(bgColor); + + const backend = + localStorage.getItem("backendUrl") || "http://192.168.200.111:8000"; + setBackendUrl(backend); + + if (!localStorage.getItem("token")) { + loadLogin(); + } else { + initRouter(); + } +}); diff --git a/webapp/components/header.html b/webapp/components/header.html new file mode 100644 index 0000000..20ba393 --- /dev/null +++ b/webapp/components/header.html @@ -0,0 +1,47 @@ +
+

My Speaker

+ +
+
User: admin
+
Polling Interval: 5000 ms
+
Current Time: 15:35:28
+
+
+ + diff --git a/webapp/components/nav.html b/webapp/components/nav.html new file mode 100644 index 0000000..5636a69 --- /dev/null +++ b/webapp/components/nav.html @@ -0,0 +1,5 @@ + diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..30cbe83 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,20 @@ + + + + + + My Speaker + + + + + +
+ +
+ + + diff --git a/webapp/modules/commands-manager.js b/webapp/modules/commands-manager.js new file mode 100644 index 0000000..e69de29 diff --git a/webapp/modules/commands.js b/webapp/modules/commands.js new file mode 100644 index 0000000..dc666ee --- /dev/null +++ b/webapp/modules/commands.js @@ -0,0 +1,29 @@ +import { getSettings, updateSettings } from "../api.js"; + +export async function loadCommands() { + const content = document.getElementById("content"); + const commandsHtml = await fetch("pages/commands.html").then((res) => + res.text() + ); + content.innerHTML = commandsHtml; + + const settings = await getSettings(); + document.getElementById("commands-file").value = + settings.commands.commands_file; + + // Предполагаем, что API возвращает список команд, но по docs — только файл. Для списка — заглушка. + const commandsList = document.getElementById("commands-list"); + commandsList.innerHTML = '

"Turn on light"

"Play music"

'; // Заглушка, расширить если нужно + + document.getElementById("edit-btn").addEventListener("click", () => { + alert("Edit mode"); // Логика редактирования + }); + + document.getElementById("save-btn").addEventListener("click", async () => { + const data = { + commands_file: document.getElementById("commands-file").value, + }; + await updateSettings("commands", data); + alert("Saved"); + }); +} diff --git a/webapp/modules/dashboard.js b/webapp/modules/dashboard.js new file mode 100644 index 0000000..7cc8083 --- /dev/null +++ b/webapp/modules/dashboard.js @@ -0,0 +1,196 @@ +import { + getSpeechData, + getSysMonitor, + getRecentPhrases, + getSettings, +} from "../api.js"; + +let speechChart; +let energyChart; +let historyMaxLen = 20; // Значение по умолчанию в области видимости модуля + +export async function loadDashboard() { + const content = document.getElementById("content"); + const dashboardHtml = await fetch("pages/dashboard.html").then((res) => + res.text() + ); + content.innerHTML = dashboardHtml; + + // Инициализация графика речи + const speechCtx = document.getElementById("speech-chart").getContext("2d"); + speechChart = new Chart(speechCtx, { + type: "line", + data: { + labels: [], + datasets: [ + { + label: "Speech Prob", + data: [], + borderColor: "#0f0", + backgroundColor: "rgba(0, 255, 0, 0.1)", + borderWidth: 2, + pointRadius: 0, + fill: false, + }, + { + label: "Speech Threshold", + data: [], + borderColor: "#ffcc00", + borderWidth: 1, + pointRadius: 0, + borderDash: [5, 5], + fill: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + max: 1, + title: { + display: true, + text: "Speech Probability", + }, + }, + }, + }, + }); + + // Инициализация графика энергии + const energyCtx = document.getElementById("energy-chart").getContext("2d"); + energyChart = new Chart(energyCtx, { + type: "line", + data: { + labels: [], + datasets: [ + { + label: "Energy", + data: [], + borderColor: "#ff00ff", + backgroundColor: "rgba(255, 0, 255, 0.1)", + borderWidth: 2, + pointRadius: 0, + fill: false, + }, + { + label: "Energy Threshold", + data: [], + borderColor: "#ff6600", + borderWidth: 1, + pointRadius: 0, + borderDash: [5, 5], + fill: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: "Energy Level", + }, + }, + }, + }, + }); + + // Получение и обработка настроек + let settings; + try { + settings = await getSettings(); + } catch (error) { + console.error("Failed to load settings:", error); + settings = { system: { polling_interval_ms: 1000, history_maxlen: 20 } }; // Значения по умолчанию + } + const pollingIntervalMs = settings.system.polling_interval_ms || 1000; + historyMaxLen = settings.system.history_maxlen || 20; // Обновляем глобальную переменную + + // Установка интервала опроса и передача значения в хедер + setInterval(updateDashboard, pollingIntervalMs); + updateDashboard(); + window.dispatchEvent( + new CustomEvent("updateHeader", { detail: pollingIntervalMs }) + ); +} + +async function updateDashboard() { + try { + const [speech, sys, phrases] = await Promise.all([ + getSpeechData(), + getSysMonitor(), + getRecentPhrases(5), + ]); + + // Update main-params + document.getElementById("status").textContent = `Status: ${speech.state}`; + + // Обновление карточки речи + document.getElementById("speech-value").textContent = + speech.speech_prob.toFixed(2); + document.getElementById("speech-max").textContent = + speech.speech_threshold.toFixed(2); + document.getElementById("sp-detect").textContent = speech.speech_detected + ? "True" + : "False"; + + // Обновление карточки энергии + document.getElementById("energy-value").textContent = Math.round( + speech.energy + ); + document.getElementById("energy-max").textContent = Math.round( + speech.energy_threshold + ); + document.getElementById("noise-floor").textContent = Math.round( + speech.noise_floor + ); + // Обновление системного монитора + document.getElementById("cpu").textContent = `${sys.cpu_percent}%`; + document.getElementById("mem").textContent = `${sys.memory_percent}%`; + document.getElementById("temp").textContent = `${sys.temperature}°C`; + + // Update speech chart + const now = new Date(); + const timestamp = + now.toLocaleTimeString() + + "." + + now.getMilliseconds().toString().padStart(3, "0"); + speechChart.data.labels.push(timestamp); + speechChart.data.datasets[0].data.push(speech.speech_prob); + speechChart.data.datasets[1].data.push(speech.speech_threshold); + if (speechChart.data.labels.length > historyMaxLen) { + speechChart.data.labels.shift(); + speechChart.data.datasets.forEach((ds) => ds.data.shift()); + } + speechChart.update("none"); // Используем 'none' для оптимизации при частых обновлениях + + // Update energy chart + energyChart.data.labels.push(timestamp); + energyChart.data.datasets[0].data.push(speech.energy); + energyChart.data.datasets[1].data.push(speech.energy_threshold); + if (energyChart.data.labels.length > historyMaxLen) { + energyChart.data.labels.shift(); + energyChart.data.datasets.forEach((ds) => ds.data.shift()); + } + energyChart.update("none"); // Используем 'none' для оптимизации при частых обновлениях + + // Update footer + const footer = document.getElementById("footer"); + footer.innerHTML = phrases + .map( + (p) => + `

${p.timestamp} - ${ + p.action || "неизвестно" + } - ${p.text}

` + ) + .join(""); + } catch (error) { + console.error("Error updating dashboard:", error); + } +} diff --git a/webapp/modules/header.js b/webapp/modules/header.js new file mode 100644 index 0000000..ec7f17e --- /dev/null +++ b/webapp/modules/header.js @@ -0,0 +1,37 @@ +import { logout as apiLogout } from "../api.js"; + +export function renderHeader(pollingIntervalMs) { + // Установка имени пользователя (заглушка) + const userNameElement = document.getElementById("user-name"); + if (userNameElement) { + userNameElement.textContent = "User: admin"; // Заглушка, заменить на данные из /auth/me + } + + // Отображение частоты опроса + const pollingIntervalElement = document.getElementById("polling-interval"); + if (pollingIntervalElement) { + pollingIntervalElement.textContent = `Polling Interval: ${ + pollingIntervalMs || 1000 + } ms`; // Значение по умолчанию 1000 мс + } + + // Отображение текущего времени + const currentTimeElement = document.getElementById("current-time"); + if (currentTimeElement) { + function updateTime() { + const now = new Date().toLocaleTimeString("ru-RU", { + timeZone: "Europe/Moscow", + hour12: false, + }); + currentTimeElement.textContent = `Current Time: ${now}`; + } + updateTime(); // Инициализация + setInterval(updateTime, 1000); // Обновление каждую секунду + } + + // Обработчик кнопки выхода + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", apiLogout); + } +} diff --git a/webapp/modules/login.js b/webapp/modules/login.js new file mode 100644 index 0000000..b6b3bd8 --- /dev/null +++ b/webapp/modules/login.js @@ -0,0 +1,55 @@ +import { login as apiLogin, setBackendUrl } from "../api.js"; +import { initRouter } from "./router.js"; +import { setBgColor, setMainColor, setBorderColor } from "./utils.js"; + +export async function loadLogin() { + const app = document.getElementById("app"); + app.innerHTML = ""; + const loginHtml = await fetch("pages/login.html").then((res) => res.text()); + app.insertAdjacentHTML("beforeend", loginHtml); + + const form = document.getElementById("login-form"); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = document.getElementById("username").value; + const password = document.getElementById("password").value; + try { + const data = await apiLogin(username, password); + localStorage.setItem("token", data.access_token); + initRouter(); + } catch (err) { + document.getElementById("error").textContent = "ERROR: Invalid login"; + } + }); + + // Settings + const settingsBtn = document.getElementById("settings-btn"); + settingsBtn.addEventListener("click", () => { + const settings = document.getElementById("settings-form"); + settings.style.display = + settings.style.display === "none" ? "block" : "none"; + }); + + const colorInput = document.getElementById("main-color"); + colorInput.value = localStorage.getItem("mainColor") || "#0f0"; + colorInput.addEventListener("change", (e) => { + setMainColor(e.target.value); + }); + const colorBorderInput = document.getElementById("border-color"); + colorBorderInput.value = localStorage.getItem("borderColor") || "#0f0"; + colorBorderInput.addEventListener("change", (e) => { + setBorderColor(e.target.value); + }); + const colorBgInput = document.getElementById("bg-color"); + colorBgInput.value = localStorage.getItem("bgColor") || "#000"; + colorBgInput.addEventListener("change", (e) => { + setBgColor(e.target.value); + }); + + const backendInput = document.getElementById("backend-url"); + backendInput.value = + localStorage.getItem("backendUrl") || "http://localhost:8000"; + backendInput.addEventListener("change", (e) => { + setBackendUrl(e.target.value); + }); +} diff --git a/webapp/modules/nav.js b/webapp/modules/nav.js new file mode 100644 index 0000000..90bf455 --- /dev/null +++ b/webapp/modules/nav.js @@ -0,0 +1,5 @@ +export function renderNav(currentSection) { + document.querySelectorAll("nav button").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.section === currentSection); // Используем переданный currentSection + }); +} diff --git a/webapp/modules/router.js b/webapp/modules/router.js new file mode 100644 index 0000000..a0f8772 --- /dev/null +++ b/webapp/modules/router.js @@ -0,0 +1,78 @@ +import { loadDashboard } from "./dashboard.js"; +import { loadSettings } from "./settings.js"; +import { loadCommands } from "./commands.js"; +import { renderHeader } from "./header.js"; +import { renderNav } from "./nav.js"; +import { apiFetch } from "../api.js"; + +let currentSection = "dashboard"; // Локальная переменная в модуле + +export async function initRouter() { + const app = document.getElementById("app"); + app.innerHTML = ""; + + // Загрузка header и nav + const headerHtml = await fetch("components/header.html").then((res) => + res.text() + ); + app.insertAdjacentHTML("beforeend", headerHtml); + renderHeader(1000); // Начальное значение до получения настроек + + const navHtml = await fetch("components/nav.html").then((res) => res.text()); + app.insertAdjacentHTML("beforeend", navHtml); + renderNav(currentSection); + + // Основной контейнер + app.insertAdjacentHTML( + "beforeend", + '
' + ); + + // Загрузка main-params + const mainParamsHtml = await fetch("pages/main-params.html").then((res) => + res.text() + ); + document.getElementById("main-params").innerHTML = mainParamsHtml; + + // Футер + updateFooter(); + + // Навигация + document.querySelectorAll("nav button").forEach((btn) => { + btn.addEventListener("click", () => { + currentSection = btn.dataset.section; + renderNav(currentSection); // Обновляем навигацию + loadSection(); + }); + }); + + // Обработка события обновления хедера + window.addEventListener("updateHeader", (event) => { + renderHeader(event.detail); // Обновляем хедер с полученным pollingIntervalMs + }); + + loadSection(); +} + +async function loadSection() { + switch (currentSection) { + case "dashboard": + loadDashboard(); + break; + case "settings": + loadSettings(); + break; + case "commands": + loadCommands(); + break; + } +} + +async function updateFooter() { + const phrases = await apiFetch("/dashboard/recent-phrases?limit=5"); + const footer = document.getElementById("footer"); + footer.innerHTML = + '== RECENT PHRASES ==
' + + phrases.map((p) => `

${p.text}

`).join("") + + "
"; +} diff --git a/webapp/modules/settings.js b/webapp/modules/settings.js new file mode 100644 index 0000000..a91978e --- /dev/null +++ b/webapp/modules/settings.js @@ -0,0 +1,197 @@ +import { getSettings, updateSettings, restart } from "../api.js"; + +export async function loadSettings() { + const content = document.getElementById("content"); + if (!content) { + console.error("Content element not found"); + return; + } + + // Показать индикатор загрузки + content.innerHTML = '
Loading settings...
'; + + try { + const settingsHtml = await fetch("pages/settings.html").then((res) => + res.text() + ); + content.innerHTML = settingsHtml; + + const allSettings = await getSettings(); + + // Заполнение форм + fillForm("vad-form", allSettings.vad); + fillForm("system-form", allSettings.system); + fillForm("stt-form", allSettings.stt); + fillForm("commands-form", allSettings.commands); + fillForm("auth-form", allSettings.auth); + fillForm("tts-form", allSettings.tts); + fillForm("audio-settings-form", allSettings.audio_settings); + + // Инициализация аккордеона + initAccordion(); + + // Обработчики кнопок сохранения + document.querySelectorAll(".save-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + const section = btn.dataset.section; + const formId = section.replace('_', '-') + '-form'; + const form = document.getElementById(formId); + if (!form) { + console.error(`Form ${formId} not found`); + alert(`Form for ${section} not found`); + return; + } + + // Собираем данные формы вручную + const formData = {}; + + // Обрабатываем все поля ввода + form.querySelectorAll("input, select").forEach((input) => { + if (input.type === "checkbox") { + formData[input.name] = input.checked; + } else if (input.value !== "") { + formData[input.name] = input.value; + } + }); + + // Выводим данные в консоль для отладки + console.log("Отправляемые данные:", formData); + + try { + await updateSettings(section, formData); + alert( + `${ + section.charAt(0).toUpperCase() + section.slice(1) + } settings saved successfully` + ); + } catch (error) { + console.error(`Failed to save ${section} settings:`, error); + alert(`Failed to save ${section} settings`); + } + }); + }); + + // Обработчик переинициализации VAD + const restartBtn = document.getElementById("restart-btn"); + if (restartBtn) { + restartBtn.addEventListener("click", async () => { + if (confirm("Are you sure you want to restart VAD?")) { + try { + await restart(); + alert("VAD restarted successfully"); + } catch (error) { + console.error("Failed to restart VAD:", error); + alert("Failed to restart VAD"); + } + } + }); + } + } catch (error) { + console.error("Error loading settings:", error); + content.innerHTML = + '
Failed to load settings. Check console for details.
'; + } +} + +// Функция инициализации аккордеона +function initAccordion() { + // Получаем все заголовки секций + const sectionHeaders = document.querySelectorAll(".section-header"); + + // Добавляем обработчик клика для каждого заголовка + sectionHeaders.forEach((header) => { + header.addEventListener("click", function () { + // Получаем содержимое секции + const content = this.nextElementSibling; + + // Если секция уже активна, просто сворачиваем её + if (!this.classList.contains("collapsed")) { + this.classList.add("collapsed"); + content.classList.remove("active"); + return; + } + + // Сворачиваем все секции + document.querySelectorAll(".section-header").forEach((h) => { + h.classList.add("collapsed"); + h.nextElementSibling.classList.remove("active"); + }); + + // Разворачиваем текущую секцию + this.classList.remove("collapsed"); + content.classList.add("active"); + }); + }); +} + +function fillForm(formId, data) { + const form = document.getElementById(formId); + if (!form) { + console.warn(`Form ${formId} not found`); + return; + } + + for (const [key, value] of Object.entries(data)) { + const input = form.querySelector(`[name="${key}"]`); + if (input) { + if (input.type === "checkbox") { + input.checked = !!value; // Приводим к булевому типу + } else { + input.value = value !== undefined ? value : ""; + } + } else { + console.warn(`Input for ${key} not found in ${formId}`); + } + } +} + +function processFormData(section, formData) { + const data = {}; + for (let [key, value] of formData) { + if (value === "") continue; // Пропускаем пустые значения + switch (key) { + case "mode": + case "sample_rate": + case "frame_duration_ms": + case "ring_buffer_size": + case "energy_threshold": + case "min_speech_duration_ms": + case "max_record_duration_sec": + case "target_rms": + case "max_gain": + case "history_maxlen": + case "polling_interval_ms": + case "port": + case "timeout": + case "playback_device": + case "capture_device": + case "playback_volume": + case "capture_volume": + data[key] = parseInt(value); + break; + case "speech_threshold": + case "record_stop_delay_sec": + case "prebuffer_duration_sec": + case "noise_reduction_factor": + case "noise_update_alpha": + case "min_rms_threshold_factor": + case "gain_smoothing_alpha": + case "adaptive_energy_std_factor": + case "pause_duration": + case "post_stop_wait": + data[key] = parseFloat(value); + break; + case "adaptive_energy": + case "noise_reduction_enabled": + case "auto_gain_enabled": + case "record_on_speech": + case "log_save_file": + case "save_TTS_files": + data[key] = value === "on"; // Checkbox возвращает 'on' при checked или 'off' при unchecked + break; + default: + data[key] = value; + } + } + return data; +} diff --git a/webapp/modules/utils.js b/webapp/modules/utils.js new file mode 100644 index 0000000..8aaebde --- /dev/null +++ b/webapp/modules/utils.js @@ -0,0 +1,15 @@ +export function setMainColor(color) { + document.documentElement.style.setProperty("--main-color", color); + localStorage.setItem("mainColor", color); +} +export function setBorderColor(color) { + document.documentElement.style.setProperty("--border-color", color); + localStorage.setItem("borderColor", color); +} +export function setBgColor(color) { + document.documentElement.style.setProperty("--bg-color", color); + localStorage.setItem("bgColor", color); +} +export function showError(msg) { + alert(msg); // Или в UI +} diff --git a/webapp/pages/commands.html b/webapp/pages/commands.html new file mode 100644 index 0000000..9d42c8d --- /dev/null +++ b/webapp/pages/commands.html @@ -0,0 +1,4 @@ + + + +
diff --git a/webapp/pages/dashboard.html b/webapp/pages/dashboard.html new file mode 100644 index 0000000..25fd9c2 --- /dev/null +++ b/webapp/pages/dashboard.html @@ -0,0 +1,27 @@ +
+
+

Speech Probability

+ +
+
+

Energy Level

+ +
+
+ + diff --git a/webapp/pages/login.html b/webapp/pages/login.html new file mode 100644 index 0000000..6e19350 --- /dev/null +++ b/webapp/pages/login.html @@ -0,0 +1,33 @@ +

== ♩ ♪ ♫ [My Speaker] ♫ ♪ ♩ ==

+
+
+ + +
+
+ + +
+ +
+

+

Default: admin / admin

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/webapp/pages/main-params.html b/webapp/pages/main-params.html new file mode 100644 index 0000000..a3ab60d --- /dev/null +++ b/webapp/pages/main-params.html @@ -0,0 +1,29 @@ +

Status: Listening

+ +
+ +
+

Речь:

+

+ 0.00 из 1.00 +

+

False

+
+ + +
+

Энергия:

+

0 из 100

+

Шум: 0

+
+
+ +
+ +
+

Системный монитор:

+

Процессор: 0%

+

Память: 0%

+

Температура: 0°C

+
+
diff --git a/webapp/pages/settings.html b/webapp/pages/settings.html new file mode 100644 index 0000000..537887d --- /dev/null +++ b/webapp/pages/settings.html @@ -0,0 +1,654 @@ + + + + + Settings + + + + +

Settings

+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mode: + +
Sample Rate: + +
Frame Duration (ms): + +
Ring Buffer Size: + +
Speech Threshold: + +
Energy Threshold: + +
Min Speech Duration (ms): + +
Record Stop Delay (s): + +
Prebuffer Duration (s): + +
Max Record Duration (s): + +
Target RMS: + +
Max Gain: + +
Noise Reduction Factor: + +
Noise Update Alpha: + +
Min RMS Threshold Factor: + +
Gain Smoothing Alpha: + +
Adaptive Energy Std Factor: + +
Adaptive Energy: + +
Noise Reduction Enabled: + +
Auto Gain Enabled: + +
Record on Speech: + +
+ +
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
History Max Length: + +
Polling Interval (ms): + +
Logging Level: + +
Log File: + +
Log Save File: + +
+ +
+
+
+ + +
+ +
+
+ + + + + +
Model Path: + +
+ +
+
+
+ + +
+ +
+
+ + + + + +
Commands File: + +
+ +
+
+
+ + +
+ +
+
+ + + + + + + + + +
Username: + +
Password: + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+
+ + + + diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..c4bc370 --- /dev/null +++ b/webapp/style.css @@ -0,0 +1,244 @@ +:root { + --main-color: #0f0; /* Дефолтный зелёный, меняется из localStorage */ + --bg-color: #000; + --error-color: #f00; + --border-color: #0f0; +} + +body { + font-family: "Press Start 2P", cursive; + background: var(--bg-color); + color: var(--main-color); + + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + min-height: 100vh; + font-size: 12px; /* Пиксельный размер */ +} + +/* Стили для формы логина */ +.form-group { + margin-bottom: 15px; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.form-group label { + margin-bottom: 8px; + text-align: center; +} + +.form-group input { + width: 100%; + padding: 8px; + box-sizing: border-box; +} + +#login-form { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +#settings-form { + display: none; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 400px; + margin: 15px auto 0; +} + +h1 { + text-align: center; +} + +button { + margin-top: 15px; + align-self: center; +} + +/* Центрирование текстовых элементов */ +#error, +p { + text-align: center; + width: 100%; + max-width: 400px; + margin: 10px auto; +} + +#settings-btn { + display: block; + margin: 15px auto; +} + +/* Стили для элементов истории в dashboard */ +.history-item { + text-align: left; + width: 100%; + max-width: 100%; + margin: 5px 0; + padding: 5px; + border-bottom: 1px solid var(--border-color); + font-family: monospace; + font-size: 14px; +} + +#app { + width: 100%; + max-width: 1200px; + border: 2px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 0 50px var(--main-color); + padding: 10px; +} + +header, +nav { + border: 1px solid var(--border-color); + box-shadow: 0 0 10px var(--main-color); + border-radius: 10px; + padding: 5px; + margin-bottom: 10px; + justify-content: center; /* ← центрирует по горизонтали */ + gap: 10px; /* отступ между кнопками (опционально) */ +} +footer { + border: 1px solid var(--border-color); + box-shadow: 0 0 10px var(--main-color); + border-radius: 10px; + padding: 5px; + margin-top: 10px; +} +footer p { + font-size: 28px; +} +.main-container { + display: flex; + flex-direction: row; /* Десктоп: горизонтально */ +} + +.main-params { + border-radius: 10px; + width: 40%; + border: 1px solid var(--border-color); + box-shadow: 0 0 10px var(--main-color); + border-radius: 10px; + margin-right: 10px; + padding: 10px; +} + +.content { + width: 60%; + border: 1px solid var(--border-color); + box-shadow: 0 0 10px var(--main-color); + border-radius: 10px; + padding: 10px; +} + +.progress-bar { + background: var(--bg-color); + border: 1px solid var(--border-color); + height: 10px; + margin: 5px 0; +} + +.progress-fill { + background: var(--main-color); + height: 100%; +} + +button { + background: var(--bg-color); + color: var(--main-color); + border: 1px solid var(--border-color); + border-radius: 10px; + cursor: pointer; + font-family: inherit; + padding: 8px 16px; +} + +button:hover { + background: var(--main-color); + color: var(--bg-color); + box-shadow: 0 0 0px var(--main-color); + transition: box-shadow 0.3s ease; +} + +input, +select { + background: var(--bg-color); + color: var(--main-color); + border: 1px solid var(--border-color); + font-family: inherit; +} +input[type="color"] { + width: 250px; /* Увеличьте ширину */ + height: 25px; /* Увеличьте высоту */ + padding: 2px; /* Добавьте внутренние отступы */ +} +.error { + color: var(--error-color); +} + +@media (max-width: 768px) { + .main-container { + flex-direction: column; /* Мобильные: вертикально */ + } + .main-params, + .content { + width: 100%; + } + button, + input, + select { + font-size: 14px; /* Увеличено для мобильных */ + } +} + +/* Пиксельные рамки */ +.pixel-border { + border: 2px solid var(--border-color); + box-shadow: inset 0 0 0 1px var(--bg-color), 0 0 0 1px var(--border-color); +} + +/* Стили для карточек */ +.card-container { + display: flex; + justify-content: space-between; + margin-bottom: 15px; + gap: 10px; +} + +.card, +.system-card { + border: 1px solid var(--border-color); + box-shadow: 0 0 10px var(--main-color); + border-radius: 10px; + padding: 10px; + text-align: center; + width: 100%; +} + +.card h3, +.system-card h3 { + margin-top: 0; + margin-bottom: 10px; + font-size: 14px; +} + +/* Адаптивность для мобильных устройств */ +@media (max-width: 768px) { + .card-container { + flex-direction: column; + } +}