Initial commit: полный проект my_speaker
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
# API package initialization
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": "Нет активной сессии"}
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Core package initialization
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# app/core/system_monitor.py
|
||||||
|
import logging
|
||||||
|
import psutil
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger('VADApp')
|
||||||
|
|
||||||
|
def get_system_load() -> Dict[str, float]:
|
||||||
|
"""
|
||||||
|
Получение текущей нагрузки системы для Raspberry Pi 3B+.
|
||||||
|
|
||||||
|
Возвращает словарь с:
|
||||||
|
- 'cpu_percent': Процент загрузки CPU
|
||||||
|
- 'memory_percent': Процент использования памяти
|
||||||
|
- 'temperature': Температура CPU в °C
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Загрузка CPU (с интервалом для более точного измерения)
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||||
|
|
||||||
|
# Использование памяти
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
memory_percent = memory.percent
|
||||||
|
|
||||||
|
# Температура CPU (специфично для Raspberry Pi)
|
||||||
|
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as temp_file:
|
||||||
|
temperature = float(temp_file.read().strip()) / 1000.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cpu_percent': cpu_percent,
|
||||||
|
'memory_percent': memory_percent,
|
||||||
|
'temperature': temperature
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения нагрузки системы: {e}")
|
||||||
|
return {
|
||||||
|
'cpu_percent': 0.0,
|
||||||
|
'memory_percent': 0.0,
|
||||||
|
'temperature': 0.0
|
||||||
|
}
|
||||||
+135
@@ -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
|
||||||
+152
@@ -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}")
|
||||||
+168
@@ -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
|
||||||
@@ -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
|
||||||
+60
@@ -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
|
||||||
@@ -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 <token>` или query-параметр `token=<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>.
|
||||||
|
- token_param (опционально, строка): Query-параметр token=<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>.
|
||||||
|
- token_param (опционально, строка): Query-параметр token=<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 <token>`
|
||||||
|
- В query-параметре: `?token=<token>`
|
||||||
|
- **Безопасность**: Пароли в настройках хранятся в открытом виде. Рекомендуется использовать хэширование (например, bcrypt) для auth.password.
|
||||||
|
- **Хранение сессий**: Токены хранятся в памяти (ACTIVE_SESSIONS). Для продакшена используйте Redis или другую базу данных.
|
||||||
|
- **Ограничения**: Токены не имеют срока действия. Рекомендуется добавить expiration (например, хранить timestamp в ACTIVE_SESSIONS).
|
||||||
|
- **Обновления**: Документация актуализирована на 13 октября 2025 года. Добавлены новые поля в /speech-data, новый эндпоинт /sys-monitor и расширенная структура для /stats.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# Документация API My Speaker
|
||||||
|
|
||||||
|
## Общая информация
|
||||||
|
|
||||||
|
API My Speaker предоставляет интерфейс для взаимодействия с системой распознавания речи, управления настройками и получения данных о работе системы. API построено на FastAPI и использует REST-подход.
|
||||||
|
|
||||||
|
Базовый URL: `/api`
|
||||||
|
|
||||||
|
## Аутентификация
|
||||||
|
|
||||||
|
Все запросы к API (кроме авторизации) требуют аутентификации. Аутентификация осуществляется с помощью токена доступа, который можно получить через эндпоинт `/api/auth/login`.
|
||||||
|
|
||||||
|
Токен можно передавать одним из следующих способов:
|
||||||
|
|
||||||
|
1. В заголовке `Authorization: Bearer <token>`
|
||||||
|
2. В параметре запроса `?token=<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
|
||||||
|
- Для всех запросов, требующих авторизации, необходимо передавать токен доступа
|
||||||
|
- Параметры в запросах на обновление настроек являются опциональными, можно передавать только те параметры, которые нужно изменить
|
||||||
@@ -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. Соответствие требованиям производительности
|
||||||
@@ -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|
|
||||||
|
+---------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пояснения**:
|
||||||
|
|
||||||
|
- **Десктоп**: Правая часть показывает формы (`<details>`), например, 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`.
|
||||||
|
- **Структура**: Шапка, основной экран (две карточки) и футер соответствуют запросу. Страница авторизации отдельная.
|
||||||
|
- **Функциональность**: Макеты включают все элементы из требований (индикаторы, графики, формы, логи).
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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 # Разметка для настроек (раскрывающиеся формы с <details>, поля ввода, кнопки сохранения)
|
||||||
|
│ └── 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` добавить контейнеры: `<header id="header"></header>`, `<nav id="nav"></nav>`, `<main><div id="main-params"></div><div id="content"></div></main>`, `<footer id="footer"></footer>`.
|
||||||
|
- В `router.js` загружать контент в `#content` на основе маршрута (например, `#/graphics` → `graphics.html`).
|
||||||
|
- В `utils.js` добавить функции для показа ошибок в ретро-стиле (красный текст на темном фоне).
|
||||||
|
- Для графиков в `graphics.js` использовать Chart.js (подключить через CDN в `index.html`) или Canvas API.
|
||||||
|
|
||||||
|
Эта структура готова к реализации. Если нужны дополнительные корректировки (например, добавление папок для ассетов или тестов), уточните.
|
||||||
+362
@@ -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)
|
||||||
+19
@@ -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": "Эта функция находится в разработке"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+439
@@ -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 завершено")
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<header>
|
||||||
|
<h1>My Speaker</h1>
|
||||||
|
<button id="logout-btn">[Logout]</button>
|
||||||
|
<div class="info-row">
|
||||||
|
<div id="user-name">User: admin</div>
|
||||||
|
<div id="polling-interval">Polling Interval: 5000 ms</div>
|
||||||
|
<div id="current-time">Current Time: 15:35:28</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto; /* Не растягивается */
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logout-btn {
|
||||||
|
margin-left: auto; /* Прижимаем вправо */
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-name,
|
||||||
|
#polling-interval,
|
||||||
|
#current-time {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: aliceblue;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<nav>
|
||||||
|
<button data-section="dashboard">[ Dashboard ]</button>
|
||||||
|
<button data-section="settings">[ Settings ]</button>
|
||||||
|
<button data-section="commands">[ Commands ]</button>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Speaker</title>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Контент загружается динамически -->
|
||||||
|
</div>
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = '<p>"Turn on light"</p><p>"Play music"</p>'; // Заглушка, расширить если нужно
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 class="history-item">${p.timestamp} - ${
|
||||||
|
p.action || "неизвестно"
|
||||||
|
} - ${p.text}</p>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating dashboard:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function renderNav(currentSection) {
|
||||||
|
document.querySelectorAll("nav button").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.section === currentSection); // Используем переданный currentSection
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
'<div class="main-container"><div id="main-params" class="main-params"></div><div id="content" class="content"></div></div><footer id="footer"></footer>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Загрузка 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 ==<div class="phrases">' +
|
||||||
|
phrases.map((p) => `<p>${p.text}</p>`).join("") +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
@@ -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 = '<div class="loading">Loading settings...</div>';
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<div class="error">Failed to load settings. Check console for details.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция инициализации аккордеона
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<label>Commands File: <input id="commands-file" type="text" /></label>
|
||||||
|
<button id="edit-btn">[ EDIT ]</button>
|
||||||
|
<button id="save-btn">[ SAVE ]</button>
|
||||||
|
<div id="commands-list"></div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="charts-container">
|
||||||
|
<div class="chart-box">
|
||||||
|
<h3>Speech Probability</h3>
|
||||||
|
<canvas id="speech-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-box">
|
||||||
|
<h3>Energy Level</h3>
|
||||||
|
<canvas id="energy-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.charts-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
overflow: visible; /* Убеждаемся, что содержимое не обрезается */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<h1>== ♩ ♪ ♫ [My Speaker] ♫ ♪ ♩ ==</h1>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Your Name:</label>
|
||||||
|
<input id="username" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Secret Code:</label>
|
||||||
|
<input id="password" type="password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit">[==Let's Go==]</button>
|
||||||
|
</form>
|
||||||
|
<p id="error" class="error"></p>
|
||||||
|
<p>Default: admin / admin</p>
|
||||||
|
<button id="settings-btn">[Settings]</button>
|
||||||
|
<div id="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="main-color">Main Color:</label>
|
||||||
|
<input id="main-color" type="color" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="border-color">Border Color:</label>
|
||||||
|
<input id="border-color" type="color" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bg-color">Background Color:</label>
|
||||||
|
<input id="bg-color" type="color" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="backend-url">Backend URL:</label>
|
||||||
|
<input id="backend-url" type="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<p id="status">Status: Listening</p>
|
||||||
|
|
||||||
|
<div class="card-container">
|
||||||
|
<!-- Карточка для речи -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Речь:</h3>
|
||||||
|
<p>
|
||||||
|
<span id="speech-value">0.00</span> из <span id="speech-max">1.00</span>
|
||||||
|
</p>
|
||||||
|
<p><span id="sp-detect">False</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Карточка для энергии -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Энергия:</h3>
|
||||||
|
<p><span id="energy-value">0</span> из <span id="energy-max">100</span></p>
|
||||||
|
<p>Шум: <span id="noise-floor">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-container">
|
||||||
|
<!-- Карточка для системного монитора -->
|
||||||
|
<div class="system-card">
|
||||||
|
<h3>Системный монитор:</h3>
|
||||||
|
<p>Процессор: <span id="cpu">0%</span></p>
|
||||||
|
<p>Память: <span id="mem">0%</span></p>
|
||||||
|
<p>Температура: <span id="temp">0°C</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Settings</title>
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<style>
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
background-color: var(--header-color);
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover {
|
||||||
|
background-color: var(--header-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
display: none;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon::after {
|
||||||
|
content: "▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header.collapsed .toggle-icon::after {
|
||||||
|
content: "►";
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div class="settings-container">
|
||||||
|
<!-- Вкладка VAD -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>VAD Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="vad-form" class="settings-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Mode:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-mode"
|
||||||
|
name="mode"
|
||||||
|
min="0"
|
||||||
|
max="3"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sample Rate:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-sample-rate"
|
||||||
|
name="sample_rate"
|
||||||
|
min="8000"
|
||||||
|
max="48000"
|
||||||
|
step="100"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Frame Duration (ms):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-frame-duration-ms"
|
||||||
|
name="frame_duration_ms"
|
||||||
|
min="5"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Ring Buffer Size:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-ring-buffer-size"
|
||||||
|
name="ring_buffer_size"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Speech Threshold:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-speech-threshold"
|
||||||
|
name="speech_threshold"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Energy Threshold:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-energy-threshold"
|
||||||
|
name="energy_threshold"
|
||||||
|
min="100"
|
||||||
|
max="1000"
|
||||||
|
step="10"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Min Speech Duration (ms):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-min-speech-duration-ms"
|
||||||
|
name="min_speech_duration_ms"
|
||||||
|
min="50"
|
||||||
|
max="500"
|
||||||
|
step="10"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Record Stop Delay (s):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-record-stop-delay-sec"
|
||||||
|
name="record_stop_delay_sec"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
step="0.5"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Prebuffer Duration (s):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-prebuffer-duration-sec"
|
||||||
|
name="prebuffer_duration_sec"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
step="0.5"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Max Record Duration (s):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-max-record-duration-sec"
|
||||||
|
name="max_record_duration_sec"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Target RMS:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-target-rms"
|
||||||
|
name="target_rms"
|
||||||
|
min="1000"
|
||||||
|
max="5000"
|
||||||
|
step="100"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Max Gain:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-max-gain"
|
||||||
|
name="max_gain"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
step="0.5"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Noise Reduction Factor:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-noise-reduction-factor"
|
||||||
|
name="noise_reduction_factor"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Noise Update Alpha:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-noise-update-alpha"
|
||||||
|
name="noise_update_alpha"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Min RMS Threshold Factor:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-min-rms-threshold-factor"
|
||||||
|
name="min_rms_threshold_factor"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Gain Smoothing Alpha:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-gain-smoothing-alpha"
|
||||||
|
name="gain_smoothing_alpha"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Adaptive Energy Std Factor:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="vad-adaptive-energy-std-factor"
|
||||||
|
name="adaptive_energy_std_factor"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Adaptive Energy:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="vad-adaptive-energy"
|
||||||
|
name="adaptive_energy"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Noise Reduction Enabled:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="vad-noise-reduction-enabled"
|
||||||
|
name="noise_reduction_enabled"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Auto Gain Enabled:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="vad-auto-gain-enabled"
|
||||||
|
name="auto_gain_enabled"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Record on Speech:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="vad-record-on-speech"
|
||||||
|
name="record_on_speech"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="save-vad"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="vad"
|
||||||
|
>
|
||||||
|
Save VAD
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка System -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>System Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="system-form" class="settings-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>History Max Length:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="system-history-maxlen"
|
||||||
|
name="history_maxlen"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Polling Interval (ms):</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="system-polling-interval-ms"
|
||||||
|
name="polling_interval_ms"
|
||||||
|
min="500"
|
||||||
|
max="5000"
|
||||||
|
step="100"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Logging Level:</td>
|
||||||
|
<td>
|
||||||
|
<select id="system-logging-level" name="logging_level">
|
||||||
|
<option>DEBUG</option>
|
||||||
|
<option>INFO</option>
|
||||||
|
<option>WARNING</option>
|
||||||
|
<option>ERROR</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Log File:</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" id="system-log-file" name="log_file" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Log Save File:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="system-log-save-file"
|
||||||
|
name="log_save_file"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="save-system"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="system"
|
||||||
|
>
|
||||||
|
Save System
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка STT -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>STT Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="stt-form" class="settings-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Model Path:</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" id="stt-model-path" name="model_path" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="save-stt"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="stt"
|
||||||
|
>
|
||||||
|
Save STT
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка Commands -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>Commands Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="commands-form" class="settings-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Commands File:</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="commands-commands-file"
|
||||||
|
name="commands_file"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="save-commands"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="commands"
|
||||||
|
>
|
||||||
|
Save Commands
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка Auth -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>Auth Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="auth-form" class="settings-form">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Username:</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" id="auth-username" name="username" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Password:</td>
|
||||||
|
<td>
|
||||||
|
<input type="password" id="auth-password" name="password" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="save-auth"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="auth"
|
||||||
|
>
|
||||||
|
Save Auth
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Настройки TTS -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>TTS Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="tts-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host">Host:</label>
|
||||||
|
<input type="text" id="host" name="host" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port:</label>
|
||||||
|
<input type="number" id="port" name="port" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="timeout">Timeout (seconds):</label>
|
||||||
|
<input type="number" id="timeout" name="timeout" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pause_duration">Pause Duration (seconds):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="pause_duration"
|
||||||
|
name="pause_duration"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="post_stop_wait">Post Stop Wait (seconds):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="post_stop_wait"
|
||||||
|
name="post_stop_wait"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="voice">Voice:</label>
|
||||||
|
<input type="text" id="voice" name="voice" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="save_TTS_files">Save TTS Files:</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="save_TTS_files"
|
||||||
|
name="save_TTS_files"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="save-btn" data-section="tts">
|
||||||
|
Save TTS Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Настройки аудио -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>Audio Settings</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<form id="audio-settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playback_device">Playback Device:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="playback_device"
|
||||||
|
name="playback_device"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="capture_device">Capture Device:</label>
|
||||||
|
<input type="number" id="capture_device" name="capture_device" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playback_volume">Playback Volume:</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="playback_volume"
|
||||||
|
name="playback_volume"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="capture_volume">Capture Volume:</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="capture_volume"
|
||||||
|
name="capture_volume"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="save-btn"
|
||||||
|
data-section="audio_settings"
|
||||||
|
>
|
||||||
|
Save Audio Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка переинициализации -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-header collapsed">
|
||||||
|
<h3>System Control</h3>
|
||||||
|
<span class="toggle-icon"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<button type="button" id="restart-btn">Restart VAD</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Получаем все заголовки секций
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user