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

This commit is contained in:
2025-12-25 12:16:19 +03:00
commit 8279fbb35f
85 changed files with 6027 additions and 0 deletions
BIN
View File
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.
+28
View File
@@ -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"
+96
View File
@@ -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.
+1
View File
@@ -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.
+92
View File
@@ -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": "Нет активной сессии"}
+110
View File
@@ -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))
+11
View File
@@ -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)
+35
View File
@@ -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()
+204
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
# Core package initialization
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# app/core/auth.py
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing import Dict, Optional, Any
import secrets
import logging
from app.core.config import get_settings
logger = logging.getLogger('VADApp')
# Базовая HTTP аутентификация
security = HTTPBasic()
# Хранилище сессий (в реальном приложении лучше использовать Redis или другое хранилище)
sessions: Dict[str, str] = {}
def verify_credentials(credentials: HTTPBasicCredentials) -> bool:
"""Проверка учетных данных пользователя"""
settings = get_settings()
correct_username = settings.auth.username
correct_password = settings.auth.password
# Безопасное сравнение строк для предотвращения timing-атак
is_username_correct = secrets.compare_digest(credentials.username, correct_username)
is_password_correct = secrets.compare_digest(credentials.password, correct_password)
return is_username_correct and is_password_correct
def create_session_token(username: str) -> str:
"""Создание токена сессии"""
token = secrets.token_hex(16)
sessions[token] = username
logger.info(f"Создана новая сессия для пользователя {username}")
return token
def remove_session(token: str) -> None:
"""Удаление сессии"""
if token in sessions:
username = sessions.pop(token)
logger.info(f"Сессия для пользователя {username} удалена")
def get_current_user(request: Request) -> Optional[str]:
"""Получение текущего пользователя из сессии"""
token = request.cookies.get("session_token")
if token and token in sessions:
return sessions[token]
return None
def require_auth(request: Request) -> str:
"""Проверка аутентификации пользователя"""
username = get_current_user(request)
if not username:
logger.warning("Попытка доступа без аутентификации")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация",
headers={"WWW-Authenticate": "Basic"},
)
return username
+181
View File
@@ -0,0 +1,181 @@
# app/core/config.py
import os
import logging
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
from ruamel.yaml import YAML
from functools import lru_cache
# Инициализация логгера
logger = logging.getLogger('VADApp')
# Путь к файлу конфигурации
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.yaml')
class AuthConfig(BaseModel):
username: str = "admin"
password: str = "admin"
class VADConfig(BaseModel):
mode: int = 1
sample_rate: int = 16000
frame_duration_ms: int = 30
ring_buffer_size: int = 20
speech_threshold: float = 0.5
energy_threshold: int = 1000
min_speech_duration_ms: int = 300
record_stop_delay_sec: float = 1.0
prebuffer_duration_sec: float = 1.0
max_record_duration_sec: float = 5.0
target_rms: float = 2000.0
max_gain: float = 5.0
noise_reduction_factor: float = 1.5
noise_update_alpha: float = 0.05
min_rms_threshold_factor: float = 2.0
gain_smoothing_alpha: float = 0.1
adaptive_energy_std_factor: float = 2.5
adaptive_energy: bool = True
noise_reduction_enabled: bool = True
auto_gain_enabled: bool = True
record_on_speech: bool = True
class SystemConfig(BaseModel):
history_maxlen: int = 100
polling_interval_ms: int = 500
logging_level: str = "INFO"
log_file: str = "logs/app.log"
log_save_file: bool = True
class STTConfig(BaseModel):
model_path: str = ""
class CommandsConfig(BaseModel):
commands_file: str = ""
class SessionConfig(BaseModel):
session_timeout_seconds: int = 30
class TTSConfig(BaseModel):
host: str = "192.168.200.81"
port: int = 10200
timeout: int = 30
pause_duration: float = 0.5
post_stop_wait: float = 1.0
voice: str = "ru_RU-ruslan-medium"
save_TTS_files: bool = False
class AudioSettingsConfig(BaseModel):
playback_device: int = 0
capture_device: int = 0
playback_volume: int = 80
capture_volume: int = 90
class Settings(BaseModel):
auth: AuthConfig = Field(default_factory=AuthConfig)
vad: VADConfig = Field(default_factory=VADConfig)
system: SystemConfig = Field(default_factory=SystemConfig)
stt: STTConfig = Field(default_factory=STTConfig)
commands: CommandsConfig = Field(default_factory=CommandsConfig)
session: SessionConfig = Field(default_factory=SessionConfig)
tts: TTSConfig = Field(default_factory=TTSConfig)
audio_settings: AudioSettingsConfig = Field(default_factory=AudioSettingsConfig)
def setup_logging(logging_level='INFO'):
"""Настройка уровня логирования"""
logger.debug(f"Установка уровня логирования: {logging_level}")
try:
logger.setLevel(getattr(logging, logging_level.upper(), logging.INFO))
logger.info(f"Уровень логирования установлен: {logging_level}")
except Exception as e:
logger.error(f"Ошибка настройки логирования: {e}", exc_info=True)
raise
def load_yaml_config() -> Dict[str, Any]:
"""Загрузка конфигурации из YAML файла"""
logger.debug("Загрузка конфигурации из YAML")
try:
yaml = YAML()
yaml.preserve_quotes = True
if not os.path.exists(CONFIG_PATH):
logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}")
raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}")
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = yaml.load(f) or {}
# Проверяем и адаптируем пути для текущей ОС
if 'commands' in config and 'commands_file' in config['commands']:
commands_file = config['commands']['commands_file']
# Преобразуем путь для текущей ОС, если необходимо
commands_file = os.path.normpath(commands_file)
config['commands']['commands_file'] = commands_file
logger.debug(f"Путь к файлу команд: {commands_file}")
if 'stt' in config and 'model_path' in config['stt']:
model_path = config['stt']['model_path']
# Преобразуем путь для текущей ОС, если необходимо
model_path = os.path.normpath(model_path)
config['stt']['model_path'] = model_path
logger.debug(f"Путь к модели STT: {model_path}")
return config
except Exception as e:
logger.error(f"Ошибка загрузки конфигурации: {e}", exc_info=True)
raise
@lru_cache()
def get_settings() -> Settings:
"""Получение настроек приложения (кэшируется)"""
config = load_yaml_config()
# Создаем объект настроек
settings = Settings(
auth=AuthConfig(**config.get('auth', {})),
vad=VADConfig(**config.get('vad', {})),
system=SystemConfig(**config.get('system', {})),
stt=STTConfig(**config.get('stt', {})),
commands=CommandsConfig(**config.get('commands', {})),
tts=TTSConfig(**config.get('tts', {})),
audio_settings=AudioSettingsConfig(**config.get('audio_settings', {}))
)
# Настраиваем логирование
setup_logging(settings.system.logging_level)
return settings
def save_settings(settings: Settings) -> bool:
"""Сохранение настроек в YAML файл"""
logger.debug("Сохранение конфигурации")
try:
# Загружаем текущую конфигурацию
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.preserve_quotes = True
if not os.path.exists(CONFIG_PATH):
logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}")
raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}")
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = yaml.load(f) or {}
# Обновляем конфигурацию
config['auth'] = settings.auth.dict()
config['vad'] = settings.vad.dict()
config['system'] = settings.system.dict()
config['stt'] = settings.stt.dict()
config['commands'] = settings.commands.dict()
config['tts'] = settings.tts.dict()
config['audio_settings'] = settings.audio_settings.dict()
# Сохраняем обновленную конфигурацию
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
yaml.dump(config, f)
# Сбрасываем кэш настроек
get_settings.cache_clear()
logger.info("Конфигурация сохранена")
return True
except Exception as e:
logger.error(f"Ошибка сохранения конфигурации: {e}", exc_info=True)
return False
+41
View File
@@ -0,0 +1,41 @@
# app/core/history.py
from datetime import datetime
from typing import List, Dict, Any
from pydantic import BaseModel
from app.core.config import get_settings
import collections
class RecognizedPhrase(BaseModel):
text: str
timestamp: str
action: str = None
# Хранилище распознанных фраз (в реальном приложении лучше использовать базу данных)
recognition_history: collections.deque = collections.deque(maxlen=100)
def add_recognized_phrase(text: str, action: str = None) -> None:
"""Добавление распознанной фразы в историю"""
global recognition_history
# Обновляем максимальный размер истории из настроек
settings = get_settings()
recognition_history = collections.deque(list(recognition_history),
maxlen=settings.system.history_maxlen)
# Добавляем только фразы с действиями (команды)
if action is not None:
phrase = RecognizedPhrase(
text=text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
action=action
)
recognition_history.append(phrase)
def get_recognition_history(limit: int = 10) -> List[RecognizedPhrase]:
"""Получение истории распознанных фраз"""
# Возвращаем последние N фраз (самые новые в начале списка)
return list(reversed(list(recognition_history)))[:limit]
def clear_recognition_history() -> None:
"""Очистка истории распознанных фраз"""
recognition_history.clear()
+50
View File
@@ -0,0 +1,50 @@
import uuid
from typing import Dict, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
# Простое хранилище активных сессий (в реальном приложении лучше использовать Redis или другое хранилище)
ACTIVE_SESSIONS: Dict[str, str] = {}
# Схема безопасности для проверки токена
security = HTTPBearer(auto_error=False)
def create_session_token(username: str) -> str:
"""Создание простого сессионного токена"""
token = str(uuid.uuid4())
ACTIVE_SESSIONS[token] = username
return token
def remove_session_token(token: str) -> None:
"""Удаление сессионного токена"""
if token in ACTIVE_SESSIONS:
del ACTIVE_SESSIONS[token]
async def validate_session_token(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> Optional[str]:
"""Проверка сессионного токена"""
token_value = None
# Проверяем заголовок Authorization
if credentials:
token_value = credentials.credentials
# Или query-параметр token (для обратной совместимости)
elif 'token' in request.query_params:
token_value = request.query_params['token']
if not token_value or token_value not in ACTIVE_SESSIONS:
return None
return ACTIVE_SESSIONS[token_value]
async def require_auth(token: Optional[str] = Depends(validate_session_token)) -> str:
"""Требование обязательной аутентификации"""
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не авторизован",
headers={"WWW-Authenticate": "Bearer"},
)
return token
+31
View File
@@ -0,0 +1,31 @@
# app/core/status.py
from enum import Enum
import logging
from threading import Lock
logger = logging.getLogger('VADApp')
class SystemStatus(Enum):
INITIALIZATION = "Инициализация"
LISTENING = "Прослушивание"
RECORDING = "Запись"
RECOGNITION = "Распознавание"
COMMAND_PROCESSING = "Обработка команды"
RESPONSE = "Ответ"
class StatusTracker:
def __init__(self):
self.current_status = SystemStatus.INITIALIZATION
self.lock = Lock()
def set_status(self, status: SystemStatus):
with self.lock:
self.current_status = status
logger.info(f"Статус: {status.value}")
def get_status(self) -> str:
with self.lock:
return self.current_status.value
# Global instance (or can be stored in app.state)
status_tracker = StatusTracker()
+40
View File
@@ -0,0 +1,40 @@
# app/core/system_monitor.py
import logging
import psutil
from typing import Dict
logger = logging.getLogger('VADApp')
def get_system_load() -> Dict[str, float]:
"""
Получение текущей нагрузки системы для Raspberry Pi 3B+.
Возвращает словарь с:
- 'cpu_percent': Процент загрузки CPU
- 'memory_percent': Процент использования памяти
- 'temperature': Температура CPU в °C
"""
try:
# Загрузка CPU (с интервалом для более точного измерения)
cpu_percent = psutil.cpu_percent(interval=0.5)
# Использование памяти
memory = psutil.virtual_memory()
memory_percent = memory.percent
# Температура CPU (специфично для Raspberry Pi)
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as temp_file:
temperature = float(temp_file.read().strip()) / 1000.0
return {
'cpu_percent': cpu_percent,
'memory_percent': memory_percent,
'temperature': temperature
}
except Exception as e:
logger.error(f"Ошибка получения нагрузки системы: {e}")
return {
'cpu_percent': 0.0,
'memory_percent': 0.0,
'temperature': 0.0
}
+135
View File
@@ -0,0 +1,135 @@
# app/core/vad.py
from fastapi import Request
import os
from app.core.config import get_settings
from typing import Tuple, Dict, Any, Optional
import logging
from app.core.status import status_tracker, SystemStatus # Добавлено
from app.core.history import add_recognized_phrase
logger = logging.getLogger('VADApp')
def get_vad_instance(request: Request):
"""Получение экземпляра VAD из состояния приложения"""
return getattr(request.app.state, 'vad_instance', None)
def get_stt_instance(request: Request):
"""Получение экземпляра STT из состояния приложения"""
return getattr(request.app.state, 'stt_instance', None)
def get_commands(request: Request):
"""Получение команд из состояния приложения"""
return getattr(request.app.state, 'commands', None)
def init_vad_and_stt(request: Request = None) -> Tuple[Any, Any, Dict[str, Any], Any]:
"""Инициализация VAD и STT"""
logger.debug("Инициализация VAD и STT")
status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено
try:
# Импортируем здесь, чтобы избежать циклических импортов
from vad_core import VADPolling
from vosk_local_STT import VoskSTT # Исправлено с vosk_stt на vosk_local_STT
from commands import handle_command
from session_manager import SessionManager
from volume import set_volume
config = get_settings()
commands = {}
# Инициализация громкости аудиоустройств
try:
# Устанавливаем громкость для устройства воспроизведения
set_volume(config.audio_settings.playback_device, config.audio_settings.playback_volume, mixer_type='Playback')
# Устанавливаем громкость для устройства записи
set_volume(config.audio_settings.capture_device, config.audio_settings.capture_volume, mixer_type='Capture')
logger.info(f"Громкость устройств инициализирована: воспроизведение={config.audio_settings.playback_volume}%, запись={config.audio_settings.capture_volume}%")
except Exception as e:
logger.error(f"Ошибка при инициализации громкости: {e}")
# Загружаем команды из файла
if config.commands.commands_file and os.path.exists(config.commands.commands_file):
from ruamel.yaml import YAML
yaml = YAML()
with open(config.commands.commands_file, 'r', encoding='utf-8') as f:
commands = yaml.load(f) or {}
# Инициализируем SessionManager
session_manager = SessionManager(config.session.session_timeout_seconds)
logger.info(f"SessionManager инициализирован с таймаутом {config.session.session_timeout_seconds} секунд")
# Инициализируем STT
stt_instance = VoskSTT(
model_path=config.stt.model_path,
commands_file=config.commands.commands_file,
sample_rate=config.vad.sample_rate
)
# Функция обработки распознанной речи
def process_speech(audio_data, sample_rate):
try:
status_tracker.set_status(SystemStatus.RECOGNITION) # Добавлено
recognized = stt_instance.recognize(audio_data)
if recognized:
logger.info(f"Команда распознана: {recognized}")
status_tracker.set_status(SystemStatus.COMMAND_PROCESSING) # Добавлено
action = handle_command(recognized, commands, session_manager)
status_tracker.set_status(SystemStatus.RESPONSE) # Добавлено (предполагая, что здесь происходит ответ; если нет, удалите)
# Добавляем распознанную фразу в историю
if action:
add_recognized_phrase(recognized, action=action)
except Exception as e:
logger.error(f"Ошибка обработки речи: {e}")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено (возврат к прослушиванию)
# Инициализируем VAD
vad_config = config.vad.dict()
vad_config['polling_interval_ms'] = config.system.polling_interval_ms
vad_config['history_maxlen'] = config.system.history_maxlen
vad_instance = VADPolling(vad_config, on_speech_end_callback=process_speech)
logger.info("VAD и STT инициализированы")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено
return vad_instance, stt_instance, commands, session_manager
except Exception as e:
logger.error(f"Ошибка инициализации VAD/STT: {e}", exc_info=True)
raise
def cleanup_vad_and_stt(vad_instance, stt_instance):
"""Завершение работы VAD и STT"""
logger.debug("Завершение работы VAD и STT")
try:
if vad_instance:
logger.info("Завершаем VAD...")
vad_instance.close()
if stt_instance:
logger.info("Завершаем STT...")
stt_instance = None
logger.info("VAD и STT завершены")
except Exception as e:
logger.error(f"Ошибка завершения VAD/STT: {e}")
def restart_vad_and_stt(request: Request):
"""Перезапуск VAD и STT с новыми настройками"""
logger.info("Перезапуск VAD и STT")
status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено
try:
# Завершаем текущие экземпляры
vad_instance = get_vad_instance(request)
stt_instance = get_stt_instance(request)
cleanup_vad_and_stt(vad_instance, stt_instance)
# Инициализируем новые экземпляры
vad_instance, stt_instance, commands = init_vad_and_stt()
# Обновляем состояние приложения
request.app.state.vad_instance = vad_instance
request.app.state.stt_instance = stt_instance
request.app.state.commands = commands
logger.info("VAD и STT перезапущены")
status_tracker.set_status(SystemStatus.LISTENING) # Добавлено
return True
except Exception as e:
logger.error(f"Ошибка перезапуска VAD/STT: {e}", exc_info=True)
raise
+152
View File
@@ -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
View File
@@ -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
+46
View File
@@ -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
View File
@@ -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
+318
View File
@@ -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.
+377
View File
@@ -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
- Для всех запросов, требующих авторизации, необходимо передавать токен доступа
- Параметры в запросах на обновление настроек являются опциональными, можно передавать только те параметры, которые нужно изменить
+343
View File
@@ -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. Соответствие требованиям производительности
+284
View File
@@ -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`.
- **Структура**: Шапка, основной экран (две карточки) и футер соответствуют запросу. Страница авторизации отдельная.
- **Функциональность**: Макеты включают все элементы из требований (индикаторы, графики, формы, логи).
+86
View File
@@ -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
```
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Эта функция находится в разработке"
}
+7
View File
@@ -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
+33
View File
@@ -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"
)
+169
View File
@@ -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
}
Executable
+1
View File
@@ -0,0 +1 @@
/home/sirmrak/vad_env/bin/python run_server.py
+439
View File
@@ -0,0 +1,439 @@
# vad_core.py
import pyaudio
import webrtcvad # type: ignore
import collections
import threading
import wave
import time
import os
import struct
import numpy as np
import logging
import sys
from datetime import datetime
from app.core.status import status_tracker, SystemStatus
class VADPolling:
def __init__(self, config, on_speech_end_callback=None):
self.config = config
self.vad = webrtcvad.Vad()
self.vad.set_mode(config['mode'])
self.ring_buffer = collections.deque(maxlen=config['ring_buffer_size'])
self.history = collections.deque(maxlen=config.get('history_maxlen', 100))
self.lock = threading.Lock()
# Параметры шумоподавления и автоусиления
self.noise_reduction_enabled = config.get('noise_reduction_enabled', True)
self.noise_floor_buffer = collections.deque(maxlen=50) # Буфер для оценки уровня шума
self.noise_floor = 0.0
self.auto_gain_enabled = config.get('auto_gain_enabled', True)
self.target_rms = config.get('target_rms', 2000.0) # Целевой уровень сигнала
self.current_gain = 1.0
self.frame_size = int(config['sample_rate'] * config['frame_duration_ms'] / 1000)
# 🔹 Размер предбуфера (в фреймах)
self.prebuffer_size = int(config.get('prebuffer_duration_sec', 1.0) * config['sample_rate'] / self.frame_size)
self.prebuffer = collections.deque(maxlen=self.prebuffer_size)
# 🔹 Основной буфер записи (используется только во время записи)
self.recording_buffer = []
# 🔹 Максимальная длина записи
self.max_record_duration_sec = config.get('max_record_duration_sec', 5.0)
self.max_record_frames = int(self.max_record_duration_sec * config['sample_rate'] / self.frame_size)
# 🔹 Фильтрация по энергии и длительности
self.energy_threshold = config.get('energy_threshold', 1000)
self.min_speech_duration_ms = config.get('min_speech_duration_ms', 300)
self.adaptive_energy = config.get('adaptive_energy', True)
# 🔹 Адаптивный порог энергии
self.background_energy_buffer = collections.deque(maxlen=100)
self.current_energy_threshold = self.energy_threshold
self.adaptive_energy_std_factor = config.get('adaptive_energy_std_factor', 2.5) # Множитель для стандартного отклонения
# 🔹 Счетчики для фильтрации
self.speech_start_time = None
self.continuous_speech_frames = 0
self.min_speech_frames = int(self.min_speech_duration_ms / config['frame_duration_ms'])
# 🔹 Хранение последнего состояния
self.last_result = {
'speech_ratio': 0.0,
'speech_detected': False,
'energy': 0.0,
'energy_threshold': float(self.current_energy_threshold),
'speech_threshold': float(self.config['speech_threshold']),
'continuous_frames': 0,
}
# 🔹 Состояния записи
self.last_speech_detected = False
self.prev_speech_detected = False
self.is_recording = False
# 🔹 Настройка записи
self.record_enabled = config.get('record_on_speech', False)
self.record_stop_delay_sec = config.get('record_stop_delay_sec', 1.0)
self.output_filename = "recordings/VAD/debug_recording.wav"
# 🔹 Таймер остановки записи
self.stop_timer = None
# 🔹 Callback для обработки речи после завершения записи
self.on_speech_end_callback = on_speech_end_callback
# 🔹 Удаляем старый файл при запуске, если запись включена
if self.record_enabled:
os.makedirs("recordings", exist_ok=True)
if os.path.exists(self.output_filename):
os.remove(self.output_filename)
logging.getLogger('VADApp').info(f"Старый файл записей удалён: {self.output_filename}")
# 🔹 Запуск фонового потока
self.audio = pyaudio.PyAudio()
self.running = True
self.vad_thread = threading.Thread(target=self._vad_loop, daemon=True)
self.vad_thread.start()
def _calculate_energy(self, frame):
"""Вычисляет RMS энергию аудио фрейма с шумоподавлением и автоусилением.
Возвращает кортеж: (обработанный_фрейм, исходная_энергия)
"""
logger = logging.getLogger('VADApp')
try:
# Преобразуем байты в массив int16
audio_data = struct.unpack(f'{len(frame)//2}h', frame)
audio_array = np.array(audio_data, dtype=np.float32)
# Вычисляем энергию ИСХОДНОГО сигнала (для VAD)
base_energy = np.sqrt(np.mean(audio_array**2))
# Обновляем оценку уровня шума
if self.noise_reduction_enabled and base_energy < self.current_energy_threshold * 0.5:
current_noise = np.mean(np.abs(audio_array))
if self.noise_floor == 0.0:
# Первая инициализация
self.noise_floor = current_noise
else:
# Плавное обновление с настраиваемой скоростью
alpha = float(self.config.get('noise_update_alpha', 0.05))
self.noise_floor = (1 - alpha) * self.noise_floor + alpha * current_noise
# ========== ШУМОПОДАВЛЕНИЕ ==========
if self.noise_reduction_enabled and self.noise_floor > 0:
reduction_factor = float(self.config.get('noise_reduction_factor', 1.5))
noise_threshold = self.noise_floor * reduction_factor
audio_array = np.where(
np.abs(audio_array) > noise_threshold,
np.sign(audio_array) * (np.abs(audio_array) - self.noise_floor),
0
)
# ========== АВТОУСИЛЕНИЕ ==========
if self.auto_gain_enabled:
current_rms = np.sqrt(np.mean(audio_array**2))
# Вычисляем минимальный порог RMS относительно уровня шума
min_rms_factor = float(self.config.get('min_rms_threshold_factor', 2.0))
min_rms_threshold = self.noise_floor * min_rms_factor if self.noise_floor > 0 else 100.0
if current_rms > min_rms_threshold:
# Вычисляем целевой коэффициент усиления
target_gain = self.target_rms / current_rms
# Ограничиваем максимальное усиление
max_gain = float(self.config.get('max_gain', 5.0))
target_gain = min(max_gain, max(0.1, target_gain))
# Плавное изменение усиления с настраиваемой скоростью
alpha = float(self.config.get('gain_smoothing_alpha', 0.1))
self.current_gain = (1 - alpha) * self.current_gain + alpha * target_gain
# Применяем усиление
audio_array = audio_array * self.current_gain
# Преобразуем обратно в байты
audio_array = np.clip(audio_array, -32768, 32767).astype(np.int16)
processed_frame = struct.pack(f'{len(audio_array)}h', *audio_array)
# Возвращаем обработанный фрейм И исходную энергию
return processed_frame, base_energy
except Exception as e:
logger.error(f"_calculate_energy: {e}")
return frame, 0.0 # В случае ошибки возвращаем исходный фрейм
def _update_adaptive_threshold(self, energy, is_speech_candidate):
"""Обновляет адаптивный порог энергии на основе фонового шума"""
if not self.adaptive_energy:
return
# Если не похоже на речь, добавляем в буфер фоновой энергии
if not is_speech_candidate:
self.background_energy_buffer.append(energy)
# Обновляем порог как среднее + отклонение фонового шума
if len(self.background_energy_buffer) >= 10:
bg_mean = np.mean(self.background_energy_buffer)
bg_std = np.std(self.background_energy_buffer)
# Порог = среднее + adaptive_energy_std_factor стандартных отклонения
self.current_energy_threshold = max(bg_mean + self.adaptive_energy_std_factor * bg_std, self.energy_threshold)
def _vad_loop(self):
logger = logging.getLogger('VADApp')
stream = None
try:
stream = self.audio.open(
format=pyaudio.paInt16,
channels=1,
rate=self.config['sample_rate'],
input=True,
frames_per_buffer=self.frame_size
)
logger.info("Инициализация VAD завершена. Начинаем обработку...")
while self.running:
try:
frame = stream.read(self.frame_size, exception_on_overflow=False)
# Базовая VAD проверка
is_speech_vad = self.vad.is_speech(frame, self.config['sample_rate'])
# Вычисляем энергию сигнала
processed_frame, energy = self._calculate_energy(frame)
# Обновляем адаптивный порог
self._update_adaptive_threshold(energy, is_speech_vad)
with self.lock:
# Комбинированная проверка речи (VAD + энергия)
is_speech = is_speech_vad and (energy > self.current_energy_threshold)
# Обновляем кольцевой буфер и историю
self.ring_buffer.append(1 if is_speech else 0)
speech_ratio = sum(self.ring_buffer) / len(self.ring_buffer)
speech_confirmed = speech_ratio > self.config['speech_threshold']
self.history.append(speech_ratio)
# Обновляем счетчик連続ных фреймов речи
if speech_confirmed:
self.continuous_speech_frames += 1
else:
self.continuous_speech_frames = 0
# Финальное подтверждение речи (фильтр по длительности)
speech_detected = self.continuous_speech_frames >= self.min_speech_frames
# Обновляем last_result
self.last_result = {
'speech_ratio': speech_ratio,
'speech_detected': speech_detected,
'energy': energy,
'energy_threshold': self.current_energy_threshold,
'speech_threshold': self.config['speech_threshold'],
'continuous_frames': self.continuous_speech_frames,
}
# Логика начала/окончания записи
if speech_detected:
if not self.is_recording:
self._start_recording()
self._cancel_stop_recording() # Отменяем таймер, если речь продолжается
else:
if self.is_recording and not self.stop_timer:
self._schedule_stop_recording()
# Добавляем фрейм в буфер
if not self.is_recording:
self.prebuffer.append(processed_frame)
else:
self.recording_buffer.append(processed_frame)
# Проверка на максимальную длину записи
if len(self.recording_buffer) >= self.max_record_frames:
logger.info(f"Максимальная длина записи достигнута ({self.max_record_duration_sec} сек)")
self._stop_recording()
# Обновляем состояния
self.prev_speech_detected = speech_confirmed
self.last_speech_detected = speech_confirmed
# Вывод статуса
recording_status = "🔴 ЗАПИСЬ" if self.is_recording else "⚪ ПРЕДБУФЕР"
timer_status = " [⏱️]" if self.stop_timer else ""
prebuf_len = len(self.prebuffer) if not self.is_recording else 0
rec_len = len(self.recording_buffer) if self.is_recording else 0
status_line = ( f"Статус: {status_tracker.get_status()} |"
f"VAD: {recording_status}{timer_status} |"
f"Речь: {speech_ratio:.2f} | Энергия: {energy:.0f}/{self.current_energy_threshold:.0f} | "
f"Gain: {self.current_gain:.2f}x | Шум: {self.noise_floor:.0f} | "
f"Кадры: {self.continuous_speech_frames:>3} | Буферы: П{prebuf_len:>3}/З{rec_len:>4}")
if logger.isEnabledFor(logging.DEBUG):
sys.stdout.write(f"\r{status_line}")
sys.stdout.flush()
else:
logger.debug(status_line.replace('\n', ' '))
except Exception as e:
if self.running:
logger.error(f"VAD Read Error: {e}")
break
except Exception as e:
logger.error(f"VAD Stream Error: {e}")
finally:
if stream:
try:
stream.stop_stream()
stream.close()
except:
pass
def _start_recording(self):
"""Начинаем запись: копируем предбуфер в основной буфер и переключаемся в режим записи"""
logger = logging.getLogger('VADApp')
self.is_recording = True
self.recording_buffer = list(self.prebuffer)
self.prebuffer.clear()
logger.info(f"Запись начата (предбуфер: {len(self.recording_buffer)} фреймов)")
status_tracker.set_status(SystemStatus.RECORDING)
def _schedule_stop_recording(self):
"""Запускает таймер на остановку записи через N секунд (только если таймер еще не запущен)"""
logger = logging.getLogger('VADApp')
if self.stop_timer is not None:
return
def delayed_stop():
with self.lock:
if self.is_recording:
self._stop_recording()
self.stop_timer = threading.Timer(self.record_stop_delay_sec, delayed_stop)
self.stop_timer.start()
logger.info(f"Таймер остановки запущен ({self.record_stop_delay_sec} сек)")
def _cancel_stop_recording(self):
"""Отменяет таймер остановки записи"""
logger = logging.getLogger('VADApp')
if self.stop_timer:
self.stop_timer.cancel()
self.stop_timer = None
logger.info("Таймер остановки отменён (речь возобновилась)")
def _stop_recording(self):
"""Останавливаем запись: обрабатываем буфер для распознавания и/или сохранения в файл"""
logger = logging.getLogger('VADApp')
if not self.is_recording:
return
if self.on_speech_end_callback and self.recording_buffer:
try:
audio_data = b''.join(self.recording_buffer)
self.on_speech_end_callback(audio_data, self.config['sample_rate'])
except Exception as e:
logger.error(f"Ошибка вызова callback: {e}")
if self.recording_buffer and self.record_enabled:
try:
with wave.open(self.output_filename, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(self.config['sample_rate'])
wf.writeframes(b''.join(self.recording_buffer))
duration = len(self.recording_buffer) * self.config['frame_duration_ms'] / 1000
file_size = os.path.getsize(self.output_filename) / 1024
logger.info(f"Запись сохранена: {self.output_filename}")
logger.info(f"Длительность: {duration:.2f} сек, Размер: {file_size:.1f} KB, Фреймов: {len(self.recording_buffer)}")
except Exception as e:
logger.error(f"Ошибка сохранения записи: {e}")
self.recording_buffer.clear()
status_tracker.set_status(SystemStatus.LISTENING)
self.is_recording = False
self.stop_timer = None
logger.info("Возврат к режиму предбуфера")
def get_latest_status(self):
"""Возвращает последнее состояние VAD (для polling)"""
with self.lock:
return self.last_result.copy()
def update_config(self, new_config):
logger = logging.getLogger('VADApp')
with self.lock:
self.config = new_config
self.vad.set_mode(new_config['mode'])
self.ring_buffer = collections.deque(maxlen=new_config['ring_buffer_size'])
self.history = collections.deque(maxlen=new_config.get('history_maxlen', 100))
self.frame_size = int(new_config['sample_rate'] * new_config['frame_duration_ms'] / 1000)
# Обновляем параметры фильтрации
self.energy_threshold = new_config.get('energy_threshold', 1000)
self.min_speech_duration_ms = new_config.get('min_speech_duration_ms', 300)
self.adaptive_energy = new_config.get('adaptive_energy', True)
self.adaptive_energy_std_factor = new_config.get('adaptive_energy_std_factor', 2.5)
self.min_speech_frames = int(self.min_speech_duration_ms / new_config['frame_duration_ms'])
# Обновляем параметры шумоподавления и автоусиления
self.noise_reduction_enabled = new_config.get('noise_reduction_enabled', True)
self.auto_gain_enabled = new_config.get('auto_gain_enabled', True)
self.target_rms = new_config.get('target_rms', 2000.0)
# Обновляем размер предбуфера и пересоздаем его
old_prebuffer_data = list(self.prebuffer) if not self.is_recording else []
self.prebuffer_size = int(new_config.get('prebuffer_duration_sec', 1.0) * new_config['sample_rate'] / self.frame_size)
self.prebuffer = collections.deque(old_prebuffer_data, maxlen=self.prebuffer_size)
# Обновляем максимальную длину записи
self.max_record_duration_sec = new_config.get('max_record_duration_sec', 5.0)
self.max_record_frames = int(self.max_record_duration_sec * new_config['sample_rate'] / self.frame_size)
# Обновляем настройки записи
self.record_enabled = new_config.get('record_on_speech', False)
self.record_stop_delay_sec = new_config.get('record_stop_delay_sec', 1.0)
logger.info("Конфигурация VAD обновлена")
def close(self):
"""Корректное завершение работы с очисткой всех ресурсов"""
logger = logging.getLogger('VADApp')
logger.info("Начинаем корректное завершение VAD...")
self.running = False
# Даем время потоку завершиться
if hasattr(self, 'vad_thread') and self.vad_thread.is_alive():
self.vad_thread.join(timeout=3.0)
with self.lock:
# Отменяем таймер, если активен
if self.stop_timer:
self.stop_timer.cancel()
self.stop_timer = None
# Сохраняем текущую запись, если активна
if self.is_recording:
logger.info("Сохраняем активную запись...")
self._stop_recording()
# Очищаем все буферы для освобождения памяти
self.prebuffer.clear()
self.recording_buffer.clear()
self.ring_buffer.clear()
self.history.clear()
self.background_energy_buffer.clear()
# Завершаем audio
if hasattr(self, 'audio'):
try:
self.audio.terminate()
except:
pass # Игнорируем ошибки при завершении
logger.info("Корректное завершение VAD завершено")
+112
View File
@@ -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))
+102
View File
@@ -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()
+72
View File
@@ -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" });
}
+23
View File
@@ -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();
}
});
+47
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<nav>
<button data-section="dashboard">[ Dashboard ]</button>
<button data-section="settings">[ Settings ]</button>
<button data-section="commands">[ Commands ]</button>
</nav>
+20
View File
@@ -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>
View File
+29
View File
@@ -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");
});
}
+196
View File
@@ -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);
}
}
+37
View File
@@ -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);
}
}
+55
View File
@@ -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);
});
}
+5
View File
@@ -0,0 +1,5 @@
export function renderNav(currentSection) {
document.querySelectorAll("nav button").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.section === currentSection); // Используем переданный currentSection
});
}
+78
View File
@@ -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>";
}
+197
View File
@@ -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;
}
+15
View File
@@ -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
}
+4
View File
@@ -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>
+27
View File
@@ -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>
+33
View File
@@ -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>
+29
View File
@@ -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>
+654
View File
@@ -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>
+244
View File
@@ -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;
}
}