Compare commits
91
Commits
vad-kws-models
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d68e63944 | ||
|
|
aafdf024d3 | ||
|
|
0f92b0a26d | ||
|
|
68b5484da5 | ||
|
|
9ee6ac649c | ||
|
|
8522e955aa | ||
|
|
0748bedceb | ||
|
|
fbef7c495c | ||
|
|
e986e45d7e | ||
|
|
2e939a72d7 | ||
|
|
aa2a5d8474 | ||
|
|
b69bd5a8f9 | ||
|
|
f4340f068f | ||
|
|
b07e7500c1 | ||
|
|
2027a61877 | ||
|
|
3017473850 | ||
|
|
118c34eb5c | ||
|
|
ab872180fc | ||
|
|
7bdbb30bd2 | ||
|
|
7e80d6cd74 | ||
|
|
2ff214c873 | ||
|
|
cdb4fcff85 | ||
|
|
ba6eaf6674 | ||
|
|
41be071b60 | ||
|
|
84c332d742 | ||
|
|
09c625cf32 | ||
|
|
aabe882b88 | ||
|
|
316f61d0bb | ||
|
|
4bce9e8d23 | ||
|
|
871d01a158 | ||
|
|
1caef01836 | ||
|
|
f4b70088fe | ||
|
|
81ac343098 | ||
|
|
b277fb7234 | ||
|
|
9fe7ebd65d | ||
|
|
f7dd1586e8 | ||
|
|
28d714a2ef | ||
|
|
76260d412c | ||
|
|
05d2174873 | ||
|
|
fbe64f63dd | ||
|
|
518436d000 | ||
|
|
9b2f61b037 | ||
|
|
bd9565577f | ||
|
|
962647afea | ||
|
|
8e788f4b47 | ||
|
|
392d8bc21b | ||
|
|
06645dc563 | ||
|
|
61ecb07a99 | ||
|
|
3d9d852bda | ||
|
|
fba40393c2 | ||
|
|
c521e274e9 | ||
|
|
70a64e1eee | ||
|
|
77a19c41e8 | ||
|
|
b7292dbd12 | ||
|
|
0f75a10b9f | ||
|
|
2d6c17d02f | ||
|
|
871a04ef79 | ||
|
|
7b473ad86a | ||
|
|
61943b16ac | ||
|
|
d43a9588c9 | ||
|
|
5e70f73c1e | ||
|
|
31e997a192 | ||
|
|
39b2dfe220 | ||
|
|
066fddbb68 | ||
|
|
b5d00db7ca | ||
|
|
1405eca23f | ||
|
|
43e6739726 | ||
|
|
affd94d1cf | ||
|
|
53f8ab31ee | ||
|
|
c5edea969c | ||
|
|
e4dc39ef39 | ||
|
|
729ba86d43 | ||
|
|
0075f13ffd | ||
|
|
96d69ec8a6 | ||
|
|
c0b11436fd | ||
|
|
948364dcc9 | ||
|
|
c06bbf6c51 | ||
|
|
f2ba7b496d | ||
|
|
aa5d4490bb | ||
|
|
e3609fc888 | ||
|
|
ba828b782d | ||
|
|
bcf98c5365 | ||
|
|
8e7165e636 | ||
|
|
d83c2b3093 | ||
|
|
824ff6bc68 | ||
|
|
db642fc176 | ||
|
|
7974ab3f2b | ||
|
|
c34c884f86 | ||
|
|
c61f44e5ed | ||
|
|
b2eba4bb89 | ||
|
|
66d222bdf7 |
@@ -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
|
||||
@@ -47,3 +47,4 @@ __pycache__
|
||||
.mi.json
|
||||
xiaozhi.json
|
||||
open-xiaoai.node
|
||||
examples/hass/config.json
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
## 你的声音 + 小爱音箱 = 无限可能
|
||||
|
||||
👉 [小爱音箱接入小智 AI 演示视频](https://www.bilibili.com/video/BV1NBXWYSEvX)
|
||||
👉 [小爱音箱接入小智 AI 演示视频](https://www.bilibili.com/video/BV1TxJhzvEhz)
|
||||
|
||||
[](https://www.bilibili.com/video/BV1NBXWYSEvX)
|
||||
[](https://www.bilibili.com/video/BV1TxJhzvEhz)
|
||||
|
||||
👉 [小爱音箱自定义唤醒词演示视频](https://www.bilibili.com/video/BV1YfVUz5EMj)
|
||||
|
||||
@@ -52,8 +52,9 @@
|
||||
- 👉 [小爱音箱自定义唤醒词](examples/kws/README.md)
|
||||
- 👉 [小爱音箱接入 MiGPT(完美版)](examples/migpt/README.md)
|
||||
- 👉 [小爱音箱接入 Gemini Live API](examples/gemini/README.md)
|
||||
- 👉 [小爱音箱组立体声(支持不同型号机型)](examples/stereo/README.md)
|
||||
|
||||
以上皆为抛砖引玉,你也可以亲手编写自己想要的功能(比如:Airplay、DLNA、开启 DTS 音效等),一切由你定义!
|
||||
以上皆为抛砖引玉,你也可以亲手编写自己想要的功能,一切由你定义!
|
||||
|
||||
## 相关项目
|
||||
|
||||
@@ -89,4 +90,4 @@
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) License © 2024-PRESENT Del Wang
|
||||
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
> 请先确认你已经将小爱音箱刷机成功,并安装运行了 Rust 补丁程序 [👉 教程](../../packages/client-rust/README.md),否则该项目启动后收不到音频输入,将无法正常工作。
|
||||
|
||||
```bash
|
||||
# 克隆代码
|
||||
git clone https://github.com/idootop/open-xiaoai.git
|
||||
|
||||
# 进入当前项目根目录
|
||||
cd examples/gemini
|
||||
|
||||
# 安装 Python 依赖
|
||||
uv sync --locked
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 pipeline(STT→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.process(curl)
|
||||
|
||||
```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 时)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
__all__ = [
|
||||
"config_loader",
|
||||
"errors",
|
||||
"ha_client",
|
||||
"memory",
|
||||
"typing",
|
||||
"vad",
|
||||
"xiaoai_bridge",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class HassAssistantError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HomeAssistantRequestError(HassAssistantError):
|
||||
pass
|
||||
|
||||
|
||||
class HomeAssistantAuthError(HomeAssistantRequestError):
|
||||
pass
|
||||
|
||||
|
||||
class HomeAssistantResponseError(HomeAssistantRequestError):
|
||||
pass
|
||||
|
||||
|
||||
class XiaoAiBridgeError(HassAssistantError):
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#[macro_export]
|
||||
macro_rules! pylog {
|
||||
($($arg:tt)*) => {{
|
||||
println!($($arg)*);
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == "书房"
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
@@ -146,4 +146,12 @@ curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws
|
||||
|
||||
由于小爱音箱 client 端算力和存储空间有限,默认只使用了一个较小的[语音识别模型](https://k2-fsa.github.io/sherpa/onnx/kws/pretrained_models/index.html#sherpa-onnx-kws-pre-trained-models)(5MB 左右),需要占用大约 20 MB 的存储空间。其他型号的小爱音箱存储空间和算力可能不足以运行该模型,请自行测试。
|
||||
|
||||
你也可以参考 [open-xiaoai](https://github.com/idootop/open-xiaoai) 里的演示程序,在 server 端运行更大规模、更先进的 AI 模型,来进行唤醒词识别,达到相同目的。推荐使用 [FunASR](https://github.com/modelscope/FunASR) 和 [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx),可以参考 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目。
|
||||
你也可以参考 [open-xiaoai](https://github.com/idootop/open-xiaoai) 里的演示程序,在 server 端运行更大规模、更先进的 AI 模型,来进行唤醒词识别,达到相同目的。推荐使用 [FunASR](https://github.com/modelscope/FunASR) 和 [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx),可以参考 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 项目。
|
||||
|
||||
### Q:我是小爱音箱 Pro(LX06)为什么启动失败显示 Killed?
|
||||
|
||||
目前只在新款小米智能音箱 Pro(OH2P)和旧款带 DTS 功能的小爱音箱 Pro(256 MB 内存)上测试可以正常运行。
|
||||
|
||||
有些新版的小爱音箱 Pro(不带 DTS 版本)内存仅为 128 MB,无法提供充足的内存来加载模型文件,故无法在 Client 端运行该 KWS 服务。
|
||||
|
||||
建议参考 [xiaozhi](../xiaozhi/README.md) 演示,在 Server 端运行 KWS 服务。
|
||||
+23
-2
@@ -1,6 +1,27 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
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 "✅ 网络连接成功"
|
||||
|
||||
MIN_SPACE_MB=32
|
||||
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
|
||||
|
||||
+12
-1
@@ -1,4 +1,15 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
▄▖ ▖▖▘ ▄▖▄▖
|
||||
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||
▌
|
||||
|
||||
v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
+12
-1
@@ -1,4 +1,15 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
▄▖ ▖▖▘ ▄▖▄▖
|
||||
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||
▌
|
||||
|
||||
v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.mp3
|
||||
*.wav
|
||||
Generated
+547
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod entry;
|
||||
pub mod master;
|
||||
pub mod slave;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
pub mod player;
|
||||
pub mod recorder;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod discovery;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 编码数据
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod alsa;
|
||||
pub mod sync;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+19
-14
@@ -2,9 +2,12 @@
|
||||
|
||||
[Open-XiaoAI](https://github.com/idootop/open-xiaoai) 的 Python 版 Server 端,用来演示小爱音箱接入[小智 AI](https://github.com/78/xiaozhi-esp32)。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本项目只是一个简单的演示程序,抛砖引玉。诸如一些音频压缩、加密传输、多账号管理等功能并未提供,建议只在局域网内测试运行,不推荐部署在公网服务器上(消耗流量 100kb/s),请自行评估相关风险,合理使用。
|
||||
|
||||
- 小爱音箱接入小智 AI
|
||||
- 自定义唤醒词(中英文)和提示语
|
||||
- 支持连续对话和中途打断
|
||||
- 自定义唤醒词(中英文)和提示语
|
||||
- 支持自定义消息处理,方便个人定制
|
||||
|
||||
## 快速开始
|
||||
@@ -63,8 +66,11 @@ docker run -it --rm -p 4399:4399 -v $(pwd)/config.py:/app/config.py idootop/open
|
||||
# 安装 Python 依赖
|
||||
uv sync --locked
|
||||
|
||||
# 编译运行
|
||||
# 编译运行(GUI 模式,不支持唤醒词唤醒)
|
||||
uv run main.py
|
||||
|
||||
# 或者设置环境变量 CLI=true,开启 CLI 模式(支持自定义唤醒词)
|
||||
CLI=true uv run main.py
|
||||
```
|
||||
|
||||
如果你只是想体验一下小智 AI,请使用以下命令启动:
|
||||
@@ -75,9 +81,6 @@ uv run main.py --mode xiaozhi
|
||||
|
||||
该模式下使用电脑的麦克风和扬声器作为音频输入输出设备,无需连接小爱音箱。
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目只是一个简单的演示程序,抛砖引玉。诸如一些音频压缩、加密传输、多账号管理等功能并未提供,请自行评估用途和使用风险。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q:回答太长了,如何打断小智 AI 的回答?
|
||||
@@ -131,21 +134,15 @@ APP_CONFIG = {
|
||||
|
||||
文字识别结果取决于你的小智 AI 服务器端的语音识别方案,与本项目无关。
|
||||
|
||||
不过需要注意的是,在唤醒后或连续对话时,由于 VAD 激活阈值的关系,开始的一小段语音可能会被丢弃识别不到,使用时需要多注意。
|
||||
|
||||
比如:“你知道我是谁吗” 有可能会被识别为“知道我是谁吗”(丢掉了最前面一个字)
|
||||
|
||||
### Q:唤醒词一直没有反应?
|
||||
|
||||
由于小爱音箱远场拾音音量较小,有时可能会识别不清,你可以调大 `config.py` 配置文件里的 `boost` 参数,然后重启应用 / Docker 试试看。
|
||||
如果唤醒词还是不敏感,可以先调低 `vad.threshold`,然后重启应用 / Docker 试试看。
|
||||
|
||||
```py
|
||||
APP_CONFIG = {
|
||||
"vad": {
|
||||
# 小爱音箱录音音量较小,需要后期放大一下
|
||||
"boost": 100,
|
||||
# boost 调大后,语音检测阈值可能也需要一起调大些
|
||||
"threshold": 0.50,
|
||||
# 语音检测阈值(0-1,越小越灵敏)
|
||||
"threshold": 0.05,
|
||||
},
|
||||
# ... 其他配置
|
||||
}
|
||||
@@ -157,6 +154,14 @@ APP_CONFIG = {
|
||||
|
||||
PS:如果还是不行,建议更换其他更易识别的唤醒词。
|
||||
|
||||
### Q: 我想自己编译运行,模型文件在哪里下载?
|
||||
|
||||
由于 ASR 相关模型文件体积较大,并未直接提交在 git 仓库中,你可以在 release 中下载 [VAD + KWS 相关模型](https://github.com/idootop/open-xiaoai/releases/tag/vad-kws-models),然后解压到 `xiaozhi/models` 路径下即可。
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [oxa-server](https://github.com/pu-007/oxa-server): 提供了更强大易用的 config.py 的配置方式
|
||||
|
||||
## 鸣谢
|
||||
|
||||
该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。
|
||||
|
||||
@@ -9,19 +9,12 @@ async def before_wakeup(speaker, text, source):
|
||||
- 'kws': 关键字唤醒
|
||||
- 'xiaoai': 小爱同学收到用户指令
|
||||
"""
|
||||
if source == "kws" and text == "你好小智":
|
||||
if source == "kws":
|
||||
# 播放唤醒提示语
|
||||
await speaker.play(text="你好主人,我是小智,请问有什么吩咐?")
|
||||
# 返回 True 唤醒小智 AI
|
||||
return True
|
||||
|
||||
if source == "kws" and text == "hi siri":
|
||||
# 播放唤醒提示语
|
||||
await speaker.play(text="I'm Siri, how can I help you?")
|
||||
# 唤醒小爱
|
||||
await speaker.wake_up()
|
||||
return False
|
||||
|
||||
if source == "xiaoai" and text == "召唤小智":
|
||||
# 打断原来的小爱同学
|
||||
await speaker.abort_xiaoai()
|
||||
@@ -60,8 +53,6 @@ APP_CONFIG = {
|
||||
"after_wakeup": after_wakeup,
|
||||
},
|
||||
"vad": {
|
||||
# 录音音量增强倍数(小爱音箱录音音量较小,需要后期放大一下)
|
||||
"boost": 10,
|
||||
# 语音检测阈值(0-1,越小越灵敏)
|
||||
"threshold": 0.10,
|
||||
# 最小语音时长(ms)
|
||||
@@ -72,8 +63,8 @@ APP_CONFIG = {
|
||||
"xiaozhi": {
|
||||
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
|
||||
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
|
||||
"WEBSOCKET_ACCESS_TOKEN": "(可选)一般用不到这个值",
|
||||
"DEVICE_ID": "(可选)默认自动生成",
|
||||
"VERIFICATION_CODE": "首次登陆时,验证码会在这里更新",
|
||||
"WEBSOCKET_ACCESS_TOKEN": "", #(可选)一般用不到这个值
|
||||
"DEVICE_ID": "", #(可选)默认自动生成
|
||||
"VERIFICATION_CODE": "", # 首次登陆时,验证码会在这里更新
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -1,8 +1,17 @@
|
||||
import asyncio
|
||||
|
||||
from config import APP_CONFIG
|
||||
from xiaozhi.ref import get_audio_codec, get_speaker, get_vad, get_xiaoai, get_xiaozhi
|
||||
from xiaozhi.ref import (
|
||||
get_audio_codec,
|
||||
get_kws,
|
||||
get_speaker,
|
||||
get_vad,
|
||||
get_xiaoai,
|
||||
get_xiaozhi,
|
||||
set_speech_frames,
|
||||
)
|
||||
from xiaozhi.services.protocols.typing import AbortReason, DeviceState, ListeningMode
|
||||
from xiaozhi.utils.base import get_env
|
||||
|
||||
|
||||
class Step:
|
||||
@@ -22,7 +31,7 @@ class __EventManager:
|
||||
self.next_step_future = None
|
||||
|
||||
def update_step(self, step: Step, step_data=None):
|
||||
if get_xiaoai().mode == "xiaozhi":
|
||||
if not get_env("CLI"):
|
||||
return
|
||||
|
||||
self.current_step = step
|
||||
@@ -32,7 +41,7 @@ class __EventManager:
|
||||
)
|
||||
self.next_step_future = None
|
||||
|
||||
async def wait_next_step(self, timeout=None) -> Step | None:
|
||||
async def wait_next_step(self, timeout=None):
|
||||
current_session = self.session_id
|
||||
|
||||
self.next_step_future = get_xiaoai().async_loop.create_future()
|
||||
@@ -70,16 +79,16 @@ class __EventManager:
|
||||
self.update_step(Step.on_wakeup)
|
||||
self.start_session()
|
||||
|
||||
def on_tts_end(self):
|
||||
def on_tts_end(self, session_id):
|
||||
"""TTS结束"""
|
||||
if self.current_step == Step.on_interrupt:
|
||||
if self.current_step in [Step.on_interrupt, Step.on_tts_end]:
|
||||
# 当前 session 已经被打断了,不再处理
|
||||
return
|
||||
self.session_id = self.session_id + 1
|
||||
self.update_step(Step.on_tts_end)
|
||||
self.start_session()
|
||||
|
||||
def on_tts_start(self):
|
||||
def on_tts_start(self, session_id):
|
||||
"""TTS结束"""
|
||||
self.update_step(Step.on_tts_start)
|
||||
|
||||
@@ -97,7 +106,7 @@ class __EventManager:
|
||||
)
|
||||
|
||||
async def __start_session(self):
|
||||
if get_xiaoai().mode == "xiaozhi":
|
||||
if not get_env("CLI"):
|
||||
return
|
||||
|
||||
vad = get_vad()
|
||||
@@ -113,11 +122,6 @@ class __EventManager:
|
||||
if self.current_step == Step.on_interrupt:
|
||||
return
|
||||
|
||||
# 尝试打开音频通道
|
||||
if not xiaozhi.protocol.is_audio_channel_opened():
|
||||
xiaozhi.set_device_state(DeviceState.CONNECTING)
|
||||
await xiaozhi.protocol.open_audio_channel()
|
||||
|
||||
# 等待 TTS 余音结束
|
||||
if self.current_step in [Step.on_tts_end]:
|
||||
vad.resume("silence")
|
||||
@@ -141,8 +145,9 @@ class __EventManager:
|
||||
return
|
||||
|
||||
# 开始说话
|
||||
set_speech_frames(speech_buffer)
|
||||
codec.input_stream.start_stream() # 开启录音
|
||||
await xiaozhi.protocol.send_start_listening(ListeningMode.MANUAL)
|
||||
codec.input_stream.input(speech_buffer) # 追加音频输入片段
|
||||
xiaozhi.set_device_state(DeviceState.LISTENING)
|
||||
|
||||
# 等待说话结束
|
||||
@@ -155,5 +160,13 @@ class __EventManager:
|
||||
await xiaozhi.protocol.send_stop_listening()
|
||||
xiaozhi.set_device_state(DeviceState.IDLE)
|
||||
|
||||
async def wakeup(self, text, source):
|
||||
before_wakeup = APP_CONFIG["wakeup"]["before_wakeup"]
|
||||
get_kws().pause() # 暂停 KWS 检测
|
||||
wakeup = await before_wakeup(get_speaker(), text, source)
|
||||
get_kws().resume() # 恢复 KWS 检测
|
||||
if wakeup:
|
||||
self.on_wakeup()
|
||||
|
||||
|
||||
EventManager = __EventManager()
|
||||
|
||||
@@ -49,3 +49,11 @@ def set_kws(kws: Any):
|
||||
|
||||
def get_kws() -> Any:
|
||||
return GLOBAL_STATES.get("kws")
|
||||
|
||||
|
||||
def set_speech_frames(speech_frames: Any):
|
||||
GLOBAL_STATES["speech_frames"] = speech_frames
|
||||
|
||||
|
||||
def get_speech_frames() -> Any:
|
||||
return GLOBAL_STATES.get("speech_frames")
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import opuslib_next as opuslib
|
||||
|
||||
from xiaozhi.ref import set_audio_codec
|
||||
from xiaozhi.ref import (
|
||||
get_speech_frames,
|
||||
get_xiaozhi,
|
||||
set_audio_codec,
|
||||
set_speech_frames,
|
||||
)
|
||||
from xiaozhi.services.audio.stream import MyAudio
|
||||
from xiaozhi.services.protocols.typing import AudioConfig
|
||||
from xiaozhi.utils.base import get_env
|
||||
|
||||
|
||||
class AudioCodec:
|
||||
@@ -36,11 +42,11 @@ class AudioCodec:
|
||||
|
||||
# 初始化音频输出流
|
||||
self.output_stream = self.audio.open(
|
||||
output=True,
|
||||
format=AudioConfig.FORMAT,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
rate=AudioConfig.SAMPLE_RATE,
|
||||
output=True,
|
||||
frames_per_buffer=AudioConfig.FRAME_SIZE,
|
||||
rate=get_xiaozhi().protocol.server_sample_rate,
|
||||
frames_per_buffer=get_xiaozhi().protocol.server_frame_size,
|
||||
output_device_index=MyAudio.get_output_device_index(self.audio),
|
||||
)
|
||||
|
||||
@@ -53,18 +59,36 @@ class AudioCodec:
|
||||
|
||||
# 初始化Opus解码器
|
||||
self.opus_decoder = opuslib.Decoder(
|
||||
fs=AudioConfig.SAMPLE_RATE, channels=AudioConfig.CHANNELS
|
||||
fs=get_xiaozhi().protocol.server_sample_rate,
|
||||
channels=AudioConfig.CHANNELS,
|
||||
)
|
||||
self.temp_frames = bytes([])
|
||||
|
||||
def read_audio(self):
|
||||
"""读取音频输入数据并编码"""
|
||||
try:
|
||||
speech_frames = get_speech_frames()
|
||||
|
||||
# 加入语音片段
|
||||
if speech_frames:
|
||||
self.temp_frames = speech_frames
|
||||
set_speech_frames([])
|
||||
|
||||
# 读取音频输入数据
|
||||
data = self.input_stream.read(
|
||||
AudioConfig.FRAME_SIZE, exception_on_overflow=False
|
||||
num_frames=None if get_env("CLI") else AudioConfig.FRAME_SIZE,
|
||||
exception_on_overflow=False,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
return self.opus_encoder.encode(data, AudioConfig.FRAME_SIZE)
|
||||
|
||||
self.temp_frames += data
|
||||
if len(self.temp_frames) < AudioConfig.FRAME_SIZE * 2:
|
||||
return None
|
||||
|
||||
opus_frames, remain_frames = self.encode_audio(self.temp_frames)
|
||||
self.temp_frames = remain_frames
|
||||
return opus_frames
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -76,24 +100,29 @@ class AudioCodec:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def decode_audio(self, opus_data, frame_size=AudioConfig.FRAME_SIZE):
|
||||
def decode_audio(self, opus_data):
|
||||
"""解码音频数据"""
|
||||
return self.opus_decoder.decode(opus_data, frame_size, decode_fec=False)
|
||||
return self.opus_decoder.decode(
|
||||
opus_data,
|
||||
frame_size=get_xiaozhi().protocol.server_frame_size,
|
||||
decode_fec=False,
|
||||
)
|
||||
|
||||
def encode_audio(self, buffer, frame_size=AudioConfig.FRAME_SIZE):
|
||||
def encode_audio(self, buffer: bytes, frame_size=AudioConfig.FRAME_SIZE):
|
||||
"""编码音频数据"""
|
||||
opus_frames = []
|
||||
remain_frames = bytes([])
|
||||
try:
|
||||
opus_frames = []
|
||||
for i in range(0, len(buffer), frame_size * 2):
|
||||
chunk = buffer[i : i + frame_size * 2]
|
||||
if len(chunk) < frame_size * 2:
|
||||
# 如果 buffer 长度不是 FRAME_SIZE 的 2 倍,需要补齐
|
||||
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
||||
remain_frames = chunk
|
||||
break
|
||||
opus_frame = self.opus_encoder.encode(chunk, frame_size)
|
||||
opus_frames.append(opus_frame)
|
||||
return opus_frames
|
||||
return opus_frames, remain_frames
|
||||
except Exception:
|
||||
return None
|
||||
return None, remain_frames
|
||||
|
||||
def start_streams(self):
|
||||
"""启动音频流"""
|
||||
|
||||
@@ -31,6 +31,7 @@ class _KWS:
|
||||
)
|
||||
|
||||
# 启动 KWS 服务
|
||||
self.paused = False
|
||||
self.thread = threading.Thread(target=self._detection_loop, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
@@ -38,6 +39,12 @@ class _KWS:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(current_dir, "../../../models", file_name)
|
||||
|
||||
def pause(self):
|
||||
self.paused = True
|
||||
|
||||
def resume(self):
|
||||
self.paused = False
|
||||
|
||||
def _detection_loop(self):
|
||||
SherpaOnnx.start()
|
||||
self.stream.start_stream()
|
||||
@@ -46,10 +53,15 @@ class _KWS:
|
||||
frames = self.stream.read()
|
||||
|
||||
# 在说话和监听状态时,暂停 KWS
|
||||
if not frames or get_xiaozhi().device_state in [
|
||||
DeviceState.LISTENING,
|
||||
DeviceState.SPEAKING,
|
||||
]:
|
||||
if (
|
||||
not frames
|
||||
or self.paused
|
||||
or get_xiaozhi().device_state
|
||||
in [
|
||||
DeviceState.LISTENING,
|
||||
DeviceState.SPEAKING,
|
||||
]
|
||||
):
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
@@ -60,14 +72,9 @@ class _KWS:
|
||||
|
||||
def on_message(self, text: str):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._on_message(text), get_xiaoai().async_loop
|
||||
EventManager.wakeup(text, "kws"),
|
||||
get_xiaoai().async_loop,
|
||||
)
|
||||
|
||||
async def _on_message(self, text: str):
|
||||
before_wakeup = APP_CONFIG["wakeup"]["before_wakeup"]
|
||||
wakeup = await before_wakeup(get_speaker(), text, "kws")
|
||||
if wakeup:
|
||||
EventManager.on_wakeup()
|
||||
|
||||
|
||||
KWS = _KWS()
|
||||
|
||||
@@ -9,10 +9,10 @@ class _SherpaOnnx:
|
||||
self.keyword_spotter = sherpa_onnx.KeywordSpotter(
|
||||
provider="cpu",
|
||||
num_threads=1,
|
||||
max_active_paths=4,
|
||||
max_active_paths=8,
|
||||
keywords_score=2.0,
|
||||
keywords_threshold=0.2,
|
||||
num_trailing_blanks=1,
|
||||
num_trailing_blanks=0,
|
||||
keywords_file=get_model_file_path("keywords.txt"),
|
||||
tokens=get_model_file_path("tokens.txt"),
|
||||
encoder=get_model_file_path("encoder.onnx"),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import uuid
|
||||
from typing import Any, Callable, ClassVar, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import APP_CONFIG
|
||||
from xiaozhi.ref import get_xiaoai
|
||||
|
||||
|
||||
@@ -74,7 +71,7 @@ class MyStream:
|
||||
self._is_active = False
|
||||
if self._is_input:
|
||||
GlobalStream.unregister_reader(self)
|
||||
self.input_bytes = []
|
||||
self.input_bytes.clear()
|
||||
|
||||
def write(self, frames: bytes) -> None:
|
||||
# 发送输出音频流到扬声器
|
||||
@@ -88,15 +85,12 @@ class MyStream:
|
||||
return
|
||||
|
||||
if len(data) > 0:
|
||||
samples = np.frombuffer(data, dtype=np.int16)
|
||||
# 小爱音箱录音音量较小,需要后期放大一下
|
||||
samples = samples * APP_CONFIG["vad"]["boost"]
|
||||
self.input_bytes.extend(samples.tobytes())
|
||||
self.input_bytes.extend(data)
|
||||
|
||||
def read(self, num_frames=None, exception_on_overflow=False) -> bytes:
|
||||
if num_frames is None:
|
||||
data = bytes(self.input_bytes)
|
||||
self.input_bytes = []
|
||||
self.input_bytes.clear()
|
||||
return data
|
||||
|
||||
num_frames = num_frames * 2
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import APP_CONFIG
|
||||
from xiaozhi.event import EventManager
|
||||
from xiaozhi.ref import set_vad
|
||||
from xiaozhi.services.audio.stream import MyAudio
|
||||
from xiaozhi.services.audio.vad.silero import VAD_MODEL
|
||||
from xiaozhi.services.audio.vad.silero import Silero
|
||||
from xiaozhi.services.protocols.typing import AudioConfig
|
||||
from xiaozhi.utils.base import get_env
|
||||
|
||||
|
||||
class _VAD:
|
||||
"""基于WebRTC VAD的语音活动检测器,用于检测用户打断"""
|
||||
|
||||
def __init__(self):
|
||||
set_vad(self)
|
||||
|
||||
@@ -33,36 +29,30 @@ class _VAD:
|
||||
self.speech_count = 0
|
||||
self.silence_count = 0
|
||||
|
||||
# 创建独立的PyAudio实例和流,避免与主音频流冲突
|
||||
self.audio = None
|
||||
self.stream = None
|
||||
|
||||
# 暂存的语音片段
|
||||
self.temp_frames = []
|
||||
self.speech_buffer = []
|
||||
self.silence_frames = [] # 静音片段
|
||||
self.speech_frames = [] # 语音片段
|
||||
self.target = None # 检测目标 speech/silence
|
||||
|
||||
def _reset_state(self):
|
||||
"""重置状态"""
|
||||
self.speech_count = 0
|
||||
self.silence_count = 0
|
||||
self.speech_buffer = []
|
||||
self.temp_frames = []
|
||||
self.speech_frames = []
|
||||
self.silence_frames = []
|
||||
|
||||
def start(self):
|
||||
"""启动VAD检测器"""
|
||||
if not get_env("CLI"):
|
||||
return
|
||||
|
||||
if self.thread and self.thread.is_alive():
|
||||
return
|
||||
|
||||
self.paused = False
|
||||
|
||||
# 初始化PyAudio和流
|
||||
self._initialize_audio_stream()
|
||||
|
||||
# 启动检测线程
|
||||
self.paused = False
|
||||
self.thread = threading.Thread(target=self._detection_loop, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
@@ -88,9 +78,16 @@ class _VAD:
|
||||
"""处理语音帧"""
|
||||
self.speech_count += len(frames)
|
||||
self.silence_count = 0
|
||||
self.speech_buffer.extend(frames)
|
||||
|
||||
speech_bytes = bytes(self.speech_buffer)
|
||||
if self.target == "speech":
|
||||
if not self.speech_frames:
|
||||
# 加入静音片段(潜在的语音片段)
|
||||
self.speech_frames.extend(self.silence_frames)
|
||||
|
||||
# 加入语音片段
|
||||
self.speech_frames.extend(frames)
|
||||
|
||||
speech_bytes = bytes(self.speech_frames)
|
||||
|
||||
if (
|
||||
self.target == "speech"
|
||||
@@ -103,7 +100,18 @@ class _VAD:
|
||||
"""处理静音帧"""
|
||||
self.silence_count += len(frames)
|
||||
self.speech_count = 0
|
||||
self.speech_buffer = []
|
||||
|
||||
if self.target == "speech":
|
||||
if not self.speech_frames:
|
||||
# 如果之前没有语音片段,则将当前帧加入静音片段
|
||||
self.silence_frames.extend(frames)
|
||||
# 确保静音片段长度不超过 1s
|
||||
self.silence_frames = self.silence_frames[
|
||||
-1 * 1 * 2 * self.sample_rate :
|
||||
]
|
||||
else:
|
||||
# 如果之前有语音片段,则将当前帧加入语音片段
|
||||
self.speech_frames.extend(frames)
|
||||
|
||||
if (
|
||||
self.target == "silence"
|
||||
@@ -112,17 +120,6 @@ class _VAD:
|
||||
self.pause()
|
||||
EventManager.on_silence()
|
||||
|
||||
def _detect_speech(self, frames):
|
||||
"""检测是否是语音"""
|
||||
try:
|
||||
audio_int16 = np.frombuffer(frames, dtype=np.int16)
|
||||
audio_float32 = audio_int16.astype(np.float32) / 32768.0
|
||||
speech_prob = VAD_MODEL(audio_float32, 16000).item()
|
||||
is_speech = speech_prob >= self.threshold
|
||||
return is_speech
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _initialize_audio_stream(self):
|
||||
"""初始化独立的音频流"""
|
||||
try:
|
||||
@@ -171,7 +168,8 @@ class _VAD:
|
||||
continue
|
||||
|
||||
# 检测是否是语音
|
||||
is_speech = self._detect_speech(frames)
|
||||
speech_prob = Silero.vad(frames, self.sample_rate) or 0
|
||||
is_speech = speech_prob >= self.threshold
|
||||
if is_speech:
|
||||
self._handle_speech_frame(frames)
|
||||
else:
|
||||
|
||||
@@ -84,6 +84,19 @@ class OnnxWrapper:
|
||||
return out
|
||||
|
||||
|
||||
VAD_MODEL = OnnxWrapper(
|
||||
path=get_model_file_path("silero_vad.onnx"),
|
||||
)
|
||||
class _Silero:
|
||||
def __init__(self) -> None:
|
||||
self.model = OnnxWrapper(
|
||||
path=get_model_file_path("silero_vad.onnx"),
|
||||
)
|
||||
|
||||
def vad(self, frames, sample_rate):
|
||||
try:
|
||||
audio_int16 = np.frombuffer(frames, dtype=np.int16)
|
||||
audio_float32 = audio_int16.astype(np.float32) / 32768.0
|
||||
return self.model(audio_float32, sample_rate).item()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
Silero = _Silero()
|
||||
|
||||
@@ -24,7 +24,7 @@ class EventType:
|
||||
class AudioConfig:
|
||||
"""音频配置"""
|
||||
FORMAT = 8
|
||||
SAMPLE_RATE = 24000
|
||||
SAMPLE_RATE = 16000
|
||||
CHANNELS = 1
|
||||
FRAME_DURATION = 60 # ms
|
||||
FRAME_SIZE = int(SAMPLE_RATE * (FRAME_DURATION / 1000))
|
||||
|
||||
@@ -3,7 +3,9 @@ import json
|
||||
|
||||
import websockets
|
||||
|
||||
from xiaozhi.ref import get_xiaozhi
|
||||
from xiaozhi.services.protocols.protocol import Protocol
|
||||
from xiaozhi.services.protocols.typing import DeviceState
|
||||
from xiaozhi.utils.config import ConfigManager
|
||||
|
||||
|
||||
@@ -13,7 +15,11 @@ class WebsocketProtocol(Protocol):
|
||||
# 获取配置管理器实例
|
||||
self.config = ConfigManager.instance()
|
||||
self.websocket = None
|
||||
self.server_sample_rate = 16000
|
||||
self.server_sample_rate = 24000
|
||||
self.server_frame_duration = 60
|
||||
self.server_frame_size = int(
|
||||
self.server_sample_rate * (self.server_frame_duration / 1000)
|
||||
)
|
||||
self.connected = False
|
||||
self.hello_received = None # 初始化时先设为 None
|
||||
self.WEBSOCKET_URL = self.config.get_config("NETWORK.WEBSOCKET_URL")
|
||||
@@ -23,9 +29,20 @@ class WebsocketProtocol(Protocol):
|
||||
self.CLIENT_ID = self.config.get_client_id()
|
||||
self.DEVICE_ID = self.config.get_device_id()
|
||||
|
||||
async def _close_websocket(self):
|
||||
if self.websocket:
|
||||
try:
|
||||
await self.websocket.close()
|
||||
self.websocket = None
|
||||
self.connected = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""连接到WebSocket服务器"""
|
||||
try:
|
||||
await self._close_websocket()
|
||||
|
||||
# 在连接时创建 Event,确保在正确的事件循环中
|
||||
self.hello_received = asyncio.Event()
|
||||
|
||||
@@ -68,7 +85,6 @@ class WebsocketProtocol(Protocol):
|
||||
if self.on_network_error:
|
||||
self.on_network_error("等待响应超时")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
if self.on_network_error:
|
||||
self.on_network_error(f"无法连接服务: {str(e)}")
|
||||
@@ -91,25 +107,21 @@ class WebsocketProtocol(Protocol):
|
||||
pass
|
||||
elif self.on_incoming_audio:
|
||||
self.on_incoming_audio(message)
|
||||
except websockets.ConnectionClosed:
|
||||
except Exception:
|
||||
self.connected = False
|
||||
if self.on_audio_channel_closed:
|
||||
await self.on_audio_channel_closed()
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
if self.on_network_error:
|
||||
self.on_network_error(f"连接错误: {str(e)}")
|
||||
|
||||
async def send_audio(self, data: bytes):
|
||||
async def send_audio(self, frames: list[bytes]):
|
||||
"""发送音频数据"""
|
||||
if not self.is_audio_channel_opened(): # 使用已有的 is_connected 方法
|
||||
return
|
||||
|
||||
try:
|
||||
await self.websocket.send(data)
|
||||
except Exception as e:
|
||||
if self.on_network_error:
|
||||
self.on_network_error(f"发送音频失败: {str(e)}")
|
||||
for frame in frames:
|
||||
await self.websocket.send(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_text(self, message: str):
|
||||
"""发送文本消息"""
|
||||
@@ -117,24 +129,18 @@ class WebsocketProtocol(Protocol):
|
||||
try:
|
||||
await self.websocket.send(message)
|
||||
except Exception as e:
|
||||
await self.close_audio_channel()
|
||||
if self.on_network_error:
|
||||
self.on_network_error(f"发送消息失败: {str(e)}")
|
||||
|
||||
raise e
|
||||
def is_audio_channel_opened(self) -> bool:
|
||||
"""检查音频通道是否打开"""
|
||||
return self.websocket is not None and self.connected
|
||||
|
||||
async def open_audio_channel(self) -> bool:
|
||||
"""建立 WebSocket 连接
|
||||
_is_heartbeat_running = False
|
||||
|
||||
如果尚未连接,则创建新的 WebSocket 连接
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
if not self.connected:
|
||||
return await self.connect()
|
||||
return True
|
||||
async def open_audio_channel(self):
|
||||
if not self._is_heartbeat_running:
|
||||
self._is_heartbeat_running = True
|
||||
asyncio.create_task(self.heartbeat())
|
||||
await self.connect()
|
||||
|
||||
async def _handle_server_hello(self, data: dict):
|
||||
"""处理服务器的 hello 消息
|
||||
@@ -150,13 +156,22 @@ class WebsocketProtocol(Protocol):
|
||||
if not transport or transport != "websocket":
|
||||
return
|
||||
|
||||
# TODO 使用默认的 24k 采样率
|
||||
# xiaozhi-esp32-server 返回的参数是 16k 采样率,但实际用的是 24k 采样率
|
||||
|
||||
# 获取音频参数
|
||||
audio_params = data.get("audio_params")
|
||||
if audio_params:
|
||||
# 获取服务器的采样率
|
||||
sample_rate = audio_params.get("sample_rate")
|
||||
if sample_rate:
|
||||
self.server_sample_rate = sample_rate
|
||||
# audio_params = data.get("audio_params")
|
||||
# if audio_params:
|
||||
# # 获取服务器的采样率
|
||||
# sample_rate = audio_params.get("sample_rate")
|
||||
# if sample_rate:
|
||||
# self.server_sample_rate = sample_rate
|
||||
# frame_duration = audio_params.get("frame_duration")
|
||||
# if frame_duration:
|
||||
# self.server_frame_duration = frame_duration
|
||||
# self.server_frame_size = int(
|
||||
# self.server_sample_rate * (self.server_frame_duration / 1000)
|
||||
# )
|
||||
|
||||
# 设置 hello 接收事件
|
||||
self.hello_received.set()
|
||||
@@ -180,3 +195,15 @@ class WebsocketProtocol(Protocol):
|
||||
await self.on_audio_channel_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def heartbeat(self):
|
||||
while True:
|
||||
if self.websocket and get_xiaozhi().device_state == DeviceState.IDLE:
|
||||
try:
|
||||
await self.send_text(
|
||||
json.dumps({"session_id": "", "type": "ping"})
|
||||
)
|
||||
except Exception:
|
||||
# 发送心跳失败,重新连接
|
||||
await self.open_audio_channel()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -5,7 +5,6 @@ import threading
|
||||
import numpy as np
|
||||
import open_xiaoai_server
|
||||
|
||||
from config import APP_CONFIG
|
||||
from xiaozhi.event import EventManager
|
||||
from xiaozhi.ref import get_speaker, set_xiaoai
|
||||
from xiaozhi.services.audio.stream import GlobalStream
|
||||
@@ -69,12 +68,6 @@ class XiaoAI:
|
||||
event_data = event_json.get("data", {})
|
||||
event_type = event_json.get("event")
|
||||
|
||||
async def on_wakeup(text, source):
|
||||
before_wakeup = APP_CONFIG["wakeup"]["before_wakeup"]
|
||||
wakeup = await before_wakeup(get_speaker(), text, source)
|
||||
if wakeup:
|
||||
EventManager.on_wakeup()
|
||||
|
||||
if not event_json.get("event"):
|
||||
return
|
||||
|
||||
@@ -87,11 +80,11 @@ class XiaoAI:
|
||||
):
|
||||
text = line.get("payload", {}).get("results")[0].get("text")
|
||||
if not text and not line.get("payload", {}).get("is_vad_begin"):
|
||||
print(f"🔥 唤醒小爱: {line}")
|
||||
print("🔥 唤醒小爱")
|
||||
EventManager.on_interrupt()
|
||||
elif text and line.get("payload", {}).get("is_final"):
|
||||
print(f"🔥 收到指令: {text}")
|
||||
await on_wakeup(text, "xiaoai")
|
||||
await EventManager.wakeup(text, "xiaoai")
|
||||
elif event_type == "playing":
|
||||
get_speaker().status = event_data.lower()
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from xiaozhi.services.audio.kws import KWS
|
||||
from xiaozhi.services.audio.vad import VAD
|
||||
from xiaozhi.services.protocols.typing import (
|
||||
AbortReason,
|
||||
AudioConfig,
|
||||
DeviceState,
|
||||
EventType,
|
||||
ListeningMode,
|
||||
@@ -88,9 +87,9 @@ class XiaoZhi:
|
||||
# 等待事件循环准备就绪
|
||||
time.sleep(0.1)
|
||||
|
||||
# 初始化应用程序(移除自动连接)
|
||||
# 初始化应用程序
|
||||
asyncio.run_coroutine_threadsafe(XiaoAI.init_xiaoai(), self.loop)
|
||||
asyncio.run_coroutine_threadsafe(self._initialize_without_connect(), self.loop)
|
||||
asyncio.run_coroutine_threadsafe(self._initialize_xiaozhi(), self.loop)
|
||||
|
||||
# 启动主循环线程
|
||||
main_loop_thread = threading.Thread(target=self._main_loop)
|
||||
@@ -109,8 +108,8 @@ class XiaoZhi:
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_forever()
|
||||
|
||||
async def _initialize_without_connect(self):
|
||||
"""初始化应用程序组件(不建立连接)"""
|
||||
async def _initialize_xiaozhi(self):
|
||||
"""初始化应用程序组件"""
|
||||
|
||||
# 初始化音频编解码器
|
||||
self._initialize_audio()
|
||||
@@ -122,8 +121,9 @@ class XiaoZhi:
|
||||
self.protocol.on_audio_channel_opened = self._on_audio_channel_opened
|
||||
self.protocol.on_audio_channel_closed = self._on_audio_channel_closed
|
||||
|
||||
# 设置设备状态为待命
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
# 打开音频通道
|
||||
self.device_state = DeviceState.CONNECTING
|
||||
await self.protocol.open_audio_channel()
|
||||
|
||||
def _initialize_audio(self):
|
||||
"""初始化音频设备和编解码器"""
|
||||
@@ -134,6 +134,8 @@ class XiaoZhi:
|
||||
except Exception as e:
|
||||
self.alert("错误", f"初始化音频设备失败: {e}")
|
||||
|
||||
threading.Thread(target=self._audio_input_event_trigger, daemon=True).start()
|
||||
|
||||
def _initialize_display(self):
|
||||
"""初始化显示界面"""
|
||||
if get_env("CLI"):
|
||||
@@ -220,50 +222,6 @@ class XiaoZhi:
|
||||
self.protocol.close_audio_channel(), self.loop
|
||||
)
|
||||
|
||||
def _attempt_reconnect(self):
|
||||
"""尝试重新连接服务器"""
|
||||
if self.device_state != DeviceState.CONNECTING:
|
||||
self.set_device_state(DeviceState.CONNECTING)
|
||||
|
||||
# 关闭现有连接
|
||||
if self.protocol:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.protocol.close_audio_channel(), self.loop
|
||||
)
|
||||
|
||||
# 延迟一秒后尝试重新连接
|
||||
def delayed_reconnect():
|
||||
time.sleep(1)
|
||||
asyncio.run_coroutine_threadsafe(self._reconnect(), self.loop)
|
||||
|
||||
threading.Thread(target=delayed_reconnect, daemon=True).start()
|
||||
|
||||
async def _reconnect(self):
|
||||
"""重新连接到服务器"""
|
||||
|
||||
# 设置协议回调
|
||||
self.protocol.on_network_error = self._on_network_error
|
||||
self.protocol.on_incoming_audio = self._on_incoming_audio
|
||||
self.protocol.on_incoming_json = self._on_incoming_json
|
||||
self.protocol.on_audio_channel_opened = self._on_audio_channel_opened
|
||||
self.protocol.on_audio_channel_closed = self._on_audio_channel_closed
|
||||
|
||||
# 连接到服务器
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
|
||||
while retry_count < max_retries:
|
||||
if await self.protocol.connect():
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
return True
|
||||
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2) # 等待2秒后重试
|
||||
|
||||
self.schedule(lambda: self.alert("连接错误", "无法重新连接到服务器"))
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
return False
|
||||
|
||||
def _on_incoming_audio(self, data):
|
||||
"""接收音频数据回调"""
|
||||
if self.device_state == DeviceState.SPEAKING:
|
||||
@@ -296,20 +254,22 @@ class XiaoZhi:
|
||||
"""处理TTS消息"""
|
||||
state = data.get("state", "")
|
||||
if state == "start":
|
||||
EventManager.on_tts_start()
|
||||
EventManager.on_tts_start(data.get("session_id"))
|
||||
self.schedule(lambda: self._handle_tts_start())
|
||||
elif state == "stop":
|
||||
EventManager.on_tts_end()
|
||||
EventManager.on_tts_end(data.get("session_id"))
|
||||
self.schedule(lambda: self._handle_tts_stop())
|
||||
elif state == "sentence_start":
|
||||
text = data.get("text", "")
|
||||
if text:
|
||||
print(f"🤖 小智:{text}")
|
||||
|
||||
verification_code = re.findall(r"验证码.*\d+", text)
|
||||
verification_code = re.search(r"验证码.*?(\d+)", text) or re.search(
|
||||
r"控制面板.*?(\d+)", text
|
||||
)
|
||||
if verification_code:
|
||||
self.config.update_config_file(
|
||||
"VERIFICATION_CODE", verification_code[0]
|
||||
"VERIFICATION_CODE", verification_code.group(1)
|
||||
)
|
||||
|
||||
self.schedule(lambda: self.set_chat_message("assistant", text))
|
||||
@@ -341,45 +301,14 @@ class XiaoZhi:
|
||||
|
||||
async def _on_audio_channel_opened(self):
|
||||
"""音频通道打开回调"""
|
||||
self.schedule(lambda: self._start_audio_streams())
|
||||
|
||||
def _start_audio_streams(self):
|
||||
"""启动音频流"""
|
||||
try:
|
||||
# 确保流已关闭后再重新打开
|
||||
if self.audio_codec.input_stream.is_active():
|
||||
self.audio_codec.input_stream.stop_stream()
|
||||
|
||||
# 重新打开流
|
||||
self.audio_codec.input_stream.start_stream()
|
||||
|
||||
if self.audio_codec.output_stream.is_active():
|
||||
self.audio_codec.output_stream.stop_stream()
|
||||
|
||||
# 重新打开流
|
||||
self.audio_codec.output_stream.start_stream()
|
||||
|
||||
# 设置事件触发器
|
||||
threading.Thread(
|
||||
target=self._audio_input_event_trigger, daemon=True
|
||||
).start()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
|
||||
def _audio_input_event_trigger(self):
|
||||
"""音频输入事件触发器"""
|
||||
while self.running:
|
||||
try:
|
||||
if self.audio_codec.input_stream.is_active():
|
||||
self.events[EventType.AUDIO_INPUT_READY_EVENT].set()
|
||||
except OSError as e:
|
||||
if "Stream not open" in str(e):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(AudioConfig.FRAME_DURATION / 1000) # 按帧时长触发
|
||||
if self.audio_codec.input_stream.is_active():
|
||||
self.events[EventType.AUDIO_INPUT_READY_EVENT].set()
|
||||
time.sleep(0.01)
|
||||
|
||||
async def _on_audio_channel_closed(self):
|
||||
"""音频通道关闭回调"""
|
||||
@@ -492,26 +421,10 @@ class XiaoZhi:
|
||||
self.protocol.send_abort_speaking(AbortReason.ABORT),
|
||||
self.loop,
|
||||
)
|
||||
|
||||
# 尝试打开音频通道
|
||||
if not self.protocol.is_audio_channel_opened():
|
||||
self.set_device_state(DeviceState.CONNECTING) # 设置设备状态为连接中
|
||||
try:
|
||||
# 等待异步操作完成
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.protocol.open_audio_channel(), self.loop
|
||||
)
|
||||
# 等待操作完成并获取结果
|
||||
assert future.result(timeout=10.0) # 添加超时时间
|
||||
except Exception as e:
|
||||
self.alert("错误", f"打开音频通道失败: {str(e)}")
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
return
|
||||
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.protocol.send_start_listening(ListeningMode.MANUAL), self.loop
|
||||
)
|
||||
self.set_device_state(DeviceState.LISTENING) # 设置设备状态为监听中
|
||||
self.set_device_state(DeviceState.LISTENING)
|
||||
|
||||
def stop_listening(self):
|
||||
"""停止监听"""
|
||||
@@ -519,8 +432,8 @@ class XiaoZhi:
|
||||
|
||||
def _stop_listening_impl(self):
|
||||
"""停止监听的实现"""
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
asyncio.run_coroutine_threadsafe(self.protocol.send_stop_listening(), self.loop)
|
||||
self.set_device_state(DeviceState.IDLE)
|
||||
|
||||
def abort_speaking(self, reason):
|
||||
"""中止语音输出"""
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# 你的小米账号/密码
|
||||
#你的小米账号/密码
|
||||
MI_USER=23333333
|
||||
MI_PASS=xxxxxxxxx
|
||||
|
||||
# 你的小爱音箱名称/DID
|
||||
#你的小爱音箱名称/DID
|
||||
MI_DID=小爱音箱Pro
|
||||
|
||||
# 你的 SSH 登录密码(默认为 open-xiaoai)
|
||||
#你的 SSH 登录密码,默认为 open-xiaoai
|
||||
SSH_PASSWORD=open-xiaoai
|
||||
|
||||
#账号登录异常时,请取消下面的注释,使用 MI_TOKEN 登录
|
||||
#MI_TOKEN=你的 passToken 获取教程 👉 https://github.com/idootop/migpt-next/issues/4
|
||||
|
||||
# 获取设备异常时,请取消下面的注释,查看设备列表详情
|
||||
#MI_DEBUG=true
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& apk add --no-cache \
|
||||
bash \
|
||||
file \
|
||||
patch \
|
||||
python3 \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> 刷机有风险,操作需谨慎。请勿下载使用不明来历的固件!
|
||||
|
||||
小爱音箱 Pro 补丁固件制作流程:
|
||||
|
||||
- 固件提取(登录小米账号获取 OTA 链接)
|
||||
- 开启固化 SSH(支持自定义登录密码)
|
||||
- 禁用系统自动更新(系统更新后需要重新刷机打补丁)
|
||||
@@ -12,31 +13,31 @@
|
||||
## 下载固件
|
||||
|
||||
你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件:
|
||||
- [Xiaomi 智能音箱 Pro v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
|
||||
- [小爱音箱 Pro v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
|
||||
|
||||
- [Xiaomi 智能音箱 Pro v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
|
||||
- [小爱音箱 Pro v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
|
||||
|
||||
> [!TIP]
|
||||
> 里面有两个文件,下载 `patched` 那个:
|
||||
>
|
||||
> - `xxx_patched.squashfs` 打补丁后的固件
|
||||
> - `xxx.squashfs` 原版固件(可用来刷回原系统)
|
||||
|
||||
> [!NOTE]
|
||||
> 默认 SSH 登录密码为 `open-xiaoai`,如需修改请自行制作固件。
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请下载和你当前小爱音箱版本一致的固件,跨版本刷机可能会出现未知错误,导致设备变砖。
|
||||
> 如果上面没有你的版本,请升级设备固件到最新版本,或者按照下面的教程自行制作固件。
|
||||
|
||||
> [!CAUTION]
|
||||
> 当前支持的最新固件版本为:
|
||||
> - Xiaomi 智能音箱 Pro 👉 [v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
|
||||
> - 小爱音箱 Pro 👉 [v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
|
||||
>
|
||||
> - Xiaomi 智能音箱 Pro 👉 [v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
|
||||
> - 小爱音箱 Pro 👉 [v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
|
||||
>
|
||||
> 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。
|
||||
|
||||
|
||||
|
||||
## 制作固件
|
||||
|
||||
你可以按照下面的 2 种方法,制作自定义固件。
|
||||
@@ -66,13 +67,25 @@ SSH_PASSWORD=open-xiaoai
|
||||
- Docker:https://www.docker.com/get-started/
|
||||
|
||||
> [!NOTE]
|
||||
> Windows 系统推荐使用 [Git Bash](https://git-scm.com/downloads) 终端运行。
|
||||
>
|
||||
> CMD 和 PowerShell 终端需要调整下面命令中文件(夹)的实际路径。
|
||||
> Windows 系统请在 [Git Bash](https://git-scm.com/downloads) 终端中运行以下命令。
|
||||
|
||||
> [!TIP]
|
||||
> 如果你是 Apple Silicon 芯片,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
|
||||
```shell
|
||||
# 克隆代码
|
||||
git clone https://github.com/idootop/open-xiaoai.git
|
||||
|
||||
# 进入当前项目根目录
|
||||
cd packages/client-patch
|
||||
|
||||
# 使用 Docker 进行构建
|
||||
docker run -it --rm --env-file $(pwd)/.env -v $(pwd)/assets:/app/assets -v $(pwd)/patches:/app/patches idootop/open-xiaoai:latest
|
||||
docker run -it --rm \
|
||||
--platform linux/amd64 \
|
||||
--env-file $(pwd)/.env \
|
||||
-v $(pwd)/assets:/app/assets \
|
||||
-v $(pwd)/patches:/app/patches \
|
||||
idootop/open-xiaoai:latest
|
||||
|
||||
# ✅ 打包完成,固件文件已复制到 assets 目录...
|
||||
# /app/assets/mico_all_92db90ed6_1.88.197/root-patched.squashfs
|
||||
@@ -86,6 +99,12 @@ docker run -it --rm --env-file $(pwd)/.env -v $(pwd)/assets:/app/assets -v $(pwd
|
||||
- Node.js 22.x: https://nodejs.org/zh-cn/download
|
||||
|
||||
```bash
|
||||
# 克隆代码
|
||||
git clone https://github.com/idootop/open-xiaoai.git
|
||||
|
||||
# 进入当前项目根目录
|
||||
cd packages/client-patch
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
@@ -107,7 +126,7 @@ npm run build
|
||||
示例:
|
||||
|
||||
```bash
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
/usr/sbin/tts_play.sh '初始化成功'
|
||||
```
|
||||
```
|
||||
|
||||
Generated
+20
-20
@@ -8,11 +8,11 @@
|
||||
"name": "open-xiaoai-patch",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@mi-gpt/miot": "^1.0.0"
|
||||
"@mi-gpt/miot": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mi-gpt/config": "1.0.0",
|
||||
"@types/node": "^22.13.11",
|
||||
"@mi-gpt/config": "1.1.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
@@ -443,9 +443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/config": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.0.0.tgz",
|
||||
"integrity": "sha512-oo/roYiRcGiHsZESjukwPTsRFqddShTJ8gkccR5ZMYn8SySj07A0nCsEsLEfB9mLkvNY8D6PCO1NxbRDDRBBlA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.1.0.tgz",
|
||||
"integrity": "sha512-o24iFINsoDuvInnMpfBQ4zCuTObbdodjXyizXmxl3yyYOMjmIAXizO/1UZ9/dv3UVTov3sjL09aCGuBeIVhvtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -453,12 +453,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/miot": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.0.0.tgz",
|
||||
"integrity": "sha512-smWhddHzK1du3/LW2FifBrIIyeZNx/RvTSe0Zi4v3lYZwK8NeEwomg1jBjktQCCV4Fh8OuqNbCdNbmiVXmr5kw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.2.1.tgz",
|
||||
"integrity": "sha512-DAFNUsLPepXSqJk/b+zfct8NCQHZzcl7f1t4X2WPKzxmDNagkawYlb96uPbk4syvn+DSg6vqle0Wm+gjmJ4kUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mi-gpt/utils": "1.0.0",
|
||||
"@mi-gpt/utils": "1.1.1",
|
||||
"axios": "^1.8.4",
|
||||
"pako": "^2.1.0"
|
||||
},
|
||||
@@ -467,22 +467,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/utils": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.0.0.tgz",
|
||||
"integrity": "sha512-hAU/uYPNiNJgXWkQrVqRLe/9Uz8AgtoiNwcbbwQs8iXJcyq3NjcSHkkwUseJAnaQFE9iv/kvHZZsx3r2HhlKjw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.1.1.tgz",
|
||||
"integrity": "sha512-FiGHV2RDBRGE/Xb9Q5H757t3ZqOpo6gL+44VXiRxQb3fQiapOpn038RAWErHJYZajYzkxP2jl8Wggsb+wp77Zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
@@ -883,9 +883,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"ota": "tsx --env-file=.env src/ota.ts",
|
||||
"extract": "sh src/extract.sh",
|
||||
"patch": "sh src/patch.sh",
|
||||
"squashfs": "sh src/squashfs.sh",
|
||||
"build": "sh src/build.sh"
|
||||
"extract": "bash src/extract.sh",
|
||||
"patch": "bash src/patch.sh",
|
||||
"squashfs": "bash src/squashfs.sh",
|
||||
"build": "bash src/build.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mi-gpt/miot": "^1.0.0"
|
||||
"@mi-gpt/miot": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mi-gpt/config": "1.0.0",
|
||||
"@types/node": "^22.13.11",
|
||||
"@mi-gpt/config": "1.1.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
# Put your custom commands here that should be executed once
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -13,36 +13,18 @@ const kSupportedDevices = [
|
||||
// 获取 OTA 信息
|
||||
async function getOTA(channel: "release" | "current" | "stable" = "release") {
|
||||
const MiNA = await getMiNA({
|
||||
did: process.env.MI_DID!,
|
||||
userId: process.env.MI_USER!,
|
||||
password: process.env.MI_PASS!,
|
||||
passToken: process.env.MI_TOKEN,
|
||||
debug: !!process.env.MI_DEBUG,
|
||||
});
|
||||
if (!MiNA) {
|
||||
console.log(`❌ 登录失败,请检查小米账号密码是否正确:`);
|
||||
console.log(`当前账号:${process.env.MI_USER}`);
|
||||
console.log(`当前密码:${process.env.MI_PASS}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await MiNA.getDevices();
|
||||
const speaker = devices?.find(
|
||||
(e: any) =>
|
||||
e.name === process.env.MI_DID || e.miotDID === process.env.MI_DID
|
||||
);
|
||||
if (!speaker) {
|
||||
console.log(`❌ 找不到设备: ${process.env.MI_DID}`);
|
||||
console.log(`可用设备列表:`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
devices.map((e: any) => ({
|
||||
name: e.name,
|
||||
did: e.miotDID,
|
||||
})),
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const speaker = MiNA.account.device as any;
|
||||
|
||||
if (!kSupportedDevices.includes(speaker.hardware)) {
|
||||
console.log(
|
||||
@@ -65,7 +47,7 @@ async function getOTA(channel: "release" | "current" | "stable" = "release") {
|
||||
}
|
||||
|
||||
const model = speaker.hardware;
|
||||
const time = new Date().getTime();
|
||||
const time = Date.now();
|
||||
const sn = process.env.DEBUG_VERSION ? "" : speaker.serialNumber;
|
||||
const version = process.env.DEBUG_VERSION ?? speaker.romVersion;
|
||||
const otaInfo = `channel=${channel}&filterID=${sn}&locale=zh_CN&model=${model}&time=${time}&version=${version}&8007236f-a2d6-4847-ac83-c49395ad6d65`;
|
||||
@@ -88,7 +70,7 @@ async function main() {
|
||||
} else {
|
||||
ota = await getOTA();
|
||||
}
|
||||
if (!ota.url) {
|
||||
if (!ota?.url) {
|
||||
console.log(`❌ 获取设备信息失败`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -22,13 +22,13 @@ apply_patches() {
|
||||
# 创建临时文件用于占位符替换
|
||||
local temp_patch=$(mktemp)
|
||||
# 将补丁文件中的 {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"
|
||||
# 清理临时文件
|
||||
rm "$temp_patch"
|
||||
elif [[ "$file" == *.sh ]]; then
|
||||
sh "$file"
|
||||
bash "$file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -40,14 +40,20 @@ elif [ "$MODEL" = "LX06" ]; then
|
||||
IMAGE_MAX_SIZE=$((0x02800000))
|
||||
fi
|
||||
|
||||
SIZE=`stat -L -c %s $FIRMWARE/root-patched.squashfs`
|
||||
echo "📊 当前固件大小: $SIZE 字节"
|
||||
SIZE=$(stat -L -c %s "$FIRMWARE/root-patched.squashfs")
|
||||
SIZE_MB=$((SIZE / 1024 / 1024))
|
||||
IMAGE_MAX_SIZE_MB=$((IMAGE_MAX_SIZE / 1024 / 1024))
|
||||
|
||||
echo "📊 当前固件大小: $SIZE 字节 ($SIZE_MB MB)"
|
||||
|
||||
if [ "$SIZE" -ge "$IMAGE_MAX_SIZE" ]; then
|
||||
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节"
|
||||
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节 ($IMAGE_MAX_SIZE_MB MB)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 固件大小检查通过,剩余空间: $((IMAGE_MAX_SIZE - SIZE)) 字节"
|
||||
REMAINING_BYTES=$((IMAGE_MAX_SIZE - SIZE))
|
||||
REMAINING_MB=$((REMAINING_BYTES / 1024 / 1024))
|
||||
echo "✅ 固件大小检查通过,剩余可用空间: $REMAINING_BYTES 字节 ($REMAINING_MB MB)"
|
||||
|
||||
cp -rf $FIRMWARE $BASE_DIR/assets/$FIRMWARE
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
.clwb
|
||||
Generated
+208
-147
@@ -4,30 +4,24 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
|
||||
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.74"
|
||||
version = "0.3.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
|
||||
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"cfg-if",
|
||||
@@ -35,14 +29,14 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-targets",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -53,6 +47,12 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
@@ -61,9 +61,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
@@ -86,9 +86,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
@@ -197,9 +197,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "1dc8f7d2ded5f9209535e4b3fd4d39c002f30902ff5ce9f64e2c33d549576500"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -207,21 +207,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
@@ -240,6 +240,17 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -247,62 +258,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.171"
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
|
||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"scopeguard",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
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]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.7"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "open-xiaoai"
|
||||
version = "1.0.0"
|
||||
@@ -316,29 +333,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -362,33 +356,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.94"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
version = "1.0.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.2.0"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
@@ -414,19 +408,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.11"
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -435,25 +426,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -462,14 +457,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
version = "1.0.145"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -485,33 +481,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.2"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
|
||||
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
|
||||
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
@@ -519,9 +506,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -530,18 +517,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -550,17 +537,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.44.2"
|
||||
version = "1.47.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
|
||||
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
@@ -608,15 +596,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
@@ -626,11 +614,13 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.16.0"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -641,24 +631,98 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
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 = [
|
||||
"windows-targets",
|
||||
]
|
||||
@@ -728,28 +792,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.24"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
|
||||
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.24"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
|
||||
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -16,9 +16,9 @@ image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:0.2.5"
|
||||
|
||||
[dependencies]
|
||||
futures = "0.3.31"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-tungstenite = "0.26.2"
|
||||
rand = "0.9.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.15.1", features = ["v4"] }
|
||||
rand = "0.9.1"
|
||||
tokio = { version = "1.47", features = ["fs", "macros", "process", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-tungstenite = "0.26.2"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
|
||||
@@ -48,12 +48,18 @@ reboot
|
||||
为了构建能够在小爱音箱上运行的 ARMv7 应用,你还需要安装 `cross` 👉 [传送门](https://github.com/cross-rs/cross)
|
||||
|
||||
```shell
|
||||
# 克隆代码
|
||||
git clone https://github.com/idootop/open-xiaoai.git
|
||||
|
||||
# 进入当前项目根目录
|
||||
cd packages/client-rust
|
||||
|
||||
# 交叉编译
|
||||
cross build --release --target armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 如果你是 Apple silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
> 如果你是 Apple Silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
|
||||
编译成功后,将构建好的补丁程序 `client` 复制到小爱音箱上
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
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/open-xiaoai-client"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -16,33 +16,45 @@ use open_xiaoai::services::connect::rpc::RPC;
|
||||
use open_xiaoai::services::monitor::instruction::InstructionMonitor;
|
||||
use open_xiaoai::services::monitor::playing::PlayingMonitor;
|
||||
|
||||
struct AppClient;
|
||||
struct AppClient {
|
||||
kws_monitor: KwsMonitor,
|
||||
instruction_monitor: InstructionMonitor,
|
||||
playing_monitor: PlayingMonitor,
|
||||
}
|
||||
|
||||
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?;
|
||||
Ok(WsStream::Client(ws_stream))
|
||||
}
|
||||
|
||||
pub async fn run() {
|
||||
pub async fn run(&mut self) {
|
||||
let url = std::env::args().nth(1).expect("❌ 请输入服务器地址");
|
||||
println!("✅ 已启动");
|
||||
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;
|
||||
continue;
|
||||
};
|
||||
println!("✅ 已连接: {:?}", url);
|
||||
AppClient::init(ws_stream).await;
|
||||
self.init(ws_stream).await;
|
||||
if let Err(e) = MessageManager::instance().process_messages().await {
|
||||
eprintln!("❌ 消息处理异常: {}", e);
|
||||
}
|
||||
AppClient::dispose().await;
|
||||
self.dispose().await;
|
||||
eprintln!("❌ 已断开连接");
|
||||
}
|
||||
}
|
||||
|
||||
async fn init(ws_stream: WsStream) {
|
||||
async fn init(&mut self, ws_stream: WsStream) {
|
||||
MessageManager::instance().init(ws_stream).await;
|
||||
MessageHandler::<Event>::instance()
|
||||
.set_handler(on_event)
|
||||
@@ -59,35 +71,38 @@ impl AppClient {
|
||||
rpc.add_command("start_recording", start_recording).await;
|
||||
rpc.add_command("stop_recording", stop_recording).await;
|
||||
|
||||
InstructionMonitor::start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("instruction", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
self.instruction_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("instruction", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
PlayingMonitor::start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("playing", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
self.playing_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("playing", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
KwsMonitor::start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("kws", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
self.kws_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("kws", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn dispose() {
|
||||
async fn dispose(&mut self) {
|
||||
MessageManager::instance().dispose().await;
|
||||
let _ = AudioPlayer::instance().stop().await;
|
||||
let _ = AudioRecorder::instance().stop_recording().await;
|
||||
InstructionMonitor::stop().await;
|
||||
PlayingMonitor::stop().await;
|
||||
KwsMonitor::stop().await;
|
||||
self.instruction_monitor.stop().await;
|
||||
self.playing_monitor.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> {
|
||||
let Stream { tag, bytes, .. } = stream;
|
||||
match tag.as_str() {
|
||||
"play" => {
|
||||
// 播放接收到的音频流
|
||||
let _ = AudioPlayer::instance().play(bytes).await;
|
||||
}
|
||||
_ => {}
|
||||
if tag.as_str() == "play" {
|
||||
// 播放接收到的音频流
|
||||
let _ = AudioPlayer::instance().play(bytes).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
AppClient::run().await;
|
||||
AppClient::new().run().await;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async fn on_keyword(_keyword: String) {
|
||||
.split("\n")
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
if replies.len() > 0 {
|
||||
if !replies.is_empty() {
|
||||
wakeup_sounds.clear();
|
||||
wakeup_sounds.extend(replies.iter().map(|s| s.to_string()));
|
||||
}
|
||||
@@ -53,14 +53,15 @@ async fn main() {
|
||||
on_started().await;
|
||||
}
|
||||
|
||||
KwsMonitor::start(|event| async move {
|
||||
match event {
|
||||
KwsMonitorEvent::Started => on_started().await,
|
||||
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
KwsMonitor::new()
|
||||
.start(|event| async move {
|
||||
match event {
|
||||
KwsMonitorEvent::Started => on_started().await,
|
||||
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user