From 157a34fe877f66674698326686de7440952fdc29 Mon Sep 17 00:00:00 2001 From: LKTX Date: Tue, 14 Apr 2026 09:54:26 +0800 Subject: [PATCH] Initial commit: voice drone assistant Made-with: Cursor --- .gitignore | 26 + README.md | 94 + assets/tts_cache/wake_greeting.wav | Bin 0 -> 87644 bytes docs/API.md | 0 docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md | 179 ++ .../CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md | 55 + docs/CLOUD_VOICE_SESSION_SCHEME_v1.md | 163 ++ docs/DEPLOYMENT_AND_OPERATIONS.md | 288 +++ docs/FLIGHT_BRIDGE_ROS1.md | 88 + docs/FLIGHT_INTENT_IMPLEMENTATION_PLAN.md | 113 + docs/FLIGHT_INTENT_SCHEMA_v1.md | 372 +++ docs/PROJECT_GUIDE.md | 148 ++ main.py | 29 + requirements.txt | 25 + scripts/bundle_for_device.sh | 54 + scripts/generate_wake_greeting_wav.py | 42 + scripts/run_flight_bridge_with_mavros.sh | 146 ++ scripts/run_flight_intent_bridge_ros1.sh | 37 + scripts/run_px4_offboard_one_terminal.sh | 172 ++ tests/test_cloud_dialog_v1.py | 44 + tests/test_flight_intent.py | 160 ++ voice_drone/__init__.py | 3 + .../config/cloud_voice_px4_context.yaml | 25 + voice_drone/config/command_.yaml | 59 + voice_drone/config/keywords.yaml | 71 + voice_drone/config/system.yaml | 246 ++ voice_drone/config/wake_word.yaml | 71 + voice_drone/core/audio.py | 714 ++++++ voice_drone/core/cloud_dialog_v1.py | 78 + voice_drone/core/cloud_voice_client.py | 999 ++++++++ voice_drone/core/command.py | 205 ++ voice_drone/core/configuration.py | 209 ++ voice_drone/core/flight_intent.py | 338 +++ voice_drone/core/mic_device_select.py | 186 ++ voice_drone/core/portaudio_env.py | 60 + voice_drone/core/qwen_intent_chat.py | 115 + voice_drone/core/recognizer.py | 969 +++++++ voice_drone/core/rule.py | 0 voice_drone/core/scoket_client.py | 239 ++ voice_drone/core/streaming_llm_tts.py | 46 + voice_drone/core/stt.py | 494 ++++ voice_drone/core/text_preprocessor.py | 716 ++++++ voice_drone/core/tts.py | 695 +++++ voice_drone/core/tts_ack_cache.py | 152 ++ voice_drone/core/vad.py | 429 ++++ voice_drone/core/wake_word.py | 375 +++ .../core/任务执行完成,开始返航降落.wav | Bin 0 -> 171644 bytes voice_drone/core/好的收到,开始起飞.wav | Bin 0 -> 118844 bytes voice_drone/flight_bridge/__init__.py | 1 + .../flight_bridge/ros1_mavros_executor.py | 330 +++ voice_drone/flight_bridge/ros1_node.py | 94 + voice_drone/logging_/__init__.py | 14 + voice_drone/logging_/color_logger.py | 107 + voice_drone/main_app.py | 2271 +++++++++++++++++ voice_drone/tools/__init__.py | 1 + voice_drone/tools/config_loader.py | 15 + .../tools/publish_flight_intent_ros_once.py | 66 + voice_drone/tools/wrapper.py | 17 + with_system_alsa.sh | 20 + 59 files changed, 12665 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/tts_cache/wake_greeting.wav create mode 100644 docs/API.md create mode 100644 docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md create mode 100644 docs/CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md create mode 100644 docs/CLOUD_VOICE_SESSION_SCHEME_v1.md create mode 100644 docs/DEPLOYMENT_AND_OPERATIONS.md create mode 100644 docs/FLIGHT_BRIDGE_ROS1.md create mode 100644 docs/FLIGHT_INTENT_IMPLEMENTATION_PLAN.md create mode 100644 docs/FLIGHT_INTENT_SCHEMA_v1.md create mode 100644 docs/PROJECT_GUIDE.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scripts/bundle_for_device.sh create mode 100644 scripts/generate_wake_greeting_wav.py create mode 100644 scripts/run_flight_bridge_with_mavros.sh create mode 100644 scripts/run_flight_intent_bridge_ros1.sh create mode 100644 scripts/run_px4_offboard_one_terminal.sh create mode 100644 tests/test_cloud_dialog_v1.py create mode 100644 tests/test_flight_intent.py create mode 100644 voice_drone/__init__.py create mode 100644 voice_drone/config/cloud_voice_px4_context.yaml create mode 100644 voice_drone/config/command_.yaml create mode 100644 voice_drone/config/keywords.yaml create mode 100644 voice_drone/config/system.yaml create mode 100644 voice_drone/config/wake_word.yaml create mode 100644 voice_drone/core/audio.py create mode 100644 voice_drone/core/cloud_dialog_v1.py create mode 100644 voice_drone/core/cloud_voice_client.py create mode 100644 voice_drone/core/command.py create mode 100644 voice_drone/core/configuration.py create mode 100644 voice_drone/core/flight_intent.py create mode 100644 voice_drone/core/mic_device_select.py create mode 100644 voice_drone/core/portaudio_env.py create mode 100644 voice_drone/core/qwen_intent_chat.py create mode 100644 voice_drone/core/recognizer.py create mode 100644 voice_drone/core/rule.py create mode 100644 voice_drone/core/scoket_client.py create mode 100644 voice_drone/core/streaming_llm_tts.py create mode 100644 voice_drone/core/stt.py create mode 100644 voice_drone/core/text_preprocessor.py create mode 100644 voice_drone/core/tts.py create mode 100644 voice_drone/core/tts_ack_cache.py create mode 100644 voice_drone/core/vad.py create mode 100644 voice_drone/core/wake_word.py create mode 100644 voice_drone/core/任务执行完成,开始返航降落.wav create mode 100644 voice_drone/core/好的收到,开始起飞.wav create mode 100644 voice_drone/flight_bridge/__init__.py create mode 100644 voice_drone/flight_bridge/ros1_mavros_executor.py create mode 100644 voice_drone/flight_bridge/ros1_node.py create mode 100644 voice_drone/logging_/__init__.py create mode 100644 voice_drone/logging_/color_logger.py create mode 100644 voice_drone/main_app.py create mode 100644 voice_drone/tools/__init__.py create mode 100644 voice_drone/tools/config_loader.py create mode 100644 voice_drone/tools/publish_flight_intent_ros_once.py create mode 100644 voice_drone/tools/wrapper.py create mode 100644 with_system_alsa.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e1f1de --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +.venv/ +venv/ +.env +*.egg-info/ +.eggs/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 大模型与缓存(见 models/README.txt,请单独拷贝或从原仓库同步) +models/ +cache/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bb5043 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# voice_drone_assistant + +从原仓库抽离的**独立可运行**子工程:麦克风采集 → VAD 切段 → **SenseVoice STT** → **唤醒词** →(关键词起飞 / **Qwen + Kokoro 对话播报**)。 + +**部署与外场启动(推荐先读):[docs/DEPLOYMENT_AND_OPERATIONS.md](docs/DEPLOYMENT_AND_OPERATIONS.md)** +**日常配置索引:[docs/PROJECT_GUIDE.md](docs/PROJECT_GUIDE.md)** · 云端协议:[docs/llmcon.md](docs/llmcon.md) + +## 目录结构 + +| 路径 | 说明 | +|------|------| +| `main.py` | 启动入口 | +| `with_system_alsa.sh` | Conda 下建议包一层启动,修正 ALSA/PortAudio | +| `voice_drone/core/` | 音频、VAD、STT、TTS、预处理、唤醒、配置、识别器主流程 | +| `voice_drone/main_app.py` | 唤醒流程 + LLM 流式 + 起飞脚本联动(原 `rocket_drone_audio.py`) | +| `voice_drone/config/` | `system.yaml`、`wake_word.yaml`、`keywords.yaml`、`command_.yaml` | +| `voice_drone/logging_/` | 彩色日志 | +| `voice_drone/tools/` | YAML 加载等 | +| `scripts/` | PX4 offboard、`generate_wake_greeting_wav.py` | +| `assets/tts_cache/` | 唤醒问候 WAV 缓存 | +| `models/` | **需自备或软链**,见 `models/README.txt` | + +## 环境准备 + +1. Python 3.10+(与原项目一致即可),安装依赖: + + ```bash + pip install -r requirements.txt + ``` + +2. 模型:将 STT / TTS /(可选)Silero VAD 放到 `models/`,或按 `models/README.txt` 从原仓库 `src/models` 创建符号链接。 + +3. 大模型:默认查找 `cache/qwen25-1.5b-gguf/qwen2.5-1.5b-instruct-q4_k_m.gguf`,或通过环境变量 `ROCKET_LLM_GGUF` 指定 GGUF 路径。 + +## 运行 + +在 **`voice_drone_assistant` 根目录** 执行: + +```bash +bash with_system_alsa.sh python main.py +``` + +常用参数与环境变量与原 `rocket_drone_audio.py` 相同(如 `ROCKET_LLM_STREAM`、`ROCKET_INPUT_DEVICE_INDEX`、`--input-index`、`ROCKET_ENERGY_VAD` 等),说明见 `voice_drone/main_app.py` 文件头注释。 + +也可直接跑模块: + +```bash +bash with_system_alsa.sh python -m voice_drone.main_app +``` + +## 为什么不默认带上原仓库的 models? + +- **ONNX / GGUF 体积大**(动辄数百 MB~数 GB),放进 Git 或重复拷贝会加重仓库和同步成本。 +- 抽离时只保证 **代码与配置自给**;权重文件用 **本机拷贝 / U 盘 / 另一台预先 `bundle`** 更灵活。 + +若你本机仍摆着原仓库 `rocket_drone_audio`,且 `voice_drone_assistant` 在其子目录下,代码里有个**临时便利**:`models/...` 找不到时会尝试 **上一级 `src/models/...`**,所以在开发机上可以不改目录也能跑。 +**这只在「子目录 + 上层仍有原仓库」时有效**,把 `voice_drone_assistant` **单独拷到另一台香橙派后,上层没有原仓库,必须在本目录自备 `models/`(和可选 `cache/`)**。 + +## 拷到另一台香橙派要做什么? + +1. **整目录复制**(建议先在本机执行下面脚本打全模型,再打包 `voice_drone_assistant`): + + ```bash + cd /path/to/voice_drone_assistant + bash scripts/bundle_for_device.sh /path/to/rocket_drone_audio + ``` + + 会把 `SenseVoiceSmall`、`Kokoro-82M-v1.1-zh-ONNX`(及存在的 `SileroVad`)复制到本目录 `models/`;可按提示选择是否复制 Qwen GGUF。 + +2. **新机器上 Python 依赖**:另一台是**全新系统**时,需要再装一次(或整体迁移同一个 conda/env): + + ```bash + cd voice_drone_assistant + pip install -r requirements.txt + ``` + + 二进制/系统库层面若仍用 conda + PortAudio,建议继续 **`bash with_system_alsa.sh python main.py`**。 + +3. **大模型路径**:若未打包 `cache/`,在新机器设环境变量或放入默认路径,例如: + + ```bash + export ROCKET_LLM_GGUF=/path/to/qwen2.5-1.5b-instruct-q4_k_m.gguf + ``` + +综上:**工程可独立**,但必须带上 **`models/` + 已装依赖 +(可选)GGUF**;**`pip install` 每台新环境通常要做一次**,除非你把整个 conda env 目录一起迁移。 + +## 与原仓库关系 + +- 本目录为**代码与配置的复制 + 包名调整**(`src.*` → `voice_drone.*`),默认不把大体积 `models/`、`cache/` 放进版本库。 +- 原仓库 `rocket_drone_audio` 仍可继续使用;开发阶段两者可并存,部署到单机时只带走 `voice_drone_assistant`(+ `bundle` 后的模型)即可。 + +## 未纳入本工程的模块 + +PX4 电机演示、独立录音脚本、Socket 试飞控协议服务端、ChatTTS 转换脚本等均留在原仓库,以减小篇幅;本工程仍通过 `SocketClient` 预留配置项(`TakeoffPrintRecognizer` 使用 `auto_connect_socket=False`,不依赖外置试飞控 Socket)。 diff --git a/assets/tts_cache/wake_greeting.wav b/assets/tts_cache/wake_greeting.wav new file mode 100644 index 0000000000000000000000000000000000000000..e28f224ac07a0d5054e6d7835563b19fb020ad0a GIT binary patch literal 87644 zcmeEt^?wvQ)c5$zx@))Hy1To#P_%_oD74Vx?k*R&=*8XLy2u%b^Zo_zFKj+1$t35Te0d4+4J>_=CV71pXlK2Z28b{6XLk0)G(rgTNmI z{vhxNfj%i zSFir_a6x|a(zN-yE8it+=K4>ftL{Gu81Qw~m~;RA{#UlEt*#h_P{4rvPk_J`3}L^? zTw|^?m*9Z9Qsj4v0UiwGFo*z>X2O1_7{mmeB&eYP;h+=;wK&KVzsW2`{4O<@o8`?M z=GMD(nR{(+-Q4c~{>-);X#~>K=FQILU0uC1w_?8T!o|#EMyd-Bb9tAHna6CA+2*da3n_CuT{_M3 zX5KF9vX%JVYL{%6ezWZV+SJvu**cmw*O;+0TVw9+e_UNHn62t6G3zn+t_z*6w5tvW zl4c$=b&Y~9i7q6&5a`Nx+4o=mKLon^-epBsKh3=`-$^q%UEE!~T?m+wHFKM(YfPBA zyG8;D_$e@A7{DfTjQ$=yU9}{rA;5_0O3irUAphT3G+Sf>qvC)61tuB-_&|{1_xLbd zY3`R9F(YlZu&d-hEY1BSK#S%+np-oDVUEq(NKi`wA^;!rcb*AyX02u!b1DQH*&sESvVc}|5I|y}SpiA@ zhoq1SCwB>#OS|D7s9nFSzgNDbKmKI`9kD-c_PD=R=YK*AQ}Yyd}Bum_y> zzk>^?aeoiN*{*P7{kaq@py?{<QAnyd)lY>?)Kx-<%F9JCgAOhs%z!r0>Dk$K0%Z^YW6bto)dVw?%N`f+= zbf`bn9ZH1;f_y487|ZjA+(BDDP!x#$LFxi|fR^0>26mtyF2GKg-^iQql==SHgWg*Ktn9#*1L&t2 zMF)U`HNebFmVn#gH#%ksHK?%#8ZAMY1HjLGw;X^*x8M3LLC*EJwH`pP8{oAA*Jh;c zfTT4@odLHsD02WkGRwLHU5-GpvK7ooEtT!ubC51{+d9q1wS40-{5 zgWf|;P%~5xy#-f~K+O#xw*|=Gfu2LfPyvXKLFb`M&;<}S|L(^M(4Psw<}tv|H6TCa zHv(DEaDYq{7-2oY=<)|Rs=x?^pc=ZCE~S6bpXqD#CHfo)XXq33P5L<~c|_l*-_Y;q z5A+rK7$|!~=hI*5k90ZRNEZW+Q}jdn8U3Bk0~*?B5x6^EV6-T~*w@o~x`j5-VsLMw zp?+YT_68%m7ZeW0V?WTZ#n1`p8gvhO2h~7zU;>0;ChQ2uz+>PHcnUlVUJqY}pTpna z0zfF@i1b1RAp?*J$Ts9Gas@e$JVrhsjR=e~(I7MwO+#bRUT7lP2Ter>qJC&3IvQP% zzC|CQSI{l!d~_$e13ivz0QrUJcJv;&&Ox`K`_a9CR-!A=1?Y4%14xcRN1zkYK(rfb zjryQYXgTr=d4p_6HX(D6EMzj0h;fC05^y)qKu@5<;J%&sJ&MMI5uHyTrq|QU=&kf-dINoc{)aw7 zUkB)R0yN*yN9pqb_2u+T+J@Gcq;xPnfzF}>={!@ism*xXIMkSAv^BOECK&<^xdwaV zQsY$PJHvN_!kA*rH2N4f8LNz$rbbg8eG57QKY(Y$+h8VYi7z5>awB0y4y5`rcd)qZ zXY6y}@o|}F%kL?u5yp!p;&mdan3Q-*-K9SzC6f1&?b2V;XlW10e(^i8gCs{hK{QdQ z5wL{w1$X&Xyo=mZoa^j7_C2;Q`v9|?@r1!*gj1D-nCOA;!g^!JFcy9Rn~yj{iS%Q- z1#Us=ku-P+6bt9VPmw$DWvHE=24x@v(LgK}^+jI7-;qIB9sZRXNyU=+*cqaU5yqa! z9mv5rv20InAN~o!YGJLA7Td_4%0A0t6!(8iEkO+($Tc5 z;c0zQ!^0-;=A(@>8wNDIt*7hu)H3T`8Y`NGZC_g9){V`#nhRUKI^7MZk*(j`8KbQ? z4JVGUWb7}Dr__7qI6f@eHZ)g^$qpT^19{G=5FK8b$RSK%wdlG1iM1(BsJgip2bdCj#wvNBkmMc z^MY9g)LSZy5lcP5C?p5WG#$_(Xe%9L{97+GOfCka=o0M>%g`;jg8Z9IqW+;iF-9<_Ft@T(I3B!%yea&pq7;dh{HuJRe5eAkIAQJM zP;Ix&QRzrKq}z?KKkxp=bFKGtcUPAI&f{F{JlA=omZrx1-GQ zqg{~wT-)pFOO`7X8g=(YvFGG6mA}G7%z)`obdv4MU>EDxERquFumMZsSD8c z(;n33be`?F3hvP=O=^2x%hwi7OH->)Yg2iqb$2etQXX>18>9;Lks&!k>cU!g~O zYwcoqA@`o_nU%XTS_q3SDkKhK&tcv^-oxE{xukNzObnRnSQxpD04EH=+q_((=t9J5%W3RLb+ErFFAX{hU@^5v8LLcE ztd`FdYxys^TGm0B-t5-j-cE91FyJNn@bx$xRY>YdQur2;&Txr6Z{CQEe;rZ8FuMZ`sc=z^htCOMxZm$WtbY{5(ltjj0J)ZqG0h9 z-e015(khE>4)#t4hkW%s>i~zv&f%UmUdO$bcpve!^r`oK=r=a7Bj8RzbHMI^F@e|o zSpE_Iv;F1;Z1h>=&G#&I$#W4pXSu3eUpq$H=oP;5x$<;HUum*{!9Ga6B+H3acoUuo z|7BEbd2MZ)98H97ac5fV{FcU!9A|_t4TfC_jOW4w`Wm*MJ2@TN|@api9HT2XmpaC( zQl!2j2XVVxp|~#T&B-CkX+PsRG>AILN+KBgK zoCAkx|DtvHdkUo<;{UMw@bbh5B=7j{!qLKQs%}oBoC}|ylD_L<}5 z?ApV<%(q|g_Q;4}b)d;F!@J2Z-FI@ptN^25yl=Mq2M>mOhU+*NkzKU?FM9|3Hg%3H zLu4uXAW4vX=46o>^aR~uz0t53{39Rh7qoq^<25ul+-`Nyq_ros&aLyTo?LOeJfJG7 zazS-`RcHBwid7XyD$iG^mhCKiR6e0}+>c&`^*?TuZZ4DlY$@_7^R9kTmHTUZMQ!Wz z*0(Lw>xx=7H~Q7gG<;^Q=e{ETGAz}G(Vw_0Y@*z%JqqLo;urUX-L|mJy$2+vglD+3 zU7iN*NII8sXJ|oMRIGiFB+jB==J5X6_5(NfJRKPt**m(VyG7FU1ZG@uSWI9qZ-%#n zPm=FXcg$_RorhI7*;}rk;FM^g@`B=qU?6iGJyU-{?@!+$?a4myhxXL^ltv5fK=>^& z7+GZc*fFB{SL^N025KTl%ANzqcR-!p;32{)t01*W`h_iHayZ43v$kJc-n#X54YOm~ z-*WBky*}h&$lB0z0hxa1{jU4gg)*X)@vCDEA^rT{d9pnjK5D;pftLezdM|SBw7zOn zY2V`1-|42+I$1k+D6=o~CbN(oO-i8s?I#--HnG|!cfQpk?Q@%!)t#*wSLad_SkI~( zQq#Mtq=YW*C<`m=S!q$ptW^IRTKMjpL&1@sCyG0ZoqovjSLGcm9Q-q~Vp;7b&DrL= zB|q~&{?axl8~QUQvpzsKo9i2#Oyd>r>@_Zf9lk2&QHkU<>nl+M`ycP~CZxfAlAG2) zq^D%i#H_vpb|+2`JRYz%iOMJ(6Ec+Q-5PNx=wamAr1{AOz2?SwgtvN+cMEa9=CwQU za*&6Y*rmzphIF1_KPOx?M6$(Vp)8v@7jn}**VjSY2@6IkhU+x-->S|v|81(HY&px& z`+A$U)boWNRVY_ssn(f%l6NDk0)0mP*OuKb#qfj?kGp})8jlQO^uBK^qV?zU3x1&6;q~=FLk+Qhr$Dp$G zUkeMA-;(qCe+w^skoUA`TUqhXm+y1lTr5^sw$v`KNUju@E-y&0D(kEv?=kloA2(uE zr`tAA9u{|9PTJM-Bba-++14*Y_Qqx<+zC$cPIggww8RCcdyk6EK4*l-EVcz>9MauZu=U2WPWkJi9SlNd+tZ7oW!1oA;QO0yF>P3`8W$b7@pR(^}C<^uhM+{EBxew{yC##kRs_i&s%VGMEug``CF4d^h@b56KKqiJTZ26`UThFK9&Q z;m|vQ4}96~@lGoo5rh7-S{JnJ zZJ_GgD&xx!mK`W$7i}yw6bgzMAAfv)@u~R3l@Hdh9(_p7C%&G4x!^fbc(eFo(T*S2 z3Y@++eY*I4Y{wjEwlS}H{jaz=BA)J6o z-p`$P*q5tsC<-L~gf9FK-2IIH)KRq1(7iLUxpzm7!3t8C#BIt}zcyi~%6QTc2EC{E z8tP54NF&Qzz~v>gN3yO8I+TUBZO$_LZR*#yw_SG!C53ehvkI8%pC3Ff_)+A?xH}1x z5{Q_yQ5n&}aoOPCe<$u%bY}SCpv^w1-v79dbH!aIIGl9YV#QYs7d~R%W3HgqGj>x> z&?~LH?M0Jy{hq4XRUW1MGW*i)h4YH=q7enL1^UmNFRXXSvq#S@Ud?$m>Di?h`(7=3 zoc{2yrw=|kl-w&`UcB_X;p>-Ir`~O-9}e{=ml;OY53gC>RE6Vq>jP&8uCUML3wZ|h zuki8xClCFS+B2$GxGLUv@XX1AsqPa_Wp@q{q^pNij?5mX9XD{8DrIiJiUCUo)urw4 zIWw+*lvmiohzSvNkgMkx`%JKfGFBBSvk_sOQcMIJjqy;Oaa6~l)hya0Z;9i;xO@_k!Lc*X{R%ivXX~g zPdyZ~H#99^YtZyaJYs&}aYwOYn;@0P61z%YOL}r(Yq>OC2rWDRmdE;~il3gJ)`m zt;Qz1##i+->a(j>mR>2@P~ z+ovAIydPPtD`FRfe6!6v`=vF1Si^pLE}_M)zzV#AwN5tMA=mSB(BY7gAhw3orKG^F)cpLpQ@geMU*Lj+$&P% z|MiZ1kH5e5=F^Mn2R$BLxody>%x(HU>xu2X&$%VHh#LoPYaU77CVahFpetomEGaqg z&A)hi!%jm7FVCXLI>7p-^+~&Ku5-LA!bOSCdsX(|FmOuN>5*$jD@O!mX-20^ct7RE z4DD3siJM2y8nbdj_1GmN#ToH^c0{uR9tA&$@7IUb<7Vh2_flnv;24+SjADf|&Qb;F zL})V|p{sA(Tu+pTlsDI?+H-X=`mMV4ZItGK?heGI1U!z6rR-(3*t*EEk0&#rJ-|DB zaqRSNS9&&dTiUZRWks5DfK|VL(=H8M(En?4x9&5$^-oxr_@n#L_*v02{G&ZyIC@*R zDMKWSMNhdL>OH#E*sMuy=uzJ5S8j3O&l!bQd4_yJe(qP@d(Y>`pZmOUe&+Ke|IvU) zJMZ1NGyN8FC*gkk!@?&oAN753^gjJ^$hRM5RQ;^h{N}3-Z8dh9={$Suvu=%^RKU=H z%z!=Nuc8I9%h z>V$tprK}#D6T)=SLGcp{7uz{*=e#Ncu?Q}hgVrT9$L~$L)K`(RcR*6AD6Kqg{UC?I zn*MIdzml#dct;CDZA1I{ukb$PDzQ~s{3T#>3yVvhF-23;z%LjcQAAEhb$hWqs zIi;zsF0k6WhTArhvD0FobC}QV;MsBd`1y%fVmT42xW~Q!877#%Xa4c|j?*uWXO22Q zv}9QM@R6f)$BrLGjbV@Vp4fBjll1d_o<^VX&vaKf4RAyJP6P$|_jDR$nZchy-oVLYX?m0Yn)Bx7TgAJ~ zH-}y{Jz4#ReBbmT^_}ExT47$vwT9;QFq(^Q(LL9`HV$Sr$yeC4xWIm=L*~R<#Vw7= z55E|Y6&fAgEBSHeyJ2y|xI@0DIrn?s=ScF7Nj^|oQ!B*|I?bGW^z&1eAXNZw^7agz8~C3-Pe-YOj}9V8zlyCknv zwK{xrclUb|lpC}%^nR!=lo#O^{yFku^z*m@36J9@C2mSM(9I!!bmY#kGlAIwN}mSj z`_|)BH|7210n!=#^;AETcSl;ouj+M`?DA11y?<=}iT}tgs{C$SC@r{^U;8aNf9;o* zc{{!q-iJECKayCTk|R9{nq#M^GXZx;v3&PN<8aMwpes%jb0{4 z<2J)^dL(NCFJAmnnPFGp@y(|;C^U!{SQRwe_iV=@1x9a-3JHY*G~O57s-3m=)wUArD4R8QxptMd?Ka-(XR7atp^7)s zAUSO@Tlq-+*uKQ6-JWUN?kIA9;^pGs?7PF~ncsy#hmeJV2f#X<73La~6T^y14vO$) zd-6QKdEN0^=)KW>fMc2cCL6Z(1`A6;4rK}18EXto{pQZw?M}^#`kacaGEu4Q$DqQj zyqs@w1;(O&Kkoc&{k7ufG;~bl_HahjzL@5S#E9i#hr+u>>=d}{xiFkNgN0K!$)Q9Q zHVYX_-!lyW=-lWuwQOj5S6@{hUl&8}b+CPK;q3<35w#vzV)%W^>v$!Qrv}M`yn044jf$6b%j}`;rRniHeq4+s#x<4|n;(@DF(vd_h;49#|5NYj z9>d-0T;@7Wv|nR$%_hLcSv^{HNq$0hNRlLdBkPpC1^e86r4OVA*=n#GUm(ttBuU1I zH;TUVoB5TzP5c^OZxA2kZslHJr?P?=TgWr`dSn|o>p5-o(I3#JXlgo_bRO$$Z5!W` z*pkqwtsYAh#W`fVQYvjR3ayoGm`s(`Gw4- zn%L0-rTDcpRo-3I%W{X!QU_-j%7g2c;^yKu*Q1};c&|ZzlEBKK{XwRHf}l$w!@`Dz z&kU^yITeHjqoKn>?)jhdKIHYc&v(De0aN?}yq!FGZiTLxo6(KuI?AEmx|`(yWrzZi zXvE6}TX+cnFmJEmHg^+iKe3h^!_E>&g`fB>Y!2@Rf1K!;^n>t?Adr*74r2`>qp4@) zMM}(QLM+ikP&m8{CE)M$9D}!Rnl?-`pv~Cuy7pJq_=<^DMD^La@n9EWRE4+|QE`-OeLX1hJ}D1*tN`Ip)6IX(CS$y5o>zsuh# zP=NiFqu_+)7wZ(O8#{%i=7w`A-X8uz-Vd&tWyR64T^YX^vCJv#Da;WJf9?z6Mo~Y} z3{iuWP-ZKRN!x_I03jbqxYa#f zNk8^xbPzPvDiHmm*mx$2F2TLo$K_t~VD52h5%r8Q9b2Ruq4Uw# z)4h!S=~zjSGs5mt-~I8C*1>{)Cg+axkRKz?F+&ROO? zPNSq#7$A=qEfT+F&BQ-4NAL?JNxbFMzod@&jOoPg##qI<%G^c#;L^Mt$r|=KOb^bD zc_ar}$Q;k^OYAdM8yqMNqc?FMzee_AoFr1PyUaS?DXN6{fOjV+VyVVMxRTh8Z9!jP zcNlYswfG%k1$QB5A@P)KfCs~Kj2k);Q;ew&DS{{BD_Aa67BY|?q~B;d35OHAxvTJY zs6xM)zJ%O^m1sLTg7iR7!F>@2d>rONkI)E=@1dTIy^PVI$2Mde>W3_#9g!%~33G?f z8?MpEU~lv_8fLOYr($!-H)I;N4?9b1k+3@Y&Iv63#ASH-3;{WB+RTKx%e8^ZfY~rhxHuYPqyL=dV=9v#~|QY!ZRhnYtdVre~69f zSu_&6k58jFpx==P@J?)pX)du0ZP&rZvnb6>#h>c6x>RH}l|ddLf*DoB8O)Q3u{MJl z^#r+yo6pRF6HM;N7W7o7m3{@W2JA^H(Ogy=;|kPf`k@^K7o&C1MLG>Sf^toKNK8j| zEZ2IV-`UmdgTx$Uh|vPcgRdGd7`LGxiQ&+c&e3`{a+auL1<_-Thw)hcD}E$JkUdSY z`sU7bBL%0Ks^Dsx0b5WD$Sh(Fc^e*qPs6N;VEi~ak-G!Q1UrXvYBx8Yb(y?_Ne!!Y zaj3muujn{-AF4KeGVg`P9)KrBcN?Wp(Gi=c;;meH5|j8|Y1dId5MdDMBKHKW6Y zF^rjx?nAtEmb9WHv#AMBmmQa^)sJZ(jh?0iV@$aaA=7lI*`k}L*k8;M)#QBBihZ~##ACY+y^~v5>k74 zZLDtki3Wl=MB>Ibnw@kg(+4XjQy2@lSJ^bSoiU1I#ZJRzNG^1T4mVJkf%Kv#kSykS zp@x5i@t%y}P8CeTK5O>2`_WU8dg4B{mbrx6i=WNU7pN62JR9yn+!mUyPw4bPIx(95 z*lN|hpNNp!i<3A#$l>6$-yJ-4+qrDk4=Be}2+pY^VVL-fe2BMV*STAzxxzN;jVYak z_&wP|vJfgo*5a#>8wi5j!7oq)I8%5A1{1g+&S1}Hyw&d3UB?o*FDcTfAeroa?5|WE zycUNUgV>uW33>r|()J{`F>>HpG>>v%Corvm_ZCfEVJWGLdZ9_iddT0x`iY~MHTE8{ zVtm3@nu4GTDu*|RjH5isW@a|gtlLCS;e6zd0?*?tbUKlUZ=@t(l)b|Z^j+gydIb@| z_(ZrAmlz>RbvMCqRiR^<9LRyIi=3}KY$&Ibuwv>9>pA-)yOSJ16)}Hso%l2}iqxY`Xfbe7U4a$BUa*PQ z>(&}}!4h%+siGd?ImBbKjFrm2$E`wm^cKh&tk~rE5pt$!Q|CkN2>1}Ih)lq@F%zgI znV$kh9RgkR$_1m!O+i3A}D#-O&fi z!%7*?xOdrB#9+onRtmX}+`#C@8OC{uZbuxMpP5gvO-LT|F24_}8Cq|MWj>+Q@L~N> z{VIBuaSc9^Y9qPGB-)dcBor}bIIHo^p1wDyc1KE93?|*_<&SatYWcJ%l%bx!4+dYFeNV zfD$o29!A>3O5op?jC7#uwO0&V-~<-N?1{ZZjnGzD2k=>iw-Sf(N9Zna(!UmZkL99S zuss}P+-*DxJb68#Y&ad$057LhbRX7}$|cSKKc*zSiJFH!frKarodY={4=_s>58bMN zqOXUikmc9{Vjt+)LSwnnkzC1Q62Xv*iH|SGwZL<19NLcRj56@Ay77vgnH zEyu)o0q=pM@wfPF)C&1T)l>bkbM$EA0?dde5J^N4;f?x0Ji-Uz8zkDR&{Vbu`ycEu zyvF3OFV?)%R=}It&5SSTIAjny8DD{>VLrfH;R)53eU$u6Po`ang_I3q0W%>-y2^OY zw8@kVZ8xREZ*XgJ1u>N*sW5ybyaY`rP;$HBM(aOXxtEIpl5(3`J*u|tDF%vlptMFmKPa%uQ#6|QgLr*vh&Vy#b&uJz) z82A%jM-cQI@esd>10O%c7T-Xq83g$^tf41>`D6@nl~_p*K=Vy&pfKnpv6|RGJi<-_ z|LIWpAa#m$7iYo*I-c>AY@&alC{afmp#ks*s*DUJzr*j0mBxw4ZH5ms6CF;k(%C^@ ziG%1ZW0T1P9gK~nk0GClN!VssWBLl&nI^*qtUtv?78-TPO(GWGW7uIVXQIqKP&54= zX=mjTO>lSE9ePNo)3j-X=_S%kc`}FNw-66_KDG!Q2*<)Kf=DDviOSzT?i+G13Z=Kx*tHz5tTwG1CabhWd;} zpeiB}BcNxdNk&)T^?C@(H4TACVls*Y7xSrx8q+zroLIts#a_j+BNWgzgP%SC9*^}P zhmw8JlTf6ooUXuj0B^;UcpPyC-vZr7O=u0$0LH%ycqf~R7J!|VJIGf$%v26>U=217 zJr2Lp#pqUox3W-XC?0^##&;4Pq%~8{+=)Mhj7SRO3hM~xCp8Lrt>2}YZnB~7vFgZL z^f)2GH=-rTU(i`ZkJ^xW>MzPfsmP0{GgM*vj$1GznT<>dV<6E3?5fJBR2Sg*jAG%|DV(e@3q;qK=>vDTg}T*{l%?fa6BV$B$-ywsfWx88 zVZ=M*JB{411}nv$A=ip-K1<5z3lDDqskB*Ge!B zxqvKS1fjD{)uu{h3L!+i;p>nHU5;YtH0l+39JPmD&}yPLaN~c$7)2b#1|!KtCE-eD z;J@H!P>3noI0GufHadL{ zjQ&E;qX}dYC8lUR8Lz@JF$<&v$tHdfmgq;*0J;v&!Cq0GR8Qg)G8O5>rxC~SF&IUh zBG{M?_Qal%S@0nj;13bz826%Nx($Q3;7_I^{ zZVlMRL-DDknjxX);F(}OeVDMrod_La2WDCiY%HEjCgbhsOz`Yq2*z0<>VYl8GLTT( z4f;YCLO+oiD2cSvX{JzQF8-W|rY4bOmKu<{T>+n_s0InenLxlGp>?7_(?1=}Hyp)903`T|}>ryDky^vG3w5P1SO zBHzGs)e7kgO@p_hQ3Qe|zy@Rh5kxRCKV&d67@Y-1z$xfo;LYoe+(%21&B!s}aeN$J z1_zTS)+^RXaxP{9@9JlOhvgW?V@3yK4(k)+648gaL+TjU7&A#yAJw`ww$HIBu-z?6;i$e3j^n zG)&%0(jYMM*9q=PS|mJ4l&G0g#>l76G9NOWse^qLk zOe?_KeU532$p`9(yg`RyyRi{yAUqRph6CZlCL6t{uDx@gZiqo_WEc&GU?iE`&*;Vc z#7Jazr&eNrqYIIL>1~F4+6HZ^Hd52GHKQe@BS81gP^gbEVS#w9Xr$meZwqfP=U?U; zc6Z)t-d#>2=OOnskIA1dxF{GVoFjfJeWw_!Jgnfzrz=<%*DT6Z^DPL)L~)6rMSuy| z{5Z}z)&s_BY6GK=sbin!Jm=)|?h2%$hvIh(phEq^2LDXWqVl*h~7N=!ndU^c%8 zw>P_lsb{=poF*ky5F?P`$mq*xXFg=cu=cPjnCBVM)M;`G@eGsTTd|G!b&Q8<(KUn? ztUWK{p5%Gz2GtFBMr^Ss;GYS>kx&{`ViK7?8E+b8#_jr91`(Jg-N7EvCNTdkL5^X! z$%mkoUX%|t0K7qL!zttdSQq?)#zS-H29vL$UGJ)?c+o)goUe z9VcBaV_7xX8(jW!J@2qu?I~X<&J?`krf{41Q$$yQZ#q*ngV&e6f>BPDaBZX+7D=iz zN|j=Rc!b~}FNBvYSR+KmHlkYod#)$Ph35v|vzquT#5bgRnNXH3XUN>d4kA?CCMgi7 zi%8*WUNG=fQ?hMYuffRa!^&kZ;8qDrM8}2O1fv8p;S$kK@oj0g{FF3HdPq7)<|R8I zT_S&{C|3v-1@b-0i^`K0uT;Q+PQFHzDO@02B0SG`<(=XdvVs{i7_->3I1f3W*dw@; z*xwmfnD@B861L=>a2&6dvz7CcJ%O{Ctz_0x(L^0KAKQmc!F^T-fgi##oD3wV) zCHGQB!k5@atRSyZw!~sI9+?TZp>N5{q&>dWRAq1j_w2H1l|Ehbv30ZNlc7I60l5x5 z{bSK?$aaiH48RURm~M7wWyg8VGR>@xyDg@MlEx2>iyGt&F%5(2TB>_h=QcuZz1kmk z9Mt)v$JwY%W$odBxUBcs?3)#&2s|60_dD(*^i((=Q%5P+NcEzN+_6kM(gz!bjmD+y z@q$~ziNZ(11G2rUC)RsyBGfLbQ%bo-s4__sExaL63bec?&Mcm%pg^!m=ppu%T~)5L zdI$Vc9a5h73~wOoKBIt|N_kOrl$P1S9K`&|+|J44w~E}Q1=3lP z0Lc*PKzXM;Q(_R=3s(!`1onbx@nuP@^lzz$?6O><47WV0o~$mnqE!=B;VMs+yGmrS zL^)VqCv}uAla@###rDE+f{Q|Tajs;h*i$l0%9Zz4SzA?FDb$p8ply`xS^K4qISvP{ zW0Za5;nFH`AK@>-Dn6IBnNXo`5h*SrN*UKV&TKDEF4s;lMJNEi?~BAC!W2#f8|N+O zY-P0)-tYlagdtb|O>fZ6(|TxrwI6T0-M&S4ojwYlCQrckA$r&do~ZW7QaHu5UAIEB zvEy-jM5|-t)LOjeWlc`)`C7WFr8NC#c5&*@*(EKdu@!47eQN5O2AgP6lJm5{?U7>= zL;A!HIFl(HK0EtWdO)uu(dT`BT3?lXXFVZq=>40W>yB5p);KmF(OXdcIOl~^<=?H| zxs-Uf`Mdc}cU|kmv_EC@%W{q6Bu~fqOy)59v&wkZQZLI3wv!zvc|HsxBmE=qg$xa_ z^2&AA*iKPVvhG}4?6vN6OJh?<>!?n#eu$xh-bnn*xh(RL+o}3kKd~=%%yKBVDYK-M zFT~lrW>zt^n|w>QQRkR0tT~*OyuXEaWD6~WZU3_MRuT z+BDhkaX94g#`b_EM}9{5k$Z`gemA)>~{i2J`ehVt< z_49P8rgBg710qN@!?&P2J?MP)pfRG!muB9W*)XMS^zA{$#4lbytgi60akXx5-P@lx z@@{|L@ombl>c*3%kJMp)Pvt<@hCt_tpW!z`rUgv$s&q9utyf1%Q+Z5QA8H}q#<(jG z1HY9WwoPu~0jtCFV;o||z6?MvwK}a`TkO`GGqCo+KsJe_191d zdx2PQIo@fP=LO%{zP-IhyMA*xWy7^RsF*3a$ggGg0rSp2MjvpRXT=%DMTD_3cZ&dv zV#QnO5HTf^3o>}SS;d3|`DXs|$g~h1gUuuNvf}u1=?Uc(E2iypyJrqH4jJ}i?N(Ul zSe{p5md7k>RDCTrC}>%k+|#DRb5E#q*s9Q$kgXvuVcSEOMeGSSxo6q&R4*mnIFE=H zc)ekA=f?KWovZa@4CUx&_7{n}Ws8l4^If+Q?p$|AmlO6UEqf{&rL#mQcrzK@kvMIB z%c!;t-M?T}>x+CxhGC%$7@Pymk}MH~aW62hlS#xw#KP3vF}W$bv9+P4VMN2QhStXS zjpdE;P1Vi$ZT&SB9rdjP8tSW?D=Ny~l zgKb=0ANiC7O$vS$^vk=&X^ia&b&G16e6hfX`49_7uM(Ho|A5)I+-aW2O&@0P)9BsZ zKXrc;>l?P%x768RohJ@u9fuZnFH3=w`2Byz7X zqOlRs7-PLQvvW`9D#K`G4K;@wB^s?LRUdbZcISIKc{aLlbCbBOajAA3Z`aQ{Qr*ug zTWw`^(PEiJsa3L5vd@j+BN6Tqzk(L{@qAqT4*S}A6*+IRzOEQ0uw`eFE^v{j+&W2lUbvZalDdlY*1u}s(|V>gq;rH3 z11D6mY#)AaaW6@uc$0Xbh$Cp=nwSdgZ^N^;j>gmVMb%Z6V=BH>?5mm5aIz()J+srx zaNO9J&V&MCHf9YE(MNV%YA&h2Upcu<^fSNU{nxe+V?QW9?aNU5ag9@h=Tyz}pRb+aHAd1eHqzIJC6XB%hrT{%*i$vlgTupD$4F@%aWSR%C{8{so=W(BL`*SDrnng?H>ntc`Pg$*;P)$^?vuUsvS&fnhi?;DDvAtMV7>lW=n2O$Q zs5cat4ns83h*@DQv=y#E5{b8@41AS029Lp*Xe~MvYe2@r+l?!AuDXHxcHO`>c0*o` zp>jlJR^`JAV`qwr&{)7^JI`UT_CL{G7n2iW;o2ni#7a82&24o>^?jTEX{l(d?mT0hO^A7jbb;kG z+qJggwu7vTt!OKe)lkb0g-~K8oGFwEhe>A3o2^uix7;A_bAH1^)<*mr`#5er8TO)F_8hUwVy$|$%@3PYyH@+{ z_T_d@ts5;5$ywqVf{{FLL4f#~j4AULd4ca7uUkB~39+AV%U5qwrCK~vgh<(ZONJ-L zfR-3{>3V5ax6kV6)DAKAK=s66)&k)-rPi*(J=aGUpbQp-Mn(`(oY>j1#W7ffG$6xk zk*kCAQ3tv84&`{sWFbpGIWfEz!EITX)pEyw-G}<@3n`6w6|Ie(5mnB6ctoxO#_C)-(8LCV9@22lqul=TU}VhYz*x8Ld~ofsVGsD;*zSu+R>?(SE}FPAUu3lWJshY6g)FSiUZ#GKJv75zv1-OeuUjY zt1M-Qj3d9K*s9hRu`w8OJbFcI2*n1c`m?tblWLOBfoBB?5VO#|ir z=ImP5%r4{3@>QW)lElYwdbU%bx_5kqwromaWI?}zk^g?@x$+FZZ+tKOmh|K9PxUYU z=lY-KpEZ9)=4~!)SbDOu0rEq7(KIv+PwbyMFk@|Ym&QZdsk+_n`M!&<{kRq%>%XX5 zoaw3lD*k(LW3yQGhnA7Sl%ClKx|tooc`g;nf0?U>?d?T|Q*IHo#}+W%Qr8QQ8x%dd*R(m1t?w2{FCfz^hDep_OO zw3DvDqIbLxxe!qhbs?@*(m!dapYwVV0^^^hjg zeTot4NZm5sF|AQIRhz55twGgU%DvJJP@fr!9O4xm!}j-QdZpe1Pm-7M9q=6p&4G*-yVPd`SH=`r(Y7jZvMIZ@8aUM zzVT!W-Id_xQN{5)Y8s;(DRpDCSJZRlheWTaA;eN@wdABir+KKR zH9HM8Y{OjHQ6&j+)y7pPtM5-al=>y@bIn^d3<}1ghEnHaaLiJKauX1X_Q%-G_6I`OZ{9`uG*qLp}nb(Hf}TD zHrF%1HQ%tfZQ`KN;M2~fwv(n1!+HHF;{?+Y<568JRkkux=~p?lpS5;VQ)?6ZQu|(e zdeGevMO5pUc*xpnB9)QnB3FhBA*+KwIo{h&Ten%Y);$)>ZdhBsVQL^|PIE$bvdJ zrS_xdQTq0}SiRaXSQn<+AT`q;fKyP1aFQ>HS;RX+Pi~;bFpb0;B-bT{(!H{Dd4=qu zY@s|)rjp4dV?|1+KOdu-lDDyM$OJx@TgT>P>ywqViUH$O*x`|!#7@zwjV_x0Yt`G|j8^Sg6#%YcLMNN#EJozd}UYC0Od zY^CXF=qz-s)ydtlb%&9;5p6rQ=%3TAx;Gw)I1s$mPMaR7qooHZj2ulDh#pAa$txwX zk~7kD#Z~n>b*b{Na*b@cXf^RsxX9NLa6X4gt5@eKHR|!2J-Wt*4dx*GG^faQ zKSUYUI=oKA=cvokUn9?jsa*|%%+3XljdrcIj;X2sm$svJxn_bkQUAhVFjg}xFkCRa zHTVo&3~ThGw56&WiXpNo5}sK~O~X$LtvHOm8VC+lz`J+TH_P865XUVMo+5GBP+UnA z5;#=IKa)7shU!jC!W1ZYpaeBH&Og+<$lbE+4R|>}=O53T{rmloL0{326W)D!Kj*{o zkMlph{KEW}6fAT{U@|#tm>d)z{XS)HR@27AT9>!~FZWiPEp0VzXSV9l>}rlWU6p() zrfb+rhs-oi+g|-xIY=gxu9eo4b(T}g9qJ18LFIG#F4+RvT3NAlq&S(1!OjY(ki~uR z?+qaQWrWAi(o3bYl*4qBtp%=q(Jhl+R{t-ppk|HQmoqP9ozGa3R+>CLAw53?S^$C zQ>kUtcgJpJ+H-=_!t$dd;*Tecs`|THhtv@@r=&489Le7k>qLF&-((_Dfo(&U zaV-P5zny=&zsz^k7w`MvP4pe}r?N$X1%cK6uf7C7?|tP7^}Kfb%BPmKF1=pF<*&-i z_#^&x<*W0v_tWu@zW2HB&c1!{;pC6pLS5w*VF=Y)_SD$T)iXZ67FMrs6HW6w&E>7* zTc@;s*a~U-DThsOQQepn7F{>wqVqp{WAhIE3SB!*cU3D*p}v)Afx)5Qtuty@X{YEq z=yB~U86{pof5rC+4cRV%4gT@|=DxQ6yKFh{!#9gk`Clx$1!8gY=N3iFB-NtK1`(fmd^{JW91lTh;K+c-y$d zuuYe#E!6zg{8THHx24&lY%&^45RS2?K&CGRR=d#htED%}R#qgti@^7Mv!ZY1h018p zYR}8c=I(E$HHsS);Qxw#{r&duQ^nf_FY%X}moHycc^C7c!w>$SuTSrsYOKF18wWKZ{FcyyS>e2_4Bh@)_j({yh=vYqTmX%&ahIqNFT1Fbr<#L zbVmJ9<7#t+rO^D`m}8u+M>SoPS0thIW$dGHhV%QnyR~IiN;ReJ%YL~#d3X92usblX zq)^+{`XabfgfI4T)l;btYCTRTYPYYQTYGZNW+}}QvA8pl%R@N(QIk^}uJ|ZxG2rIbMCz?Z7C2!$N(e1()uFPNQF}f#||01K`~A7R^C7{Sh-2L8usRG zWj!Us#37Q-;(<&N*#~cleM6&w1cadNh3@lcpyxAM>0Z>v9)y(@U5em&yl$LAeiD&H>oROjcI!haQhb^)zc-?EK~ z`d0NpO=Z@9IW?P%Z*{xv=ysP{H*J2V(f#`AIm?0Gu`ao7!uFW;5o1F-1e=4NI{G=D z*=07=T;KG@z?gcP8XF12X3c)(0{MN(EP6Jc4eo(i>;&J(idkjNO9qt;EfZIG+*Ld? z{7T+{?-2{i5&F*NGFw~MwTPv$-Kr=P+a-innOS9il{WEAY=fv8;U_}|1rHCJVxMZc zXzZ*%rTeN)*ZxsuD4xrg$}<$FWkuouolUhQF>rxgfh2LZKiqHj$$X2wPESwoJMSd! zI3mJ|@!8ZuNtQBP_rti%GTecOR7Be2JXLxmT}WP7<80cenhR@1)@o6csCBfaCGAg2 zXw{U2HE|)aY0>KX5=O%a*Fa@w=F@GqveyWNnkOQ*R@|IKd%3H{QGRaq9hYCDiZ~c`Yzq4Zf;)hm=O{a zkrOLU9A8yjtu(n)O8u0z)e3=(<4w33UoZY~+`t$<(iq_k-xc~kq((@lOB%e<`JZEw zeY5p}`L$`b(WSR&D^;BQw4{m11hvx-SRTKGz3tETUad4&KB;tizIgh2F<*iIJ%__- zm87JiHnMKY{^~i}t%myMWP7>uOAzBq4z>lC28|Dnb%|Xz*DvQI`%&9d8*Qg-5!OED zi^c|^+ofxx)YFuC^7^u3@RjzXB5)1X8(n}-MlbIS4$^~_cGw;!CRnNLK-J$cfnr1kEg`lr*dxP zxXM57Y3|pRLEaTSt+|Q@Ys*|H~Sj^HM}fiE8_^#Hs_SQR+mE}hMAV84sWLBNSmMZ}waLe; zEl)n0)G@JEm4vuEF{h&vB36gZ4XqoxK6JgSVet4M)-l29bw&r*398|=JBHeS*e+U3 zruBwv+WV?v#RxbdaNrjID4b^7`g1)*Wpc&&vb2gT6|*XyRy=VhdAA1s@mc6Ctb48P zuQ93nDc>nx%CF16$`(ik=_jdDnj&o{*(1Ixo-4LWFG~AKFN?=RcHp}-TCrMjSS|-W za*e{M8VCJ12<}O z$g$vtLE+Aw_WIV7rX1s2{T_WU{dnC4%@ox;C8heSgyV(0k!+_lAh|91A-YN5BvYYc zw*~pfm$9tBvHz1d%BS-k^E83|%|u`0Ks(OIm2p1)AkvliK$%59#jT`G<^7c()#r2* z44qBi%sVa1Y-8U8G8>Tl1&apW}ZI|4S^GS@gP84x{svb)p8ULOe)uUvtY4 zX_Y#Ey72I)5iO&GVmrs5sPd>vO2W*9$qCo0_~YBfJ&ch?r$)XF4-Wg_@&!F~l-j0S zt6RF7dl@(DIqf>lL3Mjog(6S>Og2vHm9!M!Wy0uaay$MM?SvHa3%EA{n}4zIvp3c| z!*i`N($m#b;K96d?^kb69|gKf0XP_y=p*a`;h{EzlZ%pXP?RadG_ABv_45pqjpI#2 z%va4dER3bVoMgc)OUx}zO^nF~m0^_rux_k&hkBmsj&iEXLGR8Ljkl_iQriu;OI%8iO|^7BA)J1w0pohdDsER`g|_??3KbsMQz+Cln5 zI$ribu2A$=n3emL>2MZ54Yh#n>Z$4->TT+N>Yu6!DyyG9TnZ&1@3$9_3j1kqi#8H+$^4w%Br4x z&v)-VUvGcsKt;gKX7Oeu6I+bGCF)X5nc3oZ(n5I;JM>(dY9jeJQ?2 zzR~`^foK-tzH{gJw*rB-z`Ei+hzq0-Jb|&II^sQ&$B=mHEVqI$MWWQI2B^-eUZ`%V z2z4-!`s8X6={*mL~^q{ zL0((irgx{DC1#-3nU#Rm&Ip+-?4!nM0FzX;dihZ zXcOcYKY?q?)(+J3=lGg<8+ztf{&i=&7gpdE+sg-)*DkMLey{vo`Oos66{x#KI$*CD>==IT&xo2HyMLdueZ~ zr(I<&_pyo{<$M`d#*}t0k(X2y4=;`@zFl;x5Gj0H@TTBi;q@X>$-OR86D<%0i&V^f`Ya`*R+G1gKez&K zfDJ<-F^^0Xyu1S{Tu$x4;2*>eT*j~swtiZ-$ zO|e1nu@?&g8fgr^87GN_#1o>NNF=9`7}Xj)%X_Go;G>-lC+8FGBXUal*@mA}oG@&Tyxl?XA&8er4DM;fEI(Hh{YT?!0WH`W)Qg>S*1 zB@9smN5B@OSD9EUnGcHi4TZvk`aS#7S}^HHnU8{LEZYP4OpjM~PeFf@>(0 z219CMF3iZ|;CH1=B-<^`kg}4Sl2pkKalE((sQL>SH@y_x?ML99dj!=&1$mUHMO*`} z?N96_wgvdPYU~_3A5BGr(NY-2i@+~_3LN1xFej8iYB?4DMTfQmR_#Fa9Qp?6wt6fZ zn~iZ;4WQ<3!%u;euo2M_ylG9zIi!?oNFAZhQa-9Ky_P;mPor1TgXv_t9!=2OsNvww zehW{Xh1^68B#ME!I~>1^)x~IRG1?w&iXKKz0SUPSB-&;I6L%F734iwj?GA5EC!q0~ zAU9fr3Q(KqM4*+P5 z*&5kB=@_Y0I$v^H>=oHW%^9Ay(v7HtSuL57dWIOy#a|i}`e*9=Ah& zAUn~Pm>U0xk0eHuE#Xb3nbpiN$TmHJ_x7tKS9%rve;;IF@}ctC@*VOQ@`ds%ppb{j z&&Y1ZxG8IVamVdnk? zGxu0*HDq?y0iwPOagS(8ULdD}x|t0b$KK3GX02$cC`SBIOiJ!aaA{rXcxjPTB-6=S z$S9c$oQRX8{iSN@aY=K@VR4-Js3;1a90HQJU8xe7%LWoMq77u=iqSTZ#u$Ly5gfuL zK9Rr3)#hfgYXThu-~2E9P5du>1AJe+PrMzx8QxIuMb8RPs)wyyT-mR(ZRI_8SY=qH zy|N)7c*j-F_n5s0yr+Fb{igzxxR?A#!GZR{zT*+pAzCLoEB-7QCrej6SGG`();PgACz@*@ixbWgt`5(mw!?!65A>%|P`MRV`(vA_2V42~tYZUOZIPo7qO+ zpi-#>vK@gEy>KHw0kQ+5Py^J3-XJT0h+YR=^GV1z;UBDr;NF6dLXm4io*)HU_;%zc zl86pQKcgtN4f}v)!_#Uf`V*50KS7fV$rq##p2m^X8R{$5oUTgS=*i%p`9eR1HPZ-l z_DH5Rvw)ckxrUw0MP?E6m)Q+7(sl+&oQw+mxi6UO@X=SsCi26xv5VQpyklyDYd1-> zQgld^BHk;mAqkSymUNRelSE15qzk2KQcj|frb`b>XGoh%pGyYA3J?cp(bnQmq5-gG z9%ho6F7zJi1lff25d(eKaCbg8=PT2vFE z&Q?8EqRQX$1K^9CDnTXn#EnF^=pU33ay>)I@x&=yji19>VhU_FN~3##RK5e*h4e;_ z2)%@GpkhY@Pkjwg!@2^WuTf7pT>W}Iz8csv&Vs-m;mvwh^!}b02UG-(U zHoBYIf!a#VI1Q_ks=h0J$aCb^r3^T=n~F`MRHhewhos2IcqYCFdx55)-H|~8%a7+T zaG#;lG8NRwNB(2JU+|VJ_l$+}nXIzdt#jY0m{8HJVlB9zhL-OuJ5*M;>`Cc{((a|_ zOXin0E2~zPRra(jxq@}S@O1D+1S;5LyaiiJjHbJaJIJmo$7xFS1I&|bYG=RTIU()C z=SRjz?~5_Tu8#G_9*DEYjf>q9!$dEMN{eh0{v))f>q1a(=Pvtc>wo4oMz4N=?j@XD z)+k5HdrRv}=7~yZ7gdXJKn@zZPzaF6VXp@?0f&F1*Y2(2Rd_#nEFPVwi>I^ayr-FW zqwl5ftN(dmEIXS^6yjmcKZ3O;ev&!#T;{l_OfpMmQ8ZRgQTf$fv;kd|;fZ0eQEuvQ zzGD7sc3EDVn^*=}4qA>`EEco*jp>GIwJFJzY>YM(z;CV)!&PqkY`sirHR zDMkW+`iNqlB1I99=gXfe{EA1)56ZjBAk_uc1NAmdZ>>`MMbkqY(DJ&I`uFg^xO5fT z+ByyB;dLR$dQ-DTBhi$oI;zT)ZIm6A>lCBqSu!V_q=WbZLJ@%DDJ70caDPj7E+UjyGp-{e3| zZmX~e`Gwkvz0`T;o%pG=s*IOiQQTMk(%5y=b^mnn`l$xiC^vJab*3Js38q=5ug3Al z=LR!)>Jtp%`m5Sun&IjU^>)=s)j?$&MZEm9EL0u#1>w|#ifV%Lq3>f!sty^$|t>cq8=x5N#MKNgn?&cp68 zN1|*IQ^RkBE)9O@=xdrJSj{HPKn+a_o+n{OF>nZ;`jdmxZz}GUU4J z2Q=2`YyD+v1&UOC^)=aA(HwFV`j;2{eJlHvH!MvmK3UYH=zigc!Wo6bif@!FJdOS3 zfn**yBh+MxRLN*;y6VQdmj1TAjtN17gY#VDLp&j)!`6na20RY1a96K<0Y5X=Zo}5Mfqt}Zj!`^xTID!u;x~gud8R!q7GbUMHI=GOEh>x)&l7^;Q34{f5jh(#q%K8->%hr{?D;`|(p~O&Lt8$0; zT;Md{0@Kjz#rJQ{+!JqI#cj-yb)4&NKof;?csB*14LU%^*j7?2im%^lt&QR28 zSZ74m?~FECo9iIiy|Ybq$U56HpJ!FgK9_kd1J5{@k(zEx)uhx(N{RUs*3UK1-c!F$ zK9bSkBl!Yk)^FG=6G`h7r2K)F>(wKXF?>+q<^F{WZM+C zReD`L@J5d_PqFHQo`(*PoEke9+MTGA^Hb`lZmM~`=7yRzYIaTgRqab+r#N|pGbGqK z)0$}1siS4JKqcD_1nkpD55AtTm7mV12#dJ>Y&v&}ofH@m2=Z_D*72ggbiXZ7(?8A^ z37m}ncnRG~d|X-u|Ek)x>bAtm3Af{xNA3w75Dc(CDUXZ zrHf<-JW)6mUlg<|Ls_W!2$hG; zO0#mU;uK^laz!4h8}S%v#qRcQaIYvITh_N^NufJGr?6{LcFEq7wBlvO&5G2;qLO(< zw+hVvdi+`cv;Vh}PnSQ;d3*kq;dS3PiSKWHT<|&h=dMDH|1!N+KRlR?JRAQn*<0&o z*4%n|jmjI44f@nes7K{Y%o>&1r#4bcTeE8I*sR%^x!HB*>*=@8TSZ z2Tt@Q;#;y$ng~lo(Dcw_5hLR^CJ(5k$!?$Xu-@SM^7<=tQnNSIj!aunZB)Xy$Qaja z%Okx_TU8ULnWLJeD3N5t8z+%P%P&X>IsIK2Z8~8p|3`KZSHihG%b84tA@=~-oCNH{OOghxE&PF1k{et$|Zkm@E z(!teKL~kdD5o+u*GK+WdEBOQbDPbgkF3{CGq*7T~)jg%WYsuV#6Zs7aMi)#gxKJ>% z=>Gq>-tN50cHY5GE2>`j^RT!uBGl~)IJQ~)nxB|V zrb$MT@uS{oup6Qc8x39b#oAQ$dBsdchV(3R3r`cK@Cck!Cj_SWP+(m|K`)#gNCi@g zRZ=!_Ur9enka&Zrz38{7tt3;rSeB+}tP(UM^t8dJf36>FIBXnYq(Dg{wGT9xv`*bC z^&v=XSd_f#sCJg&tbVS3jJ5+L8}<6ChA-M}>Z)2?`&GM3KUCkt_!_*4SWrLbSI5_& z=AoS&*_UB2MBphrO?oUd#Fvs>Rthv>d* z5>;Z^cVMK)F-lQ&(OpJHKP8XA$$k;pgB(kyL)QHT5d)_HE!C4eMh&1pgUj-z_>y#x ztcP5XNo5(*z7n~3G$T;m$t}1G-6!mTx=AB`8FaO|&T`J*w1AoSHm1B|0^>*0%I-=}d;Sw!G%hl*Vxr!`3?G zHp2Q=_eL=d>XVzL?-XXmW63jm5566l!%yPRa1YsQfiiG$UGP`18E6jKT(nFgk@l1s z6!$b&^)7?N;L^0wE`xMvHTy>g?MevA4-F4n9@Z_aYsCA=Ghus!eYVTSAKD$7Fm;e7 zRC!AtDm5^z#G_?4SkcnyDR>Gpi2E2g=6mXi^_=o(yxqK`1Am1}_&#a^BN1;BeTQnM zLA+0VQp||0;&4fpyqoHpwz@7~H%edAV6n`x{IMq5G-leg$#mT0wbi%Jw&j_xn;Y8x z3lfKnw?~>9>iZiq%-5`MjF$}abzXfN^DWC(+eW+9dC{@oIX!5DBh}H;neChwG}qy? zCR?4>Iu32n;h>(*a+}WKx92!FIS!hC=%%VKEBh*2DM_oGe7EOalNGy5PMzYTqX9fXQx1$qsh^23;#*h_Y0W{SE&tK&(sD64ChTR%@W_*K!HLb1-0@Vxr>aeB z24^*>Q< zF{|%e#Ub}D&m&*c%03mh%XO8veH{aPxLSNB;>KKXUVTg*U_MFp%3N)1QxY6a$)4|h^-OV!smrnb6pG)g04E7I8WQ%mP_UxMvtzE#-uPxGMFLcUVI12 zfXDMR{}H-DdAJ}>%!aTX0#^c=P<6}_bih+s3|t8*0sas17s-9;P4R!yD~dU~0s3@p zS4gtaat1^l~d%BSk*np^4#xAx@jWp32W;JeX4%1 zHc0nbE7JGTCaH@+&C64al7AD^;<}<0z}YG!4**Gy#aCm^pzl`%`cjz1p#hJ-D|?x< z^N-o0#*$G>!7~);A zr=mAUR1K54JRwU$>$qAv+8bW#wUA^@QMD4UBxmC$p#n9F@-GkNd=qq`BK zu#Xe-!-c!Z2COq$9qSFrcdh8HWRv`j>_6EI#W>Yb4WYfOEi*hf6q#}@L#?wNyTN@D z=Nb_7-DL}%ANnQuwIe;K7qs9DcfAI$tJ%5O{>#dk!p(`Mz4}XfLj6%BaaLnL zpu%|>ZH%5qTB0P(1{2ZANKf=0s>5~*U%6%67Jj*~pF0r92;giJZV9-Vy@8E^L4imv zf;}B*2lsZ9e~AA)Fm@)g6#J2#%2~LzoWLIAj&eEBcWW(Z5~JWbld*#XyZvQ;ByfxE z&HsRISFimgfg@};ZU+BDc!S!oENma%k=iEmOFPP{$yY*E$gK=kFVnQt3&wQQK=TQ! z&K~N#;0$uic0LHAL!O3Ib%h1@bS@2g8*E1NaBL5%pMHLdO8jXb!yt zR^uha9O4ZXA?qhE6;Gu%GMk~g^;O1+)v|YrJ&Gu4n#`oCu9>A>uXqY-@G;e9wL+Bu z-wEy+)Gn9F%(8fiU37@*A8YThMXBFysQNMed>`vQb!t3FuP1KC+X2i2MfX$5Euc@EutT{rJWR znSu)4$L~e{Lp3~u?hslFiAX-Xg;U|d$UN=>f0rMQbrph=e zL=Tyo%uBk2t|z+B^+o*>!<_1!M?Vfq}^hnNN~v_|9%Ai$+# z17o@ z6l+6Rh%@K|jKwbC13@#)2VMCVz7}W^`KS+Ri{+w?pkh1<9g2?=obWDZV%M>af&rU{ zHi3I-$F`#r@m@#-ROiF-IAS{R|5~8Oa68@}8$)E_W>P_%!hT{RqB*$(o{C*qDp7+7 zBj-cM!uvowyiQ&ddJ@gCyTB}4h$P}c*g0Yuq#TxjGIbuig6%-}!@a4AKLq+p3AP)X z06*g?^cfnDKgAZHeX*J7I&2@*?>ZxJdcsI-9XbZNi5^Espw|TxQUz9%*3cpKu<*a$ zzEgx8aJG|3Tllk--^QmQv-u*vk+2=qiw_(MRdx|*>$&_C=$<(jaR}3eJJ3Hg4Oxen z(AmIr`zj1Z!_oK1L3A$kxhzM3)X2|4If3H)Aw2{)auI5`2r>uym3BrC3ww}dD1s(pn}oec73>K*9Frnf zz>z-}DaJk_4X_yOAfmw5BdzexXgV+z_airOE6@cO;TzBxY?QDJSed_sXJ`zXiQmIw zg(74)RtuD%{X!yk3~h$sFqSYD(8;wW#vu>T473a60qn?8EKa~NJsySBM^_2Cz`<*b zuy9WcvE>+p3_(|btMn2kK^kIZuq$YT4Mvq%Df$kniT6V{AxeB3vJLx!EI{)R7z5NH z)WjPIJ<+%5T46eRhiB3A$O>MCec?wVOA%n7K(F9DUJE@D&qGDDg5QO-60RbrxnH1` z-r;8p2KaX{=)d|2UgH> zbQeC3yNQ2=>wJ!u@Mnp9;WKs*y8r&ddtqh#MZ6u~0N;tkpp%Fh{8qfBP#e>uOZXym zi0}u$fZpcD<2K=95xiL;RTFREn*dN5c|br$Tn&-HyvHT z_M#dR8#o1AF9(#l9R4zY4|$I7AeM4I zBh)U;BL)9cygfG@Z-eUCj`)Uvo9cq@4Ukw<_?fT60ga9f%)uqYVOfs+2v3{w6p03%nw-g*fyoep6TuGk0Co0jp$n41>?q zq=ubIsQx#5;hEfhbSFFyv+*JPR)iuBauTv9_Y)Mme`qEC&o`8yftS?@uNl}#`U7M6 zy-Y{&g80C56D{c3_e7fT08K)I12%CmyBLvU=D>HV9#<+%CwsD`;NtB^?dJQlwWw7* zBbey3{x?_^!q3ekSNIe}RcrwI9?#(Am<#Dix;Z;?kM83gK`tWd1m@sffD_)9zlC%Y zZUEtSK0gy{L@L=sXav=Z8wDMCt0L#gOMx56A-L0Tg-9eHixj?K;n*I&4Bdy_=l|k! z`D^HW?ge^~`sGW-YNNS0k96S%;}>`}wudRM9ELt7ox(xnyD$Oi$=OBwg=xNvq@L|X zD=AI+J+h1_L>KXA*w2tmeFmL;^;o)4D%>R;$bbADVI8uA0kW9aL8ah_gc71F+YtMT zUjf==5c?a`Qz7hN+%5QUDO#VuPodm)G$5R&_9K;?3-u$%MH|_aKnW#6n<5ACecUc= z3OsXP11*REFfQL?Q}IjOXY2=A+wUhgKsW3uz&w;;MXZS2$nsDRR`_R-Erg$f8Gp@o zK=)wL=sfHY_W(nn7jGKf*_(pd;Vx7l!?_H+J9Mw!3HSIJ5&&;TW4sT09F>A+>bmfa zwNc}N7j*@X;n!mC1q}a%UJWFYxA`7Ob8;^~71q&wq>K#mThL4@iO)y-Blqw!E*F`E z%Ypcu9q36(u^9Fyah8=4LvSK+9lMB`Q8_jMxy3IcuLz^jqe4UUBQlnsN1hO733137 zJokScYVo;&cjyC(541;%(b@#h>yRo$d*K4o9UCP0pfC1bd>>Fd9}~gI6CxZN#!exA zbJxig$ZfVIQ|y-!BQXp28SjJ45?T|TP=tL$%)yJ%Y;GRf1M!m@tRRp}oEEARYmg(z zU%X@BF6hZ&ehsX>UVIMDySqs{pzqioR3qO5MnFcP2Y4m%2YbVn5Q~7{XMmaEJen<4 z1qQ);dIEKk%h5O_LugFKU^>`O_9xc!Uy--$TG@PdtDvASxnWG>g8$?`3vjp!== z8dF-?9&I72BkU5=F&xW;HR~@f_ctLIQ8)do$&LIwJQHmMG}694skA$nhYptv@Rr~@ zYAN`0U~S|^5MN~N5u5iZ{U3UrYDeGj$BQ@lJBVB&NhK|7iWt}%l6Inc{9AW*dZ(g0 zxwP_WtZ<_XSrQ|fmaD@d~Nle zxQno!6tOD23YA3}unf;OdXtRC+Ou0cE4BTpO93zY4OJ>>VSVLJs+;JY_{!hb*e2_5 zZx-hGlcjT7Ge940$!0n8#GYSH>blzCZ$M>oOK5nz(B=e*oY!MMc%S#I{E?-D@q7uIKT7E6EE6UC7$r23En|9c;{z9%$-FzzN#23U z@$D*_Dcf#6&J`B$rL!&fG@|nA?yA@wsZmy1Df12`UqIdA1mp(sKu7rl`RDS!Y^-0x zEY+(yZ+TzOQn6UsJD~6g6>7x;O;-6^&u6}=e2#Kg+0BY0=ukzLWN_f0$Hm?uf)&Ha z8i9?KRgrLQ9Jk27hCMG{EKw96Wd~E!rTNshig|n=EQ9Jrd)OgJkWfUqFpir|3>G>P zmq|PShb_dt;vA})rz!e{?jr@hrDr>H3D04onVVRc!b~3U{vsA~W6+P}JUB_M=ADF* z=qkR8oD!zei#Xu>5np&GG9CFqj^)pCBJvJ;K{&v6hi-43380+$RtRbGg+5c|MC2LW z9^cOP6PnZ8#2>MNSUfeH$rjp+4^ziPX4wYlD$oM)F&WHe`WUthNn{QXQvyHGON@cp zD-w`5WIfSAgybIk4`T0$`b-9WpB+xUMFf8ov5&VwqAXXqNRLDtun3z(yh5LQKMAow z%Dj&jLzjj(;49lJ_@P78eJmR(BHJM{;u&_37)a(K99~G;2@hWrexmKjRv>9MAf7OY z&=lRkL}1NeS9qJANf#mGv7ykxVKLShSlSne`RHUg8Jt1&=nno3dIxh7(EUNU46NWT zXnUZ8t`&{Jh6z=WD5#ER0=x1gT8r632-t7p0ydL0Gk?jucqRFl?8yuzCW4Q$8n_$h zqix{l>_^CnJea>sK++FF+aTkG&O$lAOW4T2KroLiAfNbPaFH&A^WHdc_B}?IkVb-s`fCX$r8!tFUJ3*8XJjK> z0c+-OyalK|@4$W44SR%bL7O87g?E?+R#*e{rvC?i+;!+;;T~#)lb($bfWb@RCkP+a zn+yYLZF5m0@kHhqjY;H^7Ls#JvShw^nW(4aoMrr#TYXk+`E-j67!BqB_i?rSR2wz%*9rMlXVn2 zA6budhG*>@A0jA)a_|qUz&U&mxz2WD|99@#k6hs-{C!Yu=c0sgNmzq8Fa)0jd$E;3 zI~7D zG1M|PHI1`Ow|=yaF|RVuvMsRZIf|_(teb2P?1>Js-EH?;i!B`?qd3VZ7|i-iZ5Pc? z&0Wnp%~N$V)nCN`c`S5RpCo@Ty(Y?_-vEgL69tQRORp(r0z2bB#eCT{=|R~%`B+F5 zj8N8A|Aox-Kb1|L3ED&pwM^AYc@olyAEl|1Ws+QRZ^=f{T^glo6L0ZW$U9!cZt?4Z z5cvW+99;C&^n_Hta%D|-dBf*BZ|Tgatq%E|^e31z~{ zLVkx$i3*Nb8{q+M^H^eGT9;ZgYFpED(|Xk!T=R8maB^nMvC!(lve1zcEh7g+jBx6m ziH;NYla{`!V#zb26@8bslYOyHoCFz(rV>kNC9{JXK<*%FF}S3y_ynCncbAqc`y0LH zM<$o$fxWeBR;a^uGHiClsED+vqmk!hPQ<*9wa4y^`5aw0DmB_1Q4t*O40W`35RNe0 zCu>{tbKPnfzc5{Y{V?qX#ad|=P%4wuQsp1%Zbdy!TjOeL5Y$y|rfky#+Z9J&d#H7! zWs|j=ZA4I{E8Nl35@qljYnc6pVqI5-OL|pOUG`qyMfO3w4<7+Lx*P0hKVR-IJyAHa zsJQrLQLFqve{Fw17ECEQP_nP^{$DbGRQ|T2E`@6g2A9Z-P33n>r6r3CkNh2wSCSW% z|9|bhb&%Y~*ETvRoRKt=M#H;1%j`AAux54~+iS;h95b^MGgAyPGsKvf9W!IhOffSv zF3%3`&fL?hcfb7J@4NS(TXpNLTeYLoh+3`gb56Hf-F=>?@<-^le7@-A*1(pdn<*(sCZ`s zYgogTXExBE@K@SO#1bk_zn$%4NHeGeg}7h*P1TBe*H!Z8*Og5u6}Z@Yt@g`xLL1m)2q$KHoRP?ZTi|uT=^TZ z$E;`AmiQ9&05wcJ#+MQKfXuy;CUpJiiTHdq2fQB!R?QvIt!J7E!)!^;GSP`~(-JdM z52ZaVx1`*uvW-*!N*R`nr-Y|q$uAO)MkhxfjM^17&)L{<&U{GxEbJ5r{u(ovr~zoe zvG`qV8T27;RgKrC5FP1v%p+bUHMaa@ukTnFW^mLEOLok#6ZSO6F^4g%dAK|5rt^99 z>6ERN_D;&^$Tz{jHXYL!}N!k zx8hFodHeOSUEvj-_aa6++d1z$#@Qa3Z}a1Y#%weC6Zu@*LeoaIv}m1oSpFa2nX1e* zWX}FL{!{34$FJtE&QD>PZN47MT>R;$xAR|DezouQ-Ir~jCO)e7VAX@#4{zKZe%JJH z{!``i&2LqChZHC2ZN^Q`DPsk z?A4gIVRfb2LK^+OV!CH!&bflQp?v%i(}mg2Zez!=t+@v5CVrf80igYMvaGVtiKrdh zJJFRmCylMdRpu)-Oj}iEecB)82A2Ibu}RcKM=x7vtHb=OG+yd%Qb}%RAD#~1QxTfO z+Wy*#;Ai+o^_%82ULLSd^SMDnUE@__j%l%_pQWL-z^t`2wCp!6H}w%7aZ|W-VY_*} z{V|}aYMh&54yFE?zPomd^aWL;D?Tmbj83yovivPRlg0@P`Mu&^;j!4zP>~s;`$hM= zzAbmr&`sowTa5$EUo0uktf=>~O=DI@CPfnAarQW)hW`ot**j9tvGVFj==&a`tXGum zN%D-(cV>V7xc1$)k4$FQoapQs+2Q$T-DA9;U4P_M&)V?yN!G2e{KwiK#CH=vj(gww zZI#zqkILM?b(6n!;C|z0L*I4(I@KS+-EtgCHdHO&_~wu2JCy6xuGNOded~3pakRRn z@(*R3r5s8OOB+9Jy=bWc)R%UMS|j@yq~ zJ35lW4@IWOJ&t4JN$?L}mliDVs-!GWlz9<9DcTdWAnt3_<8ZTSzfff8!bY(Rz$4@@ zeQkrDf5S|qPGWxmbHsAPLU9$ZVyEjD>aVgk!+D{WctTiW6s5hEp5PJtoq3q_n>fet zJ7Z&pGgGO*sqT7>v^OFyaZFh{ZB!Xm`Ce6q0#?|pw9YZ(91p@5f`3JgXP0}cDY`fU?vUF|vt#S|dyr`c&QF%*RlG?rY9~mEhdeP0+J>#dt z8B6Nbs~J`8ZKdXwvnwT5eph8>wKCr*61?_y2A-&bfhi(2Wy(K zi5Xn_!$VjIu(R@&#qfq$QR~ zPZ1I-$0o&n7k@skNpwBuI(w${+|Y(O$FwyZ0`%6u8Iih%tAPPAkGiMppntDhrXR@s z$Q?DL8_lMlE%U7lEPAlc*MP4QXPIE?Cw?_l6J8tI@VV?a%x&<$Rq~iY%SVV4>`lP6 z>F@Hv>IZApuVzk*kLetHEpmDUZ_MLP@IP3b&S2Oc%QRz#Folm6em9H~j`NF!*Fp

uo&7D0R^Q~)42aV_W`Qk(IrZ|dEW~S(K=qKc3JftDDWni3bp?W9yrP)JW z3pIg}ena7pf&IR!?&En6^S2aaxm)EAgZAXzoU5+$`PST#S-LMhzUF=wKezwb`}NW1 zLmv6>1n-QyuX=RoVeZq;pMUgyN4g{_`g+-R)w3I&YZPCXuRJ?>WI~5gg7%9FGwZ@0SLqX(!E7FRt= zKnM>+wNQ8Y3FU_-sj5p^zk0`IlX;7 zqdb@0hduU!Avs^aZv2v;IXizq-q|ee#}==*Kl}V>-lLx%UAw>a#@lOc?=5&;HfxxB zM3I}WV(**0u-xu)wGv-D2SrSZTpw31HLAj`s&8smtIpIoUHzMC`^zqk>Tg=d4Q7u5 z8bdYym3YdWV67yL;;oE_ZldeNgt!J`hWV@|-mW;-^)>YwyqBljCi`pJz zkH47MHSYI_8jiiz_txIFiI$mmKJ1u1*L;?b5>5-VgpXX3?q^_3xUE}E72-CcKS}8p z>-x}xD2_Zs#^|mv54e871lh|_hW}H{H)fmmnOaEmjklp>?lr9B+Oaz3jc$_u6cfws zV>>Yu^>g%f#qH*n_6JtLkT-X+_ln6#%1k0s$0p85TAb7>xlT%lq`mQrq7Ov%hT3#e z%&?f_&Q=j+tjkPkMmwOtV20*gGEdS^zj(ZUoVXyK{R?&D7{}pQ;E|CRUqO)m|YtNfmzG!dmZx2ge-CZ0jX^$kD}-FE!!q>?`or z>VbbITQie{JEmWxW>Pz2Kk)}~uux6hNTZW}$cxuY6s*503-JI^?)v?Q12aEBseO2Vf>`WWb@klR`VO7vLV<7JuaS=vQMZ=mlx6hy}mq zwZNP(Lh&>>6nyLk`DS`XcxU=9c`v&i1vUNc!6Q-cevscce|*lE%vPTte^h_&__@)i zFP{#-p7m<=vzbqhK8m^f`p&@n=O4^@qs-eRWc-cv8>HB>ld#xAIv zXfCM!Pz)|g_K)*fy&<>8{cXWuj{`jGZWp-onm|uR7nd?WCTCn;z?JA*=4;^I>*}6; zJ8Nxb*)J_VU3$0Zb&Hqvo;hAHk83@=_V~@y3a{}udp_iRte&|c@46>V*;#i@cwt&E zWr@kgdDhHmEHNFv7ji4~`lfTm=<-*SPQ@l9Zb&|utSK`m`jfLqcqn|kb46G~`yx}0 zX@Ypju%BziXGs~B^-`W#kE>44A(P1fn;|u{HLx8tZ82%AGaW(4W?Pmy*D~I=KJ2h# zrhNdQRo)I;ZqK&Yuy?UXSn7i>Uqy3Y>r2!3&`;3?IA0{*!p_hQpvy8BVNQM>eMkBm zY8uss>_PoPkc1mIVtt7z_%(bj_&+wLZ&AAdOZF<=jXB1S5c0%w#-D^ehN*@*hH{2B z+*!jjzNzt8cVjrGGP-g^ ztC%iP-$i$et{mAc^09T4G{-c`lx*!B=C-u5^|v%Le*;*VHc4fyCd`JKRwWEI{w{vc zm8X;SO_;t+0d*JqR?}WRS9KCRjv3`kMY+(8K#IGMi^}_u-^@ME6XPfRBYcxwZ*s64 zSDvL{l-p9U$`$U?<&Dbgl{>d!s;g|lvRnW?%yb2Frr=r+;mDP7CdO}U%!F;WxuJgPy$ zt0a3_MSLiJQ>-PvC??mrH6q*gCOpP5&3?qrJFbQ&IwEcLtp%pp0?)OgG8o!;${e(I z7Bj?1XdOp!w{&UxY1}pNU>j+iBaAaGG>^7orVZwBo6*|Mx<_1TnrKyrPjGYr6slg9 z`_?w5^Wq5pvuHIxw!SyL5?^pf**Dx|z+W3o)gwL;Ib?v|r^_K%;yz6pJ{fOI#9=n= zJM0ebp)S+gbcbmh^$+=&QtEzUHZudcsfHhUJM;}K5Z?1Qg=0|uelT0iD$6oU4?u|t zTB|!MgqtF&NA!(Y<{T5}OBk6@5PQn`IjRoK6<_N7B`m|aI$}>G7Jl8n#{RpVvro1B zWo{$Qmu8EP#e9CO^isNP^olKkDW|#Nm0<{b9xzV3a*cuC_itKHmd7swYj$TshqYAv z6ucI=242~bg>JtOc&A(V_IU&DLeJEKA?}T^ek^F|t_U8=HGCe=LeCA)d(StX-`sEV zceuB>!#rKRJ@a?vR?nuh8h>v8`Pn=F+wb3uetF_q%(GE1u{U);9{=1W>r?Iw|6M>A zZ5w(Ql!yXdPqBi{WqW3O683#q#ppLN<>L;;h_USxvy;cBx|2=uFXMYAK1(Q{Ffg)b z^yH{NV+&%YIc?!{?JsRDfXODEA7hxpRW__++w%tuHMs`*YeW|1(r?!R+a>joE^7$z zXM`lKnSm4j62^-dpDJXESIrvhFiUgW7t0#pnOQ7VlkS_QIwBmiZHJ7k>4mh|+Sz=8 zZ((@KbQVqD=e68aXYpz_~D{dd$Pf3t=|<+lXOKN@~5{Hx=tX}q|>G}zo6dOh<@ z$Hc+>A;WZbFGmAUPj|rh>tbNI`TCaB1N<*?HhCR?2k2PA>QavD}&{W|of=tSW^ zfI#?Nppt)(e^_CMqQRlMg*CnLuH4*0*D7ynKoywbneO@IrD2}ubztRS-7eRcg4&*U z-hJ*31(>^2{y(k+z-k%ymHuM=So!m|k2Bum?~~qc`FQ93?GH)sh_89>Kf%v^DD))X zRIKFI2}R;M!EWFzSmeHFDP~0kkQzFd#?((POn3?24+Eo?Bu+^>pZt650B2%U-{`I2 zg=uo=9LbK=;U^-#x2}@hhJi49_Za9{&re`~X6MjN!(4LRSa>yn0BDR?>i)u}c z(bMcn_5iD3w(z421B|_dZcwlFvV_c?q_yTVQgxf#yuz~5oM#?x8*cjyh)es8mxUa@ zi(w8w5zyL=+&=hHc%$nHjMwV5kc9#88f6Es^f}W*DhOPWk?!4|F zor`kn6wG(}Df%f;t5hSGQeJ8n^$8FTW@~3rJn(|u#0=_ZnxW8Z3%-4-GKva{i!l2t zx~P31Ch$j~R-xCo#6R9U!Sk+QimR3TiYLkYkNZo0L)Wu{ngw0+mu4@{y_vh9;IzB7 zE7jF6cWM5z{6+cdTr%%LZYXbM?zyZo+1Qs|Uz5Ic$gYvwIETt|eZ7_y>(PYVq3WTe zqUs8r<|Ne!{P=s3PVS|(ZTRkpS=Q~+OOw{&janF6A@)kl#ppvxixXESig9(KcEqhp z?2xiOp?%EJ$Q6;zV=u+DcXkTzV>@d-4d2D1j9Sqqbu*5az8HPbw|0Qq2|oTG=_GwG z;9Sd~UJ&c>^LQWZEZzyQiF29PfC4d@CV*8diFl)%&o<`Y8CC;BeLR;{gj{ zy0DY~URn@^Y+shp{`X|HLsbV%$WI>g?v4gvP1U)XEl zt)qlxjZV-Hrl-->0MFtG;P!ekBc6zN$7|pYjE5QER{+=IXU!M*K>0;|R#g$OMc#rp z(^kOaXscKm;zR3$*NgVTnAy$36@Xk&6})I#1Reoi?i62)FWfg15V4;4=KFA88L!{d z$-Blo+&9|W#=FG(n-}xNczc20{4Vzf_fR+E{!%crfN)2+DNhsc4DjQ9>^kcj<1Xvl zAGjTu6X;au4-d}|yAp17ijjw$t(=>j7o7!8!8r-ec7jnJG5mnzrk%D| zwXL_Vv)C=`%+ugI`U!l$RTocPq-v*Lq#mlir_!nI zfCBMGHAmGzovdjAJjVMqS(QhQhEga6O_@T`j`5XOlNi` zx5O~tFv~E>(3u@t&!Bgo3#QEiesP`m6!4rv0z+U-H+a8cb>T0T}`@+mr66OJC z0|I{cP0bdl%>%%>dJcLRLA@iJkoSl<(m=K#8<7(E6?hnq z5V`RGkXS~f5JLgkXd$5|vH;U*C?0{E@B++^7XhaKUTh-v1>1>D$7*2hpk{xBxdB%z z2Jl-t0rq0Yw`8;9}H|Q91L?n2P@hrm%_Xd9_Cfo0@l#a+H3eSawV`_mt)$o zEci8TueuupKY(^usPI@SN3T8bX!N;R@+RQ$@aw>W*q^1 z8OVcP23>hypmI)R=JSYi=WC+aA0H|_-- zo=4;waui@#Z6~{tG2~n5ljX@GqApPf-ceie2$+#F4>Y|LwA&1;qTQf*rpeI0gc%-d zwR-I-&0@_lO_;W>_A|`k->Ye>J*vH+t*On>>;{}O3G0jf1}%U+SWVoFr{Uf3+7SCZ zGM<`Gt){M1zknTl(4Eo?OfNtc9>CQybO1iPEWW0=LHrE5zASbyPBm7Rx&V9L0I8PL zO1fq&6it_YJp;-2vVqd-MbKuXStbMCvGb#pMtUiN=6= zxL#XJ>s2>VKURIOs;;`JY^gLWTPmi9_6AQB2{7kkUr|EQuEIA^JI5C7D69i@!Ii-F zK;M8qkmG;nj|?35kM)QA+o4b72fxGr(Ut$yL5l*H|d)IzScdaAGgua*ig&R%rJq!2D40_0bj^Iafdk9 zxXoynzLj1A17msPVnD%}WK0%E2?n97a1{DW(}i;USwmIB7A}K3$bQEz1hmIx`nvl2 zIZaZH~{D8YG`_Ba&TBt z(;`>lWtesIDG&-!g%O3<0u=&OfDKFy9D&;4Jj|WxP&ggtmVEIU{JjB*WwQ6AXOa77 zX!ZMC{as^RZ(RFbs{tqYVSZwMz5Mt2PYcF+c6m>FEBj{nC|`A7W#2tto60JtpS-#V&XUlFa*33oRZ$W%2LysXDI|`fF$cytJc=ce%wCU z-pZB?n5endUA98&WUFK~TQ6E#f}dn<^8?d)lh#}wW(=fDS469D)sSKMz|G|Pv%Q(S zP~-CYvAX5-WlBlCAa4S8U0w1x(T`{VJt``|mFh@LA$k)>0ajA`-hSUS}GFMt=J7FH1p$5?DApn*MwcR+K@hPk!dwXe0SwD-XW z>4ml}_8!JL^}x9D2YwvyOGwb-NFh%{eXx_PPVJ+T>A&euI+wmFJB_meuVyR57=EU( zL)<8?6&)}FeN>7zJ(B*GZb_|7qfNMJp!CT2()h|a)7TB(3Gc)a;vPW~5JGF5VKjGx zZO5Kw3`|x1Nu5#Gkse4bB)3mZ`lBW)!sXh$UFB}=@ z35e+2%~}1RY{DiWh}u!X3UX%$qt3 zZ^#o&Kjxmkg1#5bU;7qtGruLv5mvl2_EI|wdx+J?tT5|sl%}iZSC|p$Q*TiJpgyhM z1LI#W0UPnO`m*|a&1uap%}F(*j#7;VznC6s@UQ`H?k>Q|-WofIt-u;$GqjDMtymwt z>(^+5+TIue>?$@NJB~HRZ{b~lW1~D(pI%Onrt_&4)J5tf zRYc){khlQw2S4a8>*JYd_*PxXmFMPiBw)!pgy}+4p@R6xSYRA(yeBLdwWgt#T#INH z%yVr7%sLwiWd#64Y(@4e)}EGm=1ovPmbDHwzmxtl0>d%C5%7S2WK&`Msyw_6D*+q9 zDqPV10?b9rRfO81{!!7cXkcJ&z+7~zXnWD$fHsv}SOlfNi}xG1$KBc|1*ZCG=p}Ct zb$ zeBOQvAM`c)VpPL|G@e+RD7=XR}Qv?DQWp_7Yl z7*jDO8L(tOMw+9QQGtkQVaIHJtpm*|rVL}D@Pbd}KXO|EBcIV_QvJz~!~@)`{ZTVb zeF;2XYipjVPpN;_cGuR!0@^>Z12_g)adU|(q=g=*ceB$BWLKvaM)o~Q7Z>#EIy??hIqg!!oxVD@zr(DrbD4WAM?s#ymjM%K6*yfuEnxL>-agO==jw<*`jdlapI9L9cwH@0LOi~6vj_xC+kyT%xR=P(|`lIqXD0R zC6j&iqM@w*Hgy}%CO^^Lxs$qIp`EpzePLWFbuj(H-{F>V4TW*iP}2aZDj>+ko51JW zq?d3gVH1Vv##k}j@QS&vdjvdB_1HwF7a&~SCAyJy0re0e8+}pt(pJKw@z&UCwMvty ziN+paAE2d>q@4nsci-X10N>V3#?lk%a=Hk>jWyHNbU3q-=?i^*0YK>9#vS0o4ZGMw z>~HK%X#E@6x@=RXA-9=b!F^&o=^gt0fQ#FUuFWkp4z>JY8VNk)OSw*BmarK3?MPF$ zb*vp2K<8ut zs6kjm1^szI)E}suz|3Ic^t%A>X#pLnQ`0{BKA_9b)7_?;Q+9YeVSoX3jT}l@smjzO zdE86TSunok;4hHcDa>IYqG!2VpuxPXUhj{XVwN3S<5rT)gpP*?QtfLpICvr^wv z7-PC-Zf_YR3c!03%in~W^_Jk|3Ym_g$CztcE08cRAcduhr_j7ifiU_P=lf&Y2Bt&L@zbinkBxi*ZtD=edcVYY>RWcWfyk$sO% zv}u8xqNeo>^pzFzAz`YNYMyTyY@(#b#ymp+P{}8-G0;0Tk{ibCW*lrk-2;7Vwg;1| zU#ZKXPD36lQ}d_^^mCfi^+0b7!a^*@K4Gn}EKM7%oR-2C0YdO9m;>KmeNpLGu2c*S zPAg>Kn{g^2sDJ0%;cWMZh>*l=}hAm)D5^zi`-MUx;bIbG{n*j=xb)(O%R}*Nj(xRXkDjR-}i*gF6C=F!x?v_#x28UpBDB|CjH$ z7x!%RzVKvvB0QYChd0Yx70wUv-z%yg3{y-gn(gc8P4FfMCiw;L*ZdXPOENWIlwa#) z=41}e8Js;X*X;7;A9a1mFXNt8SOM?HPJwwfW4TdWD$LhjVA*16W|?ho7uhN5ugK|< zPa>u}n?)XsJP@|oI?KFAdT6?6R+#2U1H|Ki(G%jEuwyBc?jAdY@5Gj;kCFs2gT6(# zC;y=m^aBii`8S56tdIT-x_qk}z}69N7;l;)Ob1MZfct6z%-NYLRu?YtqM;188+cti z7%s4FnewcW{hpnn50ckmhDbSrC8DAAH-@MUh{2lyQ}zhqBpI-kf50hhJ9ZD2YJjgj z61*=ClNU)pIUTl7V2t+|QJy+Worj)_5hQf7(r@WTbaiqPwn8&j`!l|Q9IW%goc3I1 zx^6qYow`qrVk&Wi40o80OghZLC=!!|N!$v)ma)5es6}h8EA5mHS$f;!96yG|I3#HXgzXQfD!Q)| z_e-eA-zK(;YZRRn=?lLdZnJeYUkAQG(K_7x z)YRJ)X}NCR4I|y{_-!1+_5yrTmA+AOD(+#Z+SJ^RoEp61l zWqKPr^2b5@jSc+_lCV;!D2(8HaFe*B+M>>c6M6A{Q3cczu%>Fi2e!eEd=v_xp>h!=^D5(;L_}TIDlxU`cL5hcnjmX zYF$&ko=N4Z0R~Kf`;%h~I{qqnFwp$mV&;!I(C*&Fo?>R{XY0fC?P2ucJn#sl5cTl@ zd_A_n7Gr8aEs6pJ`8@b?siGOFZlJ!U+^JNlTWdA|qr*?y=b9WqN!bUXj%Ye*=4kF| zjo3Tjr~FB~1KWTDWkvu((g=u1k8H;Qwka*q^{DhFgw5sJby{X z9I=o1o_}drXjp0B_;N5>_B-2>oyyu+D>Fdf4j5)0=#~M_c_K*WBv^QmdOIK|x1=-~69E?Bo{ zUqBhC?Emb0?Q{A@`%d}a_)US2zCGTi{zv|k{%Zc#zWrXOuZORzN9`V1kl|9f%I1gV zi}^M3f6QOu8dmVV??~X=!b63vi*5t@#37AZ9aLqg*J>&e9jK-BW9W|ure&7mZgP9s zHq0MPfu3NuGfC_&raha_&fE2|IzL?jT7WNfxZb5358nk(VaDWjT}R#b zR2`TB62N1L)A%spw+LZN@a1?4-UIV!)@wEZF8W6BOYW(O6q9w0s+XG6?G<^n@O|B73`6MCS3)_2yg)@gOa>Bkg9H76}(Gw4qT zo;4Uf!LbBvm3F_Tf@ZxsOMOqhMV$!9DqGY6^)}5iO)891J_W80Tzd%K5ngo-wG%9S zrKYWBpE_OrlX{xUtE{IKm1~uMD3!{dfRpkjv zzMe2XQOUQ^bKg_sY2fMV8Bnm?CAsT(%K6s&R(S_`w|cw!zX`mCk*V;&{lfHMGsS)7 zA=O*;EASsZ2O8J`ebT9919B}nmdc^a=-vS)d=*_ionMFPU(qY5xiDsUjEbPokv$0! z-v|txG2}EN1KxPs@C!sf@E?4{#$$`{8_?tT3O@}t9!oSOjuRHLJ-H6r)P1Pi&=1^| z8VN1ySM*ukpSl@z2Raw5M+w_kFjDmgl}q-A*55}w6~Bb_fquJ>z|nC*vj#?UqBT}c zq-L@vSCg)3r>+XPJ)6`w0k_AcKBIb}-k})-m`OFDA8Vm{pSC6RS8vw5*Q@{r@7BaX z;t>8bwuAVYh#+ZVAJ!h5j4uYP)b(^-Dw;~A-Sjw^N41!`Pws~0K75CU0sBd7-2&Yc zU9PSJ>`$bIP)@oI^k6Tc%+wXK4)r6o3)noq;NRkpur=6N=mU$}?auvSr1IaPUH*#SylriNy@rU*P~YXaNLaE(@bQfmWd2{Y!`*2bnnFLDN8YNlv&G!Hcfz@lQb zUp2Qi2Z6(8zWN-nsxzwb%8|c?NEhOiRxjhc)-ektX!))26$5x^mdL?uT$Su zFM-zl9rasvf<~?19Y{j3SVel zXlrOhsB*|1d=$J0>-=EHpd-`G5_#wulH;3ib;o245BR0T#;vp^>4o zfc6s!I4u#Ox}m0_X(2`t3A2UP0;fbjMJB{LJJbtsD&~YXg|3A{fUZOUrp}qrzR-3U z#W)fg2q^JRIxDrzfzS3 z=wA7Nv=y#8s2r%Qshk3y5(nTv5l|JID_1Fx1D8)Fr3Vl?zEyTomQ`w%Ie=Ex0nlC| zl_6k-idS|9%&KdOeTq4X{faY+Er9!U2zX)g;lC3|Fh8VHBmqKJMc`ig4o1wT0e4Jy zMK_qeF&{9nhQQtc$m1j6lDPoe%L=ce4seK6f-ycHJg1B@0vH}#id4YcS_|pw4D6Rl zfbq9pIS+6jYbYBkS1X?=3!ro?1Hbo&fDFf|o-0o%uPDvHO;iV1!e1+o0gBIUV9N<8 zeSmJo0(widiiUom^U9~n&ye@$V5vux>j3-bXUMgv)F~^2{%gYIluwFFiW`uw1&SXa z^#rUP71b5#3KFzMDb!Ft$A-QMv7soyjB5!xuO6xi>^wUG2MF?o!LU#ju!HDO zTBuX#uh8Dm2Efwl9oi8(3qI|h(EiXxzzvIpG7_%fU=0HawNU)1XblpI1MZQjFuwQ> zWO)K4^A|9Z%!4yyVa8Lof(A`klzC9jZY!<<;z~Kt$3W$`Ah|NYxzrl=8o<&Sq_$Ie z7xI}8SZmozO63PRyi&T9F;MOfsGdWw`9M{9m|am-RYsMnsscQ#QLx`hH3e*Kk}6#l z2G~~@Kzh5Oyk-L*>kZ{LDAii!9Z1Uv(D*vg++)Ra#c@d8Akbz!w>7~L_L0Lx`uC&7Y_!a0nM-c&%8>Jks`~~WO3}rj8 zv~;kyPEh~;0i*uE1B%fGI0QPA!N}0vxs-ifzD?^iiP! zjLOr1<5m^k8!>>c6R9XytYeeH2(q~rIsjNokNxf_sk3=rcY6>eanc?uzP#S$q1>DVFObSPhG3KKk|4p`Dpuzm-43X>JHKs!^Q zl&w-+1&hg5e1P14QhZfN$~4G#oH9%q3ppGQ2w^LfzbXF&OS!6iq5PzL3FvokV11x` zrp$zLR7O<|YKL6qKEOI$soVw3LWiNW%!OqPu*b}TbtIswrh`n?$^wZ0nc^u-|5jqSHJ)pV6pv8Sq`X7KU z{a`~yU`;O+55bb2Kn_nss7(-lGvsqQV393@60j0#iit2cWhj)5VNkF21qPZfppmw) zZ3^r6P_mmSzE#u*4~`bVuUAh|87#Og>{SN&RDs!yRUw6CL7&l3lA^$>Bf-kUU=uIVCKazF{Nr#HhXv@KU=IWKDm5%hI6^T1 zfEWCuE!va+VW;$0DuGf7luDpf0;Litl|ZQkN+nP#fl>*SN}yB%r4lHWK&b>uB~U7X zQVEnwpi}~-5-62GsRT+TP%42^36x5pR05?ED3w5|1WF}PDuGf7luDpf0;Litl|ZQk zN+nP#fl>*SN}yB%r4so6wFJ;C`O;sh1WF}PDuMqA3H+b+&%Zu9|6cpQ+xwqLZRs=r zzmULxYP>|#C0pdfg?{M2WL3ewyef-*z2xg9cc9piKN#`?lNaPWhCIlSf0!2jG1y02 zc~QX@d5~dn742c*TZa6{&|O*-HT=+>==ms&ws@@gBMlrB3#eL;H&V zm3kDHoJy3Z|BO|RO}+)VO69*PK;h-5qBxL*(iVr6?F4C8&K*j75OR%HWGzT$D9uQ&C>{TqHY8(pam?D{C!)B~vyql$JCpB3 zX_Rdkg+{CVER-5#?Xs7ooHqFkx?k1`$}f7VEOj~m|5^cDD!z*Tk@P~4T3PPMOB3zO z8bmRKz=vG64`lTiECj@gToS`%ka31-yqI9Euy!fAjV;$`2io=m`mWCsA z586X|kR>7CC+Aw$ubjt{(uwkc(j=!D*%~T^@{06`d#bQ^G%5QT!+`NJ2VT(G!up zWQm~gD3q*IS?h8QCEFuP6_Tc+ctz>Z6sH5(rv`jykuA}121!j$H3?VHia`!Bg}yju z0=9Yxfvivi{`w?Hfq?5c?Bm5+K(a?EL+8*Q(x0qDlv4RvTO5*t6qSUMpMkEcAT;)0 zy3|98(2CB}a1K3>E?y0ACpt0|U+4bUHC=J~Xb3N-3&o3agwiNmJc0`Ek@nFw5*Ace^sv(Z zB~KFWr(j6^W5IjesIb9#^em}(0jhI2D;1wbVNkpj#DwyOQp^{}B^0L+ z*&%wirZ|myNRa`;$#THpiRdmQIUU?3!Zoh=Nhoh9$H;b3-gR(|gRKPTjgVRs9HXc3 zaAX4Sd=YTmQQ9mDGyEGNbto*l#|*1boI0c{0n&oxgr11B&Va0tPLahSy`!28)m+HZ zRS*X0O!kFGSLM0{-H%EKk{*&DDp^RX@*a{Z(g|OjUKT=_V9yFGN(~S-6sH!+Q_hD8 z(j-7?kq%Jqx#FB7n?U&&;0Rqu=LNXO40oaXM93Wn@<%b6;k-~Bs}b&@i=V?oDj2w* zfe`W@k|UC7$&=7MNK@z-X;w}@$^)`WWKYNrcsS2Pz6DrO`o-dZWCtQV5#RT4#;9y11LiUR^gh~mj@rdFa8etEm7v&sD6>X7Kp)!N)66GA# zqv(mKMk#q4qWTuag;I|sgi7g;tcL8wHcdR#8A=;;hd1eI$f31oxff9<0@h!7Gz z4dqHMJE*jxxM;{5swGhVQQA?QD7WYu3uloH%2Gl0DZhhcYeh9FdXLHPDP+f}-a=bc z^3a_shymFqdIHL$q4+t-LXmapAhm{K>q0UyLh6x?BI_`L^&-0!i?5=6w1+G~f~> zkKC-4+K+=RDv78bw8Fjv5JJL>FcVMR|s^)3pBEK9B>k!_$<(8aoxOAu;lpm#iKX9Pn?E+{{8J40Wrcl1s}B>=VD zkj7EYkR(v4N2M4jy&w$?JPFlmsHR7Cp{y75YzFR-ElaMiWy?m=lWR+~MHYwbQI-)7 z=|rpiwn2BHnno^}$o6ErL)t?3DT-4pYXMm?(i8<(kbcqI5akHz4z&)@yIih;OIl_~ zZzvYIB_OwpQA{UPg+zbDWYxkXaaE`%WPE3qGWzxdRDot0bIC1>RO<^Pf^D8~Q( zlUpkC|9@WjH{`!}$a^JW|DWzGdG3G4_CL4(Z|Y0Z@Si#R&wZ3`w90KjB=eFSmbA&y zHTnBT)*T9guA%x1ElAh$*C49R$!}$3O~^)( z&C9JdbVY8%qIW*BTNF}mC(12Pw2$fx)Pj=JA*V&IRphS@*%D>@L19n`lxjK6C^sl4 za&Ag=EnDNiQbaP5bBn^4{N%GG^7=owCE;Wl|9d3wq459vPmbfipZ)Lg{(G-{e@PrA zq0n`7PF_pSmV}j$-xFHiK7xBuQLpDq0_l|ZQkN+nP#fl>*S zN}yB%r4lHWK&b>uB~U7XQVEnwpi}~-5-62GsRT+TP%42^36x5pR05?ED3w5|1WF}P NDuGf7{Le_>{{q9eW-tH% literal 0 HcmV?d00001 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md b/docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md new file mode 100644 index 0000000..04b70e1 --- /dev/null +++ b/docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md @@ -0,0 +1,179 @@ +# 云端语音 · `dialog_result` 与飞控二次确认(v1) + +供 **云端服务** 与 **机端 voice_drone_assistant** 同步实现。**尚无线上存量**:本文即 **`dialog_result` 的飞机位约定**,服务端可按 v1 直接改结构,无需迁就旧字段。 + +--- + +## 1. 目标 + +1. **`routing=chitchat`**:只走闲聊与对应 TTS,**不**下发可执行飞控负载。 +2. **`routing=flight_intent`**:携 **`flight_intent`(v1)** + **`confirm`**;机端是否立刻执行仅由 **`confirm.required`** 决定,并支持 **确认 / 取消 / 超时** 交互。 +3. **ASR**:飞控句是否改用云端识别见 **附录 A**;与 `confirm` 独立。 + +--- + +## 2. 术语 + +| 术语 | 含义 | +|------|------| +| **首轮** | 用户说一句;本轮 WS 收到 `dialog_result` 为止。 | +| **确认窗** | `confirm.required=true` 时,机端播完本轮 PCM 后 **仅收口令** 的时段,时长 **`confirm.timeout_sec`**。 | +| **`flight_intent`** | 见 `FLIGHT_INTENT_SCHEMA_v1.md`。 | + +--- + +## 3. `dialog_result` 形状(云端 → 机端) + +### 3.1 公共顶层(每轮必带) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `turn_id` | string | 是 | 与现有一致,关联本 turn。 | +| **`protocol`** | string | 是 | 固定 **`cloud_voice_dialog_v1`**,便于机端强校验、排障。 | +| `routing` | string | 是 | **`chitchat`** \| **`flight_intent`** | +| `user_input` | string | 建议 | 本回合用于生成回复的用户文本(可为云端 STT 结果)。 | + +### 3.2 `routing=chitchat` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chat_reply` | string | 是 | 闲聊文本(与 TTS 语义一致或由服务端定义)。 | +| `flight_intent` | — | **禁止** | 不得出现。 | +| `confirm` | — | **禁止** | 不得出现。 | + +### 3.3 `routing=flight_intent` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `flight_intent` | object | 是 | v1:`is_flight_intent`、`version`、`actions`、`summary` 等。 | +| **`confirm`** | object | 是 | 见 §3.4;**每轮飞控必带**,机端拒收缺字段报文。 | + +### 3.4 `confirm` 对象(`routing=flight_intent` 时必填) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| **`required`** | bool | 是 | `true`:进入确认窗,**首轮禁止**执行飞控;`false`:首轮允许按机端执行开关立即执行(调试/免确认策略)。 | +| **`timeout_sec`** | number | 是 | 确认窗秒数;建议默认 **10**。 | +| **`confirm_phrases`** | string[] | 是 | 非空;与口播一致,推荐 **`["确认"]`**。 | +| **`cancel_phrases`** | string[] | 是 | 非空;推荐 **`["取消"]`**。 | +| **`pending_id`** | string | 是 | 本轮待定意图 ID(建议 UUID);日志、可选第二轮遥测(附录 B)。 | +| **`summary_for_user`** | string | 建议 | 与口播语义一致,供日志/本地 TTS 兜底;**最终以本轮 PCM 为准**。 | + +--- + +## 4. 播报(理解与提示) + +- **TTS**:仍用 **`tts_audio_chunk` + PCM**;内容示例:复述理解 + **「请回复确认或取消」**;服务端在 `confirm_*_phrases` 中与口播保持一致(推荐 **`确认` / `取消`**)。 +- 机端 **须** 在 **本轮 PCM 播放结束**(或播放管线给出「可收听下一句」)后再进入确认窗,避免抢话。 + +--- + +## 5. 机端短语匹配(确认窗内) + +对用户 **一句** STT 规范化后,与 `confirm_phrases` / `cancel_phrases` 比对(机端实现见 `match_phrase_list`): + +1. **取消优先**:若命中 `cancel_phrases` 任一 → 取消本轮。 +2. **确认**:否则若命中 `confirm_phrases` 任一 → 执行 **`flight_intent`**。 +3. **规则要点**:**全等**(去尾标点)算命中;或对 **很短** 的句子(长度 ≤ 短语长+2)允许 **子串** 命中,以便「好的确认」类说法;**整句复述**云端长提示(如「请回复确认或取消」)不会因同时含「确认」「取消」子串而误匹配。 +4. **未命中**:可静候超时(v1 建议确认窗内 **可多句** 直至超时,由机端实现决定)。 +4. **超时 / 取消** 固定中文播报见下表(机端本地 TTS,降低时延): + +| 事件 | 文案 | +|------|------| +| 超时 | `未收到确认指令,请重新下发指令` | +| 取消 | `已取消指令,请重新唤醒后下发指令` | +| 确认并执行 | `开始执行飞控指令` | + +若产品强制云端音色,见 **附录 C**。 + +--- + +## 6. 机端执行条件(归纳) + +| 条件 | 行为 | +|------|------| +| `routing=chitchat` | 不执行飞控。 | +| `routing=flight_intent` 且 `confirm.required=false` 且机端已开执行开关 | 首轮校验通过后 **可立即** 执行。 | +| `routing=flight_intent` 且 `confirm.required=true` | **仅**在确认窗内命中确认短语后执行;**首轮绝不**执行。 | + +--- + +## 7. 机端状态机(摘要) + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Chitchat: routing=chitchat + Idle --> ExecNow: routing=flight_intent 且 confirm.required=false + Idle --> ConfirmWin: routing=flight_intent 且 confirm.required=true + + ConfirmWin --> ExecIntent: 命中 confirm_phrases + ConfirmWin --> SayCancel: 命中 cancel_phrases + ConfirmWin --> SayTimeout: timeout_sec + + ExecNow --> Idle + ExecIntent --> Idle + SayCancel --> Idle + SayTimeout --> Idle + Chitchat --> Idle +``` + +--- + +## 8. 会话握手 + +**`session.start`**(或等价)的 `client` **须** 带: + +```json +{ + "protocol": { + "dialog_result": "cloud_voice_dialog_v1" + } +} +``` + +服务端仅对声明该协议的客户端下发 §3 结构;机端若未声明,服务端可拒绝或返显式错误码(由服务端定义)。 + +--- + +## 9. 安全说明 + +二次确认减轻 **错词误飞**,不替代 **急停、遥控介入、场地规范**。 +TTS 若为「请回复确认或取消」,服务端请在 `confirm_phrases` / `cancel_phrases` 中下发 **`确认`**、**`取消`**(与口播一致);**听与判均在机端**,云端无需再收一轮确认消息。 + +--- + +## 附录 A:云端 ASR(可选) + +服务端可将飞控相关 utterance 改为 **云端 STT** 结果填入 `user_input`,与 `flight_intent` 解析同源;**执行仍以 `flight_intent` + `confirm` 为准**。 + +--- + +## 附录 B:第二轮 `turn`(可选遥测) + +用户确认后机端可再发一轮文本(ASR 原文),payload 可带 `pending_id`、`phase: confirm_ack`;**执行成功与否不依赖**该轮响应。 + +--- + +## 附录 C:超时/取消走云端 TTS(可选) + +若 `confirm.play_server_tts_on_timeout` 为真(服务端与机端扩展字段),则由云端推 PCM;**易增延迟**,v1 默认 **关**,以 §5 本地播报为准。 + +--- + +## 文档关系 + +| 文档 | 关系 | +|------|------| +| `FLIGHT_INTENT_SCHEMA_v1.md` | `flight_intent` 体 | +| `DEPLOYMENT_AND_OPERATIONS.md` | 部署 | + +**版本**:`cloud_voice_dialog_v1`(本文);后续 breaking 变更递增 `cloud_voice_dialog_v2` 等。 + +--- + +## 机端实现状态(voice_drone_assistant) + +- **`CloudVoiceClient`**:`session.start.client` 已带 `protocol.dialog_result: cloud_voice_dialog_v1`;`run_turn` 返回含 `protocol`、`confirm`。 +- **`main_app.TakeoffPrintRecognizer`**:解析 `confirm`;`required=true` 且已开 `ROCKET_CLOUD_EXECUTE_FLIGHT` 时,播完本轮 PCM 后进入 **`FLIGHT_CONFIRM_LISTEN`**,本地匹配短语 / 超时文案见 **`voice_drone.core.cloud_dialog_v1`**。 +- **服务端未升级前**:若缺 `protocol` 或 `confirm`,机端 **不执行** 飞控(仍播 TTS)。 diff --git a/docs/CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md b/docs/CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md new file mode 100644 index 0000000..3f8c12d --- /dev/null +++ b/docs/CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md @@ -0,0 +1,55 @@ +# PCM ASR 上行协议 v1(机端实现摘要) + +与 `CloudVoiceClient`(`voice_drone/core/cloud_voice_client.py`)及 voicellmcloud 的 `pcm_asr_uplink` **session.start.transport_profile** 对齐。 + +## 上行:仅文本 WebSocket 帧 + +**禁止**用 WebSocket **binary** 发送用户 PCM。对端(Starlette)`receive_text()` 与 `receive_bytes()` 分流;binary 上发会导致对端异常,客户端可能表现为空文本等异常。 + +用户音频只出现在 **`turn.audio.chunk` 的 JSON 字段 `pcm_base64`** 中(标准 Base64,内容为 little-endian **pcm_s16le** 原始字节)。 + +## session.start + +- `transport_profile`: **`pcm_asr_uplink`** +- 其余与会话通用字段相同(`client`、`auth_token`、`session_id` 等)。 + +## 单轮上行(一个 `turn_id`) + +1. **文本 JSON**:`turn.audio.start` + - `type`: `"turn.audio.start"` + - `proto_version`: `"1.0"` + - `transport_profile`: `"pcm_asr_uplink"` + - `turn_id`: UUID 字符串 + - `sample_rate_hz`: 整数(机端一般为 **16000**,与采集一致) + - `codec`: `"pcm_s16le"` + - `channels`: **1** + +2. **文本 JSON**(可多条):`turn.audio.chunk` + - `type`: `"turn.audio.chunk"` + - `proto_version`、`transport_profile`、`turn_id` 与 start 一致 + - `pcm_base64`: 本段 PCM 原始字节的 Base64(不传 WebSocket binary) + + 每段原始字节长度由环境变量 **`ROCKET_CLOUD_AUDIO_CHUNK_BYTES`** 控制(默认 8192,对 **解码前** 的 PCM 字节数做钳制)。 + +3. **文本 JSON**:`turn.audio.end` + - `type`: `"turn.audio.end"` + - `proto_version`、`transport_profile`、`turn_id` 与 start 一致。 + +**并发**:同一 WebSocket 会话内,**勿**在收到上一轮的 `turn.complete` 之前再发新一轮 `turn.audio.start`。 + +## 下行(与 turn.text 同形态) + +- 可选:`asr.partial` — 机端仅日志/UI,**不参与状态机**。 +- `llm.text_delta`(可选) +- `tts_audio_chunk`(JSON)后随 **binary PCM**(TTS 下行仍可为 binary,与上行约定无关) +- `dialog_result` +- `turn.complete` + +机端对 **空文本帧** 会忽略并继续读(与云端「空文本忽略」一致)。 + +机端须 **收齐 `turn.complete` 且按序拼完该轮 TTS 二进制** 后再视为播报结束,再按产品规则分支(闲聊再滴声 / 飞控确认窗等)。 + +## 参考 + +- 会话产品流:[`CLOUD_VOICE_SESSION_SCHEME_v1.md`](./CLOUD_VOICE_SESSION_SCHEME_v1.md) +- 飞控确认:`CLOUD_VOICE_FLIGHT_CONFIRM_v1.md` diff --git a/docs/CLOUD_VOICE_SESSION_SCHEME_v1.md b/docs/CLOUD_VOICE_SESSION_SCHEME_v1.md new file mode 100644 index 0000000..f5aa5a9 --- /dev/null +++ b/docs/CLOUD_VOICE_SESSION_SCHEME_v1.md @@ -0,0 +1,163 @@ +# 语音助手会话方案 v1(服务端 + 机端对齐) + +本文档描述 **「唤醒 → 问候 → 滴声开录 → 断句上云 → 提示音 → 云端理解与 TTS → 分支循环/待机」** 的端到端方案,供 **服务端(voicellmcloud)** 与 **机端(本仓库 voice_drone_assistant)** 分工落地。 + +**v1 明确不做**:播报中途 **抢话 / 打断 TTS(barge-in)**;播放 TTS 时机端 **关麦或不处理用户语音**。 + +**关联协议**: + +- 音频上行与 Fun-ASR:[CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md](./CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md) +- 未唤醒不上云:由服务端/产品文档 `CLOUD_VOICE_CLIENT_WAKE_GATE_v1` 约定(机端须本地门禁后再建联上云) +- 总接口:以 voicellmcloud 仓库 `API_SPECIFICATION` 为准 +- 飞控确认窗:[CLOUD_VOICE_FLIGHT_CONFIRM_v1.md](./CLOUD_VOICE_FLIGHT_CONFIRM_v1.md) + +--- + +## 1. 产品流程(用户视角) + +1. 用户说唤醒词(如「无人机」,由机端配置),**仅本地处理,不上云 ASR**。 +2. 机端播放问候语(如「你好,有什么事儿吗」)— 可用本地 TTS 或 `tts.synthesize`。 +3. 机端 **滴一声**,表示 **开始收音**;同时启动 **5 秒静默超时** 计时(见 §4)。 +4. 用户说话;机端 **VAD/端点检测** 得到 **一整句** 后: + - 播放 **极短断句提示音**(表示「已截句、将上云」); + - **提示音播放期间闭麦或做回声隔离**,提示音结束后 **短消抖**(建议 **150~300 ms**)再恢复采集逻辑,避免把提示音当成用户语音。 +5. 将该句 **PCM** 以 `turn.audio.*` 发云端;云端 **Fun-ASR → LLM → `dialog_result` → TTS**;机端播完 **全部** `tts_audio_chunk` 及收到 **`turn.complete`** 后,视为本轮播报结束(见 §3 服务端)。 +6. **分支**: + - **`routing === flight_intent`**:进入 **飞控子状态机**(口头确认/取消/超时),**不使用** §4 的「闲聊滴声后 5s」规则覆盖确认窗;超时以 **`dialog_result.confirm.timeout_sec`** 及 [CLOUD_VOICE_FLIGHT_CONFIRM_v1.md](./CLOUD_VOICE_FLIGHT_CONFIRM_v1.md) 为准。 + - **`routing === chitchat`**:本轮结束后 **再滴一声**,进入下一轮 **步骤 4**(同一 WebSocket 会话内 **新 `turn_id`** 再起一轮 `turn.audio.*`)。 +7. 若在 **步骤 3 的滴声之后** 连续 **5 s** 内未检测到有效语音(见 §4),机端播 **超时提示音**,**不再收音**,回到 **待机**(仅唤醒)。 + +--- + +## 2. 机端状态机(规范性) + +| 状态 | 含义 | 开麦 | 上云 ASR | 备注 | +|------|------|------|----------|------| +| `STANDBY` | 仅监听唤醒 | 按现网 VAD | **禁止** `turn.audio.*` | 本地 STT + 唤醒词 | +| `GREETING` | 播问候 | 可关麦或忽略输入 | 否 | 避免问候进识别 | +| `PROMPT_LISTEN` | 已滴「开始录」,等用户一句 | 开 | 否 | **5s 超时**在此状态监控 | +| `SEGMENT_END` | 已断句,播短提示音 | **闭麦/屏蔽** | 否 | 消抖后再转 `UPLOADING` | +| `UPLOADING` | 发送 `turn.audio.*` | 否 | **是** | 一轮一个 `turn_id` | +| `PLAYING_CLOUD_TTS` | 播云端 TTS | **关麦**(v1 无抢话) | 否 | 至 `turn.complete` + PCM 播完 | +| `FLIGHT_CONFIRM` | 飞控确认窗 | 按飞控文档 | **可** `turn.text` 或按产品另定 | **独立超时**,不共用 5s | +| `CHITCHAT_TAIL` | 闲聊结束,将再滴一声 | — | 否 | 回到 `PROMPT_LISTEN` | + +**并发**:同一时刻仅允许 **一路** `turn.audio.start`~`end`;须等 `turn.complete` 后再开下一轮(与现有 `pipeline_lock` 一致)。 + +**唤醒前**:须满足未唤醒不上云的产品/协议约定。 + +--- + +## 3. 服务端(voicellmcloud)职责 — v1 **无新消息类型** + +### 3.1 单轮行为(不变) + +对每个完整 `turn.audio.*` 或 `turn.text`: + +1. Fun-ASR(仅 `pcm_asr_uplink` + 音频轮)→ 文本 +2. LLM 流式 → `dialog_result`(`routing` / `flight_intent` / `chat_reply` 等) +3. `tts_audio_chunk*` → `turn.complete` + +服务端 **不** 下发「请再滴一声」「进入待机」类机端 UX 信令;这些由机端根据 **`routing` + `turn.complete`** **固定规则** 驱动。 + +### 3.2 机端判定「播报完成」 + +须同时满足: + +- 收到该轮 **`turn.complete`** +- 已按序播完该轮关联的 **binary PCM**(`tts_audio_chunk` 与现实现一致) + +然后机端再执行 §1 步骤 6 的分支。 + +### 3.3 可选下行 + +- **`asr.partial`**:机端 **不得** 用于驱动状态跳转;仅可 UI 展示。 +- **错误**:`error` / `ASR_FAILED` 等 → 机端播简短失败提示后,建议 **回 `STANDBY` 或回到 `PROMPT_LISTEN`**(产品定)。 + +--- + +## 4. 5 秒静默超时(闲聊路径) + +| 项 | 约定 | +|----|------| +| **起算点** | 「**开始收音**」的 **滴声播放结束** 时刻(或滴声后固定 **50~100 ms** 偏移,避免与滴声能量重叠)。 | +| **「无说话」** | 麦克 **RMS / VAD** 低于阈值,持续累计 ≥ **5 s**(建议可配置,默认 5)。 | +| **期间若开始说话** | 清零超时;**断句上云**后本超时在下一轮「滴声」后重新起算。 | +| **触发动作** | 播 **超时提示音** → 进入 **`STANDBY`**(不再滴声、不上云)。 | +| **不适用** | **`FLIGHT_CONFIRM`** 整段;确认窗用 **服务端给的 `timeout_sec`**。 | + +**机端配置**(`system.yaml` `cloud_voice`):`listen_silence_timeout_sec`、`post_cue_mic_mute_ms`、`segment_cue_duration_ms`;环境变量见 `main_app.py` 头部说明。 + +--- + +## 5. 断句后提示音(工程) + +| 项 | 约定 | +|----|------| +| 目的 | 用户感知「已截句,可等待播报」 | +| 实现 | 机端本地短 WAV / 蜂鸣;时长建议 **≤ 200 ms** | +| 回声 | **SEGMENT_END** 阶段闭麦或硬件 AEC;结束后 **≥ 150 ms** 再进入 `UPLOADING` | +| 与云端 | **无需** 上传该提示音 | + +--- + +## 6. 时序简图(闲聊多轮) + +```mermaid +sequenceDiagram + participant U as 用户 + participant D as 机端 + participant S as 服务端 + + U->>D: 唤醒词(本地) + D->>D: GREETING 播问候 + D->>D: 滴声 → PROMPT_LISTEN(起 5s 定时) + U->>D: 一句语音 + D->>D: VAD 断句 → 短提示音 → UPLOADING + D->>S: turn.audio.start/chunk/end + S->>D: asr.partial(可选) + S->>D: dialog_result + TTS + turn.complete + D->>D: PLAYING_CLOUD_TTS(关麦) + alt chitchat + D->>D: 再滴声 → PROMPT_LISTEN + else flight_intent + D->>D: FLIGHT_CONFIRM(独立超时) + end +``` + +--- + +## 7. 配置建议(机端) + +| 键 | 默认值 | 说明 | +|----|--------|------| +| `listen_silence_timeout_sec` | `5` | 滴声后起算 | +| `post_cue_mic_mute_ms` | `150`~`300` | 断句提示音后再采集 | +| `cue_tone_duration_ms` / `segment_cue_duration_ms` | `≤200` | 断句提示 | +| `flight_confirm_handling` | 遵循飞控文档 | 禁用闲聊 5s 覆盖 | + +--- + +## 8. 机端开发自检 + +- [ ] `STANDBY` 下无 `turn.audio.start`。 +- [ ] `PLAYING_CLOUD_TTS` 与 `SEGMENT_END` 提示音阶段 **不开麦**(v1)。 +- [ ] 每轮新 `turn_id`;不并行两轮音频上行。 +- [ ] `flight_intent` 后进入 `FLIGHT_CONFIRM`,**不**误用 5s 闲聊超时。 +- [ ] `chitchat` 在 TTS 完成后 **再滴** 再 `PROMPT_LISTEN`。 + +--- + +## 9. 非目标(v1) + +- 播报中抢话、打断 TTS、实时 re-prompt。 +- 服务端驱动「滴声/待机」(均由机端规则实现)。 +- 连续免唤醒「直接说指令」跨多轮(若需另开 v2)。 + +--- + +## 10. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1 | 2026-04-07 | 首版:小爱类会话 + 双端分工;不含 barge-in | diff --git a/docs/DEPLOYMENT_AND_OPERATIONS.md b/docs/DEPLOYMENT_AND_OPERATIONS.md new file mode 100644 index 0000000..1879e5a --- /dev/null +++ b/docs/DEPLOYMENT_AND_OPERATIONS.md @@ -0,0 +1,288 @@ +# 部署与运维手册(项目总结) + +本文面向 **生产/外场部署**:说明 **voice_drone_assistant** 是什么、与 **云端** / **ROS 伴飞桥** / **PX4** 如何衔接,以及 **推荐启动顺序**、**环境变量**与**常见问题**。 +协议细节见 [`llmcon.md`](llmcon.md),通用配置索引见 [`PROJECT_GUIDE.md`](PROJECT_GUIDE.md),伴飞桥行为见 [`FLIGHT_BRIDGE_ROS1.md`](FLIGHT_BRIDGE_ROS1.md),`flight_intent` 字段见 [`FLIGHT_INTENT_SCHEMA_v1.md`](FLIGHT_INTENT_SCHEMA_v1.md)。 + +--- + +## 1. 项目总结 + +### 1.1 定位 + +**voice_drone_assistant** 是板端 **语音无人机助手**:麦克风 → 降噪/VAD → **SenseVoice STT** → **唤醒词** → 用户一句指令 → **云端 WebSocket**(LLM + TTS)或 **本地 Qwen + Kokoro** → 若服务端返回 **`flight_intent`**,可在本机 **校验后执行**(TCP Socket 旧路径,或 **ROS 伴飞桥** 推荐路径)。 + +### 1.2 推荐数据流(方案一:云 → 语音程序 → ROS 桥) + +```mermaid +flowchart LR + subgraph cloud [云端] + WS[WebSocket LLM+TTS] + end + subgraph board [机载 香橙派等] + MIC[麦克风] + MAIN[main.py TakeoffPrintRecognizer] + ROSPUB[子进程 publish JSON] + BRIDGE[flight_bridge ros1_node] + MAV[MAVROS] + end + FCU[PX4 飞控] + + MIC --> MAIN + MAIN <-->|WSS pcm_asr_uplink + flight_intent| WS + MAIN -->|ROCKET_FLIGHT_INTENT_ROS_BRIDGE| ROSPUB + ROSPUB -->|std_msgs/String /input| BRIDGE + BRIDGE --> MAV --> FCU +``` + +- **不**把 ROS 直接暴露给公网:云端只连板子的 **WSS/WS**;飞控由 **本机 MAVROS + 伴飞桥** 执行。 +- **TCP Socket**(`system.yaml` → `socket_server`)是另一条试飞控通道,与云端 **无关**;未起 Socket 服务端时仅会重连日志,不影响 ROS 方案。 + +### 1.3 目录与核心入口(仓库根 = `voice_drone_assistant/`) + +| 路径 | 说明 | +|------|------| +| `main.py` | 语音助手入口 | +| `with_system_alsa.sh` | 建议包装启动,修正 Conda 与系统 ALSA | +| `voice_drone/main_app.py` | 唤醒、云端/本地 LLM、TTS、`flight_intent` 执行策略 | +| `voice_drone/flight_bridge/ros1_node.py` | ROS1 订阅 `/input`,执行 `flight_intent` | +| `voice_drone/flight_bridge/ros1_mavros_executor.py` | MAVROS:offboard / AUTO.LAND / RTL | +| `voice_drone/tools/publish_flight_intent_ros_once.py` | 单次向 ROS 发布 JSON(主程序 ROS 桥会子进程调用) | +| `scripts/run_flight_bridge_with_mavros.sh` | 一键:roscore(可选)+ MAVROS + 伴飞桥 | +| `scripts/run_flight_intent_bridge_ros1.sh` | 仅伴飞桥(须已有 roscore + MAVROS) | +| `voice_drone/config/system.yaml` | 音频、STT、TTS、云端、`assistant` 等 | +| `requirements.txt` | Python 依赖;**rospy** 来自 `apt` 的 ROS Noetic,见文件内注释 | + +--- + +## 2. 环境与依赖 + +### 2.1 硬件与系统(典型) + +- ARM64 板卡(如 RK3588)、ES8388 等音频编解码器、USB/内置麦克风。 +- Ubuntu 20.04 + **ROS Noetic**(伴飞桥 / MAVROS 路径);同机运行语音进程与 `ros1_node`。 +- 飞控串口(如 `/dev/ttyACM0`)与 MAVROS `fcu_url` 一致。 + +### 2.2 Python + +- Python 3.10+(与原仓库一致即可)。 +- 在 **`voice_drone_assistant`** 根目录: + + ```bash + pip install -r requirements.txt + ``` + +### 2.3 ROS / MAVROS(伴飞桥方案必选) + +```bash +sudo apt install ros-noetic-ros-base ros-noetic-mavros ros-noetic-mavros-extras +# 按官方文档执行 mavros 地理库安装(如有) +``` + +- 语音主程序的 **ROS 桥**子进程会 `source /opt/ros/noetic/setup.bash` 并 **prepend** `PYTHONPATH`,**不要**在未 source ROS 的 shell 里把 `PYTHONPATH` 设成「只有工程根」,否则会找不到 `rospy`(参见 `main_app` 中 `_publish_flight_intent_to_ros_bridge`)。 + +### 2.4 模型与权重 + +- STT / TTS /(可选)VAD 放入 `models/`,或 `bash scripts/bundle_for_device.sh` 从原仓库打包。 +- 本地 LLM:GGUF 默认路径或 `ROCKET_LLM_GGUF`;**纯云端对话**时可弱化本地模型,但回退/混合模式仍需。 + +--- + +## 3. 部署拓扑 + +### 3.1 单机一体化(常见) + +同一台香橙派上同时运行: + +1. **roscore**(若尚无 master,由 `run_flight_bridge_with_mavros.sh` 拉起)。 +2. **MAVROS**(`px4.launch`,串口连 PX4)。 +3. **伴飞桥** `python3 -m voice_drone.flight_bridge.ros1_node`(订阅 **`/input`**)。 +4. **语音** `bash with_system_alsa.sh python main.py`。 + +`ROS_MASTER_URI` / `ROS_HOSTNAME`:一键脚本内默认 `http://127.0.0.1:11311` 与 `127.0.0.1`;**新开调试终端** 执行 `rostopic`/`rosservice` 前须自行 `source /opt/ros/noetic/setup.bash` 并 export **同一** `ROS_MASTER_URI`(见下文「常见问题」)。 + +### 3.2 网络 + +- 板子能访问 **云端 WebSocket**(`ROCKET_CLOUD_WS_URL`)。 +- PX4 + 遥控 + 安全开关等按外场规范配置;本文不替代安全检校清单。 + +--- + +## 4. 启动顺序(推荐) + +### 4.1 终端 A:飞控栈 + 伴飞桥 + +在 **`voice_drone_assistant`** 根目录: + +```bash +cd /path/to/voice_drone_assistant +bash scripts/run_flight_bridge_with_mavros.sh /dev/ttyACM0 921600 +``` + +脚本会: + +- 设置 `ROS_MASTER_URI`、`ROS_HOSTNAME`(未预设时默认为本机 master); +- 如无 master 则启动 **roscore**; +- 启动 **MAVROS** 并等待 `/mavros/state` **connected**; +- 前台启动伴飞桥,日志中应出现:`flight_intent_bridge 就绪:订阅 /input`。 + +**仅桥(已有 MAVROS 时)**: + +```bash +source /opt/ros/noetic/setup.bash +export ROS_MASTER_URI="${ROS_MASTER_URI:-http://127.0.0.1:11311}" +export ROS_HOSTNAME="${ROS_HOSTNAME:-127.0.0.1}" +bash scripts/run_flight_intent_bridge_ros1.sh +``` + +### 4.2 终端 B:语音助手 + 云端 + 执行飞控 + +```bash +cd /path/to/voice_drone_assistant + +export ROCKET_CLOUD_VOICE=1 +export ROCKET_CLOUD_WS_URL='ws://<云主机>:8766/v1/voice/session' +export ROCKET_CLOUD_AUTH_TOKEN='' +export ROCKET_CLOUD_DEVICE_ID='drone-001' # 可选 + +# 云端返回 flight_intent 时是否在机端执行 +export ROCKET_CLOUD_EXECUTE_FLIGHT=1 +# 走 ROS 伴飞桥(与 Socket/offboard 序列互斥,勿双开重复执行) +export ROCKET_FLIGHT_INTENT_ROS_BRIDGE=1 +# 可选:ROCKET_FLIGHT_BRIDGE_TOPIC=/input ROCKET_FLIGHT_BRIDGE_WAIT_SUB=2 + +# 默认关闭本地「起飞演示」口令直起 offboard;需要时再设为 1 +# export ROCKET_LOCAL_KEYWORD_TAKEOFF=1 + +bash with_system_alsa.sh python main.py +``` + +成功时日志类似:`[飞控-ROS桥] 已发布至 /input`;伴飞桥端出现 `执行 flight_intent:steps=...`。 + +### 4.3 配置写进 YAML(可选) + +- 云端:`system.yaml` → `cloud_voice`(`enabled`、`server_url`、`auth_token` 等)。 +- 本地口令起飞:`assistant.local_keyword_takeoff_enabled`(默认 `false`);环境变量 `ROCKET_LOCAL_KEYWORD_TAKEOFF` **非空时优先生效**。 + +--- + +## 5. 环境变量速查(飞控与云端) + +| 变量 | 含义 | +|------|------| +| `ROCKET_CLOUD_VOICE` | `1`:对话走云端 WebSocket | +| `ROCKET_CLOUD_WS_URL` | 云端会话地址 | +| `ROCKET_CLOUD_AUTH_TOKEN` | WS 鉴权 | +| `ROCKET_CLOUD_DEVICE_ID` | 设备 ID(可选) | +| `ROCKET_CLOUD_EXECUTE_FLIGHT` | `1`:云端 `flight_intent` 在机端执行 | +| `ROCKET_FLIGHT_INTENT_ROS_BRIDGE` | `1`:执行方式为 **发布到 ROS `/input`**,不跑机内 Socket+offboard 序列 | +| `ROCKET_FLIGHT_BRIDGE_TOPIC` | 默认 `/input` | +| `ROCKET_FLIGHT_BRIDGE_SETUP` | 子进程内 source ROS 的命令,默认 `source /opt/ros/noetic/setup.bash` | +| `ROCKET_FLIGHT_BRIDGE_WAIT_SUB` | 发布前等待订阅者的秒数,默认 `2`;`0` 即尽可能快发 | +| `ROCKET_LOCAL_KEYWORD_TAKEOFF` | 非空时:`1/true/yes` 开启 **`keywords.yaml` takeoff → 本地 offboard** | +| `ROCKET_CLOUD_PX4_CONTEXT_FILE` | 覆盖 `cloud_voice.px4_context_file`,合并进 session.start | + +更多调试变量见 **`voice_drone/main_app.py` 文件头注释** 与 [`PROJECT_GUIDE.md`](PROJECT_GUIDE.md) 第 5 节。 + +--- + +## 6. 联调与自测 + +### 6.1 仅测 ROS 链(无语音) + +终端已 `source /opt/ros/noetic/setup.bash` 且与 master 一致: + +```bash +rostopic pub -1 /input std_msgs/String \ + "data: '{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"land\",\"args\":{}}],\"summary\":\"测\"}'" +``` + +注意:`std_msgs/String` 在命令行里只能写 **`data: '...json...'`**,不能把 JSON 放在消息顶层。 + +### 6.2 确认话题与 master + +```bash +source /opt/ros/noetic/setup.bash +export ROS_MASTER_URI=http://127.0.0.1:11311 +rosnode list +rostopic info /input +rosservice list | grep set_mode +``` + +若 `Unable to communicate with master!`:当前 shell 未连上正在运行的 **roscore**(或未 export 正确 `ROS_MASTER_URI`)。 + +--- + +## 7. 常见问题(摘录) + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| `ModuleNotFoundError: rospy` | 子进程未继承 ROS 的 `PYTHONPATH` | 已修复为 `PYTHONPATH=<根>:$PYTHONPATH`;确保 `ROCKET_FLIGHT_BRIDGE_SETUP` 能 source Noetic | +| 语音端「已发布」但桥无日志 | 曾用相对 `input`,与全局 `/input` 不一致 | 伴飞桥默认已改为订阅 **`/input`**;重启桥 | +| `set_mode unavailable` / land 失败 | OFFBOARD 断流、MAVROS 异常等 | 伴飞桥降落逻辑已带持续 setpoint + 重连 proxy;仍失败则查 `rosservice`、`/mavros/state`、链路 | +| takeoff 超时 | 未进 OFFBOARD、未解锁、定位未就绪 | 查地面站、`/mavros/state`、适当增大 `~takeoff_timeout_sec`(ROS 私有参数) | +| ALSA underrun | 播放与采集竞争 | 板端常见;可调缓冲区/设备或 `recognizer.ack_pause_mic_for_playback` | + +--- + +## 8. 安全与运维建议 + +- 外场前在 **SITL 或系留** 环境验证完整 **`flight_intent`** 序列。 +- 云端 token、WS URL 勿提交到公开仓库;用环境变量或本机 **overlay** 配置注入。 +- 升级伴飞桥或 MAVROS 后清日志重试一遍 **`/input`** 手发 JSON。 + +--- + +## 9. 迁移到另一台香橙派:是否只拷贝 `voice_drone_assistant` 即可? + +**结论:目录是「代码 + 配置」的核心载体,但仅靠「整文件夹 scp 过去」通常不够;新板必须再装系统级依赖、模型与(可选)ROS,并按现场改配置。** + +### 9.1 拷贝目录本身会带上什么 + +| 已包含 | 说明 | +|--------|------| +| 全部 Python 源码、`voice_drone/config/*.yaml` 默认配置 | 可直接改 YAML / 环境变量适配新环境 | +| `scripts/`、`with_system_alsa.sh`、`docs/` | 启动与说明在包内 | + +### 9.2 新板必须单独准备(不随目录自动存在) + +| 项 | 说明 | +|----|------| +| **Ubuntu + 音频/ALSA** | 与当前开发板同代或自行适配;录音设备索引可能变化,需重选或设 `ROCKET_INPUT_DEVICE_INDEX` | +| **`pip install -r requirements.txt`** | 每台新 Python 环境执行一次(或整体迁移同一 conda 目录) | +| **`models/`** | STT/TTS/VAD 体积大,**务必**在本机先 `bash scripts/bundle_for_device.sh /path/to/rocket_drone_audio` 或手工拷入,见 `models/README.txt` | +| **`cache/` GGUF** | 纯云端可不强依赖;若需本地 Qwen 回退,拷贝或设 `ROCKET_LLM_GGUF` | +| **ROS Noetic + MAVROS** | **apt** 安装;伴飞桥方案 **必选**;`rospy` **不要**指望只靠 pip | +| **云端连通** | 新板 IP/防火墙能访问 `ROCKET_CLOUD_WS_URL`;token 用环境变量注入 | +| **`dialout` 等权限** | 访问 `/dev/ttyACM0` 的用户加入 `dialout`,否则 MAVROS 无串口 | +| **`system.yaml` 现场差异** | `socket_server` IP、可选 `tts.output_device`、若麦索引固定可写 `audio.input_device_index` | + +### 9.3 推荐迁移流程(简表) + +1. 在旧机或 CI:**bundle 模型** → 打包整个 `voice_drone_assistant`(含 `models/`,按需含 `cache/`)。 +2. 新香橙派:解压到任意路径,安装 **`requirements.txt`**、**ROS+MAVROS**、系统音频工具。 +3. 用 **`with_system_alsa.sh python main.py`** 试麦与 STT;再按本文 **§4** 双终端起 **桥 + 语音**。 +4. 首次外场前做一次 **`rostopic pub /input`** 手发 JSON(见 **§6**)。 + +### 9.4 常见误区 + +- **只拉 Git、不拷 `models/`**:STT/TTS 启动即失败。 +- **新板 Noetic 未装却开 `ROCKET_FLIGHT_INTENT_ROS_BRIDGE`**:发布子进程仍可能报错。 +- **假设麦克风设备号一定相同**:Orange Pi 刷机或换内核后常变,以首次启动日志为准。 + +--- + +## 10. 文档索引 + +| 文档 | 用途 | +|------|------| +| [`README.md`](../README.md) | 仓库简介、模型、`bundle` | +| [`PROJECT_GUIDE.md`](PROJECT_GUIDE.md) | 配置项与日常用法索引 | +| **本文** | 部署拓扑、启动顺序、环境变量、联调、**§9 迁移清单** | +| [`FLIGHT_BRIDGE_ROS1.md`](FLIGHT_BRIDGE_ROS1.md) | 伴飞桥参数、PX4 行为、`rostopic pub` 注意 | +| [`FLIGHT_INTENT_SCHEMA_v1.md`](FLIGHT_INTENT_SCHEMA_v1.md) | JSON 协议 | +| [`llmcon.md`](llmcon.md) | 云端协议 | +| [`CLOUD_VOICE_FLIGHT_CONFIRM_v1.md`](CLOUD_VOICE_FLIGHT_CONFIRM_v1.md) | **飞控口头二次确认**(闲聊不变、确认/取消/超时)云端与机端字段约定 | + +--- + +*文档版本与仓库同步;若行为与代码不一致,以当前 `main_app.py`、`flight_bridge/*.py` 为准。* diff --git a/docs/FLIGHT_BRIDGE_ROS1.md b/docs/FLIGHT_BRIDGE_ROS1.md new file mode 100644 index 0000000..0d9b615 --- /dev/null +++ b/docs/FLIGHT_BRIDGE_ROS1.md @@ -0,0 +1,88 @@ +# Flight Intent 伴飞桥(ROS 1 + MAVROS) + +本目录代码与语音助手 **`main.py` 独立进程**:在 **MAVROS 已连接 PX4** 的前提下,订阅一条 JSON,按 **`FLIGHT_INTENT_SCHEMA_v1.md`** 顺序执行。 + +## 依赖 + +- Ubuntu / 设备上已装 **ROS Noetic**、`mavros`(与 `scripts/run_px4_offboard_one_terminal.sh` 一致) +- Python 能 import:`rospy`、`std_msgs`、`geometry_msgs`、`mavros_msgs` +- 本仓库根目录 **`voice_drone_assistant`** 需在 `PYTHONPATH`(启动脚本已设置) + +## 启动 + +**推荐(不会单独敲 roslaunch 时用)**:一键拉起 roscore(若尚无)→ MAVROS → 伴飞桥: + +```bash +cd voice_drone_assistant +bash scripts/run_flight_bridge_with_mavros.sh +bash scripts/run_flight_bridge_with_mavros.sh /dev/ttyACM0 921600 +``` + +**已有 MAVROS 时**只启桥: + +```bash +cd voice_drone_assistant +bash scripts/run_flight_intent_bridge_ros1.sh +``` + +默认节点名(含 anonymous 后缀):`/flight_intent_mavros_bridge_<...>`,默认订阅 **全局 `/input`**(`std_msgs/String`,内容为 JSON)。私有参数 `~input_topic` 可改(例如专用名时填入完整话题)。桥启动日志会打印实际订阅名。 + +## JSON 格式 + +- **完整** `flight_intent`(与云端相同顶层字段),或 +- **最小**:`{"actions":[...], "summary":"任意非空"}`(节点内会补 `is_flight_intent/version`) + +校验失败会打 `rospy.logerr`,不执行。 + +## 行为映射(首版) + +| `type` | 行为(简述) | +|--------|----------------| +| `takeoff` | Offboard 位姿:当前点预热 setpoint → `OFFBOARD` + arm → `z0 + Δz`(Δz 来自 `relative_altitude_m` 或参数 `~default_takeoff_relative_m`,与 `px4_ctrl_offboard_demo` 同号约定) | +| `hover` / `hold` | 当前位姿 hold 约 1s(持续发 setpoint) | +| `wait` | `rospy.Duration`,Offboard 时顺带维持当前点 setpoint | +| `goto` | `local_ned` / `body_ned` 增量 → 目标 NED 点,到位容差见 `~goto_position_tolerance` | +| `land` | `AUTO.LAND` | +| `return_home` | `AUTO.RTL` | + +**注意**:真机前请 SITL 验证;不同 PX4/机型的 `custom_mode` 字符串若不一致需在 `ros1_mavros_executor.py` 中调整。 + +## 参数(私有命名空间) + +| 参数 | 默认 | 含义 | +|------|------|------| +| `~input_topic` | `/input` | 订阅话题(建议绝对路径;勿再用相对名 `input`,否则与 `/input` 对不上) | +| `~default_takeoff_relative_m` | `0.5` | `takeoff` 无 `relative_altitude_m` 时 | +| `~takeoff_timeout_sec` | `15` | | +| `~goto_position_tolerance` | `0.15` | m | +| `~goto_timeout_sec` | `60` | | +| `~land_timeout_sec` | `45` | land/rtl 等待 disarm 超时 | +| `~offboard_pre_stream_count` | `80` | 与 demo 类似 | + +## 与语音程序的关系 + +- **`main.py`**:仍可用 Socket / 本地 offboard **演示脚本**(产品过渡期)。 +- **桥**:适合作为 **长期** MAVROS 执行端;后续可把语音侧改为 **向本节点 `input` 发布 JSON**(`rospy` 或 `roslibpy` 等),而不再直接起 bash demo。 + +## 与语音侧联调:`rostopic pub`(注意 `data:`) + +`std_msgs/String` 的 YAML 只有字段 **`data`**,JSON 必须写在 **`data: '...'`** 里;不能把 JSON 直接当消息顶层(否则会 `ERROR: No field name [is_flight_intent]` / `Args are: [data]`)。 + +终端 A:已跑 `run_flight_bridge_with_mavros.sh`(或 MAVROS + 桥)。 +终端 B: + +```bash +source /opt/ros/noetic/setup.bash +# 订阅名以桥日志为准,常见为全局 /input +rostopic pub -1 /input std_msgs/String \ + "data: '{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"land\",\"args\":{}}],\"summary\":\"联调降落\"}'" +``` + +**悬停 2 秒再降**(须已在空中时再试): + +```bash +rostopic pub -1 /input std_msgs/String \ + "data: '{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"hover\",\"args\":{}},{\"type\":\"wait\",\"args\":{\"seconds\":2}},{\"type\":\"land\",\"args\":{}}],\"summary\":\"测\"}'" +``` + +在语音进程内集成时,建议 **不要在 asyncio 里直接调 rospy**,用 **独立桥进程 + topic** 或 **UNIX socket 转发到桥**。 diff --git a/docs/FLIGHT_INTENT_IMPLEMENTATION_PLAN.md b/docs/FLIGHT_INTENT_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..eba4320 --- /dev/null +++ b/docs/FLIGHT_INTENT_IMPLEMENTATION_PLAN.md @@ -0,0 +1,113 @@ +# Flight Intent v1 + 伴飞桥 — 实施计划 + +本文档与 [`FLIGHT_INTENT_SCHEMA_v1.md`](FLIGHT_INTENT_SCHEMA_v1.md) 配套,描述从协议闭环到 ROS/PX4 可控的**分阶段交付**。顺序建议按阶段 0→4;各阶段内任务可并行处已标注。 + +--- + +## 目标与验收标准 + +| 维度 | 验收标准 | +|------|-----------| +| **协议** | 云端下发的 `flight_intent` 满足 v1:含 `wait`、`takeoff` 可选高度、`trace_id`;L1–L3 校验可自动化 | +| **语音客户端** | 能解析并记录完整 `actions`;在 `ROCKET_CLOUD_EXECUTE_FLIGHT=1` 时通过 Socket/桥 执行或与桥约定本地执行 `wait` | +| **桥** | 顺序执行 `actions`,每步有超时/失败策略;可对接 MAVROS(或既定 ROS 2 栈)驱动 PX4 | +| **安全** | 执行前 L4 门禁、执行中可中断、急停路径明确 | +| **回归** | SITL 或台架可重复跑通「起飞 → 悬停 → wait → 降落」等示例 | + +--- + +## 阶段 0:对齐与基线(约 0.5~1 天) + +- [ ] 全员精读 `FLIGHT_INTENT_SCHEMA_v1.md`,冻结 **v1 白名单**(`type` / `args` 键)。 +- [ ] 确认伴飞侧技术选型:**ROS 2 + MAVROS**(或 `px4_ros_com`)与默认 **AUTO vs Offboard** 策略(写入桥 YAML,不写进 JSON)。 +- [ ] 盘点现有 **Socket 服务**:是否即「桥」或仅转发;是否需新进程 `flight_intent_bridge`。 +- [ ] 建立 **trace_id** 在日志中的格式(云端 / 语音 / 桥统一)。 + +**产出**:架构一页纸(谁消费 WebSocket、谁连 PX4)、桥配置模板路径约定。 + +--- + +## 阶段 1:协议与云端(可与阶段 2 并行,约 2~4 天) + +- [ ] **Schema 校验**:服务端对 `flight_intent` 做 L1–L3(必要时 L4 占位);非法则 `routing=error` 或产品协议兜底。 +- [ ] **LLM 提示词**:只允许 §3.7 中 `type` 与允许键;强调 **时长必须用 `wait`**,禁止用 `summary` 控机。 +- [ ] **示例与回归用例**:固定 JSON golden(§7.1~§7.3 + 边界:首步 `wait`、`seconds` 超界、多余 `args` 键)。 +- [ ] **可选 `trace_id`**:服务端生成或在 bundle 层透传。 + +**产出**:校验测试集、提示词 MR、发布说明(对客户端可见的字段变更)。 + +--- + +## 阶段 2:语音客户端(`voice_drone_assistant`)(约 3~5 天) + +可与阶段 1、3 部分并行。 + +- [x] **Pydantic**:`voice_drone/core/flight_intent.py`(v2)按 v1 文档收紧动作与 `args`。 +- [x] **`parse_flight_intent_dict`**:等价 L1–L3 + 首步禁止 `wait`;白名单、`goto.frame`、`wait.seconds`、`takeoff.relative_altitude_m`。 +- [x] **`main_app`**:`ROCKET_CLOUD_EXECUTE_FLIGHT=1` 时在后台线程 **`_run_cloud_flight_intent_sequence`** 顺序执行;`wait` 用 `time.sleep`;`goto` **单轴** 映射 Socket `Command`;`return_home` 已入 `Command`;**含 `takeoff` 的序列**在 offboard 完成后继续后续步(不再丢失)。 +- [x] **日志**:序列开始时打印 `trace_id`;`takeoff` 打相对高度提示(offboard 是否消费须自行接参数)。 +- [x] **单测**:`tests/test_flight_intent.py`(无完整依赖时 goto 用例自动 skip)。 + +**产出**:MR 合并后,本地无 PX4 也能跑通解析与 mock 执行。 + +--- + +## 阶段 3:伴飞桥 + ROS/PX4(约 5~10 天,视现网复用程度) + +- [x] **进程边界(首版)**:独立 ROS1 节点,订阅 `std_msgs/String` JSON;见 **`docs/FLIGHT_BRIDGE_ROS1.md`**、`scripts/run_flight_intent_bridge_ros1.sh`。 +- [x] **执行器(首版)**:`voice_drone/flight_bridge/ros1_mavros_executor.py` 单线程顺序执行;`takeoff/goto` 带超时;`land/rtl` 等待 disarm 超时。 +- [x] **翻译实现(首版 / MAVROS)**: + - `takeoff` / `hover` / `wait` / `goto`:`/mavros/setpoint_raw/local`(Offboard)+ `set_mode` / `arming`。 + - `land` / `return_home`:`AUTO.LAND` / `AUTO.RTL`。 +- [ ] **安全**:L4(电量、围栏、急停 topic);`wait` 中异常策略。 +- [ ] **回执**:result topic / 与 `main.py` 的 topic 串联。 +- [ ] **ROS2 / 仅 TCP 无 ROS**:按需另起接口。 + +**产出(当前)**:ROS1 桥可 `rostopic pub` 联调;**待** launch、与语音侧发布 JSON、SITL CI。 + +--- + +## 阶段 4:联调、硬化与发布(约 3~7 天) + +- [ ] **端到端**:真机或 SITL:语音 → 云 → 客户端 → 桥 → PX4,带 `trace_id` 串 log。 +- [ ] **压测与失败注入**:断 WebSocket、桥崩溃重启、Offboard 丢失等(预期行为写进运维文档)。 +- [ ] **配置与门禁**:默认关闭实飞执行;仅生产镜像打开;参数与围栏双人复核。 +- [ ] **文档**:更新 `PROJECT_GUIDE.md` 中「飞控路径」链接到本文与 SCHEMA。 + +**产出**:发布 checklist、已知限制列表(如某机型仅支持 AUTO 等)。 + +--- + +## 依赖与风险 + +| 风险 | 缓解 | +|------|------| +| Socket 协议与 `Command` 无法表达多步 | **推荐**由桥消费**完整** `flight_intent` JSON,客户端只负责下发一份;少步经 Socket 逐条 | +| Offboard 与 AUTO 混用冲突 | 桥配置单一「主策略」;`goto` 仅在 Offboard 就绪时接受 | +| LLM 仍产出非法 JSON | L2 硬拒绝 + 提示词回归 + golden 测试 | +| 排期膨胀 | 先交付 **AUTO 模式族 + wait + land**,再迭代复杂 `goto` | + +--- + +## 建议里程碑(日历为估算) + +| 里程碑 | 内容 | +|--------|------| +| **M1** | 阶段 0–1 完成:云校验 + 提示词 + golden | +| **M2** | 阶段 2 完成:客户端 strict 模型 + `wait` + 执行路径单一数据源 | +| **M3** | 阶段 3 完成:桥 + SITL 跑通 §7.2 | +| **M4** | 阶段 4:联调签字 + 生产策略 | + +--- + +## 文档索引 + +| 文档 | 用途 | +|------|------| +| [`FLIGHT_INTENT_SCHEMA_v1.md`](FLIGHT_INTENT_SCHEMA_v1.md) | 字段、校验、桥分层、ROS 参考 | +| [`PROJECT_GUIDE.md`](PROJECT_GUIDE.md) | 仓库总览与运行方式 | +| 本文 | 任务拆解、顺序、验收 | + +--- + +**版本**:2026-04-07;随 SCHEMA v1 修订同步更新本计划中的阶段勾选与工期估算。 diff --git a/docs/FLIGHT_INTENT_SCHEMA_v1.md b/docs/FLIGHT_INTENT_SCHEMA_v1.md new file mode 100644 index 0000000..9a0a9ae --- /dev/null +++ b/docs/FLIGHT_INTENT_SCHEMA_v1.md @@ -0,0 +1,372 @@ +# 云端高层飞控意图 JSON 规范 v1(完整版) + +> **定位**:定义 WebSocket `dialog_result.flight_intent` 中的**语义对象**与**伴飞侧执行约定**,不是 MAVLink 二进制帧。 +> **目标**: +> +> 1. **协议**:客户端与云端可 **100% 按字段表 strict 解析**;**禁止**用自然语言或 `summary` 驱动机控。 +> 2. **桥(companion)**:按本文执行 **有序 `actions`**、校验、排队与安全门,再译为 PX4 可接受的模式 / 指令 / Offboard setpoint。 +> 3. **ROS**:为 MAVROS(或等价 ROS 2 封装)提供**参考映射表**;具体 topic/service 名称以装机软件栈为准。 + +**协议(bundle)**:`proto_version: "1.0"`,会话 `transport_profile: "pcm_asr_uplink"`(与机端 CloudVoiceClient 一致)。 + +--- + +## 1. 顶层对象 `flight_intent` + +当 `routing === "flight_intent"` 时,`flight_intent` **必须非 null**,且为下表 JSON 对象(键顺序任意)。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `is_flight_intent` | `boolean` | 是 | **必须为 `true`** | +| `version` | `integer` | 是 | **Schema 版本,本文档固定为 `1`** | +| `actions` | `array` | 是 | **有序**动作列表,按**时间先后**执行(见 §5、§10) | +| `summary` | `string` | 是 | 一句人类可读中文摘要(播报/日志);**不参与机控解析** | +| `trace_id` | `string` | 否 | **端到端追踪 ID**(建议 UUID 或雪花 ID);用于桥、ROS 节点与日志关联;长度建议 ≤ 128 | + +**禁止字段**:除上表外,顶层不得出现其它键(便于 `strict` 解析)。扩展须 **递增 `version`**(见 §8)。 + +**字符编码**:UTF-8。 + +--- + +## 2. `actions[]` 通用形式 + +每个元素: + +```json +{ "type": "", "args": { ... } } +``` + +| 字段 | 类型 | 必填 | +|------|------|------| +| `type` | `string` | 是,取值限于 §3 枚举 | +| `args` | `object` | 是,允许为空对象 `{}`;**仅允许**各小节表中列出的键 | + +--- + +## 3. `ActionType` 与白名单 `args` + +以下 **`type` 仅允许小写**。未列出的值在 v1 **非法**。 + +### 3.1 `takeoff` + +| args 键 | 类型 | 必填 | 说明 | +|---------|------|------|------| +| `relative_altitude_m` | `number` | 否 | 相对起飞点(或飞控定义的 TAKEOFF 参考)的**目标高度**,单位 **米**,须 **> 0**。省略则 **完全由机端/飞控缺省参数** 决定(与旧版「空 args」语义一致) | + +```json +{ "type": "takeoff", "args": {} } +``` + +```json +{ "type": "takeoff", "args": { "relative_altitude_m": 5 } } +``` + +**桥 / ROS 映射提示**:PX4 `TAKEOFF` 模式、`MAV_CMD_NAV_TAKEOFF`;相对高度常与 `MIS_TAKEOFF_ALT` 或命令参数结合,**以装机参数为准**。 + +--- + +### 3.2 `land` + +| args 键 | 类型 | 必填 | 说明 | +|---------|------|------|------| +| — | — | — | v1 无参;降落行为由飞控 `AUTO.LAND` 等策略决定 | + +```json +{ "type": "land", "args": {} } +``` + +**桥 / ROS 映射提示**:`AUTO.LAND`、`MAV_CMD_NAV_LAND`;固定翼 / 多旋翼路径不同,由机端处理。 + +--- + +### 3.3 `return_home` + +```json +{ "type": "return_home", "args": {} } +``` + +语义:**返航至 Home 并按飞控策略降落或盘旋**(与 PX4 **RTL** 概义一致)。 + +--- + +### 3.4 `hover` 与 `hold` + +二者在 v1 **语义等价**:**在当前位置附近保持**(多旋翼常见为位置保持;固定翼可能映射为 Loiter,由机端按机型解释)。 + +| args 键 | 类型 | 必填 | 说明 | +|---------|------|------|------| +| — | — | — | 仅 `{}` 合法;表示进入保持,**不隐含**持续时间(时长用 §3.6 `wait`) | + +```json +{ "type": "hover", "args": {} } +``` + +```json +{ "type": "hold", "args": {} } +``` + +**约定**:同一 `actions` 序列建议只选 `hover` 或 `hold` 一种命名;解析端可映射到同一 PX4 行为。 + +**互操作(非规范首选)**:个别上游可能错误输出 `"args": { "duration": 3 }`(秒)。**伴飞客户端**(如本仓库 `flight_intent.py`)可在校验时将其**折叠**为:`hover`(无 `duration`)+ `wait`,与上表典型组合等价;**新开发的上游仍应只产 `wait`**。 + +**典型组合**(「悬停 3 秒后降落」): + +```json +[ + { "type": "takeoff", "args": {} }, + { "type": "hover", "args": {} }, + { "type": "wait", "args": { "seconds": 3 } }, + { "type": "land", "args": {} } +] +``` + +--- + +### 3.5 `goto` — 相对/局部位移 + +| args 键 | 类型 | 必填 | 说明 | +|---------|------|------|------| +| `frame` | `string` | 是 | 坐标系,取值见 §4 | +| `x` | `number` \| `null` | 否 | **米**;`null` 或 **省略** 表示该轴**无位移意图**(机端保持) | +| `y` | `number` \| `null` | 否 | 同上 | +| `z` | `number` \| `null` | 否 | 同上 | + +```json +{ + "type": "goto", + "args": { + "frame": "local_ned", + "x": 100, + "y": 0, + "z": 0 + } +} +``` + +**语义**:在 `frame` 下相对当前位置的**增量**。v1 **不**定义绝对经纬度航点;若未来需要,应 **v2** 增加 `goto_global` / `waypoint`。 + +**口语映射示例**:「向前飞 10 米」可建模为 `frame: "body_ned"`, `x: 10`(前为 x+,与 §4 一致)。 + +--- + +### 3.6 `wait` — 纯时间等待 + +**不包含**模式切换;桥在**本地计时**后继续下一步。用于「悬停多久」「停顿再执行」等。 + +| args 键 | 类型 | 必填 | 说明 | +|---------|------|------|------| +| `seconds` | `number` | 是 | 等待秒数,须满足 **0 < seconds ≤ 3600**(上限可防止 LLM 写极大值;产品可改小) | + +```json +{ "type": "wait", "args": { "seconds": 3 } } +``` + +**安全**:等待期间桥须持续监测遥测(失联、低电量、姿态异常等),**可中断**序列并转入 `RTL` / `LAND` / `HOLD`(策略见 §10.4)。 + +--- + +### 3.7 v1 动作类型一览 + +| `type` | 必填 `args` 键 | 备注 | +|--------|----------------|------| +| `takeoff` | 无 | 可选 `relative_altitude_m` | +| `land` | 无 | | +| `return_home` | 无 | | +| `hover` | 无 | | +| `hold` | 无 | 与 `hover` 等价 | +| `goto` | `frame` | 可选 x/y/z | +| `wait` | `seconds` | | + +--- + +## 4. `frame`(仅 `goto`) + +| 取值 | 含义 | +|------|------| +| `local_ned` | **局部 NED**:北(x)-东(y)-地(z),单位 m;**向下为 z 正**(与 PX4 `LOCAL_NED` 常见用法一致) | +| `body_ned` | **机体系**:**前(x)-右(y)-下(z)**,单位 m;桥或 ROS 侧需转换到 NED / setpoint | + +**v1 仅此两值**;其它字符串 **L2 非法**。 + +--- + +## 5. 序列语义与组合规则 + +- **`actions` 有序**:严格对应口语**时间顺序**(先执行索引 0,再 1,…)。 +- **空列表**:**不允许**(至少 1 个元素)。 +- **`wait`**:不改变飞控模式;若需「边悬停边等」,应先 `hover`/`hold` 再 `wait`(或在上一步已进入位置模式的假定下仅 `wait`,由机端策略定义;**推荐**显式 `hover` 再 `wait`)。 +- **首步**:首元素为 `wait` **不推荐**(飞机未起飞则等待无控飞意义);服务端可做 **L4 警告或拒绝**。 +- **`takeoff` 后出现 `goto`**:桥应确保已有位置估计/GPS 等前置条件,否则拒绝并回报原因。 +- **重复动作**:不禁止连续多个 `goto` / `wait`;机端可合并或排队。 + +--- + +## 6. 校验分级(服务端 + 桥建议共用) + +| 级别 | 内容 | +|------|------| +| **L1 结构** | JSON 可解析;`is_flight_intent===true`;`version===1`;`actions` 为非空数组;`summary` 为非空字符串;`trace_id` 若存在则为 string。 | +| **L2 枚举** | 每个 `action.type` ∈ §3.7;`goto` 含合法 `frame`;各 `args` **仅含**该 `type` 允许的键(无多余键)。 | +| **L3 数值** | `relative_altitude_m` 若存在则 **> 0** 且建议 capped(如 ≤ 500);`wait.seconds` 在 **(0, 3600]**;`goto` 的 x/y/z 为有限 number 或 null;位移模长可设上限(如 10e3 m)。 | +| **L4 语义** | 结合 `session.start.client.px4`(机型、是否支持 Offboard、地理围栏等);禁止不合法序列(如无定位时 `goto`);不通过则 `error` 或带工程约定 `warnings`(v2 可标准化 `warnings` 数组)。 | + +--- + +## 7. 完整示例 + +### 7.1 起飞 → 北飞 100m → 悬停 + +```json +{ + "is_flight_intent": true, + "version": 1, + "trace_id": "550e8400-e29b-41d4-a716-446655440000", + "actions": [ + { "type": "takeoff", "args": {} }, + { + "type": "goto", + "args": { "frame": "local_ned", "x": 100, "y": 0, "z": 0 } + }, + { "type": "hover", "args": {} } + ], + "summary": "起飞后向北飞约100米并悬停" +} +``` + +### 7.2 起飞 → 悬停 3 秒 → 降落 + +```json +{ + "is_flight_intent": true, + "version": 1, + "actions": [ + { "type": "takeoff", "args": { "relative_altitude_m": 3 } }, + { "type": "hover", "args": {} }, + { "type": "wait", "args": { "seconds": 3 } }, + { "type": "land", "args": {} } + ], + "summary": "起飞至约3米高,悬停3秒后降落" +} +``` + +### 7.3 返航 + +```json +{ + "is_flight_intent": true, + "version": 1, + "actions": [ + { "type": "return_home", "args": {} } + ], + "summary": "返航至 Home" +} +``` + +--- + +## 8. 演进与扩展 + +- **新增 `type`、新 `frame`、顶层字段**:须 **递增 `version`**(如 `2`)并附迁移说明。 +- **严禁**:在 `flight_intent` 内增加自由文本字段用于机动解释(仅 `summary` 可读)。 +- **调试**:可在外层 bundle(非 `flight_intent` 体内)附加 `schema: "cloud_voice.flight_intent@1"`,由工程约定。 + +--- + +## 9. JSON → PX4 责任边界(摘要) + +| JSON `type` | 机端典型职责(PX4 侧,非规范强制) | +|-------------|--------------------------------------| +| `return_home` | RTL / `MAV_CMD_NAV_RETURN_TO_LAUNCH` 等 | +| `takeoff` | TAKEOFF / `MAV_CMD_NAV_TAKEOFF`,高度来自 args 或参数 | +| `land` | LAND 模式 / `MAV_CMD_NAV_LAND` | +| `goto` | Offboard 轨迹、外部跟踪或 Mission 航点(**桥根据策略选一**) | +| `hover` / `hold` | LOITER / HOLD / 位置保持 setpoint | +| `wait` | 仅伴飞计时;**不发**模式切换 MAV 命令(除非实现为「保持当前模式下的阻塞」) | + +**不重样规定**:MAVLink messageId、发送频率、Offboard 心跳、EKF 就绪条件由 **companion + PX4 装机** 保证。 + +--- + +## 10. 伴飞桥(Bridge)设计要点 + +本节约定:**桥** = 运行在伴飞计算机上的进程(可与语音同源或独立),负责消费 `flight_intent`(或等价 JSON),**绝不**把原始 LLM 文本直接发给 PX4。 + +### 10.1 逻辑分层 + +1. **接入**:WebSocket 回调 → 解析 `flight_intent`;或订阅 ROS Topic `flight_intent/json`;或 TCP 接收与本文相同的 JSON。 +2. **校验**:至少 L1–L3;有 px4 上下文时做 L4。 +3. **执行器**:对 `actions` **单线程顺序**执行;内部每步调用 **翻译器**(见 §10.3)。 +4. **遥测与安全**:每步前置检查(模式、解锁、定位、电量、围栏);执行中 watchdog;可打断队列。 +5. **回执(建议)**:ROS 发布 `flight_intent/result` 或写日志/Socket:success / rejected / aborted + `trace_id` + 步号 + 原因码。 + +### 10.2 与语音客户端的关系(本仓库) + +- 语音侧可将 **`flight_intent` 映射** 为现有 `Command`(`command` + `params` + `sequence_id` + `timestamp`)经 **Socket** 发到桥;或由桥 **直接订阅云端结果**(二选一,避免双源)。 +- **`wait`**:若 Socket 协议暂无对应 `Command`,桥在本地对「已解析的 `actions` 列表」执行 `wait`,**不必**经 Socket 转发计时。 +- **扩展 `Command`**:若希望所有步骤可经 Socket 观测,可增加 `command: "noop"` + `params.duration` 仅作日志,但 **推荐** 桥本地处理 `wait`。 + +### 10.3 翻译器(`type` → 行为) + +实现为代码表 + 机型分支,示例: + +| `type` | 桥内典型步骤(抽象) | +|--------|----------------------| +| `takeoff` | 检查 arming 策略 → 发送起飞命令/切 TAKEOFF → 等待「达到 hover 可接受高度」或超时 | +| `land` | 切 LAND 或发 NAV_LAND → 监测直到 disarm 或超时 | +| `return_home` | 切 RTL | +| `hover`/`hold` | 切 AUTO.LOITER 或发位置保持 setpoint(Offboard 路径则发零速/当前位 setpoint) | +| `goto` | 按 `frame` 解算目标 → Offboard 轨迹或上传迷你 mission → 等待到达容差或超时 | +| `wait` | `sleep(seconds)` + 可中断环形检查遥测 | + +每步应定义 **超时** 与 **失败策略**(中止整段序列 / 仅跳过一步)。 + +### 10.4 安全与中断 + +- **急停 / 人机优先级**:本地硬件或 ROS `/emergency_hold` 等应能 **清空队列** 并进入安全模式。 +- **云断连**:不要求中断已在执行的序列(产品可配置「断连即 RTL」)。 +- **`wait` 期间**:持续判据;触发阈值则 **中止等待** 并执行安全动作。 + +--- + +## 11. ROS / MAVROS 实施参考 + +以下为方便对接 **ROS 2 + MAVROS**(或 `px4_ros_com`)的**参考映射**;实际包名、话题名、QoS 以你方 `mavros` 版本与 launch 为准。 + +### 11.1 常用接口类型 + +| 目的 | 常见 ROS 2 形态 | 说明 | +|------|------------------|------| +| 模式切换 | `mavros_msgs/srv/VehicleCmd` 或 SetMode 等价服务 | 切 `AUTO.TAKEOFF`, `AUTO.LAND`, `AUTO.LOITER`, `AUTO.RTL` 等 | +| 解锁/上锁 | `cmd/arming` 服务或 VehicleCommand | 桥策略决定是否自动 arm | +| Offboard 轨迹 | `trajectory_setpoint`、`offboard_control_mode`(PX4 官方 ROS 2 示例) | 用于 `goto` / `hover` 的 setpoint 路径 | +| 状态反馈 | `vehicle_status`、`local_position`、电池 topic | L4 与每步完成判定 | +| 长航指令 | `Mission`、`CMD` 接口 | 复杂航迹可选用 mission 上传 | + +### 11.2 JSON → ROS 责任划分建议 + +- **桥节点**订阅或接收 `flight_intent`,执行 §10.3,并调用 **MAVROS / px4_ros_com** 客户端。 +- **飞控仿真**:同一套 `flight_intent` 可在 SITL 上回放,便于 CI。 +- **单飞控单 writer**:同一时刻建议只有一个节点向 Offboard 端口写 setpoint,避免竞争。 + +### 11.3 与 PX4 模式的关系(概念) + +- **AUTO 模式族**(TAKEOFF / LAND / LOITER / RTL):适合 `takeoff`、`land`、`return_home`、部分 `hover`。 +- **Offboard**:适合连续 `goto`、精细悬停;桥需负责 **先切 Offboard 再发 setpoint**,并满足 PX4 Offboard 丢包监测。 +- 具体选 AUTO 还是 Offboard 由 **桥配置**(YAML)决定,**不写入** `flight_intent` JSON(保持云侧与机型解耦)。 + +--- + +## 12. 与当前仓库实现的对齐清单 + +| 项 | 建议 | +|----|------| +| Pydantic | `FlightIntentPayload` / `FlightIntentAction`:收紧 `type` Literal;`args` 按 §3 分类型或 discriminated union | +| 云端校验 | `_validate_flight_intent`:L2 白名单 + `goto.frame` + `wait.seconds` + `takeoff.relative_altitude_m` | +| LLM 提示词 | 仅允许 §3.7 中 `type` 与各 `args` 键;**必须**用 `wait` 表达明确停顿时长 | +| `main_app` | `land`/`hover` 已有 Socket 映射;`goto`/`return_home`/`takeoff`/`wait` 需在桥或 Socket 侧补全 | +| `Command` | 可扩展 `Literal` 与 `CommandParams`,或与桥约定「语音只发 Socket,复杂序列由桥执行」 | + +--- + +**文档版本**:2026-04-07(修订:增加 `wait`、`takeoff` 可选高度、`trace_id`、桥与 ROS 章节)。与 **`flight_intent.version === 1`** 对应。 diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md new file mode 100644 index 0000000..7aa68fc --- /dev/null +++ b/docs/PROJECT_GUIDE.md @@ -0,0 +1,148 @@ +# voice_drone_assistant — 项目说明与配置指南 + +面向部署与二次开发:**目录结构**、**配置文件用法**、**启动与日常操作**、**与云端/飞控的关系**。**外场统一部署与双终端启动顺序**见 **`docs/DEPLOYMENT_AND_OPERATIONS.md`**;协议细节以 `docs/llmcon.md` 为准。 + +--- + +## 1. 项目做什么 + +- **麦克风** → 预处理(降噪/AGC)→ **VAD 切段** → **SenseVoice STT** → **唤醒词** +- **关键词起飞(offboard 演示,默认关闭)**:`system.yaml` → **`assistant.local_keyword_takeoff_enabled`** 或 **`ROCKET_LOCAL_KEYWORD_TAKEOFF=1`** 开启后,`keywords.yaml` 里 **`takeoff` 词表**(如「起飞演示」)→ 提示音 + offboard 脚本;飞控主路径推荐 **云端 `flight_intent` + ROS 伴飞桥** +- **其它语音**:本地 **Qwen + Kokoro**,或 **云端 WebSocket**(LLM + TTS 上云,见 `cloud_voice`) +- 可选通过 **TCP Socket** 下发结构化飞控命令(`VoiceCommandRecognizer` 路径;`TakeoffPrintRecognizer` 默认不在启动时连 Socket,飞控多为云端 JSON + 可选 `ROCKET_CLOUD_EXECUTE_FLIGHT`) + +--- + +## 2. 目录结构(仓库根 = `voice_drone_assistant/`) + +| 路径 | 说明 | +|------|------| +| `main.py` | 程序入口(会 `chdir` 到本目录并跑 `voice_drone.main_app`) | +| `with_system_alsa.sh` | 在 Conda/残缺 ALSA 环境下修正 `LD_LIBRARY_PATH`,建议始终包一层启动 | +| `requirements.txt` | Python 依赖(含 `websocket-client` 等) | +| **`voice_drone/main_app.py`** | 主流程:唤醒、问候/快路径、关麦、LLM/云端、TTS、offboard | +| **`voice_drone/core/`** | 音频采集、预处理、VAD、STT、TTS、Socket、云端 WS、唤醒、命令、文本预处理、配置加载 | +| **`voice_drone/flight_bridge/`** | 伴飞桥(ROS1+MAVROS):`flight_intent` → 飞控;说明见 **`docs/FLIGHT_BRIDGE_ROS1.md`** | +| **`voice_drone/config/`** | 各类 YAML,见下文「配置文件」 | +| **`voice_drone/logging_/`** | 日志与彩色输出 | +| **`voice_drone/tools/`** | `config_loader` 等工具 | +| **`docs/`** | `PROJECT_GUIDE.md`(本文)、`llmcon.md`(云端协议)、`clientguide.md`(联调与示例) | +| **`scripts/`** | `run_px4_offboard_one_terminal.sh`;**伴飞桥** `run_flight_bridge_with_mavros.sh`(含 MAVROS)、`run_flight_intent_bridge_ros1.sh`(仅桥);另有 `generate_wake_greeting_wav.py`、`bundle_for_device.sh` | +| **`assets/tts_cache/`** | 唤醒问候等预生成 WAV(可自动生成) | +| **`models/`** | STT/TTS/VAD ONNX 等(需自备或 bundle,见 `models/README.txt`) | + +--- + +## 3. 配置文件一览 + +配置由 `voice_drone/core/configuration.py` 在进程启动时读入;主文件为 **`voice_drone/config/system.yaml`**(路径相对 **`voice_drone_assistant` 根目录**)。 + +| 文件 | 作用 | +|------|------| +| **`system.yaml`** | **总控**:`audio`(采样、设备、AGC、降噪)、`vad`、`stt`、`tts`、`cloud_voice`、`socket_server`、`text_preprocessor`、`recognizer`(VAD 能量门槛、尾静音、问候/TTS 关麦等) | +| **`wake_word.yaml`** | 唤醒词主词、变体、模糊/部分匹配策略 | +| **`keywords.yaml`** | 命令关键词与同义词(供文本预处理映射到 `Command`) | +| **`command_.yaml`** | 各飞行动作默认 `distance/speed/duration`(与 `Command` 联动) | +| **`cloud_voice_px4_context.yaml`** | 云端 **`session.start.client` 扩展**:`vehicle_class`、`mav_type`、`default_setpoint_frame`、`extras` 等,供服务端 LLM 生成 PX4 相关指令;路径在 `system.yaml` → `cloud_voice.px4_context_file`,也可用环境变量 **`ROCKET_CLOUD_PX4_CONTEXT_FILE`** 覆盖 | + +修改 YAML 后需**重启** `main.py` 生效(`SYSTEM_CLOUD_VOICE_PX4_CONTEXT` 等在 import 时加载一次)。 + +--- + +## 4. `system.yaml` 常用区块(索引) + +- **`audio`**:采样率、`frame_size`、`input_device_index`(`null` 则枚举设备)、`prefer_stereo_capture`(ES8388 等)、`noise_reduce`、`agc*`、`agc_release_alpha` +- **`vad`**:Silero 用阈值、`end_frame` 等(能量 VAD 时部分由 `recognizer` 覆盖) +- **`stt`**:SenseVoice 模型路径、ORT 线程等 +- **`tts`**:Kokoro 目录、音色 `voice`、`speed`、`output_device`、`playback_*` +- **`cloud_voice`**:`enabled`、`server_url`、`auth_token`、`device_id`、`timeout`、`fallback_to_local`、`px4_context_file` +- **`socket_server`**:试飞控 TCP 地址、`reconnect_interval`、`max_retries`(`-1` 为断线持续重连直至成功) +- **`recognizer`**:`trailing_silence_seconds`、`vad_backend`(`energy`/`silero`)、`energy_vad_*`、`energy_vad_utt_peak_decay`、`energy_vad_end_peak_ratio`、`pre_speech_max_seconds`、`ack_pause_mic_for_playback`、应答 TTS 等 + +更细的参数含义以各 YAML 内注释为准。 + +--- + +## 5. 系统使用方式 + +### 5.1 推荐启动命令 + +在 **`voice_drone_assistant` 根目录**: + +```bash +bash with_system_alsa.sh python main.py +``` + +或使用模块方式: + +```bash +bash with_system_alsa.sh python -m voice_drone.main_app +``` + +录音设备:**首次**可交互选择;非交互时可设 `ROCKET_INPUT_DEVICE_INDEX` 或使用 `main.py --input-index N` / `--non-interactive`(详见 `main_app` 内 `argparse` 与文件头注释)。 + +### 5.2 典型工作流(默认 `TakeoffPrintRecognizer`) + +1. 说唤醒词(如「无人机」);若**同句带指令**,会**跳过问候与滴声**,直接关麦处理:命中 `keywords.yaml` 的 **takeoff** 则 offboard,否则走 LLM/云端。 +2. 若**只唤醒**,则问候(或缓存 WAV)+ 可选滴声 → 再说**一句**指令。 +3. 云端模式:指令以文本上云,TTS 多为服务端 PCM;本地模式:Qwen 推理 + Kokoro 播报。 + +### 5.3 云端语音(可选) + +- `system.yaml` 里 `cloud_voice.enabled: true`,或环境变量 **`ROCKET_CLOUD_VOICE=1`** +- **`ROCKET_CLOUD_WS_URL`**、`ROCKET_CLOUD_AUTH_TOKEN`、可选 **`ROCKET_CLOUD_DEVICE_ID`**(可覆盖 yaml) +- PX4 语境:见 `cloud_voice_px4_context.yaml` / **`ROCKET_CLOUD_PX4_CONTEXT_FILE`** +- 协议与消息类型: **`docs/llmcon.md`** +- 飞控 JSON 是否机端执行: **`ROCKET_CLOUD_EXECUTE_FLIGHT=1`**;走 ROS 伴飞桥时再设 **`ROCKET_FLIGHT_INTENT_ROS_BRIDGE=1`**(详见 **`docs/DEPLOYMENT_AND_OPERATIONS.md`**) + +### 5.4 本地大模型与 TTS + +- GGUF:`cache/` 默认路径或 **`ROCKET_LLM_GGUF`** +- 关闭对话:**`ROCKET_LLM_DISABLE=1`** +- 流式输出:**`ROCKET_LLM_STREAM=0`** 可改为整段生成后再播(调试) +- 详细列表见 **`voice_drone/main_app.py` 文件头部注释**。 + +### 5.5 其它实用环境变量(摘录) + +| 变量 | 说明 | +|------|------| +| `ROCKET_ENERGY_VAD` | `1` 时使用能量 VAD(板载麦常见) | +| `ROCKET_PRINT_STT` / `ROCKET_PRINT_VAD` | 终端打印 STT/VAD 诊断 | +| `ROCKET_CLOUD_TURN_RETRIES` | 云端 WS 单轮失败重连重试次数(默认 3) | +| `ROCKET_PRINT_LLM_STREAM` | 云端流式字 `llm.text_delta` 打印到终端 | +| `ROCKET_WAKE_PROMPT_BEEP` | `0` 关闭问候后滴声 | +| `ROCKET_MIC_RESTART_SETTLE_MS` | 播完 TTS 恢复麦克风后的等待毫秒 | + +--- + +## 6. 相关文档与代码入口 + +| 文档 | 内容 | +|------|------| +| **`README.md`** | 简版说明、bundle 到香橙派、与原仓库关系 | +| **`docs/DEPLOYMENT_AND_OPERATIONS.md`** | **部署与外场启动**:拓扑、`ROS_MASTER_URI`、双终端启动顺序、环境变量速查、联调 | +| **`docs/PROJECT_GUIDE.md`** | 本文:目录、配置、使用方式总览 | +| **`docs/FLIGHT_BRIDGE_ROS1.md`** | ROS1 伴飞桥、MAVROS、`/input`、`rostopic pub` | +| **`docs/llmcon.md`** | 云端 WebSocket 消息类型与客户端约定 | +| **`docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md`** | 云端 **`dialog_result` v1**(`protocol=cloud_voice_dialog_v1`,闲聊/飞控分流 + `confirm`) | + +| 能力 | 主要代码 | +|------|-----------| +| 音频采集/AGC | `voice_drone/core/audio.py` | +| 能量/Silero VAD | `voice_drone/core/recognizer.py`、`voice_drone/core/vad.py` | +| STT | `voice_drone/core/stt.py` | +| 本地 LLM 提示词 | `voice_drone/core/qwen_intent_chat.py`(`FLIGHT_INTENT_CHAT_SYSTEM`) | +| 云端会话 | `voice_drone/core/cloud_voice_client.py` | +| 主流程 | `voice_drone/main_app.py` | +| 配置聚合 | `voice_drone/core/configuration.py` | + +--- + +## 7. 版本与维护 + +- 配置项会随功能迭代增加;若与运行日志或 `llmcon` 不一致,以**当前仓库 YAML + 代码**为准。 +- 新增仅与云端相关的字段时,请同时通知服务端解析 **`session.start.client`**(含 PX4 扩展块)。 + +--- + +*文档版本:与仓库同步维护;更新日期见 Git 提交。* diff --git a/main.py b/main.py new file mode 100644 index 0000000..0b9327f --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""入口:请在工程根目录 voice_drone_assistant 下运行。 + + bash with_system_alsa.sh python main.py + +或使用包方式:python -m voice_drone.main_app(需先 cd 到本目录)。""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +try: + os.chdir(ROOT) +except OSError: + pass + +from voice_drone.core.portaudio_env import fix_ld_path_for_portaudio + +fix_ld_path_for_portaudio() + +if __name__ == "__main__": + from voice_drone.main_app import main + + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..99b5925 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +# 语音助手最小依赖(从大工程 requirements 精简,若 import 报错再补包) +numpy>=1.21.0 +scipy>=1.10.0 +onnx>=1.14.0 +onnxruntime>=1.16.0 +librosa>=0.10.0 +soundfile>=0.12.0 +pyaudio>=0.2.14 +noisereduce>=2.0.0 +sounddevice>=0.4.6 +pyyaml>=5.4.0 +jieba>=0.42.1 +pypinyin>=0.50.0 +opencc-python-reimplemented>=0.1.7 +cn2an>=0.5.0 +misaki[zh]>=0.8.2 +pydantic>=2.4.0 +# 大模型(Qwen GGUF) +llama-cpp-python>=0.2.0 +# 云端语音 WebSocket(websocket-client) +websocket-client>=1.6.0 + +# ROS1:publish_flight_intent_ros_once / flight_bridge 需 rospy(std_msgs)。Noetic 一般 apt 安装,勿用 pip 覆盖: +# sudo apt install ros-noetic-ros-base # 或桌面版,须含 rospy +# 使用 conda/venv 时请在启动前 source /opt/ros/noetic/setup.bash,且保持 PYTHONPATH 含上述 dist-packages(主程序 ROS 桥子进程已把工程根 prepend 到 $PYTHONPATH)。 diff --git a/scripts/bundle_for_device.sh b/scripts/bundle_for_device.sh new file mode 100644 index 0000000..15c7c54 --- /dev/null +++ b/scripts/bundle_for_device.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# 在本机把「原仓库」里的 models(及可选 GGUF 缓存)拷进本子工程,便于整包 scp/rsync 到另一台香橙派。 +# +# 用法(在 voice_drone_assistant 根目录): +# bash scripts/bundle_for_device.sh +# bash scripts/bundle_for_device.sh /path/to/rocket_drone_audio +# +# 默认上一级目录为原仓库(即 voice_drone_assistant 仍放在 rocket_drone_audio 子目录时的布局)。 +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="${1:-$ROOT/..}" +M="$ROOT/models" + +if [[ ! -d "$SRC/src/models" ]]; then + echo "未找到 $SRC/src/models ,请传入正确的原仓库根路径:" >&2 + echo " bash scripts/bundle_for_device.sh /path/to/rocket_drone_audio" >&2 + exit 1 +fi + +mkdir -p "$M" +echo "源目录: $SRC" +echo "目标 models: $M" + +copy_dir() { + local name="$1" + if [[ -d "$SRC/src/models/$name" ]]; then + echo " 复制 models/$name ..." + rm -rf "$M/$name" + cp -a "$SRC/src/models/$name" "$M/" + else + echo " 跳过(不存在): src/models/$name" + fi +} + +copy_dir "SenseVoiceSmall" +copy_dir "Kokoro-82M-v1.1-zh-ONNX" +copy_dir "SileroVad" + +# 可选:大模型 GGUF(体积大,按需) +if [[ -d "$SRC/cache/qwen25-1.5b-gguf" ]]; then + read -r -p "是否复制 Qwen GGUF 到 cache/?(可能数百 MB~数 GB)[y/N] " ans + if [[ "${ans:-}" =~ ^[yY] ]]; then + mkdir -p "$ROOT/cache/qwen25-1.5b-gguf" + cp -a "$SRC/cache/qwen25-1.5b-gguf/"* "$ROOT/cache/qwen25-1.5b-gguf/" 2>/dev/null || true + echo " 已复制 cache/qwen25-1.5b-gguf" + fi +else + echo " 未找到 $SRC/cache/qwen25-1.5b-gguf ,大模型请在新机器上再下载或单独拷贝" +fi + +echo +echo "完成。可将整个目录打包拷贝到另一台设备:" +echo " $ROOT" +echo "新设备上请执行: pip install -r requirements.txt(或使用相同 conda 环境)" diff --git a/scripts/generate_wake_greeting_wav.py b/scripts/generate_wake_greeting_wav.py new file mode 100644 index 0000000..64bca7b --- /dev/null +++ b/scripts/generate_wake_greeting_wav.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""生成 assets/tts_cache/wake_greeting.wav。须与 voice_drone.main_app._WAKE_GREETING 一致。 + +用法(在 voice_drone_assistant 根目录): + python scripts/generate_wake_greeting_wav.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) +try: + os.chdir(_ROOT) +except OSError: + pass + +from voice_drone.core.portaudio_env import fix_ld_path_for_portaudio + +fix_ld_path_for_portaudio() + +_WAKE_TEXT = "你好,我在呢" + + +def main() -> None: + out_dir = _ROOT / "assets" / "tts_cache" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / "wake_greeting.wav" + + from voice_drone.core.tts import KokoroOnnxTTS + + tts = KokoroOnnxTTS() + tts.synthesize_to_file(_WAKE_TEXT, str(out_path)) + print(f"已写入: {out_path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_flight_bridge_with_mavros.sh b/scripts/run_flight_bridge_with_mavros.sh new file mode 100644 index 0000000..c20a1c6 --- /dev/null +++ b/scripts/run_flight_bridge_with_mavros.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# 一键:roscore(若还没有)→ MAVROS(串口连 PX4)→ flight_intent 伴飞桥(前台,直到 Ctrl+C) +# +# 适合「不想自己敲 roslaunch」时直接跑;用法与 run_px4_offboard_one_terminal.sh 前两参一致。 +# +# 在 voice_drone_assistant 根目录: +# bash scripts/run_flight_bridge_with_mavros.sh +# bash scripts/run_flight_bridge_with_mavros.sh /dev/ttyACM0 921600 +# +# 飞控连上后,另开终端发 JSON(std_msgs/String 必须用 YAML 字段 data,见 docs/FLIGHT_BRIDGE_ROS1.md): +# source /opt/ros/noetic/setup.bash +# rostopic pub -1 /input std_msgs/String \ +# "data: '{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"land\",\"args\":{}}],\"summary\":\"降\"}'" +# +# 环境变量(可选): +# ROS_MASTER_URI 默认 http://127.0.0.1:11311 +# ROS_HOSTNAME 默认 127.0.0.1 +# BRIDGE_PYTHON 若不设则 python3(与 mavros 同机即可,不必 conda) +# OFFBOARD_PYTHON 未设 BRIDGE_PYTHON 时也试 yanshi 环境(与 offboard 脚本习惯一致) +# +# Ctrl+C:结束伴飞桥,并停止本脚本拉起的 MAVROS;若 roscore 由本脚本启动则一并结束。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +if [[ ! -f /opt/ros/noetic/setup.bash ]]; then + echo "未找到 /opt/ros/noetic/setup.bash(伴飞桥当前仅支持 ROS1 Noetic)" >&2 + exit 2 +fi + +# shellcheck source=/dev/null +source /opt/ros/noetic/setup.bash +export ROS_MASTER_URI="${ROS_MASTER_URI:-http://127.0.0.1:11311}" +export ROS_HOSTNAME="${ROS_HOSTNAME:-127.0.0.1}" + +DEV="${1:-/dev/ttyACM0}" +BAUD="${2:-921600}" +FCU_URL="${DEV}:${BAUD}" + +ROSCORE_PID="" +MAVROS_PID="" +WE_STARTED_ROSCORE=0 + +master_ok() { + timeout 3 rosnode list &>/dev/null +} + +stop_mavros() { + pkill -f '/opt/ros/noetic/lib/mavros/mavros_node' 2>/dev/null || true +} + +kill_children() { + if [[ -n "${MAVROS_PID:-}" ]] && kill -0 "$MAVROS_PID" 2>/dev/null; then + kill "$MAVROS_PID" 2>/dev/null || true + wait "$MAVROS_PID" 2>/dev/null || true + fi + if [[ "${WE_STARTED_ROSCORE}" -eq 1 ]] && [[ -n "${ROSCORE_PID:-}" ]] && kill -0 "$ROSCORE_PID" 2>/dev/null; then + kill "$ROSCORE_PID" 2>/dev/null || true + wait "$ROSCORE_PID" 2>/dev/null || true + fi +} + +trap 'kill_children; exit 130' INT +trap 'kill_children; exit 143' TERM + +resolve_python() { + if [[ -n "${BRIDGE_PYTHON:-}" ]] && [[ -x "$BRIDGE_PYTHON" ]]; then + echo "$BRIDGE_PYTHON" + return + fi + if [[ -n "${OFFBOARD_PYTHON:-}" ]] && [[ -x "$OFFBOARD_PYTHON" ]]; then + echo "$OFFBOARD_PYTHON" + return + fi + for cand in \ + "${HOME}/miniconda3/envs/yanshi/bin/python" \ + "${HOME}/anaconda3/envs/yanshi/bin/python" \ + "${HOME}/mambaforge/envs/yanshi/bin/python"; do + if [[ -x "$cand" ]]; then + echo "$cand" + return + fi + done + command -v python3 +} + +echo "===== flight_intent 伴飞桥(含 MAVROS)fcu_url=${FCU_URL} =====" + +if ! master_ok; then + echo "[1/3] 启动 roscore …" + roscore > /tmp/roscore_flight_bridge.log 2>&1 & + ROSCORE_PID=$! + WE_STARTED_ROSCORE=1 + for _ in $(seq 1 50); do + master_ok && break + sleep 0.2 + done + if ! master_ok; then + echo "roscore 未起来: tail -40 /tmp/roscore_flight_bridge.log" >&2 + kill_children + exit 1 + fi + echo "roscore 已就绪 (pid=$ROSCORE_PID)" +else + echo "[1/3] 已有 ROS master,跳过 roscore" +fi + +echo "[2/3] 启动 MAVROS …" +stop_mavros +sleep 1 +roslaunch mavros px4.launch fcu_url:="$FCU_URL" > /tmp/mavros_flight_bridge.log 2>&1 & +MAVROS_PID=$! + +echo "等待飞控 connected=true(超时 60s)…" +connected=0 +for _ in $(seq 1 60); do + if timeout 4 rostopic echo /mavros/state -n 1 2>/dev/null | grep -qE 'connected: [Tt]rue'; then + connected=1 + break + fi + sleep 1 +done +if [[ "$connected" -ne 1 ]]; then + echo "仍未连上飞控。检查串口/波特率: tail -60 /tmp/mavros_flight_bridge.log" >&2 + echo "可重试: bash $0 ${DEV} 57600" >&2 + kill_children + exit 1 +fi +echo "MAVROS 已连接飞控" + +PY="$(resolve_python)" +export PYTHONPATH="${PYTHONPATH:-}:${ROOT}" + +echo "[3/3] 启动伴飞桥(Python: $PY)…" +echo " Ctrl+C 退出并清理本脚本启动的 MAVROS$([[ "$WE_STARTED_ROSCORE" -eq 1 ]] && echo +roscore)。" +echo " 发意图: 另开终端 source noetic 后 rostopic pub ... 见脚本头注释。" +set +e +"$PY" -m voice_drone.flight_bridge.ros1_node +BRIDGE_EXIT=$? +set -e + +kill_children +exit "$BRIDGE_EXIT" diff --git a/scripts/run_flight_intent_bridge_ros1.sh b/scripts/run_flight_intent_bridge_ros1.sh new file mode 100644 index 0000000..89d442f --- /dev/null +++ b/scripts/run_flight_intent_bridge_ros1.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 在已有 ROS master + MAVROS(已连飞控)的前提下启动 flight_intent 伴飞桥节点。 +# 若希望一键连 MAVROS:用同目录 run_flight_bridge_with_mavros.sh +# +# 用法(在 voice_drone_assistant 根目录): +# bash scripts/run_flight_intent_bridge_ros1.sh +# bash scripts/run_flight_intent_bridge_ros1.sh my_bridge # 节点名前缀(anonymous 仍会加后缀) +# +# 另开终端发意图(示例降落,默认订阅全局 /input): +# rostopic pub -1 /input std_msgs/String \ +# "{data: '{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"land\",\"args\":{}}],\"summary\":\"降\"}'}" +# +# 若改了 ~input_topic:rosnode info <节点名> 查看订阅话题 +# +# 环境变量(与 run_flight_bridge_with_mavros.sh 一致,未设置时给默认值): +# ROS_MASTER_URI 默认 http://127.0.0.1:11311 +# ROS_HOSTNAME 默认 127.0.0.1 +# 注意:这些只在「本脚本进程」里生效;另开终端调试 rostopic/rosservice 时须自行 source noetic 并 export 相同 URI,或与跑 roscore 的机器一致。 + +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f /opt/ros/noetic/setup.bash ]]; then + echo "未找到 /opt/ros/noetic/setup.bash(当前桥仅支持 ROS1 Noetic)" >&2 + exit 2 +fi + +# shellcheck source=/dev/null +source /opt/ros/noetic/setup.bash + +export ROS_MASTER_URI="${ROS_MASTER_URI:-http://127.0.0.1:11311}" +export ROS_HOSTNAME="${ROS_HOSTNAME:-127.0.0.1}" + +export PYTHONPATH="${PYTHONPATH}:${ROOT}" + +exec python3 -m voice_drone.flight_bridge.ros1_node "$@" diff --git a/scripts/run_px4_offboard_one_terminal.sh b/scripts/run_px4_offboard_one_terminal.sh new file mode 100644 index 0000000..611ef06 --- /dev/null +++ b/scripts/run_px4_offboard_one_terminal.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# 单终端一键:按需 roscore → MAVROS 串口(MAVLink) → px4_ctrl_offboard_demo.py +# +# 用法(在仓库根目录): +# bash scripts/run_px4_offboard_one_terminal.sh +# bash scripts/run_px4_offboard_one_terminal.sh /dev/ttyACM0 921600 +# bash scripts/run_px4_offboard_one_terminal.sh /dev/ttyACM0 921600 120 +# 第三参 DEMO_MAX_SEC:仅限制「px4_ctrl_offboard_demo.py」运行时长(秒),到时发 SIGTERM 结束 +# demo,随后脚本清理 MAVROS / 可选 roscore 并退出;0 或不写表示不限制。 +# 也可用环境变量 OFFBOARD_DEMO_MAX_SEC(第三参优先)。 +# +# 环境变量(可选): +# ROS_MASTER_URI 默认 http://127.0.0.1:11311 +# ROS_HOSTNAME 默认 127.0.0.1 +# OFFBOARD_PYTHON 默认优先用 conda env「yanshi」里的 python,否则 python3 +# OFFBOARD_DEMO_MAX_SEC 同第三参;整条脚本从头限时请用: timeout 600 bash 本脚本 ... +# ROCKET_OFFBOARD_DEMO_PY 显式指定 px4_ctrl_offboard_demo.py 路径(缺省时先 $ROOT/src/ 再 $ROOT/../src/) +# +# 退出:Ctrl+C 会结束 demo,并停止本脚本拉起的 MAVROS;若 roscore 由本脚本启动,会一并结束。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +resolve_demo_py() { + if [[ -n "${ROCKET_OFFBOARD_DEMO_PY:-}" ]] && [[ -f "${ROCKET_OFFBOARD_DEMO_PY}" ]]; then + echo "$(cd "$(dirname "${ROCKET_OFFBOARD_DEMO_PY}")" && pwd)/$(basename "${ROCKET_OFFBOARD_DEMO_PY}")" + return + fi + local c1="$ROOT/src/px4_ctrl_offboard_demo.py" + if [[ -f "$c1" ]]; then + echo "$c1" + return + fi + local c2="$ROOT/../src/px4_ctrl_offboard_demo.py" + if [[ -f "$c2" ]]; then + echo "$(cd "$(dirname "$c2")" && pwd)/px4_ctrl_offboard_demo.py" + return + fi + echo "错误: 找不到 px4_ctrl_offboard_demo.py。已尝试:" >&2 + echo " ROCKET_OFFBOARD_DEMO_PY=${ROCKET_OFFBOARD_DEMO_PY:-(未设置)}" >&2 + echo " $c1" >&2 + echo " $c2" >&2 + exit 3 +} + +DEMO_PY="$(resolve_demo_py)" + +source /opt/ros/noetic/setup.bash +export ROS_MASTER_URI="${ROS_MASTER_URI:-http://127.0.0.1:11311}" +export ROS_HOSTNAME="${ROS_HOSTNAME:-127.0.0.1}" + +DEV="${1:-/dev/ttyACM0}" +BAUD="${2:-921600}" +DEMO_MAX_SEC="${3:-${OFFBOARD_DEMO_MAX_SEC:-0}}" +FCU_URL="${DEV}:${BAUD}" + +if ! [[ "$DEMO_MAX_SEC" =~ ^[0-9]+$ ]]; then + echo "错误: 第三参(demo 最长运行秒数)须为非负整数,当前=${DEMO_MAX_SEC}" + exit 2 +fi + +ROSCORE_PID="" +MAVROS_PID="" +WE_STARTED_ROSCORE=0 + +master_ok() { + timeout 3 rosnode list &>/dev/null +} + +stop_mavros() { + pkill -f '/opt/ros/noetic/lib/mavros/mavros_node' 2>/dev/null || true +} + +kill_children() { + if [[ -n "${MAVROS_PID:-}" ]] && kill -0 "$MAVROS_PID" 2>/dev/null; then + kill "$MAVROS_PID" 2>/dev/null || true + wait "$MAVROS_PID" 2>/dev/null || true + fi + if [[ "${WE_STARTED_ROSCORE}" -eq 1 ]] && [[ -n "${ROSCORE_PID:-}" ]] && kill -0 "$ROSCORE_PID" 2>/dev/null; then + kill "$ROSCORE_PID" 2>/dev/null || true + wait "$ROSCORE_PID" 2>/dev/null || true + fi +} + +trap 'kill_children; exit 130' INT +trap 'kill_children; exit 143' TERM + +resolve_python() { + if [[ -n "${OFFBOARD_PYTHON:-}" ]] && [[ -x "$OFFBOARD_PYTHON" ]]; then + echo "$OFFBOARD_PYTHON" + return + fi + for cand in \ + "${HOME}/miniconda3/envs/yanshi/bin/python" \ + "${HOME}/anaconda3/envs/yanshi/bin/python" \ + "${HOME}/mambaforge/envs/yanshi/bin/python"; do + if [[ -x "$cand" ]]; then + echo "$cand" + return + fi + done + command -v python3 +} + +if ! master_ok; then + echo "[1/3] 启动 roscore ..." + roscore > /tmp/roscore_offboard_one_terminal.log 2>&1 & + ROSCORE_PID=$! + WE_STARTED_ROSCORE=1 + for _ in $(seq 1 50); do + master_ok && break + sleep 0.2 + done + if ! master_ok; then + echo "roscore 未起来,请查看: tail -40 /tmp/roscore_offboard_one_terminal.log" + kill_children + exit 1 + fi + echo "roscore 已就绪 (pid=$ROSCORE_PID)" +else + echo "[1/3] 已有 ROS master,跳过 roscore" +fi + +echo "[2/3] 启动 MAVROS fcu_url=${FCU_URL}" +stop_mavros +sleep 1 +roslaunch mavros px4.launch fcu_url:="$FCU_URL" > /tmp/mavros_offboard_one_terminal.log 2>&1 & +MAVROS_PID=$! + +echo "等待飞控连接 (connected=true),超时 60s ..." +connected=0 +for _ in $(seq 1 60); do + if timeout 4 rostopic echo /mavros/state -n 1 2>/dev/null | grep -qE 'connected: [Tt]rue'; then + connected=1 + break + fi + sleep 1 +done +if [[ "$connected" -ne 1 ]]; then + echo "仍未连上飞控。请检查串口与波特率,日志: tail -60 /tmp/mavros_offboard_one_terminal.log" + echo "可重试: bash $0 ${DEV} 57600" + kill_children + exit 1 +fi +echo "MAVROS 已连接飞控" + +PY="$(resolve_python)" +echo "[3/3] 使用 Python: $PY" +if [[ "$DEMO_MAX_SEC" -gt 0 ]]; then + echo "运行 $DEMO_PY,${DEMO_MAX_SEC}s 后自动结束 demo 并退出本脚本(随后清理子进程)" +else + echo "运行 $DEMO_PY (Ctrl+C 结束并将停止本脚本启动的 MAVROS)" +fi +set +e +if [[ "$DEMO_MAX_SEC" -gt 0 ]]; then + timeout --signal=TERM --kill-after=8 "$DEMO_MAX_SEC" \ + "$PY" "$DEMO_PY" + DEMO_EXIT=$? + if [[ "$DEMO_EXIT" -eq 124 ]]; then + echo "[timeout] 已到 ${DEMO_MAX_SEC}s,已终止 px4_ctrl_offboard_demo.py(退出码 124)" + fi +else + "$PY" "$DEMO_PY" + DEMO_EXIT=$? +fi +set -e + +kill_children +exit "$DEMO_EXIT" diff --git a/tests/test_cloud_dialog_v1.py b/tests/test_cloud_dialog_v1.py new file mode 100644 index 0000000..2fa0198 --- /dev/null +++ b/tests/test_cloud_dialog_v1.py @@ -0,0 +1,44 @@ +"""cloud_voice_dialog_v1 短语与 confirm 解析。""" +from voice_drone.core.cloud_dialog_v1 import ( + match_phrase_list, + normalize_phrase_text, + parse_confirm_dict, +) + + +def test_normalize(): + assert normalize_phrase_text(" a b ") == "a b" + + +def test_match_phrases(): + assert match_phrase_list(normalize_phrase_text("确认"), ["确认"]) + assert match_phrase_list(normalize_phrase_text("取消"), ["取消"]) + assert match_phrase_list(normalize_phrase_text("好的确认"), ["确认"]) + # 复述长提示:不应仅因子串「取消」命中 + long_prompt = normalize_phrase_text("请回复确认或取消") + assert match_phrase_list(long_prompt, ["取消"]) is False + assert match_phrase_list(long_prompt, ["确认"]) is False + + +def test_parse_confirm_ok(): + d = parse_confirm_dict( + { + "required": True, + "timeout_sec": 10, + "confirm_phrases": ["确认"], + "cancel_phrases": ["取消"], + "pending_id": "p1", + } + ) + assert d is not None + assert d["required"] is True + assert d["timeout_sec"] == 10.0 + assert "确认" in d["confirm_phrases"] + + +def test_parse_confirm_reject_bad_required(): + assert parse_confirm_dict({"required": "yes"}) is None + + +def test_cancel_priority_concept(): + assert match_phrase_list(normalize_phrase_text("取消"), ["取消"]) diff --git a/tests/test_flight_intent.py b/tests/test_flight_intent.py new file mode 100644 index 0000000..dfc9b7d --- /dev/null +++ b/tests/test_flight_intent.py @@ -0,0 +1,160 @@ +"""flight_intent v1 校验与 goto→Command 映射。""" + +from __future__ import annotations + +import pytest + +from voice_drone.core.flight_intent import ( + goto_action_to_command, + parse_flight_intent_dict, +) + + +def _command_stack_available() -> bool: + try: + import yaml # noqa: F401 + + from voice_drone.core.command import Command # noqa: F401 + + return True + except Exception: + return False + + +needs_command_stack = pytest.mark.skipif( + not _command_stack_available(), + reason="需要 pyyaml 与工程配置以加载 Command", +) + + +def test_parse_minimal_ok(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [{"type": "land", "args": {}}], + "summary": "降落", + } + ) + assert err == [] + assert v is not None + assert v.actions[0].type == "land" + + +def test_hover_duration_cloud_legacy_expands_to_wait(): + """云端误将停顿时长写在 hover.args.duration 时,客户端规范化为 hover + wait。""" + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [ + {"type": "takeoff", "args": {}}, + {"type": "hover", "args": {"duration": 3}}, + {"type": "land", "args": {}}, + ], + "summary": "test", + } + ) + assert err == [] + assert v is not None + assert len(v.actions) == 4 + assert v.actions[0].type == "takeoff" + assert v.actions[1].type == "hover" + assert v.actions[2].type == "wait" + assert float(v.actions[2].args.seconds) == 3.0 + assert v.actions[3].type == "land" + + +def test_wait_after_hover_ok(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [ + {"type": "takeoff", "args": {}}, + {"type": "hover", "args": {}}, + {"type": "wait", "args": {"seconds": 2.5}}, + {"type": "land", "args": {}}, + ], + "summary": "test", + } + ) + assert err == [] + assert v is not None + assert len(v.actions) == 4 + assert v.actions[2].type == "wait" + assert v.trace_id is None + + +def test_first_wait_rejected(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [ + {"type": "wait", "args": {"seconds": 1}}, + {"type": "land", "args": {}}, + ], + "summary": "x", + } + ) + assert v is None + assert err + + +def test_extra_top_key_rejected(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [{"type": "land", "args": {}}], + "summary": "x", + "foo": 1, + } + ) + assert v is None + assert err + + +@needs_command_stack +def test_goto_body_forward(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [ + { + "type": "goto", + "args": {"frame": "body_ned", "x": 3}, + } + ], + "summary": "s", + } + ) + assert v is not None and not err + cmd, reason = goto_action_to_command(v.actions[0], sequence_id=7) + assert reason is None + assert cmd is not None + assert cmd.command == "forward" + assert cmd.sequence_id == 7 + + +@needs_command_stack +def test_goto_multi_axis_no_command(): + v, err = parse_flight_intent_dict( + { + "is_flight_intent": True, + "version": 1, + "actions": [ + { + "type": "goto", + "args": {"frame": "body_ned", "x": 1, "y": 1}, + } + ], + "summary": "s", + } + ) + assert v is not None and not err + cmd, reason = goto_action_to_command(v.actions[0], 1) + assert cmd is None + assert reason and "multi-axis" in reason diff --git a/voice_drone/__init__.py b/voice_drone/__init__.py new file mode 100644 index 0000000..a1e3446 --- /dev/null +++ b/voice_drone/__init__.py @@ -0,0 +1,3 @@ +"""语音无人机助手:采集 → VAD → STT → 唤醒 → LLM/起飞 → Kokoro 播报。""" + +__version__ = "0.1.0" diff --git a/voice_drone/config/cloud_voice_px4_context.yaml b/voice_drone/config/cloud_voice_px4_context.yaml new file mode 100644 index 0000000..bd56c4a --- /dev/null +++ b/voice_drone/config/cloud_voice_px4_context.yaml @@ -0,0 +1,25 @@ +# 云端 session.start → client 扩展字段(PX4 / MAVLink 语境) +# 与服务端约定:原样进入 LLM;vehicle_class 与 mav_type 应一致,冲突时以 mav_type 为准。 +# +# 修改后重启 main.py;可用环境变量 ROCKET_CLOUD_PX4_CONTEXT_FILE 覆盖本文件路径。 + +# 机体类别(自由文本,与 mav_type 语义对齐即可,例如 multicopter / fixed_wing) +vehicle_class: multicopter + +# MAV_TYPE 枚举整数值,参见 https://mavlink.io/en/messages/common.html#MAV_TYPE +mav_type: 2 + +# 口语「前/右/上」未说明坐标系时,云端生成 goto 的默认系: +# local_ned — 北东地(与常见 Offboard 位置设定一致) +# body_ned / body — 机体系前右下(仅当机端按体轴解析相对位移时填写) +default_setpoint_frame: local_ned + +# 以下为可选运行时状态(可由 MAVROS / ROS2 写入同文件,或由机载进程覆写后再连云端) +# home_position_valid: true +# offboard_capable: true +# current_nav_state: "OFFBOARD" + +# 任意短键名 JSON,进入 LLM;适合「电池低」「室内无 GPS」「仅限 mission」等 +extras: + platform: companion_orangepi + # indoor_no_gps: true diff --git a/voice_drone/config/command_.yaml b/voice_drone/config/command_.yaml new file mode 100644 index 0000000..fb2e841 --- /dev/null +++ b/voice_drone/config/command_.yaml @@ -0,0 +1,59 @@ +# 命令配置文件 用于命令生成和填充默认值 +control_params: + # 起飞默认参数 + takeoff: + distance: 0.5 + speed: 0.5 + duration: 1 + # 降落默认参数 + land: + distance: 0 + speed: 0 + duration: 0 + # 跟随默认参数 + follow: + distance: 0.5 + speed: 0.5 + duration: 2 + # 向前默认参数 + forward: + distance: 0.5 + speed: 0.5 + duration: 1 + # 向后默认参数 + backward: + distance: 0.5 + speed: 0.5 + duration: 1 + # 向左默认参数 + left: + distance: 0.5 + speed: 0.5 + duration: 1 + # 向右默认参数 + right: + distance: 0.5 + speed: 0.5 + duration: 1 + # 向上默认参数 + up: + distance: 0.5 + speed: 0.5 + duration: 1 + # 向下默认参数 + down: + distance: 0.5 + speed: 0.5 + duration: 1 + # 悬停默认参数 + hover: + distance: 0 + speed: 0 + duration: 5 + # 返航(Socket 协议与 land/hover 同类占位参数) + return_home: + distance: 0 + speed: 0 + duration: 0 + + diff --git a/voice_drone/config/keywords.yaml b/voice_drone/config/keywords.yaml new file mode 100644 index 0000000..7e0b61d --- /dev/null +++ b/voice_drone/config/keywords.yaml @@ -0,0 +1,71 @@ +keywords: + # takeoff 仅用于「一键 offboard 演示」唤醒路径;用「起飞演示」避免句子里单独出现「起飞」误触(如「起飞,悬停再降落」) + takeoff: + - "起飞演示" + - "演示起飞" + + land: + - "立刻降落" + - "紧急降落" + - "降落" + - "落地" + - "着陆" + + follow: + - "跟随" + - "跟着我" + - "跟我飞" + - "跟随模式" + + hover: + - "马上停下" + - "立刻停下" + - "悬停" + - "停下" + - "停止" + - "停" + + forward: + - "向前飞" + - "往前飞" + - "向前" + - "往前" + - "前面飞" + - "前进" + + backward: + - "向后飞" + - "往后飞" + - "向后" + - "往后" + - "后退" + + left: + - "向左飞" + - "往左飞" + - "向左" + - "往左" + - "左移" + + right: + - "向右飞" + - "往右飞" + - "向右" + - "往右" + - "右移" + + up: + - "向上飞" + - "往上飞" + - "向上" + - "往上" + - "上升" + - "升高" + + down: + - "向下飞" + - "往下飞" + - "向下" + - "往下" + - "下降" + - "降低" \ No newline at end of file diff --git a/voice_drone/config/system.yaml b/voice_drone/config/system.yaml new file mode 100644 index 0000000..a72719f --- /dev/null +++ b/voice_drone/config/system.yaml @@ -0,0 +1,246 @@ +# ***********音频采集配置 ************* +audio: + sample_rate: 16000 + channels: 1 + sample_width: 2 + frame_size: 1024 + audio_format: "wav" + # 麦克风:仅使用 input_device_index(PyAudio 整数)。null = 自动尝试默认输入 + 所有 in>0 设备。 + # 运行 python src/rocket_drone_audio.py 会默认打印 arecord -l、PyAudio 列表与 hw 映射并交互选择。 + # 自动化可加 --non-interactive 并在此写入整数索引;或传 --input-index / ROCKET_INPUT_DEVICE_INDEX。 + input_device_index: null + # 以下字段已不再参与选麦逻辑(可删或保留作备忘) + input_strict_selection: false + input_hw_card_device: null + input_device_name_match: null + # 设备报告双声道、但 channels 为 1 时,用立体声打开再下混为 mono(Orange Pi ES8388 等需开启) + prefer_stereo_capture: true + # ES8388 常需 48k 才能打开;打开后会在采集里重采样回 sample_rate + audio_open_try_rates: [16000, 48000, 44100] + # conda 下 PyAudio 常链到残缺 ALSA 插件:请在 shell 用 bash with_system_alsa.sh python … 启动 + # ES8388 大声说话仍只有 RMS≈30:多为 ALSA「Left/Right Channel」采集=0%,先执行 scripts/es8388_capture_up.sh 再 sudo alsactl store + + # 高性能优化配置 + high_performance_mode: true # 启用高性能模式(多线程+异步处理) + use_callback_mode: true # 使用回调模式(非阻塞) + buffer_queue_size: 10 # 音频缓冲队列大小 + processing_threads: 2 # 处理线程数(采集和处理并行) + batch_processing: true # 启用批处理优化 + + # 降噪配置 + noise_reduce: true + noise_reduction_method: "lightweight" # "lightweight" (轻量级) 或 "noisereduce" (完整版) + noise_sample_duration_ms: 500 # 噪声样本收集时长(毫秒) + noise_reduction_cutoff_hz: 80.0 # 轻量级降噪高通滤波截止频率(Hz) + + # 自动增益控制配置 + agc: true + agc_method: "incremental" # "incremental" (增量) 或 "standard" (标准) + agc_target_db: -20.0 # AGC 目标音量(dB) + # 过小(如 0.1)时在短时强噪声后易把波形压到 int16 近 0,能量 VAD 长期收不到音;建议 0.25~0.5 + agc_gain_min: 0.25 # AGC 最小增益倍数 + agc_gain_max: 10.0 # AGC 最大增益倍数 + agc_rms_threshold: 1e-6 # AGC RMS 阈值(避免除零) + agc_smoothing_alpha: 0.1 # 压低增益时的平滑系数(0-1,越小越慢) + agc_release_alpha: 0.45 # 需要抬增益时(巨响/小声后恢复)用更大系数,由 audio.py 读取 + +# ***********语音活动检测配置 ************* +vad: + # 略降低门槛,避免板载麦/经 AGC 后仍达不到 0.65 + threshold: 0.45 + start_frame: 2 + # 句尾连续静音块数(每块时长≈audio.frame_size/sample_rate);recognizer.trailing_silence_seconds 优先覆盖此项与 energy_vad_end_chunks + end_frame: 10 + min_silence_duration_s: 0.5 + max_silence_duration_s: 30 + model_path: "models/SileroVad/silero_vad.onnx" + +# ***********语音识别配置 ************* +stt: + # 模型路径配置 + model_dir: "models/SenseVoiceSmall" # 模型目录 + model_path: "models/SenseVoiceSmall/model.int8.onnx" # 直接指定模型路径(优先级高于 model_dir) + prefer_int8: true # 是否优先使用 INT8 量化模型 + warmup_file: "" # 预热音频文件 + + # 音频预处理配置 + sample_rate: 16000 # 采样率(与 audio 配置保持一致) + n_mels: 80 # Mel 滤波器数量 + frame_length_ms: 25 # 帧长度(毫秒) + frame_shift_ms: 10 # 帧移(毫秒) + log_eps: 1e-10 # log 计算时的极小值(避免 log(0)) + + # ARM 设备优化配置 + arm_optimization: + enabled: true # 是否启用 ARM 优化 + max_threads: 4 # 最大线程数(RK3588 使用 4 个大核) + + # CTC 解码配置 + ctc_decode: + blank_id: 0 # 空白 token ID + + # 语言和文本规范化配置(默认值,实际从模型元数据读取) + language: + zh_id: 3 # 中文语言ID(默认值) + text_norm: + with_itn_id: 14 # 使用 ITN 的 ID(默认值) + without_itn_id: 15 # 不使用 ITN 的 ID(默认值) + + # 后处理配置 + postprocess: + special_tokens: # 需要移除的特殊 token + - "<|zh|>" + - "<|NEUTRAL|>" + - "<|Speech|>" + - "<|woitn|>" + - "<|withitn|>" + +# ***********文本转语音配置 ************* +tts: + # Kokoro ONNX 中文模型目录 + model_dir: "models/Kokoro-82M-v1.1-zh-ONNX" + + # ONNX 子目录中的模型文件名 + # 清晰度优先: model_fp16.onnx / model.onnx > model_q4f16.onnx > int8/uint8 + # 速度(板端 CPU):可试 model_q4f16.onnx;uint8 有时更慢(取决于 ORT/CPU) + # 若仓库中仅有量化版,可改回 model_uint8.onnx 或 model_q4f16.onnx + model_name: "model_uint8.onnx" + + # 语音风格(对应 voices 目录下的 *.bin, 这里不写扩展名) + # 女声常用 zf_001 / zf_002;可换 zm_* 男声。以本机 voices 目录实际文件为准。 + voice: "zm_009" + + # 语速系数(1.0 最自然; >1 易显赶、含糊; <1 更稳但略慢) + speed: 1.15 + # 输出采样率(与 Kokoro 模型保持一致, 官方为 24000Hz) + sample_rate: 24000 + + # sounddevice 播放输出设备(命令应答语音走扬声器时请指定,避免播到虚拟声卡/耳机) + # null:使用系统默认输出设备 + # 整数:设备索引(启动日志会列出「sounddevice 输出设备列表」供对照) + # 字符串:设备名称子串匹配(不区分大小写),例如 "扬声器"、"Speakers"、"Realtek" + # 香橙派走 HDMI 出声时 PortAudio 名常含 rockchip-hdmi0,可设 output_device: "rockchip-hdmi0"(与 ROCKET_TTS_DEVICE 一致) + output_device: null + + # 播放前重采样到该输出设备的 default_samplerate(Windows/WASAPI 下 24000Hz 常无声,强烈建议 true) + playback_resample_to_device_native: true + + # 播放前将峰值压到约 0.92,减轻削波导致的爆音/杂音 + playback_peak_normalize: true + # 播放音量增益(波形幅度乘法,1.0 不变;1.3~1.8 更响,过大可能削波失真) + playback_gain: 2.5 + # 首尾淡入淡出(毫秒),减轻驱动/缓冲区切换时的爆音与「咔哒」声 + playback_edge_fade_ms: 8 + # sounddevice OutputStream 延迟: low / medium / high(high 易积缓冲,部分机器听感发闷或拖尾) + playback_output_latency: low + +# ***********云端语音(LLM + TTS 上云,见 clientguide.md)************* +cloud_voice: + enabled: false + server_url: "ws://192.168.0.186:8766/v1/voice/session" + auth_token: "drone-voice-cloud-token-2024" + device_id: "drone-001" + timeout: 120 + # PROMPT_LISTEN:麦克 RMS 持续低于 recognizer.energy_vad_rms_low 的累计秒数 ≥ 此值则超时(非滴声后固定墙上时钟);消抖/提示音见下 + listen_silence_timeout_sec: 5 + post_cue_mic_mute_ms: 200 + segment_cue_duration_ms: 120 + # 问候语 / 本地 LLM 文案 / 飞控确认超时等字符串是否走 WebSocket tts.synthesize(见 docs/API.md §3.3);失败回退 Kokoro + remote_tts_for_local: true + # 云端失败时是否回退本地 Qwen + Kokoro(需本地模型) + fallback_to_local: true + # PX4/MAV 语境:合并进 WebSocket session.start 的 client,供服务端 LLM;也可用 ROCKET_CLOUD_PX4_CONTEXT_FILE 覆盖路径 + px4_context_file: "voice_drone/config/cloud_voice_px4_context.yaml" + +# ***********socket服务器配置 ************* +socket_server: + # deployed + host: "192.168.43.200" + port: 6666 + + #local + # host: "127.0.0.1" + # port: 8888 + connection_timeout: 5.0 + send_timeout: 2.0 + reconnect_interval: 3.0 + # -1:断线后持续重连并发送直到成功(仅打 warning,不当作一次性致命错误);正整数:最多尝试次数 + max_retries: -1 + +# ***********文本预处理配置 ************* +text_preprocessor: + # 功能开关 + enable_traditional_to_simplified: true # 启用繁简转换 + enable_segmentation: true # 启用分词 + enable_correction: true # 启用纠错 + enable_number_extraction: true # 启用数字提取 + enable_keyword_detection: true # 启用关键词检测 + + # 性能配置 + lru_cache_size: 512 # LRU缓存大小(分词结果缓存) + +# ***********识别器流程配置 ************* +recognizer: + # 句尾连续静音达到该秒数后才切段送 STT,减少句中停顿被切开、识别半句。按 audio.frame_size 与 sample_rate 换算块数, + # 并同时设置 Silero 的 vad.end_frame 与 energy 的 energy_vad_end_chunks。不配置则分别用 yaml 中上述两项。 + trailing_silence_seconds: 1.5 + # VAD 后端:energy(默认,无需 Silero ONNX)或 silero(需 models/SileroVad/silero_vad.onnx) + vad_backend: energy + energy_vad_rms_high: 8000 # int16 块 RMS,连续达到 start_chunks 块判为开始说话 + energy_vad_rms_low: 5000 # 连续 end_chunks 块低于此判为结束(高噪底时单独不够) + # 相对峰值判停:首字很响时若仍用 0.88,句中略轻易误判「已说完」。可改为 0.75~0.82;设 0 则关闭相对判据(只靠 rms_low + 尾静音) + energy_vad_end_peak_ratio: 0.80 + # 每音频块后对句内峰值衰减系数(0.95~0.999),与 end_peak_ratio 配合减少「长句说到一半被切」 + energy_vad_utt_peak_decay: 0.988 + energy_vad_start_chunks: 4 + energy_vad_end_chunks: 15 + # 预缓冲:在检测到语音开始时,把开始前的一小段音频也拼进语音段 + # 用于避免 VAD 起始判定稍慢导致“丢字/丢开头” + pre_speech_max_seconds: 0.8 + # Socket 命令发送成功后,是否用 TTS 语音回复(需 Kokoro 模型与 sounddevice) + ack_tts_enabled: true + # 若配置了 ack_tts_phrases 且非空:仅下列命令会播报,且每次从对应列表随机选一句。 + # 若未配置或为空 {}:回退为全局 ack_tts_text,所有成功命令均播报同一句(并可预缓存波形)。 + # 阻塞预加载时会对所有「不重复」的备选句逐条合成并缓存;句数越多启动阶段越久,但播报为低延迟播放缓存。 + ack_tts_phrases: + takeoff: + - "收到,正在控制无人机起飞" + - "明白,正在准备起飞" + - "懂你意思, 这就开始起飞" + land: + - "收到,正在控制无人机降落" + - "明白,马上降落" + - "懂你意思, 这就开始降落" + follow: + - "好的,我将持续跟随你,但请你不要移动太快" + - "主人,我已经开始跟随模式,请不要突然离开我的视线" + - "我已经开启了跟随模式" + hover: + - "我已悬停" + - "我已经停下脚步了" + - "放心,我现在开始不会乱动了" + # 未使用 ack_tts_phrases 时的全局固定应答(旧行为) + ack_tts_text: "收到!执行命令!" + # 应答波形磁盘缓存:文案与 tts 配置未变时从 cache/ack_tts_pcm/ 读取,跳过后续启动时的逐条合成(可明显加快二次启动) + ack_tts_disk_cache: true + # 启动时预加载 Kokoro(首次加载约需十余秒) + ack_tts_prewarm: true + # true:启动阶段阻塞到 TTS 就绪后再进入监听(命令成功后可马上播报);false:后台加载(首条命令可能仍要等十余秒) + ack_tts_prewarm_blocking: true + # 播报应答前暂时停止 PyAudio 麦克风(Windows 上输入/输出同时占用时常导致扬声器无声;单独跑 tts 脚本时无此问题) + # 香橙派 ES8388:暂停/恢复采集后出现「VAD RMS≈0、像没电平」时,可改为 false(避免 stop/start 采集),或加大 ROCKET_MIC_RESTART_SETTLE_MS。 + ack_pause_mic_for_playback: true + +# ***********主程序 main_app(TakeoffPrintRecognizer)************* +assistant: + # 「keywords.yaml 里 takeoff 词 → 本地 offboard + WAV」捷径;默认关,飞控走云端 flight_intent / ROS 桥即可。 + # 若需恢复口令起飞:此处改为 true,或启动前 export ROCKET_LOCAL_KEYWORD_TAKEOFF=1(非空环境变量优先于本项)。 + local_keyword_takeoff_enabled: false + +# ***********日志配置 ************* +logging: + level: "INFO" + debug: false + + + diff --git a/voice_drone/config/wake_word.yaml b/voice_drone/config/wake_word.yaml new file mode 100644 index 0000000..5fff531 --- /dev/null +++ b/voice_drone/config/wake_word.yaml @@ -0,0 +1,71 @@ +# 唤醒词配置 +wake_word: + # 主唤醒词(标准形式) + primary: "无人机" + + # 唤醒词变体映射(支持同音字、拼音、错别字等) + variants: + # 标准形式 + - "无人机" + - "无 人 机" # 带空格 + - "无人机," + - "无人机 。" + - "无人机。" + - "喂 无人机" + - "喂,无人机" + - "嗨 无人机" + - "嘿 无人机" + - "哈喽 无人机" + + # 常见误识别 / 近音 + - "五人机" + - "吾人机" + - "无人鸡" + - "无认机" + - "无任机" + + # 拼音变体(小写) + - "wu ren ji" + - "wurenj" + - "wu renji" + - "wuren ji" + - "wu ren, ji" + - "wu ren ji." + - "hey wu ren ji" + - "hi wu ren ji" + - "hello wu ren ji" + - "ok wu ren ji" + + # 拼音变体(大小写混合) + - "Wu Ren Ji" + - "WU REN JI" + - "Wu ren ji" + - "Hey Wu Ren Ji" + - "Hi Wu Ren Ji" + + # 短说 / 部分匹配(易与日常用语冲突时可删去「无人」单独一项) + - "无人" + # 勿单独使用「人机」:易在「牛人机学庭」等误识别里子串命中,导致假唤醒 + - "hey 无人" + - "嗨 无人" + - "喂 无人" + + # 匹配模式配置 + matching: + # 是否启用模糊匹配(同音字、拼音) + enable_fuzzy: true + + # 是否启用部分匹配(代码侧:主词长度足够时取前半段;短词依赖上面 variants) + enable_partial: true + + # 是否忽略大小写 + ignore_case: true + + # 是否忽略空格 + ignore_spaces: true + + # 最小匹配长度(字符数,用于部分匹配) + min_match_length: 2 + + # 相似度阈值(0-1,用于模糊匹配) + similarity_threshold: 0.7 diff --git a/voice_drone/core/audio.py b/voice_drone/core/audio.py new file mode 100644 index 0000000..3f6d3ba --- /dev/null +++ b/voice_drone/core/audio.py @@ -0,0 +1,714 @@ +""" +音频采集模块 - 优化版本 + +输入设备(麦克风)选择(已简化): +- 若 system.yaml 中 audio.input_device_index 为整数:只尝试该 PyAudio 索引(无则启动失败并列设备)。 +- 若为 null:依次尝试系统默认输入、所有 maxInputChannels>0 的设备。 + rocket_drone_audio 启动时可交互选择并写入 input_device_index(见 src.core.mic_device_select)。 +""" +from voice_drone.core.portaudio_env import fix_ld_path_for_portaudio + +fix_ld_path_for_portaudio() + +import re +import pyaudio +import numpy as np +import queue +import threading +from typing import List, Optional, Tuple +from voice_drone.core.configuration import SYSTEM_AUDIO_CONFIG +from voice_drone.logging_ import get_logger + +logger = get_logger("audio.capture.optimized") + + +class AudioCaptureOptimized: + """ + 优化版音频采集器 + + 使用回调模式 + 队列,实现非阻塞音频采集 + """ + + def __init__(self): + """ + 初始化音频采集器 + """ + # 确保数值类型正确(从 YAML 读取可能是字符串) + self.sample_rate = int(SYSTEM_AUDIO_CONFIG.get("sample_rate", 16000)) + self.channels = int(SYSTEM_AUDIO_CONFIG.get("channels", 1)) + self.chunk_size = int(SYSTEM_AUDIO_CONFIG.get("frame_size", 1024)) + self.sample_width = int(SYSTEM_AUDIO_CONFIG.get("sample_width", 2)) + + # 高性能模式配置 + self.buffer_queue_size = int(SYSTEM_AUDIO_CONFIG.get("buffer_queue_size", 10)) + self._prefer_stereo_capture = bool( + SYSTEM_AUDIO_CONFIG.get("prefer_stereo_capture", True) + ) + raw_idx = SYSTEM_AUDIO_CONFIG.get("input_device_index", None) + self._input_device_index_cfg: Optional[int] = ( + int(raw_idx) if raw_idx is not None and str(raw_idx).strip() != "" else None + ) + + tr = SYSTEM_AUDIO_CONFIG.get("audio_open_try_rates") + if tr: + raw_rates: List[int] = [int(x) for x in tr if x is not None] + else: + raw_rates = [self.sample_rate, 48000, 44100, 32000] + seen_r: set[int] = set() + self._open_try_rates: List[int] = [] + for r in raw_rates: + if r not in seen_r: + seen_r.add(r) + self._open_try_rates.append(r) + + # 逻辑通道(送给 VAD/STT 的 mono);_pa_channels 为 PortAudio 实际打开的通道数 + self._pa_channels = self.channels + self._stereo_downmix = False + self._pa_open_sample_rate: int = self.sample_rate + + self.audio = pyaudio.PyAudio() + self.format = self.audio.get_format_from_width(self.sample_width) + + # 使用队列缓冲音频数据(非阻塞) + self.audio_queue = queue.Queue(maxsize=self.buffer_queue_size) + self.stream: Optional[pyaudio.Stream] = None + + logger.info( + f"优化版音频采集器初始化成功: " + f"采样率={self.sample_rate}Hz, " + f"块大小={self.chunk_size}, " + f"使用回调模式+队列缓冲" + ) + + def _device_hw_tuple_in_name(self, dev_name: str) -> Optional[Tuple[int, int]]: + m = re.search(r"\(hw:(\d+),\s*(\d+)\)", dev_name) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + + def _ordered_input_candidates(self) -> Tuple[List[int], List[int]]: + preferred: List[int] = [] + seen: set[int] = set() + + def add(idx: Optional[int]) -> None: + if idx is None: + return + ii = int(idx) + if ii in seen: + return + seen.add(ii) + preferred.append(ii) + + # 配置了整数索引:只打开该设备(与交互选择 / CLI 写入一致) + if self._input_device_index_cfg is not None: + add(self._input_device_index_cfg) + return preferred, [] + + try: + add(int(self.audio.get_default_input_device_info()["index"])) + except Exception: + pass + for i in range(self.audio.get_device_count()): + try: + inf = self.audio.get_device_info_by_index(i) + if int(inf.get("maxInputChannels", 0)) > 0: + add(i) + except Exception: + continue + + fallback: List[int] = [] + for i in range(self.audio.get_device_count()): + if i in seen: + continue + try: + self.audio.get_device_info_by_index(i) + except Exception: + continue + fallback.append(i) + return preferred, fallback + + def _channel_plan(self, max_in: int, dev_name: str) -> List[Tuple[int, bool]]: + ch = self.channels + pref = self._prefer_stereo_capture + if ch != 1: + return [(ch, False)] + if max_in <= 0: + logger.warning( + "设备 %s 报告 maxInputChannels=%s,将尝试 mono / stereo", + dev_name or "?", + max_in, + ) + return [(1, False), (2, True)] + if max_in == 1: + return [(1, False)] + if pref: + return [(2, True), (1, False)] + return [(1, False)] + + def _frames_per_buffer_for_rate(self, pa_rate: int) -> int: + if pa_rate <= 0: + pa_rate = self.sample_rate + return max(128, int(round(self.chunk_size * pa_rate / self.sample_rate))) + + @staticmethod + def _resample_linear_int16( + x: np.ndarray, sr_in: int, sr_out: int + ) -> np.ndarray: + if sr_in == sr_out or x.size == 0: + return x + n_out = max(1, int(round(x.size * (sr_out / sr_in)))) + t_in = np.arange(x.size, dtype=np.float64) + t_out = np.linspace(0.0, float(x.size - 1), n_out, dtype=np.float64) + y = np.interp(t_out, t_in, x.astype(np.float32)) + return np.clip(np.round(y), -32768, 32767).astype(np.int16) + + def _try_open_on_device(self, input_device_index: int) -> bool: + try: + dev = self.audio.get_device_info_by_index(input_device_index) + except Exception: + return False + max_in = int(dev.get("maxInputChannels", 0)) + dev_name = str(dev.get("name", "")) + if ( + max_in <= 0 + and self._input_device_index_cfg is not None + and int(input_device_index) == int(self._input_device_index_cfg) + and self._prefer_stereo_capture + and self.channels == 1 + ): + max_in = 2 + logger.warning( + "设备 %s 上报 maxInputChannels=0,假定 2 通道以尝试 ES8388 立体声采集", + input_device_index, + ) + plan = self._channel_plan(max_in, dev_name) + hw_t = self._device_hw_tuple_in_name(dev_name) + + for pa_ch, stereo_dm in plan: + self._pa_channels = pa_ch + self._stereo_downmix = stereo_dm + if stereo_dm and pa_ch == 2: + logger.info( + "输入按立体声打开并下混 mono(%s index=%s)", + dev_name, + input_device_index, + ) + for rate in self._open_try_rates: + fpb = self._frames_per_buffer_for_rate(int(rate)) + try: + self.stream = self.audio.open( + format=self.format, + channels=self._pa_channels, + rate=int(rate), + input=True, + input_device_index=input_device_index, + frames_per_buffer=fpb, + stream_callback=self._audio_callback, + start=False, + ) + self.stream.start_stream() + self._pa_open_sample_rate = int(rate) + extra = ( + f" hw=card{hw_t[0]}dev{hw_t[1]}" if hw_t else "" + ) + if self._pa_open_sample_rate != self.sample_rate: + logger.warning( + "输入实际 %s Hz,将重采样为 %s Hz 供 VAD/STT", + self._pa_open_sample_rate, + self.sample_rate, + ) + logger.info( + "音频流启动成功 index=%s name=%r PA_ch=%s PA_rate=%s 逻辑rate=%s%s", + input_device_index, + dev_name, + self._pa_channels, + self._pa_open_sample_rate, + self.sample_rate, + extra, + ) + return True + except Exception as e: + if self.stream is not None: + try: + self.stream.close() + except Exception: + pass + self.stream = None + logger.warning( + "打开失败 index=%s ch=%s rate=%s: %s", + input_device_index, + pa_ch, + rate, + e, + ) + return False + + def _audio_callback(self, in_data, frame_count, time_info, status): + """ + 音频回调函数(非阻塞) + """ + if status: + logger.warning(f"音频流状态: {status}") + + # 将数据放入队列(非阻塞) + try: + self.audio_queue.put(in_data, block=False) + except queue.Full: + logger.warning("音频队列已满,丢弃数据块") + + return (None, pyaudio.paContinue) + + def _log_input_devices_for_user(self) -> None: + """列出 PortAudio 全部设备(含 in_ch=0),便于选 --input-index / 核对子串。""" + n_dev = self.audio.get_device_count() + if n_dev <= 0: + print( + "[audio] PyAudio get_device_count()=0,多为 ALSA/PortAudio 未初始化;" + "请用 bash with_system_alsa.sh python … 启动。", + flush=True, + ) + logger.error("PyAudio 枚举不到任何设备") + return + lines: List[str] = [] + for i in range(n_dev): + try: + inf = self.audio.get_device_info_by_index(i) + mic = int(inf.get("maxInputChannels", 0)) + outc = int(inf.get("maxOutputChannels", 0)) + name = str(inf.get("name", "?")) + mark = " <- 可录音" if mic > 0 else "" + lines.append(f" [{i}] in={mic} out={outc} {name}{mark}") + except Exception: + continue + msg = "\n".join(lines) + logger.error("PortAudio 设备列表:\n%s", msg) + print( + "[audio] PortAudio 设备列表(in>0 才可作输入;若板载显示 in=0 仍可用 probe 试采):\n" + + msg, + flush=True, + ) + + def start_stream(self) -> None: + """启动音频流(回调模式)""" + if self.stream is not None: + return + + preferred, fallback = self._ordered_input_candidates() + to_try: List[int] = preferred + fallback + if not to_try: + print( + "[audio] 无任何输入候选。请检查 PortAudio/ALSA(建议:bash with_system_alsa.sh python …)。", + flush=True, + ) + if self._input_device_index_cfg is not None: + logger.error( + "已配置 input_device_index=%s 但无效或不可打开", + self._input_device_index_cfg, + ) + print( + f"[audio] 当前配置的 PyAudio 索引 {self._input_device_index_cfg} 不可用," + "请改 system.yaml 或重新运行交互选设备。", + flush=True, + ) + self._log_input_devices_for_user() + raise OSError("未找到任何 PyAudio 输入候选设备") + + for input_device_index in to_try: + if self._try_open_on_device(input_device_index): + return + + logger.error("启动音频流失败:全部候选设备无法打开") + self._log_input_devices_for_user() + raise OSError("启动音频流失败:全部候选设备无法打开") + + def stop_stream(self) -> None: + """停止音频流""" + if self.stream is None: + return + + try: + self.stream.stop_stream() + self.stream.close() + self.stream = None + self._pa_open_sample_rate = self.sample_rate + # 清空队列 + while not self.audio_queue.empty(): + try: + self.audio_queue.get_nowait() + except queue.Empty: + break + logger.info("音频流已停止") + except Exception as e: + logger.error(f"停止音频流失败: {e}") + + def read_chunk(self, timeout: float = 0.1) -> Optional[bytes]: + """ + 读取一个音频块(非阻塞) + + Args: + timeout: 超时时间(秒) + + Returns: + 音频数据(bytes),如果超时则返回 None + """ + if self.stream is None: + return None + + try: + return self.audio_queue.get(timeout=timeout) + except queue.Empty: + return None + + def read_chunk_numpy(self, timeout: float = 0.1) -> Optional[np.ndarray]: + """读取一个音频块并转换为 numpy 数组(非阻塞)""" + data = self.read_chunk(timeout) + if data is None: + return None + + sample_size = self._pa_channels * self.sample_width + if len(data) % sample_size != 0: + aligned_len = (len(data) // sample_size) * sample_size + if aligned_len == 0: + return None + data = data[:aligned_len] + + mono = np.frombuffer(data, dtype=" float: + """ + 更新 RMS 值 + + Args: + sample: 新的采样值 + + Returns: + 当前 RMS 值 + """ + if self.count < self.window_size: + # 填充阶段 + self.buffer[self.count] = sample + self.sum_sq += sample * sample + self.count += 1 + if self.count == 0: + return 0.0 + return np.sqrt(self.sum_sq / self.count) + else: + # 滑动窗口阶段 + old_sq = self.buffer[self.idx] * self.buffer[self.idx] + self.sum_sq = self.sum_sq - old_sq + sample * sample + self.buffer[self.idx] = sample + self.idx = (self.idx + 1) % self.window_size + return np.sqrt(self.sum_sq / self.window_size) + + def update_batch(self, samples: np.ndarray) -> float: + """ + 批量更新 RMS 值 + + Args: + samples: 采样数组 + + Returns: + 当前 RMS 值 + """ + for sample in samples: + self.update(sample) + return np.sqrt(self.sum_sq / min(self.count, self.window_size)) + + def reset(self): + """重置计算器""" + self.buffer.fill(0.0) + self.sum_sq = 0.0 + self.idx = 0 + self.count = 0 + + +class LightweightNoiseReduction: + """ + 轻量级降噪算法 + + 使用简单的高通滤波 + 谱减法,性能比 noisereduce 快 10-20 倍 + """ + + def __init__(self, sample_rate: int = 16000, cutoff: float = 80.0): + """ + Args: + sample_rate: 采样率 + cutoff: 高通滤波截止频率(Hz) + """ + self.sample_rate = sample_rate + self.cutoff = cutoff + + # 简单的 IIR 高通滤波器系数(一阶 Butterworth) + # H(z) = (1 - z^-1) / (1 - 0.99*z^-1) + self.alpha = np.exp(-2.0 * np.pi * cutoff / sample_rate) + self.prev_input = 0.0 + self.prev_output = 0.0 + + def process(self, audio: np.ndarray) -> np.ndarray: + """ + 处理音频(高通滤波) + + Args: + audio: 音频数据(float32,范围 [-1, 1]) + + Returns: + 处理后的音频 + """ + if audio.dtype != np.float32: + audio = audio.astype(np.float32) + + # 简单的一阶高通滤波 + output = np.zeros_like(audio) + for i in range(len(audio)): + output[i] = self.alpha * (self.prev_output + audio[i] - self.prev_input) + self.prev_input = audio[i] + self.prev_output = output[i] + + return output + + def reset(self): + """重置滤波器状态""" + self.prev_input = 0.0 + self.prev_output = 0.0 + + +class AudioPreprocessorOptimized: + """ + 优化版音频预处理器 + + 性能优化: + 1. 轻量级降噪(替代 noisereduce) + 2. 增量 AGC 计算 + 3. 减少类型转换 + """ + + def __init__(self, enable_noise_reduction: Optional[bool] = None, + enable_agc: Optional[bool] = None): + """ + 初始化音频预处理器 + """ + # 从配置读取 + if enable_noise_reduction is None: + enable_noise_reduction = SYSTEM_AUDIO_CONFIG.get("noise_reduce", True) + if enable_agc is None: + enable_agc = SYSTEM_AUDIO_CONFIG.get("agc", True) + + self.enable_noise_reduction = enable_noise_reduction + self.enable_agc = enable_agc + self.sample_rate = int(SYSTEM_AUDIO_CONFIG.get("sample_rate", 16000)) + + # AGC 参数(确保类型正确,从 YAML 读取可能是字符串) + self.agc_target_db = float(SYSTEM_AUDIO_CONFIG.get("agc_target_db", -20.0)) + self.agc_gain_min = float(SYSTEM_AUDIO_CONFIG.get("agc_gain_min", 0.1)) + self.agc_gain_max = float(SYSTEM_AUDIO_CONFIG.get("agc_gain_max", 10.0)) + self.agc_rms_threshold = float(SYSTEM_AUDIO_CONFIG.get("agc_rms_threshold", 1e-6)) + self._agc_alpha = float(SYSTEM_AUDIO_CONFIG.get("agc_smoothing_alpha", 0.1)) + self._agc_alpha = max(0.02, min(0.95, self._agc_alpha)) + # 当需要抬增益(小声/巨响过后)时用更大系数,避免长时间压在 agc_gain_min + self._agc_release_alpha = float( + SYSTEM_AUDIO_CONFIG.get("agc_release_alpha", 0.45) + ) + self._agc_release_alpha = max(self._agc_alpha, min(0.95, self._agc_release_alpha)) + + # 初始化组件 + if enable_noise_reduction: + self.noise_reducer = LightweightNoiseReduction( + sample_rate=self.sample_rate, + cutoff=80.0 # 可配置 + ) + else: + self.noise_reducer = None + + if enable_agc: + # 使用增量 RMS 计算器 + window_size = int(SYSTEM_AUDIO_CONFIG.get("frame_size", 1024)) + self.rms_calculator = IncrementalRMS(window_size=window_size) + self.current_gain = 1.0 # 缓存当前增益 + else: + self.rms_calculator = None + + logger.info( + f"优化版音频预处理器初始化完成: " + f"降噪={'启用(轻量级)' if enable_noise_reduction else '禁用'}, " + f"自动增益控制={'启用(增量)' if enable_agc else '禁用'}" + ) + + def reset(self) -> None: + """ + 重置高通滤波与 AGC 状态。应在「暂停采集再重新 start_stream」之后调用, + 避免停麦/播 TTS 期间的状态带到新流上(否则易出现恢复后长时间 RMS≈0 或电平怪异)。 + """ + if self.noise_reducer is not None: + self.noise_reducer.reset() + if self.rms_calculator is not None: + self.rms_calculator.reset() + if self.enable_agc: + self.current_gain = 1.0 + + def reset_agc_state(self) -> None: + """ + 每段语音结束或需Recovery时调用:清空 RMS 滑窗并将增益重置为 1。 + 避免短时强噪声把 current_gain 压在 agc_gain_min、滑窗仍含高能量导致后续 RMS≈0。 + """ + if not self.enable_agc or self.rms_calculator is None: + return + self.rms_calculator.reset() + self.current_gain = 1.0 + + def reduce_noise(self, audio_data: np.ndarray) -> np.ndarray: + """ + 轻量级降噪处理 + + Args: + audio_data: 音频数据(int16 或 float32) + + Returns: + 降噪后的音频数据 + """ + if not self.enable_noise_reduction or self.noise_reducer is None: + return audio_data + + # 转换为 float32 + if audio_data.dtype == np.int16: + audio_float = audio_data.astype(np.float32) / 32768.0 + is_int16 = True + else: + audio_float = audio_data.astype(np.float32) + is_int16 = False + + # 轻量级降噪 + reduced = self.noise_reducer.process(audio_float) + + # 转换回原始格式 + if is_int16: + reduced = (reduced * 32768.0).astype(np.int16) + + return reduced + + def automatic_gain_control(self, audio_data: np.ndarray) -> np.ndarray: + """ + 自动增益控制(使用增量 RMS) + + Args: + audio_data: 音频数据(int16 或 float32) + + Returns: + 增益调整后的音频数据 + """ + if not self.enable_agc or self.rms_calculator is None: + return audio_data + + # 转换为 float32 + if audio_data.dtype == np.int16: + audio_float = audio_data.astype(np.float32) / 32768.0 + is_int16 = True + else: + audio_float = audio_data.astype(np.float32) + is_int16 = False + + # 使用增量 RMS 计算 + rms = self.rms_calculator.update_batch(audio_float) + + if rms < self.agc_rms_threshold: + return audio_data + + # 计算增益(可以进一步优化:使用滑动平均) + current_db = 20 * np.log10(rms) + gain_db = self.agc_target_db - current_db + gain_linear = 10 ** (gain_db / 20.0) + gain_linear = np.clip(gain_linear, self.agc_gain_min, self.agc_gain_max) + + # 压低增益用较小 alpha;需要恢复(gain_linear 明显高于当前)时用 release alpha + if gain_linear > self.current_gain * 1.08: + alpha = self._agc_release_alpha + else: + alpha = self._agc_alpha + self.current_gain = alpha * gain_linear + (1 - alpha) * self.current_gain + + # 应用增益 + adjusted = audio_float * self.current_gain + adjusted = np.clip(adjusted, -1.0, 1.0) + + # 转换回原始格式 + if is_int16: + adjusted = (adjusted * 32768.0).astype(np.int16) + + return adjusted + + def process(self, audio_data: np.ndarray) -> np.ndarray: + """ + 完整的预处理流程(优化版) + + Args: + audio_data: 音频数据(numpy array) + + Returns: + 预处理后的音频数据 + """ + processed = audio_data.copy() + + # 降噪 + if self.enable_noise_reduction: + processed = self.reduce_noise(processed) + + # 自动增益控制 + if self.enable_agc: + processed = self.automatic_gain_control(processed) + + return processed + + +# 向后兼容别名(保持API一致性) +AudioCapture = AudioCaptureOptimized +AudioPreprocessor = AudioPreprocessorOptimized + + +# 使用示例 +if __name__ == "__main__": + # 优化版使用 + with AudioCapture() as capture: + preprocessor = AudioPreprocessor() + + for i in range(10): + chunk = capture.read_chunk_numpy(timeout=0.1) + if chunk is not None: + processed = preprocessor.process(chunk) + print(f"处理了 {len(processed)} 个采样点") diff --git a/voice_drone/core/cloud_dialog_v1.py b/voice_drone/core/cloud_dialog_v1.py new file mode 100644 index 0000000..267246c --- /dev/null +++ b/voice_drone/core/cloud_dialog_v1.py @@ -0,0 +1,78 @@ +"""cloud_voice_dialog_v1:dialog_result 约定(见 docs/CLOUD_VOICE_FLIGHT_CONFIRM_v1.md)。""" + +from __future__ import annotations + +from typing import Any + +CLOUD_VOICE_DIALOG_V1 = "cloud_voice_dialog_v1" + +MSG_CONFIRM_TIMEOUT = "未收到确认指令,请重新下发指令。" +MSG_CANCELLED = "已取消指令,请重新唤醒后下发指令。" +MSG_CONFIRM_EXECUTING = "开始执行飞控指令。" +MSG_PROMPT_LISTEN_TIMEOUT = "未检测到语音,请重新唤醒后再说。" + + +def normalize_phrase_text(s: str) -> str: + """去首尾空白、合并连续空白。""" + return " ".join((s or "").strip().split()) + + +def _strip_tail_punct(s: str) -> str: + return s.rstrip("。!!??,, \t") + + +def match_phrase_list(norm: str, phrases: Any) -> bool: + """ + 命中规则(适配「请回复确认或取消」类长提示 + 只说「确认」「取消」): + - 去尾标点后 **全等** 短语;或 + - 短语为 **子串** 且整句长度 <= len(短语)+2,避免用户复述整段提示时同时含「确认」「取消」而误触。 + """ + if not isinstance(phrases, list) or not norm: + return False + base = _strip_tail_punct(normalize_phrase_text(norm)) + if not base: + return False + for p in phrases: + raw = _strip_tail_punct((p or "").strip()) + if not raw: + continue + if base == raw: + return True + if raw in base and len(base) <= len(raw) + 2: + return True + return False + + +def parse_confirm_dict(raw: Any) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + required = raw.get("required") + if not isinstance(required, bool): + return None + try: + timeout_sec = float(raw.get("timeout_sec", 10)) + except (TypeError, ValueError): + timeout_sec = 10.0 + timeout_sec = max(1.0, min(120.0, timeout_sec)) + cp = raw.get("confirm_phrases") + kp = raw.get("cancel_phrases") + if not isinstance(cp, list) or not cp: + return None + if not isinstance(kp, list) or not kp: + return None + pending = raw.get("pending_id") + if pending is not None and not isinstance(pending, str): + pending = str(pending) + cplist = [str(x) for x in cp if str(x).strip()] + kplist = [str(x) for x in kp if str(x).strip()] + if not cplist or not kplist: + return None + return { + "required": required, + "timeout_sec": timeout_sec, + "confirm_phrases": cplist, + "cancel_phrases": kplist, + "pending_id": pending, + "summary_for_user": raw.get("summary_for_user"), + "raw": raw, + } diff --git a/voice_drone/core/cloud_voice_client.py b/voice_drone/core/cloud_voice_client.py new file mode 100644 index 0000000..c7e4fdb --- /dev/null +++ b/voice_drone/core/cloud_voice_client.py @@ -0,0 +1,999 @@ +""" +云端语音 WebSocket 客户端:会话 `session.start.transport_profile` 固定为 pcm_asr_uplink。 + +- 主路径:`turn.audio.start` → 若干 `turn.audio.chunk`(每条仅文本 JSON,含 `pcm_base64`)→ `turn.audio.end`;**禁止**用 WebSocket binary 上发 PCM(与 Starlette receive 语义一致)。 +- 辅助:`run_turn` 发 `turn.text`(如同句快路径仅有文本);`run_tts_synthesize` 仅 TTS。 +- `asr.partial` 仅调试展示,不参与机端状态机。 + +文档:`docs/CLOUD_VOICE_SESSION_SCHEME_v1.md`,`docs/CLOUD_VOICE_PROTOCOL_pcm_asr_uplink_v1.md`。 +""" + +from __future__ import annotations + +import base64 +import json +import os +import threading +import time +import uuid +from typing import Any + +import numpy as np + +from voice_drone.core.cloud_dialog_v1 import CLOUD_VOICE_DIALOG_V1 +from voice_drone.logging_ import get_logger + +logger = get_logger("voice_drone.cloud_voice") + +_CLOUD_PROTO = "1.0" +TRANSPORT_PCM_ASR_UPLINK = "pcm_asr_uplink" + + +def _merge_session_client( + device_id: str, + *, + session_client_extensions: dict[str, Any] | None, +) -> dict[str, Any]: + """session.start 的 client:capabilities 与设备信息 + 可选 PX4 等扩展(不覆盖 device_id/locale)。""" + client: dict[str, Any] = { + "device_id": device_id, + "locale": "zh-CN", + "capabilities": { + "playback_sample_rate_hz": 24000, + "prefer_tts_codec": "pcm_s16le", + }, + } + ext = session_client_extensions or {} + for k, v in ext.items(): + if v is None or k in ("device_id", "locale", "capabilities", "protocol"): + continue + if k == "extras" and isinstance(v, dict) and len(v) == 0: + continue + client[k] = v + client["protocol"] = {"dialog_result": CLOUD_VOICE_DIALOG_V1} + return client + + +def _transient_ws_exc(exc: BaseException) -> bool: + """可通过对端已关、网络抖动等通过重连重发 turn 恢复的异常。""" + import websocket as _websocket # noqa: PLC0415 + + if isinstance( + exc, + ( + BrokenPipeError, + ConnectionResetError, + ConnectionAbortedError, + ), + ): + return True + if isinstance( + exc, + ( + _websocket.WebSocketConnectionClosedException, + _websocket.WebSocketTimeoutException, + ), + ): + return True + if isinstance(exc, OSError) and getattr(exc, "errno", None) in ( + 32, + 104, + 110, + ): # EPIPE, ECONNRESET, ETIMEDOUT + return True + return False + + +def _merge_tts_pcm_chunks( + chunk_entries: list[tuple[int | None, int, bytes]], +) -> bytes: + """按 seq 升序拼接;无 seq 时按到达顺序。chunk_entries: (seq|None, arrival, pcm)。""" + if not chunk_entries: + return b"" + if all(s is not None for s, _, _ in chunk_entries): + ordered = sorted(chunk_entries, key=lambda x: (x[0], x[1])) + seqs = [x[0] for x in ordered] + for a, b in zip(seqs, seqs[1:]): + if b != a + 1: + logger.warning("TTS seq 不连续(仍按序拼接): %s → %s", a, b) + break + return b"".join(x[2] for x in ordered) + return b"".join(x[2] for x in sorted(chunk_entries, key=lambda x: x[1])) + + +class CloudVoiceError(RuntimeError): + """云端返回 error 消息或协议不符合预期。""" + + def __init__(self, message: str, *, code: str | None = None, retryable: bool = False): + super().__init__(message) + self.code = code + self.retryable = retryable + + +class CloudVoiceClient: + """连接 ws://…/v1/voice/session;session 为 pcm_asr_uplink,含 run_turn_audio / run_turn / tts.synthesize。""" + + def __init__( + self, + *, + server_url: str, + auth_token: str, + device_id: str, + recv_timeout: float = 120.0, + session_client_extensions: dict[str, Any] | None = None, + ) -> None: + self.server_url = server_url.strip() + self.auth_token = auth_token.strip() + self.device_id = (device_id or "drone-001").strip() + self.recv_timeout = float(recv_timeout) + self._session_client_extensions: dict[str, Any] = dict( + session_client_extensions or {} + ) + self._transport_profile: str = TRANSPORT_PCM_ASR_UPLINK + self._ws: Any = None + self._session_id: str | None = None + self._lock = threading.Lock() + + @property + def connected(self) -> bool: + with self._lock: + return self._ws is not None + + def close(self) -> None: + with self._lock: + self._close_nolock() + + def _close_nolock(self) -> None: + if self._ws is None: + self._session_id = None + return + try: + if self._session_id: + try: + self._ws.send( + json.dumps( + { + "type": "session.end", + "proto_version": _CLOUD_PROTO, + "session_id": self._session_id, + }, + ensure_ascii=False, + ) + ) + except Exception: # noqa: BLE001 + pass + finally: + try: + self._ws.close() + except Exception: # noqa: BLE001 + pass + self._ws = None + self._session_id = None + + def connect(self) -> None: + """建立 WSS,发送 session.start,等待 session.ready。""" + with self._lock: + self._connect_nolock() + + def _connect_nolock(self) -> None: + import websocket # websocket-client + + self._close_nolock() + hdr = [f"Authorization: Bearer {self.auth_token}"] + try: + self._ws = websocket.create_connection( + self.server_url, + header=hdr, + timeout=self.recv_timeout, + ) + self._ws.settimeout(self.recv_timeout) + self._session_id = str(uuid.uuid4()) + client_payload = _merge_session_client( + self.device_id, + session_client_extensions=self._session_client_extensions, + ) + if self._session_client_extensions: + logger.info( + "session.start 已附加 client 扩展键: %s", + sorted(self._session_client_extensions.keys()), + ) + start = { + "type": "session.start", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "session_id": self._session_id, + "auth_token": self.auth_token, + "client": client_payload, + } + self._ws.send(json.dumps(start, ensure_ascii=False)) + raw = self._ws.recv() + if isinstance(raw, bytes): + raise CloudVoiceError("session.ready 期望 JSON 文本帧,收到二进制") + data = json.loads(raw) + if data.get("type") != "session.ready": + raise CloudVoiceError( + f"期望 session.ready,收到: {data.get('type')!r}", + code="INVALID_MESSAGE", + ) + logger.info("云端会话已就绪 session_id=%s", self._session_id) + except Exception: + self._close_nolock() + raise + + def ensure_connected(self) -> None: + with self._lock: + if self._ws is None: + self._connect_nolock() + + def _execute_turn_nolock(self, t: str) -> dict[str, Any]: + """已持锁且 _ws 已连接:发送 turn.text 并收齐本轮帧。""" + import websocket # websocket-client + + ws = self._ws + if ws is None: + raise CloudVoiceError("WebSocket 未连接") + + turn_id = str(uuid.uuid4()) + turn_msg = { + "type": "turn.text", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "turn_id": turn_id, + "text": t, + "is_final": True, + "source": "device_stt", + } + try: + ws.send(json.dumps(turn_msg, ensure_ascii=False)) + except Exception as e: + if _transient_ws_exc(e): + raise + raise CloudVoiceError(f"发送 turn.text 失败: {e}", code="INTERNAL") from e + logger.debug("→ turn.text turn_id=%s", turn_id) + + expecting_binary = False + _pending_tts_seq: int | None = None + pcm_entries: list[tuple[int | None, int, bytes]] = [] + _pcm_arrival = 0 + llm_stream_parts: list[str] = [] + dialog: dict[str, Any] | None = None + metrics: dict[str, Any] = {} + sample_rate_hz = 24000 + + while True: + try: + msg = ws.recv() + except websocket.WebSocketConnectionClosedException as e: + raise CloudVoiceError( + f"连接已断开: {e}", + code="DISCONNECTED", + retryable=True, + ) from e + except Exception as e: + if _transient_ws_exc(e): + raise + raise + + if isinstance(msg, bytes): + if expecting_binary: + expecting_binary = False + else: + logger.warning("收到未预期的二进制帧,仍作为 TTS 数据处理") + pcm_entries.append((_pending_tts_seq, _pcm_arrival, msg)) + _pcm_arrival += 1 + _pending_tts_seq = None + continue + + if not isinstance(msg, str): + raise CloudVoiceError( + f"期望文本帧为 str,实际为 {type(msg).__name__}", + code="INVALID_MESSAGE", + ) + text_frame = msg.strip() + if not text_frame: + logger.debug("跳过空 WebSocket 文本帧") + continue + try: + data = json.loads(text_frame) + except json.JSONDecodeError as e: + head = text_frame[:200].replace("\n", "\\n") + raise CloudVoiceError( + f"服务端文本帧不是合法 JSON: {e}; 前 {len(head)} 字符: {head!r}", + code="INVALID_MESSAGE", + ) from e + mtype = data.get("type") + + if mtype == "asr.partial": + logger.debug("← asr.partial(机端不参与状态跳转)") + continue + + if mtype == "llm.text_delta": + if data.get("turn_id") != turn_id: + logger.debug( + "llm.text_delta turn_id 与当前不一致,忽略 type=%s", + mtype, + ) + continue + raw_d = data.get("delta") + delta = "" if raw_d is None else str(raw_d) + llm_stream_parts.append(delta) + _print_stream = os.environ.get("ROCKET_PRINT_LLM_STREAM", "").lower() in ( + "1", + "true", + "yes", + ) + if _print_stream: + print(delta, end="", flush=True) + if data.get("done"): + print(flush=True) + logger.debug( + "← llm.text_delta done=%s delta_chars=%s", + data.get("done"), + len(delta), + ) + continue + + if mtype == "tts_audio_chunk": + _pending_tts_seq = None + if data.get("turn_id") != turn_id: + logger.warning("tts_audio_chunk turn_id 与当前不一致,仍消费后续二进制") + else: + try: + sample_rate_hz = int( + data.get("sample_rate_hz") or sample_rate_hz + ) + except (TypeError, ValueError): + pass + _s = data.get("seq") + try: + if _s is not None: + _pending_tts_seq = int(_s) + except (TypeError, ValueError): + _pending_tts_seq = None + if data.get("is_final"): + logger.debug("← tts_audio_chunk is_final=true seq=%s", _s) + expecting_binary = True + continue + + if mtype == "dialog_result": + if data.get("turn_id") != turn_id: + raise CloudVoiceError( + "dialog_result turn_id 不匹配", code="INVALID_MESSAGE" + ) + dialog = data + logger.info( + "← dialog_result routing=%s", data.get("routing") + ) + continue + + if mtype == "turn.complete": + if data.get("turn_id") != turn_id: + raise CloudVoiceError( + "turn.complete turn_id 不匹配", code="INVALID_MESSAGE" + ) + metrics = data.get("metrics") or {} + break + + if mtype == "error": + code = str(data.get("code") or "INTERNAL") + raise CloudVoiceError( + data.get("message") or code, + code=code, + retryable=bool(data.get("retryable")), + ) + + logger.debug("忽略服务端消息 type=%s", mtype) + + if dialog is None: + raise CloudVoiceError("未收到 dialog_result", code="INVALID_MESSAGE") + + full_pcm = _merge_tts_pcm_chunks(pcm_entries) + pcm = ( + np.frombuffer(full_pcm, dtype=np.int16).copy() + if full_pcm + else np.array([], dtype=np.int16) + ) + if pcm.size > 0: + mx = int(np.max(np.abs(pcm))) + if mx == 0: + logger.warning( + "云端 TTS 已收齐二进制总长 %s 字节(≈%s 个 s16 采样),但全为 0x00," + "属于服务端发出的静音占位或未写入合成结果;机端无法通过重采样/扬声器修复。" + "请在服务端对同一次 synthesize 写 WAV 核对非零采样,并确认 WS 先发 tts_audio_chunk JSON、" + "再发 raw PCM 帧、且未把 JSON/base64 误当 binary 发出。", + len(full_pcm), + pcm.size, + ) + if os.environ.get("ROCKET_CLOUD_PCM_HEX", "").strip().lower() in ( + "1", + "true", + "yes", + ): + head = full_pcm[:64] + logger.warning( + "ROCKET_CLOUD_PCM_HEX: 前 %s 字节 hex=%s", + len(head), + head.hex(), + ) + + llm_stream_text = "".join(llm_stream_parts) + return { + "protocol": dialog.get("protocol"), + "routing": dialog.get("routing"), + "flight_intent": dialog.get("flight_intent"), + "confirm": dialog.get("confirm"), + "chat_reply": dialog.get("chat_reply"), + "user_input": dialog.get("user_input"), + "pcm": pcm, + "sample_rate_hz": sample_rate_hz, + "metrics": metrics, + "llm_stream_text": llm_stream_text, + } + + def _execute_turn_audio_nolock( + self, pcm_int16: np.ndarray, sample_rate_hz: int + ) -> dict[str, Any]: + """发送 turn.audio.start → 多条 turn.audio.chunk(pcm_base64 文本帧)→ turn.audio.end;禁止 binary 上发 PCM。""" + import websocket # websocket-client + + ws = self._ws + if ws is None: + raise CloudVoiceError("WebSocket 未连接") + + pcm_int16 = np.asarray(pcm_int16, dtype=np.int16).reshape(-1) + if pcm_int16.size == 0: + raise CloudVoiceError("turn.audio PCM 为空") + + pcm_mx = int(np.max(np.abs(pcm_int16))) + pcm_rms = float(np.sqrt(np.mean(pcm_int16.astype(np.float64) ** 2))) + dur_sec = float(pcm_int16.size) / max(1, int(sample_rate_hz)) + logger.info( + "turn.audio 上行: samples=%s sr_hz=%s dur≈%.2fs abs_max=%s rms=%.1f dtype=int16", + pcm_int16.size, + int(sample_rate_hz), + dur_sec, + pcm_mx, + pcm_rms, + ) + if pcm_mx == 0: + logger.warning( + "turn.audio 上行波形全零,云端 ASR 通常会判无有效语音(请查麦/切段/VAD 是否误交静音)" + ) + elif pcm_mx < 200: + logger.warning( + "turn.audio 上行幅值极小 abs_max=%s(仍发送);若云端反复无识别请调 AGC/VAD/麦增益", + pcm_mx, + ) + + turn_id = str(uuid.uuid4()) + start = { + "type": "turn.audio.start", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "turn_id": turn_id, + "sample_rate_hz": int(sample_rate_hz), + "codec": "pcm_s16le", + "channels": 1, + } + raw = pcm_int16.tobytes() + try: + ws.send(json.dumps(start, ensure_ascii=False)) + try: + raw_chunk = int(os.environ.get("ROCKET_CLOUD_AUDIO_CHUNK_BYTES", "8192")) + except ValueError: + raw_chunk = 8192 + raw_chunk = max(2048, min(256 * 1024, raw_chunk)) + n_chunks = 0 + for i in range(0, len(raw), raw_chunk): + piece = raw[i : i + raw_chunk] + chunk_msg = { + "type": "turn.audio.chunk", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "turn_id": turn_id, + "pcm_base64": base64.b64encode(piece).decode("ascii"), + } + ws.send(json.dumps(chunk_msg, ensure_ascii=False)) + n_chunks += 1 + end = { + "type": "turn.audio.end", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "turn_id": turn_id, + } + ws.send(json.dumps(end, ensure_ascii=False)) + except Exception as e: + if _transient_ws_exc(e): + raise + raise CloudVoiceError(f"发送 turn.audio 失败: {e}", code="INTERNAL") from e + logger.debug( + "→ turn.audio start/%s chunk(s)/end turn_id=%s samples=%s", + n_chunks, + turn_id, + pcm_int16.size, + ) + + expecting_binary = False + _pending_tts_seq: int | None = None + pcm_entries: list[tuple[int | None, int, bytes]] = [] + _pcm_arrival = 0 + llm_stream_parts: list[str] = [] + dialog: dict[str, Any] | None = None + metrics: dict[str, Any] = {} + out_sr = 24000 + + while True: + try: + msg = ws.recv() + except websocket.WebSocketConnectionClosedException as e: + raise CloudVoiceError( + f"连接已断开: {e}", + code="DISCONNECTED", + retryable=True, + ) from e + except Exception as e: + if _transient_ws_exc(e): + raise + raise + + if isinstance(msg, bytes): + if expecting_binary: + expecting_binary = False + else: + logger.warning("收到未预期的二进制帧,仍作为 TTS 数据处理") + pcm_entries.append((_pending_tts_seq, _pcm_arrival, msg)) + _pcm_arrival += 1 + _pending_tts_seq = None + continue + + if not isinstance(msg, str): + raise CloudVoiceError( + f"期望文本帧为 str,实际为 {type(msg).__name__}", + code="INVALID_MESSAGE", + ) + text_frame = msg.strip() + if not text_frame: + logger.debug("跳过空 WebSocket 文本帧") + continue + try: + data = json.loads(text_frame) + except json.JSONDecodeError as e: + head = text_frame[:200].replace("\n", "\\n") + raise CloudVoiceError( + f"服务端文本帧不是合法 JSON: {e}; 前 {len(head)} 字符: {head!r}", + code="INVALID_MESSAGE", + ) from e + mtype = data.get("type") + + if mtype == "asr.partial": + logger.debug("← asr.partial(机端不参与状态跳转)") + continue + + if mtype == "llm.text_delta": + if data.get("turn_id") != turn_id: + logger.debug( + "llm.text_delta turn_id 与当前不一致,忽略 type=%s", + mtype, + ) + continue + raw_d = data.get("delta") + delta = "" if raw_d is None else str(raw_d) + llm_stream_parts.append(delta) + _print_stream = os.environ.get("ROCKET_PRINT_LLM_STREAM", "").lower() in ( + "1", + "true", + "yes", + ) + if _print_stream: + print(delta, end="", flush=True) + if data.get("done"): + print(flush=True) + logger.debug( + "← llm.text_delta done=%s delta_chars=%s", + data.get("done"), + len(delta), + ) + continue + + if mtype == "tts_audio_chunk": + _pending_tts_seq = None + if data.get("turn_id") != turn_id: + logger.warning("tts_audio_chunk turn_id 与当前不一致,仍消费后续二进制") + else: + try: + out_sr = int(data.get("sample_rate_hz") or out_sr) + except (TypeError, ValueError): + pass + _s = data.get("seq") + try: + if _s is not None: + _pending_tts_seq = int(_s) + except (TypeError, ValueError): + _pending_tts_seq = None + if data.get("is_final"): + logger.debug("← tts_audio_chunk is_final=true seq=%s", _s) + expecting_binary = True + continue + + if mtype == "dialog_result": + if data.get("turn_id") != turn_id: + raise CloudVoiceError( + "dialog_result turn_id 不匹配", code="INVALID_MESSAGE" + ) + dialog = data + logger.info( + "← dialog_result routing=%s", data.get("routing") + ) + continue + + if mtype == "turn.complete": + if data.get("turn_id") != turn_id: + raise CloudVoiceError( + "turn.complete turn_id 不匹配", code="INVALID_MESSAGE" + ) + metrics = data.get("metrics") or {} + break + + if mtype == "error": + code = str(data.get("code") or "INTERNAL") + raise CloudVoiceError( + data.get("message") or code, + code=code, + retryable=bool(data.get("retryable")), + ) + + logger.debug("忽略服务端消息 type=%s", mtype) + + if dialog is None: + raise CloudVoiceError("未收到 dialog_result", code="INVALID_MESSAGE") + + full_pcm = _merge_tts_pcm_chunks(pcm_entries) + out_pcm = ( + np.frombuffer(full_pcm, dtype=np.int16).copy() + if full_pcm + else np.array([], dtype=np.int16) + ) + if out_pcm.size > 0: + mx = int(np.max(np.abs(out_pcm))) + if mx == 0: + logger.warning( + "云端 TTS 已收齐但全零采样,请核对服务端。", + ) + + llm_stream_text = "".join(llm_stream_parts) + return { + "protocol": dialog.get("protocol"), + "routing": dialog.get("routing"), + "flight_intent": dialog.get("flight_intent"), + "confirm": dialog.get("confirm"), + "chat_reply": dialog.get("chat_reply"), + "user_input": dialog.get("user_input"), + "pcm": out_pcm, + "sample_rate_hz": out_sr, + "metrics": metrics, + "llm_stream_text": llm_stream_text, + } + + def run_turn_audio( + self, pcm_int16: np.ndarray, sample_rate_hz: int + ) -> dict[str, Any]: + """上行一轮麦克风 PCM:chunk 均为含 pcm_base64 的文本 JSON;收齐 dialog_result + TTS + turn.complete。""" + try: + raw_attempts = int(os.environ.get("ROCKET_CLOUD_TURN_RETRIES", "3")) + except ValueError: + raw_attempts = 3 + attempts = max(1, raw_attempts) + try: + delay = float(os.environ.get("ROCKET_CLOUD_TURN_RETRY_DELAY_SEC", "0.35")) + except ValueError: + delay = 0.35 + delay = max(0.0, delay) + + for attempt in range(attempts): + with self._lock: + try: + if self._ws is None: + self._connect_nolock() + return self._execute_turn_audio_nolock(pcm_int16, sample_rate_hz) + except CloudVoiceError as e: + retry = bool(e.retryable) or e.code == "DISCONNECTED" + if retry and attempt < attempts - 1: + logger.warning( + "turn.audio 可恢复错误,重连重试 (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + except Exception as e: + if _transient_ws_exc(e) and attempt < attempts - 1: + logger.warning( + "turn.audio WebSocket 瞬断,重连重试 (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + + raise CloudVoiceError("run_turn_audio 未执行", code="INTERNAL") + + def _execute_tts_synthesize_nolock(self, text: str) -> dict[str, Any]: + """已持锁且 _ws 已连接:发送 tts.synthesize,仅收 tts_audio_chunk* 与 turn.complete(无 dialog_result)。""" + import websocket # websocket-client + + ws = self._ws + if ws is None: + raise CloudVoiceError("WebSocket 未连接") + + turn_id = str(uuid.uuid4()) + synth_msg = { + "type": "tts.synthesize", + "proto_version": _CLOUD_PROTO, + "transport_profile": self._transport_profile, + "turn_id": turn_id, + "text": text, + } + try: + ws.send(json.dumps(synth_msg, ensure_ascii=False)) + except Exception as e: + if _transient_ws_exc(e): + raise + raise CloudVoiceError(f"发送 tts.synthesize 失败: {e}", code="INTERNAL") from e + logger.debug("→ tts.synthesize turn_id=%s", turn_id) + + expecting_binary = False + _pending_tts_seq: int | None = None + pcm_entries: list[tuple[int | None, int, bytes]] = [] + _pcm_arrival = 0 + metrics: dict[str, Any] = {} + sample_rate_hz = 24000 + + while True: + try: + msg = ws.recv() + except websocket.WebSocketConnectionClosedException as e: + raise CloudVoiceError( + f"连接已断开: {e}", + code="DISCONNECTED", + retryable=True, + ) from e + except Exception as e: + if _transient_ws_exc(e): + raise + raise + + if isinstance(msg, bytes): + if expecting_binary: + expecting_binary = False + else: + logger.warning("收到未预期的二进制帧,仍作为 TTS 数据处理") + pcm_entries.append((_pending_tts_seq, _pcm_arrival, msg)) + _pcm_arrival += 1 + _pending_tts_seq = None + continue + + if not isinstance(msg, str): + raise CloudVoiceError( + f"期望文本帧为 str,实际为 {type(msg).__name__}", + code="INVALID_MESSAGE", + ) + text_frame = msg.strip() + if not text_frame: + logger.debug("跳过空 WebSocket 文本帧") + continue + try: + data = json.loads(text_frame) + except json.JSONDecodeError as e: + head = text_frame[:200].replace("\n", "\\n") + raise CloudVoiceError( + f"服务端文本帧不是合法 JSON: {e}; 前 {len(head)} 字符: {head!r}", + code="INVALID_MESSAGE", + ) from e + mtype = data.get("type") + + if mtype == "asr.partial": + logger.debug("← asr.partial(tts 轮次,忽略)") + continue + + if mtype == "llm.text_delta": + if data.get("turn_id") != turn_id: + logger.debug( + "llm.text_delta turn_id 与当前 tts 不一致,忽略", + ) + continue + + if mtype == "tts_audio_chunk": + _pending_tts_seq = None + if data.get("turn_id") != turn_id: + logger.warning( + "tts_audio_chunk turn_id 与 tts.synthesize 不一致,仍消费后续二进制", + ) + else: + try: + sample_rate_hz = int( + data.get("sample_rate_hz") or sample_rate_hz + ) + except (TypeError, ValueError): + pass + _s = data.get("seq") + try: + if _s is not None: + _pending_tts_seq = int(_s) + except (TypeError, ValueError): + _pending_tts_seq = None + if data.get("is_final"): + logger.debug("← tts_audio_chunk is_final=true seq=%s", _s) + expecting_binary = True + continue + + if mtype == "dialog_result": + logger.debug("tts.synthesize 收到 dialog_result(非预期),忽略") + continue + + if mtype == "turn.complete": + if data.get("turn_id") != turn_id: + raise CloudVoiceError( + "turn.complete turn_id 不匹配", code="INVALID_MESSAGE" + ) + metrics = data.get("metrics") or {} + break + + if mtype == "error": + code = str(data.get("code") or "INTERNAL") + raise CloudVoiceError( + data.get("message") or code, + code=code, + retryable=bool(data.get("retryable")), + ) + + logger.debug("忽略服务端消息 type=%s", mtype) + + full_pcm = _merge_tts_pcm_chunks(pcm_entries) + pcm = ( + np.frombuffer(full_pcm, dtype=np.int16).copy() + if full_pcm + else np.array([], dtype=np.int16) + ) + if pcm.size > 0: + mx = int(np.max(np.abs(pcm))) + if mx == 0: + logger.warning( + "tts.synthesize 收齐 PCM 但全零(服务端静音占位);总长 %s 字节", + len(full_pcm), + ) + + return { + "pcm": pcm, + "sample_rate_hz": sample_rate_hz, + "metrics": metrics, + } + + def run_tts_synthesize(self, text: str) -> dict[str, Any]: + """ + 发送 tts.synthesize,收齐 TTS 块与 turn.complete(无 dialog_result)。 + 与 run_turn 共用连接,互斥由服务端排队;重试策略同 ROCKET_CLOUD_TURN_RETRIES。 + """ + t = (text or "").strip() + if not t: + raise CloudVoiceError("tts.synthesize text 不能为空") + + try: + raw_attempts = int(os.environ.get("ROCKET_CLOUD_TURN_RETRIES", "3")) + except ValueError: + raw_attempts = 3 + attempts = max(1, raw_attempts) + try: + delay = float(os.environ.get("ROCKET_CLOUD_TURN_RETRY_DELAY_SEC", "0.35")) + except ValueError: + delay = 0.35 + delay = max(0.0, delay) + + for attempt in range(attempts): + with self._lock: + try: + if self._ws is None: + self._connect_nolock() + return self._execute_tts_synthesize_nolock(t) + except CloudVoiceError as e: + retry = bool(e.retryable) or e.code == "DISCONNECTED" + if retry and attempt < attempts - 1: + logger.warning( + "tts.synthesize 可恢复错误,将重连并重试 (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + except Exception as e: + if _transient_ws_exc(e) and attempt < attempts - 1: + logger.warning( + "tts.synthesize WebSocket 瞬断,重连并重试 (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + + raise CloudVoiceError("run_tts_synthesize 未执行", code="INTERNAL") + + def run_turn(self, text: str) -> dict[str, Any]: + """ + 发送一轮用户文本,收齐 dialog_result、TTS 块、turn.complete。 + + 支持流式下行:可先于 dialog_result 收到 tts_audio_chunk+PCM 与 llm.text_delta; + 飞控与最终文案仍以 dialog_result 为准。 + + 若中间因对端已关 TCP、ping/pong Broken pipe 等断开,会自动关连接、 + 重连 session 并重发本轮(次数由 ROCKET_CLOUD_TURN_RETRIES 控制,默认 3)。 + + Returns: + dict: routing, flight_intent, chat_reply, user_input, pcm, sample_rate_hz, + metrics, llm_stream_text(llm.text_delta 拼接,可选调试/UI) + """ + t = (text or "").strip() + if not t: + raise CloudVoiceError("turn.text 不能为空") + + try: + raw_attempts = int(os.environ.get("ROCKET_CLOUD_TURN_RETRIES", "3")) + except ValueError: + raw_attempts = 3 + attempts = max(1, raw_attempts) + try: + delay = float(os.environ.get("ROCKET_CLOUD_TURN_RETRY_DELAY_SEC", "0.35")) + except ValueError: + delay = 0.35 + delay = max(0.0, delay) + + for attempt in range(attempts): + with self._lock: + try: + if self._ws is None: + self._connect_nolock() + return self._execute_turn_nolock(t) + except CloudVoiceError as e: + retry = bool(e.retryable) or e.code == "DISCONNECTED" + if retry and attempt < attempts - 1: + logger.warning( + "云端回合可恢复错误,将重连并重试 (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + except Exception as e: + if _transient_ws_exc(e) and attempt < attempts - 1: + logger.warning( + "云端 WebSocket 瞬断(如对端先关、PONG 写失败)," + "重连并重发 turn (%s/%s): %s", + attempt + 1, + attempts, + e, + ) + self._close_nolock() + if delay: + time.sleep(delay) + continue + raise + + raise CloudVoiceError("run_turn 未执行", code="INTERNAL") diff --git a/voice_drone/core/command.py b/voice_drone/core/command.py new file mode 100644 index 0000000..613b86a --- /dev/null +++ b/voice_drone/core/command.py @@ -0,0 +1,205 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional, Literal +from datetime import datetime +from voice_drone.core.configuration import ( + TAKEOFF_CONFIG, + LAND_CONFIG, + FOLLOW_CONFIG, + FORWARD_CONFIG, + BACKWARD_CONFIG, + LEFT_CONFIG, + RIGHT_CONFIG, + UP_CONFIG, + DOWN_CONFIG, + HOVER_CONFIG, + RETURN_HOME_CONFIG, +) +import warnings +warnings.filterwarnings("ignore") + + +class CommandParams(BaseModel): + """ + 命令参数 + """ + distance: Optional[float] = Field( + None, + description="飞行距离,单位:米(m),必须大于等于0(land/hover 可以为0)", + ge=0 + ) + speed: Optional[float] = Field( + None, + description="飞行速度,单位:米每秒(m/s),必须大于等于0(land/hover 可以为0)", + ge=0 + ) + duration: Optional[float] = Field( + None, + description="飞行持续时间,单位:秒(s),必须大于0", + gt=0 + ) + +class Command(BaseModel): + """ + 无人机控制命令 + """ + command: Literal[ + "takeoff", + "follow", + "forward", + "backward", + "left", + "right", + "up", + "down", + "hover", + "land", + "return_home", + ] = Field( + ..., + description="无人机控制动作: takeoff(起飞), follow(跟随), forward(向前), backward(向后), left(向左), right(向右), up(向上), down(向下), hover(悬停), land(降落), return_home(返航)", + ) + params: CommandParams = Field(..., description="命令参数") + timestamp: str = Field(..., description="命令生成时间戳,ISO 8601 格式(如:2024-01-01T12:00:00.000Z)") + sequence_id: int = Field(..., description="命令序列号,用于保证命令顺序和去重") + + # 命令配置映射字典 + _CONFIG_MAP = { + "takeoff": TAKEOFF_CONFIG, + "follow": FOLLOW_CONFIG, + "land": LAND_CONFIG, + "forward": FORWARD_CONFIG, + "backward": BACKWARD_CONFIG, + "left": LEFT_CONFIG, + "right": RIGHT_CONFIG, + "up": UP_CONFIG, + "down": DOWN_CONFIG, + "hover": HOVER_CONFIG, + "return_home": RETURN_HOME_CONFIG, + } + + # 创建命令 + @classmethod + def create( + cls, + command: str, + sequence_id: int, + distance: Optional[float] = None, + speed: Optional[float] = None, + duration: Optional[float] = None + ) -> "Command": + return cls( + command=command, + params=CommandParams(distance=distance, speed=speed, duration=duration), + timestamp=datetime.utcnow().isoformat() + "Z", + sequence_id=sequence_id, + ) + + def _get_default_config(self): + """获取当前命令的默认配置""" + return self._CONFIG_MAP.get(self.command) + + # 填充默认值 + def fill_defaults(self) -> None: + """填充缺失的参数值""" + # 如果所有参数都已提供,直接返回 + if (self.params.distance is not None and + self.params.speed is not None and + self.params.duration is not None): + return + + # 如果有缺失的参数,调用智能填充方法 + self._fill_smart_params() + + def _fill_smart_params(self): + """智能填充缺失的参数值""" + default = self._get_default_config() + if default is None: + # 若命令未知,则直接返回不填充 + return + + d = self.params.distance + s = self.params.speed + t = self.params.duration + + # 统计 None 个数 + none_cnt = sum(x is None for x in [d, s, t]) + + # 三个都为None,直接填默认值 + if none_cnt == 3: + self.params.distance = default["distance"] + self.params.speed = default["speed"] + self.params.duration = default["duration"] + return + + # 只有一个参数有值的情况 + if none_cnt == 2: + if s is not None and d is None and t is None: + # 仅速度:使用默认持续时间,计算距离 + self.params.duration = default["duration"] + self.params.distance = s * self.params.duration + return + + if t is not None and d is None and s is None: + # 仅持续时间:使用默认速度,计算距离 + self.params.speed = default["speed"] + self.params.distance = self.params.speed * t + return + + if d is not None and s is None and t is None: + # 仅距离:使用默认速度,计算持续时间 + self.params.speed = default["speed"] + # 防止除以0 + if self.params.speed == 0: + self.params.duration = 0 + else: + self.params.duration = d / self.params.speed + return + + # 两个参数有值,一个None,自动计算缺失的参数 + if none_cnt == 1: + if d is None and s is not None and t is not None: + # 缺失距离:distance = speed * duration + self.params.distance = s * t + return + + if s is None and d is not None and t is not None: + # 缺失速度:speed = distance / duration + if t == 0: + self.params.speed = 0 + else: + self.params.speed = d / t + return + + if t is None and d is not None and s is not None: + # 缺失持续时间:duration = distance / speed + if s == 0: + self.params.duration = 0 + else: + self.params.duration = d / s + return + + + # 转换为字典 + def to_dict(self) -> dict: + + result = { + "command": self.command, + "params": {}, + "timestamp": self.timestamp, + "sequence_id": self.sequence_id + } + + if self.params.distance is None or self.params.speed is None or self.params.duration is None: + self.fill_defaults() + + result["params"]["distance"] = self.params.distance + result["params"]["speed"] = self.params.speed + result["params"]["duration"] = self.params.duration + + return result + + + +if __name__ == "__main__": + command = Command.create("takeoff", 1, speed=2) + print(command.to_dict()) \ No newline at end of file diff --git a/voice_drone/core/configuration.py b/voice_drone/core/configuration.py new file mode 100644 index 0000000..969e134 --- /dev/null +++ b/voice_drone/core/configuration.py @@ -0,0 +1,209 @@ +import os +from pathlib import Path + +from voice_drone.tools.config_loader import load_config +from voice_drone.logging_ import get_logger + +_cfg_log = get_logger("voice_drone.configuration") + +# voice_drone/core/configuration.py -> 工程根目录 voice_drone_assistant 为 parents[2] +_PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _abs_config_path(relative: str) -> str: + p = Path(relative) + if p.is_absolute(): + return str(p) + return str(_PROJECT_ROOT / p) + + +# 系统配置加载器 +class SystemConfigLoader: + def __init__(self, config_path="voice_drone/config/system.yaml"): + self.config_path = _abs_config_path(config_path) + self.config = load_config(self.config_path) + + # 获取音频配置 + def get_audio_config(self): + return self.config["audio"] + + # 获取语音活动检测配置 + def get_vad_config(self): + return self.config["vad"] + + # 获取socket配置 + def get_socket_server_config(self): + return self.config["socket_server"] + + # 获取日志配置 + def get_logging_config(self): + return self.config["logging"] + + # 获取STT配置 + def get_stt_config(self): + return self.config["stt"] + + # 获取TTS配置 + def get_tts_config(self): + """ + 获取文本转语音(TTS)配置 + + 结构示例: + tts: + model_dir: "src/models/Kokoro-82M-v1.1-zh-ONNX" + model_name: "model_q4.onnx" # 可选: model.onnx, model_fp16.onnx, model_quantized.onnx 等 + voice: "zf_001" # 语音风格文件名(不含扩展名) + speed: 1.0 # 语速系数 + sample_rate: 24000 # 输出采样率 + """ + return self.config.get("tts", {}) + + # 获取文本预处理配置 + def get_text_preprocessor_config(self): + return self.config.get("text_preprocessor", {}) + + # 获取识别器流程配置 + def get_recognizer_config(self): + return self.config.get("recognizer", {}) + + # 云端语音(WebSocket / pcm_asr_uplink 会话) + def get_cloud_voice_config(self): + return self.config.get("cloud_voice", {}) + + # 主程序 TakeoffPrintRecognizer(main_app) + def get_assistant_config(self): + return self.config.get("assistant", {}) + +# 命令配置加载器 +class CommandConfigLoader: + def __init__(self, config_path="voice_drone/config/command_.yaml"): + self.config_path = _abs_config_path(config_path) + self.config = load_config(self.config_path)["control_params"] + + def get_takeoff_config(self): + return self.config["takeoff"] + def get_land_config(self): + return self.config["land"] + def get_follow_config(self): + return self.config["follow"] + def get_forward_config(self): + return self.config["forward"] + def get_backward_config(self): + return self.config["backward"] + def get_left_config(self): + return self.config["left"] + def get_right_config(self): + return self.config["right"] + def get_up_config(self): + return self.config["up"] + def get_down_config(self): + return self.config["down"] + def get_hover_config(self): + return self.config["hover"] + + def get_return_home_config(self): + return self.config["return_home"] + +# 关键词配置加载器 +class KeywordsConfigLoader: + def __init__(self, config_path="voice_drone/config/keywords.yaml"): + self.config_path = _abs_config_path(config_path) + self.config = load_config(self.config_path)["keywords"] + + def get_keywords(self): + return self.config + +# 唤醒词配置加载器 +class WakeWordConfigLoader: + def __init__(self, config_path="voice_drone/config/wake_word.yaml"): + self.config_path = _abs_config_path(config_path) + self.config = load_config(self.config_path)["wake_word"] + + def get_primary(self): + return self.config.get("primary", "") + + def get_variants(self): + return self.config.get("variants", []) + + def get_matching_config(self): + return self.config.get("matching", {}) + + +# 系统配置常量 +system_config = SystemConfigLoader() +SYSTEM_AUDIO_CONFIG = system_config.get_audio_config() +SYSTEM_VAD_CONFIG = system_config.get_vad_config() +SYSTEM_SOCKET_SERVER_CONFIG = system_config.get_socket_server_config() +SYSTEM_LOGGING_CONFIG = system_config.get_logging_config() +SYSTEM_STT_CONFIG = system_config.get_stt_config() +SYSTEM_TTS_CONFIG = system_config.get_tts_config() +SYSTEM_TEXT_PREPROCESSOR_CONFIG = system_config.get_text_preprocessor_config() +SYSTEM_RECOGNIZER_CONFIG = system_config.get_recognizer_config() +SYSTEM_CLOUD_VOICE_CONFIG = system_config.get_cloud_voice_config() +SYSTEM_ASSISTANT_CONFIG = system_config.get_assistant_config() + + +def load_cloud_voice_px4_context() -> dict: + """ + 加载合并到云端 session.start.client 的 PX4/MAV 扩展字段。 + 路径:环境变量 ROCKET_CLOUD_PX4_CONTEXT_FILE,否则 cloud_voice.px4_context_file(相对工程根)。 + """ + cv = SYSTEM_CLOUD_VOICE_CONFIG if isinstance(SYSTEM_CLOUD_VOICE_CONFIG, dict) else {} + raw = (os.environ.get("ROCKET_CLOUD_PX4_CONTEXT_FILE") or "").strip() + rel = raw or str(cv.get("px4_context_file") or "").strip() + if not rel: + return {} + p = Path(rel) + if not p.is_absolute(): + p = _PROJECT_ROOT / rel + if not p.is_file(): + _cfg_log.warning("cloud_voice PX4 上下文文件不存在,已跳过: %s", p) + return {} + try: + data = load_config(str(p)) + except Exception as e: # noqa: BLE001 + _cfg_log.warning("读取 PX4 上下文 YAML 失败: %s — %s", p, e) + return {} + if not isinstance(data, dict): + return {} + return data + + +SYSTEM_CLOUD_VOICE_PX4_CONTEXT = load_cloud_voice_px4_context() + +# 命令配置常量 +command_config = CommandConfigLoader() +TAKEOFF_CONFIG = command_config.get_takeoff_config() +LAND_CONFIG = command_config.get_land_config() +FOLLOW_CONFIG = command_config.get_follow_config() +FORWARD_CONFIG = command_config.get_forward_config() +BACKWARD_CONFIG = command_config.get_backward_config() +LEFT_CONFIG = command_config.get_left_config() +RIGHT_CONFIG = command_config.get_right_config() +UP_CONFIG = command_config.get_up_config() +DOWN_CONFIG = command_config.get_down_config() +HOVER_CONFIG = command_config.get_hover_config() +RETURN_HOME_CONFIG = command_config.get_return_home_config() + +# 关键词配置常量 +keywords_config = KeywordsConfigLoader() +KEYWORDS_CONFIG = keywords_config.get_keywords() + +# 唤醒词配置常量 +wake_word_config = WakeWordConfigLoader() +WAKE_WORD_PRIMARY = wake_word_config.get_primary() +WAKE_WORD_VARIANTS = wake_word_config.get_variants() +WAKE_WORD_MATCHING_CONFIG = wake_word_config.get_matching_config() + + +if __name__ == "__main__": + print(TAKEOFF_CONFIG) + print(LAND_CONFIG) + print(FORWARD_CONFIG) + print(BACKWARD_CONFIG) + print(LEFT_CONFIG) + print(RIGHT_CONFIG) + print(UP_CONFIG) + print(DOWN_CONFIG) + print(HOVER_CONFIG) + print(KEYWORDS_CONFIG) \ No newline at end of file diff --git a/voice_drone/core/flight_intent.py b/voice_drone/core/flight_intent.py new file mode 100644 index 0000000..dbac4d3 --- /dev/null +++ b/voice_drone/core/flight_intent.py @@ -0,0 +1,338 @@ +""" +flight_intent v1 校验与辅助(对齐 docs/FLIGHT_INTENT_SCHEMA_v1.md)。 + +兼容 Pydantic v2(field_validator / ConfigDict)。 + +互操作:部分云端会把「悬停 N 秒」写成 hover.args.duration;规范建议用 hover + wait.seconds。 +解析时会折叠为 hover(无 duration)+ wait(seconds),与机端执行一致。 +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, List, Literal, Optional, Tuple, Union + +from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator + +_COORD_CAP = 10_000.0 + + +def _check_coord(name: str, v: Optional[float]) -> Optional[float]: + if v is None: + return None + if not isinstance(v, (int, float)) or not math.isfinite(float(v)): + raise ValueError(f"{name} must be a finite number") + fv = float(v) + if abs(fv) > _COORD_CAP: + raise ValueError(f"{name} out of range (|.| <= {_COORD_CAP})") + return fv + + +# --- args ----------------------------------------------------------------- + + +class TakeoffArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + relative_altitude_m: Optional[float] = None + + @field_validator("relative_altitude_m") + @classmethod + def _alt(cls, v: Optional[float]) -> Optional[float]: + if v is not None and v <= 0: + raise ValueError("relative_altitude_m must be > 0 when set") + return v + + +class EmptyArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class HoverHoldArgs(BaseModel): + """hover / hold:规范仅 {}; 为兼容云端可带 duration(秒),解析后展开为 wait。""" + + model_config = ConfigDict(extra="forbid") + + duration: Optional[float] = None + + @field_validator("duration") + @classmethod + def _dur(cls, v: Optional[float]) -> Optional[float]: + if v is None: + return None + fv = float(v) + if not (0 < fv <= 3600): + raise ValueError("duration must satisfy 0 < duration <= 3600 when set") + return fv + + +class WaitArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + seconds: float + + @field_validator("seconds") + @classmethod + def _rng(cls, v: float) -> float: + if not (0 < v <= 3600): + raise ValueError("seconds must satisfy 0 < seconds <= 3600") + return v + + +class GotoArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + frame: str + x: Optional[float] = None + y: Optional[float] = None + z: Optional[float] = None + + @field_validator("frame") + @classmethod + def _frame(cls, v: str) -> str: + if v not in ("local_ned", "body_ned"): + raise ValueError('frame must be "local_ned" or "body_ned"') + return v + + @field_validator("x", "y", "z") + @classmethod + def _coord(cls, v: Optional[float], info: ValidationInfo) -> Optional[float]: + return _check_coord(str(info.field_name), v) + + +# --- actions -------------------------------------------------------------- + + +class ActionTakeoff(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["takeoff"] = "takeoff" + args: TakeoffArgs + + +class ActionLand(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["land"] = "land" + args: EmptyArgs + + +class ActionReturnHome(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["return_home"] = "return_home" + args: EmptyArgs + + +class ActionHover(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["hover"] = "hover" + args: HoverHoldArgs + + +class ActionHold(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["hold"] = "hold" + args: HoverHoldArgs + + +class ActionGoto(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["goto"] = "goto" + args: GotoArgs + + +class ActionWait(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["wait"] = "wait" + args: WaitArgs + + +FlightAction = Union[ + ActionTakeoff, + ActionLand, + ActionReturnHome, + ActionHover, + ActionHold, + ActionGoto, + ActionWait, +] + + +class FlightIntentPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_flight_intent: bool + version: int + actions: List[Any] + summary: str + trace_id: Optional[str] = None + + @field_validator("is_flight_intent") + @classmethod + def _flag(cls, v: bool) -> bool: + if v is not True: + raise ValueError("is_flight_intent must be true") + return v + + @field_validator("version") + @classmethod + def _ver(cls, v: int) -> int: + if v != 1: + raise ValueError("version must be 1") + return v + + @field_validator("summary") + @classmethod + def _sum(cls, v: str) -> str: + if not (isinstance(v, str) and v.strip()): + raise ValueError("summary must be non-empty") + return v + + @field_validator("trace_id") + @classmethod + def _tid(cls, v: Optional[str]) -> Optional[str]: + if v is not None and len(v) > 128: + raise ValueError("trace_id length must be <= 128") + return v + + @field_validator("actions") + @classmethod + def _actions_nonempty(cls, v: List[Any]) -> List[Any]: + if not isinstance(v, list) or len(v) == 0: + raise ValueError("actions must be a non-empty array") + return v + + +@dataclass +class ValidatedFlightIntent: + summary: str + trace_id: Optional[str] + actions: List[FlightAction] + + +def _parse_one_action(raw: dict) -> FlightAction: + t = raw.get("type") + if not isinstance(t, str): + raise ValueError("action.type must be a string") + args = raw.get("args") + if not isinstance(args, dict): + raise ValueError("action.args must be an object") + if t == "takeoff": + return ActionTakeoff(args=TakeoffArgs.model_validate(args)) + if t == "land": + return ActionLand(args=EmptyArgs.model_validate(args)) + if t == "return_home": + return ActionReturnHome(args=EmptyArgs.model_validate(args)) + if t == "hover": + return ActionHover(args=HoverHoldArgs.model_validate(args)) + if t == "hold": + return ActionHold(args=HoverHoldArgs.model_validate(args)) + if t == "goto": + return ActionGoto(args=GotoArgs.model_validate(args)) + if t == "wait": + return ActionWait(args=WaitArgs.model_validate(args)) + raise ValueError(f"unknown action.type: {t!r}") + + +def _expand_hover_duration(actions: List[FlightAction]) -> List[FlightAction]: + """将 hover/hold 上附带的 duration 转为标准 wait(seconds)。""" + out: List[FlightAction] = [] + for a in actions: + if isinstance(a, ActionHover): + d = a.args.duration + if d is not None: + out.append(ActionHover(args=HoverHoldArgs())) + out.append(ActionWait(args=WaitArgs(seconds=float(d)))) + else: + out.append(a) + elif isinstance(a, ActionHold): + d = a.args.duration + if d is not None: + out.append(ActionHold(args=HoverHoldArgs())) + out.append(ActionWait(args=WaitArgs(seconds=float(d)))) + else: + out.append(a) + else: + out.append(a) + return out + + +def parse_flight_intent_dict(data: dict) -> Tuple[Optional[ValidatedFlightIntent], List[str]]: + """ + L1–L3 校验。成功返回 (ValidatedFlightIntent, []);失败返回 (None, [错误信息, ...])。 + """ + errors: List[str] = [] + try: + top = FlightIntentPayload.model_validate(data) + except Exception as e: # noqa: BLE001 + return None, [str(e)] + + parsed_actions: List[FlightAction] = [] + for i, item in enumerate(top.actions): + if not isinstance(item, dict): + errors.append(f"actions[{i}] must be an object") + continue + try: + parsed_actions.append(_parse_one_action(item)) + except Exception as e: # noqa: BLE001 + errors.append(f"actions[{i}]: {e}") + + if errors: + return None, errors + + parsed_actions = _expand_hover_duration(parsed_actions) + + if isinstance(parsed_actions[0], ActionWait): + return None, ["first action must not be wait (nothing to control yet)"] + + return ( + ValidatedFlightIntent( + summary=top.summary.strip(), + trace_id=top.trace_id, + actions=parsed_actions, + ), + [], + ) + + +def goto_action_to_command(action: ActionGoto, sequence_id: int) -> Tuple[Optional[Any], Optional[str]]: + """ + 将单轴 goto 映射为现有 Socket Command。 + 返回 (Command | None, error_reason | None)。 + """ + from voice_drone.core.command import Command + + a = action.args + coords = [ + ("x", a.x), + ("y", a.y), + ("z", a.z), + ] + active = [(name, val) for name, val in coords if val is not None and val != 0] + if len(active) == 0: + return None, "goto: all axes omit or zero (no-op)" + if len(active) > 1: + return ( + None, + f"goto: multi-axis ({', '.join(n for n, _ in active)}) not sent via Socket " + "(use bridge or decompose)", + ) + + name, val = active[0] + dist = abs(float(val)) + body_map = { + "x": ("forward", "backward"), + "y": ("right", "left"), + "z": ("down", "up"), + } + pos, neg = body_map[name] + cmd_name = pos if val > 0 else neg + cmd = Command.create(cmd_name, sequence_id, distance=dist) + cmd.fill_defaults() + return cmd, None diff --git a/voice_drone/core/mic_device_select.py b/voice_drone/core/mic_device_select.py new file mode 100644 index 0000000..4f844ae --- /dev/null +++ b/voice_drone/core/mic_device_select.py @@ -0,0 +1,186 @@ +"""启动时列出 arecord -l 与 PyAudio 输入设备,并把 ALSA card/device 映射到 PyAudio 索引供交互选择。""" + +from __future__ import annotations + +import re +import subprocess +from typing import List, Optional, Tuple, Any + +from voice_drone.logging_ import get_logger + +logger = get_logger("mic_device_select") + + +def run_arecord_l() -> str: + try: + r = subprocess.run( + ["arecord", "-l"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + out = (r.stdout or "").rstrip() + err = (r.stderr or "").strip() + body = out + (f"\n{err}" if err else "") + return body.strip() if body.strip() else "(arecord 无输出)" + except FileNotFoundError: + return "(未找到 arecord,可安装 alsa-utils)" + except Exception as e: # noqa: BLE001 + return f"(执行 arecord -l 失败: {e})" + + +def parse_arecord_capture_lines(text: str) -> List[Tuple[int, int, str]]: + rows: List[Tuple[int, int, str]] = [] + for line in text.splitlines(): + m = re.search(r"card\s+(\d+):.+?,\s*device\s+(\d+):", line, re.IGNORECASE) + if m: + rows.append((int(m.group(1)), int(m.group(2)), line.strip())) + return rows + + +def _pyaudio_input_devices() -> List[Tuple[int, Any]]: + from voice_drone.core.portaudio_env import fix_ld_path_for_portaudio + + fix_ld_path_for_portaudio() + import pyaudio + + pa = pyaudio.PyAudio() + out: List[Tuple[int, Any]] = [] + try: + for i in range(pa.get_device_count()): + try: + inf = pa.get_device_info_by_index(i) + if int(inf.get("maxInputChannels", 0)) <= 0: + continue + out.append((i, inf)) + except Exception: + continue + return out + finally: + pa.terminate() + + +def match_alsa_hw_to_pyaudio_index( + card: int, + dev: int, + pa_items: List[Tuple[int, Any]], +) -> Optional[int]: + want1 = f"(hw:{card},{dev})" + want2 = f"(hw:{card}, {dev})" + for idx, inf in pa_items: + name = str(inf.get("name", "")) + if want1 in name or want2 in name: + return idx + return None + + +def print_mic_device_menu() -> List[int]: + """ + 打印 arecord + PyAudio + 映射表。 + 返回本菜单中列出的 PyAudio 索引列表(顺序与 [1]、[2]… 一致)。 + """ + alsa_text = run_arecord_l() + pa_items = _pyaudio_input_devices() + + print("\n" + "=" * 72, flush=True) + print("录音设备(先 ALSA,再 PortAudio;请记下要用的 PyAudio 索引)", flush=True) + print("=" * 72, flush=True) + print("\n--- arecord -l(系统硬件视角)---\n", flush=True) + print(alsa_text, flush=True) + + print("\n--- PyAudio 可录音设备(maxInputChannels > 0)---\n", flush=True) + ordered_indices: List[int] = [] + if not pa_items: + print("(无)\n", flush=True) + for rank, (idx, inf) in enumerate(pa_items, start=1): + ordered_indices.append(idx) + mic = int(inf.get("maxInputChannels", 0)) + outc = int(inf.get("maxOutputChannels", 0)) + name = str(inf.get("name", "?")) + print( + f" [{rank}] PyAudio_index={idx} in={mic} out={outc} {name}", + flush=True, + ) + + alsa_rows = parse_arecord_capture_lines(alsa_text) + print( + "\n--- 映射:arecord 的 card / device → PyAudio 索引(匹配设备名中的 hw:X,Y)---\n", + flush=True, + ) + if not alsa_rows: + print(" (未解析到 card/device 行,请直接用上一表的 PyAudio_index)", flush=True) + for card, dev, line in alsa_rows: + pidx = match_alsa_hw_to_pyaudio_index(card, dev, pa_items) + short = line if len(line) <= 76 else line[:73] + "..." + if pidx is not None: + print( + f" card {card}, device {dev} → PyAudio 索引 {pidx}\n {short}", + flush=True, + ) + else: + print( + f" card {card}, device {dev} → (无 in>0 设备名含 hw:{card},{dev})\n {short}", + flush=True, + ) + + print( + "\n说明:程序只会用「一个」PyAudio 索引打开麦克风;" + "HDMI 等若 in=0 不会出现在可录音列表。\n" + + "=" * 72 + + "\n", + flush=True, + ) + return ordered_indices + + +def prompt_for_input_device_index() -> int: + """交互式选择,返回写入 audio.input_device_index 的 PyAudio 索引。""" + ordered = print_mic_device_menu() + if not ordered: + print("错误:没有发现可录音的 PyAudio 设备。", flush=True) + raise SystemExit(2) + + valid_set = set(ordered) + print( + "请输入菜单序号 [1-" + f"{len(ordered)}](推荐),或直接输入 PyAudio_index 数字;q 退出。", + flush=True, + ) + while True: + try: + raw = input("录音设备> ").strip() + except EOFError: + raise SystemExit(1) from None + if not raw: + continue + if raw.lower() in ("q", "quit", "exit"): + raise SystemExit(0) + if not raw.isdigit(): + print("请输入正整数或 q。", flush=True) + continue + n = int(raw) + if 1 <= n <= len(ordered): + chosen = ordered[n - 1] + print(f"已选择:菜单 [{n}] → PyAudio 索引 {chosen}\n", flush=True) + logger.info("交互选择录音设备 PyAudio index=%s", chosen) + return chosen + if n in valid_set: + print(f"已选择:PyAudio 索引 {n}\n", flush=True) + logger.info("交互选择录音设备 PyAudio index=%s", n) + return n + print( + f"无效:{n} 不在可选列表。可选序号为 1~{len(ordered)}," + f"或索引之一 {sorted(valid_set)}。", + flush=True, + ) + + +def apply_input_device_index_only(index: int) -> None: + """写入运行时配置:仅用索引选设备,其余 yaml 中的 hw/名称匹配不再参与 logic。""" + from voice_drone.core.configuration import SYSTEM_AUDIO_CONFIG + + SYSTEM_AUDIO_CONFIG["input_device_index"] = int(index) + SYSTEM_AUDIO_CONFIG["input_hw_card_device"] = None + SYSTEM_AUDIO_CONFIG["input_device_name_match"] = None + SYSTEM_AUDIO_CONFIG["input_strict_selection"] = False diff --git a/voice_drone/core/portaudio_env.py b/voice_drone/core/portaudio_env.py new file mode 100644 index 0000000..50e6b91 --- /dev/null +++ b/voice_drone/core/portaudio_env.py @@ -0,0 +1,60 @@ +"""PortAudio/PyAudio 启动前调整动态库搜索路径。 + +conda 环境下 `.../envs/xxx/lib` 里的 libasound 会到同前缀的 alsa-lib 子目录找插件, +该目录常缺 libasound_module_*.so,日志里刷屏且采集电平可能异常。 + +处理:1) 去掉仅含插件目录的路径;2) 把系统 /usr/lib/ 插到 LD_LIBRARY_PATH 最前, +让动态链接优先用系统的 libasound。""" +from __future__ import annotations + +import os +import platform + + +def strip_conda_alsa_from_ld_library_path() -> None: + ld = os.environ.get("LD_LIBRARY_PATH", "") + if not ld: + return + parts: list[str] = [] + for p in ld.split(":"): + if not p: + continue + pl = p.lower() + if "conda" in pl or "miniconda" in pl or "mamba" in pl: + if "alsa-lib" in pl or "alsa_lib" in pl: + continue + parts.append(p) + if parts: + os.environ["LD_LIBRARY_PATH"] = ":".join(parts) + else: + os.environ.pop("LD_LIBRARY_PATH", None) + + +def prepend_system_lib_dirs_for_alsa() -> None: + """Linux:把系统 lib 目录放在 LD_LIBRARY_PATH 最前面。""" + if platform.system() != "Linux": + return + triplet = { + "aarch64": ("/usr/lib/aarch64-linux-gnu", "/lib/aarch64-linux-gnu"), + "x86_64": ("/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"), + "amd64": ("/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"), + }.get(platform.machine().lower()) + if not triplet: + return + prepend: list[str] = [] + for d in triplet: + if os.path.isdir(d): + prepend.append(d) + if not prepend: + return + rest = [p for p in os.environ.get("LD_LIBRARY_PATH", "").split(":") if p] + out: list[str] = [] + for p in prepend + rest: + if p not in out: + out.append(p) + os.environ["LD_LIBRARY_PATH"] = ":".join(out) + + +def fix_ld_path_for_portaudio() -> None: + prepend_system_lib_dirs_for_alsa() + strip_conda_alsa_from_ld_library_path() diff --git a/voice_drone/core/qwen_intent_chat.py b/voice_drone/core/qwen_intent_chat.py new file mode 100644 index 0000000..740837d --- /dev/null +++ b/voice_drone/core/qwen_intent_chat.py @@ -0,0 +1,115 @@ +"""与 scripts/qwen_flight_intent_sim.py 对齐:飞控意图 JSON vs 闲聊,供语音主程序内调用。""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any, Optional, Tuple + +# 与 qwen_flight_intent_sim._SYSTEM 保持一致 +FLIGHT_INTENT_CHAT_SYSTEM = """你是无人机飞控意图助手,只做两件事(必须二选一): + +【规则 A — 飞控相关】当用户话里包含对无人机的飞行任务、航线、起降、返航、悬停、等待、速度高度、坐标点、offboard、PX4/MAVROS 等操作意图时: +只输出一行 JSON,且不要有任何其它字符、不要 Markdown、不要代码块。 +JSON Schema 含义(见仓库 docs/FLIGHT_INTENT_SCHEMA_v1.md): +{ + "is_flight_intent": true, + "version": 1, + "actions": [ // 按时间顺序排列 + {"type": "takeoff", "args": {}}, + {"type": "takeoff", "args": {"relative_altitude_m": number}}, + {"type": "goto", "args": {"frame": "local_ned"|"body_ned", "x": number|null, "y": number|null, "z": number|null}}, + {"type": "land" | "return_home" | "hover" | "hold", "args": {}}, + {"type": "wait", "args": {"seconds": number}} + ], + "summary": "一句话概括", + "trace_id": "可选,简短追踪ID" +} +- 停多久、延迟多久必须用 wait,例如「悬停 3 秒再降落」应为 hover → wait(3) → land;不要把秒数写进 summary 代替 wait。 +- 坐标缺省 frame 时用 "local_ned";无法确定的数字可省略字段或用 null。 +- 返程/返航映射为 {"type":"return_home","args":{}}。 +- 仅允许小写 type;args 只含规范允许键,禁止多余键。 + +【规则 B — 非飞控】若只是日常聊天、与无人机任务无关:用正常的自然中文回复,不要输出 JSON,不要用花括号开头。""" + + +def _strip_fenced_json(text: str) -> str: + text = text.strip() + m = re.match(r"^```(?:json)?\s*\n?(.*)\n?```\s*$", text, re.DOTALL | re.IGNORECASE) + if m: + return m.group(1).strip() + return text + + +def _first_balanced_json_object(text: str) -> Optional[str]: + t = _strip_fenced_json(text) + start = t.find("{") + if start < 0: + return None + depth = 0 + for i in range(start, len(t)): + if t[i] == "{": + depth += 1 + elif t[i] == "}": + depth -= 1 + if depth == 0: + return t[start : i + 1] + return None + + +def parse_flight_intent_reply(raw: str) -> Tuple[str, Optional[dict[str, Any]]]: + """返回 (模式标签, 若为飞控则 dict 否则 None)。""" + chunk = _first_balanced_json_object(raw) + if chunk: + try: + obj = json.loads(chunk) + except json.JSONDecodeError: + return "闲聊", None + if isinstance(obj, dict) and obj.get("is_flight_intent") is True: + return "飞控意图JSON", obj + return "闲聊", None + + +def default_qwen_gguf_path(project_root: Path) -> Path: + """子工程优先本目录 cache/;不存在时回退到上级仓库(同级 rocket_drone_audio/cache/)。""" + name = "qwen2.5-1.5b-instruct-q4_k_m.gguf" + primary = project_root / "cache" / "qwen25-1.5b-gguf" / name + if primary.is_file(): + return primary + legacy = project_root.parent / "cache" / "qwen25-1.5b-gguf" / name + if legacy.is_file(): + return legacy + return primary + + +def load_llama_qwen( + model_path: Path, + n_ctx: int = 4096, +): + """ + llama-cpp-python 封装。可选环境变量(详见 rocket_drone_audio 文件头): + ROCKET_LLM_N_THREADS、ROCKET_LLM_N_GPU_LAYERS、ROCKET_LLM_N_BATCH。 + """ + if not model_path.is_file(): + return None + try: + from llama_cpp import Llama + except ImportError: + return None + opts: dict = { + "model_path": str(model_path), + "n_ctx": int(n_ctx), + "verbose": False, + } + nt = os.environ.get("ROCKET_LLM_N_THREADS", "").strip() + if nt.isdigit() or (nt.startswith("-") and nt[1:].isdigit()): + opts["n_threads"] = max(1, int(nt)) + ng = os.environ.get("ROCKET_LLM_N_GPU_LAYERS", "").strip() + if ng.isdigit(): + opts["n_gpu_layers"] = int(ng) + nb = os.environ.get("ROCKET_LLM_N_BATCH", "").strip() + if nb.isdigit(): + opts["n_batch"] = max(1, int(nb)) + return Llama(**opts) diff --git a/voice_drone/core/recognizer.py b/voice_drone/core/recognizer.py new file mode 100644 index 0000000..6f1536b --- /dev/null +++ b/voice_drone/core/recognizer.py @@ -0,0 +1,969 @@ +""" +高性能实时语音识别与命令生成系统 + +整合所有模块,实现从语音检测到命令发送的完整流程: +1. 音频采集(高性能模式) +2. 音频预处理(降噪+AGC) +3. VAD语音活动检测 +4. STT语音识别 +5. 文本预处理(纠错+参数提取) +6. 命令生成 +7. Socket发送 + +性能优化: +- 多线程异步处理 +- 非阻塞音频采集 +- LRU缓存优化 +- 低延迟设计 +""" + +import math +import numpy as np +import os +import random +import threading +import queue +import time +from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING +from voice_drone.core.audio import AudioCapture, AudioPreprocessor +from voice_drone.core.vad import VAD +from voice_drone.core.stt import STT +from voice_drone.core.text_preprocessor import TextPreprocessor, get_preprocessor +from voice_drone.core.command import Command +from voice_drone.core.scoket_client import SocketClient +from voice_drone.core.configuration import ( + SYSTEM_AUDIO_CONFIG, + SYSTEM_RECOGNIZER_CONFIG, + SYSTEM_SOCKET_SERVER_CONFIG, +) +from voice_drone.core.tts_ack_cache import ( + compute_ack_pcm_fingerprint, + load_cached_phrases, + persist_phrases, +) +from voice_drone.core.wake_word import WakeWordDetector, get_wake_word_detector +from voice_drone.logging_ import get_logger + +if TYPE_CHECKING: + from voice_drone.core.tts import KokoroOnnxTTS + +logger = get_logger("recognizer") + + +class VoiceCommandRecognizer: + """ + 高性能实时语音命令识别器 + + 完整的语音转命令系统,包括: + - 音频采集和预处理 + - 语音活动检测 + - 语音识别 + - 文本预处理和参数提取 + - 命令生成 + - Socket发送 + """ + + def __init__(self, auto_connect_socket: bool = True): + """ + 初始化语音命令识别器 + + Args: + auto_connect_socket: 是否自动连接Socket服务器 + """ + logger.info("初始化语音命令识别系统...") + + # 初始化各模块 + self.audio_capture = AudioCapture() + self.audio_preprocessor = AudioPreprocessor() + self.vad = VAD() + self.stt = STT() + self.text_preprocessor = get_preprocessor() # 使用全局单例 + self.wake_word_detector = get_wake_word_detector() # 使用全局单例 + + # Socket客户端 + self.socket_client = SocketClient(SYSTEM_SOCKET_SERVER_CONFIG) + self.auto_connect_socket = auto_connect_socket + if self.auto_connect_socket: + if not self.socket_client.connect(): + logger.warning("Socket连接失败,将在发送命令时重试") + + # 语音段缓冲区 + self.speech_buffer: list = [] # 存储语音音频块 + self.speech_buffer_lock = threading.Lock() + + # 预缓冲区:保存语音检测前一小段音频,避免丢失开头 + # 例如:pre_speech_max_seconds = 0.8 表示保留最近约 0.8 秒音频 + self.pre_speech_buffer: list = [] # 存储最近的静音/背景音块 + # 从系统配置读取(确保类型正确:YAML 可能把数值当字符串) + self.pre_speech_max_seconds: float = float( + SYSTEM_RECOGNIZER_CONFIG.get("pre_speech_max_seconds", 0.8) + ) + self.pre_speech_max_chunks: Optional[int] = None # 根据采样率和chunk大小动态计算 + + # 命令发送成功后的 TTS 反馈(懒加载 Kokoro,避免拖慢启动) + self.ack_tts_enabled = bool(SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_enabled", True)) + self.ack_tts_text = str(SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_text", "好的收到")).strip() + self.ack_tts_phrases: Dict[str, List[str]] = self._normalize_ack_tts_phrases( + SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_phrases") + ) + # True:仅 ack_tts_phrases 中出现的命令会播报,且每次随机一句;False:全局 ack_tts_text(所有成功命令同一应答) + self._ack_mode_phrases: bool = bool(self.ack_tts_phrases) + self.ack_tts_prewarm = bool(SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_prewarm", True)) + self.ack_tts_prewarm_blocking = bool( + SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_prewarm_blocking", True) + ) + self.ack_pause_mic_for_playback = bool( + SYSTEM_RECOGNIZER_CONFIG.get("ack_pause_mic_for_playback", True) + ) + self.ack_tts_disk_cache = bool( + SYSTEM_RECOGNIZER_CONFIG.get("ack_tts_disk_cache", True) + ) + self._tts_engine: Optional["KokoroOnnxTTS"] = None + # 阻塞预加载时缓存波形:全局单句 _tts_ack_pcm,或按命令随机模式下的 _tts_phrase_pcm_cache(每句一条) + self._tts_ack_pcm: Optional[Tuple[np.ndarray, int]] = None + self._tts_phrase_pcm_cache: Dict[str, Tuple[np.ndarray, int]] = {} + self._tts_lock = threading.Lock() + # 命令线程只入队,主线程 process_audio_stream 中统一播放(避免 Windows 下后台线程 sd.play 无声) + self._ack_playback_queue: queue.Queue = queue.Queue(maxsize=8) + + # STT识别线程和队列 + self.stt_queue = queue.Queue(maxsize=5) # STT识别队列 + self.stt_thread: Optional[threading.Thread] = None + + # 命令处理线程和队列 + self.command_queue = queue.Queue(maxsize=10) # 命令处理队列 + self.command_thread: Optional[threading.Thread] = None + + # 运行状态 + self.running = False + + # 命令序列号(用于去重和顺序保证) + self.sequence_id = 0 + self.sequence_lock = threading.Lock() + + logger.info( + f"应答TTS配置: enabled={self.ack_tts_enabled}, " + f"mode={'按命令随机短语' if self._ack_mode_phrases else '全局固定文案'}, " + f"prewarm_blocking={self.ack_tts_prewarm_blocking}, " + f"pause_mic={self.ack_pause_mic_for_playback}, " + f"disk_cache={self.ack_tts_disk_cache}" + ) + if self._ack_mode_phrases: + logger.info(f" 仅播报命令: {list(self.ack_tts_phrases.keys())}") + + # VAD 后端:silero(默认)或 energy(按块 RMS,Silero 在部分板载麦上长期无段时使用) + _ev_env = os.environ.get("ROCKET_ENERGY_VAD", "").lower() in ( + "1", + "true", + "yes", + ) + _yaml_backend = str( + SYSTEM_RECOGNIZER_CONFIG.get("vad_backend", "silero") + ).lower() + self._use_energy_vad: bool = _ev_env or _yaml_backend == "energy" + self._energy_rms_high: float = float( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_rms_high", 280) + ) + self._energy_rms_low: float = float( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_rms_low", 150) + ) + self._energy_start_chunks: int = int( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_start_chunks", 4) + ) + self._energy_end_chunks: int = int( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_end_chunks", 15) + ) + # 高噪底/AGC 下 RMS 几乎不低于 energy_vad_rms_low 时,用「相对本段峰值」辅助判停 + self._energy_end_peak_ratio: float = float( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_end_peak_ratio", 0.88) + ) + # 说话过程中对 utt 峰值每块乘衰减再与当前 rms 取 max,避免前几个字特响导致后半句一直被判「相对衰减」而误切段 + self._energy_utt_peak_decay: float = float( + SYSTEM_RECOGNIZER_CONFIG.get("energy_vad_utt_peak_decay", 0.988) + ) + self._energy_utt_peak_decay = max(0.95, min(0.9999, self._energy_utt_peak_decay)) + self._ev_speaking: bool = False + self._ev_high_run: int = 0 + self._ev_low_run: int = 0 + self._ev_rms_peak: float = 0.0 + self._ev_last_diag_time: float = 0.0 + self._ev_utt_peak: float = 0.0 + # 可选:能量 VAD 刚进入「正在说话」时回调(用于机端 PROMPT_LISTEN 计时清零等) + self._vad_speech_start_hook: Optional[Callable[[], None]] = None + + _trail_raw = SYSTEM_RECOGNIZER_CONFIG.get("trailing_silence_seconds") + if _trail_raw is not None: + _trail = float(_trail_raw) + if _trail > 0: + fs = int(SYSTEM_AUDIO_CONFIG.get("frame_size", 1024)) + sr = int(SYSTEM_AUDIO_CONFIG.get("sample_rate", 16000)) + if fs > 0 and sr > 0: + n_end = max(1, int(math.ceil(_trail * sr / fs))) + self._energy_end_chunks = n_end + self.vad.silence_end_frames = n_end + logger.info( + "VAD 句尾切段:trailing_silence_seconds=%.2f → 连续静音块数=%d " + "(每块≈%.0fms,Silero 与 energy 共用)", + _trail, + n_end, + 1000.0 * fs / sr, + ) + + if self._use_energy_vad: + logger.info( + "VAD 后端: energy(RMS)" + f" high={self._energy_rms_high} low={self._energy_rms_low} " + f"start_chunks={self._energy_start_chunks} end_chunks={self._energy_end_chunks}" + f" end_peak_ratio={self._energy_end_peak_ratio}" + f" utt_peak_decay={self._energy_utt_peak_decay}" + ) + + logger.info("语音命令识别系统初始化完成") + + @staticmethod + def _normalize_ack_tts_phrases(raw) -> Dict[str, List[str]]: + """YAML: ack_tts_phrases: { takeoff: [\"...\", ...], ... }""" + result: Dict[str, List[str]] = {} + if not isinstance(raw, dict): + return result + for k, v in raw.items(): + key = str(k).strip() + if not key: + continue + if isinstance(v, list): + phrases = [str(x).strip() for x in v if str(x).strip()] + elif isinstance(v, str) and v.strip(): + phrases = [v.strip()] + else: + phrases = [] + if phrases: + result[key] = phrases + return result + + def _has_ack_tts_content(self) -> bool: + if self._ack_mode_phrases: + return any(bool(v) for v in self.ack_tts_phrases.values()) + return bool(self.ack_tts_text) + + def _pick_ack_phrase(self, command_name: str) -> Optional[str]: + if self._ack_mode_phrases: + phrases = self.ack_tts_phrases.get(command_name) + if not phrases: + return None + return random.choice(phrases) + return self.ack_tts_text or None + + def _get_cached_pcm_for_phrase(self, phrase: str) -> Optional[Tuple[np.ndarray, int]]: + """若启动阶段已预合成该句,则返回缓存,播报时不再跑 ONNX(低延迟)。""" + if self._ack_mode_phrases: + return self._tts_phrase_pcm_cache.get(phrase) + if self._tts_ack_pcm is not None: + return self._tts_ack_pcm + return None + + def _ensure_tts_engine(self) -> "KokoroOnnxTTS": + """懒加载 Kokoro(双检锁,避免多线程重复加载)。""" + from voice_drone.core.tts import KokoroOnnxTTS + + if self._tts_engine is not None: + return self._tts_engine + with self._tts_lock: + if self._tts_engine is None: + logger.info("TTS: 正在加载 Kokoro 模型(首次约需十余秒)…") + self._tts_engine = KokoroOnnxTTS() + logger.info("TTS: Kokoro 模型加载完成") + assert self._tts_engine is not None + return self._tts_engine + + def _enqueue_ack_playback(self, command_name: str) -> None: + """ + 命令已成功发出后,将待播音频交给主线程队列。 + + 不在此线程直接调用 sounddevice:Windows 上后台线程常出现播放完全无声。 + """ + if not self.ack_tts_enabled: + return + phrase = self._pick_ack_phrase(command_name) + if not phrase: + return + try: + cached = self._get_cached_pcm_for_phrase(phrase) + if cached is not None: + audio, sr = cached + self._ack_playback_queue.put(("pcm", audio.copy(), sr), block=False) + logger.info( + f"命令已发送,已排队语音应答(主线程播放,预缓存): {phrase!r}" + ) + print(f"[TTS] 已排队语音应答(主线程播放,预缓存): {phrase!r}", flush=True) + else: + self._ack_playback_queue.put(("synth", phrase), block=False) + logger.info( + f"命令已发送,已排队语音应答(主线程合成+播放,无缓存,可能有数秒延迟): {phrase!r}" + ) + print( + f"[TTS] 已排队语音应答(主线程合成+播放,无缓存): {phrase!r}", + flush=True, + ) + except queue.Full: + logger.warning("应答语音播放队列已满,跳过本次") + + def _before_audio_iteration(self) -> None: + """主循环每轮开头(主线程):子类可扩展以播放其它排队 TTS。""" + self._drain_ack_playback_queue() + + def _drain_ack_playback_queue(self, recover_mic: bool = True) -> None: + """在主线程中播放队列中的应答(与麦克风采集同进程、同主循环线程)。 + + Args: + recover_mic: 播完后是否恢复麦克风;退出 shutdown 时应为 False,避免与 stop() 中关流冲突。 + """ + from voice_drone.core.tts import play_tts_audio, speak_text + + items: list = [] + while True: + try: + items.append(self._ack_playback_queue.get_nowait()) + except queue.Empty: + break + if not items: + return + + mic_stopped = False + if self.ack_pause_mic_for_playback: + try: + logger.info( + "TTS: 已暂停麦克风采集以便扬声器播放(避免 Windows 下输入/输出同时开无声)" + ) + self.audio_capture.stop_stream() + mic_stopped = True + except Exception as e: + logger.warning(f"暂停麦克风失败,将尝试直接播放: {e}") + + try: + for item in items: + try: + kind = item[0] + if kind == "pcm": + _, audio, sr = item + logger.info("TTS: 主线程播放应答(预缓存波形)") + play_tts_audio(audio, sr) + logger.info("TTS: 播放完成") + elif kind == "synth": + logger.info("TTS: 主线程合成并播放应答(无预缓存)") + tts = self._ensure_tts_engine() + text = item[1] if len(item) >= 2 else (self.ack_tts_text or "") + speak_text(text, tts=tts) + except Exception as e: + logger.warning(f"应答语音播放失败: {e}", exc_info=True) + finally: + if mic_stopped and recover_mic: + try: + self.audio_capture.start_stream() + try: + self.audio_preprocessor.reset() + except Exception as e: # noqa: BLE001 + logger.debug("audio_preprocessor.reset: %s", e) + # TTS 暂停期间若未凑齐「尾静音」帧,VAD 会一直保持 is_speaking=True; + # 恢复后 detect_speech_start 会直接放弃,表现为「能恢复采集但再也不识别」。 + self.vad.reset() + with self.speech_buffer_lock: + self.speech_buffer.clear() + self.pre_speech_buffer.clear() + logger.info("TTS: 麦克风采集已恢复(已重置 VAD 与语音缓冲)") + except Exception as e: + logger.error(f"麦克风采集恢复失败,请重启程序: {e}", exc_info=True) + + def _prewarm_tts_async(self) -> None: + """后台预加载 TTS(仅当未使用阻塞预加载时)。""" + if not self.ack_tts_enabled or not self._has_ack_tts_content() or not self.ack_tts_prewarm: + return + + def _run() -> None: + try: + self._ensure_tts_engine() + if self._ack_mode_phrases: + logger.warning( + "TTS: 当前为「按命令随机短语」且未使用阻塞预加载," + "各句首次播报可能仍有数秒延迟;若需低延迟请将 ack_tts_prewarm_blocking 设为 true。" + ) + except Exception as e: + logger.warning(f"TTS 预加载失败(将在首次播报时重试): {e}", exc_info=True) + + threading.Thread(target=_run, daemon=True, name="tts-prewarm").start() + + def _prewarm_tts_blocking(self) -> None: + """启动时准备应答 PCM:优先读磁盘缓存(文案与 TTS 配置未变则跳过合成);必要时加载 Kokoro 并合成。""" + if not self.ack_tts_enabled or not self._has_ack_tts_content() or not self.ack_tts_prewarm: + return + use_disk = self.ack_tts_disk_cache + logger.info("TTS: 正在准备语音反馈(磁盘缓存 / 合成)…") + print("正在加载语音反馈…") + try: + if self._ack_mode_phrases: + self._tts_phrase_pcm_cache.clear() + seen: set = set() + unique: List[str] = [] + for lst in self.ack_tts_phrases.values(): + for t in lst: + p = str(t).strip() + if p and p not in seen: + seen.add(p) + unique.append(p) + if not unique: + return + + fingerprint = compute_ack_pcm_fingerprint(unique, mode_phrases=True) + missing = list(unique) + if use_disk: + loaded, missing = load_cached_phrases(unique, fingerprint) + for ph, pcm in loaded.items(): + self._tts_phrase_pcm_cache[ph] = pcm + + if not missing: + self._tts_ack_pcm = None + logger.info( + "TTS: 已从磁盘加载全部应答波形(%d 句),跳过 Kokoro 加载与合成", + len(unique), + ) + print("语音反馈已就绪(本地缓存),可以开始说话下指令。") + return + + self._ensure_tts_engine() + assert self._tts_engine is not None + need = [p for p in unique if p not in self._tts_phrase_pcm_cache] + for j, phrase in enumerate(need, start=1): + logger.info( + f"TTS: 合成应答句 {j}/{len(need)}: {phrase!r}" + ) + audio, sr = self._tts_engine.synthesize(phrase) + self._tts_phrase_pcm_cache[phrase] = (audio, sr) + self._tts_ack_pcm = None + if use_disk: + persist_phrases(fingerprint, dict(self._tts_phrase_pcm_cache)) + logger.info( + "TTS: 语音反馈已就绪(随机应答已缓存,播报低延迟)" + ) + print("语音反馈引擎已就绪,可以开始说话下指令。") + else: + text = (self.ack_tts_text or "").strip() + if not text: + return + fingerprint = compute_ack_pcm_fingerprint( + [], global_text=text, mode_phrases=False + ) + missing = [text] + if use_disk: + loaded, missing = load_cached_phrases([text], fingerprint) + if text in loaded: + self._tts_ack_pcm = loaded[text] + + if not missing: + logger.info( + "TTS: 已从磁盘加载全局应答波形,跳过 Kokoro 加载与合成" + ) + print("语音反馈已就绪(本地缓存),可以开始说话下指令。") + return + + self._ensure_tts_engine() + assert self._tts_engine is not None + audio, sr = self._tts_engine.synthesize(text) + self._tts_ack_pcm = (audio, sr) + if use_disk: + persist_phrases(fingerprint, {text: self._tts_ack_pcm}) + logger.info( + "TTS: 语音反馈引擎已就绪;已缓存应答语音,命令成功后将快速播报" + ) + print("语音反馈引擎已就绪,可以开始说话下指令。") + except Exception as e: + logger.warning( + f"TTS: 启动阶段预加载失败,命令成功后可能延迟或无语音反馈: {e}", + exc_info=True, + ) + + @staticmethod + def _init_sounddevice_output_probe() -> None: + """在主线程探测默认输出设备;应答播报必须在主线程调用 sd.play。""" + try: + from voice_drone.core.tts import log_sounddevice_output_devices + + log_sounddevice_output_devices() + import sounddevice as sd # type: ignore + + from voice_drone.core.tts import _sounddevice_default_output_index + + out_idx = _sounddevice_default_output_index() + if out_idx is not None and int(out_idx) >= 0: + info = sd.query_devices(int(out_idx)) + logger.info( + f"sounddevice 默认输出设备: {info.get('name', '?')} (index={out_idx})" + ) + sd.check_output_settings(samplerate=24000, channels=1, dtype="float32") + # 预解析 tts.output_device,启动日志中可见实际用于播放的设备 + from voice_drone.core.tts import get_playback_output_device_id + + get_playback_output_device_id() + except Exception as e: + logger.warning(f"sounddevice 输出设备探测失败,可能导致无法播音: {e}") + + def _get_next_sequence_id(self) -> int: + """获取下一个命令序列号""" + with self.sequence_lock: + self.sequence_id += 1 + return self.sequence_id + + @staticmethod + def _int16_chunk_rms(chunk: np.ndarray) -> float: + if chunk.size == 0: + return 0.0 + return float(np.sqrt(np.mean(chunk.astype(np.float64) ** 2))) + + def _submit_concatenated_speech_to_stt(self) -> None: + """在持有 speech_buffer_lock 时调用:合并 speech_buffer 并送 STT,然后清空。""" + if len(self.speech_buffer) == 0: + return + speech_audio = np.concatenate(self.speech_buffer) + self.speech_buffer.clear() + min_samples = int(self.audio_capture.sample_rate * 0.5) + if len(speech_audio) >= min_samples: + try: + self.stt_queue.put(speech_audio.copy(), block=False) + logger.debug( + f"提交语音段到STT队列,长度: {len(speech_audio)} 采样点" + ) + if os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[VAD] 已送 STT,{len(speech_audio)} 采样点(≈{len(speech_audio) / float(self.audio_capture.sample_rate):.2f}s)", + flush=True, + ) + except queue.Full: + logger.warning("STT队列已满,跳过本次识别") + elif os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[VAD] 语音段太短已丢弃({len(speech_audio)} < {min_samples} 采样)", + flush=True, + ) + + def _energy_vad_on_chunk(self, processed_chunk: np.ndarray) -> None: + rms = self._int16_chunk_rms(processed_chunk) + _vad_diag = os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ) + if _vad_diag: + self._ev_rms_peak = max(self._ev_rms_peak, rms) + now = time.monotonic() + if now - self._ev_last_diag_time >= 3.0: + print( + f"[VAD] energy 诊断:近 3s 块 RMS 峰值≈{self._ev_rms_peak:.0f} " + f"(high={self._energy_rms_high} low={self._energy_rms_low})", + flush=True, + ) + self._ev_rms_peak = 0.0 + self._ev_last_diag_time = now + + if not self._ev_speaking: + if rms >= self._energy_rms_high: + self._ev_high_run += 1 + else: + self._ev_high_run = 0 + if self._ev_high_run >= self._energy_start_chunks: + self._ev_speaking = True + self._ev_high_run = 0 + self._ev_low_run = 0 + self._ev_utt_peak = rms + hook = self._vad_speech_start_hook + if hook is not None: + try: + hook() + except Exception as e: # noqa: BLE001 + logger.debug("vad_speech_start_hook: %s", e, exc_info=True) + with self.speech_buffer_lock: + if self.pre_speech_buffer: + self.speech_buffer = list(self.pre_speech_buffer) + else: + self.speech_buffer.clear() + self.speech_buffer.append(processed_chunk) + logger.debug( + "energy VAD: 开始收集语音段(含预缓冲约 %.2f s)", + self.pre_speech_max_seconds, + ) + return + + with self.speech_buffer_lock: + self.speech_buffer.append(processed_chunk) + + self._ev_utt_peak = max(rms, self._ev_utt_peak * self._energy_utt_peak_decay) + below_abs = rms <= self._energy_rms_low + below_rel = ( + self._energy_end_peak_ratio > 0 + and self._ev_utt_peak >= self._energy_rms_high + and rms <= self._ev_utt_peak * self._energy_end_peak_ratio + ) + if below_abs or below_rel: + self._ev_low_run += 1 + else: + self._ev_low_run = 0 + + if self._ev_low_run >= self._energy_end_chunks: + self._ev_speaking = False + self._ev_low_run = 0 + self._ev_utt_peak = 0.0 + with self.speech_buffer_lock: + self._submit_concatenated_speech_to_stt() + self._reset_agc_after_utterance_end() + logger.debug("energy VAD: 语音段结束,已提交") + + def _reset_agc_after_utterance_end(self) -> None: + """VAD 句尾:清 AGC 滑窗,避免巨响后 RMS 卡死。""" + try: + self.audio_preprocessor.reset_agc_state() + except AttributeError: + pass + + def discard_pending_stt_segments(self) -> int: + """丢弃尚未被 STT 线程取走的整句,避免唤醒/播 TTS 关麦后仍识别旧段。""" + n = 0 + while True: + try: + self.stt_queue.get_nowait() + self.stt_queue.task_done() + n += 1 + except queue.Empty: + break + if n: + logger.info( + "已丢弃 %s 条待 STT 的语音段(流程切换,避免与播 TTS 重叠)", + n, + ) + return n + + def _stt_worker_thread(self): + """STT识别工作线程(异步处理,不阻塞主流程)""" + logger.info("STT识别线程已启动") + while self.running: + try: + audio_data = self.stt_queue.get(timeout=0.1) + except queue.Empty: + continue + except Exception as e: + logger.error(f"STT工作线程错误: {e}", exc_info=True) + continue + + try: + if audio_data is None: + break + + try: + text = self.stt.invoke_numpy(audio_data) + + if os.environ.get("ROCKET_PRINT_STT", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[STT] {text!r}" + if (text and text.strip()) + else "[STT] <空或不识别>", + flush=True, + ) + + if text and text.strip(): + logger.info(f"🎤 STT识别结果: {text}") + + try: + self.command_queue.put(text, block=False) + logger.debug(f"文本已提交到命令处理队列: {text}") + except queue.Full: + logger.warning("命令处理队列已满,跳过本次识别结果") + + except Exception as e: + logger.error(f"STT识别失败: {e}", exc_info=True) + finally: + self.stt_queue.task_done() + + logger.info("STT识别线程已停止") + + def _command_worker_thread(self): + """命令处理工作线程(文本预处理+命令生成+Socket发送)""" + logger.info("命令处理线程已启动") + while self.running: + try: + text = self.command_queue.get(timeout=0.1) + except queue.Empty: + continue + except Exception as e: + logger.error(f"命令处理线程错误: {e}", exc_info=True) + continue + + try: + if text is None: + break + + try: + # 1. 检测唤醒词 + is_wake, matched_wake_word = self.wake_word_detector.detect(text) + + if not is_wake: + logger.debug(f"未检测到唤醒词,忽略文本: {text}") + continue + + logger.info(f"🔔 检测到唤醒词: {matched_wake_word}") + + # 2. 提取命令文本(移除唤醒词) + command_text = self.wake_word_detector.extract_command_text(text) + if not command_text or not command_text.strip(): + logger.warning(f"唤醒词后无命令内容: {text}") + continue + + logger.debug(f"提取的命令文本: {command_text}") + + # 3. 文本预处理(快速模式,不进行分词) + normalized_text, params = self.text_preprocessor.preprocess_fast(command_text) + + logger.debug(f"文本预处理结果:") + logger.debug(f" 规范化文本: {normalized_text}") + logger.debug(f" 命令关键词: {params.command_keyword}") + logger.debug(f" 距离: {params.distance} 米") + logger.debug(f" 速度: {params.speed} 米/秒") + logger.debug(f" 时间: {params.duration} 秒") + + # 4. 检查是否识别到命令关键词 + if not params.command_keyword: + logger.warning(f"未识别到有效命令关键词: {normalized_text}") + continue + + # 5. 生成命令 + sequence_id = self._get_next_sequence_id() + command = Command.create( + command=params.command_keyword, + sequence_id=sequence_id, + distance=params.distance, + speed=params.speed, + duration=params.duration + ) + + logger.info(f"📝 生成命令: {command.command}") + logger.debug(f"命令详情: {command.to_dict()}") + + # 6. 发送命令到Socket服务器 + if self.socket_client.send_command_with_retry(command): + logger.info(f"✅ 命令已发送: {command.command} (序列号: {sequence_id})") + self._enqueue_ack_playback(command.command) + else: + logger.warning( + "命令未送达(已达 max_retries): %s (序列号: %s)", + command.command, + sequence_id, + ) + + except Exception as e: + logger.error(f"命令处理失败: {e}", exc_info=True) + + finally: + self.command_queue.task_done() + + logger.info("命令处理线程已停止") + + def start(self): + """启动语音命令识别系统""" + if self.running: + logger.warning("语音命令识别系统已在运行") + return + + # 先完成阻塞式 TTS 预加载,再开麦与识别线程,避免用户在预加载期间下指令导致无波形缓存、播报延迟 + print("[TTS] 探测扬声器并预加载应答语音(可能需十余秒,请勿说话)…", flush=True) + self._init_sounddevice_output_probe() + if self.ack_tts_enabled and self._has_ack_tts_content() and self.ack_tts_prewarm: + if self.ack_tts_prewarm_blocking: + self._prewarm_tts_blocking() + else: + print( + "[TTS] 已跳过启动预加载(ack_tts_enabled/应答文案/ack_tts_prewarm)", + flush=True, + ) + + self.running = True + + # 启动STT识别线程 + self.stt_thread = threading.Thread(target=self._stt_worker_thread, daemon=True) + self.stt_thread.start() + + # 启动命令处理线程 + self.command_thread = threading.Thread(target=self._command_worker_thread, daemon=True) + self.command_thread.start() + + # 启动音频采集 + self.audio_capture.start_stream() + + if self.ack_tts_enabled and self._has_ack_tts_content() and self.ack_tts_prewarm: + if not self.ack_tts_prewarm_blocking: + self._prewarm_tts_async() + + logger.info("语音命令识别系统已启动") + print("\n" + "=" * 70) + print("🎙️ 高性能实时语音命令识别系统已启动") + print("=" * 70) + print("💡 功能说明:") + print(" - 系统会自动检测语音并识别") + print(f" - 🔔 唤醒词: {self.wake_word_detector.primary}") + print(" - 只有包含唤醒词的语音才会被处理") + print(" - 识别结果会自动转换为无人机控制命令") + print(" - 命令会自动发送到Socket服务器") + print(" - 按 Ctrl+C 退出") + print("=" * 70 + "\n") + + def stop(self): + """停止语音命令识别系统""" + if not self.running: + return + + self.running = False + + # 先通知工作线程结束,再播放尚未 drain 的应答(避免 Ctrl+C 时主循环未跑下一轮导致无声) + if self.stt_thread is not None: + self.stt_queue.put(None) + if self.command_thread is not None: + self.command_queue.put(None) + if self.stt_thread is not None: + self.stt_thread.join(timeout=2.0) + if self.command_thread is not None: + self.command_thread.join(timeout=2.0) + + if self.ack_tts_enabled: + try: + self._drain_ack_playback_queue(recover_mic=False) + except Exception as e: + logger.warning(f"退出前播放应答失败: {e}", exc_info=True) + + self.audio_capture.stop_stream() + + # 断开Socket连接 + if self.socket_client.connected: + self.socket_client.disconnect() + + logger.info("语音命令识别系统已停止") + print("\n语音命令识别系统已停止") + + def process_audio_stream(self): + """ + 处理音频流(主循环) + + 高性能实时处理流程: + 1. 采集音频块(非阻塞) + 2. 预处理(降噪+AGC) + 3. VAD检测语音开始/结束 + 4. 收集语音段 + 5. 异步STT识别(不阻塞主流程) + """ + try: + while self.running: + # 0. 主线程播放命令应答(必须在采集循环线程中执行 sd.play,见 tts.play_tts_audio 说明) + self._before_audio_iteration() + + # 1. 采集音频块(非阻塞,高性能模式) + chunk = self.audio_capture.read_chunk_numpy(timeout=0.1) + if chunk is None: + continue + + # 2. 音频预处理(降噪+AGC) + processed_chunk = self.audio_preprocessor.process(chunk) + + # 初始化预缓冲区的最大块数(只需计算一次) + if self.pre_speech_max_chunks is None: + # 每个chunk包含的采样点数 + samples_per_chunk = processed_chunk.shape[0] + if samples_per_chunk > 0: + # 0.8 秒需要的chunk数量 = 预缓冲秒数 * 采样率 / 每块采样数 + chunks = int( + self.pre_speech_max_seconds * self.audio_capture.sample_rate + / samples_per_chunk + ) + # 至少保留 1 块,避免被算成 0 + self.pre_speech_max_chunks = max(chunks, 1) + else: + self.pre_speech_max_chunks = 1 + + # 将当前块加入预缓冲区(环形缓冲) + # 注意:预缓冲区保存的是“最近的一段音频”,无论当下是否在说话 + self.pre_speech_buffer.append(processed_chunk) + if ( + self.pre_speech_max_chunks is not None + and len(self.pre_speech_buffer) > self.pre_speech_max_chunks + ): + # 超出最大长度时,丢弃最早的块 + self.pre_speech_buffer.pop(0) + + # 3. VAD:Silero 或能量(RMS)分段 + if self._use_energy_vad: + self._energy_vad_on_chunk(processed_chunk) + else: + chunk_bytes = processed_chunk.tobytes() + + if self.vad.detect_speech_start(chunk_bytes): + hook = self._vad_speech_start_hook + if hook is not None: + try: + hook() + except Exception as e: # noqa: BLE001 + logger.debug( + "vad_speech_start_hook: %s", e, exc_info=True + ) + with self.speech_buffer_lock: + if self.pre_speech_buffer: + self.speech_buffer = list(self.pre_speech_buffer) + else: + self.speech_buffer.clear() + self.speech_buffer.append(processed_chunk) + logger.debug( + "检测到语音开始,使用预缓冲音频(约 %.2f 秒)作为前缀,开始收集语音段", + self.pre_speech_max_seconds, + ) + + elif self.vad.is_speaking: + with self.speech_buffer_lock: + self.speech_buffer.append(processed_chunk) + + if self.vad.detect_speech_end(chunk_bytes): + with self.speech_buffer_lock: + self._submit_concatenated_speech_to_stt() + self._reset_agc_after_utterance_end() + logger.debug("检测到语音结束,提交识别") + + hook = getattr(self, "_after_processed_audio_chunk", None) + if hook is not None: + try: + hook(processed_chunk) + except Exception as e: # noqa: BLE001 + logger.debug( + "after_processed_audio_chunk: %s", e, exc_info=True + ) + + except KeyboardInterrupt: + logger.info("用户中断") + except Exception as e: + logger.error(f"处理音频流时发生错误: {e}", exc_info=True) + raise + + def run(self): + """运行语音命令识别系统(完整流程)""" + try: + self.start() + self.process_audio_stream() + finally: + self.stop() + + +if __name__ == "__main__": + # 测试代码 + recognizer = VoiceCommandRecognizer() + recognizer.run() diff --git a/voice_drone/core/rule.py b/voice_drone/core/rule.py new file mode 100644 index 0000000..e69de29 diff --git a/voice_drone/core/scoket_client.py b/voice_drone/core/scoket_client.py new file mode 100644 index 0000000..fd23259 --- /dev/null +++ b/voice_drone/core/scoket_client.py @@ -0,0 +1,239 @@ +""" +Socket客户端 +""" +import socket +import json +import time +from voice_drone.core.configuration import SYSTEM_SOCKET_SERVER_CONFIG +from voice_drone.core.command import Command +from voice_drone.logging_ import get_logger + +logger = get_logger("socket.client") + + +class SocketClient: + # 初始化Socket客户端 + def __init__(self, config: dict): + self.host = config.get("host") + self.port = config.get("port") + self.connect_timeout = config.get("connect_timeout") + self.send_timeout = config.get("send_timeout") + self.reconnect_interval = float(config.get("reconnect_interval") or 3.0) + # max_retries:-1 表示断线后持续重连并发送,直到成功(不视为致命错误) + _mr = config.get("max_retries", -1) + try: + self.max_reconnect_attempts = int(_mr) + except (TypeError, ValueError): + self.max_reconnect_attempts = -1 + + self.sock = None + self.connected = False + + # 连接到socket服务器 + def connect(self) -> bool: + if self.connected and self.sock is not None: + return True + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(self.connect_timeout) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(self.send_timeout) + self.connected = True + print( + f"[SocketClient] 连接成功: {self.host}:{self.port}", + flush=True, + ) + logger.info("Socket 已连接 %s:%s", self.host, self.port) + return True + + except socket.timeout: + logger.warning( + "Socket 连接超时 host=%s port=%s timeout=%s", + self.host, + self.port, + self.connect_timeout, + ) + print( + "[SocketClient] connect: 连接超时 " + f"(host={self.host!r}, port={self.port!r}, timeout={self.connect_timeout!r})", + flush=True, + ) + self._cleanup() + return False + except ConnectionRefusedError as e: + logger.warning("Socket 连接被拒绝: %s", e) + print( + f"[SocketClient] connect: 连接被拒绝: {e!r}", + flush=True, + ) + self._cleanup() + return False + except OSError as e: + logger.warning("Socket connect OSError (%s): %s", type(e).__name__, e) + print( + f"[SocketClient] connect: OSError ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + except Exception as e: + print( + f"[SocketClient] connect: 未预期异常 ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + + # 断开与socket服务器的连接 + def disconnect(self) -> None: + self._cleanup() + + # 清理资源 + def _cleanup(self) -> None: + if self.sock is not None: + try: + self.sock.close() + except Exception: + pass + self.sock = None + self.connected = False + + # 确保连接已建立 + def _ensure_connected(self) -> bool: + if self.connected and self.sock is not None: + return True + + return self.connect() + + # 发送命令 + def send_command(self, command) -> bool: + print("[SocketClient] 正在发送命令…", flush=True) + + if not self._ensure_connected(): + logger.warning( + "Socket 未连接且 connect 失败,跳过本次发送 host=%s port=%s", + self.host, + self.port, + ) + print( + "[SocketClient] 未连接或 connect 失败,跳过发送", + flush=True, + ) + return False + + try: + command_dict = command.to_dict() + json_str = json.dumps(command_dict, ensure_ascii=False) + + # 添加换行符(根据 JSON格式说明.md,命令以换行符分隔) + message = json_str + "\n" + + # 发送数据 + self.sock.sendall(message.encode("utf-8")) + print("[SocketClient] sendall 成功", flush=True) + return True + + except socket.timeout: + logger.warning("Socket send 超时,将断开以便重连") + print("[SocketClient] send_command: socket 超时", flush=True) + self._cleanup() + return False + except ConnectionResetError as e: + logger.warning("Socket 连接被重置(将重连): %s", e) + print( + f"[SocketClient] send_command: 连接被重置 ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + except BrokenPipeError as e: + logger.warning("Socket 管道破裂(将重连): %s", e) + print( + f"[SocketClient] send_command: 管道破裂 ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + except OSError as e: + # 断网、对端关闭等:可恢复,不当作未捕获致命错误 + logger.warning("Socket send OSError (%s): %s(将重连)", type(e).__name__, e) + print( + f"[SocketClient] send_command: OSError ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + except Exception as e: + logger.warning( + "Socket send 异常 (%s): %s(将重连)", type(e).__name__, e + ) + print( + f"[SocketClient] send_command: 异常 ({type(e).__name__}): {e!r}", + flush=True, + ) + self._cleanup() + return False + + # 发送命令并重试 + def send_command_with_retry(self, command) -> bool: + """失败后清理连接并按 reconnect_interval 重试;max_retries=-1 时直到发送成功。""" + unlimited = self.max_reconnect_attempts < 0 + cap = max(1, self.max_reconnect_attempts) if not unlimited else None + attempt = 0 + while True: + attempt += 1 + self._cleanup() + if self.send_command(command): + if attempt > 1: + print( + f"[SocketClient] 重试后发送成功(第 {attempt} 次)", + flush=True, + ) + logger.info("Socket 重连后命令已发送(第 %s 次尝试)", attempt) + return True + + if not unlimited and cap is not None and attempt >= cap: + logger.warning( + "Socket 已达 max_retries=%s,本次命令未送达,稍后可再试", + self.max_reconnect_attempts, + ) + print( + "[SocketClient] 已达最大重试次数,本次命令未送达(可稍后重试)", + flush=True, + ) + return False + + # 无限重试时每 10 次打一条日志,避免刷屏 + if unlimited and attempt % 10 == 1: + logger.warning( + "Socket 发送失败,%ss 后第 %s 次重连重试…", + self.reconnect_interval, + attempt, + ) + print( + f"[SocketClient] 发送失败,{self.reconnect_interval}s 后重试 " + f"(第 {attempt} 次)", + flush=True, + ) + time.sleep(self.reconnect_interval) + + # 上下文管理器入口 + def __enter__(self): + self.connect() + return self + + # 上下文管理器出口 + def __exit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + + +if __name__ == "__main__": + from voice_drone.core.configuration import SYSTEM_SOCKET_SERVER_CONFIG + from voice_drone.core.command import Command + + config = SYSTEM_SOCKET_SERVER_CONFIG + client = SocketClient(config) + client.connect() + command = Command.create("takeoff", 1) + client.send_command(command) + client.disconnect() \ No newline at end of file diff --git a/voice_drone/core/streaming_llm_tts.py b/voice_drone/core/streaming_llm_tts.py new file mode 100644 index 0000000..1b9a757 --- /dev/null +++ b/voice_drone/core/streaming_llm_tts.py @@ -0,0 +1,46 @@ +"""流式闲聊:按句切分文本入队 TTS;飞控 JSON 路径由调用方整块推理后再播。""" + +from __future__ import annotations + +# 句末:切段送合成 +_SENTENCE_END = frozenset("。!?;\n") +# 过长且无句末时,优先在以下标点处断开 +_SOFT_BREAK = ",、," + + +def take_completed_sentences(buffer: str) -> tuple[list[str], str]: + """从 buffer 开头取出所有「以句末标点结尾」的完整小段。""" + segments: list[str] = [] + i = 0 + n = len(buffer) + while i < n: + j = i + while j < n and buffer[j] not in _SENTENCE_END: + j += 1 + if j >= n: + break + raw = buffer[i : j + 1].strip() + if raw: + segments.append(raw) + i = j + 1 + return segments, buffer[i:] + + +def force_soft_split(remainder: str, max_chars: int) -> tuple[list[str], str]: + """remainder 长度 >= max_chars 且无句末时,强制切下第一段。""" + if max_chars <= 0 or len(remainder) < max_chars: + return [], remainder + window = remainder[:max_chars] + cut = -1 + for sep in _SOFT_BREAK: + p = window.rfind(sep) + if p > cut: + cut = p + if cut <= 0: + cut = max_chars + first = remainder[: cut + 1].strip() + rest = remainder[cut + 1 :] + out: list[str] = [] + if first: + out.append(first) + return out, rest diff --git a/voice_drone/core/stt.py b/voice_drone/core/stt.py new file mode 100644 index 0000000..a673725 --- /dev/null +++ b/voice_drone/core/stt.py @@ -0,0 +1,494 @@ +""" +语音识别(Speech-to-Text)类 - 纯 ONNX Runtime 极致性能推理 +针对 RK3588 等 ARM 设备进行了深度优化,完全移除 FunASR 依赖。 +前处理(fbank + CMVN + LFR)与解码均手写实现。 +""" +import platform +import os +import multiprocessing + +import numpy as np +from pathlib import Path +from typing import List, Dict, Any, Optional + +import onnx +import onnxruntime as ort +from voice_drone.logging_ import get_logger +from voice_drone.tools.wrapper import time_cost +import scipy.special +from voice_drone.core.configuration import SYSTEM_STT_CONFIG, SYSTEM_AUDIO_CONFIG + +# voice_drone/core/stt.py -> 工程根(含 voice_drone_assistant 与本仓库根两种布局) +_STT_PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _stt_path_candidates(path: Path) -> List[Path]: + """相对配置路径的候选绝对路径:优先工程目录,其次嵌套在上一级仓库时的 src/models/。""" + if path.is_absolute(): + return [path] + out: List[Path] = [_STT_PROJECT_ROOT / path] + if path.parts and path.parts[0] == "models": + out.append(_STT_PROJECT_ROOT.parent / "src" / path) + return out + + +class STT: + """ + 语音识别(Speech-to-Text)类 + 使用 ONNX Runtime 进行最优性能推理 + 针对 RK3588 等 ARM 设备进行了深度优化 + """ + + def __init__(self): + """ + 初始化 STT 模型 + """ + stt_conf = SYSTEM_STT_CONFIG + self.logger = get_logger("stt.onnx") + + # 从配置读取参数 + self.model_dir = stt_conf.get("model_dir") + self.model_path = stt_conf.get("model_path") + self.prefer_int8 = stt_conf.get("prefer_int8", True) + _wf = stt_conf.get("warmup_file") + self.warmup_file: Optional[str] = None + if _wf: + wf_path = Path(_wf) + if wf_path.is_absolute() and wf_path.is_file(): + self.warmup_file = str(wf_path) + else: + for c in _stt_path_candidates(wf_path): + if c.is_file(): + self.warmup_file = str(c) + break + + # 音频预处理参数(确保数值类型正确) + self.sample_rate = int(stt_conf.get("sample_rate", SYSTEM_AUDIO_CONFIG.get("sample_rate", 16000))) + self.n_mels = int(stt_conf.get("n_mels", 80)) + self.frame_length_ms = float(stt_conf.get("frame_length_ms", 25)) + self.frame_shift_ms = float(stt_conf.get("frame_shift_ms", 10)) + self.log_eps = float(stt_conf.get("log_eps", 1e-10)) + + # ARM 优化配置 + arm_conf = stt_conf.get("arm_optimization", {}) + self.arm_enabled = arm_conf.get("enabled", True) + self.arm_max_threads = arm_conf.get("max_threads", 4) + + # CTC 解码配置 + ctc_conf = stt_conf.get("ctc_decode", {}) + self.blank_id = ctc_conf.get("blank_id", 0) + + # 语言和文本规范化配置(默认值) + lang_conf = stt_conf.get("language", {}) + text_norm_conf = stt_conf.get("text_norm", {}) + self.lang_zh_default = lang_conf.get("zh_id", 3) + self.with_itn_default = text_norm_conf.get("with_itn_id", 14) + self.without_itn_default = text_norm_conf.get("without_itn_id", 15) + + # 后处理配置 + postprocess_conf = stt_conf.get("postprocess", {}) + self.special_tokens = postprocess_conf.get("special_tokens", [ + "<|zh|>", "<|NEUTRAL|>", "<|Speech|>", "<|woitn|>", "<|withitn|>" + ]) + + # 检测是否为 RK3588 或 ARM 设备 + ARM = platform.machine().startswith('arm') or platform.machine().startswith('aarch64') + RK3588 = 'rk3588' in platform.platform().lower() or os.path.exists('/proc/device-tree/compatible') + + # ARM 设备性能优化配置 + if self.arm_enabled and (ARM or RK3588): + cpu_count = multiprocessing.cpu_count() + optimal_threads = min(self.arm_max_threads, cpu_count) + + # 设置 OpenMP 线程数 + os.environ['OMP_NUM_THREADS'] = str(optimal_threads) + os.environ['MKL_NUM_THREADS'] = str(optimal_threads) + os.environ['KMP_AFFINITY'] = 'granularity=fine,compact,1,0' + os.environ['OMP_DYNAMIC'] = 'FALSE' + os.environ['MKL_DYNAMIC'] = 'FALSE' + + self.logger.info("ARM/RK3588 优化已启用") + self.logger.info(f" CPU 核心数: {cpu_count}") + self.logger.info(f" 优化线程数: {optimal_threads}") + + # 确定模型路径 + onnx_model_path = self._resolve_model_path() + + # 保存模型目录路径(用于加载 tokens.txt) + self.onnx_model_dir = onnx_model_path.parent + + self.logger.info(f"加载 ONNX 模型: {onnx_model_path}") + self._load_onnx_model(str(onnx_model_path)) + + # 模型预热 + if self.warmup_file and os.path.exists(self.warmup_file): + try: + self.logger.info(f"正在预热模型(使用: {self.warmup_file})...") + _ = self.invoke(self.warmup_file) + self.logger.info("模型预热完成") + except Exception as e: + self.logger.warning(f"预热失败(可忽略): {e}") + elif self.warmup_file: + self.logger.warning(f"预热文件不存在: {self.warmup_file},跳过预热步骤") + + def _resolve_existing_model_file(self, raw: Optional[str]) -> Optional[Path]: + if not raw: + return None + p = Path(raw) + for c in _stt_path_candidates(p): + if c.is_file(): + return c + return None + + def _resolve_existing_model_dir(self, raw: Optional[str]) -> Optional[Path]: + if not raw: + return None + p = Path(raw) + for c in _stt_path_candidates(p): + if c.is_dir(): + return c + return None + + def _resolve_model_path(self) -> Path: + """ + 解析模型路径 + + Returns: + 模型文件路径 + """ + if self.model_path: + hit = self._resolve_existing_model_file(self.model_path) + if hit is not None: + return hit + + if not self.model_dir: + raise ValueError("配置中必须指定 model_path 或 model_dir") + + model_dir = self._resolve_existing_model_dir(self.model_dir) + if model_dir is None: + tried = ", ".join(str(x) for x in _stt_path_candidates(Path(self.model_dir))) + raise FileNotFoundError( + f"ONNX 模型目录不存在。config model_dir={self.model_dir!r},已尝试: {tried}。" + f"请将 SenseVoice 放入 {_STT_PROJECT_ROOT / 'models'},或 ln -s ../src/models " + f"{_STT_PROJECT_ROOT / 'models'}(见 models/README.txt)。" + ) + + # 优先使用 INT8 量化模型(如果启用) + if self.prefer_int8: + int8_path = model_dir / "model.int8.onnx" + if int8_path.exists(): + return int8_path + + # 回退到普通模型 + onnx_path = model_dir / "model.onnx" + if onnx_path.exists(): + return onnx_path + + raise FileNotFoundError(f"ONNX 模型文件不存在: 在 {model_dir} 中未找到 model.int8.onnx 或 model.onnx") + + def _load_onnx_model(self, onnx_model_path: str): + """加载 ONNX 模型""" + # 创建 ONNX Runtime 会话选项 + sess_options = ort.SessionOptions() + + # ARM 设备优化 + ARM = platform.machine().startswith('arm') or platform.machine().startswith('aarch64') + if self.arm_enabled and ARM: + cpu_count = multiprocessing.cpu_count() + optimal_threads = min(self.arm_max_threads, cpu_count) + sess_options.intra_op_num_threads = optimal_threads + sess_options.inter_op_num_threads = optimal_threads + + # 启用所有图优化(最优性能) + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + + # 创建推理会话 + self.onnx_session = ort.InferenceSession( + onnx_model_path, + sess_options=sess_options, + providers=['CPUExecutionProvider'] + ) + + # 获取模型元数据 + onnx_model = onnx.load(onnx_model_path) + self.model_metadata = {prop.key: prop.value for prop in onnx_model.metadata_props} + + # 解析元数据 + self.lfr_window_size = int(self.model_metadata.get('lfr_window_size', 7)) + self.lfr_window_shift = int(self.model_metadata.get('lfr_window_shift', 6)) + self.vocab_size = int(self.model_metadata.get('vocab_size', 25055)) + + # 解析 CMVN 参数 + neg_mean_str = self.model_metadata.get('neg_mean', '') + inv_stddev_str = self.model_metadata.get('inv_stddev', '') + self.neg_mean = np.array([float(x) for x in neg_mean_str.split(',')]) if neg_mean_str else None + self.inv_stddev = np.array([float(x) for x in inv_stddev_str.split(',')]) if inv_stddev_str else None + + # 语言和文本规范化 ID(从元数据获取,如果没有则使用配置默认值) + self.lang_zh = int(self.model_metadata.get('lang_zh', self.lang_zh_default)) + self.with_itn = int(self.model_metadata.get('with_itn', self.with_itn_default)) + self.without_itn = int(self.model_metadata.get('without_itn', self.without_itn_default)) + + self.logger.info("ONNX 模型加载完成") + self.logger.info(f" LFR窗口大小: {self.lfr_window_size}") + self.logger.info(f" LFR窗口偏移: {self.lfr_window_shift}") + self.logger.info(f" 词汇表大小: {self.vocab_size}") + + def _load_tokens(self): + """加载 tokens 映射""" + tokens_file = self.onnx_model_dir / "tokens.txt" + if tokens_file.exists(): + self.tokens = {} + with open(tokens_file, 'r', encoding='utf-8') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 2: + token = parts[0] + token_id = int(parts[-1]) + self.tokens[token_id] = token + return True + return False + + def _preprocess_audio_array(self, audio_array: np.ndarray, sample_rate: Optional[int] = None) -> tuple: + """ + 预处理音频数组:提取特征并转换为 ONNX 模型输入格式(纯 numpy 实现) + + 支持实时音频流处理(numpy数组输入) + + 流程: + 1. 输入 16k 单声道 numpy 数组(int16 或 float32) + 2. 计算 80 维 log-mel fbank + 3. 应用 CMVN(使用 ONNX 元数据中的 neg_mean / inv_stddev) + 4. 应用 LFR(lfr_m, lfr_n)堆叠,得到 560 维特征 + + Args: + audio_array: 音频数据(numpy array,int16 或 float32) + sample_rate: 采样率,None时使用配置值 + + Returns: + (features, lengths): 特征和长度 + """ + import librosa + + sr = sample_rate if sample_rate is not None else self.sample_rate + + # 1. 转换为float32格式(如果输入是int16) + if audio_array.dtype == np.int16: + audio = audio_array.astype(np.float32) / 32768.0 + else: + audio = audio_array.astype(np.float32) + + # 确保是单声道 + if len(audio.shape) > 1: + audio = np.mean(audio, axis=1) + + if audio.size == 0: + raise ValueError("音频数组为空") + + # 2. 计算 fbank 特征 + n_fft = int(self.frame_length_ms / 1000.0 * sr) + hop_length = int(self.frame_shift_ms / 1000.0 * sr) + + mel_spec = librosa.feature.melspectrogram( + y=audio, + sr=sr, + n_fft=n_fft, + hop_length=hop_length, + n_mels=self.n_mels, + window="hann", + center=True, + power=1.0, # 线性能量 + ) + + # log-mel + log_mel = np.log(np.maximum(mel_spec, self.log_eps)).T # (T, n_mels) + + # 3. CMVN:使用 ONNX 元数据中的 neg_mean / inv_stddev + if self.neg_mean is not None and self.inv_stddev is not None: + if self.neg_mean.shape[0] == log_mel.shape[1]: + log_mel = (log_mel + self.neg_mean) * self.inv_stddev + + # 4. LFR:按窗口 lfr_window_size 堆叠,步长 lfr_window_shift + T, D = log_mel.shape + m = self.lfr_window_size + n = self.lfr_window_shift + + if T < m: + # 帧数不够,补到 m 帧 + pad = np.tile(log_mel[-1], (m - T, 1)) + log_mel = np.vstack([log_mel, pad]) + T = m + + # 计算 LFR 后的帧数 + T_lfr = 1 + (T - m) // n + lfr_feats = [] + for i in range(T_lfr): + start = i * n + end = start + m + chunk = log_mel[start:end, :] # (m, D) + lfr_feats.append(chunk.reshape(-1)) # 展平为 560 维 + + lfr_feats = np.stack(lfr_feats, axis=0) # (T_lfr, m*D=560) + + # 增加 batch 维度: (1, T_lfr, 560) + lfr_feats = lfr_feats[np.newaxis, :, :].astype(np.float32) + lengths = np.array([lfr_feats.shape[1]], dtype=np.int32) + + return lfr_feats, lengths + + def _preprocess_audio(self, audio_path: str) -> tuple: + """ + 预处理音频:提取特征并转换为 ONNX 模型输入格式(纯 numpy 实现) + + 流程: + 1. 读入 16k 单声道 wav + 2. 计算 80 维 log-mel fbank + 3. 应用 CMVN(使用 ONNX 元数据中的 neg_mean / inv_stddev) + 4. 应用 LFR(lfr_m, lfr_n)堆叠,得到 560 维特征 + """ + import librosa + + # 1. 读入音频 + audio, sr = librosa.load(audio_path, sr=self.sample_rate, mono=True) + if audio.size == 0: + raise ValueError(f"音频为空: {audio_path}") + + # 使用_preprocess_audio_array处理 + return self._preprocess_audio_array(audio, sr) + + def _ctc_decode(self, logits: np.ndarray, length: np.ndarray) -> str: + """ + CTC 解码:将 logits 转换为文本 + + Args: + logits: CTC logits,形状为 (N, T, vocab_size) + length: 序列长度,形状为 (N,) + + Returns: + 解码后的文本 + """ + # 加载 tokens(如果还没加载) + if not hasattr(self, 'tokens') or len(self.tokens) == 0: + self._load_tokens() + + # Greedy CTC 解码 + # 应用 softmax 获取概率 + probs = scipy.special.softmax(logits[0][:length[0]], axis=-1) + + # 获取每个时间步的最大概率 token + token_ids = np.argmax(probs, axis=-1) + + # CTC 解码:移除空白和重复 + prev_token = -1 + decoded_tokens = [] + + for token_id in token_ids: + if token_id != self.blank_id and token_id != prev_token: + decoded_tokens.append(token_id) + prev_token = token_id + + # Token ID 转文本 + text_parts = [] + for token_id in decoded_tokens: + if token_id in self.tokens: + token = self.tokens[token_id] + # 处理 SentencePiece 标记 + if token.startswith('▁'): + if text_parts: # 如果不是第一个token,添加空格 + text_parts.append(' ') + text_parts.append(token[1:]) + elif not token.startswith('<|'): # 忽略特殊标记 + text_parts.append(token) + + text = ''.join(text_parts) + + # 后处理:移除残留的特殊标记 + for special in self.special_tokens: + text = text.replace(special, '') + + return text.strip() + + @time_cost("STT-语音识别推理耗时") + def invoke(self, audio_path: str) -> List[Dict[str, Any]]: + """ + 执行语音识别推理(从文件) + + Args: + audio_path: 音频文件路径 + + Returns: + 识别结果列表,格式: [{"text": "识别文本"}] + """ + # 预处理音频 + features, features_length = self._preprocess_audio(audio_path) + + # 执行推理 + text = self._inference(features, features_length) + + return [{"text": text}] + + def invoke_numpy(self, audio_array: np.ndarray, sample_rate: Optional[int] = None) -> str: + """ + 执行语音识别推理(从numpy数组,实时处理) + + Args: + audio_array: 音频数据(numpy array,int16 或 float32) + sample_rate: 采样率,None时使用配置值 + + Returns: + 识别文本 + """ + # 预处理音频数组 + features, features_length = self._preprocess_audio_array(audio_array, sample_rate) + + # 执行推理 + text = self._inference(features, features_length) + + return text + + def _inference(self, features: np.ndarray, features_length: np.ndarray) -> str: + """ + 执行ONNX推理(内部方法) + + Args: + features: 特征数组 + features_length: 特征长度 + + Returns: + 识别文本 + """ + # 准备 ONNX 模型输入 + N, T, C = features.shape + + # 语言ID + language = np.array([self.lang_zh], dtype=np.int32) + + # 文本规范化 + text_norm = np.array([self.with_itn], dtype=np.int32) + + # ONNX 推理 + inputs = { + 'x': features.astype(np.float32), + 'x_length': features_length.astype(np.int32), + 'language': language, + 'text_norm': text_norm + } + + outputs = self.onnx_session.run(None, inputs) + logits = outputs[0] # 形状: (N, T, vocab_size) + + # CTC 解码 + text = self._ctc_decode(logits, features_length) + + return text + + +if __name__ == "__main__": + # 使用 ONNX 模型进行推理 + import os + stt = STT() + + for i in range(10): + result = stt.invoke("/home/lktx/projects/audio_controll_drone_without_llm/test/测试音频.wav") + # result = stt.invoke_numpy(np.random.rand(16000), 16000) + print(f"第{i+1}次识别结果: {result}") \ No newline at end of file diff --git a/voice_drone/core/text_preprocessor.py b/voice_drone/core/text_preprocessor.py new file mode 100644 index 0000000..cdeb107 --- /dev/null +++ b/voice_drone/core/text_preprocessor.py @@ -0,0 +1,716 @@ +""" +文本预处理模块 - 高性能实时语音转命令文本处理 + +本模块主要用于对语音识别输出的文本进行清洗、纠错、简繁转换、分词和参数提取, +便于后续命令意图分析和参数解析。 + +主要功能: +1. 文本清理:去除杂音、特殊字符、多余空格 +2. 纠错:同音字纠正、常见错误修正 +3. 简繁转换:统一文本格式(繁体转简体) +4. 分词:使用jieba分词,便于关键词匹配 +5. 数字提取:提取距离(米)、速度(米/秒)、时间(秒) +6. 关键词识别:识别命令关键词(起飞、降落、前进等) + +性能优化: +- LRU缓存常用处理结果(分词、中文数字解析、完整预处理) +- 预编译正则表达式 +- 优化字符串操作(使用正则表达式批量替换) +- 延迟加载可选依赖 +- 缓存关键词排序结果 +""" + +import re +from typing import Dict, Optional, List, Tuple, Set +from functools import lru_cache +from dataclasses import dataclass +from voice_drone.logging_ import get_logger +from voice_drone.core.configuration import KEYWORDS_CONFIG, SYSTEM_TEXT_PREPROCESSOR_CONFIG +import warnings +warnings.filterwarnings("ignore") + +logger = get_logger("text_preprocessor") + +# 延迟加载可选依赖 +try: + from opencc import OpenCC + OPENCC_AVAILABLE = True +except ImportError: + OPENCC_AVAILABLE = False + logger.warning("opencc 未安装,将跳过简繁转换功能") + +try: + import jieba + JIEBA_AVAILABLE = True + # 初始化jieba,加载词典 + jieba.initialize() +except ImportError: + JIEBA_AVAILABLE = False + logger.warning("jieba 未安装,将跳过分词功能") + +try: + from pypinyin import lazy_pinyin, Style + PYPINYIN_AVAILABLE = True +except ImportError: + PYPINYIN_AVAILABLE = False + logger.warning("pypinyin 未安装,将跳过拼音相关功能") + + +@dataclass +class ExtractedParams: + """提取的参数信息""" + distance: Optional[float] = None # 距离(米) + speed: Optional[float] = None # 速度(米/秒) + duration: Optional[float] = None # 时间(秒) + command_keyword: Optional[str] = None # 识别的命令关键词 + + +@dataclass +class PreprocessedText: + """预处理后的文本结果""" + cleaned_text: str # 清理后的文本 + normalized_text: str # 规范化后的文本(简繁转换后) + words: List[str] # 分词结果 + params: ExtractedParams # 提取的参数 + original_text: str # 原始文本 + + +class TextPreprocessor: + """ + 高性能文本预处理器 + + 针对实时语音转命令场景优化,支持: + - 文本清理和规范化 + - 同音字纠错 + - 简繁转换 + - 分词 + - 数字和单位提取 + - 命令关键词识别 + """ + + def __init__(self, + enable_traditional_to_simplified: Optional[bool] = None, + enable_segmentation: Optional[bool] = None, + enable_correction: Optional[bool] = None, + enable_number_extraction: Optional[bool] = None, + enable_keyword_detection: Optional[bool] = None, + lru_cache_size: Optional[int] = None): + """ + 初始化文本预处理器 + + Args: + enable_traditional_to_simplified: 是否启用繁简转换(None时从配置读取) + enable_segmentation: 是否启用分词(None时从配置读取) + enable_correction: 是否启用纠错(None时从配置读取) + enable_number_extraction: 是否启用数字提取(None时从配置读取) + enable_keyword_detection: 是否启用关键词检测(None时从配置读取) + lru_cache_size: LRU缓存大小(None时从配置读取) + """ + # 从配置读取参数(如果未提供) + config = SYSTEM_TEXT_PREPROCESSOR_CONFIG or {} + + self.enable_traditional_to_simplified = ( + enable_traditional_to_simplified + if enable_traditional_to_simplified is not None + else config.get("enable_traditional_to_simplified", True) + ) and OPENCC_AVAILABLE + + self.enable_segmentation = ( + enable_segmentation + if enable_segmentation is not None + else config.get("enable_segmentation", True) + ) and JIEBA_AVAILABLE + + self.enable_correction = ( + enable_correction + if enable_correction is not None + else config.get("enable_correction", True) + ) + + self.enable_number_extraction = ( + enable_number_extraction + if enable_number_extraction is not None + else config.get("enable_number_extraction", True) + ) + + self.enable_keyword_detection = ( + enable_keyword_detection + if enable_keyword_detection is not None + else config.get("enable_keyword_detection", True) + ) + + cache_size = ( + lru_cache_size + if lru_cache_size is not None + else config.get("lru_cache_size", 512) + ) + + # 初始化OpenCC(如果可用) + if self.enable_traditional_to_simplified: + self.opencc = OpenCC('t2s') # 繁体转简体 + else: + self.opencc = None + + # 加载关键词映射(命令关键词 -> 命令类型) + self._load_keyword_mapping() + + # 预编译正则表达式(性能优化) + self._compile_regex_patterns() + + # 加载纠错字典 + self._load_correction_dict() + + # 设置LRU缓存大小 + self._cache_size = cache_size + + # 创建缓存装饰器(用于分词、中文数字解析、完整预处理) + self._segment_text_cached = lru_cache(maxsize=cache_size)(self._segment_text_impl) + self._parse_chinese_number_cached = lru_cache(maxsize=128)(self._parse_chinese_number_impl) + self._preprocess_cached = lru_cache(maxsize=cache_size)(self._preprocess_impl) + self._preprocess_fast_cached = lru_cache(maxsize=cache_size)(self._preprocess_fast_impl) + + logger.info(f"文本预处理器初始化完成") + logger.info(f" 繁简转换: {'启用' if self.enable_traditional_to_simplified else '禁用'}") + logger.info(f" 分词: {'启用' if self.enable_segmentation else '禁用'}") + logger.info(f" 纠错: {'启用' if self.enable_correction else '禁用'}") + logger.info(f" 数字提取: {'启用' if self.enable_number_extraction else '禁用'}") + logger.info(f" 关键词检测: {'启用' if self.enable_keyword_detection else '禁用'}") + + def _load_keyword_mapping(self): + """加载关键词映射表(命令关键词 -> 命令类型)""" + self.keyword_to_command: Dict[str, str] = {} + + if KEYWORDS_CONFIG: + for command_type, keywords in KEYWORDS_CONFIG.items(): + if isinstance(keywords, list): + for keyword in keywords: + self.keyword_to_command[keyword] = command_type + elif isinstance(keywords, str): + self.keyword_to_command[keywords] = command_type + + # 预计算排序结果(按长度降序,优先匹配长关键词) + self.sorted_keywords = sorted( + self.keyword_to_command.keys(), + key=len, + reverse=True + ) + + logger.debug(f"加载了 {len(self.keyword_to_command)} 个关键词映射") + + def _compile_regex_patterns(self): + """预编译正则表达式(性能优化)""" + # 清理文本:去除特殊字符、多余空格 + self.pattern_clean_special = re.compile(r'[^\u4e00-\u9fa5a-zA-Z0-9\s米每秒秒分小时\.]') + self.pattern_clean_spaces = re.compile(r'\s+') + + # 数字提取模式 + # 距离:数字 + (米|m|M|公尺)(排除速度单位) + self.pattern_distance = re.compile( + r'(\d+\.?\d*)\s*(?:米|m|M|公尺|meter|meters)(?!\s*[/每]?\s*秒)', + re.IGNORECASE + ) + + # 速度:数字 + (米每秒|m/s|米/秒|米秒|mps|MPS)(优先匹配) + # 支持"三米每秒"、"5米/秒"、"2.5米每秒"等格式 + self.pattern_speed = re.compile( + r'(?:速度\s*[::]?\s*)?(\d+\.?\d*|[零一二三四五六七八九十]+)\s*(?:米\s*[/每]?\s*秒|m\s*/\s*s|mps|MPS)', + re.IGNORECASE + ) + + # 时间:数字 + (秒|s|S|分钟|分|min|小时|时|h|H) + # 支持"持续10秒"、"5分钟"等格式 + self.pattern_duration = re.compile( + r'(?:持续\s*|持续\s*)?(\d+\.?\d*|[零一二三四五六七八九十]+)\s*(?:秒|s|S|分钟|分|min|小时|时|h|H)', + re.IGNORECASE + ) + + # 中文数字映射(用于识别"十米"、"五秒"等) + self.chinese_numbers = { + '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, + '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, + '壹': 1, '贰': 2, '叁': 3, '肆': 4, '伍': 5, + '陆': 6, '柒': 7, '捌': 8, '玖': 9, '拾': 10, + '百': 100, '千': 1000, '万': 10000 + } + + # 中文数字模式(如"十米"、"五秒"、"二十米"、"三米每秒") + # 支持"二十"、"三十"等复合数字 + self.pattern_chinese_number = re.compile( + r'([零一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾百千万]+)\s*(?:米|秒|分|小时|米\s*[/每]?\s*秒)' + ) + + def _load_correction_dict(self): + """加载纠错字典(同音字、常见错误)并编译正则表达式""" + # 无人机控制相关的常见同音字/错误字映射(只保留实际需要纠错的) + correction_pairs = [ + # 动作相关(同音字纠错) + ('起非', '起飞'), + ('降洛', '降落'), + ('悬廷', '悬停'), + ('停只', '停止'), + ] + + # 构建正则表达式模式(按长度降序,优先匹配长模式) + if correction_pairs: + # 按长度降序排序,优先匹配长模式 + sorted_pairs = sorted(correction_pairs, key=lambda x: len(x[0]), reverse=True) + + # 构建替换映射字典(用于快速查找) + self.correction_replacements = {wrong: correct for wrong, correct in sorted_pairs} + + # 编译单一正则表达式 + patterns = [re.escape(wrong) for wrong, _ in sorted_pairs] + self.correction_pattern = re.compile('|'.join(patterns)) + else: + self.correction_pattern = None + self.correction_replacements = {} + + logger.debug(f"加载了 {len(correction_pairs)} 个纠错规则") + + def clean_text(self, text: str) -> str: + """ + 清理文本:去除特殊字符、多余空格 + + Args: + text: 原始文本 + + Returns: + 清理后的文本 + """ + if not text: + return "" + + # 去除特殊字符(保留中文、英文、数字、空格、常用标点) + text = self.pattern_clean_special.sub('', text) + + # 统一空格(多个空格合并为一个) + text = self.pattern_clean_spaces.sub(' ', text) + + # 去除首尾空格 + text = text.strip() + + return text + + def correct_text(self, text: str) -> str: + """ + 纠错:同音字、常见错误修正(使用正则表达式优化) + + Args: + text: 待纠错文本 + + Returns: + 纠错后的文本 + """ + if not self.enable_correction or not text or not self.correction_pattern: + return text + + # 使用正则表达式一次性替换所有模式(性能优化) + def replacer(match): + matched = match.group(0) + # 直接从字典中查找替换(O(1)查找) + return self.correction_replacements.get(matched, matched) + + return self.correction_pattern.sub(replacer, text) + + def traditional_to_simplified(self, text: str) -> str: + """ + 繁体转简体 + + Args: + text: 待转换文本 + + Returns: + 转换后的文本 + """ + if not self.enable_traditional_to_simplified or not self.opencc or not text: + return text + + try: + return self.opencc.convert(text) + except Exception as e: + logger.warning(f"繁简转换失败: {e}") + return text + + def _segment_text_impl(self, text: str) -> List[str]: + """ + 分词实现(内部方法,不带缓存) + + Args: + text: 待分词文本 + + Returns: + 分词结果列表 + """ + if not self.enable_segmentation or not text: + return [text] if text else [] + + try: + words = list(jieba.cut(text, cut_all=False)) + # 过滤空字符串 + words = [w.strip() for w in words if w.strip()] + return words + except Exception as e: + logger.warning(f"分词失败: {e}") + return [text] if text else [] + + def segment_text(self, text: str) -> List[str]: + """ + 分词(带缓存) + + Args: + text: 待分词文本 + + Returns: + 分词结果列表 + """ + return self._segment_text_cached(text) + + def extract_numbers(self, text: str) -> ExtractedParams: + """ + 提取数字和单位(距离、速度、时间) + + Args: + text: 待提取文本 + + Returns: + ExtractedParams对象,包含提取的参数 + """ + params = ExtractedParams() + + if not self.enable_number_extraction or not text: + return params + + # 优先提取速度(避免被误识别为距离) + speed_match = self.pattern_speed.search(text) + if speed_match: + try: + speed_str = speed_match.group(1) + # 尝试解析中文数字 + if speed_str.isdigit() or '.' in speed_str: + params.speed = float(speed_str) + else: + # 中文数字(使用缓存) + chinese_speed = self._parse_chinese_number(speed_str) + if chinese_speed is not None: + params.speed = float(chinese_speed) + except (ValueError, AttributeError): + pass + + # 提取距离(米,排除速度单位) + distance_match = self.pattern_distance.search(text) + if distance_match: + try: + params.distance = float(distance_match.group(1)) + except ValueError: + pass + + # 提取时间(秒) + duration_matches = self.pattern_duration.finditer(text) # 查找所有匹配 + for duration_match in duration_matches: + try: + duration_str = duration_match.group(1) + duration_unit = duration_match.group(2).lower() if len(duration_match.groups()) > 1 else '秒' + + # 解析数字(支持中文数字) + if duration_str.isdigit() or '.' in duration_str: + duration_value = float(duration_str) + else: + # 中文数字 + chinese_duration = self._parse_chinese_number(duration_str) + if chinese_duration is None: + continue + duration_value = float(chinese_duration) + + # 转换为秒 + if '分' in duration_unit or 'min' in duration_unit: + params.duration = duration_value * 60 + break # 取第一个匹配 + elif '小时' in duration_unit or 'h' in duration_unit: + params.duration = duration_value * 3600 + break + else: # 秒 + params.duration = duration_value + break + except (ValueError, IndexError, AttributeError): + continue + + # 尝试提取中文数字(如"十米"、"五秒"、"二十米"、"三米每秒") + chinese_matches = self.pattern_chinese_number.finditer(text) + for chinese_match in chinese_matches: + try: + chinese_num_str = chinese_match.group(1) + full_match = chinese_match.group(0) + + # 解析中文数字(使用缓存) + num_value = self._parse_chinese_number(chinese_num_str) + + if num_value is not None: + # 判断单位类型 + if '米每秒' in full_match or '米/秒' in full_match or '米每' in full_match: + # 速度单位 + if params.speed is None: + params.speed = float(num_value) + elif '米' in full_match and '秒' not in full_match: + # 距离单位(不包含"秒") + if params.distance is None: + params.distance = float(num_value) + elif '秒' in full_match and '米' not in full_match: + # 时间单位(不包含"米") + if params.duration is None: + params.duration = float(num_value) + elif '分' in full_match and '米' not in full_match: + # 时间单位(分钟) + if params.duration is None: + params.duration = float(num_value) * 60 + except (ValueError, IndexError, AttributeError): + continue + + return params + + def _parse_chinese_number_impl(self, chinese_num: str) -> Optional[int]: + """ + 解析中文数字实现(内部方法,不带缓存) + + Args: + chinese_num: 中文数字字符串 + + Returns: + 对应的阿拉伯数字,解析失败返回None + """ + if not chinese_num: + return None + + # 单个数字 + if chinese_num in self.chinese_numbers: + return self.chinese_numbers[chinese_num] + + # "十" -> 10 + if chinese_num == '十' or chinese_num == '拾': + return 10 + + # "十一" -> 11, "十二" -> 12, ... + if chinese_num.startswith('十') or chinese_num.startswith('拾'): + rest = chinese_num[1:] + if rest in self.chinese_numbers: + return 10 + self.chinese_numbers[rest] + + # "二十" -> 20, "三十" -> 30, ... + if chinese_num.endswith('十') or chinese_num.endswith('拾'): + prefix = chinese_num[:-1] + if prefix in self.chinese_numbers: + return self.chinese_numbers[prefix] * 10 + + # "二十五" -> 25, "三十五" -> 35, ... + if '十' in chinese_num or '拾' in chinese_num: + parts = chinese_num.replace('拾', '十').split('十') + if len(parts) == 2: + tens_part = parts[0] if parts[0] else '一' # "十五" -> parts[0]为空 + ones_part = parts[1] if parts[1] else '' + + tens = self.chinese_numbers.get(tens_part, 1) if tens_part else 1 + ones = self.chinese_numbers.get(ones_part, 0) if ones_part else 0 + + return tens * 10 + ones + + return None + + def _parse_chinese_number(self, chinese_num: str) -> Optional[int]: + """ + 解析中文数字(支持"十"、"二十"、"三十"、"五"等,带缓存) + + Args: + chinese_num: 中文数字字符串 + + Returns: + 对应的阿拉伯数字,解析失败返回None + """ + return self._parse_chinese_number_cached(chinese_num) + + def detect_keyword(self, text: str, words: Optional[List[str]] = None) -> Optional[str]: + """ + 检测命令关键词(使用缓存的排序结果) + + Args: + text: 待检测文本 + words: 分词结果(如果已分词,可传入以提高性能) + + Returns: + 检测到的命令类型(如"takeoff"、"forward"等),未检测到返回None + """ + if not self.enable_keyword_detection or not text: + return None + + # 如果已分词,优先使用分词结果匹配 + if words: + for word in words: + if word in self.keyword_to_command: + return self.keyword_to_command[word] + + # 使用缓存的排序结果(按长度降序,优先匹配长关键词) + for keyword in self.sorted_keywords: + if keyword in text: + return self.keyword_to_command[keyword] + + return None + + def preprocess(self, text: str) -> PreprocessedText: + """ + 完整的文本预处理流程(带缓存) + + Args: + text: 原始文本 + + Returns: + PreprocessedText对象,包含所有预处理结果 + """ + return self._preprocess_cached(text) + + def _preprocess_impl(self, text: str) -> PreprocessedText: + """ + 完整的文本预处理流程实现(内部方法,不带缓存) + + Args: + text: 原始文本 + + Returns: + PreprocessedText对象,包含所有预处理结果 + """ + if not text: + return PreprocessedText( + cleaned_text="", + normalized_text="", + words=[], + params=ExtractedParams(), + original_text=text + ) + + original_text = text + + # 1. 清理文本 + cleaned_text = self.clean_text(text) + + # 2. 纠错 + corrected_text = self.correct_text(cleaned_text) + + # 3. 繁简转换 + normalized_text = self.traditional_to_simplified(corrected_text) + + # 4. 分词 + words = self.segment_text(normalized_text) + + # 5. 提取数字和单位 + params = self.extract_numbers(normalized_text) + + # 6. 检测关键词 + command_keyword = self.detect_keyword(normalized_text, words) + params.command_keyword = command_keyword + + return PreprocessedText( + cleaned_text=cleaned_text, + normalized_text=normalized_text, + words=words, + params=params, + original_text=original_text + ) + + def preprocess_fast(self, text: str) -> Tuple[str, ExtractedParams]: + """ + 快速预处理(仅返回规范化文本和参数,不进行分词,带缓存) + + 适用于实时场景,性能优先 + + Args: + text: 原始文本 + + Returns: + (规范化文本, 提取的参数) + """ + return self._preprocess_fast_cached(text) + + def _preprocess_fast_impl(self, text: str) -> Tuple[str, ExtractedParams]: + """ + 快速预处理实现(内部方法,不带缓存) + + Args: + text: 原始文本 + + Returns: + (规范化文本, 提取的参数) + """ + if not text: + return "", ExtractedParams() + + # 1. 清理 + cleaned = self.clean_text(text) + + # 2. 纠错 + corrected = self.correct_text(cleaned) + + # 3. 繁简转换 + normalized = self.traditional_to_simplified(corrected) + + # 4. 提取参数(不进行分词,提高性能) + params = self.extract_numbers(normalized) + + # 5. 检测关键词(在完整文本中搜索) + params.command_keyword = self.detect_keyword(normalized, words=None) + + return normalized, params + + +# 全局单例(可选,用于提高性能) +_global_preprocessor: Optional[TextPreprocessor] = None + + +def get_preprocessor() -> TextPreprocessor: + """获取全局预处理器实例(单例模式)""" + global _global_preprocessor + if _global_preprocessor is None: + _global_preprocessor = TextPreprocessor() + return _global_preprocessor + + +if __name__ == "__main__": + from command import Command + + # 测试代码 + preprocessor = TextPreprocessor() + + test_cases = [ + "现在起飞往前飞,飞10米,速度为5米每秒", + "向前飞二十米,速度三米每秒", + "立刻降落", + "悬停五秒", + "向右飛十米", # 繁体测试 + "往左飛,速度2.5米/秒,持續10秒", + ] + + print("=" * 60) + print("文本预处理器测试") + print("=" * 60) + + for i, test_text in enumerate(test_cases, 1): + print(f"\n测试 {i}: {test_text}") + print("-" * 60) + + # 完整预处理 + result = preprocessor.preprocess(test_text) + print(f"原始文本: {result.original_text}") + print(f"清理后: {result.cleaned_text}") + print(f"规范化: {result.normalized_text}") + print(f"分词: {result.words}") + print(f"提取参数:") + print(f" 距离: {result.params.distance} 米") + print(f" 速度: {result.params.speed} 米/秒") + print(f" 时间: {result.params.duration} 秒") + print(f" 命令关键词: {result.params.command_keyword}") + + + command = Command.create(result.params.command_keyword, 1, result.params.distance, result.params.speed, result.params.duration) + print(f"命令: {command.to_dict()}") + + # 快速预处理 + fast_text, fast_params = preprocessor.preprocess_fast(test_text) + print(f"\n快速预处理结果:") + print(f" 规范化文本: {fast_text}") + print(f" 命令关键词: {fast_params.command_keyword}") diff --git a/voice_drone/core/tts.py b/voice_drone/core/tts.py new file mode 100644 index 0000000..b08427c --- /dev/null +++ b/voice_drone/core/tts.py @@ -0,0 +1,695 @@ +""" +TTS(Text-to-Speech)模块 - 基于 Kokoro ONNX 的中文实时合成 + +使用 Kokoro-82M-v1.1-zh-ONNX 模型进行文本转语音合成: +1. 文本 -> (可选)使用 misaki[zh] 做 G2P,得到音素串 +2. 音素字符 -> 根据 tokenizer vocab 映射为 token id 序列 +3. 通过 ONNX Runtime 推理生成 24kHz 单声道语音 + +说明: +- 主要依赖: onnxruntime + numpy +- 如果已安装 misaki[zh] (推荐),效果更好: + pip install "misaki[zh]" cn2an pypinyin jieba +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import List, Optional, Tuple + +import numpy as np +import onnxruntime as ort + +# 仅保留 ERROR,避免加载 Kokoro 时大量 ConstantFolding/Reciprocal 警告刷屏(不影响推理结果) +try: + ort.set_default_logger_severity(3) +except Exception: + pass + +from voice_drone.core.configuration import SYSTEM_TTS_CONFIG +from voice_drone.logging_ import get_logger + +# voice_drone/core/tts.py -> voice_drone_assistant 根目录 +_PROJECT_ROOT = Path(__file__).resolve().parents[2] + +logger = get_logger("tts.kokoro_onnx") + + +def _tts_model_dir_candidates(rel: Path) -> List[Path]: + if rel.is_absolute(): + return [rel] + out: List[Path] = [_PROJECT_ROOT / rel] + if rel.parts and rel.parts[0] == "models": + out.append(_PROJECT_ROOT.parent / "src" / rel) + return out + + +def _resolve_kokoro_model_dir(raw: str | Path) -> Path: + """含 tokenizer.json 的目录;支持子工程 models/ 缺失时回退到上级仓库 src/models/。""" + p = Path(raw) + for c in _tts_model_dir_candidates(p): + if (c / "tokenizer.json").is_file(): + return c.resolve() + for c in _tts_model_dir_candidates(p): + if c.is_dir(): + logger.warning( + "Kokoro 目录存在但未找到 tokenizer.json: %s(将仍使用该路径,后续可能报错)", + c, + ) + return c.resolve() + return (_PROJECT_ROOT / p).resolve() + + +class KokoroOnnxTTS: + """ + Kokoro 中文 ONNX 文本转语音封装 + + 基本用法: + tts = KokoroOnnxTTS() + audio, sr = tts.synthesize("你好,世界") + + 返回: + audio: np.ndarray[float32] 形状为 (N,), 范围约 [-1, 1] + sr: int 采样率(默认 24000) + """ + + def __init__(self, config: Optional[dict] = None) -> None: + # 读取系统 TTS 配置 + self.config = config or SYSTEM_TTS_CONFIG or {} + + # 模型根目录(包含 onnx/、tokenizer.json、voices/) + _raw_dir = self.config.get( + "model_dir", "models/Kokoro-82M-v1.1-zh-ONNX" + ) + model_dir = _resolve_kokoro_model_dir(_raw_dir) + self.model_dir = model_dir + + # ONNX 模型文件名(位于 model_dir/onnx 下;若 onnx/ 下没有可改配置为根目录文件名) + self.model_name = self.config.get("model_name", "model_q4.onnx") + self.onnx_path = model_dir / "onnx" / self.model_name + if not self.onnx_path.is_file(): + alt = model_dir / self.model_name + if alt.is_file(): + self.onnx_path = alt + + # 语音风格(voices 子目录下的 *.bin, 这里不含扩展名) + self.voice_name = self.config.get("voice", "zf_001") + self.voice_path = model_dir / "voices" / f"{self.voice_name}.bin" + + # 语速与输出采样率 + self.speed = float(self.config.get("speed", 1.0)) + self.sample_rate = int(self.config.get("sample_rate", 24000)) + + # tokenizer.json 路径(本地随 ONNX 模型一起提供) + self.tokenizer_path = model_dir / "tokenizer.json" + + # 初始化组件 + self._session: Optional[ort.InferenceSession] = None + self._vocab: Optional[dict] = None + self._voices: Optional[np.ndarray] = None + self._g2p = None # misaki[zh] G2P, 如不可用则退化为直接使用原始文本 + + self._load_all() + + # ------------------------------------------------------------------ # + # 对外主接口 + # ------------------------------------------------------------------ # + def synthesize(self, text: str) -> Tuple[np.ndarray, int]: + """ + 文本转语音 + + Args: + text: 输入文本(推荐为简体中文) + + Returns: + (audio, sample_rate) + """ + if not text or not text.strip(): + raise ValueError("TTS 输入文本不能为空") + + phonemes = self._text_to_phonemes(text) + token_ids = self._phonemes_to_token_ids(phonemes) + + if len(token_ids) == 0: + raise ValueError(f"TTS: 文本在当前 vocab 下无法映射到任何 token, text={text!r}") + + # 按 Kokoro-ONNX 官方示例约定: + # - token 序列长度 <= 510 + # - 前后各添加 pad token 0 + if len(token_ids) > 510: + logger.warning(f"TTS: token 长度 {len(token_ids)} > 510, 将被截断为 510") + token_ids = token_ids[:510] + + tokens = np.array([[0, *token_ids, 0]], dtype=np.int64) # shape: (1, <=512) + + # 根据 token 数量选择 style 向量 + assert self._voices is not None, "TTS: voices 尚未初始化" + voices = self._voices # shape: (N, 1, 256) + idx = min(len(token_ids), voices.shape[0] - 1) + style = voices[idx] # shape: (1, 256) + + speed = np.array([self.speed], dtype=np.float32) + + # ONNX 输入名约定: input_ids, style, speed + assert self._session is not None, "TTS: ONNX Session 尚未初始化" + session = self._session + audio = session.run( + None, + { + "input_ids": tokens, + "style": style, + "speed": speed, + }, + )[0] + + # 兼容不同导出形状: + # - 标准 Kokoro ONNX: (1, N) + # - 也有可能是 (N,) + audio = audio.astype(np.float32) + if audio.ndim == 2 and audio.shape[0] == 1: + audio = audio[0] + elif audio.ndim > 2: + # 极端情况: 压缩多余维度 + audio = np.squeeze(audio) + + return audio, self.sample_rate + + def synthesize_to_file(self, text: str, wav_path: str) -> str: + """ + 文本合成并保存为 wav 文件(16-bit PCM) + 需要依赖 scipy, 可选: + pip install scipy + """ + try: + import scipy.io.wavfile as wavfile # type: ignore + except Exception as e: # pragma: no cover - 仅在未安装时触发 + raise RuntimeError("保存到 wav 需要安装 scipy, 请先执行: pip install scipy") from e + + audio, sr = self.synthesize(text) + # 简单归一化并转为 int16 + max_val = float(np.max(np.abs(audio)) or 1.0) + audio_int16 = np.clip(audio / max_val, -1.0, 1.0) + audio_int16 = (audio_int16 * 32767.0).astype(np.int16) + + # 某些 SciPy 版本对一维/零维数组支持不统一,这里显式加上通道维度 + if audio_int16.ndim == 0: + audio_to_save = audio_int16.reshape(-1, 1) # 标量 -> (1,1) + elif audio_int16.ndim == 1: + audio_to_save = audio_int16.reshape(-1, 1) # (N,) -> (N,1) 单声道 + else: + audio_to_save = audio_int16 + + wavfile.write(wav_path, sr, audio_to_save) + return wav_path + + # ------------------------------------------------------------------ # + # 内部初始化 + # ------------------------------------------------------------------ # + def _load_all(self) -> None: + self._load_tokenizer_vocab() + self._load_voices() + self._load_onnx_session() + self._init_g2p() + + def _load_tokenizer_vocab(self) -> None: + """ + 从本地 tokenizer.json 载入 vocab 映射: + token(str) -> id(int) + """ + if not self.tokenizer_path.exists(): + raise FileNotFoundError(f"TTS: 未找到 tokenizer.json: {self.tokenizer_path}") + + with open(self.tokenizer_path, "r", encoding="utf-8") as f: + data = json.load(f) + + model = data.get("model") or {} + vocab = model.get("vocab") + if not isinstance(vocab, dict): + raise ValueError("TTS: tokenizer.json 格式不正确, 未找到 model.vocab 字段") + + # 保存为: 字符 -> id + self._vocab = {k: int(v) for k, v in vocab.items()} + logger.info(f"TTS: tokenizer vocab 加载完成, 词表大小: {len(self._vocab)}") + + def _load_voices(self) -> None: + """ + 载入语音风格向量(voices/*.bin) + """ + if not self.voice_path.exists(): + raise FileNotFoundError(f"TTS: 未找到语音风格文件: {self.voice_path}") + + voices = np.fromfile(self.voice_path, dtype=np.float32) + try: + voices = voices.reshape(-1, 1, 256) + except ValueError as e: + raise ValueError( + f"TTS: 语音风格文件形状不符合预期, 无法 reshape 为 (-1,1,256): {self.voice_path}" + ) from e + + self._voices = voices + logger.info( + f"TTS: 语音风格文件加载完成: {self.voice_name}, 可用 style 数量: {voices.shape[0]}" + ) + + def _load_onnx_session(self) -> None: + """ + 创建 ONNX Runtime 推理会话 + """ + if not self.onnx_path.exists(): + raise FileNotFoundError(f"TTS: 未找到 ONNX 模型文件: {self.onnx_path}") + + sess_options = ort.SessionOptions() + # 启用所有图优化 + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + # RK3588 等多核 CPU:可用环境变量固定 ORT 线程,避免过小/过大(默认 0 表示交给 ORT 自动) + _ti = os.environ.get("ROCKET_TTS_ORT_INTRA_OP_THREADS", "").strip() + if _ti.isdigit() and int(_ti) > 0: + sess_options.intra_op_num_threads = int(_ti) + _te = os.environ.get("ROCKET_TTS_ORT_INTER_OP_THREADS", "").strip() + if _te.isdigit() and int(_te) > 0: + sess_options.inter_op_num_threads = int(_te) + + # 简单的 CPU 推理(如需 GPU, 可在此扩展 providers) + self._session = ort.InferenceSession( + str(self.onnx_path), + sess_options=sess_options, + providers=["CPUExecutionProvider"], + ) + + logger.info(f"TTS: Kokoro ONNX 模型加载完成: {self.onnx_path}") + + def _init_g2p(self) -> None: + """ + 初始化中文 G2P (基于 misaki[zh])。 + 如果环境中未安装 misaki, 则退化为直接使用原始文本字符做映射。 + """ + try: + from misaki import zh # type: ignore + + # 兼容不同版本的 misaki: + # - 新版: ZHG2P(version=...) 可用 + # - 旧版: ZHG2P() 不接受参数 + try: + self._g2p = zh.ZHG2P(version="1.1") # type: ignore[call-arg] + except TypeError: + self._g2p = zh.ZHG2P() # type: ignore[call-arg] + + logger.info("TTS: 已启用 misaki[zh] G2P, 将使用音素级别映射") + except Exception as e: + self._g2p = None + logger.warning( + "TTS: 未安装或无法导入 misaki[zh], 将直接基于原始文本字符做 token 映射, " + "合成效果可能较差。建议执行: pip install \"misaki[zh]\" cn2an pypinyin jieba" + ) + logger.debug(f"TTS: G2P 初始化失败原因: {e!r}") + + # ------------------------------------------------------------------ # + # 文本 -> 音素 / token + # ------------------------------------------------------------------ # + def _text_to_phonemes(self, text: str) -> str: + """ + 文本 -> 音素串 + + - 若 misaki[zh] 可用, 则使用 ZHG2P(version='1.1') 得到音素串 + - 否则, 直接返回原始文本(后续按字符映射) + """ + if self._g2p is None: + return text.strip() + + # 兼容不同版本的 misaki: + # - 有的返回 (phonemes, tokens) + # - 有的只返回 phonemes 字符串 + result = self._g2p(text) + if isinstance(result, tuple) or isinstance(result, list): + ps = result[0] + else: + ps = result + + if not ps: + # 回退: 如果 G2P 返回空, 使用原始文本 + logger.warning("TTS: G2P 结果为空, 回退为原始文本") + return text.strip() + return ps + + def _phonemes_to_token_ids(self, phonemes: str) -> List[int]: + """ + 将音素串映射为 token id 序列 + + 直接按字符级别查表: + - 每个字符在 vocab 中有唯一 id + - 空格本身也是一个 token (id=16) + """ + assert self._vocab is not None, "TTS: vocab 尚未初始化" + vocab = self._vocab + + token_ids: List[int] = [] + unknown_chars = set() + + for ch in phonemes: + if ch == "\n": + continue + tid = vocab.get(ch) + if tid is None: + unknown_chars.add(ch) + continue + token_ids.append(int(tid)) + + if unknown_chars: + logger.debug(f"TTS: 存在无法映射到 vocab 的字符: {unknown_chars}") + + return token_ids + + +def _resolve_output_device_id(raw: object) -> Optional[int]: + """ + 将配置中的 output_device 解析为 sounddevice 设备索引。 + None / 空:返回 None,表示使用 sd 默认输出。 + """ + import sounddevice as sd # type: ignore + + if raw is None: + return None + if isinstance(raw, bool): + return None + if isinstance(raw, int): + return raw if raw >= 0 else None + s = str(raw).strip() + if not s or s.lower() in ("null", "none", "default", ""): + return None + if s.isdigit(): + return int(s) + needle = s.lower() + devices = sd.query_devices() + matches: List[int] = [] + for i, d in enumerate(devices): + if int(d.get("max_output_channels", 0) or 0) <= 0: + continue + name = (d.get("name") or "").lower() + if needle in name: + matches.append(i) + if not matches: + logger.warning( + f"TTS: 未找到名称包含 {s!r} 的输出设备,将使用系统默认输出。" + "请检查 system.yaml 中 tts.output_device 或查看启动日志中的设备列表。" + ) + return None + if len(matches) > 1: + logger.info( + f"TTS: 名称 {s!r} 匹配到多个输出设备索引 {matches},使用第一个 {matches[0]}" + ) + return matches[0] + + +_playback_dev_cache: Optional[int] = None +_playback_dev_cache_key: Optional[str] = None +_playback_dev_cache_ready: bool = False + + +def get_playback_output_device_id() -> Optional[int]: + """从 SYSTEM_TTS_CONFIG 解析并缓存播放设备索引(None=默认输出)。""" + global _playback_dev_cache, _playback_dev_cache_key, _playback_dev_cache_ready + + cfg = SYSTEM_TTS_CONFIG or {} + raw = cfg.get("output_device") + key = repr(raw) + if _playback_dev_cache_ready and _playback_dev_cache_key == key: + return _playback_dev_cache + dev_id = _resolve_output_device_id(raw) + _playback_dev_cache = dev_id + _playback_dev_cache_key = key + _playback_dev_cache_ready = True + if dev_id is not None: + import sounddevice as sd # type: ignore + + info = sd.query_devices(dev_id) + logger.info( + f"TTS: 播放将使用输出设备 index={dev_id} name={info.get('name', '?')!r}" + ) + else: + logger.info("TTS: 播放使用系统默认输出设备(未指定或无法匹配 tts.output_device)") + return dev_id + + +def _sounddevice_default_output_index(): + """sounddevice 0.5+ 的 default.device 可能是 _InputOutputPair,需取 [1] 为输出索引。""" + import sounddevice as sd # type: ignore + + default = sd.default.device + if isinstance(default, (list, tuple)): + return int(default[1]) + if hasattr(default, "__getitem__"): + try: + return int(default[1]) + except (IndexError, TypeError, ValueError): + pass + try: + return int(default) + except (TypeError, ValueError): + return None + + +def log_sounddevice_output_devices() -> None: + """列出所有可用输出设备及当前默认输出,便于配置 tts.output_device。""" + try: + import sounddevice as sd # type: ignore + + out_idx = _sounddevice_default_output_index() + logger.info("sounddevice 输出设备列表(用于配置 tts.output_device 索引或名称子串):") + for i, d in enumerate(sd.query_devices()): + if int(d.get("max_output_channels", 0) or 0) <= 0: + continue + mark = " <- 当前默认输出" if out_idx is not None and int(out_idx) == i else "" + logger.info(f" [{i}] {d.get('name', '?')}{mark}") + except Exception as e: + logger.warning(f"无法枚举 sounddevice 输出设备: {e}") + + +def _resample_playback_audio(audio: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray: + """将波形重采样到设备采样率;优先 librosa(kaiser_best),失败则回退 scipy 多相。""" + from math import gcd + + from scipy.signal import resample # type: ignore + from scipy.signal import resample_poly # type: ignore + + if abs(sr_in - sr_out) < 1: + return np.asarray(audio, dtype=np.float32) + try: + import librosa # type: ignore + + return librosa.resample( + np.asarray(audio, dtype=np.float32), + orig_sr=sr_in, + target_sr=sr_out, + res_type="kaiser_best", + ).astype(np.float32) + except Exception as e: + logger.debug(f"TTS: librosa 重采样不可用,使用多相重采样: {e!r}") + try: + g = gcd(int(sr_in), int(sr_out)) + if g > 0: + up = int(sr_out) // g + down = int(sr_in) // g + return resample_poly(audio, up, down).astype(np.float32) + except Exception as e2: + logger.debug(f"TTS: resample_poly 失败,回退 FFT resample: {e2!r}") + num = max(1, int(len(audio) * float(sr_out) / float(sr_in))) + return resample(audio, num).astype(np.float32) + + +def _fade_playback_edges(audio: np.ndarray, sample_rate: int, fade_ms: float) -> np.ndarray: + """极短线性淡入淡出,减轻扬声器/驱动在段首段尾的爆音与杂音感。""" + if fade_ms <= 0 or audio.size < 16: + return audio + n = int(float(sample_rate) * fade_ms / 1000.0) + n = min(n, len(audio) // 4) + if n <= 0: + return audio + out = np.asarray(audio, dtype=np.float32, order="C").copy() + fade = np.linspace(0.0, 1.0, n, dtype=np.float32) + out[:n] *= fade + out[-n:] *= fade[::-1] + return out + + +def play_tts_audio( + audio: np.ndarray, + sample_rate: int, + *, + output_device: Optional[object] = None, +) -> None: + """ + 使用 sounddevice 播放单声道 float32 音频(阻塞至播放结束)。 + + 在 Windows 上 PortAudio/sounddevice 从非主线程调用时经常出现「无声音、无报错」, + 因此本项目中应答播报应在主线程(采集循环所在线程)调用本函数。 + + 另:多数 Realtek/WASAPI 设备对 24000Hz 播放会「完全无声」且不报错,需重采样到设备 + default_samplerate(常见 48000/44100),并用 OutputStream 写出。 + + Args: + output_device: 若指定,覆盖 system.yaml 的 tts.output_device(设备索引或名称子串)。 + """ + import sounddevice as sd # type: ignore + + cfg = SYSTEM_TTS_CONFIG or {} + force_native = bool(cfg.get("playback_resample_to_device_native", True)) + do_normalize = bool(cfg.get("playback_peak_normalize", True)) + gain = float(cfg.get("playback_gain", 1.0)) + if gain <= 0: + gain = 1.0 + fade_ms = float(cfg.get("playback_edge_fade_ms", 8.0)) + latency = cfg.get("playback_output_latency", "low") + if latency not in ("low", "medium", "high"): + latency = "low" + + audio = np.asarray(audio, dtype=np.float32).squeeze() + if audio.ndim > 1: + audio = np.squeeze(audio) + if audio.size == 0: + logger.warning("TTS: 播放跳过,音频长度为 0") + return + + if output_device is not None: + dev = _resolve_output_device_id(output_device) + else: + dev = get_playback_output_device_id() + if dev is None: + dev = _sounddevice_default_output_index() + if dev is None: + logger.warning("TTS: 无法解析输出设备索引,使用 sounddevice 默认输出") + else: + dev = int(dev) + + info = sd.query_devices(dev) if dev is not None else sd.query_devices(kind="output") + native_sr = int(float(info.get("default_samplerate", 48000))) + sr_out = int(sample_rate) + if force_native and native_sr > 0 and abs(native_sr - sr_out) > 1: + audio = _resample_playback_audio(audio, sr_out, native_sr) + sr_out = native_sr + logger.info( + f"TTS: 播放重采样 {sample_rate}Hz -> {sr_out}Hz(匹配设备 default_samplerate,避免 Windows 无声)" + ) + + peak_before = float(np.max(np.abs(audio))) + if do_normalize and peak_before > 1e-8 and peak_before > 0.95: + audio = (audio / peak_before * 0.92).astype(np.float32, copy=False) + + if gain != 1.0: + audio = (audio * np.float32(gain)).astype(np.float32, copy=False) + + audio = _fade_playback_edges(audio, sr_out, fade_ms) + + peak = float(np.max(np.abs(audio))) + rms = float(np.sqrt(np.mean(np.square(audio)))) + dname = info.get("name", "?") if isinstance(info, dict) else "?" + logger.info( + f"TTS: 播放 峰值={peak:.5f} RMS={rms:.5f} sr={sr_out}Hz 设备={dev!r} ({dname!r})" + ) + if peak < 1e-8: + logger.warning("TTS: 波形接近静音,请检查合成是否异常") + + audio = np.clip(audio, -1.0, 1.0).astype(np.float32, copy=False) + block = audio.reshape(-1, 1) + + try: + with sd.OutputStream( + device=dev, + channels=1, + samplerate=sr_out, + dtype="float32", + latency=latency, + ) as stream: + stream.write(block) + except Exception as e: + logger.warning(f"TTS: OutputStream 失败,回退 sd.play: {e}", exc_info=True) + sd.play(block, samplerate=sr_out, device=dev) + sd.wait() + + +def play_wav_path( + path: str | Path, + *, + output_device: Optional[object] = None, +) -> None: + """ + 播放 16-bit PCM WAV(单声道或立体声下混为单声道),走与 synthesize + play_tts_audio + 相同的 sounddevice 输出路径(含 ROCKET_TTS_DEVICE / yaml 设备解析)。 + """ + import wave + + p = Path(path) + with wave.open(str(p), "rb") as wf: + ch = int(wf.getnchannels()) + sw = int(wf.getsampwidth()) + sr = int(wf.getframerate()) + nframes = int(wf.getnframes()) + if sw != 2: + raise ValueError(f"仅支持 16-bit PCM: {p}") + raw = wf.readframes(nframes) + mono = np.frombuffer(raw, dtype=" None: + """ + 合成并播放一段语音;失败时仅打日志,不向外抛异常(适合命令成功后的反馈)。 + """ + if not text or not str(text).strip(): + return + try: + engine = tts or KokoroOnnxTTS() + t = str(text).strip() + logger.info(f"TTS: 开始合成并播放: {t!r}") + audio, sr = engine.synthesize(t) + play_tts_audio(audio, sr, output_device=output_device) + logger.info("TTS: 播放完成") + except Exception as e: + logger.warning(f"TTS 播放失败: {e}", exc_info=True) + + +__all__ = [ + "KokoroOnnxTTS", + "play_tts_audio", + "play_wav_path", + "speak_text", + "get_playback_output_device_id", + "log_sounddevice_output_devices", +] + +if __name__ == "__main__": + # 与主程序一致:使用 play_tts_audio(含重采样到设备 native 采样率) + tts = KokoroOnnxTTS() + + text = "任务执行完成,开始返航降落" + + print(f"正在合成语音: {text}") + audio, sr = tts.synthesize(text) + + print("正在播放(与主程序相同 play_tts_audio 路径)...") + try: + play_tts_audio(audio, sr) + print("播放结束。") + except Exception as e: + print(f"播放失败: {e}") + + # === 保存为 WAV 文件(可选)=== + try: + output_path = "任务执行完成,开始返航降落.wav" + tts.synthesize_to_file(text, output_path) + print(f"音频已保存至: {output_path}") + except RuntimeError as e: + print(f"保存失败(可能缺少 scipy): {e}") + diff --git a/voice_drone/core/tts_ack_cache.py b/voice_drone/core/tts_ack_cache.py new file mode 100644 index 0000000..1abce65 --- /dev/null +++ b/voice_drone/core/tts_ack_cache.py @@ -0,0 +1,152 @@ +""" +应答 TTS 波形磁盘缓存:文案与 TTS 配置未变时跳过逐条合成,加快启动。 + +缓存目录:项目根下 cache/ack_tts_pcm/ +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np + +from voice_drone.core.configuration import SYSTEM_TTS_CONFIG + +# 与 src/core/configuration.py 一致:src/core/tts_ack_cache.py -> parents[2] +_PROJECT_ROOT = Path(__file__).resolve().parents[2] + +ACK_PCM_CACHE_DIR = _PROJECT_ROOT / "cache" / "ack_tts_pcm" +MANIFEST_NAME = "manifest.json" +CACHE_FORMAT = 1 + + +def _tts_signature() -> dict: + tts = SYSTEM_TTS_CONFIG or {} + return { + "model_dir": str(tts.get("model_dir", "")), + "model_name": str(tts.get("model_name", "")), + "voice": str(tts.get("voice", "")), + "speed": round(float(tts.get("speed", 1.0)), 6), + "sample_rate": int(tts.get("sample_rate", 24000)), + } + + +def compute_ack_pcm_fingerprint( + unique_phrases: List[str], + *, + global_text: Optional[str] = None, + mode_phrases: bool = True, +) -> str: + """文案 + TTS 签名变化则指纹变,磁盘缓存失效。""" + payload = { + "cache_format": CACHE_FORMAT, + "tts": _tts_signature(), + "mode_phrases": mode_phrases, + } + if mode_phrases: + payload["phrases"] = sorted(unique_phrases) + else: + payload["global_text"] = (global_text or "").strip() + raw = json.dumps(payload, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _phrase_file_stem(fingerprint: str, phrase: str) -> str: + h = hashlib.sha256(fingerprint.encode("utf-8")) + h.update(b"\0") + h.update(phrase.encode("utf-8")) + return h.hexdigest()[:40] + + +def _load_one_npz(path: Path) -> Optional[Tuple[np.ndarray, int]]: + try: + z = np.load(path, allow_pickle=False) + audio = np.asarray(z["audio"], dtype=np.float32).squeeze() + sr = int(np.asarray(z["sr"]).reshape(-1)[0]) + if audio.size == 0 or sr <= 0: + return None + return (audio, sr) + except Exception: + return None + + +def load_cached_phrases( + unique_phrases: List[str], + fingerprint: str, +) -> Tuple[Dict[str, Tuple[np.ndarray, int]], List[str]]: + """ + 从磁盘加载与 fingerprint 匹配的缓存。 + + Returns: + (已加载的 phrase -> (audio, sr), 仍需合成的 phrase 列表) + """ + out: Dict[str, Tuple[np.ndarray, int]] = {} + if not unique_phrases: + return {}, [] + + cache_dir = ACK_PCM_CACHE_DIR + manifest_path = cache_dir / MANIFEST_NAME + if not manifest_path.is_file(): + return {}, list(unique_phrases) + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except Exception: + return {}, list(unique_phrases) + + if int(manifest.get("format", 0)) != CACHE_FORMAT: + return {}, list(unique_phrases) + if manifest.get("fingerprint") != fingerprint: + return {}, list(unique_phrases) + + files: Dict[str, str] = manifest.get("files") or {} + missing: List[str] = [] + + for phrase in unique_phrases: + fname = files.get(phrase) + if not fname: + missing.append(phrase) + continue + path = cache_dir / fname + if not path.is_file(): + missing.append(phrase) + continue + loaded = _load_one_npz(path) + if loaded is None: + missing.append(phrase) + continue + out[phrase] = loaded + + return out, missing + + +def persist_phrases(fingerprint: str, phrase_pcm: Dict[str, Tuple[np.ndarray, int]]) -> None: + """写入/更新整包 manifest 与各句 npz(覆盖同名 manifest)。""" + if not phrase_pcm: + return + + cache_dir = ACK_PCM_CACHE_DIR + cache_dir.mkdir(parents=True, exist_ok=True) + + files: Dict[str, str] = {} + for phrase, (audio, sr) in phrase_pcm.items(): + stem = _phrase_file_stem(fingerprint, phrase) + fname = f"{stem}.npz" + path = cache_dir / fname + audio = np.asarray(audio, dtype=np.float32).squeeze() + np.savez_compressed(path, audio=audio, sr=np.array([int(sr)], dtype=np.int32)) + files[phrase] = fname + + manifest = { + "format": CACHE_FORMAT, + "fingerprint": fingerprint, + "files": files, + } + tmp = cache_dir / (MANIFEST_NAME + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=0) + tmp.replace(cache_dir / MANIFEST_NAME) diff --git a/voice_drone/core/vad.py b/voice_drone/core/vad.py new file mode 100644 index 0000000..c99cc3a --- /dev/null +++ b/voice_drone/core/vad.py @@ -0,0 +1,429 @@ +""" +语音活动检测(VAD)模块 - 纯 ONNX Runtime 版 Silero VAD + +使用 Silero VAD 的 ONNX 模型检测语音活动,识别语音的开始和结束。 +不依赖 PyTorch/silero_vad 包,只依赖 onnxruntime + numpy。 +""" + +import os +from pathlib import Path +from typing import Optional + +import numpy as np +import onnxruntime as ort +import multiprocessing +from voice_drone.core.configuration import ( + SYSTEM_AUDIO_CONFIG, + SYSTEM_RECOGNIZER_CONFIG, + SYSTEM_VAD_CONFIG, +) +from voice_drone.logging_ import get_logger +from voice_drone.tools.wrapper import time_cost +logger = get_logger("vad.silero_onnx") + + +class VAD: + """ + 语音活动检测器 + + 使用 ONNX Runtime + """ + + def __init__(self): + """ + 初始化 VAD 检测器 + + Args: + config: 可选的配置字典,用于覆盖默认配置 + """ + # ---- 从 system.yaml 的 vad 部分读取默认配置 ---- + # system.yaml: + # vad: + # threshold: 0.65 + # start_frame: 3 + # end_frame: 10 + # min_silence_duration_s: 0.5 + # max_silence_duration_s: 30 + # model_path: "src/models/silero_vad.onnx" + vad_conf = SYSTEM_VAD_CONFIG + + # 语音概率阈值(YAML 可能是字符串) + self.speech_threshold = float(vad_conf.get("threshold", 0.5)) + + # 连续多少帧检测到语音才认为“开始说话” + self.speech_start_frames = int(vad_conf.get("start_frame", 3)) + + # 连续多少帧检测到静音才认为“结束说话” + self.silence_end_frames = int(vad_conf.get("end_frame", 10)) + + # 可选: 最短/最长语音段时长(秒),可以在上层按需使用 + self.min_speech_duration = vad_conf.get("min_silence_duration_s") + self.max_speech_duration = vad_conf.get("max_silence_duration_s") + + # 采样率来自 audio 配置 + self.sample_rate = SYSTEM_AUDIO_CONFIG.get("sample_rate") + + # 与 recognizer 一致:能量 VAD 时不加载 Silero(避免无模型文件仍强加载) + _ev_env = os.environ.get("ROCKET_ENERGY_VAD", "").lower() in ( + "1", + "true", + "yes", + ) + _yaml_backend = str( + SYSTEM_RECOGNIZER_CONFIG.get("vad_backend", "silero") + ).lower() + if _ev_env or _yaml_backend == "energy": + self.onnx_session = None + self.vad_model_path = None + self.window_size = 512 if int(self.sample_rate or 16000) == 16000 else 256 + self.input_name = None + self.sr_input_name = None + self.state_input_name = None + self.output_name = None + self.state = None + self.speech_frame_count = 0 + self.silence_frame_count = 0 + self.is_speaking = False + logger.info( + "VAD:能量(RMS)分段模式,跳过 Silero ONNX(与 ROCKET_ENERGY_VAD / vad_backend 一致)" + ) + return + + # ---- 加载 Silero VAD ONNX 模型 ---- + raw_mp = SYSTEM_VAD_CONFIG.get("model_path") + if not raw_mp: + raise FileNotFoundError( + "vad.model_path 未配置。若只用能量 VAD,请在 system.yaml 中设 " + "recognizer.vad_backend: energy 并设置 ROCKET_ENERGY_VAD=1" + ) + mp = Path(raw_mp) + if not mp.is_absolute(): + mp = Path(__file__).resolve().parents[2] / mp + self.vad_model_path = str(mp) + if not mp.is_file(): + raise FileNotFoundError( + f"Silero VAD 模型不存在: {self.vad_model_path}。请下载 silero_vad.onnx 到该路径," + "或改用能量 VAD:recognizer.vad_backend: energy 且 ROCKET_ENERGY_VAD=1" + ) + + try: + logger.info(f"正在加载 Silero VAD ONNX 模型: {self.vad_model_path}") + sess_options = ort.SessionOptions() + cpu_count = multiprocessing.cpu_count() + optimal_threads = min(4, cpu_count) + sess_options.intra_op_num_threads = optimal_threads + sess_options.inter_op_num_threads = optimal_threads + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + + self.onnx_session = ort.InferenceSession( + str(mp), + sess_options=sess_options, + providers=["CPUExecutionProvider"], + ) + + inputs = self.onnx_session.get_inputs() + outputs = self.onnx_session.get_outputs() + if not inputs: + raise RuntimeError("VAD ONNX 模型没有输入节点") + + # ---- 解析输入 / 输出 ---- + # 典型的 Silero VAD ONNX 会有: + # - 输入: audio(input), 采样率(sr), 状态(state 或 h/c) + # - 输出: 语音概率 + 可选的新状态 + self.input_name = None + self.sr_input_name: Optional[str] = None + self.state_input_name: Optional[str] = None + + for inp in inputs: + name = inp.name + if self.input_name is None: + # 优先匹配常见名称,否则退回第一个 + if name in ("input", "audio", "waveform"): + self.input_name = name + else: + self.input_name = name + if name == "sr": + self.sr_input_name = name + if name in ("state", "h", "c", "hidden"): + self.state_input_name = name + + # 如果依然没有确定 input_name,兜底使用第一个 + if self.input_name is None: + self.input_name = inputs[0].name + + self.output_name = outputs[0].name if outputs else None + + # 预分配状态向量(如果模型需要) + self.state: Optional[np.ndarray] = None + if self.state_input_name is not None: + state_inp = next(i for i in inputs if i.name == self.state_input_name) + # state 的 shape 通常是 [1, N] 或 [N], 这里用 0 初始化 + state_shape = [ + int(d) if isinstance(d, int) and d > 0 else 1 + for d in (state_inp.shape or [1]) + ] + self.state = np.zeros(state_shape, dtype=np.float32) + + # 从输入 shape 推断窗口大小: (batch, samples) 或 (samples,) + input_shape = inputs[0].shape + win_size = None + if isinstance(input_shape, (list, tuple)) and len(input_shape) >= 1: + last_dim = input_shape[-1] + if isinstance(last_dim, int): + win_size = last_dim + if win_size is None: + win_size = 512 if self.sample_rate == 16000 else 256 + self.window_size = int(win_size) + + logger.info( + f"Silero VAD ONNX 模型加载完成: 输入={self.input_name}, 输出={self.output_name}, " + f"window_size={self.window_size}, sample_rate={self.sample_rate}" + ) + except Exception as e: + logger.error(f"Silero VAD ONNX 模型加载失败: {e}") + raise RuntimeError( + f"无法加载 Silero VAD: {e}。若无需 Silero,请设 ROCKET_ENERGY_VAD=1 且 " + "recognizer.vad_backend: energy" + ) from e + + # State tracking + self.speech_frame_count = 0 # Consecutive speech frame count + self.silence_frame_count = 0 # Consecutive silence frame count + self.is_speaking = False # Currently speaking + + logger.info( + "VADDetector 初始化完成: " + f"speech_threshold={self.speech_threshold}, " + f"speech_start_frames={self.speech_start_frames}, " + f"silence_end_frames={self.silence_end_frames}, " + f"sample_rate={self.sample_rate}Hz" + ) + + # @time_cost("VAD-语音检测耗时") + def is_speech(self, audio_chunk: bytes) -> bool: + """ + 检测音频块是否包含语音 + + Args: + audio_chunk: 音频数据(bytes),必须是 16kHz, 16-bit, 单声道 PCM + + Returns: + True 表示检测到语音,False 表示静音 + """ + try: + if self.onnx_session is None: + return False + # 将 bytes 转换为 numpy array(int16),确保 little-endian 字节序 + audio_array = np.frombuffer(audio_chunk, dtype=" required_samples: + num_chunks = len(audio_float) // required_samples + speech_probs = [] + + for i in range(num_chunks): + start_idx = i * required_samples + end_idx = start_idx + required_samples + chunk = audio_float[start_idx:end_idx] + + # 模型通常期望输入形状为 (1, samples) + input_data = chunk[np.newaxis, :].astype(np.float32) + ort_inputs = {self.input_name: input_data} + + # 如果模型需要 sr,state 等附加输入,一并提供 + if getattr(self, "sr_input_name", None) is not None: + # Silero VAD 一般期望 int64 采样率 + ort_inputs[self.sr_input_name] = np.array( + [self.sample_rate], dtype=np.int64 + ) + if getattr(self, "state_input_name", None) is not None and self.state is not None: + ort_inputs[self.state_input_name] = self.state + + outputs = self.onnx_session.run(None, ort_inputs) + + # 如果模型返回新的 state,更新内部状态 + if ( + getattr(self, "state_input_name", None) is not None + and len(outputs) > 1 + ): + self.state = outputs[1] + + prob = float(outputs[0].reshape(-1)[0]) + speech_probs.append(prob) + + speech_prob = float(np.mean(speech_probs)) + else: + input_data = audio_float[:required_samples][np.newaxis, :].astype(np.float32) + ort_inputs = {self.input_name: input_data} + + if getattr(self, "sr_input_name", None) is not None: + ort_inputs[self.sr_input_name] = np.array( + [self.sample_rate], dtype=np.int64 + ) + if getattr(self, "state_input_name", None) is not None and self.state is not None: + ort_inputs[self.state_input_name] = self.state + + outputs = self.onnx_session.run(None, ort_inputs) + + if ( + getattr(self, "state_input_name", None) is not None + and len(outputs) > 1 + ): + self.state = outputs[1] + + speech_prob = float(outputs[0].reshape(-1)[0]) + + return speech_prob >= self.speech_threshold + except Exception as e: + logger.error(f"VAD detection failed: {e}") + return False + + def is_speech_numpy(self, audio_array: np.ndarray) -> bool: + """ + 检测音频数组是否包含语音 + + Args: + audio_array: 音频数据(numpy array,dtype=int16) + + Returns: + True 表示检测到语音,False 表示静音 + """ + # 转换为 bytes + audio_bytes = audio_array.tobytes() + return self.is_speech(audio_bytes) + + def detect_speech_start(self, audio_chunk: bytes) -> bool: + """ + 检测语音开始 + + 需要连续检测到多帧语音才认为语音开始 + + Args: + audio_chunk: 音频数据块 + + Returns: + True 表示检测到语音开始 + """ + if self.is_speaking: + return False + + if self.is_speech(audio_chunk): + self.speech_frame_count += 1 + self.silence_frame_count = 0 + + if self.speech_frame_count >= self.speech_start_frames: + self.is_speaking = True + self.speech_frame_count = 0 + logger.info("Speech start detected") + return True + else: + self.speech_frame_count = 0 + + return False + + def detect_speech_end(self, audio_chunk: bytes) -> bool: + """ + 检测语音结束 + + 需要连续检测到多帧静音才认为语音结束 + + Args: + audio_chunk: 音频数据块 + + Returns: + True 表示检测到语音结束 + """ + if not self.is_speaking: + return False + + if not self.is_speech(audio_chunk): + self.silence_frame_count += 1 + self.speech_frame_count = 0 + + if self.silence_frame_count >= self.silence_end_frames: + self.is_speaking = False + self.silence_frame_count = 0 + logger.info("Speech end detected") + return True + else: + self.silence_frame_count = 0 + + return False + + def reset(self) -> None: + """ + 重置检测器状态 + + 清除帧计数、是否在说话标记,以及 Silero 的 RNN 状态(长间隔后应清零,避免与后续音频错位)。 + """ + self.speech_frame_count = 0 + self.silence_frame_count = 0 + self.is_speaking = False + if self.state is not None: + self.state.fill(0) + logger.debug("VAD detector state reset") + + +if __name__ == "__main__": + """ + 使用测试音频按帧扫描,统计语音帧比例,更直观地验证 VAD 是否工作正常。 + """ + import wave + + vad = VAD() + audio_file = "test/测试音频.wav" + + # 1. 读取 wav + with wave.open(audio_file, "rb") as wf: + n_channels = wf.getnchannels() + sampwidth = wf.getsampwidth() + framerate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + + # 2. 转成 int16 数组 + audio = np.frombuffer(raw, dtype=" 单声道 + if n_channels == 2: + audio = audio.reshape(-1, 2) + audio = audio.mean(axis=1).astype(np.int16) + + # 4. 重采样到 VAD 所需采样率(通常 16k) + target_sr = vad.sample_rate + if framerate != target_sr: + x_old = np.linspace(0, 1, num=len(audio), endpoint=False) + x_new = np.linspace(0, 1, num=int(len(audio) * target_sr / framerate), endpoint=False) + audio = np.interp(x_new, x_old, audio).astype(np.int16) + + print("wav info:", n_channels, "ch,", framerate, "Hz") + print("audio len (samples):", len(audio), " target_sr:", target_sr) + + # 5. 按 VAD 窗口大小逐帧扫描 + frame_samples = vad.window_size + frame_bytes = frame_samples * 2 # int16 -> 2 字节 + audio_bytes = audio.tobytes() + num_frames = len(audio_bytes) // frame_bytes + + speech_frames = 0 + for i in range(num_frames): + chunk = audio_bytes[i * frame_bytes : (i + 1) * frame_bytes] + if vad.is_speech(chunk): + speech_frames += 1 + + speech_ratio = speech_frames / num_frames if num_frames > 0 else 0.0 + print("total frames:", num_frames) + print("speech frames:", speech_frames) + print("speech ratio:", speech_ratio) + print("has_any_speech:", speech_frames > 0) \ No newline at end of file diff --git a/voice_drone/core/wake_word.py b/voice_drone/core/wake_word.py new file mode 100644 index 0000000..54963d2 --- /dev/null +++ b/voice_drone/core/wake_word.py @@ -0,0 +1,375 @@ +""" +唤醒词检测模块 - 高性能实时唤醒词识别 + +支持: +- 精确匹配 +- 模糊匹配(同音字、拼音) +- 部分匹配 +- 配置化变体映射 + +性能优化: +- 预编译正则表达式 +- LRU缓存匹配结果 +- 优化字符串操作 +""" + +import re +from typing import Optional, List, Tuple +from functools import lru_cache +from voice_drone.logging_ import get_logger +from voice_drone.core.configuration import ( + WAKE_WORD_PRIMARY, + WAKE_WORD_VARIANTS, + WAKE_WORD_MATCHING_CONFIG +) + +logger = get_logger("wake_word") + +# 延迟加载可选依赖 +try: + from pypinyin import lazy_pinyin, Style + PYPINYIN_AVAILABLE = True +except ImportError: + PYPINYIN_AVAILABLE = False + logger.warning("pypinyin 未安装,拼音匹配功能将受限") + + +class WakeWordDetector: + """ + 唤醒词检测器 + + 支持多种匹配模式: + - 精确匹配:完全匹配唤醒词 + - 模糊匹配:同音字、拼音变体 + - 部分匹配:只匹配部分唤醒词 + """ + + def __init__(self): + """初始化唤醒词检测器""" + logger.info("初始化唤醒词检测器...") + + # 从配置加载 + self.primary = WAKE_WORD_PRIMARY + self.variants = WAKE_WORD_VARIANTS or [] + self.matching_config = WAKE_WORD_MATCHING_CONFIG or {} + + # 匹配配置 + self.enable_fuzzy = self.matching_config.get("enable_fuzzy", True) + self.enable_partial = self.matching_config.get("enable_partial", True) + self.ignore_case = self.matching_config.get("ignore_case", True) + self.ignore_spaces = self.matching_config.get("ignore_spaces", True) + self.min_match_length = self.matching_config.get("min_match_length", 2) + self.similarity_threshold = self.matching_config.get("similarity_threshold", 0.7) + + # 构建匹配模式 + self._build_patterns() + + logger.info(f"唤醒词检测器初始化完成") + logger.info(f" 主唤醒词: {self.primary}") + logger.info(f" 变体数量: {len(self.variants)}") + logger.info(f" 模糊匹配: {'启用' if self.enable_fuzzy else '禁用'}") + logger.info(f" 部分匹配: {'启用' if self.enable_partial else '禁用'}") + + def _build_patterns(self): + """构建匹配模式(预编译正则表达式)""" + # 标准化所有变体(去除空格、转小写等) + self.normalized_variants = [] + + for variant in self.variants: + normalized = self._normalize_text(variant) + if normalized: + self.normalized_variants.append(normalized) + + # 去重 + self.normalized_variants = list(set(self.normalized_variants)) + + # 按长度降序排序(优先匹配长模式) + self.normalized_variants.sort(key=len, reverse=True) + + # 构建正则表达式模式 + patterns = [] + for variant in self.normalized_variants: + # 转义特殊字符 + escaped = re.escape(variant) + patterns.append(escaped) + + # 编译单一正则表达式 + if patterns: + self.pattern = re.compile('|'.join(patterns), re.IGNORECASE if self.ignore_case else 0) + else: + self.pattern = None + + logger.debug(f"构建了 {len(self.normalized_variants)} 个匹配模式") + + def _normalize_text(self, text: str) -> str: + """ + 标准化文本(用于匹配) + + Args: + text: 原始文本 + + Returns: + 标准化后的文本 + """ + if not text: + return "" + + normalized = text + + # 忽略大小写 + if self.ignore_case: + normalized = normalized.lower() + + # 忽略空格 + if self.ignore_spaces: + normalized = normalized.replace(' ', '').replace('\t', '').replace('\n', '') + + return normalized.strip() + + def _get_pinyin(self, text: str) -> str: + """ + 获取文本的拼音(用于拼音匹配) + + Args: + text: 中文文本 + + Returns: + 拼音字符串(小写,无空格) + """ + if not PYPINYIN_AVAILABLE: + return "" + + try: + pinyin_list = lazy_pinyin(text, style=Style.NORMAL) + return ''.join(pinyin_list).lower() + except Exception as e: + logger.debug(f"拼音转换失败: {e}") + return "" + + def _fuzzy_match(self, text: str, variant: str) -> bool: + """ + 模糊匹配(同音字、拼音) + + Args: + text: 待匹配文本 + variant: 变体文本 + + Returns: + 是否匹配 + """ + # 1. 精确匹配(已标准化) + normalized_text = self._normalize_text(text) + normalized_variant = self._normalize_text(variant) + + if normalized_text == normalized_variant: + return True + + # 2. 拼音匹配 + if PYPINYIN_AVAILABLE: + text_pinyin = self._get_pinyin(text) + variant_pinyin = self._get_pinyin(variant) + + if text_pinyin and variant_pinyin: + # 完全匹配拼音 + if text_pinyin == variant_pinyin: + return True + + # 部分匹配拼音(至少匹配一半) + if len(variant_pinyin) >= 2: + # 检查是否包含变体的拼音 + if variant_pinyin in text_pinyin or text_pinyin in variant_pinyin: + # 计算相似度 + similarity = min(len(variant_pinyin), len(text_pinyin)) / max(len(variant_pinyin), len(text_pinyin)) + if similarity >= self.similarity_threshold: + return True + + # 3. 字符级相似度匹配(简单实现) + if len(normalized_text) >= self.min_match_length and len(normalized_variant) >= self.min_match_length: + # 检查是否包含变体 + if normalized_variant in normalized_text or normalized_text in normalized_variant: + return True + + return False + + def _partial_match(self, text: str) -> bool: + """ + 部分匹配(只匹配部分唤醒词,如主词较长时取前半段;短词请在配置 variants 中列出) + + Args: + text: 待匹配文本 + + Returns: + 是否匹配 + """ + if not self.enable_partial: + return False + + normalized_text = self._normalize_text(text) + + # 检查是否包含主唤醒词的一部分 + if self.primary: + # 提取主唤醒词的前半部分(如四字词可拆成前两字) + primary_normalized = self._normalize_text(self.primary) + if len(primary_normalized) >= self.min_match_length * 2: + half_length = len(primary_normalized) // 2 + half_wake_word = primary_normalized[:half_length] + + if len(half_wake_word) >= self.min_match_length: + if half_wake_word in normalized_text: + return True + + return False + + @lru_cache(maxsize=256) + def detect(self, text: str) -> Tuple[bool, Optional[str]]: + """ + 检测文本中是否包含唤醒词 + + Args: + text: 待检测文本 + + Returns: + (是否匹配, 匹配到的唤醒词) + """ + if not text or not self.pattern: + return False, None + + normalized_text = self._normalize_text(text) + + # 1. 精确匹配(使用正则表达式) + if self.pattern: + match = self.pattern.search(normalized_text) + if match: + matched_text = match.group(0) + logger.debug(f"精确匹配到唤醒词: {matched_text}") + return True, matched_text + + # 2. 模糊匹配(同音字、拼音) + if self.enable_fuzzy: + for variant in self.normalized_variants: + if self._fuzzy_match(normalized_text, variant): + logger.debug(f"模糊匹配到唤醒词变体: {variant}") + return True, variant + + # 3. 部分匹配 + if self._partial_match(normalized_text): + logger.debug(f"部分匹配到唤醒词") + return True, self.primary[:len(self.primary)//2] if self.primary else None + + return False, None + + def extract_command_text(self, text: str) -> Optional[str]: + """ + 从文本中提取命令部分(移除唤醒词) + + Args: + text: 包含唤醒词的完整文本 + + Returns: + 提取的命令文本,如果未检测到唤醒词返回None + """ + is_wake, matched_wake_word = self.detect(text) + + if not is_wake: + return None + + # 标准化文本用于查找 + normalized_text = self._normalize_text(text) + normalized_wake = self._normalize_text(matched_wake_word) if matched_wake_word else "" + + if not normalized_wake or normalized_wake not in normalized_text: + return None + + # 找到唤醒词在标准化文本中的位置 + idx = normalized_text.find(normalized_wake) + if idx < 0: + return None + + # 方法1:尝试在原始文本中精确查找匹配的变体 + original_text = text + text_lower = original_text.lower() + + # 查找所有可能的变体在原始文本中的位置 + best_match_idx = -1 + best_match_length = 0 + + # 检查配置中的所有变体 + for variant in self.variants: + variant_normalized = self._normalize_text(variant) + if variant_normalized == normalized_wake: + # 这个变体匹配到了,尝试在原始文本中找到它 + variant_lower = variant.lower() + if variant_lower in text_lower: + variant_idx = text_lower.find(variant_lower) + if variant_idx >= 0: + # 选择最长的匹配(更准确) + if len(variant) > best_match_length: + best_match_idx = variant_idx + best_match_length = len(variant) + + # 如果找到了匹配的变体 + if best_match_idx >= 0: + command_start = best_match_idx + best_match_length + command_text = original_text[command_start:].strip() + # 移除开头的标点符号 + command_text = command_text.lstrip(',。、,.').strip() + return command_text if command_text else None + + # 方法2:回退方案 - 使用字符计数近似定位 + # 计算标准化文本中唤醒词结束位置对应的原始文本位置 + wake_end_in_normalized = idx + len(normalized_wake) + + # 计算原始文本中对应的字符位置 + char_count = 0 + for i, char in enumerate(original_text): + normalized_char = self._normalize_text(char) + if normalized_char: + if char_count >= wake_end_in_normalized: + command_text = original_text[i:].strip() + command_text = command_text.lstrip(',。、,.').strip() + return command_text if command_text else None + char_count += 1 + + return None + + +# 全局单例 +_global_detector: Optional[WakeWordDetector] = None + + +def get_wake_word_detector() -> WakeWordDetector: + """获取全局唤醒词检测器实例(单例模式)""" + global _global_detector + if _global_detector is None: + _global_detector = WakeWordDetector() + return _global_detector + + +if __name__ == "__main__": + # 测试代码 + detector = WakeWordDetector() + + test_cases = [ + ("无人机,现在起飞", True), + ("wu ren ji 现在起飞", True), + ("Wu Ren Ji 现在起飞", True), + ("五人机,现在起飞", True), + ("现在起飞", False), + ("无人,现在起飞", True), # 变体列表中的短说 + ("人机 前进", False), # 已移除单独「人机」变体,避免子串误唤醒 + ("无人机 前进", True), + ] + + print("=" * 60) + print("唤醒词检测测试") + print("=" * 60) + + for text, expected in test_cases: + is_wake, matched = detector.detect(text) + command_text = detector.extract_command_text(text) + status = "OK" if is_wake == expected else "FAIL" + print(f"{status} 文本: {text}") + print(f" 匹配: {is_wake} (期望: {expected})") + print(f" 匹配词: {matched}") + print(f" 提取命令: {command_text}") + print() diff --git a/voice_drone/core/任务执行完成,开始返航降落.wav b/voice_drone/core/任务执行完成,开始返航降落.wav new file mode 100644 index 0000000000000000000000000000000000000000..1dd0d133a313173a353c8816bf2a991b897a373e GIT binary patch literal 171644 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%0DBF|=M1AEG;{L~oVbo|amnxEJIJ;$&UTQSf*$?D^ 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�@H}cO-3d=B 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 literal 0 HcmV?d00001 diff --git a/voice_drone/core/好的收到,开始起飞.wav b/voice_drone/core/好的收到,开始起飞.wav new file mode 100644 index 0000000000000000000000000000000000000000..5e61583466e04d9c38a820b0a1a8a12edae1ffeb GIT binary patch literal 118844 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

E{ua} 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 float: + siny_cosp = 2.0 * (w * z + x * y) + cosy_cosp = 1.0 - 2.0 * (y * y + z * z) + return math.atan2(siny_cosp, cosy_cosp) + + +class MavrosFlightExecutor: + """单次连接 MAVROS;对外提供 execute(intent)。""" + + def __init__(self) -> None: + rospy.init_node("flight_intent_mavros_bridge", anonymous=True) + + self.state = State() + self.pose = PoseStamped() + self.has_pose = False + + rospy.Subscriber("/mavros/state", State, self._state_cb, queue_size=10) + rospy.Subscriber( + "/mavros/local_position/pose", + PoseStamped, + self._pose_cb, + queue_size=10, + ) + self.sp_pub = rospy.Publisher( + "/mavros/setpoint_raw/local", + PositionTarget, + queue_size=20, + ) + + rospy.wait_for_service("/mavros/cmd/arming", timeout=60.0) + rospy.wait_for_service("/mavros/set_mode", timeout=60.0) + self._arming_name = "/mavros/cmd/arming" + self._set_mode_name = "/mavros/set_mode" + self.arm_srv = rospy.ServiceProxy(self._arming_name, CommandBool) + self.mode_srv = rospy.ServiceProxy(self._set_mode_name, SetMode) + + self.rate = rospy.Rate(20) + + self.default_takeoff_relative_m = rospy.get_param( + "~default_takeoff_relative_m", + 0.5, + ) + self.takeoff_timeout_sec = rospy.get_param("~takeoff_timeout_sec", 15.0) + self.goto_tol = rospy.get_param("~goto_position_tolerance", 0.15) + self.goto_timeout_sec = rospy.get_param("~goto_timeout_sec", 60.0) + self.land_timeout_sec = rospy.get_param("~land_timeout_sec", 45.0) + self.pre_stream_count = int(rospy.get_param("~offboard_pre_stream_count", 80)) + + def _state_cb(self, msg: State) -> None: + self.state = msg + + def _pose_cb(self, msg: PoseStamped) -> None: + self.pose = msg + self.has_pose = True + + def _wait_connected_and_pose(self) -> None: + rospy.loginfo("等待 FCU 与本地位置 …") + while not rospy.is_shutdown() and not self.state.connected: + self.rate.sleep() + while not rospy.is_shutdown() and not self.has_pose: + self.rate.sleep() + + @staticmethod + def _position_sp(x: float, y: float, z: float) -> PositionTarget: + sp = PositionTarget() + sp.header.stamp = rospy.Time.now() + sp.coordinate_frame = PositionTarget.FRAME_LOCAL_NED + sp.type_mask = ( + PositionTarget.IGNORE_VX + | PositionTarget.IGNORE_VY + | PositionTarget.IGNORE_VZ + | PositionTarget.IGNORE_AFX + | PositionTarget.IGNORE_AFY + | PositionTarget.IGNORE_AFZ + | PositionTarget.IGNORE_YAW + | PositionTarget.IGNORE_YAW_RATE + ) + sp.position.x = x + sp.position.y = y + sp.position.z = z + return sp + + def _publish_sp(self, sp: PositionTarget) -> None: + sp.header.stamp = rospy.Time.now() + self.sp_pub.publish(sp) + + def _stream_init_setpoint(self, x: float, y: float, z: float) -> None: + sp = self._position_sp(x, y, z) + for _ in range(self.pre_stream_count): + if rospy.is_shutdown(): + return + self._publish_sp(sp) + self.rate.sleep() + + def _refresh_mode_arm_proxies(self) -> None: + """MAVROS 重启或链路抖事后,旧 ServiceProxy 可能一直报 unavailable,需重建。""" + try: + rospy.wait_for_service(self._set_mode_name, timeout=2.0) + rospy.wait_for_service(self._arming_name, timeout=2.0) + except rospy.ROSException: + return + self.mode_srv = rospy.ServiceProxy(self._set_mode_name, SetMode) + self.arm_srv = rospy.ServiceProxy(self._arming_name, CommandBool) + + def _try_set_mode_arm_offboard(self) -> None: + try: + if self.state.mode != "OFFBOARD": + self.mode_srv(base_mode=0, custom_mode="OFFBOARD") + if not self.state.armed: + self.arm_srv(True) + except (rospy.ServiceException, rospy.ROSException) as exc: + rospy.logwarn_throttle(2.0, "set_mode/arm: %s", exc) + + def _current_xyz(self) -> Tuple[float, float, float]: + p = self.pose.pose.position + return (p.x, p.y, p.z) + + def _do_takeoff(self, step: ActionTakeoff) -> None: + alt = step.args.relative_altitude_m + dz = float(alt) if alt is not None else float(self.default_takeoff_relative_m) + self._wait_connected_and_pose() + x0, y0, z0 = self._current_xyz() + # 与 px4_ctrl_offboard_demo.py 一致:z_tgt = z0 + dz + z_tgt = z0 + dz + rospy.loginfo( + "takeoff: z0=%.2f Δ=%.2f m -> z_target=%.2f(与 demo 相同约定)", + z0, + dz, + z_tgt, + ) + + self._stream_init_setpoint(x0, y0, z0) + t0 = rospy.Time.now() + deadline = t0 + rospy.Duration(self.takeoff_timeout_sec) + while not rospy.is_shutdown() and rospy.Time.now() < deadline: + self._try_set_mode_arm_offboard() + sp = self._position_sp(x0, y0, z_tgt) + self._publish_sp(sp) + z_now = self.pose.pose.position.z + if ( + abs(z_now - z_tgt) < 0.08 + and self.state.mode == "OFFBOARD" + and self.state.armed + ): + rospy.loginfo("takeoff reached") + break + self.rate.sleep() + else: + rospy.logwarn("takeoff timeout,继续后续步骤") + + def _do_hold_position(self, _label: str = "hover") -> None: + x, y, z = self._current_xyz() + rospy.loginfo("%s: 保持当前点 (%.2f, %.2f, %.2f) NED", _label, x, y, z) + sp = self._position_sp(x, y, z) + t_end = rospy.Time.now() + rospy.Duration(1.0) + while not rospy.is_shutdown() and rospy.Time.now() < t_end: + self._try_set_mode_arm_offboard() + self._publish_sp(sp) + self.rate.sleep() + + def _do_wait(self, step: ActionWait) -> None: + sec = float(step.args.seconds) + rospy.loginfo("wait %.2f s", sec) + t_end = rospy.Time.now() + rospy.Duration(sec) + x, y, z = self._current_xyz() + sp = self._position_sp(x, y, z) + while not rospy.is_shutdown() and rospy.Time.now() < t_end: + # Offboard 下建议保持 stream + if self.state.mode == "OFFBOARD" and self.state.armed: + self._publish_sp(sp) + self.rate.sleep() + + def _ned_delta_from_goto(self, step: ActionGoto) -> Optional[Tuple[float, float, float]]: + a = step.args + dx = 0.0 if a.x is None else float(a.x) + dy = 0.0 if a.y is None else float(a.y) + dz = 0.0 if a.z is None else float(a.z) + if a.frame == "local_ned": + return (dx, dy, dz) + # body_ned: x前 y右 z下 → 转到 NED 水平增量 + q = self.pose.pose.orientation + yaw = _yaw_from_quaternion(q.x, q.y, q.z, q.w) + north = math.cos(yaw) * dx - math.sin(yaw) * dy + east = math.sin(yaw) * dx + math.cos(yaw) * dy + return (north, east, dz) + + def _do_goto(self, step: ActionGoto) -> None: + delta = self._ned_delta_from_goto(step) + if delta is None: + rospy.logwarn("goto: unsupported frame") + return + dn, de, dd = delta + if dn == 0.0 and de == 0.0 and dd == 0.0: + rospy.loginfo("goto: 零位移,跳过") + return + + x0, y0, z0 = self._current_xyz() + xt, yt, zt = x0 + dn, y0 + de, z0 + dd + rospy.loginfo( + "goto: (%.2f,%.2f,%.2f) -> (%.2f,%.2f,%.2f) NED", + x0, + y0, + z0, + xt, + yt, + zt, + ) + deadline = rospy.Time.now() + rospy.Duration(self.goto_timeout_sec) + while not rospy.is_shutdown() and rospy.Time.now() < deadline: + self._try_set_mode_arm_offboard() + sp = self._position_sp(xt, yt, zt) + self._publish_sp(sp) + px, py, pz = self._current_xyz() + err = math.sqrt( + (px - xt) ** 2 + (py - yt) ** 2 + (pz - zt) ** 2 + ) + if err < self.goto_tol and self.state.mode == "OFFBOARD": + rospy.loginfo("goto: reached (err=%.3f)", err) + break + self.rate.sleep() + else: + rospy.logwarn("goto: timeout") + + def _do_land(self) -> None: + rospy.loginfo("land: AUTO.LAND") + t0 = rospy.Time.now() + deadline = t0 + rospy.Duration(self.land_timeout_sec) + fails = 0 + while not rospy.is_shutdown() and rospy.Time.now() < deadline: + x, y, z = self._current_xyz() + # OFFBOARD 时若停发 setpoint,PX4 会很快退出该模式,MAVROS 侧 set_mode 可能长期不可用 + if self.state.armed and self.state.mode == "OFFBOARD": + self._publish_sp(self._position_sp(x, y, z)) + try: + if self.state.mode != "AUTO.LAND": + self.mode_srv(base_mode=0, custom_mode="AUTO.LAND") + except (rospy.ServiceException, rospy.ROSException) as exc: + fails += 1 + rospy.logwarn_throttle(2.0, "AUTO.LAND: %s", exc) + if fails == 1 or fails % 15 == 0: + self._refresh_mode_arm_proxies() + if not self.state.armed: + rospy.loginfo("land: disarmed") + return + self.rate.sleep() + rospy.logwarn("land: timeout") + + def _do_rtl(self) -> None: + rospy.loginfo("return_home: AUTO.RTL") + t0 = rospy.Time.now() + deadline = t0 + rospy.Duration(self.land_timeout_sec * 2) + fails = 0 + while not rospy.is_shutdown() and rospy.Time.now() < deadline: + x, y, z = self._current_xyz() + if self.state.armed and self.state.mode == "OFFBOARD": + self._publish_sp(self._position_sp(x, y, z)) + try: + if self.state.mode != "AUTO.RTL": + self.mode_srv(base_mode=0, custom_mode="AUTO.RTL") + except (rospy.ServiceException, rospy.ROSException) as exc: + fails += 1 + rospy.logwarn_throttle(2.0, "RTL: %s", exc) + if fails == 1 or fails % 15 == 0: + self._refresh_mode_arm_proxies() + if not self.state.armed: + rospy.loginfo("RTL Finished(已 disarm)") + return + self.rate.sleep() + rospy.logwarn("rtl: timeout") + + def execute(self, intent: ValidatedFlightIntent) -> None: + rospy.loginfo( + "执行 flight_intent:steps=%d summary=%s", + len(intent.actions), + intent.summary[:80], + ) + self._wait_connected_and_pose() + + for i, act in enumerate(intent.actions): + if rospy.is_shutdown(): + break + rospy.loginfo("--- step %d/%d: %s", i + 1, len(intent.actions), type(act).__name__) + self._dispatch(act) + + rospy.loginfo("flight_intent 序列结束") + + def _dispatch(self, act: FlightAction) -> None: + if isinstance(act, ActionTakeoff): + self._do_takeoff(act) + elif isinstance(act, (ActionHover, ActionHold)): + self._do_hold_position("hover" if isinstance(act, ActionHover) else "hold") + elif isinstance(act, ActionWait): + self._do_wait(act) + elif isinstance(act, ActionGoto): + self._do_goto(act) + elif isinstance(act, ActionLand): + self._do_land() + elif isinstance(act, ActionReturnHome): + self._do_rtl() + else: + rospy.logwarn("未支持的动作: %r", act) diff --git a/voice_drone/flight_bridge/ros1_node.py b/voice_drone/flight_bridge/ros1_node.py new file mode 100644 index 0000000..28e9ff2 --- /dev/null +++ b/voice_drone/flight_bridge/ros1_node.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +ROS1 伴飞桥节点:订阅 JSON(std_msgs/String),校验 flight_intent v1 后调用 MAVROS 执行。 + +话题(默认): + 全局 /input(std_msgs/String),与 rostopic pub、语音端 ROCKET_FLIGHT_BRIDGE_TOPIC 一致。 + 可通过私有参数 ~input_topic 覆盖(须带前导 / 才是全局名)。 + +示例: + rostopic pub -1 /input std_msgs/String \\ + '{data: "{\"is_flight_intent\":true,\"version\":1,\"actions\":[{\"type\":\"land\",\"args\":{}}],\"summary\":\"降\"}"}' + +前提是:已 roslaunch mavros px4.launch …,且 /mavros/state connected。 +""" + +from __future__ import annotations + +import json +import threading + +import rospy +from std_msgs.msg import String + +from voice_drone.core.flight_intent import parse_flight_intent_dict +from voice_drone.flight_bridge.ros1_mavros_executor import MavrosFlightExecutor + + +def _coerce_flight_intent_dict(raw: dict) -> dict: + """允许仅传 {actions, summary?},补全顶层字段。""" + if raw.get("is_flight_intent") is True and raw.get("version") == 1: + return raw + actions = raw.get("actions") + if isinstance(actions, list) and actions: + summary = str(raw.get("summary") or "bridge").strip() or "bridge" + return { + "is_flight_intent": True, + "version": 1, + "actions": actions, + "summary": summary, + } + raise ValueError("JSON 须为完整 flight_intent 或含 actions 数组") + + +class FlightIntentBridgeNode: + def __init__(self) -> None: + self._exec = MavrosFlightExecutor() + self._busy = threading.Lock() + # 默认用绝对名 /input:若用相对名 "input",anonymous 节点下会变成 /flight_intent_mavros_bridge_*/input,与 rostopic pub /input 不一致。 + topic = rospy.get_param("~input_topic", "/input") + self._sub = rospy.Subscriber( + topic, + String, + self._on_input, + queue_size=1, + ) + rospy.loginfo("flight_intent_bridge 就绪:订阅 %s", topic) + + def _on_input(self, msg: String) -> None: + data = (msg.data or "").strip() + if not data: + return + if not self._busy.acquire(blocking=False): + rospy.logwarn("上一段 flight_intent 仍在执行,忽略本条") + return + + def _run() -> None: + try: + try: + raw = json.loads(data) + except json.JSONDecodeError as e: + rospy.logerr("JSON 解析失败: %s", e) + return + if not isinstance(raw, dict): + rospy.logerr("顶层须为 JSON object") + return + raw = _coerce_flight_intent_dict(raw) + parsed, errors = parse_flight_intent_dict(raw) + if errors or parsed is None: + rospy.logerr("flight_intent 校验失败: %s", errors) + return + self._exec.execute(parsed) + finally: + self._busy.release() + + threading.Thread(target=_run, daemon=True, name="flight-intent-exec").start() + + +def main() -> None: + FlightIntentBridgeNode() + rospy.spin() + + +if __name__ == "__main__": + main() diff --git a/voice_drone/logging_/__init__.py b/voice_drone/logging_/__init__.py new file mode 100644 index 0000000..95dc916 --- /dev/null +++ b/voice_drone/logging_/__init__.py @@ -0,0 +1,14 @@ +""" +日志系统入口 + +提供 get_logger 接口,返回带颜色控制台输出的 logger。 + +注意:文件夹名已改为 logging_ 以避免与标准库的 logging 模块冲突。 +""" + +# 由于文件夹名已改为 logging_,不再与标准库的 logging 冲突 +# 直接导入标准库的 logging 模块 +import logging + +# 现在可以安全导入 color_logger +from .color_logger import get_logger diff --git a/voice_drone/logging_/color_logger.py b/voice_drone/logging_/color_logger.py new file mode 100644 index 0000000..26b6c8f --- /dev/null +++ b/voice_drone/logging_/color_logger.py @@ -0,0 +1,107 @@ +# 直接导入标准库的 logging(不再有命名冲突) +import logging + +from typing import Optional + +try: + # 读取系统日志配置: level / debug 等 + from voice_drone.core.configuration import SYSTEM_LOGGING_CONFIG +except Exception: + SYSTEM_LOGGING_CONFIG = {"level": "INFO", "debug": False} + + +class ColorFormatter(logging.Formatter): + """简单彩色日志格式化器.""" + + COLORS = { + "DEBUG": "\033[36m", # 青色 + "INFO": "\033[32m", # 绿色 + "WARNING": "\033[33m", # 黄色 + "ERROR": "\033[31m", # 红色 + "CRITICAL": "\033[41m", # 红底 + } + RESET = "\033[0m" + + def format(self, record: logging.LogRecord) -> str: + level = record.levelname + color = self.COLORS.get(level, "") + msg = super().format(record) + return f"{color}{msg}{self.RESET}" if color else msg + + +def _resolve_level(config_level: Optional[str], debug_flag: bool) -> int: + """根据配置字符串和 debug 标志,解析出 logging 等级.""" + if config_level is None: + config_level = "INFO" + level_str = str(config_level).upper() + + # 特殊值: 关闭日志 + if level_str in ("OFF", "NONE", "DISABLE", "DISABLED"): + return logging.CRITICAL + 1 # 实际等同于全关 + + if debug_flag: + return logging.DEBUG + + mapping = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "WARN": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + return mapping.get(level_str, logging.INFO) + + +def get_logger(name: str = "app") -> logging.Logger: + """ + 获取一个带颜色控制台输出的 logger。 + + - name: logger 名称,按模块/子系统区分即可,例如 "stt.onnx" + 日志级别与启用/禁用由配置文件 `system.yaml` 决定: + + ```yaml + logging: + level: "INFO" # DEBUG/INFO/WARNING/ERROR/CRITICAL/OFF + debug: false # true 时强制 DEBUG + ``` + + 使用示例: + + ```python + from voice_drone.logging_ import get_logger + + logger = get_logger("stt.onnx") + logger.info("模型加载完成") + logger.warning("预热失败,将继续执行") + logger.error("推理出错") + ``` + """ + logger = logging.getLogger(name) + if logger.handlers: + # 已经配置过,直接复用,但更新 level + level = _resolve_level( + SYSTEM_LOGGING_CONFIG.get("level") if isinstance(SYSTEM_LOGGING_CONFIG, dict) else None, + SYSTEM_LOGGING_CONFIG.get("debug", False) if isinstance(SYSTEM_LOGGING_CONFIG, dict) else False, + ) + logger.setLevel(level) + return logger + + # 解析配置的日志级别 + if isinstance(SYSTEM_LOGGING_CONFIG, dict): + level = _resolve_level(SYSTEM_LOGGING_CONFIG.get("level"), SYSTEM_LOGGING_CONFIG.get("debug", False)) + else: + level = logging.INFO + + logger.setLevel(level) + + handler = logging.StreamHandler() + fmt = "[%(asctime)s] [%(levelname)s] %(message)s" + datefmt = "%H:%M:%S" + handler.setFormatter(ColorFormatter(fmt=fmt, datefmt=datefmt)) + logger.addHandler(handler) + logger.propagate = False + + # 如果 level 被解析为 "关闭",则仍然返回 logger,但不会输出普通日志 + return logger + diff --git a/voice_drone/main_app.py b/voice_drone/main_app.py new file mode 100644 index 0000000..9ced2ca --- /dev/null +++ b/voice_drone/main_app.py @@ -0,0 +1,2271 @@ +# 实时检测语音:用「无人机」唤醒 → TTS「你好,我在呢」→ 收音一句指令(关麦)→ 大模型 Kokoro 播报答句 → 再仅听唤醒词。 +# 可选:assistant.local_keyword_takeoff_enabled 或 ROCKET_LOCAL_KEYWORD_TAKEOFF=1 时,「无人机 + keywords.yaml 里 takeoff 词」走本地 offboard + WAV(默认关闭)。 +# 其它指令走云端/本地 LLM → flight_intent 等(设 ROCKET_CLOUD_EXECUTE_FLIGHT=1 才执行机端序列)。 +# 环境变量:ROCKET_LLM_GGUF、ROCKET_LLM_MAX_TOKENS(默认 256)、ROCKET_LLM_CTX(默认 4096,可试 2048 省显存/略提速)、 +# ROCKET_LLM_N_THREADS(llama.cpp 线程数,如 RK3588 可试 6~8)、ROCKET_LLM_N_GPU_LAYERS(有 CUDA/Vulkan 时>0)、ROCKET_LLM_N_BATCH、 +# ROCKET_TTS_ORT_INTRA_OP_THREADS / ROCKET_TTS_ORT_INTER_OP_THREADS(Kokoro ONNXRuntime 线程), +# ROCKET_CHAT_IDLE_SEC(历史占位,每轮重置上下文)、ROCKET_TTS_DEVICE(同 qwen15b_chat --tts-device)、 +# ROCKET_INPUT_HW=2,0 对应 arecord -l 的 card,device;ROCKET_INPUT_DEVICE_INDEX、ROCKET_INPUT_DEVICE_NAME; +# 录音:默认交互列出 arecord -l + PyAudio 并选择;--input-index / ROCKET_INPUT_DEVICE_INDEX 跳过交互;--non-interactive 用 yaml 的 input_device_index(可为 null 自动探测)。 +# ROCKET_LLM_DISABLE=1 关闭对话。 +# ROCKET_LLM_STREAM=0 关闭流式输出(整段推理后再单次 TTS,便于对照调试)。 +# ROCKET_STREAM_TTS_CHUNK_CHARS 流式闲聊时、无句末标点则按此长度强制切段(默认 64,过小会听感碎)。 +# 云端语音(见 voice_drone_assistant/clientguide.md):ROCKET_CLOUD_VOICE=1 或 cloud_voice.enabled; +# ROCKET_CLOUD_WS_URL、ROCKET_CLOUD_AUTH_TOKEN、ROCKET_CLOUD_DEVICE_ID;ROCKET_CLOUD_FALLBACK_LOCAL=0 禁用本地回退。 +# 云端会话固定 pcm_asr_uplink(VAD 截句→turn.audio.*→Fun-ASR);同句快路径仍可用 turn.text。 +# 闲聊「无语音」超时:listen_silence_timeout_sec(默认 5):滴声后仅当 RMS Path: + raw = os.environ.get("ROCKET_WAKE_GREETING_WAV", "").strip() + return Path(raw).expanduser() if raw else _WAKE_GREETING_WAV + + +_CORE_DIR = _PROJECT_ROOT / "voice_drone" / "core" +_TAKEOFF_ACK_WAV = _CORE_DIR / "好的收到,开始起飞.wav" +_TAKEOFF_DONE_WAV = _CORE_DIR / "任务执行完成,开始返航降落.wav" +_OFFBOARD_SCRIPT = _PROJECT_ROOT / "scripts" / "run_px4_offboard_one_terminal.sh" + + +def _play_wav_blocking(path: Path) -> None: + """与 src/play_wav.py 相同:16-bit PCM 单文件 blocking 播放。""" + import pyaudio + + with wave.open(str(path), "rb") as wf: + ch = wf.getnchannels() + sw = wf.getsampwidth() + sr = wf.getframerate() + nframes = wf.getnframes() + if sw != 2: + raise ValueError(f"仅支持 16-bit PCM: {path}") + pcm = wf.readframes(nframes) + + p = pyaudio.PyAudio() + try: + fmt = p.get_format_from_width(sw) + chunk = 1024 + stream = p.open( + format=fmt, + channels=ch, + rate=sr, + output=True, + frames_per_buffer=chunk, + ) + stream.start_stream() + try: + step = chunk * sw * ch + for i in range(0, len(pcm), step): + stream.write(pcm[i : i + step]) + finally: + stream.stop_stream() + stream.close() + finally: + p.terminate() + + +def _synthesize_ready_beep( + sample_rate: int = 24000, + *, + duration_sec: float = 0.11, + frequency_hz: float = 988.0, + amplitude: float = 0.22, +) -> np.ndarray: + """正弦短鸣 + 淡入淡出,作唤醒后「可以说话」提示。""" + n = max(8, int(sample_rate * duration_sec)) + x = np.arange(n, dtype=np.float32) + w = np.sin(2.0 * np.pi * frequency_hz * x / float(sample_rate)).astype(np.float32) + fade = max(2, min(n // 3, int(0.006 * sample_rate))) + ramp = np.linspace(0.0, 1.0, fade, dtype=np.float32) + w[:fade] *= ramp + w[-fade:] *= ramp[::-1] + return np.clip(w * np.float32(amplitude), -1.0, 1.0) + + +def _terminate_process_group(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception as e: # noqa: BLE001 + logger.warning("SIGTERM offboard 进程组失败: %s", e) + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except Exception as e: # noqa: BLE001 + logger.warning("SIGKILL offboard 进程组失败: %s", e) + + +class _WakeFlowPhase(enum.IntEnum): + IDLE = 0 + GREETING_WAIT = 1 + ONE_SHOT_LISTEN = 2 + LLM_BUSY = 3 + FLIGHT_CONFIRM_LISTEN = 4 + + +class TakeoffPrintRecognizer(VoiceCommandRecognizer): + """待机(IDLE)仅识别含唤醒词的句子;唤醒后多轮对话在 ONE_SHOT_LISTEN 等阶段不要求句内唤醒词。 + 云端会话为 pcm_asr_uplink:滴声后整句 PCM 上云 Fun-ASR;结束一轮回到 IDLE 再要唤醒词。""" + + def __init__(self, *, skip_model_preload: bool = False) -> None: + super().__init__(auto_connect_socket=False) + self.ack_tts_enabled = False + self._audio_play_lock = threading.Lock() + self._offboard_proc_lock = threading.Lock() + self._active_offboard_proc: subprocess.Popen | None = None + self._takeoff_side_task_busy = threading.Lock() + self._model_warm_lock = threading.Lock() + + # 流式闲聊会按句/块多次入队,队列过小易丢段 + self._llm_playback_queue: queue.Queue[str] = queue.Queue(maxsize=64) + self._chat_session_lock = threading.Lock() + self._chat_session_until: float = 0.0 + self._llm_messages: list = [] + self._llm = None + self._llm_tts_engine = None + self._llm_model_path = Path( + os.environ.get( + "ROCKET_LLM_GGUF", + str(default_qwen_gguf_path(_PROJECT_ROOT)), + ) + ) + self._chat_idle_sec = float(os.environ.get("ROCKET_CHAT_IDLE_SEC", "120")) + self._llm_max_tokens = int(os.environ.get("ROCKET_LLM_MAX_TOKENS", "256")) + self._llm_ctx = int(os.environ.get("ROCKET_LLM_CTX", "4096")) + self._llm_tts_max_chars = int(os.environ.get("ROCKET_LLM_TTS_MAX_CHARS", "800")) + self._llm_stream_enabled = os.environ.get( + "ROCKET_LLM_STREAM", "1" + ).lower() not in ("0", "false", "no") + self._stream_tts_chunk_chars = max( + 16, + int(os.environ.get("ROCKET_STREAM_TTS_CHUNK_CHARS", "64")), + ) + self._llm_disabled = os.environ.get("ROCKET_LLM_DISABLE", "").lower() in ( + "1", + "true", + "yes", + ) + _kw_raw = os.environ.get("ROCKET_LOCAL_KEYWORD_TAKEOFF", "").strip() + if _kw_raw: + self._local_keyword_takeoff_enabled = _kw_raw.lower() in ( + "1", + "true", + "yes", + ) + else: + _ac = ( + SYSTEM_ASSISTANT_CONFIG + if isinstance(SYSTEM_ASSISTANT_CONFIG, dict) + else {} + ) + self._local_keyword_takeoff_enabled = bool( + _ac.get("local_keyword_takeoff_enabled", False) + ) + self._skip_model_preload = skip_model_preload or os.environ.get( + "ROCKET_SKIP_MODEL_PRELOAD", "" + ).lower() in ("1", "true", "yes") + + cv = SYSTEM_CLOUD_VOICE_CONFIG if isinstance(SYSTEM_CLOUD_VOICE_CONFIG, dict) else {} + env_cloud = os.environ.get("ROCKET_CLOUD_VOICE", "").lower() in ( + "1", + "true", + "yes", + ) + self._cloud_voice_enabled = bool(env_cloud or cv.get("enabled")) + self._cloud_fallback_local = os.environ.get( + "ROCKET_CLOUD_FALLBACK_LOCAL", "" + ).lower() not in ("0", "false", "no") and bool( + cv.get("fallback_to_local", True) + ) + # 唤醒词仅在 IDLE 由命令线程强制;ONE_SHOT_LISTEN 整句直接上行或处理,不要求句内唤醒词。 + try: + self._listen_silence_timeout_sec = max( + 0.5, + float( + os.environ.get("ROCKET_PROMPT_LISTEN_TIMEOUT_SEC") + or cv.get("listen_silence_timeout_sec") + or 5.0 + ), + ) + except ValueError: + self._listen_silence_timeout_sec = 5.0 + try: + self._post_cue_mic_mute_ms = float( + os.environ.get("ROCKET_POST_CUE_MIC_MUTE_MS") + or cv.get("post_cue_mic_mute_ms") + or 200.0 + ) + except ValueError: + self._post_cue_mic_mute_ms = 200.0 + self._post_cue_mic_mute_ms = max(0.0, min(2000.0, self._post_cue_mic_mute_ms)) + try: + self._segment_cue_duration_ms = float( + os.environ.get("ROCKET_SEGMENT_CUE_DURATION_MS") + or cv.get("segment_cue_duration_ms") + or 120.0 + ) + except ValueError: + self._segment_cue_duration_ms = 120.0 + self._segment_cue_duration_ms = max(20.0, min(500.0, self._segment_cue_duration_ms)) + ws_url = (os.environ.get("ROCKET_CLOUD_WS_URL") or cv.get("server_url") or "").strip() + auth_tok = ( + os.environ.get("ROCKET_CLOUD_AUTH_TOKEN") or cv.get("auth_token") or "" + ).strip() + dev_id = ( + os.environ.get("ROCKET_CLOUD_DEVICE_ID") or cv.get("device_id") or "drone-001" + ).strip() + self._cloud_client = None + self._cloud_remote_tts_for_local = False + if self._cloud_voice_enabled: + if ws_url and auth_tok: + from voice_drone.core.cloud_voice_client import CloudVoiceClient + + self._cloud_client = CloudVoiceClient( + server_url=ws_url, + auth_token=auth_tok, + device_id=dev_id, + recv_timeout=float(cv.get("timeout") or 120), + session_client_extensions=dict(SYSTEM_CLOUD_VOICE_PX4_CONTEXT) + if SYSTEM_CLOUD_VOICE_PX4_CONTEXT + else None, + ) + _env_rt = os.environ.get("ROCKET_CLOUD_REMOTE_TTS", "").strip().lower() + if _env_rt in ("0", "false", "no"): + self._cloud_remote_tts_for_local = False + elif _env_rt in ("1", "true", "yes"): + self._cloud_remote_tts_for_local = True + else: + self._cloud_remote_tts_for_local = bool( + cv.get("remote_tts_for_local", True) + ) + print( + f"[云端] 已启用 WebSocket 对话: {ws_url} device_id={dev_id}", + flush=True, + ) + if self._cloud_remote_tts_for_local: + print( + "[云端] 本地文案播报将走 tts.synthesize(失败回退 Kokoro)。", + flush=True, + ) + print( + f"[云端] Fun-ASR 上行 turn.audio.*;仅待机时说唤醒词;" + f"滴声后累计静默 {self._listen_silence_timeout_sec:.1f}s(低于 yaml energy_vad_rms_low 才计);" + f"断句提示 {self._segment_cue_duration_ms:.0f}ms、消抖 {self._post_cue_mic_mute_ms:.0f}ms。", + flush=True, + ) + else: + logger.warning("cloud_voice 已启用但缺少 server_url/auth_token,将使用本地 LLM") + self._cloud_voice_enabled = False + + self._wake_flow_lock = threading.Lock() + self._wake_phase: int = int(_WakeFlowPhase.IDLE) + self._greeting_done = threading.Event() + self._playback_batch_is_greeting = False + self._pending_finish_wake_cycle_after_tts = False + self._pending_flight_confirm_after_tts = False + self._pending_flight_confirm: dict | None = None + self._flight_confirm_timer: threading.Timer | None = None + self._flight_confirm_timer_lock = threading.Lock() + self._staged_one_shot_after_greeting: str | None = None + self._mic_op_queue: queue.Queue[str] = queue.Queue(maxsize=8) + + # 默认仅 1 段在 STT 队列等待;可设 ROCKET_STT_QUEUE_MAX=2~8 允许少量排队 + _raw_sq = os.environ.get("ROCKET_STT_QUEUE_MAX", "1").strip() + try: + _stn = max(1, min(16, int(_raw_sq))) + except ValueError: + _stn = 1 + self.stt_queue = queue.Queue(maxsize=_stn) + + # PROMPT_LISTEN:v1 §4 为「RMS 低于阈值持续累计」,不是滴声后固定墙上时钟 5s + self._prompt_listen_watch_armed: bool = False + self._prompt_silence_accum_sec: float = 0.0 + self._segment_cue_done = threading.Event() + self._pending_chitchat_reprompt_after_tts = False + if self._cloud_client is not None: + self._vad_speech_start_hook = self._on_vad_speech_start_prompt_listen + self._after_processed_audio_chunk = self._tick_prompt_listen_silence_accum + + def _cancel_prompt_listen_timer(self) -> None: + """停止「滴声后静默监听」累计(飞控/结束唤醒/起 PCM 上行前等)。""" + self._prompt_listen_watch_armed = False + self._prompt_silence_accum_sec = 0.0 + + def _arm_prompt_listen_timeout(self) -> None: + """滴声后进 PROMPT_LISTEN:仅在麦克持续低于 energy_vad_rms_low 时累加,超时再播 MSG。""" + if self._cloud_client is None: + return + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.ONE_SHOT_LISTEN): + return + self._prompt_silence_accum_sec = 0.0 + self._prompt_listen_watch_armed = True + logger.debug( + "PROMPT_LISTEN: 已启用 RMS 累计静默 %.1fs(低于 rms_low 才计时;说话或 rms≥low 清零)", + self._listen_silence_timeout_sec, + ) + + def _on_prompt_listen_timeout(self) -> None: + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.ONE_SHOT_LISTEN): + return + self._prompt_listen_watch_armed = False + self._prompt_silence_accum_sec = 0.0 + logger.info( + "[会话] 滴声后持续静默 ≥%.1fs(未截句),播超时提示并回待机", + self._listen_silence_timeout_sec, + ) + self._enqueue_llm_speak(MSG_PROMPT_LISTEN_TIMEOUT) + self._pending_finish_wake_cycle_after_tts = True + + def _tick_prompt_listen_silence_accum(self, processed_chunk: np.ndarray) -> None: + if not self._prompt_listen_watch_armed or self._cloud_client is None: + return + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.ONE_SHOT_LISTEN): + return + rms = self._int16_chunk_rms(processed_chunk) + dt = float(len(processed_chunk)) / float(self.audio_capture.sample_rate) + speaking = ( + self._ev_speaking + if self._use_energy_vad + else self.vad.is_speaking + ) + if speaking or rms >= self._energy_rms_low: + self._prompt_silence_accum_sec = 0.0 + return + self._prompt_silence_accum_sec += dt + if self._prompt_silence_accum_sec >= self._listen_silence_timeout_sec: + try: + self._on_prompt_listen_timeout() + except Exception as e: # noqa: BLE001 + logger.error("PROMPT_LISTEN 静默超时处理异常: %s", e, exc_info=True) + + def _on_vad_speech_start_prompt_listen(self) -> None: + """VAD 判「开始说话」时清零静默累计(v1 §4,与 RMS≥rms_low 并行)。""" + if self._cloud_client is None: + return + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.ONE_SHOT_LISTEN): + return + self._prompt_silence_accum_sec = 0.0 + + def _submit_concatenated_speech_to_stt(self) -> None: + """在唤醒/一问一答流程中节流 VAD:避免问候或云端推理时继续向 STT 积压整句。""" + allow_greeting_stt = os.environ.get( + "ROCKET_VAD_STT_DURING_GREETING", "" + ).lower() in ("1", "true", "yes") + with self._wake_flow_lock: + phase = self._wake_phase + if phase == int(_WakeFlowPhase.GREETING_WAIT) and not allow_greeting_stt: + with self.speech_buffer_lock: + self.speech_buffer.clear() + if os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + "[VAD] 问候播放中,本段不送 STT(说完问候后再说指令;" + "若需在问候同时识别请设 ROCKET_VAD_STT_DURING_GREETING=1", + flush=True, + ) + return + if phase == int(_WakeFlowPhase.LLM_BUSY): + with self.speech_buffer_lock: + self.speech_buffer.clear() + if os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + "[VAD] 大模型/云端处理中,本段不送 STT(请等本轮播报结束后再说)", + flush=True, + ) + return + if ( + self._cloud_client is not None + and phase == int(_WakeFlowPhase.ONE_SHOT_LISTEN) + ): + if len(self.speech_buffer) == 0: + return + speech_audio = np.concatenate(self.speech_buffer) + self.speech_buffer.clear() + min_samples = int(self.audio_capture.sample_rate * 0.5) + if len(speech_audio) >= min_samples: + try: + self.command_queue.put( + ( + _PCM_TURN_MARKER, + speech_audio.copy(), + int(self.audio_capture.sample_rate), + ), + block=False, + ) + if os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[VAD] turn.audio 已排队,{len(speech_audio)} 采样点" + f"(≈{len(speech_audio) / float(self.audio_capture.sample_rate):.2f}s)", + flush=True, + ) + except queue.Full: + logger.warning("命令队列已满,跳过 PCM 上行") + elif os.environ.get("ROCKET_PRINT_VAD", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[VAD] 语音段太短已丢弃({len(speech_audio)} < {min_samples} 采样)", + flush=True, + ) + return + super()._submit_concatenated_speech_to_stt() + + def _llm_tts_output_device(self) -> str | int | None: + raw = os.environ.get("ROCKET_TTS_DEVICE", "").strip() + if raw.isdigit(): + return int(raw) + if raw: + return raw + return None + + def _before_audio_iteration(self) -> None: + self._drain_mic_ops() + super()._before_audio_iteration() + self._drain_llm_playback_queue() + + def _drain_mic_ops(self) -> None: + """主线程:执行命令线程请求的麦克风流 stop/start。""" + while True: + try: + op = self._mic_op_queue.get_nowait() + except queue.Empty: + break + try: + if op == "stop": + if self.audio_capture.stream is not None: + self.audio_capture.stop_stream() + elif op == "start" and self.running: + if self.audio_capture.stream is None: + self.audio_capture.start_stream() + self.vad.reset() + with self.speech_buffer_lock: + self.speech_buffer.clear() + self.pre_speech_buffer.clear() + except Exception as e: # noqa: BLE001 + logger.warning("麦克风流控制失败 (%r): %s", op, e) + + def _finish_wake_cycle(self) -> None: + self._cancel_prompt_listen_timer() + self._cancel_flight_confirm_timer() + with self._flight_confirm_timer_lock: + self._pending_flight_confirm = None + self._pending_flight_confirm_after_tts = False + self._pending_finish_wake_cycle_after_tts = False + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.IDLE) + self._reset_llm_history() + print("[唤醒] 本轮结束。请说「无人机」再次唤醒。", flush=True) + + def _reset_llm_history(self) -> None: + with self._chat_session_lock: + self._llm_messages.clear() + self._chat_session_until = 0.0 + + def _flush_llm_playback_queue_silent(self) -> None: + """丢弃 LLM 播报队列(无日志);新一轮唤醒前清空,避免与问候语或上一轮残段叠播。""" + while True: + try: + self._llm_playback_queue.get_nowait() + except queue.Empty: + break + + def _prepare_wake_session_resources(self) -> None: + """新一轮唤醒:清空对话状态、播报队列与待 STT 段(问候/快路径共用)。""" + self._reset_llm_history() + self._flush_llm_playback_queue_silent() + self.discard_pending_stt_segments() + + def _recover_from_cloud_failure( + self, + user_msg: str, + *, + finish_wake_after_tts: bool, + idle_speak: str, + ) -> None: + """云端 run_turn 失败后:按需回退本地 LLM 或播一句占位。""" + if self._cloud_fallback_local: + print("[云端] 回退本地 LLM…", flush=True) + self._handle_llm_turn_local(user_msg, finish_wake_after_tts=finish_wake_after_tts) + return + self._enqueue_llm_speak(idle_speak) + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + + def _begin_wake_cycle(self, staged_followup: str | None) -> None: + """命中唤醒后:排队问候语,并在主线程播完后由 _after_greeting_pipeline 继续。""" + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.IDLE): + logger.info( + "唤醒忽略:当前非 IDLE(phase=%s),不重复排队问候", + _WakeFlowPhase(self._wake_phase).name, + ) + return + self._wake_phase = int(_WakeFlowPhase.GREETING_WAIT) + self._prepare_wake_session_resources() + s = (staged_followup or "").strip() + self._staged_one_shot_after_greeting = s if s else None + self._greeting_done.clear() + self._playback_batch_is_greeting = True + self._enqueue_wake_word_ack_beep() + self._enqueue_llm_speak(_WAKE_GREETING) + threading.Thread( + target=self._after_greeting_pipeline, + daemon=True, + name="wake-after-greeting", + ).start() + + def _wake_fast_path_process_follow(self, follow: str) -> bool: + """同一句已含唤醒词+指令时:跳过问候与滴声,清队列后直接 _process_one_shot_command。""" + follow = (follow or "").strip() + if not follow: + return False + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.IDLE): + logger.info( + "唤醒连带指令忽略:当前非 IDLE(phase=%s)", + _WakeFlowPhase(self._wake_phase).name, + ) + return False + self._wake_phase = int(_WakeFlowPhase.LLM_BUSY) + self._prepare_wake_session_resources() + self._staged_one_shot_after_greeting = None + self._enqueue_wake_word_ack_beep() + logger.info("唤醒含指令,跳过问候与提示音,直接处理: %s", follow[:120]) + self._process_one_shot_command(follow) + return True + + def _after_greeting_pipeline(self) -> None: + if not self._greeting_done.wait(timeout=120): + logger.error("问候语播放超时,回到 IDLE") + self._finish_wake_cycle() + return + self._greeting_done.clear() + staged: str | None = None + with self._wake_flow_lock: + staged = self._staged_one_shot_after_greeting + self._staged_one_shot_after_greeting = None + if staged is not None: + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.LLM_BUSY) + self._process_one_shot_command(staged) + else: + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.ONE_SHOT_LISTEN) + print("[唤醒] 请说您的指令(一句)。", flush=True) + self._arm_prompt_listen_timeout() + + def _process_one_shot_command(self, raw: str) -> None: + """已关麦或准备关麦:处理一句指令(起飞 / LLM),结束后再切回 IDLE。""" + user_msg = (raw or "").strip() + if not user_msg: + self._finish_wake_cycle() + return + iw, _ = self.wake_word_detector.detect(user_msg) + if iw: + user_msg = ( + self.wake_word_detector.extract_command_text(user_msg) or user_msg + ).strip() + if not user_msg: + self._finish_wake_cycle() + return + print(f"[指令] {user_msg}", flush=True) + try: + self._mic_op_queue.put_nowait("stop") + except queue.Full: + pass + time.sleep(0.12) + + _, params = self.text_preprocessor.preprocess_fast(user_msg) + if ( + self._local_keyword_takeoff_enabled + and params.command_keyword == "takeoff" + ): + threading.Thread( + target=self._run_takeoff_offboard_and_wavs, + daemon=True, + ).start() + self._finish_wake_cycle() + try: + self._mic_op_queue.put_nowait("start") + except queue.Full: + pass + return + + if self._llm_disabled and not self._cloud_voice_enabled: + print("[LLM] 已禁用(ROCKET_LLM_DISABLE)。", flush=True) + self._finish_wake_cycle() + try: + self._mic_op_queue.put_nowait("start") + except queue.Full: + pass + return + + self._handle_llm_turn( + user_msg, finish_wake_after_tts=(self._cloud_client is None) + ) + + @staticmethod + def _flight_payload_requests_takeoff(payload: dict) -> bool: + for a in payload.get("actions") or []: + if isinstance(a, dict) and a.get("type") == "takeoff": + return True + return False + + def _enqueue_llm_speak(self, line: str) -> None: + t = (line or "").strip() + if not t: + return + try: + self._llm_playback_queue.put(t, block=False) + except queue.Full: + logger.warning("LLM 播报队列已满,跳过: %s…", t[:40]) + + def _ensure_llm(self): + if self._llm is not None: + return self._llm + with self._model_warm_lock: + if self._llm is not None: + return self._llm + if not self._llm_model_path.is_file(): + logger.error("未找到 GGUF: %s", self._llm_model_path) + return None + logger.info("正在加载 LLM: %s", self._llm_model_path) + print("[LLM] 正在加载 Qwen(GGUF)…", flush=True) + self._llm = load_llama_qwen(self._llm_model_path, n_ctx=self._llm_ctx) + if self._llm is None: + logger.error("llama-cpp-python 未安装或加载失败") + else: + print("[LLM] Qwen 已载入。", flush=True) + return self._llm + + def _ensure_llm_tts(self): + if self._llm_tts_engine is not None: + return self._llm_tts_engine + with self._model_warm_lock: + if self._llm_tts_engine is not None: + return self._llm_tts_engine + from voice_drone.core.tts import KokoroOnnxTTS + + print("[LLM] 正在加载 Kokoro TTS(ONNX)…", flush=True) + self._llm_tts_engine = KokoroOnnxTTS() + print("[LLM] Kokoro 已载入。", flush=True) + return self._llm_tts_engine + + def _preload_llm_and_tts_if_enabled(self) -> None: + """启动后预加载,避免首轮对话/播报长时间卡顿。""" + if self._cloud_voice_enabled: + print( + "[云端] 跳过本地 Qwen 预加载;对话 TTS 以云端 PCM 为主。", + flush=True, + ) + try: + p = _resolve_wake_greeting_wav() + if not p.is_file(): + if ( + not self._llm_disabled + and not self._cloud_remote_tts_for_local + ): + self._ensure_wake_greeting_wav_on_disk() + except Exception as e: # noqa: BLE001 + logger.debug("云端模式下预热问候 WAV 跳过: %s", e) + if self._cloud_remote_tts_for_local: + print( + "[云端] 本地字符串播报由 tts.synthesize 提供,跳过 Kokoro 预加载" + "(失败时会临场加载 Kokoro)。", + flush=True, + ) + return + # 飞控确认超时/取消、云端 fallback 等仍走本地 Kokoro;启动时加载一次, + # 避免超时播报时现场冷启动模型(数秒卡顿)。 + if self._skip_model_preload: + print( + "[云端] 已跳过 Kokoro 预加载(--no-preload / ROCKET_SKIP_MODEL_PRELOAD);" + "首次本地提示时再加载。", + flush=True, + ) + else: + t0 = time.monotonic() + try: + print( + "[LLM] 云端模式:预加载 Kokoro(确认超时/取消等本地语音)…", + flush=True, + ) + self._ensure_llm_tts() + except Exception as e: # noqa: BLE001 + logger.warning( + "云端模式 Kokoro 预加载失败(将在首次本地播报时重试): %s", + e, + exc_info=True, + ) + print(f"[LLM] Kokoro 预加载失败: {e}", flush=True) + else: + dt = time.monotonic() - t0 + print(f"[LLM] Kokoro 预加载完成(约 {dt:.1f}s)。", flush=True) + return + + if self._llm_disabled or self._skip_model_preload: + if self._skip_model_preload and not self._llm_disabled: + print( + "[LLM] 已跳过预加载(--no-preload 或 ROCKET_SKIP_MODEL_PRELOAD),将在首次使用时加载。", + flush=True, + ) + return + if not self._llm_model_path.is_file(): + print( + f"[LLM] 未找到 GGUF,跳过预加载: {self._llm_model_path}", + flush=True, + ) + return + print( + "[LLM] 预加载 Qwen + Kokoro(数十秒属正常,完成后的首轮对话会快很多)…", + flush=True, + ) + t0 = time.monotonic() + try: + if self._ensure_llm() is None: + return + self._ensure_llm_tts() + self._ensure_wake_greeting_wav_on_disk() + except Exception as e: # noqa: BLE001 + logger.warning("预加载模型失败(将在首次使用时重试): %s", e, exc_info=True) + print(f"[LLM] 预加载失败: {e}", flush=True) + return + dt = time.monotonic() - t0 + print(f"[LLM] 预加载完成(耗时约 {dt:.1f}s)。", flush=True) + + def _ensure_wake_greeting_wav_on_disk(self) -> Path: + """若尚无问候 WAV,则用 Kokoro 合成一次并写入;之后只走 play_wav_path。""" + p = _resolve_wake_greeting_wav() + if p.is_file(): + return p + try: + p.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning("无法创建问候缓存目录 %s: %s", p.parent, e) + return p + try: + tts = self._ensure_llm_tts() + tts.synthesize_to_file(_WAKE_GREETING, str(p)) + logger.info("已自动生成唤醒问候缓存(此后只播此文件): %s", p) + print(f"[TTS] 已写入问候缓存,下次起不再合成: {p}", flush=True) + except Exception as e: # noqa: BLE001 + logger.warning( + "自动生成问候 WAV 失败(需 scipy 写盘;将本次仍用实时合成): %s", + e, + exc_info=True, + ) + return p + + def _play_wake_ready_beep(self, output_device: object | None) -> None: + """问候语播完后短鸣一声,提示用户再开口下指令。""" + from voice_drone.core.tts import play_tts_audio + + if os.environ.get("ROCKET_WAKE_PROMPT_BEEP", "1").lower() in ( + "0", + "false", + "no", + ): + return + sr = 24000 + try: + dur = float(os.environ.get("ROCKET_WAKE_BEEP_SEC", "0.11")) + except ValueError: + dur = 0.11 + dur = max(0.04, min(0.25, dur)) + try: + hz = float(os.environ.get("ROCKET_WAKE_BEEP_HZ", "988")) + except ValueError: + hz = 988.0 + try: + amp = float(os.environ.get("ROCKET_WAKE_BEEP_GAIN", "0.22")) + except ValueError: + amp = 0.22 + amp = max(0.05, min(0.45, amp)) + audio = _synthesize_ready_beep( + sr, duration_sec=dur, frequency_hz=hz, amplitude=amp + ) + try: + play_tts_audio(audio, sr, output_device=output_device) + print("[唤醒] 提示音已播,请说指令。", flush=True) + except Exception as e: # noqa: BLE001 + logger.debug("唤醒提示音播放跳过: %s", e) + + def _enqueue_wake_word_ack_beep(self) -> None: + """唤醒词命中后立即排队一声短鸣,主线程播报(与云 TTS 同队列,不阻塞命令线程)。""" + if os.environ.get("ROCKET_WAKE_ACK_BEEP", "1").lower() in ( + "0", + "false", + "no", + ): + return + try: + self._llm_playback_queue.put_nowait(_WAKE_HIT_BEEP_TAG) + except queue.Full: + logger.warning("播报队列已满,跳过唤醒确认短音") + + def _play_wake_word_hit_beep(self, output_device: object | None) -> None: + """刚识别到唤醒词时的一声「滴」,默认略短于问候后的滴声。""" + from voice_drone.core.tts import play_tts_audio + + if os.environ.get("ROCKET_WAKE_ACK_BEEP", "1").lower() in ( + "0", + "false", + "no", + ): + return + sr = 24000 + try: + raw = os.environ.get("ROCKET_WAKE_ACK_BEEP_SEC", "").strip() + if raw: + dur = float(raw) + else: + dur = float(os.environ.get("ROCKET_WAKE_BEEP_SEC", "0.11")) * 0.72 + except ValueError: + dur = 0.08 + dur = max(0.04, min(0.25, dur)) + try: + raw_h = os.environ.get("ROCKET_WAKE_ACK_BEEP_HZ", "").strip() + hz = float(raw_h) if raw_h else float(os.environ.get("ROCKET_WAKE_BEEP_HZ", "988")) + except ValueError: + hz = 1100.0 + try: + raw_g = os.environ.get("ROCKET_WAKE_ACK_BEEP_GAIN", "").strip() + amp = float(raw_g) if raw_g else float(os.environ.get("ROCKET_WAKE_BEEP_GAIN", "0.22")) + except ValueError: + amp = 0.22 + amp = max(0.05, min(0.45, amp)) + audio = _synthesize_ready_beep( + sr, duration_sec=dur, frequency_hz=hz, amplitude=amp + ) + try: + play_tts_audio(audio, sr, output_device=output_device) + except Exception as e: # noqa: BLE001 + logger.debug("唤醒确认短音播放失败: %s", e) + return + print("[唤醒] 确认短音已播。", flush=True) + + def _try_play_line_via_cloud_tts(self, s: str, dev: object | None) -> bool: + """docs/API.md §3.3 tts.synthesize:成功播放返回 True,否则 False(调用方回退 Kokoro)。""" + if not self._cloud_remote_tts_for_local or self._cloud_client is None: + return False + txt = (s or "").strip() + if not txt: + return False + from voice_drone.core.cloud_voice_client import CloudVoiceError + from voice_drone.core.tts import play_tts_audio + + t0 = time.monotonic() + try: + out = self._cloud_client.run_tts_synthesize(txt) + except CloudVoiceError as e: + logger.warning("云端 tts.synthesize 失败: %s", e) + return False + except Exception as e: # noqa: BLE001 + logger.warning("云端 tts.synthesize 异常: %s", e, exc_info=True) + return False + pcm = out.get("pcm") + try: + sr = int(out.get("sample_rate_hz") or 24000) + except (TypeError, ValueError): + sr = 24000 + if pcm is None or np.asarray(pcm).size == 0: + logger.warning("云端 tts.synthesize 返回空 PCM") + return False + pcm_i16 = np.asarray(pcm, dtype=np.int16).reshape(-1) + logger.info( + "云端 tts.synthesize: samples=%s int16_max_abs=%s elapsed=%.3fs", + pcm_i16.size, + int(np.max(np.abs(pcm_i16))), + time.monotonic() - t0, + ) + audio_f32 = pcm_i16.astype(np.float32) / 32768.0 + try: + play_tts_audio(audio_f32, sr, output_device=dev) + except Exception as e: # noqa: BLE001 + logger.warning("播放云端 tts.synthesize 结果失败: %s", e, exc_info=True) + return False + return True + + def _play_segment_end_cue(self, dev: object | None) -> None: + """断句后极短提示(§5);不计入闲聊再滴声。""" + from voice_drone.core.tts import play_tts_audio + + sr = 24000 + dur = self._segment_cue_duration_ms / 1000.0 + dur = max(0.02, min(0.5, dur)) + audio = _synthesize_ready_beep( + sr, + duration_sec=dur, + frequency_hz=1420.0, + amplitude=0.18, + ) + try: + play_tts_audio(audio, sr, output_device=dev) + except Exception as e: # noqa: BLE001 + logger.debug("断句提示音: %s", e) + + def _play_chitchat_reprompt_beep(self, dev: object | None) -> None: + """闲聊 TTS 播完后再滴一声,进入下一轮 PROMPT_LISTEN。""" + self._play_wake_word_hit_beep(dev) + + def _handle_pcm_uplink_turn(self, pcm: np.ndarray, sample_rate_hz: int) -> None: + """SEGMENT_END:断句提示 + 消抖 → turn.audio 上行一轮。""" + with self._wake_flow_lock: + if self._wake_phase != int(_WakeFlowPhase.ONE_SHOT_LISTEN): + logger.debug("PCM 上行忽略:当前非 PROMPT_LISTEN") + return + self._cancel_prompt_listen_timer() + try: + self._mic_op_queue.put_nowait("stop") + except queue.Full: + pass + self._segment_cue_done.clear() + try: + self._llm_playback_queue.put_nowait(_SEGMENT_END_CUE_TAG) + except queue.Full: + logger.error("播报队列满,无法播断句提示") + try: + self._mic_op_queue.put_nowait("start") + except queue.Full: + pass + return + if not self._segment_cue_done.wait(timeout=15.0): + logger.error("断句提示音同步超时") + try: + self._mic_op_queue.put_nowait("start") + except queue.Full: + pass + return + time.sleep(self._post_cue_mic_mute_ms / 1000.0) + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.LLM_BUSY) + self._handle_llm_turn_cloud_pcm( + pcm, sample_rate_hz, finish_wake_after_tts=False + ) + + def _drain_llm_playback_queue(self, recover_mic: bool = True) -> None: + from voice_drone.core.tts import play_tts_audio, play_wav_path + + lines: list[str] = [] + while True: + try: + lines.append(self._llm_playback_queue.get_nowait()) + except queue.Empty: + break + if not lines: + # 流式分段 TTS 时:最后一次 drain 可能在 _finalize_llm_turn 设置 + # _pending_finish_wake_cycle_after_tts 之前就把队列播空;此处补上结束本轮唤醒。 + # 注意:飞控确认窗须在「播完含本轮云端 TTS 的一批队列」之后在 finally 里进入, + # 不可在此处用 _pending_flight_confirm_after_tts,否则主线程可能在 PCM 入队前 + # 空跑 drain,抢先 begin_confirm 并清掉标志,命令线程末尾又会设 _pending_finish_wake_cycle。 + if self._pending_finish_wake_cycle_after_tts: + self._pending_finish_wake_cycle_after_tts = False + self._finish_wake_cycle() + return + greeting_batch = self._playback_batch_is_greeting + self._playback_batch_is_greeting = False + mic_stopped = False + if self.ack_pause_mic_for_playback: + # 关麦前再丢一次队列:唤醒到 drain 之间 VAD 可能又提交了片段 + self.discard_pending_stt_segments() + try: + self.audio_capture.stop_stream() + mic_stopped = True + except Exception as e: # noqa: BLE001 + logger.warning("暂停麦克风失败: %s", e) + try: + tts = None + dev = self._llm_tts_output_device() + for line in lines: + if line == _WAKE_HIT_BEEP_TAG: + self._play_wake_word_hit_beep(dev) + continue + if line == _SEGMENT_END_CUE_TAG: + self._play_segment_end_cue(dev) + self._segment_cue_done.set() + continue + if line == _CHITCHAT_REPROMPT_BEEP_TAG: + self._play_chitchat_reprompt_beep(dev) + self._arm_prompt_listen_timeout() + continue + if ( + isinstance(line, tuple) + and len(line) == 3 + and line[0] == _CLOUD_PCM_TAG + ): + _, pcm_i16, sr_cloud = line + try: + pcm_i16 = np.asarray(pcm_i16, dtype=np.int16).reshape(-1) + if pcm_i16.size == 0: + continue + dbg_max = int(np.max(np.abs(pcm_i16))) + logger.info( + "云端 PCM 解码: samples=%s int16_max_abs=%s (若 max_abs=0 则为全零或" + "协议/端序与云端不一致;请在服务端导出同段 WAV 对比)", + pcm_i16.size, + dbg_max, + ) + audio_f32 = pcm_i16.astype(np.float32) / 32768.0 + t_play0 = time.monotonic() + play_tts_audio( + audio_f32, int(sr_cloud), output_device=dev + ) + print( + f"[计时] 云端 TTS 播放 {time.monotonic() - t_play0:.3f}s " + f"({pcm_i16.size / int(sr_cloud):.2f}s 音频)", + flush=True, + ) + print("[LLM] 已播报。", flush=True) + except Exception as e: # noqa: BLE001 + logger.warning("云端 PCM 播放失败: %s", e, exc_info=True) + continue + + s = (line or "").strip() + if not s: + continue + try: + if s == _WAKE_GREETING: + t_w0 = time.monotonic() + cloud_ok = self._try_play_line_via_cloud_tts(s, dev) + if not cloud_ok: + greet_wav = self._ensure_wake_greeting_wav_on_disk() + if greet_wav.is_file(): + play_wav_path(greet_wav, output_device=dev) + print( + f"[计时] TTS 预生成问候 WAV 播完,耗时 " + f"{time.monotonic() - t_w0:.3f}s", + flush=True, + ) + else: + if tts is None: + tts = self._ensure_llm_tts() + logger.info("TTS: 开始合成并播放: %r", s) + t_syn0 = time.monotonic() + audio, sr = tts.synthesize(s) + t_syn1 = time.monotonic() + play_tts_audio(audio, sr, output_device=dev) + t_play1 = time.monotonic() + print( + f"[计时] TTS 合成 {t_syn1 - t_syn0:.3f}s," + f"播放 {t_play1 - t_syn1:.3f}s" + f"(本段合计 {t_play1 - t_syn0:.3f}s)", + flush=True, + ) + logger.info("TTS: 播放完成") + else: + print( + f"[计时] 云端 tts.synthesize 问候,耗时 " + f"{time.monotonic() - t_w0:.3f}s", + flush=True, + ) + if greeting_batch: + self._play_wake_ready_beep(dev) + else: + t_line0 = time.monotonic() + cloud_ok = self._try_play_line_via_cloud_tts(s, dev) + if not cloud_ok: + if tts is None: + tts = self._ensure_llm_tts() + logger.info("TTS: 开始合成并播放: %r", s) + t_syn0 = time.monotonic() + audio, sr = tts.synthesize(s) + t_syn1 = time.monotonic() + play_tts_audio(audio, sr, output_device=dev) + t_play1 = time.monotonic() + print( + f"[计时] TTS 合成 {t_syn1 - t_syn0:.3f}s," + f"播放 {t_play1 - t_syn1:.3f}s" + f"(本段合计 {t_play1 - t_syn0:.3f}s)", + flush=True, + ) + logger.info("TTS: 播放完成") + else: + print( + f"[计时] 云端 tts.synthesize 本段合计 " + f"{time.monotonic() - t_line0:.3f}s", + flush=True, + ) + print("[LLM] 已播报。", flush=True) + except Exception as e: # noqa: BLE001 + logger.warning("LLM 播报失败: %s", e, exc_info=True) + finally: + if mic_stopped and recover_mic: + try: + self.audio_capture.start_stream() + try: + settle_ms = float( + os.environ.get("ROCKET_MIC_RESTART_SETTLE_MS", "150") + ) + except ValueError: + settle_ms = 150.0 + settle_ms = max(0.0, min(2000.0, settle_ms)) + if settle_ms > 0: + time.sleep(settle_ms / 1000.0) + try: + self.audio_preprocessor.reset() + except Exception as e: # noqa: BLE001 + logger.debug("audio_preprocessor.reset: %s", e) + self.vad.reset() + with self.speech_buffer_lock: + self.speech_buffer.clear() + self.pre_speech_buffer.clear() + except Exception as e: # noqa: BLE001 + logger.error("麦克风恢复失败: %s", e) + if greeting_batch: + self._greeting_done.set() + if self._pending_flight_confirm_after_tts: + self._pending_flight_confirm_after_tts = False + self._begin_flight_confirm_listen() + elif self._pending_chitchat_reprompt_after_tts: + self._pending_chitchat_reprompt_after_tts = False + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.ONE_SHOT_LISTEN) + try: + self._llm_playback_queue.put_nowait(_CHITCHAT_REPROMPT_BEEP_TAG) + except queue.Full: + logger.warning("播报队列已满,跳过闲聊再滴声") + elif self._pending_finish_wake_cycle_after_tts: + self._pending_finish_wake_cycle_after_tts = False + self._finish_wake_cycle() + + def _discard_llm_playback_queue(self) -> None: + """退出时丢弃未播完的大模型 TTS,避免 stop() 里 speak_text/sounddevice 长时间阻塞导致 Ctrl+C 无法结束进程。""" + dropped = 0 + while True: + try: + self._llm_playback_queue.get_nowait() + dropped += 1 + except queue.Empty: + break + if dropped: + logger.info("退出:已丢弃 %s 条待播 LLM 语音", dropped) + + @staticmethod + def _chunk_delta_text(chunk: object) -> str: + if not isinstance(chunk, dict): + return "" + choices = chunk.get("choices") or [] + if not choices: + return "" + c0 = choices[0] + d = c0.get("delta") if isinstance(c0, dict) else None + if not isinstance(d, dict): + d = c0.get("message") if isinstance(c0, dict) else None + if not isinstance(d, dict): + return "" + raw = d.get("content") + return raw if isinstance(raw, str) else "" + + def _enqueue_segment_capped(self, seg: str, budget: int) -> int: + seg = (seg or "").strip() + if not seg or budget <= 0: + return budget + if len(seg) <= budget: + self._enqueue_llm_speak(seg) + return budget - len(seg) + self._enqueue_llm_speak(seg[: max(0, budget - 1)] + "…") + return 0 + + def _finalize_llm_turn( + self, + reply: str, + finish_wake_after_tts: bool, + *, + streamed_chat: bool, + ) -> None: + if not reply: + self._enqueue_llm_speak("我没听清,请再说一遍。") + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + mode, payload = parse_flight_intent_reply(reply) + with self._chat_session_lock: + self._llm_messages.append({"role": "assistant", "content": reply}) + + print(f"[LLM] 判定={mode}", flush=True) + print(f"[LLM] 原文: {reply[:500]}{'…' if len(reply) > 500 else ''}", flush=True) + + if streamed_chat: + if payload is not None and self._flight_payload_requests_takeoff(payload): + threading.Thread( + target=self._run_takeoff_offboard_and_wavs, + daemon=True, + ).start() + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + + if payload is not None: + to_say = str(payload.get("summary") or "好的。").strip() + if self._flight_payload_requests_takeoff(payload): + threading.Thread( + target=self._run_takeoff_offboard_and_wavs, + daemon=True, + ).start() + else: + to_say = reply.strip() + + if len(to_say) > self._llm_tts_max_chars: + to_say = to_say[: self._llm_tts_max_chars] + "…" + self._enqueue_llm_speak(to_say) + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + + def _enqueue_cloud_pcm_playback( + self, pcm_int16: np.ndarray, sample_rate_hz: int + ) -> None: + if pcm_int16 is None or np.asarray(pcm_int16).size == 0: + return + try: + self._llm_playback_queue.put( + (_CLOUD_PCM_TAG, np.asarray(pcm_int16, dtype=np.int16), int(sample_rate_hz)), + block=False, + ) + except queue.Full: + logger.warning("LLM 播报队列已满,跳过云端 PCM") + + def _send_socket_command(self, cmd: Command) -> bool: + cmd.fill_defaults() + if self.socket_client.send_command_with_retry(cmd): + logger.info("✅ Socket 已发送: %s", cmd.command) + return True + logger.warning("Socket 未送达(已达 max_retries): %s", cmd.command) + return False + + def _publish_flight_intent_to_ros_bridge(self, flight: dict) -> None: + """校验 flight_intent 后由子进程发布到 ROS std_msgs/String(伴飞桥 ~input)。""" + _parsed, errors = parse_flight_intent_dict(flight) + if errors or _parsed is None: + logger.warning("[飞控-ROS桥] flight_intent 校验失败,未发布: %s", errors) + return + setup = os.environ.get( + "ROCKET_FLIGHT_BRIDGE_SETUP", "source /opt/ros/noetic/setup.bash" + ).strip() + topic = os.environ.get("ROCKET_FLIGHT_BRIDGE_TOPIC", "/input").strip() or "/input" + wait_raw = os.environ.get("ROCKET_FLIGHT_BRIDGE_WAIT_SUB", "2").strip() + try: + wait_sub = float(wait_raw) + except ValueError: + wait_sub = 2.0 + + root = str(_PROJECT_ROOT) + body = json.dumps(flight, ensure_ascii=False) + fd, tmp_path = tempfile.mkstemp(prefix="flight_intent_", suffix=".json", text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(body) + except OSError: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp_path) + except OSError: + pass + logger.warning("[飞控-ROS桥] 无法写入临时 JSON") + return + + # 须追加 PYTHONPATH:若写成 PYTHONPATH=仅工程根,会覆盖 ROS setup 注入的 /opt/ros/.../dist-packages,导致找不到 rospy。 + cmd = ( + f"{setup} && cd {shlex.quote(root)} && " + f"export PYTHONPATH={shlex.quote(root)}:$PYTHONPATH && " + "python3 -m voice_drone.tools.publish_flight_intent_ros_once " + f"--topic {shlex.quote(topic)} --wait-subscribers {wait_sub} " + f"{shlex.quote(tmp_path)}" + ) + try: + r = subprocess.run( + ["bash", "-lc", cmd], + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + logger.warning("[飞控-ROS桥] 子进程超时(>60s)") + return + except OSError as e: + logger.warning("[飞控-ROS桥] 无法启动 bash: %s", e) + return + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + if r.returncode != 0: + logger.warning( + "[飞控-ROS桥] 发布失败 code=%s stderr=%s", + r.returncode, + (r.stderr or "").strip()[:800], + ) + else: + logger.info("[飞控-ROS桥] 已发布至 %s", topic) + + def _run_cloud_flight_intent_sequence(self, flight: dict) -> None: + """ + 在后台线程中顺序执行云端 flight_intent(校验 v1 + takeoff 走 offboard + 其余 Socket)。 + 含 takeoff 时:先跑完 offboard 流程,再继续 hover/wait/land 等(修复此前仅触发起飞、后续动作丢失)。 + """ + parsed, errors = parse_flight_intent_dict(flight) + if errors: + logger.warning("[飞控] flight_intent 校验失败: %s", errors) + return + tid = (parsed.trace_id or "").strip() or "-" + logger.info("[飞控] 开始执行序列 trace_id=%s steps=%d", tid, len(parsed.actions)) + + for step, action in enumerate(parsed.actions): + if isinstance(action, ActionTakeoff): + alt = action.args.relative_altitude_m + if alt is not None: + logger.info( + "[飞控] takeoff 请求相对高度 %.2fm(当前 offboard 脚本是否使用该参数请自行扩展)", + alt, + ) + self._run_takeoff_offboard_and_wavs() + elif isinstance(action, ActionLand): + cmd = Command.create("land", self._get_next_sequence_id()) + self._send_socket_command(cmd) + elif isinstance(action, ActionReturnHome): + cmd = Command.create("return_home", self._get_next_sequence_id()) + self._send_socket_command(cmd) + elif isinstance(action, (ActionHover, ActionHold)): + cmd = Command.create("hover", self._get_next_sequence_id()) + self._send_socket_command(cmd) + elif isinstance(action, ActionGoto): + cmd, err = goto_action_to_command(action, self._get_next_sequence_id()) + if err: + logger.warning("[飞控] step %d goto: %s", step, err) + continue + if cmd is not None: + self._send_socket_command(cmd) + elif isinstance(action, ActionWait): + sec = float(action.args.seconds) + logger.info("[飞控] step %d wait %.2fs", step, sec) + time.sleep(sec) + else: + logger.warning("[飞控] step %d 未处理的动作类型: %r", step, action) + + def _cancel_flight_confirm_timer(self) -> None: + with self._flight_confirm_timer_lock: + t = self._flight_confirm_timer + self._flight_confirm_timer = None + if t is not None: + try: + t.cancel() + except Exception: # noqa: BLE001 + pass + + def _begin_flight_confirm_listen(self) -> None: + """云端 TTS 播完后进入口头确认窗(cloud_voice_dialog_v1)。""" + self._cancel_prompt_listen_timer() + with self._flight_confirm_timer_lock: + if self._pending_flight_confirm is None: + logger.warning("[飞控] 无待确认意图,跳过确认窗") + self._finish_wake_cycle() + return + cd = self._pending_flight_confirm["confirm"] + timeout_sec = float(cd["timeout_sec"]) + phrases_repr = (cd["confirm_phrases"], cd["cancel_phrases"]) + self._cancel_flight_confirm_timer() + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.FLIGHT_CONFIRM_LISTEN) + print( + f"[飞控] 请口头确认 {phrases_repr[0]!r} 或取消 {phrases_repr[1]!r}," + f"超时 {timeout_sec:.0f}s。", + flush=True, + ) + + def _fire() -> None: + try: + self._on_flight_confirm_timeout() + except Exception as e: # noqa: BLE001 + logger.error("确认窗超时处理异常: %s", e, exc_info=True) + + with self._flight_confirm_timer_lock: + self._flight_confirm_timer = threading.Timer(timeout_sec, _fire) + self._flight_confirm_timer.daemon = True + self._flight_confirm_timer.start() + + def _on_flight_confirm_timeout(self) -> None: + with self._flight_confirm_timer_lock: + if self._pending_flight_confirm is None: + return + self._pending_flight_confirm = None + self._flight_confirm_timer = None + logger.info("[飞控] 确认窗超时") + self._enqueue_llm_speak(MSG_CONFIRM_TIMEOUT) + self._pending_finish_wake_cycle_after_tts = True + + def _handle_flight_confirm_text(self, raw: str) -> None: + utter = (raw or "").strip() + if not utter: + return + norm = normalize_phrase_text(utter) + print(f"[飞控-确认窗] {utter!r}", flush=True) + + action: str = "noop" + fi_ok: dict | None = None + t: threading.Timer | None = None + with self._flight_confirm_timer_lock: + pend = self._pending_flight_confirm + if pend is None: + return + cd = pend["confirm"] + cancel_hit = match_phrase_list(norm, cd["cancel_phrases"]) + confirm_hit = match_phrase_list(norm, cd["confirm_phrases"]) + if cancel_hit: + action = "cancel" + self._pending_flight_confirm = None + t = self._flight_confirm_timer + self._flight_confirm_timer = None + elif confirm_hit: + action = "confirm" + fi_ok = pend["flight"] + self._pending_flight_confirm = None + t = self._flight_confirm_timer + self._flight_confirm_timer = None + else: + logger.info("[飞控] 确认窗未命中短语,忽略: %s", utter[:80]) + return + + if t is not None: + try: + t.cancel() + except Exception: # noqa: BLE001 + pass + + if action == "cancel": + logger.info("[飞控] 用户取消待执行意图") + self._enqueue_llm_speak(MSG_CANCELLED) + self._pending_finish_wake_cycle_after_tts = True + return + + if action == "confirm" and fi_ok is not None: + logger.info("[飞控] 用户已确认,开始执行 flight_intent") + self._start_cloud_flight_execution(fi_ok) + self._enqueue_llm_speak(MSG_CONFIRM_EXECUTING) + self._pending_finish_wake_cycle_after_tts = True + + def _start_cloud_flight_execution(self, fi: dict) -> None: + """ROCKET_CLOUD_EXECUTE_FLIGHT 已通过校验后,起线程执行。""" + if os.environ.get("ROCKET_CLOUD_EXECUTE_FLIGHT", "").lower() not in ( + "1", + "true", + "yes", + ): + return + if os.environ.get("ROCKET_FLIGHT_INTENT_ROS_BRIDGE", "").lower() in ( + "1", + "true", + "yes", + ): + threading.Thread( + target=self._publish_flight_intent_to_ros_bridge, + args=(fi,), + daemon=True, + ).start() + else: + threading.Thread( + target=self._run_cloud_flight_intent_sequence, + args=(fi,), + daemon=True, + ).start() + + def _handle_llm_turn( + self, user_msg: str, *, finish_wake_after_tts: bool = False + ) -> None: + if self._cloud_voice_enabled and self._cloud_client is not None: + self._handle_llm_turn_cloud(user_msg, finish_wake_after_tts=finish_wake_after_tts) + return + self._handle_llm_turn_local(user_msg, finish_wake_after_tts=finish_wake_after_tts) + + def _apply_cloud_dialog_result( + self, + result: dict, + *, + finish_wake_after_tts: bool, + ) -> None: + proto = result.get("protocol") + routing = result.get("routing") + fi = result.get("flight_intent") + confirm_raw = result.get("confirm") + scheduled_flight_confirm = False + + if routing == "flight_intent" and isinstance(fi, dict) and fi.get("is_flight_intent"): + summary = str(fi.get("summary") or "好的。").strip() + actions = fi.get("actions") or [] + print(f"[LLM] 判定=飞控意图(云端) summary={summary!r}", flush=True) + print(f"[LLM] actions={actions!r}", flush=True) + if proto != CLOUD_VOICE_DIALOG_V1: + logger.error( + "[云端] flight_intent 须 protocol=%r,收到 %r;按 v1 拒执行飞控", + CLOUD_VOICE_DIALOG_V1, + proto, + ) + cd = parse_confirm_dict(confirm_raw) + if cd is None: + logger.error("[云端] flight_intent 须带合法 confirm 对象(v1),拒执行飞控") + exec_enabled = os.environ.get("ROCKET_CLOUD_EXECUTE_FLIGHT", "").lower() in ( + "1", + "true", + "yes", + ) + if ( + exec_enabled + and proto == CLOUD_VOICE_DIALOG_V1 + and cd is not None + ): + if cd["required"]: + scheduled_flight_confirm = True + with self._flight_confirm_timer_lock: + self._pending_flight_confirm = {"flight": fi, "confirm": cd} + self._pending_flight_confirm_after_tts = True + logger.info( + "[云端] flight_intent 待口头确认(pending_id=%s);" + "播完 TTS 后听确认/超时", + cd.get("pending_id"), + ) + else: + logger.info( + "[云端] flight_intent confirm.required=false,将直接执行(若已开执行开关)" + ) + self._start_cloud_flight_execution(fi) + elif exec_enabled and ( + proto != CLOUD_VOICE_DIALOG_V1 or cd is None + ): + logger.warning( + "[云端] 协议或 confirm 不完整,本轮不执行飞控(仍播 TTS)" + ) + else: + logger.info( + "[云端] flight_intent 已下发(未设 ROCKET_CLOUD_EXECUTE_FLIGHT,仅播报)" + ) + elif routing == "chitchat": + if proto != CLOUD_VOICE_DIALOG_V1: + logger.warning( + "[云端] chitchat 期望 protocol=%r,实际=%r", + CLOUD_VOICE_DIALOG_V1, + proto, + ) + cr = (result.get("chat_reply") or "").strip() + print(f"[LLM] 判定=闲聊(云端) reply={cr[:200]!r}", flush=True) + else: + logger.warning("未知 routing: %s", routing) + + pcm = result.get("pcm") + sr = int(result.get("sample_rate_hz") or 24000) + if pcm is not None and np.asarray(pcm).size > 0: + self._enqueue_cloud_pcm_playback(np.asarray(pcm, dtype=np.int16), sr) + elif self._cloud_fallback_local: + if routing == "flight_intent" and isinstance(fi, dict): + fallback_txt = str(fi.get("summary") or "好的。").strip() + else: + fallback_txt = (result.get("chat_reply") or "好的。").strip() + if fallback_txt: + self._enqueue_llm_speak(fallback_txt) + else: + self._enqueue_llm_speak("未收到云端语音。") + + if routing == "chitchat": + self._pending_chitchat_reprompt_after_tts = True + elif scheduled_flight_confirm: + pass + elif finish_wake_after_tts and not scheduled_flight_confirm: + self._pending_finish_wake_cycle_after_tts = True + elif routing == "flight_intent" and not scheduled_flight_confirm: + self._pending_finish_wake_cycle_after_tts = True + elif routing not in ("chitchat", "flight_intent"): + self._pending_finish_wake_cycle_after_tts = True + + def _handle_llm_turn_cloud( + self, user_msg: str, *, finish_wake_after_tts: bool = False + ) -> None: + from voice_drone.core.cloud_voice_client import CloudVoiceError + + assert self._cloud_client is not None + t0 = time.monotonic() + try: + result = self._cloud_client.run_turn(user_msg) + except CloudVoiceError as e: + print(f"[云端] 失败: {e} (code={e.code!r})", flush=True) + logger.error("云端对话失败: %s", e, exc_info=True) + self._recover_from_cloud_failure( + user_msg, + finish_wake_after_tts=finish_wake_after_tts, + idle_speak="云端服务不可用,请稍后再试。", + ) + return + except Exception as e: # noqa: BLE001 + print(f"[云端] 异常: {e}", flush=True) + logger.error("云端对话异常: %s", e, exc_info=True) + self._recover_from_cloud_failure( + user_msg, + finish_wake_after_tts=finish_wake_after_tts, + idle_speak="网络异常,请稍后再试。", + ) + return + + dt = time.monotonic() - t0 + metrics = result.get("metrics") or {} + print( + f"[计时] 云端一轮(turn.text) {dt:.3f}s " + f"(llm_ms={metrics.get('llm_ms')!r}, " + f"tts_first_byte_ms={metrics.get('tts_first_byte_ms')!r})", + flush=True, + ) + self._apply_cloud_dialog_result(result, finish_wake_after_tts=finish_wake_after_tts) + + def _handle_llm_turn_cloud_pcm( + self, + pcm_i16: np.ndarray, + sample_rate_hz: int, + *, + finish_wake_after_tts: bool = False, + ) -> None: + from voice_drone.core.cloud_voice_client import CloudVoiceError + + assert self._cloud_client is not None + t0 = time.monotonic() + try: + result = self._cloud_client.run_turn_audio(pcm_i16, int(sample_rate_hz)) + except CloudVoiceError as e: + print(f"[云端] turn.audio 失败: {e} (code={e.code!r})", flush=True) + logger.error("云端 turn.audio 失败: %s", e, exc_info=True) + self._recover_from_cloud_failure( + "", + finish_wake_after_tts=True, + idle_speak="云端语音识别失败,请稍后再试。", + ) + return + except Exception as e: # noqa: BLE001 + print(f"[云端] turn.audio 异常: {e}", flush=True) + logger.error("云端 turn.audio 异常: %s", e, exc_info=True) + self._recover_from_cloud_failure( + "", + finish_wake_after_tts=True, + idle_speak="网络异常,请稍后再试。", + ) + return + + dt = time.monotonic() - t0 + metrics = result.get("metrics") or {} + print( + f"[计时] 云端一轮(turn.audio) {dt:.3f}s " + f"(llm_ms={metrics.get('llm_ms')!r}, " + f"tts_first_byte_ms={metrics.get('tts_first_byte_ms')!r})", + flush=True, + ) + self._apply_cloud_dialog_result(result, finish_wake_after_tts=finish_wake_after_tts) + + def _handle_llm_turn_local( + self, user_msg: str, *, finish_wake_after_tts: bool = False + ) -> None: + llm = self._ensure_llm() + if llm is None: + self._enqueue_llm_speak( + "大模型未就绪。请确认已下载 GGUF,或设置环境变量 ROCKET_LLM_GGUF 指向模型文件。" + ) + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + + with self._chat_session_lock: + self._llm_messages = [ + {"role": "system", "content": FLIGHT_INTENT_CHAT_SYSTEM}, + {"role": "user", "content": user_msg}, + ] + messages_snapshot = list(self._llm_messages) + + if not self._llm_stream_enabled: + t_llm0 = time.monotonic() + try: + out = llm.create_chat_completion( + messages=messages_snapshot, + max_tokens=self._llm_max_tokens, + ) + except Exception as e: # noqa: BLE001 + dt_llm = time.monotonic() - t_llm0 + print(f"[计时] LLM 推理 {dt_llm:.3f}s(失败)", flush=True) + logger.error("LLM 推理失败: %s", e, exc_info=True) + with self._chat_session_lock: + if self._llm_messages and self._llm_messages[-1].get("role") == "user": + self._llm_messages.pop() + self._enqueue_llm_speak("推理出错,请稍后再说。") + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + dt_llm = time.monotonic() - t_llm0 + print(f"[计时] LLM 推理 {dt_llm:.3f}s", flush=True) + + reply = ( + (out.get("choices") or [{}])[0].get("message") or {} + ).get("content", "").strip() + self._finalize_llm_turn( + reply, finish_wake_after_tts, streamed_chat=False + ) + return + + t_llm0 = time.monotonic() + try: + stream = llm.create_chat_completion( + messages=messages_snapshot, + max_tokens=self._llm_max_tokens, + stream=True, + ) + except Exception as e: # noqa: BLE001 + dt_llm = time.monotonic() - t_llm0 + print(f"[计时] LLM 推理 {dt_llm:.3f}s(失败)", flush=True) + logger.error("LLM 推理失败: %s", e, exc_info=True) + with self._chat_session_lock: + if self._llm_messages and self._llm_messages[-1].get("role") == "user": + self._llm_messages.pop() + self._enqueue_llm_speak("推理出错,请稍后再说。") + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + + full_reply = "" + pending = "" + tts_budget = self._llm_tts_max_chars + route: str | None = None + + try: + for chunk in stream: + content = self._chunk_delta_text(chunk) + if not content: + continue + full_reply += content + if route is None: + lead = full_reply.lstrip() + if lead: + route = "json" if lead[0] == "{" else "chat" + if route != "chat" or tts_budget <= 0: + continue + pending += content + while tts_budget > 0 and pending: + segs, pending = take_completed_sentences(pending) + if segs: + for seg in segs: + tts_budget = self._enqueue_segment_capped(seg, tts_budget) + if tts_budget <= 0: + break + continue + forced, pending = force_soft_split( + pending, self._stream_tts_chunk_chars + ) + if not forced: + break + for seg in forced: + tts_budget = self._enqueue_segment_capped(seg, tts_budget) + if tts_budget <= 0: + break + except Exception as e: # noqa: BLE001 + dt_llm = time.monotonic() - t_llm0 + print(f"[计时] LLM 推理 {dt_llm:.3f}s(失败)", flush=True) + logger.error("LLM 流式推理失败: %s", e, exc_info=True) + with self._chat_session_lock: + if self._llm_messages and self._llm_messages[-1].get("role") == "user": + self._llm_messages.pop() + self._enqueue_llm_speak("推理出错,请稍后再说。") + if finish_wake_after_tts: + self._pending_finish_wake_cycle_after_tts = True + return + + dt_llm = time.monotonic() - t_llm0 + print(f"[计时] LLM 推理 {dt_llm:.3f}s", flush=True) + + reply = full_reply.strip() + if route == "chat" and tts_budget > 0: + tail = pending.strip() + if tail: + self._enqueue_segment_capped(tail, tts_budget) + + self._finalize_llm_turn( + reply, finish_wake_after_tts, streamed_chat=(route == "chat") + ) + + def start(self) -> None: + if self.running: + logger.warning("识别器已在运营") + return + + self.running = True + + self.stt_thread = threading.Thread(target=self._stt_worker_thread, daemon=True) + self.stt_thread.start() + + self.command_thread = threading.Thread( + target=self._takeoff_only_command_worker, daemon=True + ) + self.command_thread.start() + + # 先预加载再开麦:否则 PortAudio 回调会一直往 audio_queue 塞数据,而主线程还没进入 + # process_audio_stream,默认仅 10 块的队列会迅速满并触发「音频队列已满,丢弃数据块」。 + logger.info("voice_drone_assistant: 准备预加载模型(若启用)…") + self._preload_llm_and_tts_if_enabled() + + try: + self.audio_capture.start_stream() + except BaseException: + self.running = False + try: + self.stt_queue.put(None, timeout=0.5) + except Exception: # noqa: BLE001 + pass + try: + self.command_queue.put(None, timeout=0.5) + except Exception: # noqa: BLE001 + pass + if self.stt_thread is not None: + self.stt_thread.join(timeout=2.0) + if self.command_thread is not None: + self.command_thread.join(timeout=2.0) + raise + + if self._cloud_voice_enabled: + logger.info( + "voice_drone_assistant: 已启动(对话走云端 WebSocket;TTS 为云端 PCM;飞控见 Socket/offboard)" + ) + else: + logger.info( + "voice_drone_assistant: 已启动(无试飞控 Socket;大模型答复走 Kokoro TTS)" + ) + ld = os.environ.get("LD_PRELOAD", "") + sys_asound = "libasound.so" in ld and "/usr/" in ld + if not sys_asound: + print( + "\n⚠ 建议用系统 ALSA 启动(conda 下否则常无声或 VAD 不触发):\n" + " bash with_system_alsa.sh python main.py\n", + flush=True, + ) + if self._llm_disabled and not self._cloud_voice_enabled: + if self._local_keyword_takeoff_enabled: + llm_hint = "已 ROCKET_LLM_DISABLE=1:除 keywords.yaml 中 takeoff 关键词外,其它指令仅打印,不调大模型。\n" + else: + llm_hint = ( + "已 ROCKET_LLM_DISABLE=1 且未启用本地口令起飞(assistant.local_keyword_takeoff_enabled / " + "ROCKET_LOCAL_KEYWORD_TAKEOFF):指令仅打印,不调大模型。\n" + ) + elif self._cloud_voice_enabled: + if self._local_keyword_takeoff_enabled: + llm_hint = "已启用云端对话:非 takeoff 关键词指令经 WebSocket 上云,播报为云端 TTS 流。\n" + else: + llm_hint = "已启用云端对话:指令经 WebSocket 上云,播报为云端 TTS 流(本地口令起飞已关闭)。\n" + else: + llm_hint = ( + "说「无人机」唤醒后会先播报问候,再听您说一句(不必再带唤醒词);说完后关麦推理,答句播完后再说「" + f"{self.wake_word_detector.primary}」开始下一轮。非起飞指令走大模型(" + "飞控相关→JSON,否则闲聊)。\n" + ) + if self._local_keyword_takeoff_enabled: + takeoff_banner = ( + "\n本地口令起飞已开启:说「无人机」+ keywords.yaml 里 takeoff 词(如「起飞演示」)→ 播提示音、" + "启动 scripts/run_px4_offboard_one_terminal.sh(串口真机)、再播返航提示并结束脚本。\n" + ) + else: + takeoff_banner = ( + "\n本地口令起飞已关闭(飞控请用云端 flight_intent / ROS 桥等);" + "若需恢复 keywords.yaml takeoff → offboard,设 assistant.local_keyword_takeoff_enabled: true 或 " + "ROCKET_LOCAL_KEYWORD_TAKEOFF=1。\n" + ) + print( + f"{takeoff_banner}" + f"{llm_hint}" + "标记说明:[VAD] 已截段送 STT;[STT] 识别文字;[唤醒] 是否含唤醒词;[LLM] 对话与播报。\n" + "录音已在启动时选好;扬声器可设 ROCKET_TTS_DEVICE。建议:bash with_system_alsa.sh python …\n" + "Ctrl+C 退出。\n", + flush=True, + ) + + def _play_wav_serialized(self, path: Path) -> None: + if not path.is_file(): + logger.warning("WAV 文件不存在,跳过播放: %s", path) + return + with self._audio_play_lock: + try: + _play_wav_blocking(path) + except Exception as e: # noqa: BLE001 + logger.warning("播放 WAV 失败 %s: %s", path, e, exc_info=True) + + def _run_takeoff_offboard_and_wavs(self) -> None: + """独立线程:起 offboard 脚本;播第一段;第一段结束后等 10s;再播第二段;第二段结束后杀掉脚本进程组。""" + if not _OFFBOARD_SCRIPT.is_file(): + logger.error("未找到 offboard 脚本: %s", _OFFBOARD_SCRIPT) + return + + acquired = self._takeoff_side_task_busy.acquire(blocking=False) + if not acquired: + logger.warning("起飞联动已在执行,忽略重复触发") + return + + proc: subprocess.Popen | None = None + try: + log_path = Path( + os.environ.get("ROCKET_OFFBOARD_LOG", "/tmp/rocket_drone_offboard_script.log") + ).expanduser() + log_f = open(log_path, "ab", buffering=0) + try: + proc = subprocess.Popen( + [ + "bash", + str(_OFFBOARD_SCRIPT), + "/dev/ttyACM0", + "921600", + "20", + ], + cwd=str(_PROJECT_ROOT), + stdout=log_f, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except Exception as e: # noqa: BLE001 + logger.error("启动 run_px4_offboard_one_terminal.sh 失败: %s", e, exc_info=True) + return + finally: + log_f.close() + + with self._offboard_proc_lock: + self._active_offboard_proc = proc + + time.sleep(0.5) + early_rc = proc.poll() + if early_rc is not None: + logger.error( + "offboard 一键脚本已立即结束 (exit=%s),未持续运行。日志: %s (常见原因:找不到 " + "px4_ctrl_offboard_demo.py、ROS 环境、或串口未连)", + early_rc, + log_path, + ) + + logger.info( + "已启动 offboard 一键脚本 (pid=%s),并播放起飞提示音;脚本输出见 %s", + proc.pid, + log_path, + ) + + self._play_wav_serialized(_TAKEOFF_ACK_WAV) + time.sleep(10.0) + self._play_wav_serialized(_TAKEOFF_DONE_WAV) + finally: + if proc is not None: + logger.info("第二段 WAV 已播完,终止 offboard 脚本进程组 (pid=%s)", proc.pid) + _terminate_process_group(proc) + with self._offboard_proc_lock: + if self._active_offboard_proc is proc: + self._active_offboard_proc = None + self._takeoff_side_task_busy.release() + + def _takeoff_only_command_worker(self) -> None: + """唤醒;同句带指令则直转 LLM/起飞;否则问候+滴声→再问一句→关麦播报。""" + logger.info("唤醒流程命令线程已启动") + while self.running: + try: + text = self.command_queue.get(timeout=0.1) + except queue.Empty: + continue + except Exception as e: # noqa: BLE001 + logger.error(f"命令处理线程错误: {e}", exc_info=True) + continue + + try: + if text is None: + break + + try: + if ( + isinstance(text, tuple) + and len(text) == 3 + and text[0] == _PCM_TURN_MARKER + ): + self._handle_pcm_uplink_turn(text[1], int(text[2])) + continue + + with self._wake_flow_lock: + phase = self._wake_phase + + if phase == int(_WakeFlowPhase.LLM_BUSY): + continue + if phase == int(_WakeFlowPhase.GREETING_WAIT): + continue + + if phase == int(_WakeFlowPhase.FLIGHT_CONFIRM_LISTEN): + self._handle_flight_confirm_text(text) + continue + + if phase == int(_WakeFlowPhase.ONE_SHOT_LISTEN): + with self._wake_flow_lock: + self._wake_phase = int(_WakeFlowPhase.LLM_BUSY) + self._process_one_shot_command(text) + continue + + is_wake, matched = self.wake_word_detector.detect(text) + if not is_wake: + logger.debug("未检测到唤醒词,忽略: %s", text) + if os.environ.get("ROCKET_PRINT_STT", "").lower() in ( + "1", + "true", + "yes", + ): + print( + f"[唤醒] 未命中「{self.wake_word_detector.primary}」,原文: {text!r}", + flush=True, + ) + continue + + logger.info("唤醒词命中: %s", matched) + command_text = self.wake_word_detector.extract_command_text(text) + follow = (command_text or "").strip() + if follow: + if not self._wake_fast_path_process_follow(follow): + continue + continue + self._begin_wake_cycle(None) + + except Exception as e: # noqa: BLE001 + logger.error("命令处理失败: %s", e, exc_info=True) + finally: + self.command_queue.task_done() + + logger.info("唤醒流程命令线程已停止") + + def stop(self) -> None: + """停止识别;不重连 Socket(从未连接)。""" + if not self.running: + return + + self.running = False + + self._cancel_prompt_listen_timer() + self._cancel_flight_confirm_timer() + with self._flight_confirm_timer_lock: + self._pending_flight_confirm = None + self._pending_flight_confirm_after_tts = False + + if self.stt_thread is not None: + self.stt_queue.put(None) + if self.command_thread is not None: + self.command_queue.put(None) + if self.stt_thread is not None: + self.stt_thread.join(timeout=2.0) + if self.command_thread is not None: + self.command_thread.join(timeout=2.0) + + # 不在此线程做 speak_text:会阻塞数秒至数十秒,用户多次 Ctrl+C 仍杀不掉进程 + self._discard_llm_playback_queue() + + with self._offboard_proc_lock: + op = self._active_offboard_proc + self._active_offboard_proc = None + if op is not None and op.poll() is None: + logger.info("主程序退出:终止仍在运行的 offboard 脚本") + _terminate_process_group(op) + + try: + self.audio_capture.stop_stream() + except KeyboardInterrupt: + logger.info("关闭麦克风流时中断,跳过") + except Exception as e: # noqa: BLE001 + logger.warning("关闭麦克风流失败: %s", e) + + if self._cloud_client is not None: + try: + self._cloud_client.close() + except Exception as e: # noqa: BLE001 + logger.debug("关闭云端 WebSocket: %s", e) + + if self.socket_client.connected: + self.socket_client.disconnect() + + logger.info("voice_drone_assistant 已停止") + print("\n已退出。", flush=True) + + +def main() -> None: + ap = argparse.ArgumentParser( + description="无人机语音:唤醒 → 问候 → 一句指令 → 起飞或 LLM 播报 → 再唤醒" + ) + ap.add_argument( + "--input-index", + "-I", + type=int, + default=None, + help="跳过交互菜单,直接指定 PyAudio 录音设备索引(与启动时「PyAudio_index=」一致)。", + ) + ap.add_argument( + "--non-interactive", + action="store_true", + help="不选设备:用 system.yaml 的 audio.input_device_index(为 null 时自动枚举默认可录音设备)。", + ) + ap.add_argument( + "--no-preload", + action="store_true", + help="不预加载 Qwen/Kokoro,缩短启动时间(首轮对话与首次播报会变慢)。", + ) + args = ap.parse_args() + non_inter = args.non_interactive or os.environ.get( + "ROCKET_NON_INTERACTIVE", "" + ).lower() in ("1", "true", "yes") + + idx = args.input_index + if idx is None: + raw_ix = os.environ.get("ROCKET_INPUT_DEVICE_INDEX", "").strip() + if raw_ix.isdigit() or (raw_ix.startswith("-") and raw_ix[1:].isdigit()): + idx = int(raw_ix) + + if idx is not None: + from voice_drone.core.mic_device_select import apply_input_device_index_only + + apply_input_device_index_only(idx) + logger.info("录音设备: PyAudio 索引 %s(CLI/环境变量)", idx) + elif not non_inter: + from voice_drone.core.mic_device_select import ( + apply_input_device_index_only, + prompt_for_input_device_index, + ) + + chosen = prompt_for_input_device_index() + apply_input_device_index_only(chosen) + else: + logger.info( + "非交互模式:使用 system.yaml 的 audio.input_device_index(null=自动探测)" + ) + + app = TakeoffPrintRecognizer(skip_model_preload=args.no_preload) + try: + app.run() + except KeyboardInterrupt: + logger.info("用户中断") + finally: + if app.running: + app.stop() + + +if __name__ == "__main__": + main() diff --git a/voice_drone/tools/__init__.py b/voice_drone/tools/__init__.py new file mode 100644 index 0000000..2cf02fb --- /dev/null +++ b/voice_drone/tools/__init__.py @@ -0,0 +1 @@ +"""One-off helpers (CLI tools).""" diff --git a/voice_drone/tools/config_loader.py b/voice_drone/tools/config_loader.py new file mode 100644 index 0000000..bebd851 --- /dev/null +++ b/voice_drone/tools/config_loader.py @@ -0,0 +1,15 @@ +import yaml + +def load_config(config_path): + """ + 加载配置 + + Args: + config_path (str): 配置文件路径 + + Returns: + dict: 返回解析后的配置字典 + """ + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + return config \ No newline at end of file diff --git a/voice_drone/tools/publish_flight_intent_ros_once.py b/voice_drone/tools/publish_flight_intent_ros_once.py new file mode 100644 index 0000000..7792370 --- /dev/null +++ b/voice_drone/tools/publish_flight_intent_ros_once.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Publish one flight_intent JSON to a ROS1 std_msgs/String topic (for flight bridge). + +Run after sourcing ROS, e.g.: + source /opt/ros/noetic/setup.bash && python3 -m voice_drone.tools.publish_flight_intent_ros_once /tmp/intent.json + +Or from repo root(须在已 source ROS 的 shell 中,且勿覆盖 ROS 的 PYTHONPATH;应 prepend): + export PYTHONPATH="$PWD:$PYTHONPATH" && python3 -m voice_drone.tools.publish_flight_intent_ros_once ... +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import rospy +from std_msgs.msg import String + + +def _load_payload(path: str | None) -> dict[str, Any]: + if path and path != "-": + raw = Path(path).read_text(encoding="utf-8") + else: + raw = sys.stdin.read() + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("root must be a JSON object") + return data + + +def main() -> None: + ap = argparse.ArgumentParser(description="Publish flight_intent JSON once to ROS String topic") + ap.add_argument( + "json_file", + nargs="?", + default="-", + help="Path to JSON file, or - for stdin (default: stdin)", + ) + ap.add_argument("--topic", default="/input", help="ROS topic (default: /input)") + ap.add_argument( + "--wait-subscribers", + type=float, + default=0.0, + help="Seconds to wait for at least one subscriber (default: 0)", + ) + args = ap.parse_args() + + payload = _load_payload(args.json_file if args.json_file != "-" else None) + json_str = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + rospy.init_node("flight_intent_ros_publisher_once", anonymous=True) + pub = rospy.Publisher(args.topic, String, queue_size=1, latch=False) + deadline = rospy.Time.now() + rospy.Duration(args.wait_subscribers) + while args.wait_subscribers > 0 and pub.get_num_connections() < 1 and not rospy.is_shutdown(): + if rospy.Time.now() > deadline: + break + rospy.sleep(0.05) + rospy.sleep(0.15) + pub.publish(String(data=json_str)) + rospy.sleep(0.25) + + +if __name__ == "__main__": + main() diff --git a/voice_drone/tools/wrapper.py b/voice_drone/tools/wrapper.py new file mode 100644 index 0000000..4367106 --- /dev/null +++ b/voice_drone/tools/wrapper.py @@ -0,0 +1,17 @@ +import time + +def time_cost(tag=None): + """ + 装饰器,自定义函数耗时打印信息 + :param tag: 可选,自定义标志(说明当前执行的函数) + """ + def decorator(func): + def wrapper(*args, **kwargs): + start_time = time.time() + result = func(*args, **kwargs) + end_time = time.time() + label = tag if tag is not None else func.__name__ + print(f"耗时统计[{label}]: {(end_time - start_time)*1000:.2f} 毫秒") + return result + return wrapper + return decorator \ No newline at end of file diff --git a/with_system_alsa.sh b/with_system_alsa.sh new file mode 100644 index 0000000..b66b4f1 --- /dev/null +++ b/with_system_alsa.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Conda 里 PyAudio 自带的 libportaudio 会加载同目录 libasound,从而去 conda/.../lib/alsa-lib/ +# 找插件(多数环境不完整,终端刷屏且麦克风电平异常)。 +# 在启动 Python **之前** 预加载系统的 libasound.so.2 可恢复正常 ALSA 行为。 +# +# 用法(项目根目录): +# bash with_system_alsa.sh python src/mic_level_check.py +# bash with_system_alsa.sh python src/rocket_drone_audio.py + +set -euo pipefail +ARCH="$(uname -m)" +case "${ARCH}" in + aarch64) ASO="/usr/lib/aarch64-linux-gnu/libasound.so.2" ;; + x86_64) ASO="/usr/lib/x86_64-linux-gnu/libasound.so.2" ;; + *) ASO="" ;; +esac +if [[ -n "${ASO}" && -f "${ASO}" ]]; then + export LD_PRELOAD="${ASO}${LD_PRELOAD:+:${LD_PRELOAD}}" +fi +exec "$@"