65 Commits
Author SHA1 Message Date
zimonianhua 1d68e63944 chore: 移除 Rust 客户端初始化脚本中的 ASCII 艺术和版本信息
简化初始化脚本,删除启动时显示的 ASCII 艺术标题和版本信息,使输出更简洁。
2026-03-04 22:49:54 +08:00
zimonianhua aafdf024d3 feat(hass): 新增小爱音箱接入 Home Assistant Assist 的完整示例
- 新增完整的 Home Assistant 集成示例,支持文本对话与上下文会话
- 实现小爱音箱事件解析与 TTS 播报,支持实时打断功能
- 添加连续会话管理,支持区域上下文注入和会话超时结束
- 提供完整的配置系统、类型定义和错误处理
- 包含 Rust 扩展模块用于小爱音箱通信,支持音频流处理
- 添加单元测试、集成测试脚本和 Docker 部署支持
- 提供详细的使用文档和配置说明
2026-03-04 17:11:04 +08:00
DelandGitHub 0f92b0a26d Merge pull request #105 from kslr/main
feat: 优化小智的音频与唤醒链路
2026-02-24 15:26:04 +08:00
kslr 68b5484da5 revert(xiaozhi): remove kws rms gate and debounce 2026-02-24 15:19:33 +08:00
kslr 9ee6ac649c feat(xiaozhi): improve wake word robustness and remove boost path 2026-02-22 11:12:46 +00:00
DelandGitHub 8522e955aa Merge pull request #104 from kslr/main
fix: 修正Amlogic A113X PDM 24→16 位对齐导致的约 -48 dB 衰减
2026-02-22 17:23:33 +08:00
Claude Code 0748bedceb refactor(audio): remove flag-style gating from A113 conversion 2026-02-22 08:15:39 +00:00
Claude Code fbef7c495c refactor(audio): inline A113 conversion flow without flag state 2026-02-22 08:11:49 +00:00
Claude Code e986e45d7e refactor(audio): remove A113 probe and fallback path 2026-02-22 08:08:28 +00:00
Claude Code 2e939a72d7 fix(audio): handle A113 PDM S32 offset in recorder 2026-02-22 08:03:43 +00:00
Del Wang aa2a5d8474 chore(stereo): 优化播放设备重初始化 2026-01-01 20:27:42 +08:00
Del Wang b69bd5a8f9 fix(stereo): 修复主从节点同步异常的问题 2026-01-01 19:58:04 +08:00
Del Wang f4340f068f chore: 更新固件版本 2026-01-01 17:12:02 +08:00
Del Wang b07e7500c1 chore: 优化开机初始化脚本 2026-01-01 17:06:10 +08:00
Del Wang 2027a61877 feat: 新增不同型号小爱音箱组立体声应用 2026-01-01 14:29:16 +08:00
Del Wang 3017473850 fix: 异步播放提示音,防止阻塞 2026-01-01 14:28:32 +08:00
Del Wang 118c34eb5c chore: 完善从节点连接提示音 2026-01-01 13:05:06 +08:00
Del WangandDel ab872180fc feat(stereo): 新增不同型号的小爱音箱组立体声应用 2025-12-31 22:53:33 +08:00
Del WangandDel 7bdbb30bd2 chore: 清空 apps 目录 2025-12-31 22:53:33 +08:00
Del WangandDel 7e80d6cd74 feat: 新增小爱音箱交叉编译环境 2025-12-31 22:53:33 +08:00
Del WangandDel 2ff214c873 feat: 自动重启音频应用使配置生效/恢复 2025-12-31 22:53:33 +08:00
Del WangandDel cdb4fcff85 chore: 调整从节点等待时间间隔 2025-12-31 22:53:33 +08:00
Del WangandDel ba6eaf6674 Revert "feat: 支持多播放源并发混音"
This reverts commit 32dced2fa2.
2025-12-31 22:53:33 +08:00
Del WangandDel 41be071b60 Revert "chore: 修改间隔"
This reverts commit 0c8fbafd3f.
2025-12-31 22:53:33 +08:00
Del WangandDel 84c332d742 chore: 修改间隔 2025-12-31 22:53:33 +08:00
Del WangandDel 09c625cf32 feat: 支持多播放源并发混音 2025-12-31 22:53:33 +08:00
Del WangandDel aabe882b88 fix: 修复 master 无法正常退出的问题 2025-12-31 22:53:33 +08:00
Del WangandDel 316f61d0bb chore: 整理 AudioConfig 2025-12-31 22:53:33 +08:00
Del WangandDel 4bce9e8d23 chore: 优化 alsa 配置 2025-12-31 22:53:33 +08:00
Del WangandDel 871d01a158 fix: 修复开始播放音频时爆音的问题 2025-12-31 22:53:33 +08:00
Del WangandDel 1caef01836 fix: 支持从节点订阅不同声道 2025-12-31 22:53:33 +08:00
Del WangandDel f4b70088fe fix: 修复 master 退出环境清理流程 2025-12-31 22:53:33 +08:00
Del WangandDel 81ac343098 feat: 支持动态切换主从播放模式 2025-12-31 22:53:33 +08:00
Del WangandDel b277fb7234 chore: 优化连接提示 2025-12-31 22:53:33 +08:00
Del WangandDel 9fe7ebd65d fix: run_slave 支持主节点下线后自动重新连接 2025-12-31 22:53:33 +08:00
Del WangandDel f7dd1586e8 fix: 修复 OpusCodec::decode_loss 2025-12-31 22:53:33 +08:00
Del WangandDel 28d714a2ef refactor: 拆分网络通信模块 2025-12-31 22:53:33 +08:00
Del WangandDel 76260d412c refactor: 拆分 stereo 模块 2025-12-31 22:53:33 +08:00
Del WangandDel 05d2174873 refactor: 使用 UDP 传输音频流 2025-12-31 22:53:33 +08:00
Del WangandDel fbe64f63dd chore: 从设备使用默认输出 2025-12-31 22:53:33 +08:00
Del WangandDel 518436d000 chore: 直接读取本地 /etc/asound.conf 配置文件 2025-12-31 22:53:33 +08:00
Del WangandDel 9b2f61b037 chore: 优化代码 2025-12-31 22:53:33 +08:00
Del WangandDel bd9565577f feat: 优化立体声同步播放 2025-12-31 22:53:33 +08:00
Del WangandDel 962647afea feat: 多设备组立体声 2025-12-31 22:53:33 +08:00
Del WangandDel 8e788f4b47 feat: 优化音频传输参数 2025-12-31 22:53:33 +08:00
Del WangandDel 392d8bc21b feat: 完成立体声 demo 的 client / server 构建 2025-12-31 22:53:33 +08:00
Del WangandDel 06645dc563 feat: 立体声 client 构建 2025-12-31 22:53:33 +08:00
Del WangandDel 61ecb07a99 chore: 拆分 bin 入口 2025-12-31 22:53:33 +08:00
Del WangandDel 3d9d852bda perf: 优化产物体积 2025-12-31 22:53:33 +08:00
Del WangandDel fba40393c2 feat: 动态链接到 opus 和 alsa 基础库,完成音频录制播放和编解码 2025-12-31 22:53:33 +08:00
Del WangandDel c521e274e9 feat: 新增测试 runtime 2025-12-31 22:53:33 +08:00
DelandGitHub 70a64e1eee Merge pull request #90 from hehetoshang/main
ci: 添加 Rust 客户端编译工作流
2025-12-31 21:55:35 +08:00
hehetoshang 77a19c41e8 Merge branch 'main' of https://github.com/hehetoshang/open-xiaoai 2025-12-31 20:10:15 +08:00
hehetoshang b7292dbd12 ci: 添加 Rust 客户端编译工作流
添加用于编译 client-rust 的 GitHub Actions 工作流,当 main 分支的 client-rust 代码变更时自动触发。工作流包含 Rust 工具链安装、交叉编译和二进制文件上传步骤。
2025-12-31 20:10:05 +08:00
Del Wang 0f75a10b9f chore: 更新 xiaozhi 自启动初始化脚本 2025-12-10 18:27:26 +08:00
Del Wang 2d6c17d02f fix: 修复自启动脚本网络连接状态检测 2025-12-10 18:16:51 +08:00
DelandGitHub 871a04ef79 Merge pull request #66 from x1a0b0/refactor
Some refactors
2025-10-13 17:45:29 +08:00
x1a0b0 7b473ad86a Eliminate several singletons
just for better lifecycle management

Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 14:20:11 +08:00
x1a0b0 61943b16ac Apply clippy lint
Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 11:34:44 +08:00
x1a0b0 d43a9588c9 Update deps
Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 11:29:48 +08:00
3D_DLWandDel 5e70f73c1e Update sed command delimiter in patch.sh
Fix sed command to use pipe instead of slash for delimiter.
2025-09-27 14:51:21 +08:00
Del Wang 31e997a192 chore: debug ota 2025-09-01 09:49:07 +08:00
Del Wang 39b2dfe220 feat: 更新 Xiaomi 智能音箱 Pro v1.58.1 固件 2025-08-31 19:29:52 +08:00
Del Wang 066fddbb68 feat: 更新小爱音箱 Pro v1.88.221 固件 2025-08-26 09:38:04 +08:00
Del Wang b5d00db7ca fix: 开机启动延时应不低于 15s,防止设备连接后自动启动,导致连接失败 2025-08-26 09:37:43 +08:00
92 changed files with 4827 additions and 314 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Compile Client Rust
on:
push:
branches: [ main ]
paths:
- 'packages/client-rust/**'
pull_request:
branches: [ main ]
paths:
- 'packages/client-rust/**'
jobs:
compile:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
target: armv7-unknown-linux-gnueabihf
- name: Install cross
run: cargo install cross
- name: Compile client-rust
working-directory: packages/client-rust
run: cross build --release --target armv7-unknown-linux-gnueabihf
- name: Upload compiled binary
uses: actions/upload-artifact@v4
with:
name: client-rust-binary
path: packages/client-rust/target/armv7-unknown-linux-gnueabihf/release/client
retention-days: 7
+1
View File
@@ -47,3 +47,4 @@ __pycache__
.mi.json .mi.json
xiaozhi.json xiaozhi.json
open-xiaoai.node open-xiaoai.node
examples/hass/config.json
+2 -1
View File
@@ -52,6 +52,7 @@
- 👉 [小爱音箱自定义唤醒词](examples/kws/README.md) - 👉 [小爱音箱自定义唤醒词](examples/kws/README.md)
- 👉 [小爱音箱接入 MiGPT(完美版)](examples/migpt/README.md) - 👉 [小爱音箱接入 MiGPT(完美版)](examples/migpt/README.md)
- 👉 [小爱音箱接入 Gemini Live API](examples/gemini/README.md) - 👉 [小爱音箱接入 Gemini Live API](examples/gemini/README.md)
- 👉 [小爱音箱组立体声(支持不同型号机型)](examples/stereo/README.md)
以上皆为抛砖引玉,你也可以亲手编写自己想要的功能,一切由你定义! 以上皆为抛砖引玉,你也可以亲手编写自己想要的功能,一切由你定义!
@@ -89,4 +90,4 @@
## License ## License
[MIT](LICENSE) License © 2024-PRESENT Del Wang MIT License © 2024-PRESENT [Del Wang](https://del.wang)
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "open_xiaoai_server"
version = "1.0.0"
edition = "2021"
[lib]
name = "open_xiaoai_server"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py38"] }
pyo3-async-runtimes = { version = "0.24", features = ["attributes", "tokio-runtime"] }
open-xiaoai = { path = "../../packages/client-rust" }
tokio = { version = "1.32.0", features = ["full"] }
tokio-tungstenite = "0.26.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+53
View File
@@ -0,0 +1,53 @@
FROM python:3.12-slim AS builder
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \
curl build-essential pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV BASH_ENV=/root/.bash_env
RUN touch "$BASH_ENV"
RUN echo '. "$BASH_ENV"' >> "$HOME/.bashrc"
RUN echo '[ -s "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"' >> "$BASH_ENV"
WORKDIR /app
COPY examples/hass/hass ./hass
COPY examples/hass/src ./src
COPY examples/hass/Cargo.toml ./Cargo.toml
COPY examples/hass/pyproject.toml ./pyproject.toml
COPY examples/hass/README.md ./README.md
COPY examples/hass/requirements.txt ./requirements.txt
COPY examples/hass/hass_assistant.py ./hass_assistant.py
COPY examples/hass/config.example.json ./config.example.json
COPY packages/client-rust ./client-rust
RUN python -m venv /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
RUN pip install maturin
RUN sed -i 's/\.\.\/\.\.\/packages\///g' Cargo.toml \
&& maturin develop --release
FROM python:3.12-slim
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /app/hass /app/hass
COPY --from=builder /app/hass_assistant.py /app/hass_assistant.py
COPY --from=builder /app/config.example.json /app/config.example.json
CMD ["python", "hass_assistant.py"]
+125
View File
@@ -0,0 +1,125 @@
# Open-XiaoAI x Home Assistant Assist
该示例用于将小爱音箱接入 Home Assistant 的对话/语音助手能力,支持文本对话与上下文会话,并提供可选的语音输入管线对接能力。
## 功能说明
- 文本对话:对接 Home Assistant `conversation.process` API,支持自然语言控制设备/查询信息
- 实时打断:接收到新用户消息时先中断当前播报,再处理新输入,避免并发播报
- 连续对话会话管理:默认开启连续对话;当命中结束词或语音静默超时时自动结束并重建 `conversation_id`
- 区域上下文注入:支持客户端 IP 到 Home Assistant 区域映射,命中后在每次请求前注入区域前置提示词
- 小爱音箱 I/O:从小爱音箱事件中提取最终识别文本(ASR),将回复通过小爱本机 TTS 播放
- 可选音频能力(扩展点):支持接收小爱录音流 `record`,用于后续对接 Assist pipelineSTT→Intent→TTS
## 使用场景
- 用中文/英文自然语言控制 Home Assistant:开灯、关灯、调亮度、问温度/天气、查询传感器状态
- 把小爱音箱作为 Home Assistant 的“外置麦克风 + 扬声器”(局域网内运行)
## 架构概览
- 小爱音箱 →(WebSocket 4399)→ Rust 网关(`open_xiaoai_server`)→ Python 回调
- 文本链路(默认,低延迟):
- 设备事件 `instruction/NewLine` → 抽取 `SpeechRecognizer.RecognizeResult` 文本
- 调用 HA `/api/conversation/process`
- 把返回的 `response.speech.plain.speech` 交给小爱 TTS 播报
## 目录结构
本目录包含:
- `hass_assistant.py`:主入口
- `config.example.json`:配置模板
- `requirements.txt`Python 依赖
- `src/`Rust(PyO3) 扩展模块(小爱音箱 I/O 网关)
- `hass/`:Python 实现(HA 客户端、对话编排、上下文记忆)
- `tests/`:单元测试
- `scripts/`:集成测试脚本
## 安装与运行
### 前置条件
- 你的小爱音箱已刷机并运行 Rust 客户端补丁(否则服务端收不到事件/音频流)
- Home Assistant 已启用 `conversation`(默认配置一般已启用)并生成 Long-Lived Access Token
- 本机具备 Python(建议 3.10+)与 Rust 工具链(用于编译 PyO3 扩展)
- 可选:如果你希望使用 webrtcvad(更好的语音端点检测),Windows 需要安装 MSVC Build Tools
### 1) 准备配置
复制配置模板:
```bash
cd examples/hass
cp config.example.json config.json
```
编辑 `config.json`
- `homeassistant.url`HA 地址(例如 `http://192.168.1.10:8123`
- `homeassistant.access_token`HA Long-Lived Access Token
- `homeassistant.assistant_entity_id`:对话助理 ID(对应 `agent_id`,默认 `home_assistant`
### 配置新增项说明
`xiaoai` 段新增以下关键配置:
- `interrupt_on_new_input`:是否在收到新消息时立即执行打断,默认 `true`
- `interrupt_command`:打断命令,默认使用 `ubus call mibrain ai_service` 触发中断
- `continuous_conversation_enabled`:是否开启连续会话,默认 `true`
- `session_idle_timeout_seconds`:连续会话静默超时秒数,默认 `15`
- `session_end_keywords`:结束词列表,默认包含 `再见` `拜拜` `滚` `退下`,按包含匹配
- `client_ip_area_mapping`:客户端 IP 到区域名称映射,仅在命中时注入区域提示词
区域提示词注入内容为:
`当前用户对话所在区域:<区域名称>,如后续对话未明确指定区域,则默认为此区域`
### 2) 安装依赖
```bash
pip install -r requirements.txt
```
### 3) 编译 Rust 扩展(open_xiaoai_server
推荐使用 maturin
```bash
pip install maturin
maturin develop --release
```
### 4) 运行
```bash
python hass_assistant.py
```
## API 调用示例
### Home Assistant conversation.processcurl
```bash
curl -X POST \
-H "Authorization: Bearer YOUR_LONG_LIVED_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
http://HOME_ASSISTANT:8123/api/conversation/process \
-d '{"text":"打开客厅灯","language":"zh-cn","agent_id":"home_assistant"}'
```
### Python 调用(不连接小爱,仅验证 HA)
可参考 `scripts/integration_text.py`
## 故障排除
- 401/403:检查 `access_token` 是否正确、是否完整复制
- 连接超时:检查 `homeassistant.url` 是否可达、是否被防火墙/反代阻断
- 无语音输入:确认小爱已运行客户端补丁且能连到本机 4399 端口
- 无语音输出:确认小爱系统 `ubus`/`mibrain` 可用(或改用 `play_url` 播放 URL
- 对话不连贯:确认 `state.json` 能写入(用于持久化 `conversation_id`),或删除它重置会话
- 打断不生效:检查 `xiaoai.interrupt_command` 是否可在设备上执行,以及日志中是否出现 `xiaoai_interrupt_failed`
- 区域提示词未注入:检查 `client_ip_area_mapping` 是否包含当前客户端 IP,并查看 `area_mapping_hit/area_mapping_miss` 日志
## 部署说明
- 建议仅在局域网内部署运行,避免将 4399 暴露到公网
- 如果 HA 与运行本示例的机器不在同一网段,确保 HA 的 URL 在设备侧可访问(尤其是播放 URL 时)
+36
View File
@@ -0,0 +1,36 @@
{
"homeassistant": {
"url": "http://homeassistant.local:8123",
"access_token": "YOUR_LONG_LIVED_ACCESS_TOKEN",
"assistant_entity_id": "home_assistant",
"language": "zh-cn",
"conversation_id": null,
"timeout_seconds": 2.5
},
"xiaoai": {
"tts_blocking": false,
"shell_timeout_ms": 600000,
"interrupt_on_new_input": true,
"interrupt_command": "ubus call mibrain ai_service '{\"nlp\":1,\"nlp_text\":\"\"}'",
"continuous_conversation_enabled": true,
"session_idle_timeout_seconds": 15,
"session_end_keywords": ["再见", "拜拜", "滚", "退下"],
"client_ip_area_mapping": {
"192.168.1.101": "客厅",
"192.168.1.102": "主卧"
}
},
"runtime": {
"concurrency": 4,
"log_level": "INFO",
"persist_state_path": "state.json"
},
"voice": {
"enabled": false,
"mode": "record_vad_pipeline",
"vad_aggressiveness": 2,
"min_silence_ms": 800,
"max_utterance_ms": 15000,
"sample_rate": 16000
}
}
+10
View File
@@ -0,0 +1,10 @@
__all__ = [
"config_loader",
"errors",
"ha_client",
"memory",
"typing",
"vad",
"xiaoai_bridge",
]
+10
View File
@@ -0,0 +1,10 @@
import json
from pathlib import Path
from hass.typing import AppConfig
def load_config(path: str) -> AppConfig:
data = json.loads(Path(path).read_text(encoding="utf-8"))
return AppConfig.model_validate(data)
+19
View File
@@ -0,0 +1,19 @@
class HassAssistantError(Exception):
pass
class HomeAssistantRequestError(HassAssistantError):
pass
class HomeAssistantAuthError(HomeAssistantRequestError):
pass
class HomeAssistantResponseError(HomeAssistantRequestError):
pass
class XiaoAiBridgeError(HassAssistantError):
pass
+161
View File
@@ -0,0 +1,161 @@
import asyncio
from typing import Optional
import aiohttp
from hass.errors import (
HomeAssistantAuthError,
HomeAssistantRequestError,
HomeAssistantResponseError,
)
from hass.memory import ConversationMemory
from hass.typing import AssistantReply, HomeAssistantConfig
class HomeAssistantClient:
def __init__(
self, config: HomeAssistantConfig, memory: ConversationMemory, concurrency: int = 4
) -> None:
self._config = config
self._memory = memory
self._session: Optional[aiohttp.ClientSession] = None
self._lock = asyncio.Lock()
self._semaphore = asyncio.Semaphore(max(1, int(concurrency)))
async def start(self) -> None:
if self._session is not None:
return
headers = {"Authorization": f"Bearer {self._config.access_token}"}
timeout = aiohttp.ClientTimeout(total=self._config.timeout_seconds)
self._session = aiohttp.ClientSession(headers=headers, timeout=timeout)
async def close(self) -> None:
if self._session is None:
return
await self._session.close()
self._session = None
async def reset_conversation(self) -> None:
self._memory.conversation_id = None
await self._memory.save()
async def process_text(self, text: str, prefix_prompt: Optional[str] = None) -> AssistantReply:
if self._session is None:
raise HomeAssistantRequestError("HomeAssistantClient is not started")
request_text = text
if prefix_prompt:
request_text = f"{prefix_prompt}\n用户请求:{text}"
payload = {"text": request_text}
if self._config.language:
payload["language"] = self._config.language
if self._config.agent_id:
payload["agent_id"] = self._config.agent_id
conversation_id = self._memory.conversation_id or self._config.conversation_id
if conversation_id:
payload["conversation_id"] = conversation_id
url = str(self._config.url).rstrip("/") + "/api/conversation/process"
async with self._semaphore:
async with (
self._lock
if (self._memory.conversation_id or self._config.conversation_id)
else _NullAsyncContext()
):
data = None
reset_attempts = 2 if conversation_id else 1
for reset_index in range(reset_attempts):
if reset_index == 1:
payload.pop("conversation_id", None)
if self._memory.conversation_id:
self._memory.conversation_id = None
await self._memory.save()
attempts = 3
delay = 0.5
for i in range(attempts):
try:
timeout_seconds = (
self._config.timeout_seconds
if i == 0
else max(self._config.timeout_seconds, 8.0)
)
request_timeout = aiohttp.ClientTimeout(total=timeout_seconds)
async with self._session.post(
url,
json=payload,
timeout=request_timeout,
) as resp:
if resp.status in (401, 403):
raise HomeAssistantAuthError(
f"Home Assistant auth failed: {resp.status}"
)
if resp.status >= 500:
raise HomeAssistantRequestError(
f"Home Assistant server error: {resp.status}"
)
if resp.status >= 400:
raise HomeAssistantRequestError(
f"Home Assistant request failed: {resp.status}"
)
data = await resp.json()
break
except asyncio.TimeoutError:
if i < attempts - 1:
await asyncio.sleep(delay)
delay *= 2
continue
error = HomeAssistantRequestError(
f"Home Assistant request timeout, timeout_seconds={max(self._config.timeout_seconds, 8.0)}"
)
except aiohttp.ClientError as e:
if i < attempts - 1:
await asyncio.sleep(delay)
delay *= 2
continue
error = HomeAssistantRequestError(str(e))
else:
error = None
if error is not None:
if reset_index == reset_attempts - 1:
raise error
break
if data is not None:
break
if not isinstance(data, dict):
raise HomeAssistantResponseError("Unexpected response payload type")
new_conversation_id = data.get("conversation_id")
if new_conversation_id:
self._memory.conversation_id = new_conversation_id
await self._memory.save()
response = data.get("response", {}) if isinstance(data.get("response", {}), dict) else {}
speech = (
response.get("speech", {}).get("plain", {}).get("speech", "")
if isinstance(response.get("speech", {}), dict)
else ""
)
if not isinstance(speech, str):
raise HomeAssistantResponseError("Unexpected response speech type")
if not speech:
response_type = response.get("response_type")
speech = "好的" if response_type in ("action_done", "query_answer") else "出错了"
continue_conversation = bool(data.get("continue_conversation", False))
return AssistantReply(
speech=speech,
continue_conversation=continue_conversation,
conversation_id=new_conversation_id,
raw=data,
)
class _NullAsyncContext:
async def __aenter__(self):
return None
async def __aexit__(self, exc_type, exc, tb):
return False
+22
View File
@@ -0,0 +1,22 @@
import json
from pathlib import Path
from typing import Optional
class ConversationMemory:
def __init__(self, state_path: str) -> None:
self._path = Path(state_path)
self.conversation_id: Optional[str] = None
async def load(self) -> None:
if not self._path.exists():
return
data = json.loads(self._path.read_text(encoding="utf-8"))
self.conversation_id = data.get("conversation_id")
async def save(self) -> None:
self._path.write_text(
json.dumps({"conversation_id": self.conversation_id}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field, HttpUrl, model_validator
class HomeAssistantConfig(BaseModel):
url: HttpUrl
access_token: str
agent_id: str = "home_assistant"
assistant_entity_id: Optional[str] = None
language: Optional[str] = None
conversation_id: Optional[str] = None
timeout_seconds: float = 2.5
@model_validator(mode="after")
def _normalize_agent_id(self):
if self.assistant_entity_id:
self.agent_id = self.assistant_entity_id
return self
class XiaoAiConfig(BaseModel):
tts_blocking: bool = False
shell_timeout_ms: int = 10 * 60 * 1000
interrupt_on_new_input: bool = True
interrupt_command: str = "ubus call mibrain ai_service '{\"nlp\":1,\"nlp_text\":\"\"}'"
continuous_conversation_enabled: bool = True
session_idle_timeout_seconds: int = 15
session_end_keywords: list[str] = Field(
default_factory=lambda: ["再见", "拜拜", "", "退下"]
)
client_ip_area_mapping: dict[str, str] = Field(default_factory=dict)
class RuntimeConfig(BaseModel):
concurrency: int = 4
log_level: str = "INFO"
persist_state_path: str = "state.json"
class VoiceConfig(BaseModel):
enabled: bool = False
mode: Literal["record_vad_pipeline"] = "record_vad_pipeline"
vad_aggressiveness: int = 2
min_silence_ms: int = 800
max_utterance_ms: int = 15000
sample_rate: int = 16000
class AppConfig(BaseModel):
homeassistant: HomeAssistantConfig
xiaoai: XiaoAiConfig = Field(default_factory=XiaoAiConfig)
runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
voice: VoiceConfig = Field(default_factory=VoiceConfig)
class AssistantReply(BaseModel):
speech: str
continue_conversation: bool = False
conversation_id: Optional[str] = None
raw: dict = Field(default_factory=dict)
class RecognizedSpeechInput(BaseModel):
text: str
client_ip: Optional[str] = None
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from dataclasses import dataclass
try:
import webrtcvad
except Exception:
webrtcvad = None
@dataclass(frozen=True)
class VadConfig:
aggressiveness: int = 2
min_silence_ms: int = 800
max_utterance_ms: int = 15000
sample_rate: int = 16000
energy_threshold: int = 450
class VadSegmenter:
def __init__(self, config: VadConfig) -> None:
self._config = config
self._vad = webrtcvad.Vad(int(config.aggressiveness)) if webrtcvad else None
self._frame_ms = 20
self._frame_bytes = int(config.sample_rate * self._frame_ms / 1000) * 2
self._buffer = bytearray()
self._in_speech = False
self._speech = bytearray()
self._silence_ms = 0
self._speech_ms = 0
def feed(self, pcm: bytes) -> list[bytes]:
if not pcm:
return []
self._buffer.extend(pcm)
utterances: list[bytes] = []
while len(self._buffer) >= self._frame_bytes:
frame = bytes(self._buffer[: self._frame_bytes])
del self._buffer[: self._frame_bytes]
if self._vad is not None:
is_speech = self._vad.is_speech(frame, self._config.sample_rate)
else:
samples = memoryview(frame).cast("h")
avg = sum(abs(x) for x in samples) / max(1, len(samples))
is_speech = avg >= self._config.energy_threshold
if is_speech:
if not self._in_speech:
self._in_speech = True
self._speech.clear()
self._silence_ms = 0
self._speech_ms = 0
self._speech.extend(frame)
self._speech_ms += self._frame_ms
self._silence_ms = 0
else:
if self._in_speech:
self._speech.extend(frame)
self._speech_ms += self._frame_ms
self._silence_ms += self._frame_ms
if self._in_speech and (
self._silence_ms >= self._config.min_silence_ms
or self._speech_ms >= self._config.max_utterance_ms
):
utterances.append(bytes(self._speech))
self._in_speech = False
self._speech.clear()
self._silence_ms = 0
self._speech_ms = 0
return utterances
def flush(self) -> list[bytes]:
if not self._in_speech or not self._speech:
return []
utterance = bytes(self._speech)
self._in_speech = False
self._speech.clear()
self._silence_ms = 0
self._speech_ms = 0
return [utterance]
+243
View File
@@ -0,0 +1,243 @@
import asyncio
import json
import logging
import re
from collections.abc import Awaitable, Callable
from typing import Optional
import time
from hass.errors import XiaoAiBridgeError
from hass.typing import RecognizedSpeechInput, XiaoAiConfig
_LOGGER = logging.getLogger(__name__)
class XiaoAiBridge:
def __init__(self, config: XiaoAiConfig) -> None:
self._config = config
self._on_text: Optional[Callable[[RecognizedSpeechInput], Awaitable[None]]] = None
self._on_audio: Optional[Callable[[bytes], None]] = None
self._ready = asyncio.Event()
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._last_text: Optional[str] = None
self._last_client_ip: Optional[str] = None
self._last_text_ts: float = 0.0
def set_on_text(self, fn: Callable[[RecognizedSpeechInput], Awaitable[None]]) -> None:
self._on_text = fn
def set_on_audio(self, fn: Callable[[bytes], None]) -> None:
self._on_audio = fn
async def start(self) -> None:
try:
import open_xiaoai_server
except Exception as e:
raise XiaoAiBridgeError(
"open_xiaoai_server module is not available (Rust extension not built)"
) from e
self._open_xiaoai_server = open_xiaoai_server
self._loop = asyncio.get_running_loop()
self._register_callbacks()
asyncio.ensure_future(self._open_xiaoai_server.start_server())
self._ready.set()
def _register_callbacks(self) -> None:
def on_event(event_json: str) -> None:
# Rust 侧会把 Event 结构序列化成 JSON 字符串传入,这里做反序列化与容错解析
try:
event = json.loads(event_json)
except Exception:
return
if event.get("event") != "instruction":
return
data = event.get("data") or {}
line_text = data.get("NewLine")
if not line_text:
return
# NewLine 是设备端透传的 JSON 文本,包含语音识别结果等指令
try:
line = json.loads(line_text)
except Exception:
return
header = line.get("header") or {}
if header.get("namespace") != "SpeechRecognizer":
return
if header.get("name") != "RecognizeResult":
return
payload = line.get("payload") or {}
if not payload.get("is_final"):
return
results = payload.get("results") or []
if not results or not isinstance(results, list):
return
text = results[0].get("text") if isinstance(results[0], dict) else None
if not text or not isinstance(text, str):
return
text = text.strip()
if not text:
return
if self._on_text is None or self._loop is None:
return
recognized = RecognizedSpeechInput(
text=text,
client_ip=self._extract_client_ip(event=event, data=data, line=line, payload=payload),
)
now = time.monotonic()
if (
self._last_text == recognized.text
and self._last_client_ip == (recognized.client_ip or "")
and (now - self._last_text_ts) < 1.2
):
_LOGGER.info(
"xiaoai_duplicate_text_skipped text=%s client_ip=%s",
recognized.text,
recognized.client_ip or "",
)
return
self._last_text = recognized.text
self._last_client_ip = recognized.client_ip or ""
self._last_text_ts = now
async def _dispatch() -> None:
if self._config.interrupt_on_new_input:
await self.interrupt_playback(reason="new_input")
await self._on_text(recognized)
if not recognized.client_ip:
_LOGGER.info("xiaoai_client_ip_missing")
# 回调来自 Rust 的 tokio 线程,需切回 Python 主事件循环执行异步对话逻辑
asyncio.run_coroutine_threadsafe(_dispatch(), self._loop)
def on_input_data(data: bytes) -> None:
if self._on_audio is None:
return
self._on_audio(data)
self._open_xiaoai_server.register_fn("on_event", on_event)
self._open_xiaoai_server.register_fn("on_input_data", on_input_data)
@staticmethod
def _extract_client_ip(
event: dict,
data: dict,
line: dict,
payload: dict,
) -> Optional[str]:
def _normalize_ip(value: object) -> Optional[str]:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
if ":" in text:
host, _, maybe_port = text.rpartition(":")
if host and maybe_port.isdigit():
text = host
match = re.search(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", text)
if match:
return match.group(0)
return None
def _walk(obj: object) -> Optional[str]:
if isinstance(obj, dict):
for key, value in obj.items():
key_text = str(key).lower()
if any(token in key_text for token in ("ip", "addr", "remote")):
normalized = _normalize_ip(value)
if normalized:
return normalized
nested = _walk(value)
if nested:
return nested
return None
if isinstance(obj, list):
for item in obj:
nested = _walk(item)
if nested:
return nested
return None
return _normalize_ip(obj)
candidates = [
data.get("ClientIP"),
data.get("client_ip"),
data.get("ip"),
data.get("remote_addr"),
line.get("client_ip"),
payload.get("client_ip"),
payload.get("remote_addr"),
event.get("client_ip"),
event.get("remote_addr"),
event.get("peer"),
event.get("addr"),
event,
]
for candidate in candidates:
normalized = _walk(candidate)
if normalized:
return normalized
return None
@staticmethod
def _escape_single_quotes(text: str) -> str:
# run_shell 侧通常会交给 shell 执行,单引号中出现单引号需要转义
return text.replace("'", "'\\''")
async def interrupt_playback(self, reason: str) -> None:
await self._ready.wait()
command = self._config.interrupt_command.strip()
if not command:
_LOGGER.info("xiaoai_interrupt_skipped reason=%s detail=empty_command", reason)
return
_LOGGER.info("xiaoai_interrupt_triggered reason=%s", reason)
try:
await self._open_xiaoai_server.run_shell(
command, float(self._config.shell_timeout_ms)
)
except Exception as exc:
_LOGGER.warning("xiaoai_interrupt_failed reason=%s error=%s", reason, exc)
async def speak(self, text: str) -> None:
if not text:
return
await self._ready.wait()
if self._config.tts_blocking:
safe_text = self._escape_single_quotes(text)
script = f"/usr/sbin/tts_play.sh '{safe_text}'"
await self._open_xiaoai_server.run_shell(
script, float(self._config.shell_timeout_ms)
)
return
payload = json.dumps({"text": text, "save": 0}, ensure_ascii=False)
payload = self._escape_single_quotes(payload)
script = f"ubus call mibrain text_to_speech '{payload}'"
await self._open_xiaoai_server.run_shell(script, float(self._config.shell_timeout_ms))
async def play_url(self, url: str) -> None:
if not url:
return
await self._ready.wait()
payload = json.dumps({"url": url, "type": 1}, ensure_ascii=False)
payload = self._escape_single_quotes(payload)
script = f"ubus call mediaplayer player_play_url '{payload}'"
await self._open_xiaoai_server.run_shell(script, float(self._config.shell_timeout_ms))
async def wait_forever(self) -> None:
await self._ready.wait()
await asyncio.Event().wait()
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import logging
import os
from pathlib import Path
from collections.abc import Awaitable, Callable
from typing import Optional
from hass.config_loader import load_config
from hass.errors import HomeAssistantRequestError
from hass.ha_client import HomeAssistantClient
from hass.memory import ConversationMemory
from hass.typing import RecognizedSpeechInput
from hass.xiaoai_bridge import XiaoAiBridge
_LOGGER = logging.getLogger(__name__)
class ConversationSession:
def __init__(
self,
*,
enabled: bool,
idle_timeout_seconds: int,
end_keywords: list[str],
) -> None:
self._enabled = enabled
self._idle_timeout_seconds = max(1, int(idle_timeout_seconds))
self._end_keywords = [keyword.strip() for keyword in end_keywords if keyword and keyword.strip()]
self._idle_task: Optional[asyncio.Task] = None
def match_end_keyword(self, text: str) -> Optional[str]:
for keyword in self._end_keywords:
if keyword in text:
return keyword
return None
@staticmethod
def build_area_prefix(area: str) -> str:
return f"当前用户对话所在区域:{area},如后续对话未明确指定区域,则默认为此区域"
def resolve_area(self, client_ip: Optional[str], mapping: dict[str, str]) -> Optional[str]:
if not client_ip:
return None
return mapping.get(client_ip)
def restart_idle_timer(self, on_timeout: Callable[[], Awaitable[None]]) -> None:
if not self._enabled:
return
self.cancel_idle_timer()
async def _timeout_worker() -> None:
try:
await asyncio.sleep(self._idle_timeout_seconds)
await on_timeout()
except asyncio.CancelledError:
return
self._idle_task = asyncio.create_task(_timeout_worker())
def cancel_idle_timer(self) -> None:
if self._idle_task and not self._idle_task.done():
self._idle_task.cancel()
self._idle_task = None
async def close(self) -> None:
self.cancel_idle_timer()
async def run(config_path: str) -> None:
config = load_config(config_path)
logging.basicConfig(level=getattr(logging, config.runtime.log_level.upper(), logging.INFO))
memory = ConversationMemory(config.runtime.persist_state_path)
await memory.load()
ha = HomeAssistantClient(config.homeassistant, memory, concurrency=config.runtime.concurrency)
await ha.start()
bridge = XiaoAiBridge(config.xiaoai)
await bridge.start()
session = ConversationSession(
enabled=config.xiaoai.continuous_conversation_enabled,
idle_timeout_seconds=config.xiaoai.session_idle_timeout_seconds,
end_keywords=config.xiaoai.session_end_keywords,
)
session_end_lock = asyncio.Lock()
async def end_session(reason: str) -> None:
async with session_end_lock:
await ha.reset_conversation()
_LOGGER.info("session_ended reason=%s", reason)
async def on_idle_timeout() -> None:
await end_session("idle_timeout")
async def on_text(recognized: RecognizedSpeechInput) -> None:
text = recognized.text.strip()
if not text:
return
session.restart_idle_timer(on_idle_timeout)
matched_keyword = session.match_end_keyword(text)
if matched_keyword:
await end_session(f"keyword:{matched_keyword}")
await bridge.speak("好的,当前会话已结束")
return
area = session.resolve_area(recognized.client_ip, config.xiaoai.client_ip_area_mapping)
prefix_prompt = None
if area:
prefix_prompt = session.build_area_prefix(area)
_LOGGER.info(
"area_mapping_hit client_ip=%s area=%s",
recognized.client_ip,
area,
)
else:
_LOGGER.info("area_mapping_miss client_ip=%s", recognized.client_ip or "")
try:
if not config.xiaoai.continuous_conversation_enabled:
await end_session("continuous_disabled_new_round")
reply = await ha.process_text(text, prefix_prompt=prefix_prompt)
await bridge.speak(reply.speech)
except HomeAssistantRequestError as exc:
_LOGGER.warning(
"ha_process_failed text=%s client_ip=%s error=%s",
text,
recognized.client_ip or "",
exc,
)
if prefix_prompt:
try:
_LOGGER.info("ha_process_retry_without_prefix client_ip=%s", recognized.client_ip)
reply = await ha.process_text(text, prefix_prompt=None)
await bridge.speak(reply.speech)
return
except HomeAssistantRequestError as retry_exc:
_LOGGER.warning(
"ha_process_retry_failed client_ip=%s error=%s",
recognized.client_ip or "",
retry_exc,
)
await bridge.speak("Home Assistant 连接异常")
bridge.set_on_text(on_text)
try:
await bridge.wait_forever()
finally:
await session.close()
await ha.close()
def main() -> None:
config_path = os.environ.get("HASS_ASSISTANT_CONFIG", "config.json")
if not Path(config_path).exists():
config_path = "config.example.json"
asyncio.run(run(config_path))
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "open-xiaoai-hass"
version = "1.0.0"
description = "小爱音箱接入 Home Assistant Assist"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.9.0",
"websockets>=12.0",
"pydantic>=2.6.0",
"maturin>=1.8.3"
]
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[tool.maturin]
features = ["pyo3/extension-module"]
+8
View File
@@ -0,0 +1,8 @@
aiohttp>=3.9.0
websockets>=12.0
webrtcvad>=2.0.10; platform_system != "Windows"
pydantic>=2.6.0
pytest>=8.0.0
pytest-asyncio>=0.23.0
aioresponses>=0.7.7
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,21 @@
import asyncio
import sys
from hass.config_loader import load_config
from hass.xiaoai_bridge import XiaoAiBridge
async def main() -> None:
config_path = sys.argv[1] if len(sys.argv) > 1 else "config.json"
text = sys.argv[2] if len(sys.argv) > 2 else "已连接"
config = load_config(config_path)
bridge = XiaoAiBridge(config.xiaoai)
await bridge.start()
await bridge.speak(text)
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,10 @@
import asyncio
import sys
from hass_assistant import run
if __name__ == "__main__":
config_path = sys.argv[1] if len(sys.argv) > 1 else "config.json"
asyncio.run(run(config_path))
+30
View File
@@ -0,0 +1,30 @@
import asyncio
import sys
from hass.config_loader import load_config
from hass.ha_client import HomeAssistantClient
from hass.memory import ConversationMemory
async def main() -> None:
config_path = sys.argv[1] if len(sys.argv) > 1 else "config.json"
text = sys.argv[2] if len(sys.argv) > 2 else "打开客厅灯"
config = load_config(config_path)
memory = ConversationMemory(config.runtime.persist_state_path)
await memory.load()
client = HomeAssistantClient(
config.homeassistant, memory, concurrency=config.runtime.concurrency
)
await client.start()
try:
reply = await client.process_text(text)
print(reply.speech)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())
+112
View File
@@ -0,0 +1,112 @@
use open_xiaoai::services::connect::message::MessageManager;
use open_xiaoai::services::connect::rpc::RPC;
use open_xiaoai::services::audio::config::AudioConfig;
use pyo3::prelude::*;
use pyo3::types::PyBytes;
use serde_json::json;
use server::AppServer;
pub mod macros;
pub mod python;
pub mod server;
#[pyfunction]
fn on_output_data(py: Python, data: Py<PyBytes>) -> PyResult<Bound<PyAny>> {
let bytes = data.as_bytes(py).to_vec();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let _ = MessageManager::instance()
.send_stream("play", bytes, None)
.await;
Ok(())
})
}
#[pyfunction]
fn start_server(py: Python) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async {
AppServer::run().await;
Ok(())
})
}
#[pyfunction]
fn run_shell(py: Python, script: String, timeout_millis: f64) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let res = RPC::instance()
.call_remote("run_shell", Some(json!(script)), Some(timeout_millis as u64))
.await;
let result = match res {
Err(e) => format!("run_shell error: {}", e),
Ok(res) => match res.data {
Some(data) => serde_json::to_string(&data).unwrap_or_default(),
None => String::new(),
},
};
Ok(result)
})
}
#[pyfunction]
fn start_recording(py: Python, config_json: Option<String>) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let config = config_json
.and_then(|text| serde_json::from_str::<AudioConfig>(&text).ok());
let res = RPC::instance()
.call_remote("start_recording", config.map(|c| json!(c)), None)
.await;
Ok(match res {
Ok(_) => true,
Err(_) => false,
})
})
}
#[pyfunction]
fn stop_recording(py: Python) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let res = RPC::instance().call_remote("stop_recording", None, None).await;
Ok(match res {
Ok(_) => true,
Err(_) => false,
})
})
}
#[pyfunction]
fn start_play(py: Python, config_json: Option<String>) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let config = config_json
.and_then(|text| serde_json::from_str::<AudioConfig>(&text).ok());
let res = RPC::instance()
.call_remote("start_play", config.map(|c| json!(c)), None)
.await;
Ok(match res {
Ok(_) => true,
Err(_) => false,
})
})
}
#[pyfunction]
fn stop_play(py: Python) -> PyResult<Bound<PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let res = RPC::instance().call_remote("stop_play", None, None).await;
Ok(match res {
Ok(_) => true,
Err(_) => false,
})
})
}
#[pymodule]
fn open_xiaoai_server(_py: Python, m: Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(start_server, &m)?)?;
m.add_function(wrap_pyfunction!(on_output_data, &m)?)?;
m.add_function(wrap_pyfunction!(run_shell, &m)?)?;
m.add_function(wrap_pyfunction!(start_recording, &m)?)?;
m.add_function(wrap_pyfunction!(stop_recording, &m)?)?;
m.add_function(wrap_pyfunction!(start_play, &m)?)?;
m.add_function(wrap_pyfunction!(stop_play, &m)?)?;
crate::python::init_module(&m)?;
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
#[macro_export]
macro_rules! pylog {
($($arg:tt)*) => {{
println!($($arg)*);
}};
}
+64
View File
@@ -0,0 +1,64 @@
use pyo3::prelude::*;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};
pub struct PythonManager {
functions: Arc<RwLock<HashMap<String, PyObject>>>,
}
static INSTANCE: LazyLock<PythonManager> = LazyLock::new(PythonManager::new);
impl PythonManager {
pub fn new() -> Self {
Self {
functions: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn instance() -> &'static Self {
&INSTANCE
}
fn register_fn(&self, key: &str, function: PyObject) -> PyResult<()> {
let mut functions = self.functions.write().unwrap();
functions.insert(key.to_string(), function);
Ok(())
}
fn unregister_fn(&self, key: &str) -> PyResult<()> {
let mut functions = self.functions.write().unwrap();
functions.remove(key);
Ok(())
}
pub fn call_fn(&self, key: &str, arg: Option<PyObject>) -> PyResult<PyObject> {
let functions = self.functions.read().unwrap();
if let Some(function) = functions.get(key) {
Python::with_gil(|py| match arg {
None => function.call0(py),
Some(arg) => function.call1(py, (arg,)),
})
} else {
Err(pyo3::exceptions::PyKeyError::new_err(format!(
"未找到函数: {}",
key
)))
}
}
}
#[pyfunction]
fn register_fn(key: &str, function: PyObject) -> PyResult<()> {
PythonManager::instance().register_fn(key, function)
}
#[pyfunction]
fn unregister_fn(key: &str) -> PyResult<()> {
PythonManager::instance().unregister_fn(key)
}
pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(register_fn, m)?)?;
m.add_function(wrap_pyfunction!(unregister_fn, m)?)?;
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
use crate::python::PythonManager;
use open_xiaoai::base::{AppError, VERSION};
use open_xiaoai::services::connect::data::{Event, Request, Response, Stream};
use open_xiaoai::services::connect::handler::MessageHandler;
use open_xiaoai::services::connect::message::{MessageManager, WsStream};
use open_xiaoai::services::connect::rpc::RPC;
use pyo3::types::{PyBytes, PyString};
use pyo3::Python;
use serde_json::json;
use std::sync::{LazyLock, RwLock};
use tokio::net::{TcpListener, TcpStream};
use tokio_tungstenite::accept_async;
static REMOTE_ADDR: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| RwLock::new(None));
pub struct AppServer;
impl AppServer {
pub async fn connect(stream: TcpStream) -> Result<WsStream, AppError> {
let ws_stream = accept_async(stream).await?;
Ok(WsStream::Server(ws_stream))
}
pub async fn run() {
let addr = "0.0.0.0:4399";
let listener = TcpListener::bind(&addr)
.await
.expect(format!("❌ 绑定地址失败: {}", &addr).as_str());
crate::pylog!("✅ 已启动: {:?}", addr);
while let Ok((stream, addr)) = listener.accept().await {
AppServer::handle_connection(stream, addr).await;
}
}
async fn handle_connection(stream: TcpStream, addr: std::net::SocketAddr) {
let Ok(ws_stream) = AppServer::connect(stream).await else {
crate::pylog!("❌ 连接异常: {}", addr);
return;
};
crate::pylog!("✅ 已连接: {:?}", addr);
{
let mut remote = REMOTE_ADDR.write().unwrap();
*remote = Some(addr.ip().to_string());
}
AppServer::init(ws_stream).await;
if let Err(e) = MessageManager::instance().process_messages().await {
crate::pylog!("❌ 消息处理异常: {}", e);
}
AppServer::dispose().await;
{
let mut remote = REMOTE_ADDR.write().unwrap();
*remote = None;
}
crate::pylog!("❌ 已断开连接");
}
async fn init(ws_stream: WsStream) {
MessageManager::instance().init(ws_stream).await;
MessageHandler::<Event>::instance()
.set_handler(on_event)
.await;
MessageHandler::<Stream>::instance()
.set_handler(on_stream)
.await;
let rpc = RPC::instance();
rpc.add_command("get_version", get_version).await;
}
async fn dispose() {
MessageManager::instance().dispose().await;
}
}
async fn get_version(_: Request) -> Result<Response, AppError> {
let data = json!(VERSION.to_string());
Ok(Response::from_data(data))
}
async fn on_stream(stream: Stream) -> Result<(), AppError> {
let Stream { tag, bytes, .. } = stream;
match tag.as_str() {
"record" => {
let data = Python::with_gil(|py| PyBytes::new(py, &bytes).into());
PythonManager::instance().call_fn("on_input_data", Some(data))?;
}
_ => {}
}
Ok(())
}
async fn on_event(event: Event) -> Result<(), AppError> {
let mut event_json_value = serde_json::to_value(&event)?;
if let Some(event_obj) = event_json_value.as_object_mut() {
let remote = REMOTE_ADDR.read().unwrap().clone();
if let Some(ip) = remote {
event_obj.insert("remote_addr".to_string(), serde_json::Value::String(ip));
}
}
let event_json = serde_json::to_string(&event_json_value)?;
let data = Python::with_gil(|py| PyString::new(py, &event_json).into());
PythonManager::instance().call_fn("on_event", Some(data))?;
Ok(())
}
+1
View File
@@ -0,0 +1 @@
+5
View File
@@ -0,0 +1,5 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -0,0 +1,53 @@
import asyncio
import pytest
from hass_assistant import ConversationSession
@pytest.mark.asyncio
async def test_session_idle_timeout_triggers_end():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=1,
end_keywords=["再见", "拜拜", "", "退下"],
)
triggered = {"value": False}
async def on_timeout():
triggered["value"] = True
session.restart_idle_timer(on_timeout)
await asyncio.sleep(1.2)
assert triggered["value"] is True
await session.close()
def test_session_end_keyword_contains_match():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=15,
end_keywords=["再见", "拜拜", "", "退下"],
)
assert session.match_end_keyword("那就先这样吧,再见哈") == "再见"
assert session.match_end_keyword("你先退下吧") == "退下"
assert session.match_end_keyword("继续聊聊") is None
def test_session_area_mapping_hit_and_miss():
session = ConversationSession(
enabled=True,
idle_timeout_seconds=15,
end_keywords=["再见"],
)
mapping = {"192.168.1.101": "客厅"}
assert session.resolve_area("192.168.1.101", mapping) == "客厅"
assert session.resolve_area("192.168.1.102", mapping) is None
assert session.resolve_area(None, mapping) is None
prefix = session.build_area_prefix("客厅")
assert "当前用户对话所在区域:客厅" in prefix
+123
View File
@@ -0,0 +1,123 @@
import json
import pytest
from aioresponses import aioresponses
from hass.errors import HomeAssistantAuthError
from hass.ha_client import HomeAssistantClient
from hass.memory import ConversationMemory
from hass.typing import HomeAssistantConfig
@pytest.mark.asyncio
async def test_process_text_updates_conversation_id(tmp_path):
state_path = tmp_path / "state.json"
memory = ConversationMemory(str(state_path))
await memory.load()
config = HomeAssistantConfig(
url="http://ha.local:8123",
access_token="token",
assistant_entity_id="home_assistant",
language="zh-cn",
timeout_seconds=2.5,
)
client = HomeAssistantClient(config, memory, concurrency=2)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(
url,
payload={
"continue_conversation": True,
"conversation_id": "cid-1",
"response": {
"response_type": "action_done",
"speech": {"plain": {"speech": "好的"}},
},
},
status=200,
)
reply = await client.process_text("打开客厅灯")
assert reply.speech == "好的"
assert reply.continue_conversation is True
assert memory.conversation_id == "cid-1"
await client.close()
@pytest.mark.asyncio
async def test_process_text_unauthorized(tmp_path):
memory = ConversationMemory(str(tmp_path / "state.json"))
await memory.load()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="bad-token")
client = HomeAssistantClient(config, memory)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(url, payload={"message": "unauthorized"}, status=401)
with pytest.raises(HomeAssistantAuthError):
await client.process_text("hi")
await client.close()
@pytest.mark.asyncio
async def test_process_text_with_prefix_prompt(tmp_path):
memory = ConversationMemory(str(tmp_path / "state.json"))
await memory.load()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="token")
client = HomeAssistantClient(config, memory)
await client.start()
url = "http://ha.local:8123/api/conversation/process"
with aioresponses() as mocked:
mocked.post(
url,
payload={
"continue_conversation": False,
"conversation_id": "cid-2",
"response": {
"response_type": "query_answer",
"speech": {"plain": {"speech": "在客厅"}},
},
},
status=200,
)
await client.process_text(
"灯开了吗",
prefix_prompt="当前用户对话所在区域:客厅,如后续对话未明确指定区域,则默认为此区域",
)
request = mocked.requests[("POST", url)][0]
payload = json.loads(request.kwargs["data"].decode("utf-8"))
assert payload["text"].startswith("当前用户对话所在区域:客厅")
assert "用户请求:灯开了吗" in payload["text"]
await client.close()
@pytest.mark.asyncio
async def test_reset_conversation(tmp_path):
state_path = tmp_path / "state.json"
memory = ConversationMemory(str(state_path))
await memory.load()
memory.conversation_id = "cid-old"
await memory.save()
config = HomeAssistantConfig(url="http://ha.local:8123", access_token="token")
client = HomeAssistantClient(config, memory)
await client.start()
await client.reset_conversation()
assert memory.conversation_id is None
saved = json.loads(state_path.read_text(encoding="utf-8"))
assert saved["conversation_id"] is None
await client.close()
+21
View File
@@ -0,0 +1,21 @@
import json
import pytest
from hass.memory import ConversationMemory
@pytest.mark.asyncio
async def test_memory_persist_roundtrip(tmp_path):
path = tmp_path / "state.json"
m = ConversationMemory(str(path))
m.conversation_id = "abc"
await m.save()
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["conversation_id"] == "abc"
m2 = ConversationMemory(str(path))
await m2.load()
assert m2.conversation_id == "abc"
+38
View File
@@ -0,0 +1,38 @@
from hass.typing import AppConfig
def test_xiaoai_config_defaults():
cfg = AppConfig.model_validate(
{
"homeassistant": {
"url": "http://ha.local:8123",
"access_token": "token",
}
}
)
assert cfg.xiaoai.continuous_conversation_enabled is True
assert cfg.xiaoai.session_idle_timeout_seconds == 15
assert cfg.xiaoai.session_end_keywords == ["再见", "拜拜", "", "退下"]
assert cfg.xiaoai.client_ip_area_mapping == {}
assert cfg.xiaoai.interrupt_on_new_input is True
def test_xiaoai_config_area_mapping_loaded():
cfg = AppConfig.model_validate(
{
"homeassistant": {
"url": "http://ha.local:8123",
"access_token": "token",
},
"xiaoai": {
"client_ip_area_mapping": {
"192.168.1.10": "客厅",
"192.168.1.11": "书房",
}
},
}
)
assert cfg.xiaoai.client_ip_area_mapping["192.168.1.10"] == "客厅"
assert cfg.xiaoai.client_ip_area_mapping["192.168.1.11"] == "书房"
+41
View File
@@ -0,0 +1,41 @@
import math
import struct
import pytest
from hass.vad import VadConfig, VadSegmenter
def _sine_frame(sample_rate: int, freq_hz: float, frame_ms: int = 20) -> bytes:
samples = int(sample_rate * frame_ms / 1000)
values = [
int(10000 * math.sin(2 * math.pi * freq_hz * i / sample_rate))
for i in range(samples)
]
return struct.pack("<" + "h" * samples, *values)
def _silence_frame(sample_rate: int, frame_ms: int = 20) -> bytes:
samples = int(sample_rate * frame_ms / 1000)
return b"\x00\x00" * samples
@pytest.mark.parametrize("min_silence_ms", [40, 60])
def test_vad_emits_utterance(min_silence_ms: int):
config = VadConfig(
aggressiveness=1,
min_silence_ms=min_silence_ms,
max_utterance_ms=2000,
sample_rate=16000,
)
seg = VadSegmenter(config)
utterances = []
for _ in range(6):
utterances.extend(seg.feed(_sine_frame(16000, 440.0)))
for _ in range(6):
utterances.extend(seg.feed(_silence_frame(16000)))
assert len(utterances) >= 1
assert len(utterances[0]) > 0
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
import pytest
from hass.typing import XiaoAiConfig
from hass.xiaoai_bridge import XiaoAiBridge
class FakeServer:
def __init__(self):
self._fns = {}
self.shell_calls = []
def register_fn(self, name, fn):
self._fns[name] = fn
async def run_shell(self, script, timeout):
self.shell_calls.append((script, timeout))
return '{"stdout":"ok","stderr":"","exit_code":0}'
async def start_server(self):
return None
@pytest.mark.asyncio
async def test_bridge_interrupts_before_dispatch(monkeypatch):
fake_server = FakeServer()
import hass.xiaoai_bridge as module
monkeypatch.setattr(module, "_LOGGER", module._LOGGER)
cfg = XiaoAiConfig(interrupt_on_new_input=True, shell_timeout_ms=123)
bridge = XiaoAiBridge(cfg)
bridge._open_xiaoai_server = fake_server
bridge._loop = asyncio.get_running_loop()
called = []
async def on_text(input_data):
called.append(input_data)
bridge.set_on_text(on_text)
bridge._ready.set()
bridge._register_callbacks()
event = {
"event": "instruction",
"data": {
"ClientIP": "192.168.1.101",
"NewLine": json.dumps(
{
"header": {"namespace": "SpeechRecognizer", "name": "RecognizeResult"},
"payload": {
"is_final": True,
"results": [{"text": "打开客厅灯"}],
},
},
ensure_ascii=False,
),
},
}
fake_server._fns["on_event"](json.dumps(event, ensure_ascii=False))
await asyncio.sleep(0.05)
assert len(fake_server.shell_calls) == 1
assert "ubus call mibrain ai_service" in fake_server.shell_calls[0][0]
assert len(called) == 1
assert called[0].text == "打开客厅灯"
assert called[0].client_ip == "192.168.1.101"
@pytest.mark.asyncio
async def test_bridge_extracts_missing_ip_as_none():
fake_server = FakeServer()
cfg = XiaoAiConfig(interrupt_on_new_input=False)
bridge = XiaoAiBridge(cfg)
bridge._open_xiaoai_server = fake_server
bridge._loop = asyncio.get_running_loop()
captured = []
async def on_text(input_data):
captured.append(input_data)
bridge.set_on_text(on_text)
bridge._ready.set()
bridge._register_callbacks()
event = {
"event": "instruction",
"data": {
"NewLine": json.dumps(
{
"header": {"namespace": "SpeechRecognizer", "name": "RecognizeResult"},
"payload": {
"is_final": True,
"results": [{"text": "查询天气"}],
},
},
ensure_ascii=False,
),
},
}
fake_server._fns["on_event"](json.dumps(event, ensure_ascii=False))
await asyncio.sleep(0.05)
assert len(captured) == 1
assert captured[0].client_ip is None
assert fake_server.shell_calls == []
+7 -3
View File
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
EOF EOF
set -e # 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
echo "🤫 等待网络连接中..." sleep 3
sleep 5 echo "✅ 网络连接成功"
MIN_SPACE_MB=32 MIN_SPACE_MB=32
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws" DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
+2
View File
@@ -0,0 +1,2 @@
*.mp3
*.wav
+547
View File
@@ -0,0 +1,547 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "audiopus_sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
dependencies = [
"cmake",
"log",
"pkg-config",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "opus"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e"
dependencies = [
"audiopus_sys",
"libc",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stereo"
version = "0.1.0"
dependencies = [
"alsa",
"anyhow",
"opus",
"postcard",
"serde",
"tokio",
]
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "stereo"
version = "0.1.0"
edition = "2024"
[profile.release]
lto = true
opt-level = "s"
codegen-units = 1
panic = "abort"
strip = true
debug = false
[dependencies]
opus = "0.3"
anyhow = "1.0"
tokio = { version = "1.48", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.0", features = ["alloc", "use-std"] }
[target.'cfg(target_os = "linux")'.dependencies]
alsa = "0.11"
+10
View File
@@ -0,0 +1,10 @@
build:
docker run --rm -v $(shell pwd):/app idootop/open-xiaoai-runtime:oh2p \
cargo build --target armv7-unknown-linux-gnueabihf --release
# 部署到小爱音箱(调试自用)
deploy:
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.153 "dd of=/data/stereo"
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.235 "dd of=/data/stereo"
+59
View File
@@ -0,0 +1,59 @@
# Open-XiaoAI Stereo
不同型号的小爱音箱组立体声。
## 功能
- 在小爱音箱上独立运行,无需服务端部署
- 支持不同型号的小爱音箱局域网组立体声
- 支持蓝牙播放和小爱音箱自带的音乐播放
- 支持局域网内主从设备服务发现自动连接
- 支持主从节点“热拔插”,自动切换声道
- 支持前向纠错(FEC)和丢包补偿(PLC)
## 快速开始
> [!NOTE]
> 以下操作需要先将小爱音箱刷机, 然后 SSH 连接到小爱音箱。👉 [教程](../../docs/flash.md)
首先,分别在两台设备上配置主从和声道。
```shell
# 首先确保配置文件路径存在
mkdir -p /data/open-xiaoai || true
# 如果你想让左边的小爱音箱作为主音源
echo master left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
echo slave right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
# 如果你想让右边的小爱音箱作为主音源
# echo master right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
# echo slave left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
```
配置完成后,分别在两台设备上运行下面的启动脚本即可。
```shell
curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/init.sh | sh
```
如果你想要开机自启动,运行以下命令重启小爱音箱即可(需要先按上面的步骤配置主从设备和声道)。
```shell
# 下载 boot.sh 到 /data/init.sh 开机时自启动
curl -L -o /data/init.sh https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/boot.sh
# 重启小爱音箱
reboot
```
## 注意事项
- 不支持同步音量大小(不同型号设备的音量基准不同,请手动调整)
- 不支持多路音频混音(同时播放多个音频听起来像是信号故障、静音卡顿)
- 目前只支持小爱音箱 Pro(LX06)和小米智能音箱 Pro(OH2P)这两个机型(需要刷机)
- 从设备会主动同步主设备的音源,反之不支持(建议禁用从设备的麦克风和操作,防止音频叠加)
## License
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
exec > /dev/null 2>&1
WORK_DIR="/data/open-xiaoai"
APP_BINARY="$WORK_DIR/stereo"
CONFIG_FILE="$WORK_DIR/stereo.conf"
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
▌ - Stereo(立体声)
v1.0.0 by: https://del.wang
EOF
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
sleep 3
echo "✅ 网络连接成功"
main() {
# 1. 确保目录存在
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
# 2. 检查并下载程序
if [ ! -f "$APP_BINARY" ]; then
echo "🚀 正在下载 Stereo 立体声程序..."
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
echo "❌ 下载失败,请检查网络连接。"
exit 1
fi
chmod +x "$APP_BINARY"
echo "✅ 程序下载完毕"
fi
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
if [ $# -gt 0 ]; then
echo "$*" > "$CONFIG_FILE"
fi
# 4. 读取配置
local ARGS=""
if [ -f "$CONFIG_FILE" ]; then
ARGS=$(cat "$CONFIG_FILE")
fi
# 5. 停止旧进程
PID=$(pgrep -f "$APP_BINARY" || true)
if [ -n "$PID" ]; then
kill -9 $PID > /dev/null 2>&1 || true
fi
# 6. 启动程序
echo "🔥 Stereo 立体声程序启动中..."
"$APP_BINARY" $ARGS
}
main "$@"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
WORK_DIR="/data/open-xiaoai"
APP_BINARY="$WORK_DIR/stereo"
CONFIG_FILE="$WORK_DIR/stereo.conf"
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
▌ - Stereo(立体声)
v1.0.0 by: https://del.wang
EOF
main() {
# 1. 确保目录存在
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
# 2. 检查并下载程序
if [ ! -f "$APP_BINARY" ]; then
echo "🚀 正在下载 Stereo 立体声程序..."
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
echo "❌ 下载失败,请检查网络连接。"
exit 1
fi
chmod +x "$APP_BINARY"
echo "✅ 程序下载完毕"
fi
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
if [ $# -gt 0 ]; then
echo "$*" > "$CONFIG_FILE"
fi
# 4. 读取配置
local ARGS=""
if [ -f "$CONFIG_FILE" ]; then
ARGS=$(cat "$CONFIG_FILE")
fi
# 5. 停止旧进程
PID=$(pgrep -f "$APP_BINARY" || true)
if [ -n "$PID" ]; then
kill -9 $PID > /dev/null 2>&1 || true
fi
# 6. 启动程序
echo "🔥 Stereo 立体声程序启动中..."
exec "$APP_BINARY" $ARGS
}
main "$@"
+35
View File
@@ -0,0 +1,35 @@
#![cfg(target_os = "linux")]
use crate::app::master::run_master;
use crate::app::slave::run_slave;
use crate::net::protocol::ChannelRole;
use anyhow::Result;
use std::env;
pub async fn run_stereo() -> Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("用法: {} [master|slave] [left|right]", args[0]);
return Ok(());
}
let mode = if args[1].to_lowercase() == "master" {
"主节点"
} else {
"从节点"
};
let role = if args[2].to_lowercase() == "left" {
ChannelRole::Left
} else {
ChannelRole::Right
};
println!("🚗 当前为: {} {}", mode, role.to_string());
if mode == "主节点" {
run_master(role).await
} else {
run_slave(role).await
}
}
+365
View File
@@ -0,0 +1,365 @@
#![cfg(target_os = "linux")]
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::player::AudioPlayer;
use crate::net::discovery::Discovery;
use crate::net::network::{ControlConnection, MasterNetwork};
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
use crate::utils::alsa::AlsaRedirector;
use crate::utils::sync::now_us;
use anyhow::{Result, anyhow};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::signal::unix::{SignalKind, signal};
use tokio::sync::Mutex;
pub const SERVER_TCP_PORT: u16 = 53531;
#[derive(Clone)]
struct SlaveSession {
udp_addr: SocketAddr,
role: ChannelRole,
}
pub async fn run_master(master_role: ChannelRole) -> Result<()> {
// 0. 设置 ALSA 重定向
println!("🔥 启动中,请稍等...");
let _alsa_guard = AlsaRedirector::new()?;
// 1. 设置网络 (UDP + TCP)
let network = MasterNetwork::setup(SERVER_TCP_PORT).await?;
let audio_socket = network.audio_socket().clone_inner();
// 2. 启动服务发现广播
Discovery::start_broadcast(SERVER_TCP_PORT).await?;
println!("✅ 服务已启动,等待连接...");
let shutdown_flag = Arc::new(AtomicBool::new(false));
let slaves = Arc::new(Mutex::new(Vec::<SlaveSession>::new()));
// 3. 启动连接监听任务
let slaves_clone = slaves.clone();
let audio_socket_clone = audio_socket.clone();
tokio::spawn(async move {
loop {
match network.accept().await {
Ok((control_conn, client_addr)) => {
let slaves_for_session = slaves_clone.clone();
let audio_socket_for_session = audio_socket_clone.clone();
tokio::spawn(async move {
if let Err(e) = handle_master_session(
control_conn,
audio_socket_for_session,
slaves_for_session,
client_addr.to_string(),
)
.await
{
eprintln!("❌ 会话错误: {:?}", e);
}
});
}
Err(e) => {
eprintln!("❌ Accept 错误: {:?}", e);
}
}
}
});
// 4. 音频处理主循环
let config = AudioConfig::music();
let encode_config = AudioConfig {
channels: 1,
vbr: true,
..AudioConfig::music()
};
let player = AudioPlayer::new(&AudioConfig {
channels: 2,
playback_device: "plug:original_default".into(),
..config.clone()
})?;
let mut raw_buf = vec![0u8; config.frame_size * 2 * 2];
let mut pcm_out = vec![0i16; config.frame_size * 2];
let mut left_pcm = vec![0i16; config.frame_size];
let mut right_pcm = vec![0i16; config.frame_size];
let mut opus_out = vec![0u8; 1500];
let mut seq = 0u32;
// 播放延迟: 只需覆盖网络延迟 + 时钟偏移
let delay_us = 100_000; // 100ms 基础延迟
let frame_duration_us =
(config.frame_size as f64 / config.sample_rate as f64 * 1_000_000.0) as u128;
let mut stream_start_ts = 0;
let mut stream_start_seq = 0;
let shutdown_flag_clone = shutdown_flag.clone();
let audio_loop = async move {
loop {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
// 打开 FIFO
let mut fifo = match tokio::fs::File::open(AlsaRedirector::fifo_path()).await {
Ok(f) => f,
Err(_) => {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
// 每个新流开始时,重置编码器状态以避免残留音频导致爆音
let mut left_encoder = OpusCodec::new(&encode_config)?;
let mut right_encoder = OpusCodec::new(&encode_config)?;
loop {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
// 从 FIFO 读取
if let Err(_) = fifo.read_exact(&mut raw_buf).await {
break; // FIFO 关闭,重新打开
}
let active_slaves = {
let s = slaves.lock().await;
if s.is_empty() { None } else { Some(s.clone()) }
};
let now = now_us();
if stream_start_ts == 0 {
stream_start_ts = now;
stream_start_seq = seq;
}
// 计算该帧应当播放的目标时间
// target_ts = 数据包发送时间 + 播放延迟
let target_ts = stream_start_ts
+ ((seq - stream_start_seq) as u128 * frame_duration_us)
+ delay_us;
// 提取 PCM 数据
for i in 0..config.frame_size {
left_pcm[i] = i16::from_le_bytes([raw_buf[i * 4], raw_buf[i * 4 + 1]]);
right_pcm[i] = i16::from_le_bytes([raw_buf[i * 4 + 2], raw_buf[i * 4 + 3]]);
}
if let Some(slaves_list) = active_slaves {
// 情况 1: 有从节点,进行网络传输,并本地构造静音声道回放
// 1. 检查各声道是否有从节点需要
let needs_left = slaves_list.iter().any(|s| s.role == ChannelRole::Left);
let needs_right = slaves_list.iter().any(|s| s.role == ChannelRole::Right);
// 2. 编码需要的声道
let mut left_bytes = None;
let mut right_bytes = None;
if needs_left {
let len = left_encoder.encode(&left_pcm, &mut opus_out)?;
let packet = AudioPacket {
seq,
timestamp: target_ts,
data: opus_out[..len].to_vec(),
};
left_bytes = Some(postcard::to_allocvec(&packet)?);
}
if needs_right {
let len = right_encoder.encode(&right_pcm, &mut opus_out)?;
let packet = AudioPacket {
seq,
timestamp: target_ts,
data: opus_out[..len].to_vec(),
};
right_bytes = Some(postcard::to_allocvec(&packet)?);
}
// 3. 发送给对应的从节点
for slave in &slaves_list {
let bytes = match slave.role {
ChannelRole::Left => left_bytes.as_ref(),
ChannelRole::Right => right_bytes.as_ref(),
};
if let Some(b) = bytes {
let _ = audio_socket.send_to(b, slave.udp_addr).await;
}
}
// 4. 将非本节点的声道置为静音
for i in 0..config.frame_size {
match master_role {
ChannelRole::Left => {
pcm_out[i * 2] = left_pcm[i];
pcm_out[i * 2 + 1] = 0;
}
ChannelRole::Right => {
pcm_out[i * 2] = 0;
pcm_out[i * 2 + 1] = right_pcm[i];
}
}
}
// 5. 等待播放
let now = now_us();
if now < target_ts {
let wait = target_ts - now;
if wait > 1000 {
tokio::time::sleep(Duration::from_micros(wait as u64)).await;
} else {
// 小于 1ms,直接播放,让播放时机稍微早一点点
}
}
} else {
// 情况 2: 没有从节点,本地立体声播放
for i in 0..config.frame_size {
pcm_out[i * 2] = left_pcm[i];
pcm_out[i * 2 + 1] = right_pcm[i];
}
}
// 统一写入播放器 (始终是立体声)
if let Err(_) = player.write(&pcm_out) {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
}
seq += 1;
}
// 重置流计时
stream_start_ts = 0;
}
Ok::<(), anyhow::Error>(())
};
tokio::select! {
res = audio_loop => {
if let Err(e) = res {
eprintln!("❌ 音频循环错误: {:?}", e);
}
},
_ = shutdown_signal() => {
// 设置退出标志,通知音频循环停止
shutdown_flag.store(true, Ordering::Relaxed);
},
}
// 显式清理
println!("👋 正在退出...");
AlsaRedirector::cleanup();
// 强制退出
std::process::exit(0);
}
/// 监听系统退出信号 (SIGINT, SIGTERM, SIGQUIT)
async fn shutdown_signal() {
let mut sigint = signal(SignalKind::interrupt()).expect("无法注册 SIGINT 处理器");
let mut sigterm = signal(SignalKind::terminate()).expect("无法注册 SIGTERM 处理器");
let mut sigquit = signal(SignalKind::quit()).expect("无法注册 SIGQUIT 处理器");
tokio::select! {
_ = sigint.recv() => {},
_ = sigterm.recv() => {},
_ = sigquit.recv() => {},
}
}
/// 处理主节点与从节点的会话
async fn handle_master_session(
mut control: ControlConnection,
audio_socket: Arc<tokio::net::UdpSocket>,
slaves: Arc<Mutex<Vec<SlaveSession>>>,
client_tcp_addr: String,
) -> Result<()> {
let mut buf = [0u8; 1024];
// 握手
let pkt = control.recv_packet(&mut buf).await?;
let slave_role = match pkt {
ControlPacket::ClientIdentify { role } => role,
_ => return Err(anyhow!("无效的握手协议")),
};
let stereo = ControlPacket::ServerHello {
udp_port: audio_socket.local_addr()?.port(),
};
control.send_packet(&stereo).await?;
// 等待 UDP 打洞/确认
let mut buf = [0u8; 128];
let (_, client_udp_addr) = audio_socket.recv_from(&mut buf).await?;
println!(
"✅ 从节点已连接: {} {}",
client_tcp_addr,
slave_role.to_string(),
);
// 添加到从节点列表
let session = SlaveSession {
udp_addr: client_udp_addr,
role: slave_role,
};
{
let mut s = slaves.lock().await;
s.push(session.clone());
}
// 分离 TCP 读写,处理控制消息和心跳
let (mut tcp_rx, mut tcp_tx) = control.split();
let mut buf = [0u8; 1024];
loop {
match tcp_rx.read(&mut buf).await {
Ok(0) | Err(_) => {
break;
}
Ok(n) => {
if let Ok(ControlPacket::Ping { client_ts, seq }) = postcard::from_bytes(&buf[..n])
{
let pong = ControlPacket::Pong {
client_ts,
server_ts: now_us(),
seq,
};
if tcp_tx
.write_all(&postcard::to_allocvec(&pong).unwrap())
.await
.is_err()
{
break;
}
}
}
}
}
println!(
"❌ 从节点已断开: {} {}",
client_tcp_addr,
slave_role.to_string(),
);
// 从列表中移除
{
let mut s = slaves.lock().await;
s.retain(|x| x.udp_addr != client_udp_addr);
}
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entry;
pub mod master;
pub mod slave;
+199
View File
@@ -0,0 +1,199 @@
#![cfg(target_os = "linux")]
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::player::AudioPlayer;
use crate::net::discovery::Discovery;
use crate::net::network::SlaveNetwork;
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
use crate::utils::sync::{ClockSync, now_us};
use anyhow::{Result, anyhow};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, mpsc};
/// 运行从节点模式
pub async fn run_slave(role: ChannelRole) -> Result<()> {
loop {
match handle_connection(role.clone()).await {
Err(e) => {
eprintln!("{:?}", e);
tokio::time::sleep(Duration::from_secs(3)).await;
}
Ok(_) => {}
}
}
}
async fn handle_connection(role: ChannelRole) -> Result<()> {
// 1. 发现主节点
println!("🔍 正在扫描主节点...");
let (master_ip, master_tcp_port) = Discovery::discover_master().await?;
let master_tcp_addr = format!("{}:{}", master_ip, master_tcp_port);
// 2. 建立 TCP 连接
println!("🔥 发现主节点: {}", master_tcp_addr);
let network = SlaveNetwork::connect(master_tcp_addr.parse()?).await?;
let (mut control, audio) = network.split();
// 3. 身份认证
control
.send_packet(&ControlPacket::ClientIdentify { role: role.clone() })
.await?;
let mut buf = [0u8; 1024];
let pkt = control.recv_packet(&mut buf).await?;
let server_udp_port = match pkt {
ControlPacket::ServerHello { udp_port } => udp_port,
_ => return Err(anyhow!("身份认证应答异常")),
};
// 4. UDP 打洞
audio
.punch(format!("{}:{}", master_ip, server_udp_port).parse()?)
.await?;
// 5. 初始化音频与同步组件
let config = AudioConfig {
channels: 1,
..AudioConfig::music()
};
let player = AudioPlayer::new(&config)?;
let mut codec = OpusCodec::new(&config)?;
let clock = Arc::new(Mutex::new(ClockSync::new(100)));
// 用于通知主循环 TCP 已断开的消息通道
let (disconnect_tx, mut disconnect_rx) = mpsc::channel::<()>(1);
// 6. 分离 TCP 读写
let (mut tcp_rx, mut tcp_tx) = control.split();
let clock_updater = clock.clone();
let d_tx_ping = disconnect_tx.clone();
let d_tx_pong = disconnect_tx.clone();
// 定时发送 Ping (心跳 & 时间同步)
let _sync_handle = tokio::spawn(async move {
let mut seq = 0;
loop {
let t1 = now_us();
let msg = ControlPacket::Ping { client_ts: t1, seq };
let data = postcard::to_allocvec(&msg).unwrap();
if tcp_tx.write_all(&data).await.is_err() {
let _ = d_tx_ping.send(()).await; // 通知主线程 TCP 失败
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
seq += 1;
}
});
// 接收 Pong
tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
match tcp_rx.read(&mut buf).await {
Ok(n) if n > 0 => {
if let Ok(ControlPacket::Pong {
client_ts,
server_ts,
..
}) = postcard::from_bytes(&buf[..n])
{
let t4 = now_us();
clock_updater.lock().await.update(client_ts, server_ts, t4);
}
}
_ => {
let _ = d_tx_pong.send(()).await; // TCP 断开
break;
}
}
}
});
// 7. 接收音频数据包 (UDP)
let (audio_tx, mut audio_rx) = mpsc::channel(100);
let audio_socket = audio.clone_inner();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
loop {
if let Ok((len, _)) = audio_socket.recv_from(&mut buf).await {
if let Ok(packet) = postcard::from_bytes::<AudioPacket>(&buf[..len]) {
if audio_tx.send(packet).await.is_err() {
break;
}
}
}
}
});
// 8. 播放提示
println!("✅ 主节点已连接,音频串流中...");
let role_str = role.to_string();
tokio::spawn(async move {
let _ = tokio::process::Command::new("sh")
.arg("-c")
.arg(format!(
"/usr/sbin/tts_play.sh \"主节点已连接,{}\" >/dev/null 2>&1",
role_str
))
.status()
.await;
});
// 9. 播放主循环
let mut pcm_buf = vec![0i16; config.frame_size];
let mut last_seq: Option<u32> = None;
loop {
// 检查 TCP 是否已断开
if let Ok(_) = disconnect_rx.try_recv() {
return Err(anyhow!("主节点已断开: {}", master_tcp_addr));
}
// 接收数据包
if let Ok(pkt) = audio_rx.try_recv() {
let now = now_us();
let current_server_time = clock.lock().await.to_server_time(now);
// 检查包是否迟到(目标时间已过)
if current_server_time > pkt.timestamp {
let late_ms = (current_server_time - pkt.timestamp) / 1000;
if late_ms > 50 {
// 迟到超过 50ms,直接丢弃
continue;
}
// 轻微迟到(<50ms),尝试播放
}
last_seq = Some(pkt.seq);
// 精确等待到播放时间
loop {
let now = now_us();
let current_server_time_precise = clock.lock().await.to_server_time(now);
if current_server_time_precise >= pkt.timestamp {
break;
}
let wait_us = (pkt.timestamp - current_server_time_precise) as u64;
if wait_us > 1000 {
tokio::time::sleep(Duration::from_micros(wait_us as u64)).await;
} else {
// 小于 1ms,直接播放,让播放时机稍微早一点点
break;
}
}
// 解码并播放
if let Ok(len) = codec.decode(&pkt.data, &mut pcm_buf) {
let _ = player.write(&pcm_buf[..len]);
}
} else {
tokio::time::sleep(Duration::from_micros(100)).await;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use crate::audio::config::{AudioConfig, AudioScene};
use anyhow::{Context, Result};
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
pub struct OpusCodec {
encoder: Encoder,
decoder: Decoder,
}
impl OpusCodec {
pub fn new(config: &AudioConfig) -> Result<Self> {
let channels = match config.channels {
1 => Channels::Mono,
2 => Channels::Stereo,
_ => return Err(anyhow::anyhow!("Invalid channels: {}", config.channels)),
};
let mode = match config.audio_scene {
AudioScene::Music => Application::Audio,
AudioScene::Voice => Application::Voip,
};
let bitrate = match config.bitrate {
-1 => Bitrate::Max,
0 => Bitrate::Auto,
_ => Bitrate::Bits(config.bitrate),
};
let mut encoder = Encoder::new(config.sample_rate, channels, mode)
.context("Failed to create Opus encoder")?;
encoder.set_bitrate(bitrate)?;
if config.vbr {
encoder.set_vbr(true)?;
}
if config.fec {
encoder.set_inband_fec(true)?; // 内联前向纠错
encoder.set_packet_loss_perc(20)?; // 预期丢包率20%
}
let decoder =
Decoder::new(config.sample_rate, channels).context("Failed to create Opus decoder")?;
Ok(Self { encoder, decoder })
}
pub fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize> {
self.encoder
.encode(pcm, out)
.context("Opus encoding failed")
}
pub fn decode(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(opus, out, false)
.context("Opus decoding failed")
}
/// 前向纠错(FEC)
pub fn decode_fec(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(opus, out, true)
.context("Opus FEC decoding failed")
}
/// 丢包补偿(PLC)
pub fn decode_loss(&mut self, out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(&[], out, false)
.context("Opus PLC (decode_loss) failed")
}
}
+61
View File
@@ -0,0 +1,61 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AudioScene {
Music,
Voice,
}
#[derive(Debug, Clone)]
pub struct AudioConfig {
// ALSA 设备参数,用于录音和播放
pub capture_device: String,
pub playback_device: String,
pub sample_rate: u32,
pub channels: u16,
pub frame_size: usize, // 帧大小,单位为采样点
// Opus 编解码参数,用于音频传输
pub audio_scene: AudioScene,
pub bitrate: i32,
pub vbr: bool, // 是否启用 VBR(动态比特率)
pub fec: bool, // 是否启用 FEC(内联前向纠错)
}
impl AudioConfig {
pub fn music() -> Self {
Self {
audio_scene: AudioScene::Music,
sample_rate: 48_000, // 48kHz
channels: 2,
frame_size: 960, // 20ms at 48kHz
bitrate: 320_000, // 320 kbps
..Default::default()
}
}
pub fn voice() -> Self {
Self {
audio_scene: AudioScene::Voice,
sample_rate: 16_000, // 16kHz
channels: 1,
frame_size: 320, // 20ms at 16kHz
bitrate: 32_000, // 32 kbps
..Default::default()
}
}
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
audio_scene: AudioScene::Voice,
capture_device: "plug:Capture".to_string(),
playback_device: "default".to_string(),
sample_rate: 16_000,
channels: 1,
frame_size: 320, // 20ms at 16kHz
bitrate: 32_000,
vbr: false,
fec: false,
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod codec;
pub mod config;
pub mod player;
pub mod recorder;
+71
View File
@@ -0,0 +1,71 @@
#![cfg(target_os = "linux")]
use crate::audio::config::AudioConfig;
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
use anyhow::{Context, Result};
pub struct AudioPlayer {
pcm: PCM,
}
impl AudioPlayer {
pub fn new(config: &AudioConfig) -> Result<Self> {
let pcm = PCM::new(&config.playback_device, Direction::Playback, false)
.context("Failed to open playback PCM device")?;
setup_pcm(&pcm, config.sample_rate, config.channels)?;
Ok(Self { pcm })
}
pub fn write(&self, buffer: &[i16]) -> Result<usize> {
let res = self.pcm.io_i16()?.writei(buffer);
match res {
Ok(written) => Ok(written),
Err(e) => {
// Buffer Underrun,即播放缓冲区的数据被耗尽,导致音频流中断
if e.errno() == 32 {
// 恢复音频流状态
self.pcm.prepare()?;
// 重新获取 IO 对象并尝试写入数据
self.pcm
.io_i16()?
.writei(buffer)
.context("Failed to write to playback device after recovery")
} else {
Err(e).context("Failed to write to playback device")
}
}
}
}
pub fn prepare(&self) -> Result<()> {
self.pcm.prepare().context("Failed to prepare PCM")
}
}
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(channels as u32)?;
// 设置较大的缓冲区以减少由于调度抖动和设备重初始化导致的断音/卡顿
// 使用 100ms 缓冲区,既能防止 underrun,又不会引入过大延迟
let buffer_size = (sample_rate as f64 * 0.1) as u32; // 100ms 缓冲
let period_size = buffer_size / 4; // 25ms 周期
hwp.set_buffer_size_near(buffer_size as alsa::pcm::Frames)?;
hwp.set_period_size_near(period_size as alsa::pcm::Frames, alsa::ValueOr::Nearest)?;
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
let swp = pcm.sw_params_current()?;
// 设置 start_threshold,当缓冲区有 1 个 period 数据时就开始播放
// 这样可以快速启动,同时保持足够的缓冲余量
swp.set_start_threshold(period_size as alsa::pcm::Frames)?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
#![cfg(target_os = "linux")]
use crate::audio::config::AudioConfig;
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
use anyhow::{Context, Result};
pub struct AudioRecorder {
pcm: PCM,
}
impl AudioRecorder {
pub fn new(config: &AudioConfig) -> Result<Self> {
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)
.context("Failed to open capture PCM device")?;
setup_pcm(&pcm, config.sample_rate, config.channels)?;
Ok(Self { pcm })
}
pub fn read(&self, buffer: &mut [i16]) -> Result<usize> {
self.pcm
.io_i16()?
.readi(buffer)
.context("Failed to read from capture device")
}
}
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(channels as u32)?;
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
let swp = pcm.sw_params_current()?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
mod app;
mod audio;
mod net;
mod utils;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(target_os = "linux")]
{
crate::app::entry::run_stereo().await.unwrap();
}
println!("Only support Linux");
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use crate::net::protocol::ControlPacket;
use anyhow::Result;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
pub const DISCOVERY_PORT: u16 = 53530;
/// 服务发现模块,用于主从节点的自动发现
pub struct Discovery;
impl Discovery {
/// 主节点:启动广播,告知从节点自己的 TCP 端口
pub async fn start_broadcast(tcp_port: u16) -> Result<()> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
let msg = postcard::to_allocvec(&ControlPacket::ServerHello { udp_port: tcp_port })?;
tokio::spawn(async move {
loop {
let _ = socket.send_to(&msg, target).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
Ok(())
}
/// 从节点:监听广播,发现主节点的 IP 和 TCP 端口
pub async fn discover_master() -> Result<(IpAddr, u16)> {
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
let mut buf = [0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buf).await?;
if let Ok(ControlPacket::ServerHello { udp_port }) =
postcard::from_bytes::<ControlPacket>(&buf[..len])
{
return Ok((addr.ip(), udp_port));
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod discovery;
pub mod network;
pub mod protocol;
+126
View File
@@ -0,0 +1,126 @@
use crate::net::protocol::{AudioPacket, ControlPacket};
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
/// UDP 音频传输
pub struct AudioSocket {
socket: Arc<UdpSocket>,
}
impl AudioSocket {
pub async fn bind() -> Result<Self> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
Ok(Self {
socket: Arc::new(socket),
})
}
pub fn local_port(&self) -> Result<u16> {
Ok(self.socket.local_addr()?.port())
}
pub async fn send_packet(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.socket.send_to(&bytes, target).await?;
Ok(())
}
pub async fn recv_packet(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
let (len, addr) = self.socket.recv_from(buf).await?;
let packet = postcard::from_bytes(&buf[..len])?;
Ok((packet, addr))
}
pub async fn punch(&self, target: SocketAddr) -> Result<()> {
self.socket.send_to(&[0u8; 1], target).await?;
Ok(())
}
pub fn clone_inner(&self) -> Arc<UdpSocket> {
self.socket.clone()
}
}
/// TCP 控制连接
pub struct ControlConnection {
stream: TcpStream,
}
impl ControlConnection {
pub fn new(stream: TcpStream) -> Self {
Self { stream }
}
pub async fn send_packet(&mut self, packet: &ControlPacket) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.stream.write_all(&bytes).await?;
Ok(())
}
pub async fn recv_packet(&mut self, buf: &mut [u8]) -> Result<ControlPacket> {
let len = self.stream.read(buf).await?;
if len == 0 {
return Err(anyhow::anyhow!("连接已关闭"));
}
let packet = postcard::from_bytes(&buf[..len])?;
Ok(packet)
}
pub fn split(
self,
) -> (
tokio::net::tcp::OwnedReadHalf,
tokio::net::tcp::OwnedWriteHalf,
) {
self.stream.into_split()
}
}
/// 主节点网络管理器
pub struct MasterNetwork {
listener: TcpListener,
audio: AudioSocket,
}
impl MasterNetwork {
pub async fn setup(port: u16) -> Result<Self> {
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
let audio = AudioSocket::bind().await?;
Ok(Self { listener, audio })
}
pub async fn accept(&self) -> Result<(ControlConnection, SocketAddr)> {
let (stream, addr) = self.listener.accept().await?;
Ok((ControlConnection::new(stream), addr))
}
pub fn audio_socket(&self) -> &AudioSocket {
&self.audio
}
}
/// 从节点网络管理器
pub struct SlaveNetwork {
control: ControlConnection,
audio: AudioSocket,
}
impl SlaveNetwork {
pub async fn connect(master_addr: SocketAddr) -> Result<Self> {
let stream = TcpStream::connect(master_addr)
.await
.context(format!("无法连接到主节点 TCP 地址: {}", master_addr))?;
let audio = AudioSocket::bind().await?;
Ok(Self {
control: ControlConnection::new(stream),
audio,
})
}
pub fn split(self) -> (ControlConnection, AudioSocket) {
(self.control, self.audio)
}
}
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)]
pub enum ChannelRole {
Left,
Right,
}
impl ChannelRole {
pub fn to_string(&self) -> String {
match self {
ChannelRole::Left => "左声道".to_string(),
ChannelRole::Right => "右声道".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ControlPacket {
// 发现协议
ServerHello {
udp_port: u16, // UDP 音频流端口
},
// 握手协议
ClientIdentify {
role: ChannelRole,
},
// 时间同步 (持续进行)
Ping {
client_ts: u128,
seq: u32,
},
Pong {
client_ts: u128,
server_ts: u128,
seq: u32,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AudioPacket {
pub seq: u32, // 序列号,用于丢包检测
pub timestamp: u128, // 目标播放时间 (主节点时间)
pub data: Vec<u8>, // Opus 编码数据
}
+85
View File
@@ -0,0 +1,85 @@
#![cfg(target_os = "linux")]
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
const FIFO_PATH: &str = "/tmp/stereo_out.fifo";
const REAL_ASOUND_CONF: &str = "/etc/asound.conf";
const TEMP_ASOUND_CONF: &str = "/tmp/asound.stereo.conf";
/// ALSA 音频重定向器,用于拦截系统音频输出到 FIFO 管道
pub struct AlsaRedirector;
impl AlsaRedirector {
pub fn new() -> Result<Self> {
Self::cleanup(); // 确保环境干净
let original_conf = fs::read_to_string(REAL_ASOUND_CONF).unwrap_or_default();
if !original_conf.contains("pcm.original_default") {
// 重命名原有的 default 逻辑,插入拦截器
let mut new_conf = original_conf.replace("pcm.!default", "pcm.original_default");
new_conf.push_str(&format!(
"\npcm.!default {{ type plug slave {{ pcm \"stereo_interceptor\" format S16_LE rate 48000 channels 2 }} }}\n\
pcm.stereo_interceptor {{ type file slave.pcm \"null\" file \"{}\" format \"raw\" }}\n",
FIFO_PATH
));
fs::write(TEMP_ASOUND_CONF, new_conf)?;
// 挂载覆盖 /etc/asound.conf
let status = Command::new("mount")
.arg("--bind")
.arg(TEMP_ASOUND_CONF)
.arg(REAL_ASOUND_CONF)
.status()
.context("执行 mount 命令失败")?;
if !status.success() {
return Err(anyhow::anyhow!("挂载 asound.conf 失败"));
}
Self::restart_applications();
}
// 创建 FIFO 管道
let _ = Command::new("mkfifo").arg(FIFO_PATH).status();
let _ = Command::new("chmod").arg("666").arg(FIFO_PATH).status();
Ok(Self)
}
pub fn cleanup() {
let _ = Command::new("sh")
.arg("-c")
.arg(format!("umount -l {} >/dev/null 2>&1", REAL_ASOUND_CONF))
.status();
let _ = fs::remove_file(TEMP_ASOUND_CONF);
let _ = fs::remove_file(FIFO_PATH);
Self::restart_applications();
}
pub fn fifo_path() -> &'static str {
FIFO_PATH
}
pub fn restart_applications() {
// 重启媒体播放器
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/mediaplayer restart >/dev/null 2>&1")
.status();
// 重启蓝牙
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/bluetooth restart >/dev/null 2>&1")
.status();
}
}
impl Drop for AlsaRedirector {
fn drop(&mut self) {
Self::cleanup();
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod alsa;
pub mod sync;
+224
View File
@@ -0,0 +1,224 @@
use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
/// 获取当前微秒级时间戳
pub fn now_us() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("时间倒流")
.as_micros()
}
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
/// 采用改进的 NTP 算法 + Kalman 滤波思想
pub struct ClockSync {
/// 偏移量样本窗口
offsets: VecDeque<OffsetSample>,
/// 当前估计的时钟偏移 (server_time - client_time)
pub current_offset: i128,
/// RTT 样本窗口
rtts: VecDeque<i128>,
/// 当前估计的最小 RTT
min_rtt: i128,
/// 窗口大小
window_size: usize,
/// 时钟漂移率 (ppm: parts per million)
/// 正值表示从节点时钟比主节点快
drift_rate: f64,
/// 上次更新时间
last_update_time: u128,
/// 漂移率估计窗口
drift_samples: VecDeque<DriftSample>,
}
#[derive(Clone, Copy)]
struct OffsetSample {
offset: i128,
rtt: i128,
timestamp: u128,
}
#[derive(Clone, Copy)]
struct DriftSample {
offset: i128,
timestamp: u128,
}
impl ClockSync {
pub fn new(window_size: usize) -> Self {
Self {
offsets: VecDeque::with_capacity(window_size),
current_offset: 0,
rtts: VecDeque::with_capacity(window_size),
min_rtt: i128::MAX,
window_size,
drift_rate: 0.0,
last_update_time: now_us(),
drift_samples: VecDeque::with_capacity(60), // 保留 60 秒的样本
}
}
/// 更新时钟偏移估计 (NTP 算法)
///
/// NTP 时间戳标记:
/// t1 = client_send_ts : 客户端发送 Ping 的时间
/// t2 = server_ts : 服务器接收 Ping 的时间
/// t3 = server_ts : 服务器发送 Pong 的时间 (假设处理时间忽略不计)
/// t4 = client_recv_ts : 客户端接收 Pong 的时间
///
/// RTT = (t4 - t1) - (t3 - t2) = (t4 - t1) (因为 t3 = t2)
/// Offset = ((t2 - t1) + (t3 - t4)) / 2 = ((t2 - t1) + (t2 - t4)) / 2
/// = t2 - (t1 + t4) / 2
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
let t1 = client_send_ts as i128;
let t2 = server_ts as i128;
let t4 = client_recv_ts as i128;
let rtt = t4 - t1;
// 过滤异常 RTT (局域网内 > 100ms 视为异常)
if rtt < 0 || rtt > 100_000 {
return;
}
// 计算时钟偏移: offset = server_time - client_time
// offset = t2 - (t1 + t4) / 2
let offset = t2 - (t1 + t4) / 2;
// 更新 RTT 窗口
self.rtts.push_back(rtt);
if self.rtts.len() > self.window_size {
self.rtts.pop_front();
}
self.min_rtt = *self.rtts.iter().min().unwrap_or(&rtt);
// 更新偏移量窗口
let sample = OffsetSample {
offset,
rtt,
timestamp: client_recv_ts,
};
self.offsets.push_back(sample);
if self.offsets.len() > self.window_size {
self.offsets.pop_front();
}
// 偏移量估计: 使用低 RTT 样本的中位数
// 原理: RTT 较小的样本受网络抖动影响小,时间测量更准确
let mut low_rtt_offsets: Vec<i128> = self
.offsets
.iter()
.filter(|s| s.rtt <= self.min_rtt + 5000) // 5ms 容差
.map(|s| s.offset)
.collect();
if !low_rtt_offsets.is_empty() {
low_rtt_offsets.sort_unstable();
let new_offset = low_rtt_offsets[low_rtt_offsets.len() / 2];
// 漂移率估计
self.estimate_drift(new_offset, client_recv_ts);
// 平滑更新偏移量 (避免突变)
let alpha = 0.3; // 低通滤波系数
self.current_offset =
(alpha * new_offset as f64 + (1.0 - alpha) * self.current_offset as f64) as i128;
}
self.last_update_time = client_recv_ts;
}
/// 估计时钟漂移率
/// 时钟漂移率 = d(offset) / dt
fn estimate_drift(&mut self, offset: i128, timestamp: u128) {
self.drift_samples.push_back(DriftSample { offset, timestamp });
if self.drift_samples.len() > 60 {
self.drift_samples.pop_front();
}
// 至少需要 10 秒的数据才能估计漂移
if self.drift_samples.len() < 10 {
return;
}
// 使用线性回归估计漂移率
let first = self.drift_samples.front().unwrap();
let last = self.drift_samples.back().unwrap();
let dt = (last.timestamp - first.timestamp) as f64;
let d_offset = (last.offset - first.offset) as f64;
if dt > 10_000_000.0 {
// 超过 10 秒
// drift_rate 单位: 微秒/秒 = ppm
let new_drift = d_offset / (dt / 1_000_000.0);
// 平滑更新漂移率
let beta = 0.1;
self.drift_rate = beta * new_drift + (1.0 - beta) * self.drift_rate;
}
}
/// 将本地时间转换为服务器(主节点)时间
/// 考虑时钟漂移补偿
pub fn to_server_time(&self, client_time: u128) -> u128 {
let base_server_time = (client_time as i128 + self.current_offset) as u128;
// 漂移补偿: 根据距离上次同步的时间,补偿时钟漂移
let elapsed_since_update = client_time.saturating_sub(self.last_update_time) as f64;
let drift_correction = (self.drift_rate * elapsed_since_update / 1_000_000.0) as i128;
(base_server_time as i128 + drift_correction) as u128
}
/// 将服务器(主节点)时间转换为本地时间
pub fn to_client_time(&self, server_time: u128) -> u128 {
// 简化版本,不考虑漂移补偿 (播放时主要用 to_server_time)
(server_time as i128 - self.current_offset) as u128
}
/// 获取当前估计的 RTT (微秒)
pub fn get_rtt(&self) -> i128 {
self.min_rtt
}
/// 获取当前时钟漂移率 (ppm)
pub fn get_drift_rate(&self) -> f64 {
self.drift_rate
}
/// 获取同步质量评估 (0-100, 越高越好)
pub fn get_sync_quality(&self) -> u8 {
if self.offsets.is_empty() {
return 0;
}
// 基于 RTT 稳定性和偏移量方差评估
let rtt_variance = self.calculate_variance(&self.rtts.iter().copied().collect::<Vec<_>>());
let offset_variance = self.calculate_variance(
&self.offsets.iter().map(|s| s.offset).collect::<Vec<_>>(),
);
// RTT 越稳定,方差越小,质量越高
let rtt_score = ((100_000.0 - rtt_variance.min(100_000.0)) / 100_000.0 * 50.0) as u8;
let offset_score = ((50_000.0 - offset_variance.min(50_000.0)) / 50_000.0 * 50.0) as u8;
rtt_score + offset_score
}
fn calculate_variance(&self, samples: &[i128]) -> f64 {
if samples.is_empty() {
return 0.0;
}
let mean = samples.iter().sum::<i128>() as f64 / samples.len() as f64;
let variance = samples
.iter()
.map(|&x| {
let diff = x as f64 - mean;
diff * diff
})
.sum::<f64>()
/ samples.len() as f64;
variance.sqrt()
}
}
+3 -5
View File
@@ -136,15 +136,13 @@ APP_CONFIG = {
### Q:唤醒词一直没有反应? ### Q:唤醒词一直没有反应?
由于小爱音箱远场拾音音量较小,有时可能会识别不清,你可以调大 `config.py` 配置文件里的 `boost` 参数,然后重启应用 / Docker 试试看。 如果唤醒词还是不敏感,可以先调低 `vad.threshold`,然后重启应用 / Docker 试试看。
```py ```py
APP_CONFIG = { APP_CONFIG = {
"vad": { "vad": {
# 小爱音箱录音音量较小,需要后期放大一下 # 语音检测阈值(0-1,越小越灵敏)
"boost": 100, "threshold": 0.05,
# boost 调大后,语音检测阈值可能也需要一起调大些
"threshold": 0.50,
}, },
# ... 其他配置 # ... 其他配置
} }
-2
View File
@@ -53,8 +53,6 @@ APP_CONFIG = {
"after_wakeup": after_wakeup, "after_wakeup": after_wakeup,
}, },
"vad": { "vad": {
# 录音音量增强倍数(小爱音箱录音音量较小,需要后期放大一下)
"boost": 10,
# 语音检测阈值(0-1,越小越灵敏) # 语音检测阈值(0-1,越小越灵敏)
"threshold": 0.10, "threshold": 0.10,
# 最小语音时长(ms # 最小语音时长(ms
+46
View File
@@ -0,0 +1,46 @@
#! /bin/sh
exec > /dev/null 2>&1
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
v1.0.0 by: https://del.wang
EOF
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
sleep 3
echo "✅ 网络连接成功"
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/client-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/boot.sh"
fi
if [ ! -f "$WORK_DIR/kws-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/kws-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/boot.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws-boot.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client-boot.sh"
+25
View File
@@ -0,0 +1,25 @@
#! /bin/sh
set -e
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client.sh" ]; then
curl -L -# -o "$WORK_DIR/client.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/init.sh"
fi
if [ ! -f "$WORK_DIR/kws.sh" ]; then
curl -L -# -o "$WORK_DIR/kws.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/init.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client.sh"
@@ -9,10 +9,10 @@ class _SherpaOnnx:
self.keyword_spotter = sherpa_onnx.KeywordSpotter( self.keyword_spotter = sherpa_onnx.KeywordSpotter(
provider="cpu", provider="cpu",
num_threads=1, num_threads=1,
max_active_paths=4, max_active_paths=8,
keywords_score=2.0, keywords_score=2.0,
keywords_threshold=0.2, keywords_threshold=0.2,
num_trailing_blanks=1, num_trailing_blanks=0,
keywords_file=get_model_file_path("keywords.txt"), keywords_file=get_model_file_path("keywords.txt"),
tokens=get_model_file_path("tokens.txt"), tokens=get_model_file_path("tokens.txt"),
encoder=get_model_file_path("encoder.onnx"), encoder=get_model_file_path("encoder.onnx"),
@@ -1,9 +1,6 @@
import uuid import uuid
from typing import Any, Callable, ClassVar, Optional from typing import Any, Callable, ClassVar, Optional
import numpy as np
from config import APP_CONFIG
from xiaozhi.ref import get_xiaoai from xiaozhi.ref import get_xiaoai
@@ -88,10 +85,7 @@ class MyStream:
return return
if len(data) > 0: if len(data) > 0:
samples = np.frombuffer(data, dtype=np.int16) self.input_bytes.extend(data)
# 小爱音箱录音音量较小,需要后期放大一下
samples = samples * APP_CONFIG["vad"]["boost"]
self.input_bytes.extend(samples.tobytes())
def read(self, num_frames=None, exception_on_overflow=False) -> bytes: def read(self, num_frames=None, exception_on_overflow=False) -> bytes:
if num_frames is None: if num_frames is None:
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Test",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/ota.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"cwd": "${workspaceRoot}",
"envFile": "${workspaceFolder}/.env",
"internalConsoleOptions": "openOnSessionStart"
}
]
}
+4 -4
View File
@@ -14,8 +14,8 @@
你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件: 你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件:
- [Xiaomi 智能音箱 Pro v1.56.31](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.31) - [Xiaomi 智能音箱 Pro v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
- [小爱音箱 Pro v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206) - [小爱音箱 Pro v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
> [!TIP] > [!TIP]
> 里面有两个文件,下载 `patched` 那个: > 里面有两个文件,下载 `patched` 那个:
@@ -33,8 +33,8 @@
> [!CAUTION] > [!CAUTION]
> 当前支持的最新固件版本为: > 当前支持的最新固件版本为:
> >
> - Xiaomi 智能音箱 Pro 👉 [v1.56.31](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.31) > - Xiaomi 智能音箱 Pro 👉 [v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
> - 小爱音箱 Pro 👉 [v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206) > - 小爱音箱 Pro 👉 [v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
> >
> 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。 > 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。
+1 -1
View File
@@ -4,6 +4,6 @@
# Put your custom commands here that should be executed once # Put your custom commands here that should be executed once
# the system init finished. By default this file does nothing. # the system init finished. By default this file does nothing.
+[ -f "/data/init.sh" ] && sh /data/init.sh & +[ -f "/data/init.sh" ] && sh /data/init.sh >/dev/null 2>&1 &
+ +
exit 0 exit 0
+1 -1
View File
@@ -22,7 +22,7 @@ apply_patches() {
# 创建临时文件用于占位符替换 # 创建临时文件用于占位符替换
local temp_patch=$(mktemp) local temp_patch=$(mktemp)
# 将补丁文件中的 {SSH_PASSWORD} 替换为 PASSWORD # 将补丁文件中的 {SSH_PASSWORD} 替换为 PASSWORD
sed "s/{SSH_PASSWORD}/$PASSWORD/g" "$file" > "$temp_patch" sed "s|{SSH_PASSWORD}|$PASSWORD|g" "$file" > "$temp_patch"
# 应用替换后的补丁 # 应用替换后的补丁
patch -p1 < "$temp_patch" patch -p1 < "$temp_patch"
# 清理临时文件 # 清理临时文件
+2
View File
@@ -0,0 +1,2 @@
.idea
.clwb
+208 -147
View File
@@ -4,30 +4,24 @@ version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.24.2" version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [ dependencies = [
"gimli", "gimli",
] ]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [ dependencies = [
"addr2line", "addr2line",
"cfg-if", "cfg-if",
@@ -35,14 +29,14 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
"object", "object",
"rustc-demangle", "rustc-demangle",
"windows-targets", "windows-link",
] ]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.0" version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -53,6 +47,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@@ -61,9 +61,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
@@ -86,9 +86,9 @@ dependencies = [
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.8.0" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "digest" name = "digest"
@@ -197,9 +197,9 @@ dependencies = [
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" checksum = "1dc8f7d2ded5f9209535e4b3fd4d39c002f30902ff5ce9f64e2c33d549576500"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
@@ -207,21 +207,21 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.2" version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi",
"wasi 0.14.2+wasi-0.2.4", "wasi 0.14.7+wasi-0.2.4",
] ]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]] [[package]]
name = "http" name = "http"
@@ -240,6 +240,17 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "io-uring"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -247,62 +258,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]] [[package]]
name = "libc" name = "js-sys"
version = "0.2.171" version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [ dependencies = [
"autocfg", "once_cell",
"scopeguard", "wasm-bindgen",
] ]
[[package]] [[package]]
name = "log" name = "libc"
version = "0.4.27" version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.4" version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.7" version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [ dependencies = [
"adler2", "adler2",
] ]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.3" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [ dependencies = [
"libc", "libc",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys", "windows-sys",
] ]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "open-xiaoai" name = "open-xiaoai"
version = "1.0.0" version = "1.0.0"
@@ -316,29 +333,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "parking_lot"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@@ -362,33 +356,33 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.94" version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.2.0" version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.1" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core", "rand_core",
@@ -414,19 +408,16 @@ dependencies = [
] ]
[[package]] [[package]]
name = "redox_syscall" name = "rustc-demangle"
version = "0.5.11" version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustversion"
version = "0.1.24" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "ryu" name = "ryu"
@@ -435,25 +426,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]] [[package]]
name = "scopeguard" name = "serde"
version = "1.2.0" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]] [[package]]
name = "serde" name = "serde_core"
version = "1.0.219" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.219" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -462,14 +457,15 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.140" version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
"ryu", "ryu",
"serde", "serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -485,33 +481,24 @@ dependencies = [
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.2" version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
dependencies = [ dependencies = [
"libc", "libc",
] ]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.9" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys",
@@ -519,9 +506,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.100" version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -530,18 +517,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.12" version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -550,17 +537,18 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.44.2" version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"io-uring",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"slab",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys",
@@ -608,15 +596,15 @@ dependencies = [
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.18.0" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]] [[package]]
name = "utf-8" name = "utf-8"
@@ -626,11 +614,13 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.16.0" version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [ dependencies = [
"getrandom", "getrandom",
"js-sys",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -641,24 +631,98 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.14.2+wasi-0.2.4" version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [ dependencies = [
"wit-bindgen-rt", "wasip2",
] ]
[[package]] [[package]]
name = "windows-sys" name = "wasip2"
version = "0.52.0" version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets", "windows-targets",
] ]
@@ -728,28 +792,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "wit-bindgen-rt" name = "wit-bindgen"
version = "0.39.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.24" version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.24" version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+4 -4
View File
@@ -16,9 +16,9 @@ image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:0.2.5"
[dependencies] [dependencies]
futures = "0.3.31" futures = "0.3.31"
tokio = { version = "1.0", features = ["full"] } rand = "0.9.2"
tokio-tungstenite = "0.26.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
uuid = { version = "1.15.1", features = ["v4"] } tokio = { version = "1.47", features = ["fs", "macros", "process", "rt-multi-thread", "sync", "time"] }
rand = "0.9.1" tokio-tungstenite = "0.26.2"
uuid = { version = "1.18.1", features = ["v4"] }
+7 -3
View File
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
EOF EOF
set -e # 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
echo "🤫 等待网络连接中..." sleep 3
sleep 5 echo "✅ 网络连接成功"
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-client" DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-client"
-11
View File
@@ -1,16 +1,5 @@
#!/bin/sh #!/bin/sh
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
v1.0.0 by: https://del.wang
EOF
set -e set -e
+48 -36
View File
@@ -16,33 +16,45 @@ use open_xiaoai::services::connect::rpc::RPC;
use open_xiaoai::services::monitor::instruction::InstructionMonitor; use open_xiaoai::services::monitor::instruction::InstructionMonitor;
use open_xiaoai::services::monitor::playing::PlayingMonitor; use open_xiaoai::services::monitor::playing::PlayingMonitor;
struct AppClient; struct AppClient {
kws_monitor: KwsMonitor,
instruction_monitor: InstructionMonitor,
playing_monitor: PlayingMonitor,
}
impl AppClient { impl AppClient {
pub async fn connect(url: &str) -> Result<WsStream, AppError> { pub fn new() -> Self {
Self {
kws_monitor: KwsMonitor::new(),
instruction_monitor: InstructionMonitor::new(),
playing_monitor: PlayingMonitor::new(),
}
}
pub async fn connect(&self, url: &str) -> Result<WsStream, AppError> {
let (ws_stream, _) = connect_async(url).await?; let (ws_stream, _) = connect_async(url).await?;
Ok(WsStream::Client(ws_stream)) Ok(WsStream::Client(ws_stream))
} }
pub async fn run() { pub async fn run(&mut self) {
let url = std::env::args().nth(1).expect("❌ 请输入服务器地址"); let url = std::env::args().nth(1).expect("❌ 请输入服务器地址");
println!("✅ 已启动"); println!("✅ 已启动");
loop { loop {
let Ok(ws_stream) = AppClient::connect(&url).await else { let Ok(ws_stream) = self.connect(&url).await else {
sleep(Duration::from_secs(1)).await; sleep(Duration::from_secs(1)).await;
continue; continue;
}; };
println!("✅ 已连接: {:?}", url); println!("✅ 已连接: {:?}", url);
AppClient::init(ws_stream).await; self.init(ws_stream).await;
if let Err(e) = MessageManager::instance().process_messages().await { if let Err(e) = MessageManager::instance().process_messages().await {
eprintln!("❌ 消息处理异常: {}", e); eprintln!("❌ 消息处理异常: {}", e);
} }
AppClient::dispose().await; self.dispose().await;
eprintln!("❌ 已断开连接"); eprintln!("❌ 已断开连接");
} }
} }
async fn init(ws_stream: WsStream) { async fn init(&mut self, ws_stream: WsStream) {
MessageManager::instance().init(ws_stream).await; MessageManager::instance().init(ws_stream).await;
MessageHandler::<Event>::instance() MessageHandler::<Event>::instance()
.set_handler(on_event) .set_handler(on_event)
@@ -59,35 +71,38 @@ impl AppClient {
rpc.add_command("start_recording", start_recording).await; rpc.add_command("start_recording", start_recording).await;
rpc.add_command("stop_recording", stop_recording).await; rpc.add_command("stop_recording", stop_recording).await;
InstructionMonitor::start(|event| async move { self.instruction_monitor
MessageManager::instance() .start(|event| async move {
.send_event("instruction", Some(json!(event))) MessageManager::instance()
.await .send_event("instruction", Some(json!(event)))
}) .await
.await; })
.await;
PlayingMonitor::start(|event| async move { self.playing_monitor
MessageManager::instance() .start(|event| async move {
.send_event("playing", Some(json!(event))) MessageManager::instance()
.await .send_event("playing", Some(json!(event)))
}) .await
.await; })
.await;
KwsMonitor::start(|event| async move { self.kws_monitor
MessageManager::instance() .start(|event| async move {
.send_event("kws", Some(json!(event))) MessageManager::instance()
.await .send_event("kws", Some(json!(event)))
}) .await
.await; })
.await;
} }
async fn dispose() { async fn dispose(&mut self) {
MessageManager::instance().dispose().await; MessageManager::instance().dispose().await;
let _ = AudioPlayer::instance().stop().await; let _ = AudioPlayer::instance().stop().await;
let _ = AudioRecorder::instance().stop_recording().await; let _ = AudioRecorder::instance().stop_recording().await;
InstructionMonitor::stop().await; self.instruction_monitor.stop().await;
PlayingMonitor::stop().await; self.playing_monitor.stop().await;
KwsMonitor::stop().await; self.kws_monitor.stop().await;
} }
} }
@@ -147,17 +162,14 @@ async fn on_event(event: Event) -> Result<(), AppError> {
async fn on_stream(stream: Stream) -> Result<(), AppError> { async fn on_stream(stream: Stream) -> Result<(), AppError> {
let Stream { tag, bytes, .. } = stream; let Stream { tag, bytes, .. } = stream;
match tag.as_str() { if tag.as_str() == "play" {
"play" => { // 播放接收到的音频流
// 播放接收到的音频流 let _ = AudioPlayer::instance().play(bytes).await;
let _ = AudioPlayer::instance().play(bytes).await;
}
_ => {}
} }
Ok(()) Ok(())
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
AppClient::run().await; AppClient::new().run().await;
} }
+10 -9
View File
@@ -29,7 +29,7 @@ async fn on_keyword(_keyword: String) {
.split("\n") .split("\n")
.filter(|line| !line.trim().is_empty()) .filter(|line| !line.trim().is_empty())
.collect::<Vec<&str>>(); .collect::<Vec<&str>>();
if replies.len() > 0 { if !replies.is_empty() {
wakeup_sounds.clear(); wakeup_sounds.clear();
wakeup_sounds.extend(replies.iter().map(|s| s.to_string())); wakeup_sounds.extend(replies.iter().map(|s| s.to_string()));
} }
@@ -53,14 +53,15 @@ async fn main() {
on_started().await; on_started().await;
} }
KwsMonitor::start(|event| async move { KwsMonitor::new()
match event { .start(|event| async move {
KwsMonitorEvent::Started => on_started().await, match event {
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await, KwsMonitorEvent::Started => on_started().await,
} KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await,
Ok(()) }
}) Ok(())
.await; })
.await;
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@@ -39,7 +39,7 @@ impl AudioPlayer {
} }
if let Some(task) = self.player_task.lock().await.take() { if let Some(task) = self.player_task.lock().await.take() {
let _ = task.abort(); task.abort();
} }
if let Some(mut write_thread) = self.write_thread.lock().await.take() { if let Some(mut write_thread) = self.write_thread.lock().await.take() {
@@ -16,6 +16,8 @@ enum State {
Recording, Recording,
} }
const A113_CAPTURE_BITS_PER_SAMPLE: u16 = 32;
pub struct AudioRecorder { pub struct AudioRecorder {
state: Arc<Mutex<State>>, state: Arc<Mutex<State>>,
arecord_thread: Arc<Mutex<Option<Child>>>, arecord_thread: Arc<Mutex<Option<Child>>>,
@@ -69,45 +71,34 @@ impl AudioRecorder {
return Ok(()); return Ok(());
} }
let config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone()); let requested_config = config.unwrap_or_else(|| (*AUDIO_CONFIG).clone());
let capture_config = capture_config_for_recording(&requested_config);
let mut arecord_thread = Command::new("arecord") let mut arecord_thread = spawn_arecord(&capture_config)?;
.args([
"--quiet",
"-t",
"raw",
"-D",
&config.pcm,
"-f",
&format!("S{}_LE", config.bits_per_sample),
"-r",
&config.sample_rate.to_string(),
"-c",
&config.channels.to_string(),
"--buffer-size",
&config.buffer_size.to_string(),
"--period-size",
&config.period_size.to_string(),
])
.stdout(Stdio::piped())
.spawn()?;
let mut stdout = arecord_thread.stdout.take().unwrap(); let mut stdout = arecord_thread.stdout.take().unwrap();
let read_thread = tokio::spawn(async move { let read_thread = tokio::spawn(async move {
let size = (config.bits_per_sample / 8) as usize; let bytes_per_sample = (capture_config.bits_per_sample.max(8) / 8) as usize;
let target_size = config.buffer_size as usize * size; let bytes_per_frame = bytes_per_sample * capture_config.channels.max(1) as usize;
let target_frames = capture_config.buffer_size.max(1) as usize;
let read_frames = capture_config.period_size.max(1) as usize;
let target_size = target_frames * bytes_per_frame;
let read_size = read_frames * bytes_per_frame;
let mut accumulated_data = Vec::new(); let mut accumulated_data = Vec::new();
let mut buffer = vec![0u8; config.period_size as usize * size]; let mut buffer = vec![0u8; read_size];
loop { loop {
match stdout.read(&mut buffer).await { match stdout.read(&mut buffer).await {
Ok(size) if size > 0 => { Ok(size) if size > 0 => {
accumulated_data.extend_from_slice(&buffer[..size]); accumulated_data.extend_from_slice(&buffer[..size]);
if accumulated_data.len() >= target_size { while accumulated_data.len() >= target_size {
let data_to_send = let data_to_send =
accumulated_data.drain(..target_size).collect::<Vec<u8>>(); accumulated_data.drain(..target_size).collect::<Vec<u8>>();
let _ = on_stream(data_to_send).await; let data_to_send =
transform_stream_chunk(data_to_send, &requested_config, &capture_config);
if !data_to_send.is_empty() {
let _ = on_stream(data_to_send).await;
}
} }
} }
_ => break, _ => break,
@@ -124,3 +115,71 @@ impl AudioRecorder {
Ok(()) Ok(())
} }
} }
fn capture_config_for_recording(requested: &AudioConfig) -> AudioConfig {
let mut capture = requested.clone();
if requested.bits_per_sample == 16 {
capture.bits_per_sample = A113_CAPTURE_BITS_PER_SAMPLE;
}
capture
}
fn transform_stream_chunk(
chunk: Vec<u8>,
requested: &AudioConfig,
capture: &AudioConfig,
) -> Vec<u8> {
if requested.bits_per_sample != 16 || capture.bits_per_sample != A113_CAPTURE_BITS_PER_SAMPLE {
return chunk;
}
convert_a113_s32_to_s16(&chunk)
}
fn convert_a113_s32_to_s16(chunk: &[u8]) -> Vec<u8> {
if chunk.len() % 4 != 0 {
return Vec::new();
}
let frame_count = chunk.len() / 4;
let mut out = vec![0u8; frame_count * 2];
for frame in 0..frame_count {
let base = frame * 4;
let sample = i32::from_le_bytes([
chunk[base],
chunk[base + 1],
chunk[base + 2],
chunk[base + 3],
]);
// A113 PDM data lives in lower 24 bits of S32_LE: shift by 8 (not 16).
let mapped = (sample >> 8).clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let out_base = frame * 2;
out[out_base..out_base + 2].copy_from_slice(&mapped.to_le_bytes());
}
out
}
fn spawn_arecord(config: &AudioConfig) -> Result<Child, AppError> {
let child = Command::new("arecord")
.args([
"--quiet",
"-t",
"raw",
"-D",
&config.pcm,
"-f",
&format!("S{}_LE", config.bits_per_sample),
"-r",
&config.sample_rate.to_string(),
"-c",
&config.channels.to_string(),
"--buffer-size",
&config.buffer_size.to_string(),
"--period-size",
&config.period_size.to_string(),
])
.stdout(Stdio::piped())
.spawn()?;
Ok(child)
}
@@ -1,13 +1,12 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::future::Future; use std::future::Future;
use std::path::Path; use std::path::Path;
use std::sync::LazyLock;
use tokio::fs::OpenOptions; use tokio::fs::OpenOptions;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader, SeekFrom}; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader, SeekFrom};
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use crate::base::AppError; use crate::base::AppError;
use crate::utils::task::TaskManager;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum FileMonitorEvent { pub enum FileMonitorEvent {
@@ -15,20 +14,22 @@ pub enum FileMonitorEvent {
NewLine(String), NewLine(String),
} }
pub struct FileMonitor; pub struct FileMonitor {
task_holder: Option<JoinHandle<()>>,
}
static INSTANCE: LazyLock<FileMonitor> = LazyLock::new(FileMonitor::new); impl Default for FileMonitor {
fn default() -> Self {
Self::new()
}
}
impl FileMonitor { impl FileMonitor {
fn new() -> Self { pub fn new() -> Self {
Self {} Self { task_holder: None }
} }
pub fn instance() -> &'static Self { pub async fn start<F, Fut>(&mut self, file_path: &str, on_update: F)
&INSTANCE
}
pub async fn start<F, Fut>(&self, file_path: &str, on_update: F)
where where
F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static, F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static, Fut: Future<Output = Result<(), AppError>> + Send + 'static,
@@ -36,18 +37,19 @@ impl FileMonitor {
let file_path_clone = file_path.to_string(); let file_path_clone = file_path.to_string();
let monitor = tokio::spawn(async move { let monitor = tokio::spawn(async move {
let _ = FileMonitor::start_monitor(file_path_clone.as_str(), on_update).await; let _ = Self::start_monitor(file_path_clone.as_str(), on_update).await;
}); });
TaskManager::instance() if let Some(old_task) = self.task_holder.replace(monitor) {
.add(&format!("FileMonitor-{}", file_path), monitor) println!("Aborting old file monitor task");
.await; old_task.abort();
}
} }
pub async fn stop(&self, file_path: &str) { pub async fn stop(&mut self) {
TaskManager::instance() if let Some(handle) = self.task_holder.take() {
.dispose(&format!("FileMonitor-{}", file_path)) handle.abort();
.await; }
} }
async fn start_monitor<F, Fut>(file_path: &str, on_update: F) -> Result<(), AppError> async fn start_monitor<F, Fut>(file_path: &str, on_update: F) -> Result<(), AppError>
@@ -6,23 +6,37 @@ use crate::base::AppError;
use super::file::{FileMonitor, FileMonitorEvent}; use super::file::{FileMonitor, FileMonitorEvent};
pub struct InstructionMonitor;
static INSTRUCTION_FILE_PATH: &str = "/tmp/mico_aivs_lab/instruction.log"; static INSTRUCTION_FILE_PATH: &str = "/tmp/mico_aivs_lab/instruction.log";
pub struct InstructionMonitor {
file_monitor: FileMonitor,
}
impl Default for InstructionMonitor {
fn default() -> Self {
Self::new()
}
}
impl InstructionMonitor { impl InstructionMonitor {
pub async fn start<F, Fut>(on_update: F) pub fn new() -> Self {
Self {
file_monitor: FileMonitor::new(),
}
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where where
F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static, F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static, Fut: Future<Output = Result<(), AppError>> + Send + 'static,
{ {
FileMonitor::instance() self.file_monitor
.start(INSTRUCTION_FILE_PATH, on_update) .start(INSTRUCTION_FILE_PATH, on_update)
.await; .await;
} }
pub async fn stop() { pub async fn stop(&mut self) {
FileMonitor::instance().stop(INSTRUCTION_FILE_PATH).await; self.file_monitor.stop().await;
} }
} }
@@ -8,36 +8,52 @@ use crate::base::AppError;
use super::file::{FileMonitor, FileMonitorEvent}; use super::file::{FileMonitor, FileMonitorEvent};
pub static KWS_FILE_PATH: &str = "/tmp/open-xiaoai/kws.log";
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum KwsMonitorEvent { pub enum KwsMonitorEvent {
Started, Started,
Keyword(String), Keyword(String),
} }
pub struct KwsMonitor; pub struct KwsMonitor {
file_monitor: FileMonitor,
}
pub static KWS_FILE_PATH: &str = "/tmp/open-xiaoai/kws.log"; impl Default for KwsMonitor {
fn default() -> Self {
static LAST_TIMESTAMP: AtomicU64 = AtomicU64::new(0); Self::new()
}
}
impl KwsMonitor { impl KwsMonitor {
pub async fn start<F, Fut>(on_update: F) pub fn new() -> Self {
Self {
file_monitor: FileMonitor::new(),
}
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where where
F: Fn(KwsMonitorEvent) -> Fut + Send + Sync + 'static, F: Fn(KwsMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static, Fut: Future<Output = Result<(), AppError>> + Send + 'static,
{ {
let on_update = Arc::new(on_update); let on_update = Arc::new(on_update);
FileMonitor::instance() let last_ts_store = Arc::new(AtomicU64::new(0));
self.file_monitor
.start(KWS_FILE_PATH, move |event| { .start(KWS_FILE_PATH, move |event| {
let on_update = Arc::clone(&on_update); let on_update = Arc::clone(&on_update);
let last_ts_store = Arc::clone(&last_ts_store);
async move { async move {
if let FileMonitorEvent::NewLine(content) = event { if let FileMonitorEvent::NewLine(content) = event {
let data = content.split('@').collect::<Vec<&str>>(); let data = content.split('@').collect::<Vec<&str>>();
let timestamp = data[0].parse::<u64>().unwrap(); let timestamp = data[0].parse::<u64>().unwrap();
let keyword = data[1].to_string(); let keyword = data[1].to_string();
let last_timestamp = LAST_TIMESTAMP.load(Ordering::Relaxed); let last_timestamp = last_ts_store.load(Ordering::Relaxed);
if timestamp != last_timestamp { if timestamp != last_timestamp {
LAST_TIMESTAMP.store(timestamp, Ordering::Relaxed); last_ts_store.store(timestamp, Ordering::Relaxed);
let kws_event = if keyword == "__STARTED__" { let kws_event = if keyword == "__STARTED__" {
KwsMonitorEvent::Started KwsMonitorEvent::Started
} else { } else {
@@ -52,8 +68,7 @@ impl KwsMonitor {
.await; .await;
} }
pub async fn stop() { pub async fn stop(&mut self) {
LAST_TIMESTAMP.store(0, Ordering::Relaxed); self.file_monitor.stop().await;
FileMonitor::instance().stop(KWS_FILE_PATH).await;
} }
} }
@@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::future::Future; use std::future::Future;
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use crate::base::AppError; use crate::base::AppError;
use crate::utils::shell::run_shell; use crate::utils::shell::run_shell;
use crate::utils::task::TaskManager;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub enum PlayingMonitorEvent { pub enum PlayingMonitorEvent {
@@ -13,10 +13,22 @@ pub enum PlayingMonitorEvent {
Idle, Idle,
} }
pub struct PlayingMonitor; pub struct PlayingMonitor {
task_holder: Option<JoinHandle<()>>,
}
impl Default for PlayingMonitor {
fn default() -> Self {
Self::new()
}
}
impl PlayingMonitor { impl PlayingMonitor {
pub async fn start<F, Fut>(on_update: F) pub fn new() -> Self {
Self { task_holder: None }
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where where
F: Fn(PlayingMonitorEvent) -> Fut + Send + Sync + 'static, F: Fn(PlayingMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static, Fut: Future<Output = Result<(), AppError>> + Send + 'static,
@@ -25,11 +37,16 @@ impl PlayingMonitor {
let _ = PlayingMonitor::start_monitor(on_update).await; let _ = PlayingMonitor::start_monitor(on_update).await;
}); });
TaskManager::instance().add("PlayingMonitor", monitor).await; if let Some(old_task) = self.task_holder.replace(monitor) {
println!("Aborting old playing monitor task");
old_task.abort();
};
} }
pub async fn stop() { pub async fn stop(&mut self) {
TaskManager::instance().dispose("PlayingMonitor").await; if let Some(handle) = self.task_holder.take() {
handle.abort();
}
} }
async fn start_monitor<F, Fut>(on_update: F) -> Result<(), AppError> async fn start_monitor<F, Fut>(on_update: F) -> Result<(), AppError>
+1 -4
View File
@@ -73,9 +73,6 @@ impl EventBus {
async fn get_callbacks(&self, event: &str) -> Option<Vec<EventCallback>> { async fn get_callbacks(&self, event: &str) -> Option<Vec<EventCallback>> {
let subscribers = self.subscribers.lock().await; let subscribers = self.subscribers.lock().await;
match subscribers.get(event) { subscribers.get(event).cloned()
Some(callbacks) => Some(callbacks.clone()),
None => None,
}
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
pub mod event; pub mod event;
pub mod rand;
pub mod shell; pub mod shell;
pub mod task; pub mod task;
pub mod rand;
+1 -1
View File
@@ -35,7 +35,7 @@ impl TaskManager {
let mut tasks = self.tasks.lock().await; let mut tasks = self.tasks.lock().await;
if let Some(handles) = tasks.remove(tag) { if let Some(handles) = tasks.remove(tag) {
for handle in handles { for handle in handles {
let _ = handle.abort(); handle.abort();
} }
} }
} }
+4 -2
View File
@@ -44,14 +44,16 @@ chmod +x ./flash
# 执行命令后拔掉小爱音箱的电源线,重新插上电源,等待设备连接 # 执行命令后拔掉小爱音箱的电源线,重新插上电源,等待设备连接
./flash connect ./flash connect
# 第 2 步:设置启动延时(5 秒) # 第 2 步:设置启动延时(15 秒)
./flash delay 5 ./flash delay 15
# 第 3 步:切换启动分区 # 第 3 步:切换启动分区
./flash switch boot0 ./flash switch boot0
# 第 4 步:刷写固件(注意替换固件文件的实际路径) # 第 4 步:刷写固件(注意替换固件文件的实际路径)
./flash system system0 root-patched.squashfs ./flash system system0 root-patched.squashfs
# PS: 如果提示刷写错误,可以多试几次,不一定是真的无法刷机
``` ```
> [!TIP] > [!TIP]
+3
View File
@@ -0,0 +1,3 @@
squashfs-root
root.squashfs
squashfs
+88
View File
@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.4
FROM ubuntu:20.04
# 1. 环境变量合并,减少层数
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/opt/toolchain/bin:/root/.cargo/bin:${PATH}" \
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_SYSROOT_DIR=/opt/sysroot \
PKG_CONFIG_LIBDIR=/opt/sysroot/usr/lib/pkgconfig:/opt/sysroot/lib/pkgconfig \
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc \
CC_armv7_unknown_linux_gnueabihf="arm-linux-gnueabihf-gcc --sysroot=/opt/sysroot"
# 2. 基础依赖与多架构源配置合并
# 使用 --mount=type=cache 优化 apt 操作
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/deb http/deb [arch=amd64] http/g' /etc/apt/sources.list && \
echo "deb [arch=armhf] http://mirrors.aliyun.com/ubuntu-ports/ focal main restricted universe multiverse" > /etc/apt/sources.list.d/armhf.list && \
echo "deb [arch=armhf] http://mirrors.aliyun.com/ubuntu-ports/ focal-updates main restricted universe multiverse" >> /etc/apt/sources.list.d/armhf.list && \
dpkg --add-architecture armhf && \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential qemu-user-static pkg-config libssl-dev xz-utils squashfs-tools \
crossbuild-essential-armhf libasound2-dev:armhf libopus-dev:armhf libssl-dev:armhf && \
# 删除 lists 文件以减小体积
rm -rf /var/lib/apt/lists/*
# 3. 安装 Linaro Toolchain & Rust
# 将下载和清理放在一层,减少体积
RUN mkdir -p /opt/toolchain && \
curl -fSL https://releases.linaro.org/components/toolchain/binaries/latest-7/arm-linux-gnueabihf/gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf.tar.xz | tar -xJ -C /opt/toolchain --strip-components=1 && \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && \
rustup target add armv7-unknown-linux-gnueabihf
WORKDIR /app
# 4. 脚本与 Rootfs 处理
# 使用 --mount=bind 减少一次 COPY 导致的镜像体积增加
COPY run.sh runtime.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/run.sh /usr/local/bin/runtime.sh
# 处理 root.squashfs:利用临时挂载或直接解压并删除
RUN --mount=type=bind,source=root.squashfs,target=/app/root.squashfs \
mkdir -p /opt/sysroot && unsquashfs -d /opt/sysroot -f /app/root.squashfs
# 5. 构建混合 Sysroot 逻辑优化
# 将所有文件操作合并到一个 RUN 块,减少 Metadata 冗余
RUN set -e; \
# 1. 自动为共享库创建 .so 软链接
find /opt/sysroot/lib /opt/sysroot/usr/lib -name "lib*.so.*" | while read -r src; do \
link_name=$(echo "$src" | sed 's/\.so\..*$/.so/'); \
if [ ! -e "$link_name" ]; then \
ln -s "$(basename "$src")" "$link_name"; \
fi; \
done; \
# 2. 拷贝 crt*.o 文件
find /opt/toolchain/arm-linux-gnueabihf/libc -name "*crt*.o" -exec cp {} /opt/sysroot/usr/lib/ \; ; \
# 3. 拷贝 libc_nonshared.a
cp /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libc_nonshared.a /opt/sysroot/usr/lib/; \
if [ -f /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libpthread_nonshared.a ]; then \
cp /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libpthread_nonshared.a /opt/sysroot/usr/lib/; \
fi; \
# 4. 生成 Linker Scripts
printf "OUTPUT_FORMAT(elf32-littlearm)\nGROUP ( /lib/libc.so.6 /usr/lib/libc_nonshared.a AS_NEEDED ( /lib/ld-linux-armhf.so.3 ) )\n" > /opt/sysroot/usr/lib/libc.so; \
if [ -f /opt/sysroot/lib/libpthread.so.0 ]; then \
printf "OUTPUT_FORMAT(elf32-littlearm)\nGROUP ( /lib/libpthread.so.0 /usr/lib/libpthread_nonshared.a )\n" > /opt/sysroot/usr/lib/libpthread.so; \
fi; \
# 5. 注入头文件和 pkgconfig
cp -r /usr/include/alsa /opt/sysroot/usr/include/ 2>/dev/null || true; \
cp -r /usr/include/opus /opt/sysroot/usr/include/ 2>/dev/null || true; \
if [ -d /usr/include/arm-linux-gnueabihf ]; then \
cp -r /usr/include/arm-linux-gnueabihf/* /opt/sysroot/usr/include/ 2>/dev/null || true; \
fi; \
if [ -d /usr/lib/arm-linux-gnueabihf/pkgconfig ]; then \
cp -r /usr/lib/arm-linux-gnueabihf/pkgconfig/* /opt/sysroot/usr/lib/pkgconfig/ 2>/dev/null || true; \
fi
# 6. 设置 Rust 链接参数
ENV CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS="\
-C link-arg=--sysroot=/opt/sysroot \
-C link-arg=-Wl,-dynamic-linker,/lib/ld-linux-armhf.so.3 \
-C link-arg=-L/opt/sysroot/usr/lib \
-C link-arg=-L/opt/sysroot/lib \
-C link-arg=-Wl,-rpath-link,/opt/sysroot/usr/lib \
-C link-arg=-Wl,-rpath-link,/opt/sysroot/lib"
CMD ["/bin/bash"]
+37
View File
@@ -0,0 +1,37 @@
# Makefile for open-xiaoai-runtime
# 使用 host.docker.internal 访问宿主机的代理端口(加速构建)
# 注意:代理软件需要开启 "Allow LAN"(允许局域网连接)
PROXY := http://host.docker.internal:7890
IMAGE_NAME := idootop/open-xiaoai-runtime:oh2p
# 显示帮助信息
help:
@echo "Available targets:"
@echo " make build - 构建交叉编译环境镜像"
@echo " make run-x86 - 进入交叉编译环境(Ubuntu x86_64"
@echo " make run-armv7 - 进入小爱音箱模拟环境(OpenWrt ARMv7"
@echo " make help - 显示此帮助信息"
@echo ""
@echo "Variables:"
@echo " PROXY=$(PROXY)"
@echo " IMAGE_NAME=$(IMAGE_NAME)"
# 构建交叉编译环境镜像
build:
@echo "Building Docker image: $(IMAGE_NAME)..."
docker build \
--build-arg http_proxy=$(PROXY) \
--build-arg https_proxy=$(PROXY) \
-t $(IMAGE_NAME) .
# 进入交叉编译环境(Ubuntu x86_64
run-x86:
@echo "Entering cross-compilation environment (Ubuntu x86_64)..."
docker run --rm -it --privileged $(IMAGE_NAME)
# 进入小爱音箱模拟环境(OpenWrt ARMv7
run-armv7:
@echo "Entering XiaoAi speaker simulation environment (OpenWrt ARMv7)..."
docker run --rm -it --privileged $(IMAGE_NAME) run
+83
View File
@@ -0,0 +1,83 @@
# Open-XiaoAI Runtime
小爱音箱 ARMv7 交叉编译 + 模拟运行环境(基于 OH2P v1.58.1 固件)
## 概述
小爱音箱使用的 Linux 系统较旧,glibc 版本为 2.25,需要使用 `gcc-linaro-7.5.0-2019.12` 工具链才能编译出兼容的程序。
```bash
root@OH2P:~# uname -a
Linux OH2P 4.9.61 #1 SMP PREEMPT Fri Jun 27 06:11:47 2025 aarch64 GNU/Linux
root@OH2P:~# file /lib/libc-2.25.so
ELF 32-bit LSB shared object, ARM, EABI5 version 1 (GNU/Linux),
dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, stripped
```
## Rust 交叉编译示例
编译产物可直接在小爱音箱上运行。
```bash
cd /path/to/your-rust-project
# 使用预先构建好的 Docker 环境进行交叉编译
docker run --rm idootop/open-xiaoai-runtime:oh2p \
-v $(shell pwd):/app \
cargo build --target armv7-unknown-linux-gnueabihf --release
```
## 环境说明
**交叉编译环境**Ubuntu 20.04 x86_64
- Linaro GCC 7.5.0 (arm-linux-gnueabihf)
- Rust 稳定版 + armv7-unknown-linux-gnueabihf target
- 包含 ALSA、Opus、OpenSSL 等系统库(ARMv7 版本)
- 从小爱音箱固件提取的完整 Sysroot
**模拟运行环境**QEMU 用户态)
- ARMv7 架构(32-bit ARM
- OpenWrt 定制系统 + glibc 2.25
- 使用小爱音箱真实 rootfs
- 不支持音频录制/播放等操作,需在小爱音箱真机运行
## 自定义配置
### 更换固件
默认使用 OH2P v1.58.1 固件。如需使用其他型号和版本:
```bash
# 克隆代码
git clone https://github.com/idootop/open-xiaoai.git
# 进入当前项目根目录
cd packages/runtime
# 【重要】将新的 `root.squashfs` 放到当前目录
# 构建镜像
make build
```
PS:你可以修改 `Makefile` 中的代理地址加速镜像构建(代理软件需开启局域网访问)。
```makefile
PROXY := http://host.docker.internal:7890 # 对应 0.0.0.0:7890
```
### 其他 Makefile 命令
```bash
# 构建镜像
make build
# 进入交叉编译环境(Ubuntu x86_64
make run-x86
# 进入小爱音箱模拟环境(OpenWrt ARMv7
make run-armv7
```
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# 定义 sysroot 目录
SYSROOT_DIR="/opt/sysroot"
# 检查第一个参数。如果在 sysroot 下存在该文件,则自动补全路径
# 比如 `run /bin/sh` 会被转换为 `run /opt/sysroot/bin/sh`
if [ -n "$1" ] && [ -f "$SYSROOT_DIR$1" ]; then
CMD="$SYSROOT_DIR$1"
shift
set -- "$CMD" "$@"
fi
if [ $# -eq 0 ]; then
# 如果没有提供参数,则默认在 sysroot 中启动 shell
exec runtime
else
exec qemu-arm-static -L "$SYSROOT_DIR" "$@"
fi
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
SYSROOT="/opt/sysroot"
QEMU_BIN="/usr/bin/qemu-arm-static"
echo "正在准备 OpenWrt 模拟环境..."
# 1. 拷贝 QEMU 静态二进制文件
cp "$QEMU_BIN" "$SYSROOT/usr/bin/"
# 2. 挂载必要文件系统
mount_fs() {
for dir in proc sys dev dev/pts tmp; do
mkdir -p "$SYSROOT/$dir"
if ! mountpoint -q "$SYSROOT/$dir"; then
if [[ "$dir" == "proc" ]]; then
mount -t proc proc "$SYSROOT/$dir"
elif [[ "$dir" == "sys" ]]; then
mount -t sysfs sysfs "$SYSROOT/$dir"
else
mount --bind "/$dir" "$SYSROOT/$dir"
fi
fi
done
}
mount_fs
# 3. 执行进入命令
# 注意:OpenWrt 默认没有 bash,通常使用 /bin/sh (busybox)
echo "------------------------------------------------"
echo "成功进入 OpenWrt 环境。输入 'exit' 退出。"
echo "------------------------------------------------"
chroot "$SYSROOT" /usr/bin/$(basename $QEMU_BIN) /bin/sh