From 8279fbb35fa3bdc19dbda06052941b3fef9281f2 Mon Sep 17 00:00:00 2001 From: sirmrak Date: Thu, 25 Dec 2025 12:16:19 +0300 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20my=5Fspea?= =?UTF-8?q?ker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Hello.wav | Bin 0 -> 41550 bytes __pycache__/commands.cpython-311.pyc | Bin 0 -> 8913 bytes __pycache__/piper_TTS.cpython-311.pyc | Bin 0 -> 24401 bytes __pycache__/run_server.cpython-311.pyc | Bin 0 -> 1328 bytes __pycache__/session_manager.cpython-311.pyc | Bin 0 -> 10306 bytes __pycache__/vad_core.cpython-311.pyc | Bin 0 -> 26039 bytes __pycache__/volume.cpython-311.pyc | Bin 0 -> 5813 bytes __pycache__/vosk_local_STT.cpython-311.pyc | Bin 0 -> 6810 bytes __pycache__/vosk_stt.cpython-311.pyc | Bin 0 -> 6804 bytes alsa.sh | 28 + app/__init__.py | 96 +++ app/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 4329 bytes app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 3127 bytes app/__pycache__/routes.cpython-311.pyc | Bin 0 -> 8549 bytes app/__pycache__/routes.cpython-313.pyc | Bin 0 -> 7067 bytes app/__pycache__/utils.cpython-311.pyc | Bin 0 -> 13939 bytes app/__pycache__/utils.cpython-313.pyc | Bin 0 -> 11894 bytes app/api/__init__.py | 1 + app/api/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 152 bytes app/api/__pycache__/auth.cpython-311.pyc | Bin 0 -> 5494 bytes app/api/__pycache__/dashboard.cpython-311.pyc | Bin 0 -> 6578 bytes app/api/__pycache__/router.cpython-311.pyc | Bin 0 -> 698 bytes app/api/__pycache__/session.cpython-311.pyc | Bin 0 -> 2161 bytes app/api/__pycache__/settings.cpython-311.pyc | Bin 0 -> 14469 bytes app/api/auth.py | 92 +++ app/api/dashboard.py | 110 +++ app/api/router.py | 11 + app/api/session.py | 35 + app/api/settings.py | 204 ++++++ app/core/__init__.py | 1 + app/core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 153 bytes app/core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 131 bytes app/core/__pycache__/config.cpython-311.pyc | Bin 0 -> 13152 bytes app/core/__pycache__/config.cpython-313.pyc | Bin 0 -> 9666 bytes app/core/__pycache__/history.cpython-311.pyc | Bin 0 -> 2572 bytes app/core/__pycache__/security.cpython-311.pyc | Bin 0 -> 2473 bytes app/core/__pycache__/status.cpython-311.pyc | Bin 0 -> 2327 bytes .../system_monitor.cpython-311.pyc | Bin 0 -> 1994 bytes app/core/__pycache__/vad.cpython-311.pyc | Bin 0 -> 9043 bytes app/core/auth.py | 59 ++ app/core/config.py | 181 +++++ app/core/history.py | 41 ++ app/core/security.py | 50 ++ app/core/status.py | 31 + app/core/system_monitor.py | 40 ++ app/core/vad.py | 135 ++++ app/utils.py | 152 ++++ commands.py | 168 +++++ commands/commands.yaml | 46 ++ config.yaml | 60 ++ docs/API_DOCS.md | 318 +++++++++ docs/API_DOCUMENTATION.md | 377 ++++++++++ docs/FRONTEND_REQUIREMENTS.md | 343 +++++++++ docs/PAGE_LAYOUTS.md | 284 ++++++++ docs/config.yaml.md | 86 +++ docs/project_structure.md | 64 ++ piper_TTS.py | 362 ++++++++++ plugins.py | 19 + requirements.txt | 7 + run_server.py | 33 + session_manager.py | 169 +++++ start.sh | 1 + vad_core.py | 439 ++++++++++++ volume.py | 112 +++ vosk_local_STT.py | 102 +++ webapp/api.js | 72 ++ webapp/app.js | 23 + webapp/components/header.html | 47 ++ webapp/components/nav.html | 5 + webapp/index.html | 20 + webapp/modules/commands-manager.js | 0 webapp/modules/commands.js | 29 + webapp/modules/dashboard.js | 196 ++++++ webapp/modules/header.js | 37 + webapp/modules/login.js | 55 ++ webapp/modules/nav.js | 5 + webapp/modules/router.js | 78 +++ webapp/modules/settings.js | 197 ++++++ webapp/modules/utils.js | 15 + webapp/pages/commands.html | 4 + webapp/pages/dashboard.html | 27 + webapp/pages/login.html | 33 + webapp/pages/main-params.html | 29 + webapp/pages/settings.html | 654 ++++++++++++++++++ webapp/style.css | 244 +++++++ 85 files changed, 6027 insertions(+) create mode 100644 Hello.wav create mode 100644 __pycache__/commands.cpython-311.pyc create mode 100644 __pycache__/piper_TTS.cpython-311.pyc create mode 100644 __pycache__/run_server.cpython-311.pyc create mode 100644 __pycache__/session_manager.cpython-311.pyc create mode 100644 __pycache__/vad_core.cpython-311.pyc create mode 100644 __pycache__/volume.cpython-311.pyc create mode 100644 __pycache__/vosk_local_STT.cpython-311.pyc create mode 100644 __pycache__/vosk_stt.cpython-311.pyc create mode 100644 alsa.sh create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-311.pyc create mode 100644 app/__pycache__/__init__.cpython-313.pyc create mode 100644 app/__pycache__/routes.cpython-311.pyc create mode 100644 app/__pycache__/routes.cpython-313.pyc create mode 100644 app/__pycache__/utils.cpython-311.pyc create mode 100644 app/__pycache__/utils.cpython-313.pyc create mode 100644 app/api/__init__.py create mode 100644 app/api/__pycache__/__init__.cpython-311.pyc create mode 100644 app/api/__pycache__/auth.cpython-311.pyc create mode 100644 app/api/__pycache__/dashboard.cpython-311.pyc create mode 100644 app/api/__pycache__/router.cpython-311.pyc create mode 100644 app/api/__pycache__/session.cpython-311.pyc create mode 100644 app/api/__pycache__/settings.cpython-311.pyc create mode 100644 app/api/auth.py create mode 100644 app/api/dashboard.py create mode 100644 app/api/router.py create mode 100644 app/api/session.py create mode 100644 app/api/settings.py create mode 100644 app/core/__init__.py create mode 100644 app/core/__pycache__/__init__.cpython-311.pyc create mode 100644 app/core/__pycache__/__init__.cpython-313.pyc create mode 100644 app/core/__pycache__/config.cpython-311.pyc create mode 100644 app/core/__pycache__/config.cpython-313.pyc create mode 100644 app/core/__pycache__/history.cpython-311.pyc create mode 100644 app/core/__pycache__/security.cpython-311.pyc create mode 100644 app/core/__pycache__/status.cpython-311.pyc create mode 100644 app/core/__pycache__/system_monitor.cpython-311.pyc create mode 100644 app/core/__pycache__/vad.cpython-311.pyc create mode 100644 app/core/auth.py create mode 100644 app/core/config.py create mode 100644 app/core/history.py create mode 100644 app/core/security.py create mode 100644 app/core/status.py create mode 100644 app/core/system_monitor.py create mode 100644 app/core/vad.py create mode 100644 app/utils.py create mode 100644 commands.py create mode 100644 commands/commands.yaml create mode 100644 config.yaml create mode 100644 docs/API_DOCS.md create mode 100644 docs/API_DOCUMENTATION.md create mode 100644 docs/FRONTEND_REQUIREMENTS.md create mode 100644 docs/PAGE_LAYOUTS.md create mode 100644 docs/config.yaml.md create mode 100644 docs/project_structure.md create mode 100644 piper_TTS.py create mode 100644 plugins.py create mode 100644 requirements.txt create mode 100644 run_server.py create mode 100644 session_manager.py create mode 100755 start.sh create mode 100644 vad_core.py create mode 100644 volume.py create mode 100644 vosk_local_STT.py create mode 100644 webapp/api.js create mode 100644 webapp/app.js create mode 100644 webapp/components/header.html create mode 100644 webapp/components/nav.html create mode 100644 webapp/index.html create mode 100644 webapp/modules/commands-manager.js create mode 100644 webapp/modules/commands.js create mode 100644 webapp/modules/dashboard.js create mode 100644 webapp/modules/header.js create mode 100644 webapp/modules/login.js create mode 100644 webapp/modules/nav.js create mode 100644 webapp/modules/router.js create mode 100644 webapp/modules/settings.js create mode 100644 webapp/modules/utils.js create mode 100644 webapp/pages/commands.html create mode 100644 webapp/pages/dashboard.html create mode 100644 webapp/pages/login.html create mode 100644 webapp/pages/main-params.html create mode 100644 webapp/pages/settings.html create mode 100644 webapp/style.css diff --git a/Hello.wav b/Hello.wav new file mode 100644 index 0000000000000000000000000000000000000000..60078eb876d025a13b82b26bc8f6e98dd1a44e92 GIT binary patch literal 41550 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 BgO>mR literal 0 HcmV?d00001 diff --git a/__pycache__/run_server.cpython-311.pyc b/__pycache__/run_server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..16641f8040f420cb6c8c49ee2832951c75744c1a GIT binary patch literal 1328 zcmZ8f&uk zCnMz$q#n4HLl2x>szg=)1P(p)=tL{Anp32n;A8|RPJO%E4O!mKym{|?Gv9pkw7;vW zg1}z>iOjbgLjOidYSRC~`@b+kzaj$}*g~yj#VrhZ&dS+bi^CDdS-dT@ggE9c(Uw{g zMma=;G0^O%oTw!OhPyKLoxD3!BCCagW^~Kc9@Al_wi;t8>Kf9bfJ~97cO6@Ej#*2 zXpsT2PUSZ7JiP-O8ypkjA``b=YU@6eAL@=_5$Z8n(`?;zG;KIb`wl3f143_rwxM)o zCH~BBzC!3{COy`v0}>iM;~%=7&-k88eI`<3xw^q-EVrY@moyfb)E}5_mpV)Wv;&72 z2aGi8Ix9l70!0GO*)}_rk5DmrGL2ft#H7O1O&?lif1hY&h3JYC;i9+W`NU>wgnIgb zXxpYmmbvh<`J?TO3z2fAmrZ^bt=33{*lK8-mdnaKl~&<766_#f`9-;Ej+ z^;FXs2MziIy$6NpW36dBe)A{lc1hcB+B=%pBl;sko1dDv(%bncuiCEBx5yf;!>Q>1 z_HKcnav0-dG&_pF@KbPpf-)mS%aiN~(U+6#n2Jc5-}~a}(l1LR5t8!P&!nJKJ1o_M zQhicf9u-1vX7^^mElm-|_i@OVUh@k_{KBMi@wpUKF1}np*a$9t_s{)F<=$cCUI3oo z3iz!Fzjch}MpA(0LZ!S{e^!5f?YEmj_1f#|>QQy|u(}ph*Ir3~7Y>z;K-mcM--hbq zRN`^&9Rl|ua?{wAkW?OZpIE!rh&$%;d&47cal$Qzd~tMp_s3AKgz9{#mc!y)c&0kd Yi-PzLf%_1->1U2Z{CPM_YtYR90Hgg@D*ylh literal 0 HcmV?d00001 diff --git a/__pycache__/session_manager.cpython-311.pyc b/__pycache__/session_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76add6bd91c09365df7f3fac8ea4490750efcc44 GIT binary patch literal 10306 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 literal 0 HcmV?d00001 diff --git a/__pycache__/vad_core.cpython-311.pyc b/__pycache__/vad_core.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..767e0951a25658400da144c7f56c7090bb905d7a GIT binary patch literal 26039 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 literal 0 HcmV?d00001 diff --git a/__pycache__/volume.cpython-311.pyc b/__pycache__/volume.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9d44b3fa0bdfdc34c5534872a20490944e8a943 GIT binary patch literal 5813 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 K-3q-_&VK@tAd3e8 literal 0 HcmV?d00001 diff --git a/__pycache__/vosk_stt.cpython-311.pyc b/__pycache__/vosk_stt.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e1f27ed883fc3b99d38afb35516979bf8e93f77 GIT binary patch literal 6804 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 Goc{zL!-)3) literal 0 HcmV?d00001 diff --git a/alsa.sh b/alsa.sh new file mode 100644 index 0000000..eb092f2 --- /dev/null +++ b/alsa.sh @@ -0,0 +1,28 @@ +#!/bin/bash +CARD="2" # Проверьте: aplay -l + +echo "Увеличение чувствительности ReSpeaker 2-Mics для 2 м..." + +# Усиление микрофонов +amixer -c $CARD cset numid=9 1 # Left Boost +29dB +amixer -c $CARD cset numid=8 1 # Right Boost +29dB +amixer -c $CARD cset numid=1 120,120 # Capture Volume ~0dB +amixer -c $CARD cset numid=36 200,200 # ADC Capture ~+1.5dB + +# Шумоподавление +amixer -c $CARD cset numid=35 1 # Noise Gate on +amixer -c $CARD cset numid=34 10 # Noise Gate Threshold +amixer -c $CARD cset numid=19 1 # HPF on + +# ALC для адаптации к тихой речи +amixer -c $CARD cset numid=26 3 # ALC Stereo +amixer -c $CARD cset numid=27 7 # ALC Max Gain +amixer -c $CARD cset numid=28 10 # ALC Target + +# Стерео +amixer -c $CARD cset numid=41 0 # Left=Left, Right=Right + +# Сохранить +alsactl store + +echo "Настройки применены. Тест: arecord -D plughw:$CARD,0 -d 10 -f S16_LE -r 16000 -c 2 test.wav" \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3c9811d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,96 @@ +# app/__init__.py +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +from fastapi.staticfiles import StaticFiles +import os + +# Настройка логгера +logger = logging.getLogger('VADApp') + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Управление жизненным циклом приложения""" + from app.core.config import get_settings + from app.core.vad import init_vad_and_stt, cleanup_vad_and_stt + from app.api.session import set_session_manager + + logger.info("Инициализация приложения...") + try: + # Загружаем настройки + settings = get_settings() + + # Инициализируем аудиоподсистему для TTS + import sys + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import piper_TTS + logger.info("Инициализация аудиоподсистемы...") + piper_TTS.initialize_audio() + + # Инициализируем VAD и STT + vad_instance, stt_instance, commands, session_manager = init_vad_and_stt() + app.state.vad_instance = vad_instance + app.state.stt_instance = stt_instance + app.state.commands = commands + app.state.session_manager = session_manager + + # Передаем session_manager в API модуль + set_session_manager(session_manager) + + from app.core.status import status_tracker, SystemStatus # Добавлено + app.state.status_tracker = status_tracker # Добавлено + status_tracker.set_status(SystemStatus.LISTENING) # Добавлено + + logger.info("Сервер запущен") + yield + except Exception as e: + logger.error(f"Ошибка в lifespan: {e}", exc_info=True) + raise + finally: + logger.info("Завершение работы сервера...") + cleanup_vad_and_stt(app.state.vad_instance, app.state.stt_instance) + logger.info("Сервер завершил работу") + +def create_app() -> FastAPI: + """Создание и настройка приложения FastAPI""" + app = FastAPI(title="My Speaker API", lifespan=lifespan) + + # Настройка CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # В продакшене указать конкретные домены + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Подключение маршрутов API + from app.api.router import api_router + app.include_router(api_router) + + # Монтирование статических файлов на корень + app.mount("/", StaticFiles(directory="webapp", html=True), name="static") + + # Удаляем лишние маршруты, так как StaticFiles обрабатывает всё + # @app.get("/") + # async def read_root(): + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.get("/index.html") + # async def read_index(): + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.get("/{full_path:path}") + # async def catch_all(full_path: str): + # if full_path.startswith("api/") or full_path.startswith("static/"): + # raise HTTPException(status_code=404, detail="Not Found") + # return FileResponse(os.path.join("webapp", "index.html")) + # + # @app.exception_handler(StarletteHTTPException) + # async def custom_http_exception_handler(request, exc): + # if exc.status_code == 404: + # return FileResponse(os.path.join("webapp", "index.html")) + # raise exc + + return app \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20dd7674d8b9cdec5bb5467fcc85a4e9e4427b3f GIT binary patch literal 4329 zcmd5=<#uh>-P=3u z?$sFGRSrar3sG^UR-iV2995!eaa9LFN)!I%LyG#P(kxnu(@IE46;k`{A{q(tsc&|@ z_s1nFAM&BIv$JpB&b)c=H#6_e{4N#?BS;&Ukn+t0LjRx@KLuBa2R{em9Fma4s3^}& zFbt&wY9Jq+2)cAoW%Jww=hCdoPw?=cQ$u+?fme9Kgb+Y7Kkbf29PBZtyJ#`nB-By&KK zBvqafbs0EqqA{!Gas|zlkDGZ>6Q^YTAn+p@Q#6&_UPYA+hkt$K<&k3135vyHuC5PA z7vK#B5PI-Ai_jQiC7*qmzwnA}WBv$3}e1&C@8K>%bX%YKW^H<_p z?Pp-P04g)BmRC5C%Bw0Qh`<}e! zBFU?3DxoJpG1MxTLFLfm+J+7dsufp;VjXDZ{Saokf-6ELT#1x%8BIUs_s+KYR9R@@ zudDOJE&TO$ex!xp?(<=Ewj5clJIpm!H{?4i(fr0r3~Y(E^sdiuIl8#Xm(=_%$EG*? z_0_VU083$YABq1ydL5mEwXO|pMdqL{8=-EbKUayj$b6>pw?Rqaa{M>w-GCHXie7-N zbP--JG3X@tfJ*!)@fl_Y9S{Bh%`n1~?3>tXEdk45GfvZu*=m}@>H1u))}zIW$M4nn z>Mlm_(4Z^=z-jx3W)}Vf;){)$!6A&O;gdPj8wo`qiBkm{jn%zMNP^Y zrs=fhR9VzY#bze;&(H}v8irEPvMc_%G(c56Pt|f2ScWmFX3Ml@oqen#eSLigOGE#o z-?DXzTn14$$W3@JTW^54H>{Ij%r)x_NC3s-2E>Juku`NO_aj+%qM2F4l=C!Nl#CMH zTjT?B1+cqB%Ouqba5-iDj3QE^dy>3o&BGWU0dC7law(}Qld@41weKXy!CovM&t(;D zvQXMi+n#gh03<;le;(Y4`H0-GPFiP@pksYBvJ5(wdLFBTtByPHr^zM(r&3#-1VpvI zTtSzi&?c2Bokn0MTIa$n=`@_^QOb8)i$$exWm0q+Jse&wOhM#ySgN`cG-eHlEsEwb zhX*mTCOLtE;e;eb*TlT+gv6s@G5p2Z>?B0kY}OHqN>SFceBGbUaGadWOTBp8Sr%p~!(xQ_VU9{Lri^iyo zY|zn@t?AJ>`tzFEe@HJ(%Q>?@Kbtj*vIuLeA0X_{f~l}$vVFx_2m4D#?*%b*RtydH z*+1c#Lp2_8FA%o3Iak@;s4l`uibZBkk#DgAT9D zW=Yp_(5;?AK+!wIgGW%DLuS+2T4K!lNdTdxKx<5{pfXx)`d^obOuj)&!6`U#yCqOd z+yxHK7w~!sPLaG6qW2PdRtI@aJ^eO_p0`1(#x2BcN%j7(Bw@4ZTqy|>yA*EK+CAfd zy%(v?ZlcoQt`aNlfht6f8xSxqgU?@yxL1=vEz=kTKKlmwix)F!XkHa(lPhuT~YEG9WSu?@$8h(qsY#u9s zOI_)3Oco_sH~g3$hA5EL3nf$5A2Iz7KO-LnPX!|i;OD`G74%t$J!a-rho_EL$w7Ey zssB$2It9Q}DK1J2SnGPMiiG8e}~2`b#cT(Q;}@l2^ubxO}0cnL3@dRKWAk zw4FhhMKY<=_34DcDhM4}kf#p=;bEZSlUeQi~vN!xxC>_boCyTxChyPOELF-L~_yeRFAxJz%p32z#K`hoXtO{SQDzl!`l{Nw~WL z5H4WOhLu`h11}o54G*;N$}Lt^j_P!gJ0TjB;lSmz`Te|kpdr_Aanm=_b^;vEUUlQQ zPLIIuytrun9_b^LL7{wvhw=rfq{_Q=dP1Z-)%YBWT99FwduTIh%~do&T5}ckkk(v9 z@j3UYqUT9#t|DR1eE_!b=J^e_u<87uC3M?D_Z)wZSyu~eV|Fv=Lp4Od&E*$C6j*Qj WEo$RTib*WIQiDTi;wuZ+%Krew2z>bf literal 0 HcmV?d00001 diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20d30f865ca7d0dcf79c9b3f90b1906385c6a83b GIT binary patch literal 3127 zcmd5;Uu+b|8K1q~y_?(n1Ga+=#=zPTwMP?Ej47D95ypn(?h+^RauJFxUAK38X4|{H z)9hY?c{&m)G0Fo-6)KggHdP(ljKBP#^NNo4B&G2}-L{RgwDO$RI^ZANqZ} zw|7o;6;hu%4>R+9GvEK2-^@lLArWXFe)Z3@`=W&W4T+%ft;xo#Fu6h$qHtM~;TR|5 zJd_P(c*c8kJ}YEICVF!r8)jix7qgKJWmF~$;kn*GWoH~AO87A6(@a_rmB<1&9#ZHa z`LS@gwT;D;sK=F%%i@bdD)t4gr8qZoN;R#i=h84uPCq-RJfjzjW$h)EX~5}I%_XBy zs2Y}b&Z?+}x~Q>P;Kvk8we-R%y{wsT`1z@ssannD)mp94^29S7hMo@*vM~u6*h^%L zor8EGc7uZ$IfcjAjZj7~ApUtTgD5$d!2X*}5rQaXvYX_%5fVga2)R&)s)McC^G4*% zb92dWYCx(?1Kyb*c3p?s`Rz=t>w4Qq@LtTuGI4`u5=K-eXBu7i#{+#iI=3sBcg9?F zZg+E`*|*1((2_+NH*IhBW@?ARV! z)p*C3#mF$3mpw7)B1DCJ8PcX$_6BR6QK300Ne+dSs3J{-WTHVH3~5R#aV7Eteh(>e zptD`GuIK9iG#i2cNB9z#O4QR3v;7%|Eg+EbuJgM6 zN7;E5Sbu;$w}J3PZh6F;zL0Z?VRhgd#}_p#Z)%pM8;fQt zN!_gPM76)RZ$gOIv6oRNxc`cC5gnhc58{SjI?G_~8pPL>?dx(`FKK2?HIB)rAt0)q zE97;fRIMMvy+8AE1*$+M$d{MEYxZsDW#@I-xzI6ZH^IsJ{;p1V{&D>67iQ6oKAl!hpPWt4KIukJo-1fIORpNPs4-S$sTf22 z^IY8I6b+65+lH(C*bQLUvA}621|fHvyX%Ex3zen3S<_Ug}P@S@juh4ukI&EeA@;IiAm(gFUIYGgU#V)pS|ty`@(YEp(od9;_G!0Hhg`b z{;y{KWgdZqy}a+#5ck^?$(dgA!S|+yfVk5u%}nrjhPKa)^LNHYoKNsGljP3i)UH!I z_&@F7VUrtXnpI~;0aUab)*W6(vT*~tD`dX?PAlAe3#$qIMJ7T9z6WAku05H@*W7Cg ze}Gd2ug94mgxB)l2g@3nXm^GaXaG@Kp_O_1<2$cVUdUEThb3XaZlC1d0R>q%C$Q3Q! zDr??Jb-DegQ-YgN%jN1z`6|;F;byu$K3QN|Q8O%EEt|oPie{ay!u4zkoYmB##>@a= zCAFsKS+#Cy?0n<^UQxZ^(h|cTl~Dx5iWYUI6|5>-a>M>IFa<-cXsIZ}g85D*)nYMU zX@AX_04r`1K?V$r`UY6aW9mksTrX-ZtFBn7)(wkc^->}K`r|$mi!RG-56Vao7XVQn z^f3f?1VQsGbPaM(>an+f`^Hg6nruw3!^NemLcb&Qe@6S)1_m!{YlFM}5sCE}5eL2` zVKMxmmx%kf$#)5dZ)H literal 0 HcmV?d00001 diff --git a/app/__pycache__/routes.cpython-311.pyc b/app/__pycache__/routes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e51b132dfd8f7f6a32b4d8919ce1aee0d00e2d6 GIT binary patch literal 8549 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 Ih}y+}0r*wg%K!iX literal 0 HcmV?d00001 diff --git a/app/__pycache__/utils.cpython-311.pyc b/app/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b851f1334f90a5f171cc333b48eb0a80b2ee8478 GIT binary patch literal 13939 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 AWdHyG literal 0 HcmV?d00001 diff --git a/app/__pycache__/utils.cpython-313.pyc b/app/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5549038325a52005baaf5cab92325d7f30521414 GIT binary patch literal 11894 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 tN!C4j#q{|C{ErHKFl literal 0 HcmV?d00001 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..80fb947 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API package initialization \ No newline at end of file diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a469a7be0f165fed4dacf58b11780910eea3dcf7 GIT binary patch literal 152 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 YzUPYre}g&JxK)hSgEWwI}>}uKFpn2 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 literal 0 HcmV?d00001 diff --git a/app/api/__pycache__/router.cpython-311.pyc b/app/api/__pycache__/router.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62cc93f0da361f44e96f5bba528b12e84d26fc5e GIT binary patch literal 698 zcmbVIF-yZx5Wd%>NgHVq1fhbPRMSGUIf>xtB1I=tu*BwRQ=2Bdmk!+=72I{`;M5}h zGl>K&nF>yBg@QvT-?NR?p$I+hj{Cm5@80qHnT!I=w2jaD1p;{IAU0uMOtP%=2qu`Q z2{zIZ;&q~#umxSPMP0O$deWA32|)rd>M_|M-T}Id<>%B7{8eC~k&a=Hy)a-gGXzUD z;tHDN3L0s-#uUy#6+if?DvHua<>OPQ?O~#dk!ZBNdXzQ|TEBD*Vn!*7J`%ZAq5|&J#o&-u_Yuy3@5 zNbj+i9@6t8EReH;xPtmVn`5pZdmUsQj^q&7A{U+sqMDNhs z^cMY=eh=)AeJ?fDe9lp`Y!)y%c3ofzqxCYwZZbiM_#-Tbzbo}%Bx9G|%n4F?7w6oJ zJ!3dkY@Ws>V^*s$ifv^NR%ckBgWX3@COvRW7Yru>;Gu?sb`4Xxzu7l|hjJX+ZOa;b z`yz7Ne3-!|LhT3;^b=6~HofV4bk)D~zpO!GaNc!9dSN2`O4e8S7bTtm30K6?06g?- zbb!hO!ABjBZ%6Lt+pujBVWbNd(#2EYP+h_@4q4(DNU#Ew#3@VGRjlFgZlIeDB%zih z@|Z+vO27~*i?!T3<(h8Isb%1E`We{eYw$gULkb9Rf2tWh{=9$ApQEd=gwUA-T%&7D zF>`M5JfVZWv~{T!HsNGC05vd zHkJ26_X7l?c_Y8)teOGE!H|#%-2^*TcroUB;u2GNdP50dA-8fGmvdXS48D_LoP)7h z1vmn^3?a)yxYauA%?IMF==8YlD1kkeV2LYIXC0eDv*ObBJKBe#&eI?YF40~ zyWrppbPXJRIR*9N7QF+EOWlQf?7{Agx9nxS$g@KCy!sd*D#P%RREAK_Lf@YjWy zR-LiTvRlj%g)bF%rdli)n$gt*CF)?VLhwNc=W3+r&JdoyOd&yyC*AxMp4HoQmOy8+ zkMjauI>YjTHr9;%7YR={?q??n*pLK4*gzTj-@T6Z(9T{*1GKZ((FpDA8{yuIyRVE~ z8^87f4QKsuc0HW+6eywnyC_ODWJwrqpe=}q!e9e!fff?>HPGX*Fn*tN|9mLwMxu5h zIiI{ZzGQnzUpTZb9IB7Kx_0=734d&IeQdIU6yccwmI}eNntR9!sQVHUpM}pFVtZ%6 H4&3(-^t^9p literal 0 HcmV?d00001 diff --git a/app/api/__pycache__/settings.cpython-311.pyc b/app/api/__pycache__/settings.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8778d70e8685e1720ad8e476b2cbabf111a4211 GIT binary patch literal 14469 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 a3zx}jNz5gY7&2>tVyhN2CrKvvrT!Ojh77j= literal 0 HcmV?d00001 diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..783cdf9 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,92 @@ +# app/api/auth.py +from fastapi import APIRouter, Header, Query, HTTPException, Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from pydantic import BaseModel +from app.core.config import get_settings +from app.core.security import create_session_token, validate_session_token, remove_session_token, ACTIVE_SESSIONS +from typing import Optional + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +security = HTTPBasic() + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + +class UserResponse(BaseModel): + username: str + is_authenticated: bool + +class LoginRequest(BaseModel): + username: str + password: str + +@router.post("/login", response_model=LoginResponse) +async def login(login_data: LoginRequest): + """Базовая авторизация по логину и паролю""" + settings = get_settings() + + if login_data.username != settings.auth.username or login_data.password != settings.auth.password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверные учетные данные", + headers={"WWW-Authenticate": "Basic"}, + ) + + # Создаем сессионный токен (без JWT, просто уникальный идентификатор) + token = create_session_token(login_data.username) + + return LoginResponse(access_token=token) +@router.get("/me", response_model=UserResponse) +async def get_current_user( + authorization: Optional[str] = Header(None), # ← Указываем что это Header + token_param: Optional[str] = Query(None, alias="token") # ← Указываем что это Query +): + """Получение информации о текущем пользователе""" + token_value = None + + # Проверяем заголовок Authorization + if authorization and authorization.startswith("Bearer "): + token_value = authorization.replace("Bearer ", "") + print(f"✓ Токен из заголовка: {token_value}") + # Если токен не найден в заголовке, проверяем параметр запроса + elif token_param: + token_value = token_param + print(f"✓ Токен из query параметра: {token_value}") + # Для обратной совместимости проверяем, если authorization передан как параметр запроса + elif authorization and not authorization.startswith("Bearer "): + token_value = authorization + print(f"✓ Токен из authorization (без Bearer): {token_value}") + + print(f"Проверка токена: {token_value}") + print(f"Активные сессии: {list(ACTIVE_SESSIONS.keys())}") + + # Проверяем токен в активных сессиях + if token_value and token_value in ACTIVE_SESSIONS: + username = ACTIVE_SESSIONS[token_value] + print(f"✓ Токен найден! Пользователь: {username}") + return UserResponse(username=username, is_authenticated=True) + + print(f"✗ Токен не найден в активных сессиях") + return UserResponse(username="", is_authenticated=False) + +@router.post("/logout") +async def logout(authorization: Optional[str] = None, token_param: Optional[str] = None): + """Выход из системы""" + token_value = None + + # Проверяем заголовок Authorization + if authorization and authorization.startswith("Bearer "): + token_value = authorization.replace("Bearer ", "") + # Если токен не найден в заголовке, проверяем параметр запроса + elif token_param: + token_value = token_param + # Для обратной совместимости проверяем, если authorization передан как параметр запроса + elif authorization and not authorization.startswith("Bearer "): + token_value = authorization + + if token_value and token_value in ACTIVE_SESSIONS: + remove_session_token(token_value) + return {"status": "success", "message": "Выход выполнен успешно"} + + return {"status": "success", "message": "Нет активной сессии"} \ No newline at end of file diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..5ee638b --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,110 @@ +# app/api/dashboard.py +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import List, Dict, Any, Optional +from app.core.security import require_auth +from app.core.vad import get_vad_instance, get_stt_instance +from app.core.status import status_tracker +from app.core.system_monitor import get_system_load + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) + +class SpeechData(BaseModel): + state: str + speech_prob: float + speech_threshold: float # Добавлено: порог обнаружения речи + energy: float + energy_threshold: float + speech_detected: bool # Добавлено: признак обнаружения речи + continuous_frames: int # Добавлено: количество непрерывных кадров речи + gain: float # Добавлено: текущий коэффициент усиления + noise_floor: float # Добавлено: уровень шума + +class RecognizedPhrase(BaseModel): + text: str + timestamp: str + action: str = None + +class SystemLoad(BaseModel): # Добавлено: модель для системной нагрузки + cpu_percent: float + memory_percent: float + temperature: float + +@router.get("/speech-data", response_model=SpeechData) +async def get_speech_data(request: Request, token: str = Depends(require_auth)): + """Получение текущих данных о речи и энергии, включая расширенные метрики VAD и STT""" + try: + vad = get_vad_instance(request) + + if not vad: + return SpeechData( + state=status_tracker.get_status(), + speech_prob=0.0, + speech_threshold=0.0, + energy=0, + energy_threshold=0, + speech_detected=False, + continuous_frames=0, + gain=1.0, + noise_floor=0.0, + ) + + # Получаем данные из last_result VAD + last_result = vad.last_result + # Дополнительные атрибуты VAD (на основе класса VADPolling) + gain = vad.current_gain + noise_floor = vad.noise_floor + continuous_frames = last_result.get('continuous_frames', vad.continuous_speech_frames) # Если есть в last_result или атрибуте + + + return SpeechData( + state=status_tracker.get_status(), + speech_prob=float(last_result.get('speech_ratio', 0.0)), + speech_threshold=float(last_result.get('speech_threshold', 0.0)), + energy=float(last_result.get('energy', 0)), + energy_threshold=float(last_result.get('energy_threshold', 0)), + speech_detected=bool(last_result.get('speech_detected', False)), + continuous_frames=int(continuous_frames), + gain=float(gain), + noise_floor=float(noise_floor), + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/sys-monitor", response_model=SystemLoad) # Добавлено: новый эндпоинт +async def get_sys_monitor(token: str = Depends(require_auth)): + """Получение данных о нагрузке системы (CPU, память, температура)""" + try: + load_data = get_system_load() + return SystemLoad(**load_data) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/recent-phrases", response_model=List[RecognizedPhrase]) +async def get_recent_phrases(request: Request, limit: int = 10, token: str = Depends(require_auth)): + """Получение последних распознанных фраз""" + try: + stt = get_stt_instance(request) + if not stt or not hasattr(stt, 'get_history'): + from app.core.history import get_recognition_history + phrases = get_recognition_history(limit) + else: + phrases = stt.get_history(limit) + return phrases + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/stats", response_model=Dict[str, Any]) +async def get_stats(request: Request, token: str = Depends(require_auth)): + """Получение статистики работы системы""" + try: + # Здесь будет логика получения статистики работы системы + # Например, количество распознанных команд, время работы и т.д. + return { + "uptime": "0:00:00", # Будет заменено на реальное время работы + "recognized_phrases_count": 0, + "commands_executed_count": 0, + "system_load": 0.0 + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..43e2427 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,11 @@ +# app/api/router.py +from fastapi import APIRouter +from app.api import auth, dashboard, settings, session + +api_router = APIRouter() + +# Подключаем все маршруты API +api_router.include_router(auth.router) +api_router.include_router(dashboard.router) +api_router.include_router(settings.router) +api_router.include_router(session.router) \ No newline at end of file diff --git a/app/api/session.py b/app/api/session.py new file mode 100644 index 0000000..aa76c6c --- /dev/null +++ b/app/api/session.py @@ -0,0 +1,35 @@ +# app/api/session.py +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from typing import Optional +from app.core.security import require_auth + +router = APIRouter(prefix="/session", tags=["session"]) + +# Глобальная переменная для хранения экземпляра SessionManager +_session_manager = None + +def set_session_manager(session_manager): + """Устанавливает глобальный экземпляр SessionManager""" + global _session_manager + _session_manager = session_manager + +def get_session_manager(): + """Возвращает глобальный экземпляр SessionManager""" + return _session_manager + +class SessionStatus(BaseModel): + """Модель для статуса сессии""" + active: bool + time_left: float + start_time: Optional[str] = None + end_time: Optional[str] = None + +@router.get("/status", response_model=SessionStatus) +async def get_session_status(request: Request, token: str = Depends(require_auth)): + """Получить текущий статус сессии""" + session_manager = get_session_manager() + if not session_manager: + return SessionStatus(active=False, time_left=0) + + return session_manager.get_session_status() \ No newline at end of file diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..d61fa4c --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,204 @@ +# app/api/settings.py +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from typing import Dict, Any, Optional +from app.core.security import require_auth +from app.core.config import get_settings, save_settings +from app.core.vad import restart_vad_and_stt + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + +class VADSettings(BaseModel): + mode: int = 1 + sample_rate: int = 16000 + frame_duration_ms: int = 30 + ring_buffer_size: int = 20 + speech_threshold: float = 0.5 + energy_threshold: int = 1000 + min_speech_duration_ms: int = 300 + record_stop_delay_sec: float = 1.0 + prebuffer_duration_sec: float = 1.0 + max_record_duration_sec: float = 5.0 + target_rms: float = 2000.0 + max_gain: float = 5.0 + noise_reduction_factor: float = 1.5 + noise_update_alpha: float = 0.05 + min_rms_threshold_factor: float = 2.0 + gain_smoothing_alpha: float = 0.1 + adaptive_energy_std_factor: float = 2.5 + adaptive_energy: bool = True + noise_reduction_enabled: bool = True + auto_gain_enabled: bool = True + record_on_speech: bool = True + +class SystemSettings(BaseModel): + history_maxlen: int = 100 + polling_interval_ms: int = 500 + logging_level: str = "INFO" + log_file: str = "logs/app.log" + log_save_file: bool = True + +class STTSettings(BaseModel): + model_path: str = "" + +class CommandsSettings(BaseModel): + commands_file: str = "" + +class AuthSettings(BaseModel): + username: str = "admin" + password: str = "admin" + +class TTSSettings(BaseModel): + host: str = "192.168.200.81" + port: int = 10200 + timeout: int = 30 + pause_duration: float = 0.5 + post_stop_wait: float = 1.0 + voice: str = "ru_RU-ruslan-medium" + save_TTS_files: bool = False + +class AudioSettings(BaseModel): + playback_device: int = 2 + capture_device: int = 2 + playback_volume: int = 0 + capture_volume: int = 90 + + +class AllSettings(BaseModel): + vad: VADSettings + system: SystemSettings + stt: STTSettings + commands: CommandsSettings + auth: AuthSettings + tts: TTSSettings + audio_settings: AudioSettings + +@router.get("", response_model=AllSettings) +async def get_all_settings(token: str = Depends(require_auth)): + """Получение всех настроек""" + try: + config = get_settings() + return AllSettings( + vad=VADSettings(**config.vad.dict()), + system=SystemSettings(**config.system.dict()), + stt=STTSettings(**config.stt.dict()), + commands=CommandsSettings(**config.commands.dict()), + auth=AuthSettings(**config.auth.dict()), + tts=TTSSettings(**config.tts.dict()), + audio_settings=AudioSettings(**config.audio_settings.dict()) + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/vad", response_model=VADSettings) +async def update_vad_settings(settings: VADSettings, token: str = Depends(require_auth)): + """Обновление настроек VAD""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.vad, key, value) + save_settings(config) + return VADSettings(**config.vad.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/system", response_model=SystemSettings) +async def update_system_settings(settings: SystemSettings, token: str = Depends(require_auth)): + """Обновление системных настроек""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.system, key, value) + save_settings(config) + return SystemSettings(**config.system.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/stt", response_model=STTSettings) +async def update_stt_settings(settings: STTSettings, token: str = Depends(require_auth)): + """Обновление настроек STT""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.stt, key, value) + save_settings(config) + return STTSettings(**config.stt.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/commands", response_model=CommandsSettings) +async def update_commands_settings(settings: CommandsSettings, token: str = Depends(require_auth)): + """Обновление настроек команд""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.commands, key, value) + save_settings(config) + return CommandsSettings(**config.commands.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/auth", response_model=AuthSettings) +async def update_auth_settings(settings: AuthSettings, token: str = Depends(require_auth)): + """Обновление настроек авторизации""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.auth, key, value) + save_settings(config) + return AuthSettings(**config.auth.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/tts", response_model=TTSSettings) +async def update_tts_settings(settings: TTSSettings, token: str = Depends(require_auth)): + """Обновление настроек TTS""" + try: + config = get_settings() + settings_dict = settings.dict() + for key, value in settings_dict.items(): + setattr(config.tts, key, value) + save_settings(config) + return TTSSettings(**config.tts.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/audio_settings", response_model=AudioSettings) +async def update_audio_settings(settings: AudioSettings, token: str = Depends(require_auth)): + """Обновление настроек аудио""" + try: + from volume import set_volume + + config = get_settings() + old_settings = config.audio_settings.dict() + settings_dict = settings.dict() + + # Обновляем настройки в конфигурации + for key, value in settings_dict.items(): + setattr(config.audio_settings, key, value) + + # Применяем настройки громкости, если они изменились + if old_settings['playback_volume'] != settings_dict['playback_volume']: + set_volume(settings_dict['playback_device'], settings_dict['playback_volume'], mixer_type='Playback') + + if old_settings['capture_volume'] != settings_dict['capture_volume']: + set_volume(settings_dict['capture_device'], settings_dict['capture_volume'], mixer_type='Capture') + + save_settings(config) + return AudioSettings(**config.audio_settings.dict()) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/restart") +async def restart_vad(request: Request, token: str = Depends(require_auth)): + """Перезапуск VAD и STT с новыми настройками""" + try: + restart_vad_and_stt(request) + return {"status": "success", "message": "VAD и STT перезапущены"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..b9a7cf7 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core package initialization \ No newline at end of file diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ccceeb3049063c99550b37a496524e1a67ca944 GIT binary patch literal 153 zcmZ3^%ge<81V?wi&X5Jtk3k$5V1zP0vjG{?8B!R688jLFRx%VZ0r{UnlD~BHbMs2{ z1B&vqQj<&cb1UPE3sMuaQ;YNy3kvj;^NUjTC%vKQO?EB4(f%05mcqfdBvi literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50333b4f16f28a7c3368c2d76c2671d9610c3cb5 GIT binary patch literal 131 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 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/config.cpython-313.pyc b/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aef4467ed50cebe3543723b1cd30f18610c6a9e6 GIT binary patch literal 9666 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 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/history.cpython-311.pyc b/app/core/__pycache__/history.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccbcd0696584f15680ab61206c0fa88a1cfde653 GIT binary patch literal 2572 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 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/vad.cpython-311.pyc b/app/core/__pycache__/vad.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a069b6b1d135efa950f1dde1383499f4df84b56 GIT binary patch literal 9043 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 literal 0 HcmV?d00001 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..7e26bd8 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,59 @@ +# app/core/auth.py +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from typing import Dict, Optional, Any +import secrets +import logging +from app.core.config import get_settings + +logger = logging.getLogger('VADApp') + +# Базовая HTTP аутентификация +security = HTTPBasic() + +# Хранилище сессий (в реальном приложении лучше использовать Redis или другое хранилище) +sessions: Dict[str, str] = {} + +def verify_credentials(credentials: HTTPBasicCredentials) -> bool: + """Проверка учетных данных пользователя""" + settings = get_settings() + correct_username = settings.auth.username + correct_password = settings.auth.password + + # Безопасное сравнение строк для предотвращения timing-атак + is_username_correct = secrets.compare_digest(credentials.username, correct_username) + is_password_correct = secrets.compare_digest(credentials.password, correct_password) + + return is_username_correct and is_password_correct + +def create_session_token(username: str) -> str: + """Создание токена сессии""" + token = secrets.token_hex(16) + sessions[token] = username + logger.info(f"Создана новая сессия для пользователя {username}") + return token + +def remove_session(token: str) -> None: + """Удаление сессии""" + if token in sessions: + username = sessions.pop(token) + logger.info(f"Сессия для пользователя {username} удалена") + +def get_current_user(request: Request) -> Optional[str]: + """Получение текущего пользователя из сессии""" + token = request.cookies.get("session_token") + if token and token in sessions: + return sessions[token] + return None + +def require_auth(request: Request) -> str: + """Проверка аутентификации пользователя""" + username = get_current_user(request) + if not username: + logger.warning("Попытка доступа без аутентификации") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Требуется аутентификация", + headers={"WWW-Authenticate": "Basic"}, + ) + return username \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..c3e2630 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,181 @@ +# app/core/config.py +import os +import logging +from typing import Dict, Any, Optional +from pydantic import BaseModel, Field +from ruamel.yaml import YAML +from functools import lru_cache + +# Инициализация логгера +logger = logging.getLogger('VADApp') + +# Путь к файлу конфигурации +CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.yaml') +class AuthConfig(BaseModel): + username: str = "admin" + password: str = "admin" + +class VADConfig(BaseModel): + mode: int = 1 + sample_rate: int = 16000 + frame_duration_ms: int = 30 + ring_buffer_size: int = 20 + speech_threshold: float = 0.5 + energy_threshold: int = 1000 + min_speech_duration_ms: int = 300 + record_stop_delay_sec: float = 1.0 + prebuffer_duration_sec: float = 1.0 + max_record_duration_sec: float = 5.0 + target_rms: float = 2000.0 + max_gain: float = 5.0 + noise_reduction_factor: float = 1.5 + noise_update_alpha: float = 0.05 + min_rms_threshold_factor: float = 2.0 + gain_smoothing_alpha: float = 0.1 + adaptive_energy_std_factor: float = 2.5 + adaptive_energy: bool = True + noise_reduction_enabled: bool = True + auto_gain_enabled: bool = True + record_on_speech: bool = True + +class SystemConfig(BaseModel): + history_maxlen: int = 100 + polling_interval_ms: int = 500 + logging_level: str = "INFO" + log_file: str = "logs/app.log" + log_save_file: bool = True + +class STTConfig(BaseModel): + model_path: str = "" + +class CommandsConfig(BaseModel): + commands_file: str = "" + +class SessionConfig(BaseModel): + session_timeout_seconds: int = 30 + +class TTSConfig(BaseModel): + host: str = "192.168.200.81" + port: int = 10200 + timeout: int = 30 + pause_duration: float = 0.5 + post_stop_wait: float = 1.0 + voice: str = "ru_RU-ruslan-medium" + save_TTS_files: bool = False + +class AudioSettingsConfig(BaseModel): + playback_device: int = 0 + capture_device: int = 0 + playback_volume: int = 80 + capture_volume: int = 90 + +class Settings(BaseModel): + auth: AuthConfig = Field(default_factory=AuthConfig) + vad: VADConfig = Field(default_factory=VADConfig) + system: SystemConfig = Field(default_factory=SystemConfig) + stt: STTConfig = Field(default_factory=STTConfig) + commands: CommandsConfig = Field(default_factory=CommandsConfig) + session: SessionConfig = Field(default_factory=SessionConfig) + tts: TTSConfig = Field(default_factory=TTSConfig) + audio_settings: AudioSettingsConfig = Field(default_factory=AudioSettingsConfig) + +def setup_logging(logging_level='INFO'): + """Настройка уровня логирования""" + logger.debug(f"Установка уровня логирования: {logging_level}") + try: + logger.setLevel(getattr(logging, logging_level.upper(), logging.INFO)) + logger.info(f"Уровень логирования установлен: {logging_level}") + except Exception as e: + logger.error(f"Ошибка настройки логирования: {e}", exc_info=True) + raise + +def load_yaml_config() -> Dict[str, Any]: + """Загрузка конфигурации из YAML файла""" + logger.debug("Загрузка конфигурации из YAML") + try: + yaml = YAML() + yaml.preserve_quotes = True + if not os.path.exists(CONFIG_PATH): + logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}") + raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}") + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config = yaml.load(f) or {} + + # Проверяем и адаптируем пути для текущей ОС + if 'commands' in config and 'commands_file' in config['commands']: + commands_file = config['commands']['commands_file'] + # Преобразуем путь для текущей ОС, если необходимо + commands_file = os.path.normpath(commands_file) + config['commands']['commands_file'] = commands_file + logger.debug(f"Путь к файлу команд: {commands_file}") + + if 'stt' in config and 'model_path' in config['stt']: + model_path = config['stt']['model_path'] + # Преобразуем путь для текущей ОС, если необходимо + model_path = os.path.normpath(model_path) + config['stt']['model_path'] = model_path + logger.debug(f"Путь к модели STT: {model_path}") + + return config + except Exception as e: + logger.error(f"Ошибка загрузки конфигурации: {e}", exc_info=True) + raise + +@lru_cache() +def get_settings() -> Settings: + """Получение настроек приложения (кэшируется)""" + config = load_yaml_config() + + # Создаем объект настроек + settings = Settings( + auth=AuthConfig(**config.get('auth', {})), + vad=VADConfig(**config.get('vad', {})), + system=SystemConfig(**config.get('system', {})), + stt=STTConfig(**config.get('stt', {})), + commands=CommandsConfig(**config.get('commands', {})), + tts=TTSConfig(**config.get('tts', {})), + audio_settings=AudioSettingsConfig(**config.get('audio_settings', {})) + ) + + # Настраиваем логирование + setup_logging(settings.system.logging_level) + + return settings + +def save_settings(settings: Settings) -> bool: + """Сохранение настроек в YAML файл""" + logger.debug("Сохранение конфигурации") + try: + # Загружаем текущую конфигурацию + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.preserve_quotes = True + + if not os.path.exists(CONFIG_PATH): + logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}") + raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}") + + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config = yaml.load(f) or {} + + # Обновляем конфигурацию + config['auth'] = settings.auth.dict() + config['vad'] = settings.vad.dict() + config['system'] = settings.system.dict() + config['stt'] = settings.stt.dict() + config['commands'] = settings.commands.dict() + config['tts'] = settings.tts.dict() + config['audio_settings'] = settings.audio_settings.dict() + + # Сохраняем обновленную конфигурацию + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + yaml.dump(config, f) + + # Сбрасываем кэш настроек + get_settings.cache_clear() + + logger.info("Конфигурация сохранена") + return True + except Exception as e: + logger.error(f"Ошибка сохранения конфигурации: {e}", exc_info=True) + return False \ No newline at end of file diff --git a/app/core/history.py b/app/core/history.py new file mode 100644 index 0000000..4279b53 --- /dev/null +++ b/app/core/history.py @@ -0,0 +1,41 @@ +# app/core/history.py +from datetime import datetime +from typing import List, Dict, Any +from pydantic import BaseModel +from app.core.config import get_settings +import collections + +class RecognizedPhrase(BaseModel): + text: str + timestamp: str + action: str = None + +# Хранилище распознанных фраз (в реальном приложении лучше использовать базу данных) +recognition_history: collections.deque = collections.deque(maxlen=100) + +def add_recognized_phrase(text: str, action: str = None) -> None: + """Добавление распознанной фразы в историю""" + global recognition_history + + # Обновляем максимальный размер истории из настроек + settings = get_settings() + recognition_history = collections.deque(list(recognition_history), + maxlen=settings.system.history_maxlen) + + # Добавляем только фразы с действиями (команды) + if action is not None: + phrase = RecognizedPhrase( + text=text, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + action=action + ) + recognition_history.append(phrase) + +def get_recognition_history(limit: int = 10) -> List[RecognizedPhrase]: + """Получение истории распознанных фраз""" + # Возвращаем последние N фраз (самые новые в начале списка) + return list(reversed(list(recognition_history)))[:limit] + +def clear_recognition_history() -> None: + """Очистка истории распознанных фраз""" + recognition_history.clear() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..196d59f --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,50 @@ +import uuid +from typing import Dict, Optional +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +# Простое хранилище активных сессий (в реальном приложении лучше использовать Redis или другое хранилище) +ACTIVE_SESSIONS: Dict[str, str] = {} + +# Схема безопасности для проверки токена +security = HTTPBearer(auto_error=False) + +def create_session_token(username: str) -> str: + """Создание простого сессионного токена""" + token = str(uuid.uuid4()) + ACTIVE_SESSIONS[token] = username + return token + +def remove_session_token(token: str) -> None: + """Удаление сессионного токена""" + if token in ACTIVE_SESSIONS: + del ACTIVE_SESSIONS[token] + +async def validate_session_token( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> Optional[str]: + """Проверка сессионного токена""" + token_value = None + + # Проверяем заголовок Authorization + if credentials: + token_value = credentials.credentials + # Или query-параметр token (для обратной совместимости) + elif 'token' in request.query_params: + token_value = request.query_params['token'] + + if not token_value or token_value not in ACTIVE_SESSIONS: + return None + + return ACTIVE_SESSIONS[token_value] + +async def require_auth(token: Optional[str] = Depends(validate_session_token)) -> str: + """Требование обязательной аутентификации""" + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Не авторизован", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token \ No newline at end of file diff --git a/app/core/status.py b/app/core/status.py new file mode 100644 index 0000000..86c9856 --- /dev/null +++ b/app/core/status.py @@ -0,0 +1,31 @@ +# app/core/status.py +from enum import Enum +import logging +from threading import Lock + +logger = logging.getLogger('VADApp') + +class SystemStatus(Enum): + INITIALIZATION = "Инициализация" + LISTENING = "Прослушивание" + RECORDING = "Запись" + RECOGNITION = "Распознавание" + COMMAND_PROCESSING = "Обработка команды" + RESPONSE = "Ответ" + +class StatusTracker: + def __init__(self): + self.current_status = SystemStatus.INITIALIZATION + self.lock = Lock() + + def set_status(self, status: SystemStatus): + with self.lock: + self.current_status = status + logger.info(f"Статус: {status.value}") + + def get_status(self) -> str: + with self.lock: + return self.current_status.value + +# Global instance (or can be stored in app.state) +status_tracker = StatusTracker() \ No newline at end of file diff --git a/app/core/system_monitor.py b/app/core/system_monitor.py new file mode 100644 index 0000000..4f4d80e --- /dev/null +++ b/app/core/system_monitor.py @@ -0,0 +1,40 @@ +# app/core/system_monitor.py +import logging +import psutil +from typing import Dict + +logger = logging.getLogger('VADApp') + +def get_system_load() -> Dict[str, float]: + """ + Получение текущей нагрузки системы для Raspberry Pi 3B+. + + Возвращает словарь с: + - 'cpu_percent': Процент загрузки CPU + - 'memory_percent': Процент использования памяти + - 'temperature': Температура CPU в °C + """ + try: + # Загрузка CPU (с интервалом для более точного измерения) + cpu_percent = psutil.cpu_percent(interval=0.5) + + # Использование памяти + memory = psutil.virtual_memory() + memory_percent = memory.percent + + # Температура CPU (специфично для Raspberry Pi) + with open('/sys/class/thermal/thermal_zone0/temp', 'r') as temp_file: + temperature = float(temp_file.read().strip()) / 1000.0 + + return { + 'cpu_percent': cpu_percent, + 'memory_percent': memory_percent, + 'temperature': temperature + } + except Exception as e: + logger.error(f"Ошибка получения нагрузки системы: {e}") + return { + 'cpu_percent': 0.0, + 'memory_percent': 0.0, + 'temperature': 0.0 + } \ No newline at end of file diff --git a/app/core/vad.py b/app/core/vad.py new file mode 100644 index 0000000..d1af744 --- /dev/null +++ b/app/core/vad.py @@ -0,0 +1,135 @@ +# app/core/vad.py +from fastapi import Request +import os +from app.core.config import get_settings +from typing import Tuple, Dict, Any, Optional +import logging +from app.core.status import status_tracker, SystemStatus # Добавлено +from app.core.history import add_recognized_phrase +logger = logging.getLogger('VADApp') + +def get_vad_instance(request: Request): + """Получение экземпляра VAD из состояния приложения""" + return getattr(request.app.state, 'vad_instance', None) + +def get_stt_instance(request: Request): + """Получение экземпляра STT из состояния приложения""" + return getattr(request.app.state, 'stt_instance', None) + +def get_commands(request: Request): + """Получение команд из состояния приложения""" + return getattr(request.app.state, 'commands', None) + +def init_vad_and_stt(request: Request = None) -> Tuple[Any, Any, Dict[str, Any], Any]: + """Инициализация VAD и STT""" + logger.debug("Инициализация VAD и STT") + status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено + try: + # Импортируем здесь, чтобы избежать циклических импортов + from vad_core import VADPolling + from vosk_local_STT import VoskSTT # Исправлено с vosk_stt на vosk_local_STT + from commands import handle_command + from session_manager import SessionManager + from volume import set_volume + + config = get_settings() + commands = {} + + # Инициализация громкости аудиоустройств + try: + # Устанавливаем громкость для устройства воспроизведения + set_volume(config.audio_settings.playback_device, config.audio_settings.playback_volume, mixer_type='Playback') + # Устанавливаем громкость для устройства записи + set_volume(config.audio_settings.capture_device, config.audio_settings.capture_volume, mixer_type='Capture') + logger.info(f"Громкость устройств инициализирована: воспроизведение={config.audio_settings.playback_volume}%, запись={config.audio_settings.capture_volume}%") + except Exception as e: + logger.error(f"Ошибка при инициализации громкости: {e}") + + # Загружаем команды из файла + if config.commands.commands_file and os.path.exists(config.commands.commands_file): + from ruamel.yaml import YAML + yaml = YAML() + with open(config.commands.commands_file, 'r', encoding='utf-8') as f: + commands = yaml.load(f) or {} + + # Инициализируем SessionManager + session_manager = SessionManager(config.session.session_timeout_seconds) + logger.info(f"SessionManager инициализирован с таймаутом {config.session.session_timeout_seconds} секунд") + + # Инициализируем STT + stt_instance = VoskSTT( + model_path=config.stt.model_path, + commands_file=config.commands.commands_file, + sample_rate=config.vad.sample_rate + ) + + # Функция обработки распознанной речи + def process_speech(audio_data, sample_rate): + try: + status_tracker.set_status(SystemStatus.RECOGNITION) # Добавлено + recognized = stt_instance.recognize(audio_data) + if recognized: + logger.info(f"Команда распознана: {recognized}") + status_tracker.set_status(SystemStatus.COMMAND_PROCESSING) # Добавлено + action = handle_command(recognized, commands, session_manager) + status_tracker.set_status(SystemStatus.RESPONSE) # Добавлено (предполагая, что здесь происходит ответ; если нет, удалите) + + # Добавляем распознанную фразу в историю + + if action: + add_recognized_phrase(recognized, action=action) + except Exception as e: + logger.error(f"Ошибка обработки речи: {e}") + status_tracker.set_status(SystemStatus.LISTENING) # Добавлено (возврат к прослушиванию) + + # Инициализируем VAD + vad_config = config.vad.dict() + vad_config['polling_interval_ms'] = config.system.polling_interval_ms + vad_config['history_maxlen'] = config.system.history_maxlen + vad_instance = VADPolling(vad_config, on_speech_end_callback=process_speech) + + logger.info("VAD и STT инициализированы") + status_tracker.set_status(SystemStatus.LISTENING) # Добавлено + return vad_instance, stt_instance, commands, session_manager + except Exception as e: + logger.error(f"Ошибка инициализации VAD/STT: {e}", exc_info=True) + raise + +def cleanup_vad_and_stt(vad_instance, stt_instance): + """Завершение работы VAD и STT""" + logger.debug("Завершение работы VAD и STT") + try: + if vad_instance: + logger.info("Завершаем VAD...") + vad_instance.close() + if stt_instance: + logger.info("Завершаем STT...") + stt_instance = None + logger.info("VAD и STT завершены") + except Exception as e: + logger.error(f"Ошибка завершения VAD/STT: {e}") + +def restart_vad_and_stt(request: Request): + """Перезапуск VAD и STT с новыми настройками""" + logger.info("Перезапуск VAD и STT") + status_tracker.set_status(SystemStatus.INITIALIZATION) # Добавлено + try: + # Завершаем текущие экземпляры + vad_instance = get_vad_instance(request) + stt_instance = get_stt_instance(request) + cleanup_vad_and_stt(vad_instance, stt_instance) + + # Инициализируем новые экземпляры + vad_instance, stt_instance, commands = init_vad_and_stt() + + # Обновляем состояние приложения + request.app.state.vad_instance = vad_instance + request.app.state.stt_instance = stt_instance + request.app.state.commands = commands + + logger.info("VAD и STT перезапущены") + status_tracker.set_status(SystemStatus.LISTENING) # Добавлено + return True + except Exception as e: + logger.error(f"Ошибка перезапуска VAD/STT: {e}", exc_info=True) + raise \ No newline at end of file diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..9ec1abe --- /dev/null +++ b/app/utils.py @@ -0,0 +1,152 @@ +# app/utils.py +import os +import sys +import logging +from ruamel.yaml import YAML +from vad_core import VADPolling +from vosk_local_STT import VoskSTT +from commands import handle_command +from volume import set_volume + +# Инициализация логгера +logger = logging.getLogger('VADApp') +logger.setLevel(logging.DEBUG) +logger.handlers.clear() +log_format = logging.Formatter('[%(asctime)s] %(levelname)s] %(message)s', datefmt='%H:%M:%S') +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(log_format) +logger.addHandler(console_handler) +logger.debug("Логгер VADApp инициализирован") + +CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml') + +def setup_logging(logging_level='INFO'): + logger.debug(f"Установка уровня логирования: {logging_level}") + try: + logger.setLevel(getattr(logging, logging_level.upper(), logging.INFO)) + logger.info(f"Уровень логирования установлен: {logging_level}") + except Exception as e: + logger.error(f"Ошибка настройки логирования: {e}", exc_info=True) + raise + +def load_config(): + logger.debug("Загрузка конфигурации") + try: + yaml = YAML() + yaml.preserve_quotes = True + if not os.path.exists(CONFIG_PATH): + logger.error(f"Файл конфигурации не найден: {CONFIG_PATH}") + raise FileNotFoundError(f"Файл конфигурации не найден: {CONFIG_PATH}") + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config = yaml.load(f) or {} + + commands_file = config.get('commands', {}).get('commands_file', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'commands', 'commands.yaml')) + if commands_file: + if not os.path.exists(commands_file): + logger.error(f"Файл команд не найден: {commands_file}") + raise FileNotFoundError(f"Файл команд не найден: {commands_file}") + with open(commands_file, 'r', encoding='utf-8') as cmd_file: + config['commands'] = yaml.load(cmd_file) or {} + config['commands_file'] = commands_file + logger.debug(f"Команды загружены: {commands_file}") + + config.setdefault('system', {}) + config['system'].setdefault('history_maxlen', 100) + config['system'].setdefault('polling_interval_ms', 500) + config['system'].setdefault('logging_level', 'INFO') + + config.setdefault('vad', {}) + config['vad'].setdefault('mode', 1) + config['vad'].setdefault('sample_rate', 16000) + config['vad'].setdefault('frame_duration_ms', 30) + config['vad'].setdefault('ring_buffer_size', 20) + config['vad'].setdefault('speech_threshold', 0.5) + config['vad'].setdefault('energy_threshold', 1000) + config['vad'].setdefault('min_speech_duration_ms', 300) + config['vad'].setdefault('record_stop_delay_sec', 1.0) + config['vad'].setdefault('prebuffer_duration_sec', 1.0) + config['vad'].setdefault('max_record_duration_sec', 5.0) + config['vad'].setdefault('target_rms', 2000.0) + config['vad'].setdefault('max_gain', 5.0) + config['vad'].setdefault('noise_reduction_factor', 1.5) + config['vad'].setdefault('noise_update_alpha', 0.05) + config['vad'].setdefault('min_rms_threshold_factor', 2.0) + config['vad'].setdefault('gain_smoothing_alpha', 0.1) + config['vad'].setdefault('adaptive_energy_std_factor', 2.5) + config['vad'].setdefault('adaptive_energy', True) + config['vad'].setdefault('noise_reduction_enabled', True) + config['vad'].setdefault('auto_gain_enabled', True) + config['vad'].setdefault('record_on_speech', True) + + config.setdefault('auth', {}) + config['auth'].setdefault('username', 'admin') + config['auth'].setdefault('password', 'admin') + + setup_logging(config['system']['logging_level']) + logger.info("Конфигурация загружена") + return config + except Exception as e: + logger.error(f"Ошибка загрузки конфигурации: {e}", exc_info=True) + raise + +def save_config(config): + logger.debug("Сохранение конфигурации") + try: + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.preserve_quotes = True + config_to_save = config.copy() + if 'commands_file' in config_to_save: + del config_to_save['commands_file'] + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + yaml.dump(config_to_save, f) + logger.info("Конфигурация сохранена") + return True + except Exception as e: + logger.error(f"Ошибка сохранения конфигурации: {e}", exc_info=True) + return False + +def init_vad_and_stt(): + logger.debug("Инициализация VAD и STT") + try: + config = load_config() + commands = config.get('commands', {}) + + stt_instance = VoskSTT( + model_path=config.get('stt', {}).get('model_path', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'models', 'vosk-model-small-ru-0.22')), + commands_file=config.get('commands_file', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'commands', 'commands.yaml')), + sample_rate=config.get('vad', {}).get('sample_rate', 16000) + ) + + def process_speech(audio_data, sample_rate): + try: + recognized = stt_instance.recognize(audio_data) + if recognized: + logger.info(f"Команда распознана: {recognized}") + handle_command(recognized, commands) + except Exception as e: + logger.error(f"Ошибка обработки речи: {e}") + + vad_config = config.get('vad', {}) + vad_config['polling_interval_ms'] = config['system']['polling_interval_ms'] + vad_config['history_maxlen'] = config['system']['history_maxlen'] + vad_instance = VADPolling(vad_config, on_speech_end_callback=process_speech) + + logger.info("VAD и STT инициализированы") + return vad_instance, stt_instance, commands + except Exception as e: + logger.error(f"Ошибка инициализации VAD/STT: {e}", exc_info=True) + raise + +def cleanup_vad_and_stt(vad_instance, stt_instance): + logger.debug("Завершение работы VAD и STT") + try: + if vad_instance: + logger.info("Завершаем VAD...") + vad_instance.close() + if stt_instance: + logger.info("Завершаем STT...") + stt_instance = None + logger.info("VAD и STT завершены") + except Exception as e: + logger.error(f"Ошибка завершения VAD/STT: {e}") diff --git a/commands.py b/commands.py new file mode 100644 index 0000000..958cd64 --- /dev/null +++ b/commands.py @@ -0,0 +1,168 @@ +# commands.py +import logging +import time +from datetime import datetime +from piper_TTS import synthesize_text +from app.core.config import get_settings +from volume import get_volume + +def speak_text(text, voice_name=None): + """ + Синтезирует речь из текста и возвращает путь к аудиофайлу + + Args: + text (str): Текст для синтеза + voice_name (str, optional): Имя голоса + + Returns: + str: Путь к созданному аудиофайлу или None в случае ошибки + """ + logger = logging.getLogger('VADApp') + try: + logger.info(f"Синтезирую текст: '{text}'") + audio_path = synthesize_text(text, voice_name) + logger.info(f"Синтез завершен, файл: {audio_path}") + return audio_path + except Exception as e: + logger.error(f"Ошибка синтеза речи: {e}") + return None + +def handle_command(command, commands, session_manager=None): + """ + Обрабатывает распознанную команду на основе commands.yaml и возвращает action + + Args: + command (str): Распознанная команда + commands (dict): Словарь команд из commands.yaml + session_manager (SessionManager, optional): Менеджер сессий + + Returns: + str: Действие, которое нужно выполнить, или None + """ + logger = logging.getLogger('VADApp') + try: + if not commands: + logger.warning("Команды не загружены") + return None + + # Создаем список всех возможных совпадений с их приоритетами + matches = [] + session_active = session_manager.is_session_active() if session_manager else True + min_priority = 0 if session_active else 90 + + # Проверяем wakewords (всегда проверяем, независимо от статуса сессии) + for wakeword_group in commands.get('Wakewords', []): + if isinstance(wakeword_group, dict): + phrases = wakeword_group.get('phrases', []) + action = wakeword_group.get('action', 'wake_assistant') + priority = wakeword_group.get('priority', 0) + if command.lower() in [p.lower() for p in phrases]: + matches.append({ + 'action': action, + 'priority': priority + }) + # Если обнаружено wakeword и сессия не активна, активируем её + if session_manager and not session_active and action == 'wake_assistant': + logger.info("Обнаружено wakeword, активируем сессию") + session_manager.start_session() + + # Проверяем команды громкости (всегда проверяем, независимо от статуса сессии) + for volume_group in commands.get('Volume', []): + if isinstance(volume_group, dict): + volume_commands = volume_group.get('commands', {}) + priority = volume_group.get('priority', 0) + if command.lower() in volume_commands: + action = volume_commands[command.lower()] + matches.append({ + 'action': action, + 'priority': priority + }) + + # Обработка команды "какая громкость" + if action == 'get_volume': + config = get_settings() + playback_device = config.audio_settings.playback_device + capture_device = config.audio_settings.capture_device + playback_volume = get_volume(playback_device, mixer_type='Playback') + capture_volume = get_volume(capture_device, mixer_type='Capture') + + if playback_volume is not None and capture_volume is not None: + logger.info(f"Текущая громкость: воспроизведение={playback_volume}%, запись={capture_volume}%") + # Здесь можно добавить код для озвучивания громкости, если есть такая возможность + else: + logger.error("Не удалось получить информацию о громкости") + + # Если сессия активна или команда имеет высокий приоритет, проверяем остальные команды + if session_active: + # Проверяем команды сессии + for session_group in commands.get('Session', []): + if isinstance(session_group, dict): + phrases = session_group.get('phrases', []) + action = session_group.get('action', 'session_stop') + priority = session_group.get('priority', 0) + if command.lower() in [p.lower() for p in phrases]: + matches.append({ + 'action': action, + 'priority': priority + }) + # Если обнаружена команда завершения сессии + if session_manager and action == 'session_stop': + logger.info("Обнаружена команда завершения сессии") + session_manager.end_session() + + # Проверяем текстовые команды + for text_group in commands.get('Text', []): + if isinstance(text_group, dict): + text_commands = text_group.get('commands', {}) + priority = text_group.get('priority', 0) + if command.lower() in text_commands: + matches.append({ + 'action': text_commands[command.lower()], + 'priority': priority + }) + + # Проверяем плагины + for plugin_group in commands.get('Plugin', []): + if isinstance(plugin_group, dict): + priority = plugin_group.get('priority', 0) + action = plugin_group.get('action', 'speak_plugins') + # Добавляем действие плагина только если не нашли других совпадений + # и сессия активна + if not matches and session_active: + matches.append({ + 'action': action, + 'priority': priority + }) + + # Фильтруем совпадения по минимальному приоритету + matches = [m for m in matches if m['priority'] >= min_priority] + + # Если есть совпадения, выбираем действие с наивысшим приоритетом + if matches: + # Сортируем по приоритету (от высшего к низшему) + matches.sort(key=lambda x: x['priority'], reverse=True) + action = matches[0]['action'] + logger.info(f"Выполняется действие: {action} с приоритетом {matches[0]['priority']}") + # Тестовая пауза на выполение действия + session_manager.pause_session() + + # Имитируем выполнение действия с использованием TTS + + print("Выполняем синтез речи...") + audio_path = speak_text(f"Выполняю команду: {action}") + print(f"Синтез завершен, файл: {audio_path}") + time.sleep(2) # Пауза на 2 секунды + + # Возобновляем сессию после паузы + session_manager.resume_session() + # Сбрасываем таймер сессии, если действие не завершает сессию + if session_manager and session_manager.is_session_active() and action != 'session_stop': + + session_manager.reset_timer() + return action + else: + logger.warning(f"Команда не найдена в commands.yaml: {command}") + return None + except Exception as e: + logger.error(f"Ошибка обработки команды: {e}") + return None diff --git a/commands/commands.yaml b/commands/commands.yaml new file mode 100644 index 0000000..8680493 --- /dev/null +++ b/commands/commands.yaml @@ -0,0 +1,46 @@ +Wakewords: + - phrases: + - "привет дройд" + - "привет придурок" + - "привет говнюк" + - "привет жопа с ушами" + - "привет дурик" + action: wake_assistant + priority: 100 + +Volume: + - priority: 95 + commands: + "какая громкость": get_volume + "сделай громче": volume_up + "сделай тише": volume_down + "громкость ноль": set_volume_0 + "громкость один": set_volume_1 + "громкость два": set_volume_2 + "громкость три": set_volume_3 + "громкость четыре": set_volume_4 + "громкость пять": set_volume_5 + "громкость шесть": set_volume_6 + "громкость семь": set_volume_7 + "громкость восемь": set_volume_8 + "громкость девять": set_volume_9 + "громкость десять": set_volume_10 + +Session: + - phrases: + - "стоп" + - "хватит" + - "заткнись" + - "спасибо" + action: session_stop + priority: 80 + +Text: + - priority: 50 + commands: + "что ты умеешь": speak_about + "какие у тебя есть команды": speak_commands + +Plugin: + - priority: 10 + action: speak_plugins diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..043ee75 --- /dev/null +++ b/config.yaml @@ -0,0 +1,60 @@ +# Настройки системы +system: + # Максимальное количество записей в истории + history_maxlen: 100 + polling_interval_ms: 1000 + logging_level: debug + log_file: logs/app.log + log_save_file: false +vad: + # Режим VAD (0-3) + mode: 3 + sample_rate: 16000 + frame_duration_ms: 10 + ring_buffer_size: 20 + speech_threshold: 0.6 + energy_threshold: 400 + min_speech_duration_ms: 100 + record_stop_delay_sec: 1.0 + prebuffer_duration_sec: 1.0 + max_record_duration_sec: 5.0 + target_rms: 2000.0 + max_gain: 5.0 + noise_reduction_factor: 1.2 + noise_update_alpha: 0.05 + min_rms_threshold_factor: 2.0 + gain_smoothing_alpha: 0.1 + adaptive_energy_std_factor: 1.5 + adaptive_energy: true + noise_reduction_enabled: true + auto_gain_enabled: false + record_on_speech: false +stt: + # Путь к модели Vosk + model_path: /home/sirmrak/models/vosk-model-small-ru-0.22 +commands: + # Путь к файлу с командами + commands_file: /mnt/Project/my_speaker/commands/commands.yaml +auth: + # Имя пользователя + username: string + password: string +tts: + # Настройки TTS + host: 192.168.200.81 + port: 10200 + timeout: 30 + pause_duration: 0.5 + post_stop_wait: 1.0 + voice: ru_RU-ruslan-medium + save_TTS_files: false +session: + # Время сессии в секундах + session_timeout_seconds: 30 + +audio_settings: + # Настройки аудио системы + playback_device: 2 + capture_device: 2 + playback_volume: 80 + capture_volume: 100 diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md new file mode 100644 index 0000000..792c391 --- /dev/null +++ b/docs/API_DOCS.md @@ -0,0 +1,318 @@ +# Документация API + +Эта документация описывает все доступные эндпоинты API для приложения, включая методы, параметры, ответы и примеры использования. API разделено на три группы маршрутов: /api/auth, /api/settings и /api/dashboard. Большинство эндпоинтов, кроме /api/auth/login, требуют авторизации через Bearer-токен, который можно получить через эндпоинт логина. + +## Общая информация + +- **Базовый URL**: http://192.168.200.111:8000 +- **Авторизация**: Большинство эндпоинтов требуют заголовок `Authorization: Bearer ` или query-параметр `token=`. Токен получается через /api/auth/login. +- **Формат ответов**: JSON. +- **Коды ошибок**: + - 401 Unauthorized: Требуется авторизация или токен недействителен. + - 500 Internal Server Error: Ошибка на сервере. + +## Получение токена авторизации + +Для доступа к защищённым эндпоинтам необходимо получить Bearer-токен через эндпоинт /api/auth/login. + +- **Эндпоинт**: POST /api/auth/login +- **Описание**: Аутентифицирует пользователя и возвращает сессионный токен. +- **Тело запроса (JSON)**: + ``` + { + "username": "string", + "password": "string" + } + ``` +- **Ответ**: + + - Успех (200): + ``` + { + "access_token": "string", + "token_type": "bearer" + } + ``` + - Ошибка (401): + ``` + { + "detail": "Неверные учетные данные" + } + ``` + +- **Пример**: + + ``` + curl -X POST http://192.168.200.111:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin"}' + ``` + + Результат (пример): + + ``` + { + "access_token": "123e4567-e89b-12d3-a456-426614174000", + "token_type": "bearer" + } + ``` + +Сохраните `access_token` для использования в других запросах. + +## Эндпоинты /api/auth + +1. **Получение информации о текущем пользователе** + + - **Эндпоинт**: GET /api/auth/me + - **Описание**: Возвращает информацию о текущем пользователе на основе предоставленного токена. Если токен отсутствует или недействителен, возвращает неаутентифицированный статус. + - **Параметры запроса**: + - authorization (опционально, строка): Заголовок Authorization: Bearer . + - token_param (опционально, строка): Query-параметр token=. + - **Ответ**: + + - Успех (200): + ``` + { + "username": "string", + "is_authenticated": true + } + ``` + - Неаутентифицирован (200): + ``` + { + "username": "", + "is_authenticated": false + } + ``` + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/auth/me \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Выход из системы** + + - **Эндпоинт**: POST /api/auth/logout + - **Описание**: Завершает сессию пользователя, удаляя токен. + - **Параметры запроса**: + - authorization (опционально, строка): Заголовок Authorization: Bearer . + - token_param (опционально, строка): Query-параметр token=. + - **Ответ**: + + - Успех (200): + ``` + { + "status": "success", + "message": "Выход выполнен успешно" + } + ``` + - Нет сессии (200): + ``` + { + "status": "success", + "message": "Нет активной сессии" + } + ``` + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X POST http://192.168.200.111:8000/api/auth/logout \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Эндпоинты /api/settings + +1. **Получение всех настроек** + + - **Эндпоинт**: GET /api/settings + - **Описание**: Возвращает все настройки приложения (VAD, system, STT, commands, auth). + - **Ответ**: + + - Успех (200): Объект с полями vad, system, stt, commands, auth (см. модели в коде). + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/settings \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Обновление настроек VAD** + + - **Эндпоинт**: PUT /api/settings/vad + - **Описание**: Обновляет настройки VAD. + - **Тело запроса (JSON)**: Объект VADSettings (см. модель в коде). + - **Ответ**: + + - Успех (200): Обновлённый объект VADSettings. + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X PUT http://192.168.200.111:8000/api/settings/vad \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "Content-Type: application/json" \ + -d '{"mode": 1, "sample_rate": 16000, ...}' + ``` + +(Аналогично для /system, /stt, /commands, /auth — обновление соответствующих настроек.) + +3. **Перезапуск VAD и STT** + + - **Эндпоинт**: POST /api/settings/restart + - **Описание**: Перезапускает VAD и STT с новыми настройками. + - **Ответ**: + + - Успех (200): + ``` + { + "status": "success", + "message": "VAD и STT перезапущены" + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X POST http://192.168.200.111:8000/api/settings/restart \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Эндпоинты /api/dashboard + +1. **Получение данных о речи и энергии** + + - **Эндпоинт**: GET /api/dashboard/speech-data + - **Описание**: Возвращает текущие данные о речи и энергии, включая расширенные метрики VAD и STT. + - **Ответ**: + + - Успех (200): + ``` + { + "state": "string", + "speech_prob": 0.0, + "energy": 0.0, + "energy_threshold": 0.0, + "speech_detected": false, + "continuous_frames": 0, + "gain": 1.0, + "noise_floor": 0.0, + "stt_model_path": "string", + "stt_commands_count": 0 + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/speech-data \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +2. **Получение последних распознанных фраз** + + - **Эндпоинт**: GET /api/dashboard/recent-phrases + - **Описание**: Возвращает список последних распознанных фраз (до указанного лимита). + - **Параметры запроса**: + - limit (опционально, целое число, по умолчанию 10): Максимальное количество фраз. + - **Ответ**: + + - Успех (200): + ``` + [ + { + "text": "Пример фразы", + "timestamp": "2025-10-13T11:13:00", + "is_command": true + }, + ... + ] + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/recent-phrases?limit=5 \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +3. **Получение статистики системы** + + - **Эндпоинт**: GET /api/dashboard/stats + - **Описание**: Возвращает расширенную статистику работы системы (время работы, количество распознанных фраз, системная нагрузка и т.д.). + - **Ответ**: + + - Успех (200): + ``` + { + "uptime": "0:00:00", + "recognized_phrases_count": 0, + "commands_executed_count": 0, + "system_load": { + "cpu_percent": 0.0, + "memory_percent": 0.0, + "temperature": 0.0 + }, + "total_speech_frames": 0, + "avg_speech_prob": 0.0, + "vad_initialized": true, + "stt_initialized": true + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/stats \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +4. **Получение данных о нагрузке системы** + + - **Эндпоинт**: GET /api/dashboard/sys-monitor + - **Описание**: Возвращает данные о нагрузке системы (CPU, память, температура). + - **Ответ**: + + - Успех (200): + ``` + { + "cpu_percent": 0.0, + "memory_percent": 0.0, + "temperature": 0.0 + } + ``` + - Ошибка (401): {"detail": "Не авторизован"} + - Ошибка (500): {"detail": "Описание ошибки"} + + - **Пример**: + ``` + curl -X GET http://192.168.200.111:8000/api/dashboard/sys-monitor \ + -H "Authorization: Bearer 123e4567-e89b-12d3-a456-426614174000" \ + -H "accept: application/json" + ``` + +## Замечания + +- **Авторизация**: Все эндпоинты, кроме /api/auth/login, требуют токен, полученный через /api/auth/login. Токен можно передать: + - В заголовке: `Authorization: Bearer ` + - В query-параметре: `?token=` +- **Безопасность**: Пароли в настройках хранятся в открытом виде. Рекомендуется использовать хэширование (например, bcrypt) для auth.password. +- **Хранение сессий**: Токены хранятся в памяти (ACTIVE_SESSIONS). Для продакшена используйте Redis или другую базу данных. +- **Ограничения**: Токены не имеют срока действия. Рекомендуется добавить expiration (например, хранить timestamp в ACTIVE_SESSIONS). +- **Обновления**: Документация актуализирована на 13 октября 2025 года. Добавлены новые поля в /speech-data, новый эндпоинт /sys-monitor и расширенная структура для /stats. diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..f972134 --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,377 @@ +# Документация API My Speaker + +## Общая информация + +API My Speaker предоставляет интерфейс для взаимодействия с системой распознавания речи, управления настройками и получения данных о работе системы. API построено на FastAPI и использует REST-подход. + +Базовый URL: `/api` + +## Аутентификация + +Все запросы к API (кроме авторизации) требуют аутентификации. Аутентификация осуществляется с помощью токена доступа, который можно получить через эндпоинт `/api/auth/login`. + +Токен можно передавать одним из следующих способов: + +1. В заголовке `Authorization: Bearer ` +2. В параметре запроса `?token=` + +### Эндпоинты аутентификации + +#### Авторизация + +``` +POST /api/auth/login +``` + +**Запрос:** + +```json +{ + "username": "admin", + "password": "your_password" +} +``` + +**Ответ:** + +```json +{ + "access_token": "unique_session_token", + "token_type": "bearer" +} +``` + +#### Получение информации о текущем пользователе + +``` +GET /api/auth/me +``` + +**Ответ:** + +```json +{ + "username": "admin", + "is_authenticated": true +} +``` + +#### Выход из системы + +``` +POST /api/auth/logout +``` + +**Ответ:** + +```json +{ + "status": "success", + "message": "Выход выполнен успешно" +} +``` + +## Панель управления (Dashboard) + +Все запросы к эндпоинтам панели управления выполняются с использованием токена полученного при авторизации. + +### Получение данных о речи + +```` +GET /api/dashboard/speech-data +curl -X 'GET' \ + 'http://192.168.200.111:8000/api/dashboard/sys-monitor' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer dc1460d1-6e67-4730-afb2-c94de7d243b4' + +**Ответ:** +```json +{ + "state": "listening", + "speech_prob": 0.85, + "speech_threshold": 0.5, + "energy": 1200, + "energy_threshold": 1000, + "speech_detected": true, + "continuous_frames": 5, + "gain": 1.2, + "noise_floor": 300 +} +```` + +### Получение данных о нагрузке системы + +``` +GET /api/dashboard/sys-monitor +``` + +**Ответ:** + +```json +{ + "cpu_percent": 25.5, + "memory_percent": 40.2, + "temperature": 45.3 +} +``` + +### Получение последних распознанных фраз + +``` +GET /api/dashboard/recent-phrases?limit=10 +``` + +**Ответ:** + +```json +[ + { + "text": "включи свет", + "timestamp": "2023-06-15T14:30:45", + "is_command": true + }, + { + "text": "какая погода сегодня", + "timestamp": "2023-06-15T14:29:30", + "is_command": false + } +] +``` + +### Получение статистики работы системы + +``` +GET /api/dashboard/stats +``` + +**Ответ:** + +```json +{ + "uptime": "2:30:15", + "recognized_commands": 15, + "total_phrases": 45 +} +``` + +## Настройки (Settings) + +Все запросы к эндпоинтам настроек выполняются с использованием токена полученного при авторизации. + +### Получение всех настроек + +``` +GET /api/settings +``` + +**Ответ:** + +```json +{ + "vad": { + "mode": 3, + "sample_rate": 16000, + "frame_duration_ms": 30, + "ring_buffer_size": 20, + "speech_threshold": 0.5, + "energy_threshold": 1000, + "min_speech_duration_ms": 300, + "record_stop_delay_sec": 1.0, + "prebuffer_duration_sec": 1.0, + "max_record_duration_sec": 5.0, + "target_rms": 2000.0, + "max_gain": 5.0, + "noise_reduction_factor": 1.5, + "noise_update_alpha": 0.05, + "min_rms_threshold_factor": 2.0, + "gain_smoothing_alpha": 0.1, + "adaptive_energy_std_factor": 2.5, + "adaptive_energy": true, + "noise_reduction_enabled": true, + "auto_gain_enabled": true, + "record_on_speech": true + }, + "system": { + "history_maxlen": 100, + "polling_interval_ms": 500, + "logging_level": "INFO", + "log_file": "logs/app.log", + "log_save_file": true + }, + "stt": { + "model_path": "/path/to/vosk/model" + }, + "commands": { + "commands_file": "commands/commands.yaml" + }, + "auth": { + "username": "admin", + "password": "admin" + } +} +``` + +### Обновление настроек VAD + +``` +PUT /api/settings/vad +``` + +**Запрос:** + +```json +{ + "mode": 3, + "speech_threshold": 0.7, + "energy_threshold": 1200 +} +``` + +**Ответ:** + +```json +{ + "mode": 3, + "sample_rate": 16000, + "frame_duration_ms": 30, + "ring_buffer_size": 20, + "speech_threshold": 0.7, + "energy_threshold": 1200, + "min_speech_duration_ms": 300, + "record_stop_delay_sec": 1.0, + "prebuffer_duration_sec": 1.0, + "max_record_duration_sec": 5.0, + "target_rms": 2000.0, + "max_gain": 5.0, + "noise_reduction_factor": 1.5, + "noise_update_alpha": 0.05, + "min_rms_threshold_factor": 2.0, + "gain_smoothing_alpha": 0.1, + "adaptive_energy_std_factor": 2.5, + "adaptive_energy": true, + "noise_reduction_enabled": true, + "auto_gain_enabled": true, + "record_on_speech": true +} +``` + +### Обновление системных настроек + +``` +PUT /api/settings/system +``` + +**Запрос:** + +```json +{ + "history_maxlen": 200, + "polling_interval_ms": 1000 +} +``` + +**Ответ:** + +```json +{ + "history_maxlen": 200, + "polling_interval_ms": 1000, + "logging_level": "INFO", + "log_file": "logs/app.log", + "log_save_file": true +} +``` + +### Обновление настроек STT + +``` +PUT /api/settings/stt +``` + +**Запрос:** + +```json +{ + "model_path": "/new/path/to/vosk/model" +} +``` + +**Ответ:** + +```json +{ + "model_path": "/new/path/to/vosk/model" +} +``` + +### Обновление настроек команд + +``` +PUT /api/settings/commands +``` + +**Запрос:** + +```json +{ + "commands_file": "commands/new_commands.yaml" +} +``` + +**Ответ:** + +```json +{ + "commands_file": "commands/new_commands.yaml" +} +``` + +### Обновление настроек авторизации + +``` +PUT /api/settings/auth +``` + +**Запрос:** + +```json +{ + "username": "new_admin", + "password": "new_password" +} +``` + +**Ответ:** + +```json +{ + "username": "new_admin", + "password": "new_password" +} +``` + +### Перезапуск VAD и STT + +``` +POST /api/settings/restart +``` + +**Ответ:** + +```json +{ + "status": "success", + "message": "VAD и STT перезапущены" +} +``` + +## Коды ошибок + +- `401 Unauthorized` - Ошибка авторизации +- `500 Internal Server Error` - Внутренняя ошибка сервера + +## Примечания + +- Все запросы и ответы используют формат JSON +- Для всех запросов, требующих авторизации, необходимо передавать токен доступа +- Параметры в запросах на обновление настроек являются опциональными, можно передавать только те параметры, которые нужно изменить diff --git a/docs/FRONTEND_REQUIREMENTS.md b/docs/FRONTEND_REQUIREMENTS.md new file mode 100644 index 0000000..054e4d4 --- /dev/null +++ b/docs/FRONTEND_REQUIREMENTS.md @@ -0,0 +1,343 @@ +# Техническое задание на разработку фронтенда My Speaker + +## 1. Общая информация и структура проекта + +webapp/ +├── index.html # Основная страница приложения (SPA) +├── style.css # Основные стили в ретро-стилистике +├── api.js # Модуль для работы с API (уже существует) +├── app.js # Точка входа: инициализация приложения и маршрутизация +├── modules/ # Папка для модулей приложения +│ ├── router.js # Модуль маршрутизации (загрузка страниц) +│ ├── login.js # Логика для страницы авторизации +│ ├── dashboard.js # Логика для панели мониторинга +│ ├── settings.js # Логика для страницы настроек +│ ├── commands.js # Логика для страницы команд (зарезервировано на будущее) +│ ├── header.js # Логика для верхней панели (имя пользователя, выход) +│ ├── nav.js # Логика для навигационной панели +│ └── utils.js # Общие утилиты (например, обработка ошибок, форматирование) +├── pages/ # Папка для HTML-страниц +│ ├── dashboard.html # Разметка для панели мониторинга +│ ├── settings.html # Разметка для настроек +│ ├── commands.html # Разметка для команд +│ └── login.html # Разметка для авторизации +└── components/ # Папка для общих компонентов +├── header.html # Разметка верхней панели +└── nav.html # Разметка навигационной панели + +### 1.1. Цель проекта + +Разработка веб-интерфейса для системы распознавания речи My Speaker, позволяющего управлять настройками системы, отслеживать состояние распознавания речи и просматривать историю распознанных фраз. + +### 1.2. Технологический стек + +- HTML5 +- CSS3 +- JavaScript (ES6+) +- графики chart.js +- Без использования фреймворков (чистый JavaScript) + +## 2. Функциональные требования + +### 2.1. Авторизация + +- Кнопка "шестеренка" для настройки основного цвета интерфейса (по умолчанию #0f0) и поле для ввода адреса бекенда (по умолчанию http://localhost:8000) +- Форма входа с полями для ввода логина и пароля +- Сохранение токена авторизации в localStorage +- Автоматический выход при истечении срока действия токена +- Кнопка выхода из системы, которая удаляет токен из localStorage + +### 2.2. Панель мониторинга (Dashboard) не обновляется пока нет авторизации + +- Отображение текущего состояния системы распознавания речи +- Визуализация уровня речи и энергии звука в реальном времени +- Два ползунка громкости для микрофона и колонок (набудущее) +- График изменения вероятности речи ("speech_prob" и "speech_threshold") и энергии звука ("energy" и "energy_threshold") +- Данные обновляются с частотой из настроек системы (polling_interval_ms) +- Отображение системной информации (загрузка CPU, память, температура) +- Список последних распознанных фраз с выделением команд +- Индикация состояния системы (прослушивание, запись, обработка) + +### 2.3. Настройки системы + +- Формы должны быть свернуты и при развертывании новой, старая сворачивается. +- Формы должны быть заполнены согласно описанию config.yaml.md (использоватся выпадающие списки для выбора определенных значений, кнопки больше и меньше должны иметь нужный шаг) +- Кнопка сохранения для каждой формы, которая сохраняет введенные пользователем данные на сервер. +- Форма для настройки параметров VAD (Voice Activity Detection) +- Форма для настройки системных параметров +- Форма для настройки параметров STT (Speech-to-Text) +- Форма для настройки параметров команд +- Форма для изменения учетных данных +- Кнопка для перезапуска VAD и STT + +## 3. Требования к интерфейсу + +### 3.1. Общие требования + +- Адаптивный дизайн (поддержка мобильных устройств) +- Максимальное использование пространства экрана без прокрутки как по вертикали, так и по горизонтали (в десктоп версии) +- Темная цветовая схема в стиле ретро-игр (8-bit) +- Использование шрифта "Press Start 2P" для создания пиксельной эстетики +- Зеленая цветовая гамма на темном фоне (основной цвет текста #0f0) +- Пиксельные элементы интерфейса (кнопки, индикаторы, рамки) +- Интуитивно понятный интерфейс с игровой стилистикой + +### 3.2. Структура интерфейса + +- Экран авторизации в стиле игрового меню +- Основной экран с двумя основными панелями (информационная и контентная) +- Верхняя панель с именем пользователя и кнопкой выхода +- Навигационная панель для переключения между разделами (графики/настройки/команды) +- Пиксельные рамки вокруг всех блоков и элементов интерфейса + +### 3.3. Разделы интерфейса + +1. **Авторизация** + + - Форма входа в стиле игрового меню с пиксельной рамкой + - Поля для ввода логина (PLAYER NAME) и пароля (SECRET CODE) + - Кнопка "START" в пиксельном стиле + - Сообщение об ошибке в красном цвете + - Подсказка с дефолтными учетными данными + +2. **Панель мониторинга** + + - Информационная панель с основными параметрами (MAIN PARAMETERS) + - Индикатор состояния системы с цветовой индикацией + - Метрики с процентом речи и уровнем энергии с визуальными индикаторами + - Системная статистика (CPU, MEM, TEMP) с графическими индикаторами + - Игровая статистика (UPTIME, VAD, STT, AVG) + - Окно лога с последними распознанными фразами + - Графики активности речи и энергии звука + +3. **Настройки** + - Кнопка "REBOOT" для перезапуска системы + - Раскрывающиеся группы настроек (details/summary) + - Формы с полями ввода для каждого параметра в стиле ретро-игры + - Выпадающие списки и числовые поля с пиксельными рамками + +## 4. Взаимодействие с API + +### 4.1. Авторизация + +- `POST /api/auth/login` - авторизация пользователя +- `GET /api/auth/me` - получение информации о текущем пользователе +- `POST /api/auth/logout` - выход из системы + +### 4.2. Панель мониторинга + +- `GET /api/dashboard/speech-data` - получение данных о речи +- `GET /api/dashboard/sys-monitor` - получение данных о нагрузке системы +- `GET /api/dashboard/recent-phrases` - получение последних распознанных фраз +- `GET /api/dashboard/stats` - получение статистики работы системы + +### 4.3. Настройки + +- `GET /api/settings` - получение всех настроек +- `PUT /api/settings/vad` - обновление настроек VAD +- `PUT /api/settings/system` - обновление системных настроек +- `PUT /api/settings/stt` - обновление настроек STT +- `PUT /api/settings/commands` - обновление настроек команд +- `PUT /api/settings/auth` - обновление настроек авторизации +- `POST /api/settings/restart` - перезапуск VAD и STT + +## 5. Описание ключевых параметров настройки + +### 5.1. Параметры VAD (Voice Activity Detection) + +| Параметр | Описание | Тип элемента управления | +| ----------------------- | ----------------------------------------------------- | ----------------------- | +| mode | Режим работы VAD (агрессивность фильтрации) от 0 до 3 | Выпадающий список | +| speech_threshold | Порог вероятности речи (0.0 - 1.0) | Ползунок | +| energy_threshold | Порог энергии звука | Числовое поле | +| min_speech_duration_ms | Минимальная длительность речи в мс | Числовое поле | +| record_stop_delay_sec | Задержка остановки записи после окончания речи в сек | Числовое поле | +| adaptive_energy | Использовать ли адаптивный порог энергии | Переключатель | +| noise_reduction_enabled | Включить подавление шума | Переключатель | +| auto_gain_enabled | Включить автоматическую регулировку усиления | Переключатель | + +### 5.2. Системные параметры + +| Параметр | Описание | Тип элемента управления | +| ------------------- | ------------------------------------------------------- | ----------------------- | +| history_maxlen | Максимальное количество записей в истории распознавания | Числовое поле | +| polling_interval_ms | Интервал опроса состояния системы в миллисекундах | Числовое поле | +| logging_level | Уровень логирования | Выпадающий список | +| log_save_file | Сохранять ли логи в файл | Переключатель | + +## 6. Требования к реализации + +### 6.1. Структура проекта + +``` +webapp/ +├── index.html # Единая страница приложения (SPA) +├── style.css # Основные стили в ретро-стилистике +├── api.js # Модуль для работы с API +└── app.js # Основной модуль приложения с логикой +``` + +### 6.2. Требования к коду + +- Модульная структура JavaScript-кода +- Использование современных возможностей JavaScript (ES6+) +- Комментирование кода +- Обработка ошибок и исключений +- Валидация вводимых данных на стороне клиента + +### 6.3. Требования к производительности + +- Быстрая загрузка страниц (оптимизация размера файлов) +- Эффективное обновление данных без перезагрузки страницы +- Оптимизация для работы на устройствах с ограниченными ресурсами + +## 7. Примеры реализации ключевых компонентов + +### 7.1. Авторизация + +```javascript +// Пример функции авторизации +async function login(username, password) { + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + throw new Error("Ошибка авторизации"); + } + + const data = await response.json(); + localStorage.setItem("token", data.access_token); + window.location.href = "index.html"; + } catch (error) { + showError(error.message); + } +} +``` + +### 7.2. Получение данных о речи + +```javascript +// Пример функции получения данных о речи +async function getSpeechData() { + try { + const token = localStorage.getItem("token"); + + if (!token) { + window.location.href = "login.html"; + return; + } + + const response = await fetch("/api/dashboard/speech-data", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.href = "login.html"; + return; + } + throw new Error("Ошибка получения данных"); + } + + const data = await response.json(); + updateSpeechDataUI(data); + } catch (error) { + showError(error.message); + } +} + +// Обновление интерфейса с полученными данными +function updateSpeechDataUI(data) { + document.getElementById("speech-prob").textContent = + (data.speech_prob * 100).toFixed(1) + "%"; + document.getElementById("energy-level").textContent = data.energy; + + // Обновление индикаторов + const speechIndicator = document.getElementById("speech-indicator"); + speechIndicator.style.width = data.speech_prob * 100 + "%"; + speechIndicator.classList.toggle("active", data.speech_detected); + + const energyIndicator = document.getElementById("energy-indicator"); + const energyPercent = Math.min( + 100, + (data.energy / data.energy_threshold) * 100 + ); + energyIndicator.style.width = energyPercent + "%"; + + // Добавление данных в график + addDataToChart(speechChart, data.speech_prob, data.energy); +} +``` + +### 7.3. Обновление настроек + +```javascript +// Пример функции обновления настроек VAD +async function updateVADSettings(settings) { + try { + const token = localStorage.getItem("token"); + + if (!token) { + window.location.href = "login.html"; + return; + } + + const response = await fetch("/api/settings/vad", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.href = "login.html"; + return; + } + throw new Error("Ошибка обновления настроек"); + } + + const data = await response.json(); + showSuccess("Настройки успешно обновлены"); + return data; + } catch (error) { + showError(error.message); + return null; + } +} +``` + +## 8. Сроки и этапы разработки + +### 8.1. Этапы разработки + +1. Разработка структуры и базовых компонентов (3 дня) +2. Реализация авторизации (2 дня) +3. Разработка панели мониторинга (5 дней) +4. Разработка страницы настроек (4 дня) +5. Тестирование и отладка (3 дня) +6. Оптимизация и финальные доработки (2 дня) + +### 8.2. Общий срок разработки + +19 рабочих дней + +## 9. Критерии приемки + +1. Корректная работа всех функциональных элементов +2. Соответствие дизайна требованиям +3. Корректное отображение на различных устройствах +4. Отсутствие критических ошибок +5. Соответствие требованиям производительности diff --git a/docs/PAGE_LAYOUTS.md b/docs/PAGE_LAYOUTS.md new file mode 100644 index 0000000..572bb56 --- /dev/null +++ b/docs/PAGE_LAYOUTS.md @@ -0,0 +1,284 @@ +# Макеты страниц веб-приложения My Speaker + +Ниже представлены псевдографические макеты страниц приложения My Speaker в стиле ретро-игры (8-bit, шрифт "Press Start 2P", зеленая гамма `#0f0` на темном фоне). Макеты созданы для альбомной ориентации десктопных экранов и адаптивной верстки для мобильных устройств. Структура главного экрана включает: + +1. **Шапка**: Верхняя панель (`header.html`) с именем пользователя и кнопкой выхода, навигационная панель (`nav.html`) с переключением разделов. +2. **Основной экран**: + - **Левая часть (Main Parameters)**: 40% ширины, постоянно отображает статус системы, индикаторы речи/энергии и системную статистику (`main-params.html`). + - **Правая часть (Контент)**: 60% ширины, динамически подгружает `graphics.html`, `settings.html`, `commands.html`. +3. **Футер**: Логи с последними распознанными фразами (`footer.html`). + +Страница авторизации (`login.html`) отображается отдельно, без шапки, навигации и футера. + +## 1. Страница авторизации (`login.html`) + +**Описание**: Экран входа в стиле игрового меню с пиксельной рамкой. Содержит форму для логина (`PLAYER NAME`) и пароля (`SECRET CODE`), кнопку `[ START ]`, сообщение об ошибке (в красном цвете) и настройки интерфейса (цвет, URL бэкенда), открываемые по кнопке `[⚙️ Settings]`. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| | +| == MY SPEAKER LOGIN == | +| | +| +-----------------------------+ | +| | PLAYER NAME: [==========] | | +| | SECRET CODE: [==========] | | +| | [ START ] | | +| +-----------------------------+ | +| [ ERROR: Invalid login ] | +| Default: admin / admin | +| | +| [⚙️ Settings] | +| +-----------------------------+ | +| | Main Color: [####] | | +| | Backend URL: [==========] | | +| +-----------------------------+ | +| | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| | +| == MY SPEAKER LOGIN == +| | +| +-----------------+ | +| | PLAYER NAME: | | +| | [============] | | +| | SECRET CODE: | | +| | [============] | | +| | [ START ] | | +| +-----------------+ | +| [ ERROR: Invalid ] | +| Default: admin/admin| +| | +| [⚙️ Settings] | +| +-----------------+ | +| | Main Color: [##]| | +| | Backend: [====] | | +| +-----------------+ | +| | ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Форма центрирована, занимает ~30-40% ширины для альбомной ориентации. Пиксельная рамка (`+---+`) и ретро-стиль. +- **Мобильный**: Форма растягивается на всю ширину, поля ввода и кнопка увеличены. +- **Адаптивность**: Элементы масштабируются, текст остается читаемым. + +## 2. Главный экран с `graphics.html` + +**Описание**: Главный экран с `graphics.html` в правой части (60%). Левая часть (40%) показывает статус системы, индикаторы речи/энергии и статистику (`main-params.html`). Футер отображает последние фразы (`footer.html`). + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | SPEACH | +| | | | | +| | Status: Listening | | ^ +| | [============] 85% | | * | +| | | | | * | +| | Speech Prob: [===] | | | * | +| | Energy: [==] | | +-------------------+ +| | | | Time | +| | CPU: 25% | | | +| | MEM: 40% | | | +| | TEMP: 45°C | | | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == GRAPHICS == | +| ^ * | +| | * | +| | * | +| | * | +| +-----------------+ +| Time | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Шапка и навигация во всю ширину. Левая часть (40%) с индикаторами и статистикой, правая (60%) с линейным графиком (`speech_prob`, `energy`). Футер с фразами. +- **Мобильный**: Карточки располагаются вертикально, график уменьшен. +- **Адаптивность**: Альбомная ориентация на десктопе использует ширину экрана, на мобильных — вертикальная компоновка. + +## 3. Главный экран с `settings.html` + +**Описание**: Правая часть (60%) подгружает `settings.html` с раскрывающимися формами для настройки VAD, системных параметров и т.д. Левая часть и футер неизменны. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | SETTINGS | +| | | | | +| | Status: Listening | | + VAD Settings v | +| | [============] 85% | | +---------------------+ | +| | | | | Mode: [0-3] | | +| | Speech Prob: [===] | | | Speech Thr: [====] | | +| | Energy: [==] | | | Energy Thr: [####] | | +| | | | | [ SAVE ] | | +| | CPU: 25% | | +---------------------+ | +| | MEM: 40% | | - System Settings | +| | TEMP: 45°C | | [ REBOOT ] | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == SETTINGS == | +| + VAD Settings v | +| +-----------------+ | +| | Mode: [0-3] | | +| | Speech Thr: [==]| | +| | Energy Thr: [##]| | +| | [ SAVE ] | | +| +-----------------+ | +| - System Settings | +| [ REBOOT ] | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Правая часть показывает формы (`

`), например, VAD с выпадающим списком и ползунком. Кнопка `[ REBOOT ]` внизу. +- **Мобильный**: Формы растягиваются на всю ширину, поля ввода увеличены. +- **Адаптивность**: Элементы управления масштабируются, формы сворачиваются/разворачиваются. + +## 4. Главный экран с `commands.html` + +**Описание**: Правая часть (60%) подгружает `commands.html` с минимальной реализацией (путь к файлу команд и список). Левая часть и футер неизменны. + +### Десктопная версия + +``` ++--------------------------------------------------+ +| User: admin [ Logout ] | ++--------------------------------------------------+ +| [ Dashboard | Settings | Commands ] | ++--------------------------------------------------+ +| +--------------------+ +--------------------------+ +| | MAIN PARAMETERS | | COMMANDS | +| | | | | +| | Status: Listening | | Commands File: [file] | +| | [============] 85% | | [ EDIT ] [ SAVE ] | +| | | | | +| | Speech Prob: [===] | | "Turn on light" | +| | Energy: [==] | | "Play music" | +| | | | | +| | CPU: 25% | | | +| | MEM: 40% | | | +| | TEMP: 45°C | | | +| +--------------------+ +--------------------------+ ++--------------------------------------------------+ +| == RECENT PHRASES == | +| +---------------------------------------------+ | +| | "Turn on light" "What's the weather" | | +| +---------------------------------------------+ | ++--------------------------------------------------+ +``` + +### Мобильная версия + +``` ++---------------------+ +| User: admin [Logout]| ++---------------------+ +| [Dash|Sett|Commands]| ++---------------------+ +| == MAIN PARAMETERS == +| Status: Listening | +| [===========] 85% | +| Speech Prob: [====] | +| Energy: [==] 1200 | +| CPU: 25% | +| MEM: 40% | +| TEMP: 45°C | +| == COMMANDS == | +| Commands File: [f] | +| [ EDIT ] [ SAVE ] | +| "Turn on light" | +| "Play music" | +| == RECENT PHRASES == +| "Turn on light" | +| "What's the weather| ++---------------------+ +``` + +**Пояснения**: + +- **Десктоп**: Правая часть показывает поле для `commands_file` и список команд. Кнопки `[ EDIT ]` и `[ SAVE ]` для редактирования. +- **Мобильный**: Элементы растягиваются на всю ширину, список команд вертикальный. +- **Адаптивность**: Компактное расположение, текст читаемый. + +## Пояснения к макетам + +- **Ретро-стиль**: Используются символы `+`, `=`, `*` для пиксельных рамок и индикаторов, отражая 8-bit эстетику и зеленую гамму (`#0f0`). +- **Альбомная ориентация**: Десктопные макеты оптимизированы для широкого экрана, с разделением на 40% (Main Parameters) и 60% (контент). +- **Адаптивность**: Мобильные макеты используют вертикальное расположение, избегая горизонтальной прокрутки, как указано в `FRONTEND_REQUIREMENTS.md`. +- **Структура**: Шапка, основной экран (две карточки) и футер соответствуют запросу. Страница авторизации отдельная. +- **Функциональность**: Макеты включают все элементы из требований (индикаторы, графики, формы, логи). diff --git a/docs/config.yaml.md b/docs/config.yaml.md new file mode 100644 index 0000000..140ffb8 --- /dev/null +++ b/docs/config.yaml.md @@ -0,0 +1,86 @@ +# Документация по конфигурационному файлу config.yaml + +## Секция system +Основные настройки системы + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| history_maxlen | Максимальное количество записей в истории распознавания | Целое число > 0 (рекомендуется: 50-1000) | +| polling_interval_ms | Интервал опроса состояния системы в миллисекундах | Целое число > 100 (рекомендуется: 1000-5000) | +| logging_level | Уровень логирования | INFO, DEBUG, WARNING, ERROR, CRITICAL | +| log_file | Путь к файлу логов | Строка, путь к файлу | +| log_save_file | Сохранять ли логи в файл | true/false | + +## Секция vad +Настройки системы определения голосовой активности (Voice Activity Detection) + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| mode | Режим работы VAD (агрессивность фильтрации) | 0 (наименее агрессивный) - 3 (самый агрессивный) | +| sample_rate | Частота дискретизации аудио в герцах | 8000, 16000, 32000, 48000 | +| frame_duration_ms | Длительность фрейма в миллисекундах | 10, 20, 30 | +| ring_buffer_size | Размер кольцевого буфера для фреймов | Целое число > 0 (рекомендуется: 10-30) | +| speech_threshold | Порог вероятности речи | 0.0 - 1.0 (рекомендуется: 0.5-0.9) | +| energy_threshold | Порог энергии звука | Целое число > 0 (рекомендуется: 300-1000) | +| min_speech_duration_ms | Минимальная длительность речи в мс | Целое число > 0 (рекомендуется: 100-500) | +| record_stop_delay_sec | Задержка остановки записи после окончания речи в сек | Число > 0.0 (рекомендуется: 0.5-2.0) | +| prebuffer_duration_sec | Длительность предварительного буфера в сек | Число > 0.0 (рекомендуется: 0.5-2.0) | +| max_record_duration_sec | Максимальная длительность записи в сек | Число > 0.0 (рекомендуется: 5.0-30.0) | +| target_rms | Целевое среднеквадратичное значение громкости | Число > 0.0 (рекомендуется: 1000-3000) | +| max_gain | Максимальное усиление звука | Число > 0.0 (рекомендуется: 1.0-10.0) | +| noise_reduction_factor | Коэффициент подавления шума | Число > 1.0 (рекомендуется: 1.2-2.0) | +| noise_update_alpha | Коэффициент обновления оценки шума | 0.0 - 1.0 (рекомендуется: 0.01-0.1) | +| min_rms_threshold_factor | Минимальный коэффициент порога RMS | Число > 1.0 (рекомендуется: 1.5-3.0) | +| gain_smoothing_alpha | Коэффициент сглаживания усиления | 0.0 - 1.0 (рекомендуется: 0.05-0.2) | +| adaptive_energy_std_factor | Коэффициент стандартного отклонения для адаптивной энергии | Число > 0.0 (рекомендуется: 1.0-2.0) | +| adaptive_energy | Использовать ли адаптивный порог энергии | true/false | +| noise_reduction_enabled | Включить подавление шума | true/false | +| auto_gain_enabled | Включить автоматическую регулировку усиления | true/false | +| record_on_speech | Записывать ли аудио при обнаружении речи | true/false | + +## Секция stt +Настройки системы распознавания речи (Speech-to-Text) + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| model_path | Путь к модели Vosk | Строка, абсолютный путь к директории с моделью | + +## Секция commands +Настройки системы команд + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| commands_file | Путь к файлу с определениями команд | Строка, путь к YAML-файлу | + +## Секция auth +Настройки аутентификации + +| Параметр | Описание | Возможные значения | +|----------|----------|-------------------| +| username | Имя пользователя для входа | Строка | +| password | Пароль для входа | Строка | + +## Пример использования + +```yaml +system: + history_maxlen: 50 + polling_interval_ms: 1000 + logging_level: INFO + log_file: logs/app.log + log_save_file: false + +vad: + mode: 3 + sample_rate: 16000 + frame_duration_ms: 30 + speech_threshold: 0.7 + energy_threshold: 400 + +stt: + model_path: /path/to/vosk/model + +auth: + username: admin + password: your_secure_password +``` diff --git a/docs/project_structure.md b/docs/project_structure.md new file mode 100644 index 0000000..215e66d --- /dev/null +++ b/docs/project_structure.md @@ -0,0 +1,64 @@ +# Файловая структура проекта My Speaker + +Ниже представлена скорректированная файловая структура проекта веб-приложения My Speaker. Корректировки включают: +- Добавление `main-params.html` в папку `components/` для постоянной левой части (Main Parameters) на главном экране. +- Добавление `footer.html` в папку `components/` для футера с логами. +- Добавление `graphics.html` в папку `pages/` для правой части с графиками (как указано в макетах). +- Обновление модулей JavaScript для соответствия новым компонентам (например, добавление `graphics.js` для логики графиков). +- Уточнение папок для лучшей организации: `pages/` для динамически подгружаемых правых частей, `components/` для общих элементов (шапка, навигация, Main Parameters, футер). +- Обеспечение модульности в соответствии с `FRONTEND_REQUIREMENTS.md`: использование чистого JavaScript (ES6+), без фреймворков. +- Учёт ретро-стиля и адаптивности в `style.css`. + +Структура проекта оптимизирована для SPA (Single Page Application), где `index.html` служит точкой входа, а контент подгружается динамически через `router.js`. + +## Полная структура + +``` +webapp/ +├── index.html # Основная страница приложения (SPA): содержит контейнеры для шапки, основного экрана и футера +├── style.css # Основные стили в ретро-стилистике (пиксельные рамки, шрифт "Press Start 2P", адаптивность) +├── api.js # Модуль для работы с API (авторизация, получение данных, обновление настроек) +├── app.js # Точка входа: инициализация приложения, подключение модулей и запуск маршрутизации +├── modules/ # Папка для JavaScript-модулей (логика страниц и компонентов) +│ ├── router.js # Маршрутизация: динамическая загрузка HTML в правую часть основного экрана +│ ├── login.js # Логика страницы авторизации (обработка формы, настройки интерфейса) +│ ├── dashboard.js # Логика главного экрана: инициализация Main Parameters, футера и общей структуры +│ ├── graphics.js # Логика для `graphics.html` (инициализация и обновление графиков с использованием Chart.js или Canvas) +│ ├── settings.js # Логика для `settings.html` (формы настроек, валидация, отправка на API) +│ ├── commands.js # Логика для `commands.html` (список команд, редактирование файла команд) +│ ├── header.js # Логика для `header.html` (имя пользователя, кнопка выхода, запрос на /api/auth/me) +│ ├── nav.js # Логика для `nav.html` (переключение разделов, подсветка активного) +│ └── utils.js # Общие утилиты (обработка ошибок, валидация форм, форматирование данных, DOM-манипуляции) +├── pages/ # Папка для динамически подгружаемых HTML-страниц (правая часть основного экрана) +│ ├── login.html # Разметка страницы авторизации (форма входа, настройки интерфейса) +│ ├── graphics.html # Разметка для графиков (canvas для speech_prob и energy) +│ ├── settings.html # Разметка для настроек (раскрывающиеся формы с
, поля ввода, кнопки сохранения) +│ └── commands.html # Разметка для команд (поле для commands_file, список команд, кнопки редактирования) +└── components/ # Папка для общих HTML-компонентов (шапка, навигация, левая часть, футер) + ├── header.html # Разметка верхней панели (имя пользователя, кнопка выхода) + ├── nav.html # Разметка навигационной панели (кнопки Dashboard, Settings, Commands) + ├── main-params.html# Разметка левой части (Main Parameters: статус, индикаторы, статистика CPU/MEM/TEMP) + └── footer.html # Разметка футера (список последних фраз из recent-phrases) + +``` + +## Объяснение корректировок +- **Добавления**: + - `graphics.html` и `graphics.js` — для отдельного раздела с графиками, как в макетах PAGE_LAYOUTS.md. + - `main-params.html` — для постоянной левой части (40%), чтобы избежать дублирования разметки в каждом разделе. + - `footer.html` — для футера с логами, чтобы сделать его переиспользуемым и легко обновляемым. +- **Изменения**: + - `dashboard.js` теперь отвечает за инициализацию всего главного экрана (загрузка `main-params.html`, футера и динамической правой части). + - `pages/login.html` — остается отдельной, но теперь явно отделена от динамических страниц главного экрана. + - Убраны ненужные файлы (если были ранее); структура оптимизирована для модульности (каждый модуль экспортирует функции для ES6 import). +- **Соответствие требованиям**: + - Структура соответствует `FRONTEND_REQUIREMENTS.md`: модульный JS-код, отдельные файлы для логики (app.js, api.js), стили в style.css. + - Поддержка адаптивности: в style.css используются медиа-запросы для мобильных устройств (вертикальное расположение карточек). + - Масштабируемость: Легко добавить новые страницы (например, `analytics.html` и `analytics.js`) в `pages/` и зарегистрировать в `router.js`. +- **Рекомендации по реализации**: + - В `index.html` добавить контейнеры: ``, ``, `
`, `
`. + - В `router.js` загружать контент в `#content` на основе маршрута (например, `#/graphics` → `graphics.html`). + - В `utils.js` добавить функции для показа ошибок в ретро-стиле (красный текст на темном фоне). + - Для графиков в `graphics.js` использовать Chart.js (подключить через CDN в `index.html`) или Canvas API. + +Эта структура готова к реализации. Если нужны дополнительные корректировки (например, добавление папок для ассетов или тестов), уточните. \ No newline at end of file diff --git a/piper_TTS.py b/piper_TTS.py new file mode 100644 index 0000000..313c037 --- /dev/null +++ b/piper_TTS.py @@ -0,0 +1,362 @@ +import socket +import json +import time +import wave +import sys +import select +import os +import logging +import pyaudio +import io +from app.core.config import get_settings + +# Получаем настройки из конфигурации +config = get_settings() +tts_config = config.tts +audio_settings = config.audio_settings + +# Настройки из конфигурации +HOST = tts_config.host +PORT = tts_config.port +MAX_WAIT = tts_config.timeout +PAUSE_DURATION = tts_config.pause_duration +POST_STOP_WAIT = tts_config.post_stop_wait +SAVE_TTS_FILES = tts_config.save_TTS_files +PLAYBACK_DEVICE = audio_settings.playback_device + +# Директория для сохранения TTS файлов +TTS_SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings", "TTS") +if not os.path.exists(TTS_SAVE_DIR): + os.makedirs(TTS_SAVE_DIR, exist_ok=True) + +# Список доступных голосов +VOICES = [ + "ru_RU-denis-medium", + "ru_RU-irina-medium", + "ru_RU-dmitri-medium", + "ru_RU-ruslan-medium" +] +DEFAULT_VOICE = tts_config.voice + +# Глобальные переменные для аудиопотока +PAUDIO = None +AUDIO_STREAM = None +DEFAULT_AUDIO_PARAMS = {"rate": 22050, "width": 2, "channels": 1} + +# Используем логгер из основного приложения +logger = logging.getLogger('VADApp') + +def initialize_audio(device_index=PLAYBACK_DEVICE): + """Инициализирует аудиоустройство и воспроизводит Hello.wav.""" + global PAUDIO, AUDIO_STREAM + try: + PAUDIO = pyaudio.PyAudio() + logger.info("Доступные аудиоустройства:") + for i in range(PAUDIO.get_device_count()): + device_info = PAUDIO.get_device_info_by_index(i) + logger.info("Устройство %d: %s (выходные каналы: %d)", i, device_info['name'], device_info['maxOutputChannels']) + + AUDIO_STREAM = PAUDIO.open( + format=PAUDIO.get_format_from_width(DEFAULT_AUDIO_PARAMS["width"]), + channels=DEFAULT_AUDIO_PARAMS["channels"], + rate=DEFAULT_AUDIO_PARAMS["rate"], + output=True, + output_device_index=device_index, + frames_per_buffer=4096 + ) + logger.info("Открыт аудиопоток: частота=%d, ширина=%d, каналы=%d, устройство=%d", + DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], + DEFAULT_AUDIO_PARAMS["channels"], device_index) + + # Воспроизведение Hello.wav + try: + with wave.open("Hello.wav", 'rb') as wf: + if (wf.getframerate() != DEFAULT_AUDIO_PARAMS["rate"] or + wf.getsampwidth() != DEFAULT_AUDIO_PARAMS["width"] or + wf.getnchannels() != DEFAULT_AUDIO_PARAMS["channels"]): + logger.warning("Параметры Hello.wav не соответствуют: частота=%d, ширина=%d, каналы=%d", + wf.getframerate(), wf.getsampwidth(), wf.getnchannels()) + else: + logger.info("Воспроизведение Hello.wav") + data = wf.readframes(1024) + while data: + AUDIO_STREAM.write(data) + data = wf.readframes(1024) + # Ожидание завершения воспроизведения + while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: + time.sleep(0.01) + logger.info("Завершено воспроизведение Hello.wav") + except FileNotFoundError: + logger.error("Файл Hello.wav не найден") + except Exception as e: + logger.error("Ошибка воспроизведения Hello.wav: %s", e) + + except Exception as e: + logger.error("Не удалось инициализировать аудио: %s", e) + if AUDIO_STREAM: + AUDIO_STREAM.stop_stream() + AUDIO_STREAM.close() + AUDIO_STREAM = None + if PAUDIO: + PAUDIO.terminate() + PAUDIO = None + +def close_audio(): + """Закрывает аудиопоток и PyAudio.""" + global PAUDIO, AUDIO_STREAM + if AUDIO_STREAM: + try: + AUDIO_STREAM.stop_stream() + AUDIO_STREAM.close() + logger.debug("Аудиопоток закрыт") + except Exception as e: + logger.error("Ошибка закрытия аудиопотока: %s", e) + AUDIO_STREAM = None + if PAUDIO: + try: + PAUDIO.terminate() + logger.debug("PyAudio завершен") + except Exception as e: + logger.error("Ошибка завершения PyAudio: %s", e) + PAUDIO = None + +def play_audio_stream(stream, audio_data): + """Воспроизводит аудиоданные через существующий аудиопоток. + + Args: + stream: Открытый аудиопоток PyAudio. + audio_data: Бинарные аудиоданные для воспроизведения. + """ + if not audio_data: + logger.warning("Пустые аудиоданные, пропуск воспроизведения") + return + try: + stream.write(audio_data) + logger.debug("Воспроизведен аудиофрагмент: %d байт", len(audio_data)) + except Exception as e: + logger.error("Ошибка воспроизведения: %s", e) + +def send_msg(sock, typ, data_obj=None, payload=b""): + """Отправляет сообщение в формате Wyoming Protocol.""" + try: + data_bytes = json.dumps(data_obj, ensure_ascii=False).encode('utf-8') if data_obj else b'' + header = {"type": typ, "data_length": len(data_bytes), "payload_length": len(payload)} + sock.sendall(json.dumps(header, separators=(',', ':')).encode('utf-8') + b'\n') + if data_bytes: + sock.sendall(data_bytes) + if payload: + sock.sendall(payload) + logger.debug("Отправлено: %s (длина_данных=%d, длина_полезной_нагрузки=%d)", typ, len(data_bytes), len(payload)) + except socket.error as e: + logger.error("Ошибка отправки сообщения: %s", e) + raise + +def recv_into_buffer(sock, buf, timeout): + """Читает данные из сокета в буфер.""" + r, _, _ = select.select([sock], [], [], timeout) + if not r: + return 0 # Нет данных, но не ошибка + data = sock.recv(8192) + if not data: + return -1 # EOF + buf.extend(data) + logger.debug("Получено %d байт (размер буфера: %d)", len(data), len(buf)) + return len(data) + +def pop_line_from_buffer(buf): + """Извлекает JSON-линию из буфера.""" + idx = buf.find(b'\n') + if idx == -1: + return None + line = bytes(buf[:idx]) + del buf[:idx+1] + try: + return line.decode('utf-8') + except UnicodeDecodeError as e: + logger.warning("Ошибка декодирования UTF-8: %s", e) + return None + +def read_exact_from_buffer(sock, buf, n, deadline): + """Читает ровно n байт из буфера.""" + while len(buf) < n: + timeout = max(0.0, deadline - time.time()) + if timeout <= 0: + raise TimeoutError(f"Таймаут чтения (требуется %d, получено %d)", n, len(buf)) + recv_len = recv_into_buffer(sock, buf, timeout) + if recv_len == -1: + raise ConnectionError("Соединение закрыто сервером") + data = bytes(buf[:n]) + del buf[:n] + return data + +def add_pause(audio_buf, rate, width=2, duration=PAUSE_DURATION): + """Добавляет паузу (нулевые байты) в аудио буфер.""" + samples = int(rate * duration) + bytes_per_sample = width + pause_bytes = b'\x00' * (samples * bytes_per_sample) + audio_buf.extend(pause_bytes) + logger.debug("Добавлена пауза: %d байт (%s с при %d Гц, ширина=%d)", len(pause_bytes), duration, rate, width) + +def get_next_filename(prefix="tts_"): + """Получает уникальное имя файла для сохранения TTS вывода.""" + os.makedirs(TTS_SAVE_DIR, exist_ok=True) + i = 0 + while True: + filename = os.path.join(TTS_SAVE_DIR, f"{prefix}{i:03d}.wav") + if not os.path.exists(filename): + return filename + i += 1 + +def synthesize_stream(text, voice_name=None, out_filename="out.wav"): + """Отправляет текст на сервер Piper TTS, сохраняет и воспроизводит аудио.""" + sock = socket.create_connection((HOST, PORT), timeout=10) + sock.settimeout(None) + buf = bytearray() + try: + # Отправляем потоковый запрос + send_msg(sock, "synthesize-start", {"voice": {"name": voice_name}} if voice_name else {}) + send_msg(sock, "synthesize-chunk", {"text": text}) + send_msg(sock, "synthesize", {"text": text}) # Для обратной совместимости + send_msg(sock, "synthesize-stop", None) + + audio_buf = bytearray() + meta = {"rate": 22050, "width": 2, "channels": 1} + deadline = time.time() + MAX_WAIT + chunk_count = 0 + last_activity = time.time() + + while time.time() < deadline: + line = pop_line_from_buffer(buf) + if line is None: + recv_len = recv_into_buffer(sock, buf, 0.1) + if recv_len == 0: + if time.time() - last_activity > POST_STOP_WAIT: + logger.debug("Больше нет данных, завершение") + break + continue + elif recv_len == -1: + logger.debug("Соединение закрыто сервером") + break + last_activity = time.time() + continue + + last_activity = time.time() + logger.debug("Заголовок сервера: %s", line) + try: + hdr = json.loads(line) + except json.JSONDecodeError as e: + logger.warning("Ошибка разбора JSON: %s", e) + continue + + typ = hdr.get("type") + dl = int(hdr.get("data_length", 0)) + pl = int(hdr.get("payload_length", 0)) + + data_json = None + if dl: + try: + data_bytes = read_exact_from_buffer(sock, buf, dl, deadline) + data_json = json.loads(data_bytes.decode('utf-8')) + logger.debug("Данные: %s", data_json) + except Exception as e: + logger.warning("Ошибка чтения данных: %s", e) + data_json = None + + if typ == "audio-start" and data_json: + meta["rate"] = int(data_json.get("rate", meta["rate"])) + meta["width"] = int(data_json.get("width", meta["width"])) + meta["channels"] = int(data_json.get("channels", meta["channels"])) + logger.info("Получены аудио метаданные: частота=%d, ширина=%d, каналы=%d", + meta["rate"], meta["width"], meta["channels"]) + # Проверка совместимости параметров с открытым потоком + if AUDIO_STREAM: + if (meta["rate"] != DEFAULT_AUDIO_PARAMS["rate"] or + meta["width"] != DEFAULT_AUDIO_PARAMS["width"] or + meta["channels"] != DEFAULT_AUDIO_PARAMS["channels"]): + logger.warning("Несоответствие аудио параметров: ожидалось частота=%d, ширина=%d, каналы=%d; получено частота=%d, ширина=%d, каналы=%d", + DEFAULT_AUDIO_PARAMS["rate"], DEFAULT_AUDIO_PARAMS["width"], + DEFAULT_AUDIO_PARAMS["channels"], meta["rate"], meta["width"], meta["channels"]) + return None # Прерываем, так как поток несовместим + + if pl: + try: + chunk = read_exact_from_buffer(sock, buf, pl, deadline) + audio_buf.extend(chunk) + chunk_count += 1 + logger.debug("Получен аудио фрагмент %d (%d байт, всего %d)", chunk_count, len(chunk), len(audio_buf)) + if AUDIO_STREAM: + play_audio_stream(AUDIO_STREAM, chunk) + except Exception as e: + logger.warning("Ошибка чтения полезной нагрузки: %s", e) + + if typ == "audio-stop": + logger.debug("Audio-stop: добавление паузы между предложениями") + add_pause(audio_buf, meta["rate"], meta["width"]) + post_deadline = time.time() + POST_STOP_WAIT + while time.time() < post_deadline: + recv_len = recv_into_buffer(sock, buf, 0.1) + if recv_len == 0: + continue + elif recv_len == -1: + break + break + last_activity = time.time() + + elif typ == "error": + logger.error("Ошибка сервера: %s", data_json) + break + + send_msg(sock, "synthesize-stopped", None) + + # Ожидание завершения воспроизведения + if AUDIO_STREAM: + try: + while AUDIO_STREAM.get_write_available() < AUDIO_STREAM._frames_per_buffer: + time.sleep(0.01) + logger.debug("Буфер воспроизведения очищен") + except Exception as e: + logger.error("Ошибка очистки буфера воспроизведения: %s", e) + + if audio_buf and SAVE_TTS_FILES: + try: + with wave.open(out_filename, "wb") as wf: + wf.setnchannels(int(meta["channels"])) + wf.setsampwidth(int(meta["width"])) + wf.setframerate(int(meta["rate"])) + wf.writeframes(bytes(audio_buf)) + logger.info("Сохранено: %s (всего аудио байт: %d, фрагментов: %d)", out_filename, len(audio_buf), chunk_count) + return out_filename + except Exception as e: + logger.error("Не удалось сохранить аудио: %s", e) + return None + else: + logger.debug("Аудио не сохранено (SAVE_TTS_FILES=%s)", SAVE_TTS_FILES) + return None + + except Exception as e: + logger.error("Error: %s", e) + return None + + finally: + sock.close() + +def synthesize_text(text, voice_name=None, out_filename=None): + """ + Синтезирует речь из текста, воспроизводит и возвращает путь к аудиофайлу. + + Args: + text (str): Текст для синтеза + voice_name (str, optional): Имя голоса. По умолчанию используется голос из конфигурации. + out_filename (str, optional): Имя выходного файла. По умолчанию генерируется автоматически. + + Returns: + str: Путь к созданному аудиофайлу или None в случае ошибки + """ + if not voice_name: + voice_name = DEFAULT_VOICE + + if not out_filename: + out_filename = get_next_filename() + + logger.info(f"Синтезирую текст: '{text}' с голосом {voice_name} в файл {out_filename}") + return synthesize_stream(text, voice_name=voice_name, out_filename=out_filename) \ No newline at end of file diff --git a/plugins.py b/plugins.py new file mode 100644 index 0000000..bd7c451 --- /dev/null +++ b/plugins.py @@ -0,0 +1,19 @@ +# plugins.py +import logging + +def speak_plugins(): + """ + Обработчик для команды "в разработке" + + Returns: + dict: Результат выполнения команды + """ + logger = logging.getLogger('VADApp') + logger.info("Вызвана команда 'в разработке'") + + return { + "status": "success", + "message": "в разработке", + "action": "speak", + "text": "Эта функция находится в разработке" + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2dbe9b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +python-multipart==0.0.6 +ruamel.yaml==0.18.5 +webrtcvad==2.0.10 +PyAudio==0.2.14 +numpy==1.26.3 \ No newline at end of file diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..53cd61b --- /dev/null +++ b/run_server.py @@ -0,0 +1,33 @@ +# run_server.py +import uvicorn +import logging +from app import create_app +from app.core.config import get_settings +from volume import set_volume # Импортируем функцию установки громкости + +# Получение настроек +config = get_settings() + +# Настройка логирования +log_handlers = [logging.StreamHandler()] +if config.system.log_save_file: + log_handlers.append(logging.FileHandler(config.system.log_file, encoding='utf-8')) + +logging.basicConfig( + level=getattr(logging, config.system.logging_level.upper(), logging.INFO), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=log_handlers +) + +# Инициализация громкости устройств теперь выполняется в app/core/vad.py + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "run_server:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="warning" + ) diff --git a/session_manager.py b/session_manager.py new file mode 100644 index 0000000..97e8478 --- /dev/null +++ b/session_manager.py @@ -0,0 +1,169 @@ +# session_manager.py +import time +import logging +import threading +from datetime import datetime + +class SessionManager: + """Класс для управления сессией голосового помощника""" + + def __init__(self, timeout_seconds=30): + """ + Инициализация менеджера сессий + + Args: + timeout_seconds (int): Время таймаута сессии в секундах + """ + self.logger = logging.getLogger('VADApp') + self.timeout_seconds = timeout_seconds + self.session_active = False + self.session_paused = False + self.pause_start_time = None + self.session_start_time = None + self.session_end_time = None + self.last_reset_time = None + self.timer = None + self.lock = threading.Lock() + self.logger.info(f"SessionManager инициализирован с таймаутом {timeout_seconds} секунд") + + def start_session(self): + """Начать новую сессию""" + with self.lock: + if self.session_active: + self.logger.info("Сессия уже активна, сбрасываем таймер") + self.last_reset_time = datetime.now() + self._reset_timer() + else: + self.session_active = True + self.session_start_time = datetime.now() + self.last_reset_time = self.session_start_time + self.session_end_time = None + self._start_timer() + self.logger.info(f"Сессия начата в {self.session_start_time}") + + def end_session(self): + """Завершить текущую сессию""" + with self.lock: + if self.session_active: + self.session_active = False + self.session_paused = False + self.pause_start_time = None + self.session_end_time = datetime.now() + if self.timer: + self.timer.cancel() + self.timer = None + self.logger.info(f"Сессия завершена в {self.session_end_time}") + return True + return False + + def pause_session(self): + """Поставить текущую сессию на паузу""" + with self.lock: + if self.session_active and not self.session_paused: + self.session_paused = True + self.pause_start_time = datetime.now() + # Останавливаем таймер на время паузы + if self.timer: + self.timer.cancel() + self.timer = None + self.logger.info(f"Сессия поставлена на паузу в {self.pause_start_time}") + return True + return False + + def resume_session(self): + """Возобновить сессию после паузы""" + with self.lock: + if self.session_active and self.session_paused: + pause_duration = 0 + if self.pause_start_time: + # Вычисляем длительность паузы + pause_duration = (datetime.now() - self.pause_start_time).total_seconds() + + # Обновляем время последнего сброса с учетом паузы + if self.last_reset_time: + self.last_reset_time = datetime.now() + + self.session_paused = False + self.pause_start_time = None + + # Запускаем таймер заново + self._start_timer() + + self.logger.info(f"Сессия возобновлена после паузы длительностью {pause_duration:.1f} секунд") + return True + return False + + def reset_timer(self): + """Сбросить таймер сессии""" + with self.lock: + if self.session_active: + self.last_reset_time = datetime.now() + self._reset_timer() + self.logger.info("Таймер сессии сброшен") + return True + return False + + def _start_timer(self): + """Запустить таймер сессии""" + if self.timer: + self.timer.cancel() + self.timer = None + self.timer = threading.Timer(self.timeout_seconds, self._timeout_callback) + self.timer.daemon = True + self.timer.start() + + def _reset_timer(self): + """Сбросить и перезапустить таймер сессии""" + self._start_timer() + + def _timeout_callback(self): + """Обработчик истечения таймера сессии""" + with self.lock: + if self.session_active: + self.session_active = False + self.session_end_time = datetime.now() + self.logger.info(f"Сессия завершена по таймауту в {self.session_end_time}") + + def is_session_active(self): + """Проверить, активна ли сессия""" + with self.lock: + return self.session_active + + def get_session_status(self): + """Получить статус сессии""" + with self.lock: + if not self.session_active: + return { + "active": False, + "paused": False, + "time_left": 0, + "start_time": self.session_start_time.isoformat() if self.session_start_time else None, + "end_time": self.session_end_time.isoformat() if self.session_end_time else None + } + + # Вычисляем оставшееся время + # Используем время последнего сброса таймера, если оно есть + reference_time = self.last_reset_time if self.last_reset_time else self.session_start_time + + # Если сессия на паузе, не вычисляем оставшееся время + if self.session_paused: + time_left = self.timeout_seconds + if reference_time and self.pause_start_time: + # Вычисляем время, прошедшее до паузы + elapsed_before_pause = (self.pause_start_time - reference_time).total_seconds() + time_left = max(0, self.timeout_seconds - elapsed_before_pause) + else: + if reference_time: + elapsed = (datetime.now() - reference_time).total_seconds() + time_left = max(0, self.timeout_seconds - elapsed) + else: + time_left = 0 + + return { + "active": True, + "paused": self.session_paused, + "time_left": time_left, + "start_time": self.session_start_time.isoformat() if self.session_start_time else None, + "pause_time": self.pause_start_time.isoformat() if self.pause_start_time else None, + "end_time": None + } diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c5ceb91 --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +/home/sirmrak/vad_env/bin/python run_server.py \ No newline at end of file diff --git a/vad_core.py b/vad_core.py new file mode 100644 index 0000000..c37e4c0 --- /dev/null +++ b/vad_core.py @@ -0,0 +1,439 @@ +# vad_core.py +import pyaudio +import webrtcvad # type: ignore +import collections +import threading +import wave +import time +import os +import struct +import numpy as np +import logging +import sys +from datetime import datetime +from app.core.status import status_tracker, SystemStatus + +class VADPolling: + def __init__(self, config, on_speech_end_callback=None): + self.config = config + self.vad = webrtcvad.Vad() + self.vad.set_mode(config['mode']) + self.ring_buffer = collections.deque(maxlen=config['ring_buffer_size']) + self.history = collections.deque(maxlen=config.get('history_maxlen', 100)) + self.lock = threading.Lock() + + # Параметры шумоподавления и автоусиления + self.noise_reduction_enabled = config.get('noise_reduction_enabled', True) + self.noise_floor_buffer = collections.deque(maxlen=50) # Буфер для оценки уровня шума + self.noise_floor = 0.0 + self.auto_gain_enabled = config.get('auto_gain_enabled', True) + self.target_rms = config.get('target_rms', 2000.0) # Целевой уровень сигнала + self.current_gain = 1.0 + + self.frame_size = int(config['sample_rate'] * config['frame_duration_ms'] / 1000) + + # 🔹 Размер предбуфера (в фреймах) + self.prebuffer_size = int(config.get('prebuffer_duration_sec', 1.0) * config['sample_rate'] / self.frame_size) + self.prebuffer = collections.deque(maxlen=self.prebuffer_size) + + # 🔹 Основной буфер записи (используется только во время записи) + self.recording_buffer = [] + + # 🔹 Максимальная длина записи + self.max_record_duration_sec = config.get('max_record_duration_sec', 5.0) + self.max_record_frames = int(self.max_record_duration_sec * config['sample_rate'] / self.frame_size) + + # 🔹 Фильтрация по энергии и длительности + self.energy_threshold = config.get('energy_threshold', 1000) + self.min_speech_duration_ms = config.get('min_speech_duration_ms', 300) + self.adaptive_energy = config.get('adaptive_energy', True) + + # 🔹 Адаптивный порог энергии + self.background_energy_buffer = collections.deque(maxlen=100) + self.current_energy_threshold = self.energy_threshold + self.adaptive_energy_std_factor = config.get('adaptive_energy_std_factor', 2.5) # Множитель для стандартного отклонения + + # 🔹 Счетчики для фильтрации + self.speech_start_time = None + self.continuous_speech_frames = 0 + self.min_speech_frames = int(self.min_speech_duration_ms / config['frame_duration_ms']) + + # 🔹 Хранение последнего состояния + self.last_result = { + 'speech_ratio': 0.0, + 'speech_detected': False, + 'energy': 0.0, + 'energy_threshold': float(self.current_energy_threshold), + 'speech_threshold': float(self.config['speech_threshold']), + 'continuous_frames': 0, + + } + + # 🔹 Состояния записи + self.last_speech_detected = False + self.prev_speech_detected = False + self.is_recording = False + + # 🔹 Настройка записи + self.record_enabled = config.get('record_on_speech', False) + self.record_stop_delay_sec = config.get('record_stop_delay_sec', 1.0) + self.output_filename = "recordings/VAD/debug_recording.wav" + + # 🔹 Таймер остановки записи + self.stop_timer = None + + # 🔹 Callback для обработки речи после завершения записи + self.on_speech_end_callback = on_speech_end_callback + + # 🔹 Удаляем старый файл при запуске, если запись включена + if self.record_enabled: + os.makedirs("recordings", exist_ok=True) + if os.path.exists(self.output_filename): + os.remove(self.output_filename) + logging.getLogger('VADApp').info(f"Старый файл записей удалён: {self.output_filename}") + + # 🔹 Запуск фонового потока + self.audio = pyaudio.PyAudio() + self.running = True + self.vad_thread = threading.Thread(target=self._vad_loop, daemon=True) + self.vad_thread.start() + + def _calculate_energy(self, frame): + """Вычисляет RMS энергию аудио фрейма с шумоподавлением и автоусилением. + Возвращает кортеж: (обработанный_фрейм, исходная_энергия) + """ + logger = logging.getLogger('VADApp') + try: + # Преобразуем байты в массив int16 + audio_data = struct.unpack(f'{len(frame)//2}h', frame) + audio_array = np.array(audio_data, dtype=np.float32) + + # Вычисляем энергию ИСХОДНОГО сигнала (для VAD) + base_energy = np.sqrt(np.mean(audio_array**2)) + + # Обновляем оценку уровня шума + if self.noise_reduction_enabled and base_energy < self.current_energy_threshold * 0.5: + current_noise = np.mean(np.abs(audio_array)) + if self.noise_floor == 0.0: + # Первая инициализация + self.noise_floor = current_noise + else: + # Плавное обновление с настраиваемой скоростью + alpha = float(self.config.get('noise_update_alpha', 0.05)) + self.noise_floor = (1 - alpha) * self.noise_floor + alpha * current_noise + + # ========== ШУМОПОДАВЛЕНИЕ ========== + if self.noise_reduction_enabled and self.noise_floor > 0: + reduction_factor = float(self.config.get('noise_reduction_factor', 1.5)) + noise_threshold = self.noise_floor * reduction_factor + audio_array = np.where( + np.abs(audio_array) > noise_threshold, + np.sign(audio_array) * (np.abs(audio_array) - self.noise_floor), + 0 + ) + + # ========== АВТОУСИЛЕНИЕ ========== + if self.auto_gain_enabled: + current_rms = np.sqrt(np.mean(audio_array**2)) + + # Вычисляем минимальный порог RMS относительно уровня шума + min_rms_factor = float(self.config.get('min_rms_threshold_factor', 2.0)) + min_rms_threshold = self.noise_floor * min_rms_factor if self.noise_floor > 0 else 100.0 + + if current_rms > min_rms_threshold: + # Вычисляем целевой коэффициент усиления + target_gain = self.target_rms / current_rms + + # Ограничиваем максимальное усиление + max_gain = float(self.config.get('max_gain', 5.0)) + target_gain = min(max_gain, max(0.1, target_gain)) + + # Плавное изменение усиления с настраиваемой скоростью + alpha = float(self.config.get('gain_smoothing_alpha', 0.1)) + self.current_gain = (1 - alpha) * self.current_gain + alpha * target_gain + + # Применяем усиление + audio_array = audio_array * self.current_gain + + # Преобразуем обратно в байты + audio_array = np.clip(audio_array, -32768, 32767).astype(np.int16) + processed_frame = struct.pack(f'{len(audio_array)}h', *audio_array) + + # Возвращаем обработанный фрейм И исходную энергию + return processed_frame, base_energy + + except Exception as e: + logger.error(f"_calculate_energy: {e}") + return frame, 0.0 # В случае ошибки возвращаем исходный фрейм + + def _update_adaptive_threshold(self, energy, is_speech_candidate): + """Обновляет адаптивный порог энергии на основе фонового шума""" + if not self.adaptive_energy: + return + + # Если не похоже на речь, добавляем в буфер фоновой энергии + if not is_speech_candidate: + self.background_energy_buffer.append(energy) + + # Обновляем порог как среднее + отклонение фонового шума + if len(self.background_energy_buffer) >= 10: + bg_mean = np.mean(self.background_energy_buffer) + bg_std = np.std(self.background_energy_buffer) + # Порог = среднее + adaptive_energy_std_factor стандартных отклонения + self.current_energy_threshold = max(bg_mean + self.adaptive_energy_std_factor * bg_std, self.energy_threshold) + + def _vad_loop(self): + logger = logging.getLogger('VADApp') + stream = None + try: + stream = self.audio.open( + format=pyaudio.paInt16, + channels=1, + rate=self.config['sample_rate'], + input=True, + frames_per_buffer=self.frame_size + ) + + logger.info("Инициализация VAD завершена. Начинаем обработку...") + + while self.running: + try: + frame = stream.read(self.frame_size, exception_on_overflow=False) + + # Базовая VAD проверка + is_speech_vad = self.vad.is_speech(frame, self.config['sample_rate']) + + # Вычисляем энергию сигнала + processed_frame, energy = self._calculate_energy(frame) + + # Обновляем адаптивный порог + self._update_adaptive_threshold(energy, is_speech_vad) + + with self.lock: + # Комбинированная проверка речи (VAD + энергия) + is_speech = is_speech_vad and (energy > self.current_energy_threshold) + + # Обновляем кольцевой буфер и историю + self.ring_buffer.append(1 if is_speech else 0) + speech_ratio = sum(self.ring_buffer) / len(self.ring_buffer) + speech_confirmed = speech_ratio > self.config['speech_threshold'] + self.history.append(speech_ratio) + + # Обновляем счетчик連続ных фреймов речи + if speech_confirmed: + self.continuous_speech_frames += 1 + else: + self.continuous_speech_frames = 0 + + # Финальное подтверждение речи (фильтр по длительности) + speech_detected = self.continuous_speech_frames >= self.min_speech_frames + + # Обновляем last_result + self.last_result = { + 'speech_ratio': speech_ratio, + 'speech_detected': speech_detected, + 'energy': energy, + 'energy_threshold': self.current_energy_threshold, + 'speech_threshold': self.config['speech_threshold'], + 'continuous_frames': self.continuous_speech_frames, + } + + # Логика начала/окончания записи + if speech_detected: + if not self.is_recording: + self._start_recording() + self._cancel_stop_recording() # Отменяем таймер, если речь продолжается + else: + if self.is_recording and not self.stop_timer: + self._schedule_stop_recording() + + # Добавляем фрейм в буфер + if not self.is_recording: + self.prebuffer.append(processed_frame) + else: + self.recording_buffer.append(processed_frame) + # Проверка на максимальную длину записи + if len(self.recording_buffer) >= self.max_record_frames: + logger.info(f"Максимальная длина записи достигнута ({self.max_record_duration_sec} сек)") + self._stop_recording() + + # Обновляем состояния + self.prev_speech_detected = speech_confirmed + self.last_speech_detected = speech_confirmed + + # Вывод статуса + recording_status = "🔴 ЗАПИСЬ" if self.is_recording else "⚪ ПРЕДБУФЕР" + timer_status = " [⏱️]" if self.stop_timer else "" + prebuf_len = len(self.prebuffer) if not self.is_recording else 0 + rec_len = len(self.recording_buffer) if self.is_recording else 0 + + status_line = ( f"Статус: {status_tracker.get_status()} |" + f"VAD: {recording_status}{timer_status} |" + f"Речь: {speech_ratio:.2f} | Энергия: {energy:.0f}/{self.current_energy_threshold:.0f} | " + f"Gain: {self.current_gain:.2f}x | Шум: {self.noise_floor:.0f} | " + f"Кадры: {self.continuous_speech_frames:>3} | Буферы: П{prebuf_len:>3}/З{rec_len:>4}") + if logger.isEnabledFor(logging.DEBUG): + sys.stdout.write(f"\r{status_line}") + sys.stdout.flush() + else: + logger.debug(status_line.replace('\n', ' ')) + + except Exception as e: + if self.running: + logger.error(f"VAD Read Error: {e}") + break + + except Exception as e: + logger.error(f"VAD Stream Error: {e}") + finally: + if stream: + try: + stream.stop_stream() + stream.close() + except: + pass + + def _start_recording(self): + """Начинаем запись: копируем предбуфер в основной буфер и переключаемся в режим записи""" + logger = logging.getLogger('VADApp') + self.is_recording = True + self.recording_buffer = list(self.prebuffer) + self.prebuffer.clear() + logger.info(f"Запись начата (предбуфер: {len(self.recording_buffer)} фреймов)") + + status_tracker.set_status(SystemStatus.RECORDING) + + def _schedule_stop_recording(self): + """Запускает таймер на остановку записи через N секунд (только если таймер еще не запущен)""" + logger = logging.getLogger('VADApp') + if self.stop_timer is not None: + return + + def delayed_stop(): + with self.lock: + if self.is_recording: + self._stop_recording() + + self.stop_timer = threading.Timer(self.record_stop_delay_sec, delayed_stop) + self.stop_timer.start() + logger.info(f"Таймер остановки запущен ({self.record_stop_delay_sec} сек)") + + def _cancel_stop_recording(self): + """Отменяет таймер остановки записи""" + logger = logging.getLogger('VADApp') + if self.stop_timer: + self.stop_timer.cancel() + self.stop_timer = None + logger.info("Таймер остановки отменён (речь возобновилась)") + + def _stop_recording(self): + """Останавливаем запись: обрабатываем буфер для распознавания и/или сохранения в файл""" + logger = logging.getLogger('VADApp') + if not self.is_recording: + return + + if self.on_speech_end_callback and self.recording_buffer: + try: + audio_data = b''.join(self.recording_buffer) + self.on_speech_end_callback(audio_data, self.config['sample_rate']) + except Exception as e: + logger.error(f"Ошибка вызова callback: {e}") + + if self.recording_buffer and self.record_enabled: + try: + with wave.open(self.output_filename, 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(self.config['sample_rate']) + wf.writeframes(b''.join(self.recording_buffer)) + + duration = len(self.recording_buffer) * self.config['frame_duration_ms'] / 1000 + file_size = os.path.getsize(self.output_filename) / 1024 + logger.info(f"Запись сохранена: {self.output_filename}") + logger.info(f"Длительность: {duration:.2f} сек, Размер: {file_size:.1f} KB, Фреймов: {len(self.recording_buffer)}") + except Exception as e: + logger.error(f"Ошибка сохранения записи: {e}") + + self.recording_buffer.clear() + status_tracker.set_status(SystemStatus.LISTENING) + self.is_recording = False + self.stop_timer = None + logger.info("Возврат к режиму предбуфера") + + def get_latest_status(self): + """Возвращает последнее состояние VAD (для polling)""" + with self.lock: + return self.last_result.copy() + + def update_config(self, new_config): + logger = logging.getLogger('VADApp') + with self.lock: + self.config = new_config + self.vad.set_mode(new_config['mode']) + self.ring_buffer = collections.deque(maxlen=new_config['ring_buffer_size']) + self.history = collections.deque(maxlen=new_config.get('history_maxlen', 100)) + self.frame_size = int(new_config['sample_rate'] * new_config['frame_duration_ms'] / 1000) + + # Обновляем параметры фильтрации + self.energy_threshold = new_config.get('energy_threshold', 1000) + self.min_speech_duration_ms = new_config.get('min_speech_duration_ms', 300) + self.adaptive_energy = new_config.get('adaptive_energy', True) + self.adaptive_energy_std_factor = new_config.get('adaptive_energy_std_factor', 2.5) + self.min_speech_frames = int(self.min_speech_duration_ms / new_config['frame_duration_ms']) + + # Обновляем параметры шумоподавления и автоусиления + self.noise_reduction_enabled = new_config.get('noise_reduction_enabled', True) + self.auto_gain_enabled = new_config.get('auto_gain_enabled', True) + self.target_rms = new_config.get('target_rms', 2000.0) + + # Обновляем размер предбуфера и пересоздаем его + old_prebuffer_data = list(self.prebuffer) if not self.is_recording else [] + self.prebuffer_size = int(new_config.get('prebuffer_duration_sec', 1.0) * new_config['sample_rate'] / self.frame_size) + self.prebuffer = collections.deque(old_prebuffer_data, maxlen=self.prebuffer_size) + + # Обновляем максимальную длину записи + self.max_record_duration_sec = new_config.get('max_record_duration_sec', 5.0) + self.max_record_frames = int(self.max_record_duration_sec * new_config['sample_rate'] / self.frame_size) + + # Обновляем настройки записи + self.record_enabled = new_config.get('record_on_speech', False) + self.record_stop_delay_sec = new_config.get('record_stop_delay_sec', 1.0) + + logger.info("Конфигурация VAD обновлена") + + def close(self): + """Корректное завершение работы с очисткой всех ресурсов""" + logger = logging.getLogger('VADApp') + logger.info("Начинаем корректное завершение VAD...") + self.running = False + + # Даем время потоку завершиться + if hasattr(self, 'vad_thread') and self.vad_thread.is_alive(): + self.vad_thread.join(timeout=3.0) + + with self.lock: + # Отменяем таймер, если активен + if self.stop_timer: + self.stop_timer.cancel() + self.stop_timer = None + + # Сохраняем текущую запись, если активна + if self.is_recording: + logger.info("Сохраняем активную запись...") + self._stop_recording() + + # Очищаем все буферы для освобождения памяти + self.prebuffer.clear() + self.recording_buffer.clear() + self.ring_buffer.clear() + self.history.clear() + self.background_energy_buffer.clear() + + # Завершаем audio + if hasattr(self, 'audio'): + try: + self.audio.terminate() + except: + pass # Игнорируем ошибки при завершении + + logger.info("Корректное завершение VAD завершено") diff --git a/volume.py b/volume.py new file mode 100644 index 0000000..1746721 --- /dev/null +++ b/volume.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +import subprocess +import sys + +CARD_INDEX = 2 # Можно изменить при необходимости + +def _get_mixer_name(card_index, mixer_type=None): + """ + Вспомогательная функция: находит микшер указанного типа или первый из списка. + + Args: + card_index: Индекс звуковой карты + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, вернет первый найденный. + """ + try: + result = subprocess.run( + ['amixer', '-c', str(card_index), 'scontrols'], + capture_output=True, text=True, check=True + ) + + # Если указан конкретный тип микшера, ищем его + if mixer_type in ['Playback', 'Capture']: + for line in result.stdout.splitlines(): + name = line.strip().replace("Simple mixer control '", "").replace("',0", "") + if name == mixer_type: + return name + print(f"Mixer type '{mixer_type}' not found for card {card_index}.", file=sys.stderr) + return None + + # Если тип не указан, возвращаем первый подходящий + for line in result.stdout.splitlines(): + name = line.strip().replace("Simple mixer control '", "").replace("',0", "") + if name in ['Playback', 'Capture']: + return name + + print(f"No valid mixer (Playback/Capture) found for card {card_index}.", file=sys.stderr) + return None + except subprocess.CalledProcessError as e: + print(f"Error listing mixers: {e}", file=sys.stderr) + return None + + +def get_volume(card_index, mixer_type=None): + """ + Получить текущую громкость для устройства (card_index). + + Args: + card_index: Индекс звуковой карты + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, используется первый найденный. + + Возвращает: int (0-100) или None при ошибке. + """ + mixer_name = _get_mixer_name(card_index, mixer_type) + if not mixer_name: + print(f"No valid mixer found for card {card_index} with type {mixer_type}.", file=sys.stderr) + return None + + try: + result = subprocess.run( + ['amixer', '-c', str(card_index), '-M', 'sget', mixer_name], + capture_output=True, text=True, check=True + ) + for line in result.stdout.splitlines(): + if '[' in line and ']' in line and '%' in line: + percentage = line.split('[')[1].split(']')[0].strip('%') + return int(percentage) + return None + except subprocess.CalledProcessError as e: + print(f"Failed to get volume: {e}", file=sys.stderr) + return None + except ValueError: + print("Failed to parse volume percentage.", file=sys.stderr) + return None + + +def set_volume(card_index, volume, mixer_type=None): + """ + Установить громкость для устройства (card_index). + + Args: + card_index: Индекс звуковой карты + volume: Громкость (int от 0 до 100) + mixer_type: Тип микшера ('Playback' или 'Capture'). Если None, используется первый найденный. + + Возвращает: True при успехе, False при ошибке. + """ + if not (0 <= volume <= 100): + print("Volume must be between 0 and 100.", file=sys.stderr) + return False + + mixer_name = _get_mixer_name(card_index, mixer_type) + if not mixer_name: + print(f"No valid mixer found for card {card_index} with type {mixer_type}.", file=sys.stderr) + return False + + try: + subprocess.run( + ['amixer', '-c', str(card_index), '-M', 'sset', mixer_name, f'{volume}%'], + check=True + ) + print(f"Volume set to {volume}% for card {card_index}, mixer '{mixer_name}'") + return True + except subprocess.CalledProcessError as e: + print(f"Failed to set volume: {e}", file=sys.stderr) + return False + + +# === Пример использования (раскомментировать для теста) === +# if __name__ == "__main__": +# print("Current volume:", get_volume(CARD_INDEX)) +# set_volume(CARD_INDEX, 75) +# print("New volume:", get_volume(CARD_INDEX)) \ No newline at end of file diff --git a/vosk_local_STT.py b/vosk_local_STT.py new file mode 100644 index 0000000..4e7270b --- /dev/null +++ b/vosk_local_STT.py @@ -0,0 +1,102 @@ +# vosk_stt.py +import json +import yaml +import logging +from datetime import datetime +from vosk import Model, KaldiRecognizer + +class VoskSTT: + def __init__(self, model_path="/home/sirmrak/models/vosk-model-small-ru-0.22", commands_file="/mnt/Project/my_speaker/commands/commands.yaml", sample_rate=16000): + """ + Инициализирует Vosk STT с моделью и словарем команд из YAML файла. + + :param model_path: Путь к модели Vosk (vosk-model-small-ru-0.22) + :param commands_file: Путь к файлу commands.yaml + :param sample_rate: Частота дискретизации аудио (по умолчанию 16000) + """ + self.model_path = model_path + self.commands_file = commands_file + self.sample_rate = sample_rate + self.model = None + self.recognizer = None + self._load_model() + self._load_commands() + + def _load_model(self): + """Загружает модель Vosk.""" + logger = logging.getLogger('VADApp') + try: + self.model = Model(self.model_path) + logger.info(f"Модель Vosk загружена: {self.model_path}") + except Exception as e: + logger.error(f"Ошибка загрузки модели: {e}") + raise + + def _load_commands(self): + """Формирует словарь (список фраз) из commands.yaml и инициализирует recognizer.""" + logger = logging.getLogger('VADApp') + try: + with open(self.commands_file, 'r', encoding='utf-8') as f: + commands = yaml.safe_load(f) + + phrases = [] + + # Собираем все фразы из всех разделов + for section, content in commands.items(): + if section in ['Wakewords', 'Session']: + # Обрабатываем списки с вложенными phrases + for item in content: + if isinstance(item, dict) and 'phrases' in item: + phrases.extend(item['phrases']) + elif section == 'Volume': + # Обрабатываем новую структуру Volume с приоритетом + for item in content: + if isinstance(item, dict) and 'commands' in item: + phrases.extend(list(item['commands'].keys())) + elif section == 'Text': + # Обрабатываем новую структуру Text с приоритетом + for item in content: + if isinstance(item, dict) and 'commands' in item: + phrases.extend(list(item['commands'].keys())) + + # Удаляем дубликаты и сортируем + phrases = sorted(set(phrases)) + + # Инициализируем recognizer с грамматикой (списком фраз) + grammar = json.dumps(phrases, ensure_ascii=False) + self.recognizer = KaldiRecognizer(self.model, self.sample_rate, grammar) + + logger.info(f"Словарь команд загружен: {len(phrases)} фраз") + logger.debug(f"Фразы: {phrases}") + except Exception as e: + logger.error(f"Ошибка загрузки команд: {e}") + raise + + def recognize(self, audio_data): + """ + Распознает речь из аудио данных (bytes). + + :param audio_data: Bytes аудио (16-bit PCM, mono) + :return: Распознанный текст или None если ничего не распознано + """ + logger = logging.getLogger('VADApp') + try: + if not self.recognizer.AcceptWaveform(audio_data): + # Частичный результат (если нужно, но для коротких команд используем Final) + partial = json.loads(self.recognizer.PartialResult()).get("partial", "") + if partial: + logger.debug(f"Частичный результат: {partial}") + result = json.loads(self.recognizer.FinalResult()).get("text", "") + if result: + logger.info(f"Распознанная команда: {result}") + return result + else: + logger.info("Ничего не распознано") + return None + except Exception as e: + logger.error(f"Ошибка распознавания: {e}") + return None + + def reload_commands(self): + """Перезагружает словарь команд из файла.""" + self._load_commands() diff --git a/webapp/api.js b/webapp/api.js new file mode 100644 index 0000000..2b96a07 --- /dev/null +++ b/webapp/api.js @@ -0,0 +1,72 @@ +const DEFAULT_BACKEND_URL = "http://192.268.200.111:8000"; +let backendUrl = localStorage.getItem("backendUrl") || DEFAULT_BACKEND_URL; + +export function setBackendUrl(url) { + backendUrl = url; + localStorage.setItem("backendUrl", url); +} + +export async function apiFetch(endpoint, options = {}) { + const token = localStorage.getItem("token"); + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }; + + const response = await fetch(`${backendUrl}/api${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem("token"); + window.location.reload(); // Редирект на login + } + throw new Error(`API error: ${response.statusText}`); + } + + return response.json(); +} + +export async function login(username, password) { + return apiFetch("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function logout() { + await apiFetch("/auth/logout", { method: "POST" }); + localStorage.removeItem("token"); + window.location.reload(); +} + +// Другие методы по API_DOCUMENTATION.md +export async function getSpeechData() { + return apiFetch("/dashboard/speech-data"); +} + +export async function getSysMonitor() { + return apiFetch("/dashboard/sys-monitor"); +} + +export async function getRecentPhrases(limit = 10) { + return apiFetch(`/dashboard/recent-phrases?limit=${limit}`); +} + +export async function getSettings() { + return apiFetch("/settings"); +} + +export async function updateSettings(section, data) { + return apiFetch(`/settings/${section}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function restart() { + return apiFetch("/settings/restart", { method: "POST" }); +} diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..bf064a5 --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,23 @@ +import { initRouter } from "./modules/router.js"; +import { loadLogin } from "./modules/login.js"; +import { setBgColor, setMainColor, setBorderColor } from "./modules/utils.js"; +import { setBackendUrl } from "./api.js"; + +document.addEventListener("DOMContentLoaded", () => { + const color = localStorage.getItem("mainColor") || "#0f0"; + setMainColor(color); + const borderColor = localStorage.getItem("borderColor") || "#0f0"; + setBorderColor(borderColor); + const bgColor = localStorage.getItem("bgColor") || "#000"; + setBgColor(bgColor); + + const backend = + localStorage.getItem("backendUrl") || "http://192.168.200.111:8000"; + setBackendUrl(backend); + + if (!localStorage.getItem("token")) { + loadLogin(); + } else { + initRouter(); + } +}); diff --git a/webapp/components/header.html b/webapp/components/header.html new file mode 100644 index 0000000..20ba393 --- /dev/null +++ b/webapp/components/header.html @@ -0,0 +1,47 @@ +
+

My Speaker

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

"Turn on light"

"Play music"

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

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

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

${p.text}

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

Speech Probability

+ +
+
+

Energy Level

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

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

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

+

Default: admin / admin

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

Status: Listening

+ +
+ +
+

Речь:

+

+ 0.00 из 1.00 +

+

False

+
+ + +
+

Энергия:

+

0 из 100

+

Шум: 0

+
+
+ +
+ +
+

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

+

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

+

Память: 0%

+

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

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

Settings

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