Compare commits
| 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 | ||
|
|
58acbc29f4 | ||
|
|
59c3280938 | ||
|
|
e687280ab1 | ||
|
|
6bdd1b9683 | ||
|
|
c8e7861bdb | ||
|
|
8fc5f073b5 | ||
|
|
6eca122f24 | ||
|
|
9a8e07a68e | ||
|
|
cde047af19 | ||
|
|
a73ef08074 | ||
|
|
cf853bb07b | ||
|
|
9129afcc99 | ||
|
|
a2c9cd5b70 | ||
|
|
b4f52ee5b9 | ||
|
|
73bf1c6f4d | ||
|
|
e39675abb8 | ||
|
|
95caae8aa4 | ||
|
|
8d5faa28dc | ||
|
|
f8d4599cc4 | ||
|
|
e9e4ca92dd | ||
|
|
3f85e07ba7 | ||
|
|
ca728e3dfc | ||
|
|
fc9409e55f | ||
|
|
3aa0cf673b | ||
|
|
e3732a7f1c | ||
|
|
62c7c88eab | ||
|
|
20c21ca609 | ||
|
|
a74d92b4b6 | ||
|
|
4a0fde4724 | ||
|
|
755ba2f202 | ||
|
|
767b7c4548 | ||
|
|
2cb5300577 | ||
|
|
2ebfb2241a | ||
|
|
c3cac9b2b4 | ||
|
|
39febdfe57 | ||
|
|
012a123fdc | ||
|
|
e53bc65715 | ||
|
|
0900b4cf2a | ||
|
|
4e7b6f6026 | ||
|
|
10c464a4e9 | ||
|
|
487848d9b0 | ||
|
|
66dc6ef741 | ||
|
|
b63a8f95a6 | ||
|
|
9ab2ec4058 | ||
|
|
1bdea51c3a | ||
|
|
9506c83223 | ||
|
|
29f4f77c7a | ||
|
|
b7716e8654 | ||
|
|
8838087fd7 | ||
|
|
6d04185792 | ||
|
|
e0f4dba612 | ||
|
|
3280809844 | ||
|
|
80b5ae4db7 | ||
|
|
8f85d2dd56 | ||
|
|
89019c7125 | ||
|
|
a863da3067 | ||
|
|
aebf94d5cd | ||
|
|
949dee9500 | ||
|
|
05072facfd |
@@ -0,0 +1,30 @@
|
|||||||
|
# 排除全部文件
|
||||||
|
**/*
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
!packages/client-rust/src
|
||||||
|
!packages/client-rust/Cargo.toml
|
||||||
|
!packages/client-rust/Cargo.lock
|
||||||
|
|
||||||
|
# MiGPT
|
||||||
|
!examples/migpt/src
|
||||||
|
!examples/migpt/migpt
|
||||||
|
!examples/migpt/config.ts
|
||||||
|
!examples/migpt/package.json
|
||||||
|
!examples/migpt/pnpm-lock.yaml
|
||||||
|
!examples/migpt/Cargo.toml
|
||||||
|
!examples/migpt/Cargo.lock
|
||||||
|
!examples/migpt/tsconfig.json
|
||||||
|
examples/migpt/migpt/open-xiaoai.node
|
||||||
|
|
||||||
|
# XiaoZhi
|
||||||
|
!examples/xiaozhi/src
|
||||||
|
!examples/xiaozhi/xiaozhi
|
||||||
|
!examples/xiaozhi/Cargo.toml
|
||||||
|
!examples/xiaozhi/Cargo.lock
|
||||||
|
!examples/xiaozhi/config.py
|
||||||
|
!examples/xiaozhi/main.py
|
||||||
|
!examples/xiaozhi/uv.lock
|
||||||
|
!examples/xiaozhi/.python-version
|
||||||
|
!examples/xiaozhi/requirements.txt
|
||||||
|
!examples/xiaozhi/pyproject.toml
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
name: 小爱音箱 Pro v${{ steps.build.outputs.version }}
|
name: 小爱音箱 Pro v${{ steps.build.outputs.version }}
|
||||||
tag: ${{ steps.build.outputs.tag }}
|
tag: ${{ steps.build.outputs.tag }}
|
||||||
body: 小爱音箱 Pro v${{ steps.build.outputs.version }} 固件更新
|
body: 小爱音箱 Pro v${{ steps.build.outputs.version }} 固件更新
|
||||||
draft: true
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
makeLatest: latest
|
makeLatest: latest
|
||||||
removeArtifacts: true
|
removeArtifacts: true
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
name: Xiaomi 智能音箱 Pro v${{ steps.build.outputs.version }}
|
name: Xiaomi 智能音箱 Pro v${{ steps.build.outputs.version }}
|
||||||
tag: ${{ steps.build.outputs.tag }}
|
tag: ${{ steps.build.outputs.tag }}
|
||||||
body: Xiaomi 智能音箱 Pro v${{ steps.build.outputs.version }} 固件更新
|
body: Xiaomi 智能音箱 Pro v${{ steps.build.outputs.version }} 固件更新
|
||||||
draft: true
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
makeLatest: latest
|
makeLatest: latest
|
||||||
removeArtifacts: true
|
removeArtifacts: true
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ node_modules
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
temp
|
||||||
|
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
@@ -38,6 +39,7 @@ yarn-error.log*
|
|||||||
.venv
|
.venv
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
__pycache__
|
__pycache__
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -45,3 +47,4 @@ __pycache__
|
|||||||
.mi.json
|
.mi.json
|
||||||
xiaozhi.json
|
xiaozhi.json
|
||||||
open-xiaoai.node
|
open-xiaoai.node
|
||||||
|
examples/hass/config.json
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
让小爱音箱「听见你的声音」,解锁无限可能。
|
让小爱音箱「听见你的声音」,解锁无限可能。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
|
|
||||||
2017 年,当全球首款千万级销量的智能音箱诞生时,我们以为触摸到了未来。但很快发现,这些设备被困在「指令-响应」的牢笼里:
|
2017 年,当全球首款千万级销量的智能音箱诞生时,我们以为触摸到了未来。但很快发现,这些设备被困在「指令-响应」的牢笼里:
|
||||||
@@ -10,7 +12,7 @@
|
|||||||
- 它能执行命令,却不会主动思考
|
- 它能执行命令,却不会主动思考
|
||||||
- 它有千万用户,却只有一套思维
|
- 它有千万用户,却只有一套思维
|
||||||
|
|
||||||
我们曾幻想中的"贾维斯"级人工智能,在现实场景中沦为"闹钟+音乐播放器"。
|
我们曾幻想中的"贾维斯"级人工智能,在现实场景中沦为"天气预报+音乐播放器"。
|
||||||
|
|
||||||
**真正的智能不应被预设的代码逻辑所束缚,而应像生命体般在交互中进化。**
|
**真正的智能不应被预设的代码逻辑所束缚,而应像生命体般在交互中进化。**
|
||||||
|
|
||||||
@@ -24,46 +26,68 @@
|
|||||||
|
|
||||||
## 你的声音 + 小爱音箱 = 无限可能
|
## 你的声音 + 小爱音箱 = 无限可能
|
||||||
|
|
||||||
👉 [小爱音箱接入小智 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)
|
||||||
|
|
||||||
|
[](https://www.bilibili.com/video/BV1YfVUz5EMj)
|
||||||
|
|
||||||
👉 [小爱音箱接入 MiGPT 演示视频](https://www.bilibili.com/video/BV1N1421y7qn)
|
👉 [小爱音箱接入 MiGPT 演示视频](https://www.bilibili.com/video/BV1N1421y7qn)
|
||||||
|
|
||||||
[](https://www.bilibili.com/video/BV1N1421y7qn)
|
[](https://www.bilibili.com/video/BV1N1421y7qn)
|
||||||
|
|
||||||
## 刷机教程
|
## 快速开始
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!IMPORTANT]
|
||||||
> 刷机有风险,操作需谨慎。
|
> 本教程仅适用于 **小爱音箱 Pro(LX06)** 和 **Xiaomi 智能音箱 Pro(OH2P)** 这两款机型,**其他型号**的小爱音箱请勿直接使用!🚨
|
||||||
|
|
||||||
整理中,敬请期待 :)
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
本项目由 Client 端 + Server 端两部分组成,你可以按照以下顺序运行该项目:
|
本项目由 Client 端 + Server 端两部分组成,你可以按照以下顺序运行该项目:
|
||||||
|
|
||||||
1. 按照刷机教程更新小爱音箱固件,开启 SSH 并连接到小爱音箱(整理教程中……
|
1. 刷机更新小爱音箱补丁固件,开启并 SSH 连接到小爱音箱 👉 [教程](docs/flash.md)
|
||||||
2. 在你的电脑上编译好 Rust 补丁程序(Client 端),然后复制到小爱音箱上运行 👉 [教程](packages/client-rust/README.md)
|
2. 在小爱音箱上安装运行 Client 端补丁程序 👉 [教程](packages/client-rust/README.md)
|
||||||
3. 在你的电脑上运行 Server 端演示程序,体验小爱音箱的全新能力 ✨
|
3. 运行以下演示程序,体验小爱音箱的全新能力 ✨
|
||||||
- Python Server 演示 👉 [小爱音箱接入小智 AI](packages/server-python/README.md)
|
- 👉 [小爱音箱接入小智 AI](examples/xiaozhi/README.md)
|
||||||
- Node.js Server 演示 👉 [小爱音箱接入 MiGPT-Next](packages/server-node/README.md)
|
- 👉 [小爱音箱自定义唤醒词](examples/kws/README.md)
|
||||||
|
- 👉 [小爱音箱接入 MiGPT(完美版)](examples/migpt/README.md)
|
||||||
|
- 👉 [小爱音箱接入 Gemini Live API](examples/gemini/README.md)
|
||||||
|
- 👉 [小爱音箱组立体声(支持不同型号机型)](examples/stereo/README.md)
|
||||||
|
|
||||||
以上皆为抛砖引玉,你也可以自己亲手编写想要的功能,一切由你定义!
|
以上皆为抛砖引玉,你也可以亲手编写自己想要的功能,一切由你定义!
|
||||||
|
|
||||||
|
## 相关项目
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 技术的意义在于分享与共创。如果你打算或正在用本项目做些有趣的事情,
|
> 技术的意义在于分享与共创。如果你打算或正在使用本项目做些有趣的事情,
|
||||||
> 欢迎提交 PR 或 issue 分享你的项目和创意。✨
|
> 欢迎提交 PR 或 issue 分享你的项目和创意。✨
|
||||||
|
|
||||||
|
如果你不想刷机,或者不是小爱音箱 Pro,下面的项目或许对你有用:
|
||||||
|
|
||||||
|
- https://github.com/idootop/mi-gpt
|
||||||
|
- https://github.com/idootop/migpt-next
|
||||||
|
- https://github.com/yihong0618/xiaogpt
|
||||||
|
- https://github.com/hanxi/xiaomusic
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
如果你想要了解更多技术细节,下面的链接可能对你有用:
|
||||||
|
|
||||||
|
- https://github.com/yihong0618/gitblog/issues/258
|
||||||
|
- https://github.com/jialeicui/open-lx01
|
||||||
|
- https://github.com/duhow/xiaoai-patch
|
||||||
|
- https://javabin.cn/2021/xiaoai_fm.html
|
||||||
|
- https://xuanxuanblingbling.github.io/iot/2022/09/16/mi/
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
1. **适用范围**
|
1. **适用范围**
|
||||||
本项目为非盈利开源项目,仅限于技术原理研究、安全漏洞验证及非营利性个人使用。严禁用于商业服务、网络攻击、数据窃取、系统破坏等违反《网络安全法》及使用者所在地司法管辖区的法律规定的场景。
|
本项目为开源非营利项目,仅供学术研究或个人测试用途。严禁用于商业服务、网络攻击、数据窃取、系统破坏等违反《网络安全法》及使用者所在地司法管辖区的法律规定的场景。
|
||||||
2. **非官方声明**
|
2. **非官方声明**
|
||||||
本项目由第三方开发者独立开发,与小米集团及其关联方(下称"权利方")无任何隶属/合作关系,未获其官方授权/认可或技术支持。项目中涉及的商标、固件、云服务的所有权利归属小米集团。若权利方主张权益,使用者应立即主动停止使用并删除本项目。
|
本项目由第三方开发者独立开发,与小米集团及其关联方(下称"权利方")无任何隶属/合作关系,亦未获其官方授权/认可或技术支持。项目中涉及的商标、固件、云服务的所有权利归属小米集团。若权利方主张权益,使用者应立即主动停止使用并删除本项目。
|
||||||
|
|
||||||
继续下载或运行本项目,即表示您已完整阅读并同意[用户协议](agreement.md),否则请立即终止使用并彻底删除本项目。
|
继续下载或运行本项目,即表示您已完整阅读并同意[用户协议](agreement.md),否则请立即终止使用并彻底删除本项目。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE) License © 2024-PRESENT Del Wang
|
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## 授权范围
|
## 授权范围
|
||||||
|
|
||||||
本项目授权范围仅限于技术原理研究、安全漏洞验证及非营利性个人使用。严禁用于商业服务、网络攻击、数据窃取、系统破坏等违反《网络安全法》及使用者所在地司法管辖区的法律规定的场景。
|
本项目仅供学术研究或个人测试用途。严禁用于商业服务、网络攻击、数据窃取、系统破坏等违反《网络安全法》及使用者所在地司法管辖区的法律规定的场景。
|
||||||
|
|
||||||
## 非官方声明
|
## 非官方声明
|
||||||
|
|
||||||
本项目为非盈利开源项目,由第三方开发者独立开发,与小米集团及其关联方(下称"权利方")无任何隶属/合作关系,未获其官方授权/认可或技术支持。项目中涉及的商标、固件、云服务等知识产权均归属权利方所有。若权利方主张权益,用户应立即终止使用并彻底销毁项目副本。
|
本项目为开源非营利项目,由第三方开发者独立开发,与小米集团及其关联方(下称"权利方")无任何隶属/合作关系,亦未获其官方授权/认可或技术支持。项目中涉及的商标、固件、云服务等知识产权均归属权利方所有。若权利方主张权益,用户应立即终止使用并彻底销毁项目副本。
|
||||||
|
|
||||||
## 风险告知
|
## 风险告知
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# 小爱音箱 Pro 刷机教程
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 本教程仅适用于 **小爱音箱 Pro(LX06)** 和 **Xiaomi 智能音箱 Pro(OH2P)** 这两款机型,**其他型号**的小爱音箱请勿直接使用!🚨
|
||||||
|
|
||||||
|
### 准备条件
|
||||||
|
|
||||||
|
- 小爱音箱
|
||||||
|
- Xiaomi 智能音箱 Pro(OH2P)
|
||||||
|
- 小爱音箱 Pro(LX06)
|
||||||
|
- 数据线
|
||||||
|
- Type-C(适用于新款小爱音箱 Pro,**无需拆机**)
|
||||||
|
- Micro USB(旧款小爱音箱 Pro 用这种,**需要拆机**)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 注意:数据线不能只是充电线,需要连接到电脑上能传输数据才行。
|
||||||
|
|
||||||
|
### 连接电脑
|
||||||
|
|
||||||
|
新款的 Xiaomi 智能音箱 Pro 可以直接使用 Type-C 数据线通过音响底部的 Type-C 接口连接到电脑。
|
||||||
|
|
||||||
|
旧款小爱音箱 Pro 需要先把音箱外壳拆下来([参考步骤](https://www.52audio.com/archives/38303.html)),然后在主板左上角找到 Mico USB 的调试接口,使用 Mico USB 数据线连接到电脑。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 开始刷机
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> 刷机有风险,操作需谨慎。刷机可能会造成设备失去保修资格,变砖无法运行等。请自行评估相关风险,一切后果自负!🚨
|
||||||
|
|
||||||
|
首先,下载或自行制作[补丁固件](../packages/client-patch/README.md),重命名为 `root_patched.squashfs`。
|
||||||
|
|
||||||
|
然后,下载刷机工具:https://androidmtk.com/download-amlogic-flash-tool
|
||||||
|
|
||||||
|
把下载好的刷机工具文件夹解压到桌面,重命名为 `Amlogic_Flash_Tool_v6.0.0`
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果你是 macOS 系统,请到此处下载[刷机工具和使用教程](../packages/flash-tool/README.md)。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
然后运行 `AMLLogic driver installer.exe` 文件,按照提示安装 USB 驱动。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
安装好驱动后,打开 `/bin` 目录,然后在空白处右键选择使用 [Git Bash](https://git-scm.com/downloads) 打开当前目录。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 请确认你已经将小爱音箱用数据线连接到了 Windows 电脑,并且安装好了驱动程序。
|
||||||
|
|
||||||
|
然后,请按照说明在打开的终端里依次执行以下命令:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 🚗 第 1 步:拔掉小爱音箱的电源线,重新插上电源后,立即在终端执行以下命令
|
||||||
|
./update.exe identify
|
||||||
|
|
||||||
|
# 如果显示类似下面的内容,说明小爱音箱已经成功连接到了电脑,可以继续进行下一步
|
||||||
|
# This firmware version is 0-7-0-16-0-0-0-0
|
||||||
|
|
||||||
|
# 否则继续快速重试几次该命令,或者重新拔掉电源上电后重试,直至显示出来版本号。
|
||||||
|
|
||||||
|
# ⏰ 第 2 步:设置启动分区和等待时长
|
||||||
|
|
||||||
|
# 设置 uboot 启动等待 15s
|
||||||
|
./update.exe bulkcmd "setenv bootdelay 15"
|
||||||
|
# 设置 boot0 作为启动分区
|
||||||
|
./update.exe bulkcmd "setenv boot_part boot0"
|
||||||
|
# 保存环境变量,使生效
|
||||||
|
./update.exe bulkcmd "saveenv"
|
||||||
|
|
||||||
|
# ⚡️ 第 3 步:刷入 system0
|
||||||
|
./update.exe partition system0 root_patched.squashfs
|
||||||
|
# 注意:需要把 root_patched.squashfs 替换成你下载的补丁固件实际路径
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果你卡在第一步,连接不上设备,可以按照[此教程](https://github.com/idootop/open-xiaoai/issues/6#issuecomment-2815632879)排查问题。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 提示刷机成功之后,拔掉数据线和电源,重新插电重启小爱音箱即可。
|
||||||
|
> 如果重启之后小爱音箱没有反应,可以拔掉电源等几分钟再重新上电开机。
|
||||||
|
> 如果还是没反应,可以重新刷机试试看,或者将启动分区设置成 `boot1` 恢复原系统启动。
|
||||||
|
|
||||||
|
### SSH 连接到小爱音箱
|
||||||
|
|
||||||
|
刷机成功后,补丁固件默认开启 SSH 功能,默认密码 `open-xiaoai`
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ssh -o HostKeyAlgorithms=+ssh-rsa root@你的小爱音箱局域网IP地址
|
||||||
|
# 比如:ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.227
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果刷机之后 SSH 连接不上去小爱音箱,请检查:
|
||||||
|
> - 你设置的启动分区(boot0)是否和你刷写的系统分区(system0)一致(修改启动分区之后,需要 `saveenv` 才会保存生效)
|
||||||
|
> - 你的电脑和小爱音箱是否处在同一网络环境下(可以更换路由器试试看)
|
||||||
|
> - 你的内网防火墙是否屏蔽了 22 端口(SSH 默认连接端口)
|
||||||
|
> - 如果你是自己构建的补丁固件,请检查 SSH 相关的 [patch](../packages/client-patch/patches/01-ssh.patch) 是否生效
|
||||||
|
|
||||||
|
### 更新系统
|
||||||
|
|
||||||
|
小爱音箱有 2 套系统,上面我们只是将 `system0` 刷成了打补丁后的系统。
|
||||||
|
|
||||||
|
如果之后你想要更新系统,或者换回原来的系统,将启动分支设置成 `boot1` 重启即可。
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 连接到 SSH 后执行
|
||||||
|
fw_env -s boot_part boot1
|
||||||
|
|
||||||
|
# 或者使用和上面刷机相同的方式
|
||||||
|
./update.exe bulkcmd "setenv boot_part boot1"
|
||||||
|
./update.exe bulkcmd "saveenv"
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
@@ -13,7 +13,7 @@ pyo3-async-runtimes = { version = "0.24", features = [
|
|||||||
"attributes",
|
"attributes",
|
||||||
"tokio-runtime",
|
"tokio-runtime",
|
||||||
] }
|
] }
|
||||||
open-xiaoai = { path = "../client-rust" }
|
open-xiaoai = { path = "../../packages/client-rust" }
|
||||||
tokio = { version = "1.32.0", features = ["full"] }
|
tokio = { version = "1.32.0", features = ["full"] }
|
||||||
tokio-tungstenite = "0.26.2"
|
tokio-tungstenite = "0.26.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Open-XiaoAI x Gemini Live API
|
||||||
|
|
||||||
|
小爱音箱接入[Gemini Live API](https://ai.google.dev/gemini-api/docs/live) 的演示程序,支持自动 VAD + 连续对话。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 你需要先到 [Google AI Studio](https://aistudio.google.com) 注册账号并[创建 API 密钥](https://aistudio.google.com/apikey)。然后更新 `GEMINI_API_KEY` 环境变量,或 `gemini/gemini.py` 文件中的密钥。
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
为了能够正常编译运行该项目,你需要安装以下依赖环境/工具:
|
||||||
|
|
||||||
|
- uv:https://github.com/astral-sh/uv
|
||||||
|
- Rust: https://www.rust-lang.org/learn/get-started
|
||||||
|
|
||||||
|
## 编译运行
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 请先确认你已经将小爱音箱刷机成功,并安装运行了 Rust 补丁程序 [👉 教程](../../packages/client-rust/README.md),否则该项目启动后收不到音频输入,将无法正常工作。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆代码
|
||||||
|
git clone https://github.com/idootop/open-xiaoai.git
|
||||||
|
|
||||||
|
# 进入当前项目根目录
|
||||||
|
cd examples/gemini
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# 编译运行
|
||||||
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
该演示暂不支持中断 AI 的回复,需要等待 AI 回答完毕后才能重新响应用户的语音输入。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本项目只是一个简单的演示程序,抛砖引玉。如果你想要更多的功能,可以借助 Python 丰富的 AI 生态自行实现。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .gemini import Gemini
|
||||||
|
from .xiaoai import XiaoAi
|
||||||
|
|
||||||
|
__all__ = ["Gemini", "XiaoAi"]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import os
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
GEMINI_MODEL = "gemini-2.0-flash-live-001"
|
||||||
|
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") or "你的 API KEY"
|
||||||
|
|
||||||
|
|
||||||
|
class Gemini:
|
||||||
|
running = False
|
||||||
|
|
||||||
|
client = genai.Client(api_key=GEMINI_API_KEY)
|
||||||
|
|
||||||
|
session: Optional[genai.live.AsyncSession] = None
|
||||||
|
|
||||||
|
config = types.LiveConnectConfig(
|
||||||
|
response_modalities=[types.Modality.AUDIO],
|
||||||
|
system_instruction=types.Content(
|
||||||
|
parts=[types.Part(text="你是小爱音箱,请用中文回答用户的问题。")]
|
||||||
|
),
|
||||||
|
speech_config=types.SpeechConfig(
|
||||||
|
language_code="cmn-CN",
|
||||||
|
voice_config=types.VoiceConfig(
|
||||||
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Leda")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
context_window_compression=(
|
||||||
|
types.ContextWindowCompressionConfig(
|
||||||
|
sliding_window=types.SlidingWindow(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_text(cls, text: str):
|
||||||
|
if not cls.session:
|
||||||
|
return
|
||||||
|
await cls.session.send_client_content(
|
||||||
|
turns={"role": "user", "parts": [{"text": text}]},
|
||||||
|
turn_complete=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_audio(cls, data: bytes):
|
||||||
|
if not cls.session:
|
||||||
|
return
|
||||||
|
await cls.session.send_realtime_input(
|
||||||
|
audio=types.Blob(data=data, mime_type="audio/pcm;rate=16000")
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stop(cls):
|
||||||
|
cls.running = False
|
||||||
|
if cls.session:
|
||||||
|
cls.session.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def start(
|
||||||
|
cls,
|
||||||
|
on_audio: Optional[Callable[[bytes], Awaitable[None]]] = None,
|
||||||
|
on_text: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
|
set_is_speaking: Optional[Callable[[bool], Awaitable[None]]] = None,
|
||||||
|
):
|
||||||
|
if cls.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
cls.running = True
|
||||||
|
|
||||||
|
async with cls.client.aio.live.connect(
|
||||||
|
model=GEMINI_MODEL, config=cls.config
|
||||||
|
) as session:
|
||||||
|
cls.session = session
|
||||||
|
|
||||||
|
print("🔊 AI: ", "session connected")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("🔊 AI: ", "waiting for response")
|
||||||
|
|
||||||
|
if not cls.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
if set_is_speaking:
|
||||||
|
await set_is_speaking(False)
|
||||||
|
|
||||||
|
async for response in session.receive():
|
||||||
|
if response.server_content is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.server_content.interrupted is True:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if set_is_speaking:
|
||||||
|
await set_is_speaking(True)
|
||||||
|
|
||||||
|
if response.data is not None:
|
||||||
|
print("🔊 AI: ", len(response.data))
|
||||||
|
if on_audio:
|
||||||
|
await on_audio(response.data)
|
||||||
|
|
||||||
|
if response.text is not None:
|
||||||
|
print("✅ AI: ", response.text)
|
||||||
|
if on_text:
|
||||||
|
await on_text(response.text)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import open_xiaoai_server
|
||||||
|
|
||||||
|
from gemini import Gemini
|
||||||
|
|
||||||
|
|
||||||
|
class XiaoAi:
|
||||||
|
loop: asyncio.AbstractEventLoop
|
||||||
|
is_ai_speaking = False
|
||||||
|
speaking_count = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def start(cls):
|
||||||
|
cls.loop = asyncio.get_event_loop()
|
||||||
|
open_xiaoai_server.register_fn("on_input_data", cls.input_audio)
|
||||||
|
await open_xiaoai_server.start_server()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def output_audio(cls, data: bytes):
|
||||||
|
await open_xiaoai_server.on_output_data(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def input_audio(cls, data: bytes):
|
||||||
|
if cls.is_ai_speaking:
|
||||||
|
# 如果 AI 正在回答问题,则不发送音频,防止把 AI 的声音被当做输入录制进来
|
||||||
|
# 暂不支持中断 AI 的回复,需要等待 AI 回答完成后才能重新响应用户的语音输入
|
||||||
|
return
|
||||||
|
|
||||||
|
async def send_audio_task():
|
||||||
|
audio_array = np.frombuffer(data, dtype=np.uint16)
|
||||||
|
await Gemini.send_audio(audio_array.tobytes())
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(send_audio_task(), cls.loop)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def set_is_speaking(cls, is_speaking: bool):
|
||||||
|
if is_speaking:
|
||||||
|
cls.speaking_count += 1
|
||||||
|
cls.is_ai_speaking = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# 延迟 1 秒,如果 AI 还在说话,则设置为不说话
|
||||||
|
async def set_is_speaking_task():
|
||||||
|
speaking_count = cls.speaking_count
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
if cls.speaking_count == speaking_count:
|
||||||
|
cls.is_ai_speaking = False
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(set_is_speaking_task(), cls.loop)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from gemini.gemini import Gemini
|
||||||
|
from gemini.xiaoai import XiaoAi
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
xiaoai_task = asyncio.create_task(XiaoAi.start())
|
||||||
|
gemini_task = asyncio.create_task(
|
||||||
|
Gemini.start(
|
||||||
|
on_audio=XiaoAi.output_audio,
|
||||||
|
set_is_speaking=XiaoAi.set_is_speaking,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.gather(xiaoai_task, gemini_task)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "open-xiaoai-gemini"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "小爱音箱接入 Gemini Live API"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"asyncio>=3.4.3",
|
||||||
|
"google-genai>=1.12.1",
|
||||||
|
"httpx[socks]>=0.28.1",
|
||||||
|
"maturin>=1.8.3",
|
||||||
|
"numpy>=2.2.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["maturin>=1.0,<2.0"]
|
||||||
|
build-backend = "maturin"
|
||||||
|
|
||||||
|
[tool.maturin]
|
||||||
|
features = ["pyo3/extension-module"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
# Rebuild package when any rust files change
|
||||||
|
# https://github.com/PyO3/maturin/issues/2314#issuecomment-2488542771
|
||||||
|
cache-keys = [
|
||||||
|
{ file = "pyproject.toml" },
|
||||||
|
{ file = "Cargo.toml" },
|
||||||
|
{ file = "src/**/*.rs" },
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use open_xiaoai::base::{AppError, VERSION};
|
use open_xiaoai::base::{AppError, VERSION};
|
||||||
|
use open_xiaoai::services::audio::config::AudioConfig;
|
||||||
use open_xiaoai::services::connect::data::{Event, Request, Response, Stream};
|
use open_xiaoai::services::connect::data::{Event, Request, Response, Stream};
|
||||||
use open_xiaoai::services::connect::handler::MessageHandler;
|
use open_xiaoai::services::connect::handler::MessageHandler;
|
||||||
use open_xiaoai::services::connect::message::{MessageManager, WsStream};
|
use open_xiaoai::services::connect::message::{MessageManager, WsStream};
|
||||||
@@ -19,10 +20,34 @@ async fn test() -> Result<(), AppError> {
|
|||||||
SpeakerManager::play_text("已连接").await?;
|
SpeakerManager::play_text("已连接").await?;
|
||||||
|
|
||||||
let _ = RPC::instance()
|
let _ = RPC::instance()
|
||||||
.call_remote("start_recording", None, None)
|
.call_remote(
|
||||||
|
"start_recording",
|
||||||
|
Some(json!(AudioConfig {
|
||||||
|
pcm: "noop".into(),
|
||||||
|
channels: 1,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
sample_rate: 16000,
|
||||||
|
period_size: 1440 / 4,
|
||||||
|
buffer_size: 1440,
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = RPC::instance().call_remote("start_play", None, None).await;
|
let _ = RPC::instance()
|
||||||
|
.call_remote(
|
||||||
|
"start_play",
|
||||||
|
Some(json!(AudioConfig {
|
||||||
|
pcm: "noop".into(),
|
||||||
|
channels: 1,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
sample_rate: 24000,
|
||||||
|
period_size: 1440 / 4,
|
||||||
|
buffer_size: 1440,
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 1
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asyncio"
|
||||||
|
version = "3.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/54/054bafaf2c0fb8473d423743e191fcdf49b2c1fd5e9af3524efbe097bafd/asyncio-3.4.3.tar.gz", hash = "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", size = 204411 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/74/07679c5b9f98a7cb0fc147b1ef1cc1853bc07a4eb9cb5731e24732c5f773/asyncio-3.4.3-py3-none-any.whl", hash = "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d", size = 101767 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cachetools"
|
||||||
|
version = "5.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.1.31"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-auth"
|
||||||
|
version = "2.39.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cachetools" },
|
||||||
|
{ name = "pyasn1-modules" },
|
||||||
|
{ name = "rsa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/8e/8f45c9a32f73e786e954b8f9761c61422955d23c45d1e8c347f9b4b59e8e/google_auth-2.39.0.tar.gz", hash = "sha256:73222d43cdc35a3aeacbfdcaf73142a97839f10de930550d89ebfe1d0a00cde7", size = 274834 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/12/ad37a1ef86006d0a0117fc06a4a00bd461c775356b534b425f00dde208ea/google_auth-2.39.0-py2.py3-none-any.whl", hash = "sha256:0150b6711e97fb9f52fe599f55648950cc4540015565d8fbb31be2ad6e1548a2", size = 212319 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-genai"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "google-auth" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/9c/c907dbea921663bb7c41f415337bedd08259d17da8d156396c7237611744/google_genai-1.12.1.tar.gz", hash = "sha256:5c7eda422360643ce602a3f6b23152470ec1039310ef40080cbe4e71237f6391", size = 167752 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/2c/5b454dec837328eb167e78f45a14da502af223f8b94a4824e2fd0df74f19/google_genai-1.12.1-py3-none-any.whl", hash = "sha256:7cbc1bc029712946ce41bcf80c0eaa89eb8c09c308efbbfe30fd491f402c258a", size = 165940 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
socks = [
|
||||||
|
{ name = "socksio" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maturin"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/0b/3fd746cf5cfa3c8d7e20ea08c0dbc2c2c765ae051d0fc43d808a38bc9548/maturin-1.8.3.tar.gz", hash = "sha256:304762f86fd53a8031b1bf006d12572a2aa0a5235485031113195cc0152e1e12", size = 199656 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/80/08579d0184ba743345bbd350a3b6419510ff8a4f6e9e671f713160d41fbb/maturin-1.8.3-py3-none-linux_armv6l.whl", hash = "sha256:fa27466b627150123729b2e611f9f9cfade84d24385d72c6877f78c30de30e89", size = 7758366 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/1c/00755d28ae277daa828e183c3d118e2923e8b8f0cba4ff708b15d274ac0e/maturin-1.8.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:583404d20d7f1d9c8f3c18dcab9014faacabbed6be02da80062c06cd0e279554", size = 15201378 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/9f/b8738dc55ba3eb149ad2686d3f9b3c24e44b7baff46cc6baa85e5dc8cf5e/maturin-1.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f9ffdac53dfe0089cf19b597410bc552eb34c856ddb41482b243a695e8b549d3", size = 7934215 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/68/300f1a279486d6f63b624a76b81f0fba82545d027127c1ca4d5ded0d1b43/maturin-1.8.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7949a4a17637341f84e88f4cbf0c155998780bbb7a145ed735725b907881c0ae", size = 7791270 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/6d/bf1b8bb9a8b1d9adad242b4089794be318446142975762d04f04ffabae40/maturin-1.8.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:11564fac7486313b7baf3aa4e82c20e1b20364aad3fde2ccbc4c07693c0b7e16", size = 8282824 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/e9/c601de8699e546774d3f9e761eab374988f88e4ed7006afbb5ff61bb41c0/maturin-1.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8555d8701cdba6c19c4705a22ce4d2a1814efd792f55dc5873262ff903317540", size = 7595855 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/b8/82ae650e6b589289f4219a480f2b220bcf3d9391ae9ea02cc7a58ef59cfc/maturin-1.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:5b2a513468c1c9b4d1728d4b6d3d044b7c183985ef819c9ef15e373b70d99c7d", size = 7634888 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/4a/4f898a66cf4697267c447a6f240b6cbcc2dcacd3cab89735bfbd44810be1/maturin-1.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:2fe8fdff420cfccde127bbc3d8e40835163dcebb6d28c49fffd9aaf1e6ec1090", size = 9810616 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/bd/21b0d471e6ade0801f27c33672bca4d563eb1d1e624e534f3bef8a01b1ac/maturin-1.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22cd8b6dc490fee99a62590f914f0c04ddad1dd6dbd5c7e933b3f882b1bd78c2", size = 10923701 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/07/85c32f03ed757c0626e2fc5a9d6454988228be96a756991702a2a757691b/maturin-1.8.3-py3-none-win32.whl", hash = "sha256:2427924546c9d1079b1c0c6c5c1d380e1b8187baba4e6342cca81597a0f3d697", size = 7016502 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/62/f92a130a370dd7aca13c316844b82853647f048cfe1594a81f628ab7101f/maturin-1.8.3-py3-none-win_amd64.whl", hash = "sha256:85f2b882d8235c1c1cb0a38d382ccd5b3ba0674d99cb548d49df9342cc688e36", size = 7953286 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/95/8379140838cd95472de843e982d0bf674e8dbf25a899c44e2f76b15704d9/maturin-1.8.3-py3-none-win_arm64.whl", hash = "sha256:33939aabf9a06a8a14ca6c399d32616c7e574fcca8d4ff6dcd984441051f32fb", size = 6687772 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.2.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open-xiaoai-gemini"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asyncio" },
|
||||||
|
{ name = "google-genai" },
|
||||||
|
{ name = "httpx", extra = ["socks"] },
|
||||||
|
{ name = "maturin" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "asyncio", specifier = ">=3.4.3" },
|
||||||
|
{ name = "google-genai", specifier = ">=1.12.1" },
|
||||||
|
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
||||||
|
{ name = "maturin", specifier = ">=1.8.3" },
|
||||||
|
{ name = "numpy", specifier = ">=2.2.3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1-modules"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.11.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.33.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "4.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sniffio"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socksio"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.13.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "15.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
||||||
|
]
|
||||||
@@ -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 == []
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Open-XiaoAI x Sherpa-ONNX
|
||||||
|
|
||||||
|
小爱音箱自定义唤醒词,基于 [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 以下操作需要先将小爱音箱刷机, 然后 SSH 连接到小爱音箱。👉 [教程](../../docs/flash.md)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 创建 open-xiaoai/kws 文件夹
|
||||||
|
mkdir -p /data/open-xiaoai/kws
|
||||||
|
|
||||||
|
# 设置自定义唤醒词
|
||||||
|
cat <<EOF > /data/open-xiaoai/kws/keywords.txt
|
||||||
|
t iān m āo j īng l íng @天猫精灵
|
||||||
|
x iǎo d ù x iǎo d ù @小度小度
|
||||||
|
d òu b āo d òu b āo @豆包豆包
|
||||||
|
n ǐ h ǎo x iǎo zh ì @你好小智
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 设置唤醒提示语(可选)
|
||||||
|
cat <<EOF > /data/open-xiaoai/kws/reply.txt
|
||||||
|
主人你好,请问有什么吩咐?
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 运行启动脚本 init.sh
|
||||||
|
curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/init.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你想要开机自启动,运行以下命令重启小爱音箱即可。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 下载 boot.sh 到 /data/init.sh 开机时自启动
|
||||||
|
curl -L -o /data/init.sh https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/boot.sh
|
||||||
|
|
||||||
|
# 重启小爱音箱
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设置唤醒词
|
||||||
|
|
||||||
|
在你的电脑上克隆本项目,然后修改 `my-keywords.txt` 文件里的唤醒词。注意:
|
||||||
|
|
||||||
|
- 仅支持中文唤醒词
|
||||||
|
- 支持多个唤醒词,每行一个
|
||||||
|
|
||||||
|
运行以下命令,更新 `keywords.txt` 配置文件:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 你可能需要先安装 uv 👉 https://github.com/astral-sh/uv
|
||||||
|
uv run keywords.py --tokens tokens.txt --output keywords.txt --text my-keywords.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
然后将你电脑上的 `keywords.txt` 复制到小爱音箱 `/data/open-xiaoai/kws/keywords.txt`。
|
||||||
|
|
||||||
|
如果你不方便复制文件,也可以直接在小爱音箱上运行以下命令(记得修改成自己的唤醒词)。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cat <<EOF > /data/open-xiaoai/kws/keywords.txt
|
||||||
|
t iān m āo j īng l íng @天猫精灵
|
||||||
|
x iǎo d ù x iǎo d ù @小度小度
|
||||||
|
d òu b āo d òu b āo @豆包豆包
|
||||||
|
n ǐ h ǎo x iǎo zh ì @你好小智
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 修改完毕后,记得重启脚本或小爱音箱使新配置生效。
|
||||||
|
|
||||||
|
## 设置欢迎语
|
||||||
|
|
||||||
|
你也可以设置自定义唤醒词唤醒后的提示语。首先新建一个 `reply.txt` 文件:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
主人你好,请问有什么吩咐?
|
||||||
|
https://example.com/music.wav
|
||||||
|
file:///usr/share/sound-vendor/AiNiRobot/wakeup_ei_01.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 每行一句欢迎语
|
||||||
|
- 支持文字和音频链接
|
||||||
|
- 多条欢迎语会随机选择一句播放
|
||||||
|
- 默认欢迎语与小爱同学一致:“哎”、“在”
|
||||||
|
|
||||||
|
然后将你电脑上的 `reply.txt`复制到小爱音箱 `/data/open-xiaoai/kws/reply.txt`。
|
||||||
|
|
||||||
|
如果你不方便复制文件,也可以直接在小爱音箱上运行以下命令(记得修改成自己的欢迎语)。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cat <<EOF > /data/open-xiaoai/kws/reply.txt
|
||||||
|
主人你好,请问有什么吩咐?
|
||||||
|
https://example.com/music.wav
|
||||||
|
file:///usr/share/sound-vendor/AiNiRobot/wakeup_ei_01.wav
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 修改完毕后,记得重启脚本或小爱音箱使新配置生效。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q:为什么唤醒词识别不是很灵敏,时好时坏?
|
||||||
|
|
||||||
|
由于小爱音箱 client 端算力和存储空间有限,默认只使用了一个较小的语音识别模型,所以识别效果并不是很完美,需要多多尝试。
|
||||||
|
|
||||||
|
你可以在小爱音箱上运行以下调试脚本,根据语音识别结果,动态调整唤醒词拼音。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 运行调试脚本
|
||||||
|
curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/debug.sh | sh
|
||||||
|
|
||||||
|
# 🐢 模型加载较慢,请在提示 Started! Please speak 后,再使用自定义唤醒词
|
||||||
|
# Started! Please speak
|
||||||
|
# 0:tiānmāojīnglián 👈 天猫精灵
|
||||||
|
# 1:xiǎodùxiǎodù 👈 小度小度
|
||||||
|
# 2:dōubāodùba 👈 豆包豆包
|
||||||
|
# 3:nǐhǎoxiǎozhī 👈 你好小智
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你想要更完美的唤醒词识别效果,可以在 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:能不能设置英文/方言作为唤醒词?比如:Siri
|
||||||
|
|
||||||
|
默认使用的小型语音识别模型,只支持中文(普通话)作为唤醒词。
|
||||||
|
|
||||||
|
如果你想要使用英文唤醒词(比如:siri),或者更灵敏的唤醒词识别效果,可以参考这个 [Server 端演示](../xiaozhi/README.md)。
|
||||||
|
|
||||||
|
如果你需要更多语言的唤醒词,比如日语、韩语或方言等,可以在 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:是否支持说话人(声纹)识别?
|
||||||
|
|
||||||
|
由于小爱音箱 client 端算力和存储空间有限,默认只使用了一个较小的语音识别模型,并不支持说话人识别等高阶功能。
|
||||||
|
|
||||||
|
如果你需要声纹识别、连续对话等高阶功能,可以在 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:能否自定义唤醒词的响应动作?比如:关灯
|
||||||
|
|
||||||
|
该脚本默认不支持自定义唤醒词触发后执行的动作。
|
||||||
|
|
||||||
|
如果你想要自定义唤醒词的触发动作,可以参考 [open-xiaoai](https://github.com/idootop/open-xiaoai) 里的其他演示程序,根据 server 端接收到的小爱音箱上的唤醒词事件,实时触发任意自定义操作(比如:打电话,订外卖等)。
|
||||||
|
|
||||||
|
### Q:我不是小爱音箱 Pro 能不能用?
|
||||||
|
|
||||||
|
由于小爱音箱 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) 项目。
|
||||||
|
|
||||||
|
### Q:我是小爱音箱 Pro(LX06)为什么启动失败显示 Killed?
|
||||||
|
|
||||||
|
目前只在新款小米智能音箱 Pro(OH2P)和旧款带 DTS 功能的小爱音箱 Pro(256 MB 内存)上测试可以正常运行。
|
||||||
|
|
||||||
|
有些新版的小爱音箱 Pro(不带 DTS 版本)内存仅为 128 MB,无法提供充足的内存来加载模型文件,故无法在 Client 端运行该 KWS 服务。
|
||||||
|
|
||||||
|
建议参考 [xiaozhi](../xiaozhi/README.md) 演示,在 Server 端运行 KWS 服务。
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/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 "✅ 网络连接成功"
|
||||||
|
|
||||||
|
MIN_SPACE_MB=32
|
||||||
|
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
|
||||||
|
|
||||||
|
|
||||||
|
check_disk_space() {
|
||||||
|
local space_kb=$(df -k "$1" | awk 'NR==2 {print $4}')
|
||||||
|
if [ $((space_kb / 1024)) -lt "$MIN_SPACE_MB" ]; then
|
||||||
|
echo 1
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR="/data"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
BASE_DIR="/tmp"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
echo "❌ 磁盘空间不足,请先清理磁盘空间(至少需要 $MIN_SPACE_MB MB 空间)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
WORK_DIR="$BASE_DIR/open-xiaoai/kws"
|
||||||
|
KWS_BIN="$WORK_DIR/kws"
|
||||||
|
|
||||||
|
if [ ! -d "$WORK_DIR" ]; then
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/models/encoder.onnx" ]; then
|
||||||
|
echo "🔥 正在下载模型文件..."
|
||||||
|
curl -L -# -o "$WORK_DIR/kws.tar.gz" "$DOWNLOAD_BASE_URL/kws.tar.gz"
|
||||||
|
tar -xzvf "$WORK_DIR/kws.tar.gz" -C "$WORK_DIR"
|
||||||
|
chmod +x "$KWS_BIN"
|
||||||
|
rm "$WORK_DIR/kws.tar.gz"
|
||||||
|
echo "✅ 模型文件下载完毕"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR="/data/open-xiaoai/kws"
|
||||||
|
|
||||||
|
if [ ! -d "$CONFIG_DIR" ]; then
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_DIR/keywords.txt" ]; then
|
||||||
|
echo "n ǐ h ǎo x iǎo zh ì @你好小智" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "d òu b āo d òu b āo @豆包豆包" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "t iān m āo j īng l íng @天猫精灵" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "x iǎo d ù x iǎo d ù @小度小度" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "✅ 默认唤醒词已创建"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" != "--no-monitor" ]; then
|
||||||
|
# 如果没有设置 --no-monitor 参数,则启动 monitor 进程
|
||||||
|
MONITOR_BIN="$CONFIG_DIR/monitor"
|
||||||
|
if [ ! -f "$MONITOR_BIN" ]; then
|
||||||
|
curl -L -# -o "$MONITOR_BIN" "$DOWNLOAD_BASE_URL/monitor"
|
||||||
|
chmod +x "$MONITOR_BIN"
|
||||||
|
fi
|
||||||
|
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||||
|
"$MONITOR_BIN" > /dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔥 正在启动唤醒词识别服务,请耐心等待..."
|
||||||
|
echo "🐢 模型加载较慢,请在语音提示初始化成功后,再使用自定义唤醒词"
|
||||||
|
|
||||||
|
kill -9 `ps|grep "open-xiaoai/kws/kws"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||||
|
"$KWS_BIN" \
|
||||||
|
--model-type=zipformer2 \
|
||||||
|
--tokens="$WORK_DIR/models/tokens.txt" \
|
||||||
|
--encoder="$WORK_DIR/models/encoder.onnx" \
|
||||||
|
--decoder="$WORK_DIR/models/decoder.onnx" \
|
||||||
|
--joiner="$WORK_DIR/models/joiner.onnx" \
|
||||||
|
--keywords-file="/data/open-xiaoai/kws/keywords.txt" \
|
||||||
|
--provider=cpu \
|
||||||
|
--num-threads=1 \
|
||||||
|
--chunk-size=1024 \
|
||||||
|
noop
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
cat << 'EOF'
|
||||||
|
|
||||||
|
▄▖ ▖▖▘ ▄▖▄▖
|
||||||
|
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||||
|
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||||
|
▌
|
||||||
|
|
||||||
|
v1.0.0 by: https://del.wang
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MIN_SPACE_MB=32
|
||||||
|
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
|
||||||
|
|
||||||
|
|
||||||
|
check_disk_space() {
|
||||||
|
local space_kb=$(df -k "$1" | awk 'NR==2 {print $4}')
|
||||||
|
if [ $((space_kb / 1024)) -lt "$MIN_SPACE_MB" ]; then
|
||||||
|
echo 1
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR="/data"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
BASE_DIR="/tmp"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
echo "❌ 磁盘空间不足,请先清理磁盘空间(至少需要 $MIN_SPACE_MB MB 空间)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
WORK_DIR="$BASE_DIR/open-xiaoai/kws"
|
||||||
|
KWS_DEBUG_BIN="$WORK_DIR/kws-debug"
|
||||||
|
|
||||||
|
if [ ! -d "$WORK_DIR" ]; then
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/models/encoder.onnx" ]; then
|
||||||
|
echo "🔥 正在下载模型文件..."
|
||||||
|
curl -L -# -o "$WORK_DIR/kws.tar.gz" "$DOWNLOAD_BASE_URL/kws.tar.gz"
|
||||||
|
tar -xzvf "$WORK_DIR/kws.tar.gz" -C "$WORK_DIR"
|
||||||
|
rm "$WORK_DIR/kws.tar.gz"
|
||||||
|
echo "✅ 模型文件下载完毕"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$KWS_DEBUG_BIN" ]; then
|
||||||
|
echo "🔥 正在下载 kws-debug 文件..."
|
||||||
|
curl -L -# -o "$KWS_DEBUG_BIN" "$DOWNLOAD_BASE_URL/kws-debug"
|
||||||
|
chmod +x "$KWS_DEBUG_BIN"
|
||||||
|
echo "✅ kws-debug 文件下载完毕"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔥 正在启动唤醒词识别调试服务,请耐心等待..."
|
||||||
|
echo "🐢 模型加载较慢,请在提示 Started! Please speak 后,再使用自定义唤醒词"
|
||||||
|
|
||||||
|
kill -9 `ps|grep "open-xiaoai/kws/kws-debug"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||||
|
"$KWS_DEBUG_BIN" \
|
||||||
|
--model-type=zipformer2 \
|
||||||
|
--tokens="$WORK_DIR/models/tokens.txt" \
|
||||||
|
--encoder="$WORK_DIR/models/encoder.onnx" \
|
||||||
|
--decoder="$WORK_DIR/models/decoder.onnx" \
|
||||||
|
--joiner="$WORK_DIR/models/joiner.onnx" \
|
||||||
|
--provider=cpu \
|
||||||
|
--num-threads=1 \
|
||||||
|
--chunk-size=1024 \
|
||||||
|
noop
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
cat << 'EOF'
|
||||||
|
|
||||||
|
▄▖ ▖▖▘ ▄▖▄▖
|
||||||
|
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||||
|
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||||
|
▌
|
||||||
|
|
||||||
|
v1.0.0 by: https://del.wang
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MIN_SPACE_MB=32
|
||||||
|
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
|
||||||
|
|
||||||
|
|
||||||
|
check_disk_space() {
|
||||||
|
local space_kb=$(df -k "$1" | awk 'NR==2 {print $4}')
|
||||||
|
if [ $((space_kb / 1024)) -lt "$MIN_SPACE_MB" ]; then
|
||||||
|
echo 1
|
||||||
|
else
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR="/data"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
BASE_DIR="/tmp"
|
||||||
|
if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then
|
||||||
|
echo "❌ 磁盘空间不足,请先清理磁盘空间(至少需要 $MIN_SPACE_MB MB 空间)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
WORK_DIR="$BASE_DIR/open-xiaoai/kws"
|
||||||
|
KWS_BIN="$WORK_DIR/kws"
|
||||||
|
|
||||||
|
if [ ! -d "$WORK_DIR" ]; then
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/models/encoder.onnx" ]; then
|
||||||
|
echo "🔥 正在下载模型文件..."
|
||||||
|
curl -L -# -o "$WORK_DIR/kws.tar.gz" "$DOWNLOAD_BASE_URL/kws.tar.gz"
|
||||||
|
tar -xzvf "$WORK_DIR/kws.tar.gz" -C "$WORK_DIR"
|
||||||
|
chmod +x "$KWS_BIN"
|
||||||
|
rm "$WORK_DIR/kws.tar.gz"
|
||||||
|
echo "✅ 模型文件下载完毕"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR="/data/open-xiaoai/kws"
|
||||||
|
|
||||||
|
if [ ! -d "$CONFIG_DIR" ]; then
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_DIR/keywords.txt" ]; then
|
||||||
|
echo "n ǐ h ǎo x iǎo zh ì @你好小智" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "d òu b āo d òu b āo @豆包豆包" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "t iān m āo j īng l íng @天猫精灵" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "x iǎo d ù x iǎo d ù @小度小度" >> "$CONFIG_DIR/keywords.txt"
|
||||||
|
echo "✅ 默认唤醒词已创建"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" != "--no-monitor" ]; then
|
||||||
|
# 如果没有设置 --no-monitor 参数,则启动 monitor 进程
|
||||||
|
MONITOR_BIN="$CONFIG_DIR/monitor"
|
||||||
|
if [ ! -f "$MONITOR_BIN" ]; then
|
||||||
|
curl -L -# -o "$MONITOR_BIN" "$DOWNLOAD_BASE_URL/monitor"
|
||||||
|
chmod +x "$MONITOR_BIN"
|
||||||
|
fi
|
||||||
|
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||||
|
"$MONITOR_BIN" &
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔥 正在启动唤醒词识别服务,请耐心等待..."
|
||||||
|
echo "🐢 模型加载较慢,请在语音提示初始化成功后,再使用自定义唤醒词"
|
||||||
|
|
||||||
|
kill -9 `ps|grep "open-xiaoai/kws/kws"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||||
|
"$KWS_BIN" \
|
||||||
|
--model-type=zipformer2 \
|
||||||
|
--tokens="$WORK_DIR/models/tokens.txt" \
|
||||||
|
--encoder="$WORK_DIR/models/encoder.onnx" \
|
||||||
|
--decoder="$WORK_DIR/models/decoder.onnx" \
|
||||||
|
--joiner="$WORK_DIR/models/joiner.onnx" \
|
||||||
|
--keywords-file="/data/open-xiaoai/kws/keywords.txt" \
|
||||||
|
--provider=cpu \
|
||||||
|
--num-threads=1 \
|
||||||
|
--chunk-size=1024 \
|
||||||
|
noop
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# dependencies = [
|
||||||
|
# "pypinyin",
|
||||||
|
# ]
|
||||||
|
# [tool.uv]
|
||||||
|
# exclude-newer = "2025-05-06T00:00:00Z"
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Union
|
||||||
|
from pypinyin import pinyin
|
||||||
|
from pypinyin.contrib.tone_convert import to_initials, to_finals_tone
|
||||||
|
|
||||||
|
|
||||||
|
def get_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--text",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
default="my-keywords.txt",
|
||||||
|
help="""Path to the input texts.
|
||||||
|
|
||||||
|
Each line in the texts contains the original phrase, it might also contain some
|
||||||
|
extra items, for example, the boosting score (startting with :), the triggering
|
||||||
|
threshold (startting with #, only used in keyword spotting task) and the original
|
||||||
|
phrase (startting with @). Note: extra items will be kept in the output.
|
||||||
|
|
||||||
|
example input 1 (tokens_type = ppinyin):
|
||||||
|
|
||||||
|
小爱同学 :2.0 #0.6 @小爱同学
|
||||||
|
你好问问 :3.5 @你好问问
|
||||||
|
小艺小艺 #0.6 @小艺小艺
|
||||||
|
|
||||||
|
example output 1:
|
||||||
|
|
||||||
|
x iǎo ài t óng x ué :2.0 #0.6 @小爱同学
|
||||||
|
n ǐ h ǎo w èn w èn :3.5 @你好问问
|
||||||
|
x iǎo y ì x iǎo y ì #0.6 @小艺小艺
|
||||||
|
|
||||||
|
example input 2 (tokens_type = bpe):
|
||||||
|
|
||||||
|
HELLO WORLD :1.5 #0.4
|
||||||
|
HI GOOGLE :2.0 #0.8
|
||||||
|
HEY SIRI #0.35
|
||||||
|
|
||||||
|
example output 2:
|
||||||
|
|
||||||
|
▁HE LL O ▁WORLD :1.5 #0.4
|
||||||
|
▁HI ▁GO O G LE :2.0 #0.8
|
||||||
|
▁HE Y ▁S I RI #0.35
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--tokens",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
default="tokens.txt",
|
||||||
|
help="The path to tokens.txt.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
default="keywords.txt",
|
||||||
|
help="Path where the encoded tokens will be written to.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def text2token(
|
||||||
|
texts: List[str],
|
||||||
|
tokens: str,
|
||||||
|
output_ids: bool = False,
|
||||||
|
):
|
||||||
|
"""将文本转换为 token 列表"""
|
||||||
|
assert Path(tokens).is_file(), f"File not exists, {tokens}"
|
||||||
|
tokens_table = {}
|
||||||
|
with open(tokens, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
toks = line.strip().split()
|
||||||
|
assert len(toks) == 2, len(toks)
|
||||||
|
assert toks[0] not in tokens_table, f"Duplicate token: {toks} "
|
||||||
|
tokens_table[toks[0]] = int(toks[1])
|
||||||
|
|
||||||
|
texts_list: List[List[str]] = []
|
||||||
|
for txt in texts:
|
||||||
|
res = []
|
||||||
|
py = [x[0] for x in pinyin(txt)]
|
||||||
|
for x in py:
|
||||||
|
initial = to_initials(x, strict=False)
|
||||||
|
final = to_finals_tone(x, strict=False)
|
||||||
|
if initial == "" and final == "":
|
||||||
|
res.append(x)
|
||||||
|
else:
|
||||||
|
if initial != "":
|
||||||
|
res.append(initial)
|
||||||
|
if final != "":
|
||||||
|
res.append(final)
|
||||||
|
texts_list.append(res)
|
||||||
|
|
||||||
|
result: List[List[Union[int, str]]] = []
|
||||||
|
for text in texts_list:
|
||||||
|
text_list = []
|
||||||
|
contain_oov = False
|
||||||
|
for txt in text:
|
||||||
|
if txt in tokens_table:
|
||||||
|
text_list.append(tokens_table[txt] if output_ids else txt)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"Can't find token {txt} in token table, check your "
|
||||||
|
f"tokens.txt see if {txt} in it. skipping text : {text}."
|
||||||
|
)
|
||||||
|
contain_oov = True
|
||||||
|
break
|
||||||
|
if contain_oov:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
result.append(text_list)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = get_args()
|
||||||
|
|
||||||
|
texts = []
|
||||||
|
# extra information like boosting score (start with :), triggering threshold (start with #)
|
||||||
|
# original keyword (start with @)
|
||||||
|
extra_info = []
|
||||||
|
with open(args.text, "r", encoding="utf8") as f:
|
||||||
|
for line in f:
|
||||||
|
extra = []
|
||||||
|
text = []
|
||||||
|
toks = line.strip().split()
|
||||||
|
if len(toks) == 1:
|
||||||
|
text.append(toks[0])
|
||||||
|
extra.append('@'+toks[0])
|
||||||
|
else:
|
||||||
|
for tok in toks:
|
||||||
|
if tok[0] == ":" or tok[0] == "#" or tok[0] == "@":
|
||||||
|
extra.append(tok)
|
||||||
|
else:
|
||||||
|
text.append(tok)
|
||||||
|
texts.append(" ".join(text))
|
||||||
|
extra_info.append(extra)
|
||||||
|
encoded_texts = text2token(
|
||||||
|
texts,
|
||||||
|
tokens=args.tokens,
|
||||||
|
)
|
||||||
|
with open(args.output, "w", encoding="utf8") as f:
|
||||||
|
for i, txt in enumerate(encoded_texts):
|
||||||
|
txt += extra_info[i]
|
||||||
|
f.write(" ".join(txt) + "\n")
|
||||||
|
print(f"✅ 唤醒词已保存到 {args.output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
t iān m āo j īng l íng @天猫精灵
|
||||||
|
x iǎo d ù x iǎo d ù @小度小度
|
||||||
|
d òu b āo d òu b āo @豆包豆包
|
||||||
|
n ǐ h ǎo x iǎo zh ì @你好小智
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
天猫精灵
|
||||||
|
小度小度
|
||||||
|
豆包豆包
|
||||||
|
你好小智
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<blk> 0
|
||||||
|
<sos/eos> 1
|
||||||
|
<unk> 2
|
||||||
|
A 3
|
||||||
|
B 4
|
||||||
|
S 5
|
||||||
|
I 6
|
||||||
|
f 7
|
||||||
|
ù 8
|
||||||
|
ǔ 9
|
||||||
|
zh 10
|
||||||
|
y 11
|
||||||
|
ī 12
|
||||||
|
sh 13
|
||||||
|
ēng 14
|
||||||
|
uè 15
|
||||||
|
p 16
|
||||||
|
iàn 17
|
||||||
|
K 18
|
||||||
|
L 19
|
||||||
|
P 20
|
||||||
|
R 21
|
||||||
|
T 22
|
||||||
|
M 23
|
||||||
|
j 24
|
||||||
|
W 25
|
||||||
|
q 26
|
||||||
|
s 27
|
||||||
|
ān 28
|
||||||
|
b 29
|
||||||
|
ā 30
|
||||||
|
l 31
|
||||||
|
íng 32
|
||||||
|
iǔ 33
|
||||||
|
ch 34
|
||||||
|
ǐ 35
|
||||||
|
ì 36
|
||||||
|
èr 37
|
||||||
|
w 38
|
||||||
|
r 39
|
||||||
|
án 40
|
||||||
|
én 41
|
||||||
|
ó 42
|
||||||
|
g 43
|
||||||
|
uǎn 44
|
||||||
|
c 45
|
||||||
|
āng 46
|
||||||
|
ǎn 47
|
||||||
|
x 48
|
||||||
|
iān 49
|
||||||
|
ōng 50
|
||||||
|
àng 51
|
||||||
|
òng 52
|
||||||
|
d 53
|
||||||
|
ài 54
|
||||||
|
iù 55
|
||||||
|
ìng 56
|
||||||
|
ū 57
|
||||||
|
m 58
|
||||||
|
àn 59
|
||||||
|
ǎ 60
|
||||||
|
í 61
|
||||||
|
z 62
|
||||||
|
k 63
|
||||||
|
ǒu 64
|
||||||
|
ūn 65
|
||||||
|
uó 66
|
||||||
|
èng 67
|
||||||
|
òu 68
|
||||||
|
ú 69
|
||||||
|
éng 70
|
||||||
|
à 71
|
||||||
|
iào 72
|
||||||
|
āo 73
|
||||||
|
ào 74
|
||||||
|
h 75
|
||||||
|
uáng 76
|
||||||
|
t 77
|
||||||
|
iáo 78
|
||||||
|
è 79
|
||||||
|
iǎn 80
|
||||||
|
er 81
|
||||||
|
n 82
|
||||||
|
èi 83
|
||||||
|
ǐng 84
|
||||||
|
uā 85
|
||||||
|
ēi 86
|
||||||
|
ǎo 87
|
||||||
|
iú 88
|
||||||
|
uàng 89
|
||||||
|
uí 90
|
||||||
|
ǜ 91
|
||||||
|
āi 92
|
||||||
|
óu 93
|
||||||
|
ià 94
|
||||||
|
ǎng 95
|
||||||
|
īn 96
|
||||||
|
uà 97
|
||||||
|
uǐ 98
|
||||||
|
ái 99
|
||||||
|
ò 100
|
||||||
|
ē 101
|
||||||
|
óng 102
|
||||||
|
uàn 103
|
||||||
|
é 104
|
||||||
|
ìn 105
|
||||||
|
ùn 106
|
||||||
|
uò 107
|
||||||
|
ún 108
|
||||||
|
uì 109
|
||||||
|
uān 110
|
||||||
|
ǐn 111
|
||||||
|
iāo 112
|
||||||
|
ōu 113
|
||||||
|
uán 114
|
||||||
|
iǎo 115
|
||||||
|
á 116
|
||||||
|
ér 117
|
||||||
|
èn 118
|
||||||
|
C 119
|
||||||
|
D 120
|
||||||
|
E 121
|
||||||
|
O 122
|
||||||
|
N 123
|
||||||
|
Z 124
|
||||||
|
ěi 125
|
||||||
|
ián 126
|
||||||
|
ēn 127
|
||||||
|
iā 128
|
||||||
|
ǚ 129
|
||||||
|
áng 130
|
||||||
|
iě 131
|
||||||
|
ǒng 132
|
||||||
|
éi 133
|
||||||
|
iāng 134
|
||||||
|
J 135
|
||||||
|
V 136
|
||||||
|
ín 137
|
||||||
|
ié 138
|
||||||
|
īng 139
|
||||||
|
ō 140
|
||||||
|
ěr 141
|
||||||
|
F 142
|
||||||
|
ué 143
|
||||||
|
iǎng 144
|
||||||
|
uō 145
|
||||||
|
G 146
|
||||||
|
ǎi 147
|
||||||
|
ěn 148
|
||||||
|
H 149
|
||||||
|
en 150
|
||||||
|
X 151
|
||||||
|
i 152
|
||||||
|
ǒ 153
|
||||||
|
uǒ 154
|
||||||
|
iè 155
|
||||||
|
iū 156
|
||||||
|
U 157
|
||||||
|
iàng 158
|
||||||
|
uāng 159
|
||||||
|
uá 160
|
||||||
|
Q 161
|
||||||
|
ě 162
|
||||||
|
e 163
|
||||||
|
iǎ 164
|
||||||
|
iáng 165
|
||||||
|
Y 166
|
||||||
|
○ 167
|
||||||
|
iē 168
|
||||||
|
uī 169
|
||||||
|
ěng 170
|
||||||
|
uài 171
|
||||||
|
áo 172
|
||||||
|
a 173
|
||||||
|
ou 174
|
||||||
|
ǔn 175
|
||||||
|
uái 176
|
||||||
|
uāi 177
|
||||||
|
ióng 178
|
||||||
|
ei 179
|
||||||
|
ń 180
|
||||||
|
iá 181
|
||||||
|
iōng 182
|
||||||
|
üè 183
|
||||||
|
uē 184
|
||||||
|
uǎng 185
|
||||||
|
uǎ 186
|
||||||
|
o 187
|
||||||
|
uǎi 188
|
||||||
|
uě 189
|
||||||
|
ǘ 190
|
||||||
|
uo 191
|
||||||
|
iǒng 192
|
||||||
|
ang 193
|
||||||
|
ia 194
|
||||||
|
u 195
|
||||||
|
üě 196
|
||||||
|
#0 197
|
||||||
|
#1 198
|
||||||
|
#2 199
|
||||||
|
#3 200
|
||||||
|
#4 201
|
||||||
|
#5 202
|
||||||
|
#6 203
|
||||||
|
#7 204
|
||||||
|
#8 205
|
||||||
|
#9 206
|
||||||
|
#10 207
|
||||||
|
#11 208
|
||||||
|
#12 209
|
||||||
|
#13 210
|
||||||
|
#14 211
|
||||||
|
#15 212
|
||||||
|
#16 213
|
||||||
|
#17 214
|
||||||
|
#18 215
|
||||||
|
#19 216
|
||||||
|
#20 217
|
||||||
|
#21 218
|
||||||
|
#22 219
|
||||||
|
#23 220
|
||||||
|
#24 221
|
||||||
|
#25 222
|
||||||
|
#26 223
|
||||||
|
#27 224
|
||||||
|
#28 225
|
||||||
|
#29 226
|
||||||
@@ -390,6 +390,7 @@ name = "open-xiaoai"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -480,13 +481,12 @@ checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
|
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"zerocopy",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10,7 +10,7 @@ crate-type = ["cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
neon = { version = "1.1.0-alpha.1", features = ["napi-6", "futures", "tokio"] }
|
neon = { version = "1.1.0-alpha.1", features = ["napi-6", "futures", "tokio"] }
|
||||||
open-xiaoai = { path = "../client-rust" }
|
open-xiaoai = { path = "../../packages/client-rust" }
|
||||||
tokio = { version = "1.32.0", features = ["full"] }
|
tokio = { version = "1.32.0", features = ["full"] }
|
||||||
tokio-tungstenite = "0.26.2"
|
tokio-tungstenite = "0.26.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
FROM ubuntu:22.04 AS builder
|
||||||
|
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY examples/migpt .
|
||||||
|
COPY packages/client-rust ./client-rust
|
||||||
|
|
||||||
|
# 更新源
|
||||||
|
RUN apt update && apt install curl build-essential -y
|
||||||
|
|
||||||
|
# 安装 Rust
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
|
||||||
|
# Create a script file sourced by both interactive and non-interactive bash shells
|
||||||
|
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"
|
||||||
|
|
||||||
|
# nvm
|
||||||
|
RUN echo 'export NVM_DIR="$HOME/.nvm"' >> "$BASH_ENV"
|
||||||
|
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> "$BASH_ENV"
|
||||||
|
RUN echo '[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"' >> "$BASH_ENV"
|
||||||
|
|
||||||
|
# 安装 Node.js
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \
|
||||||
|
&& \. $NVM_DIR/nvm.sh && \. $NVM_DIR/bash_completion \
|
||||||
|
&& nvm install 22
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install -g corepack \
|
||||||
|
&& corepack enable && corepack install \
|
||||||
|
&& pnpm install
|
||||||
|
|
||||||
|
# 构建 Rust Server
|
||||||
|
RUN sed -i 's/\.\.\/\.\.\/packages\///g' Cargo.toml \
|
||||||
|
&& pnpm build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
FROM ubuntu:22.04 AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/migpt /app/migpt
|
||||||
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
COPY --from=builder /app/tsconfig.json /app/tsconfig.json
|
||||||
|
COPY --from=builder /app/node_modules /app/node_modules
|
||||||
|
|
||||||
|
# 更新源
|
||||||
|
RUN apt update && apt install curl build-essential -y
|
||||||
|
|
||||||
|
ENV NVM_DIR=/root/.nvm
|
||||||
|
|
||||||
|
# 安装 Node.js
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \
|
||||||
|
&& bash -c "source $NVM_DIR/nvm.sh && nvm install 22"
|
||||||
|
|
||||||
|
ENTRYPOINT ["bash", "-c", "source $NVM_DIR/nvm.sh && exec \"$@\"", "--"]
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Open-XiaoAI x MiGPT-Next
|
||||||
|
|
||||||
|
[Open-XiaoAI](https://github.com/idootop/open-xiaoai) 的 Node.js 版 Server 端,用来演示小爱音箱接入[MiGPT](https://github.com/idootop/mi-gpt)(完美版)。
|
||||||
|
|
||||||
|
相比原版的 `MiGPT` 和 `MiGPT-Next` 项目,该版本可以完美打断小爱音箱的回复,响应延迟更低,效果更完美 👍
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 继续下面的操作之前,你需要先在小爱音箱上启动运行 Rust 补丁程序 [👉 教程](../../packages/client-rust/README.md)
|
||||||
|
|
||||||
|
首先,克隆仓库代码到本地。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# 克隆代码
|
||||||
|
git clone https://github.com/idootop/open-xiaoai.git
|
||||||
|
|
||||||
|
# 进入当前项目根目录
|
||||||
|
cd examples/migpt
|
||||||
|
```
|
||||||
|
|
||||||
|
然后把 `config.ts` 文件里的配置修改成你自己的。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const kOpenXiaoAIConfig = {
|
||||||
|
openai: {
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
system: "你是一个智能助手,请根据用户的问题给出回答。",
|
||||||
|
},
|
||||||
|
async onMessage(engine, { text }) {
|
||||||
|
if (text === "测试") {
|
||||||
|
return { text: "你好,很高兴认识你!" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 运行
|
||||||
|
|
||||||
|
[](https://hub.docker.com/r/idootop/open-xiaoai-migpt)
|
||||||
|
|
||||||
|
推荐使用以下命令,直接 Docker 一键运行。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -it --rm -p 4399:4399 -v $(pwd)/config.ts:/app/config.ts idootop/open-xiaoai-migpt:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编译运行
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果你是一名开发者,想要修改源代码实现自己想要的功能,可以按照下面的步骤,自行编译运行该项目。
|
||||||
|
|
||||||
|
为了能够正常编译运行该项目,你需要安装以下依赖环境:
|
||||||
|
|
||||||
|
- Node.js v22.x: https://nodejs.org/zh-cn/download
|
||||||
|
- Rust: https://www.rust-lang.org/learn/get-started
|
||||||
|
|
||||||
|
准备好开发环境后,按以下步骤即可正常启动该项目。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启用 PNPM 包管理工具
|
||||||
|
corepack enable && corepack install
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 编译运行
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 默认 Server 服务端口为 `4399`(比如 ws://192.168.31.227:4399),运行前请确保该端口未被其他程序占用。
|
||||||
|
|
||||||
|
2. 默认 Rust Server 在启动时,并没有开启小爱音箱的录音能力。
|
||||||
|
如果你需要在 Node.js 端正常接收音频输入流,或者播放音频输出流,请将 `src/server.rs` 文件中被注释掉的 `start_recording` 和 `start_play` 代码加回来,然后重新编译运行。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本项目只是一个简单的演示程序,抛砖引玉。如果你想要更多的功能,比如唤醒词识别、语音转文字、连续对话等(甚至是对接 OpenAI 的 [Realtime API](https://platform.openai.com/docs/guides/realtime)),可参考本项目代码自行实现。
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { sleep } from "@mi-gpt/utils";
|
||||||
|
import { OpenXiaoAIConfig } from "./migpt/xiaoai.js";
|
||||||
|
|
||||||
|
export const kOpenXiaoAIConfig: OpenXiaoAIConfig = {
|
||||||
|
openai: {
|
||||||
|
/**
|
||||||
|
* 你的大模型服务提供商的接口地址
|
||||||
|
*
|
||||||
|
* 支持兼容 OpenAI 接口的大模型服务,比如:DeepSeek V3 等
|
||||||
|
*
|
||||||
|
* 注意:一般以 /v1 结尾,不包含 /chat/completions 部分
|
||||||
|
* - ✅ https://api.openai.com/v1
|
||||||
|
* - ❌ https://api.openai.com/v1/(最后多了一个 /
|
||||||
|
* - ❌ https://api.openai.com/v1/chat/completions(不需要加 /chat/completions)
|
||||||
|
*/
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
/**
|
||||||
|
* API 密钥
|
||||||
|
*/
|
||||||
|
apiKey: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
/**
|
||||||
|
* 模型名称
|
||||||
|
*/
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
/**
|
||||||
|
* 系统提示词,如需关闭可设置为:''(空字符串)
|
||||||
|
*/
|
||||||
|
system: "你是一个智能助手,请根据用户的问题给出回答。",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
/**
|
||||||
|
* 每次对话携带的最大历史消息数(如需关闭可设置为:0)
|
||||||
|
*/
|
||||||
|
historyMaxLength: 10,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 只回答以下关键词开头的消息:
|
||||||
|
*
|
||||||
|
* - 请问地球为什么是圆的?
|
||||||
|
* - 你知道世界上跑的最快的动物是什么吗?
|
||||||
|
*/
|
||||||
|
callAIKeywords: ["请", "你"],
|
||||||
|
/**
|
||||||
|
* 自定义消息回复
|
||||||
|
*/
|
||||||
|
async onMessage(engine, { text }) {
|
||||||
|
if (text === "测试播放文字") {
|
||||||
|
return { text: "你好,很高兴认识你!" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === "测试播放音乐") {
|
||||||
|
return { url: "https://example.com/hello.mp3" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === "测试其他能力") {
|
||||||
|
// 打断原来小爱的回复
|
||||||
|
await engine.speaker.abortXiaoAI();
|
||||||
|
|
||||||
|
// 播放文字
|
||||||
|
await sleep(2000); // 打断小爱后需要等待 2 秒,使其恢复运行后才能继续 TTS
|
||||||
|
await engine.speaker.play({ text: "你好,很高兴认识你!", blocking: true });
|
||||||
|
|
||||||
|
// 播放音频链接
|
||||||
|
await engine.speaker.play({ url: "https://example.com/hello.mp3" });
|
||||||
|
|
||||||
|
// 告诉 MiGPT 已经处理过这条消息了,不再使用默认的 AI 回复
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { kOpenXiaoAIConfig } from "config.js";
|
||||||
|
import { OpenXiaoAI } from "./xiaoai.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await OpenXiaoAI.start(kOpenXiaoAIConfig);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -71,7 +71,7 @@ class SpeakerManager implements ISpeaker {
|
|||||||
const res = await this.runShell(
|
const res = await this.runShell(
|
||||||
url
|
url
|
||||||
? `miplayer -f '${url}'`
|
? `miplayer -f '${url}'`
|
||||||
: `/usr/sbin/tts_play2.sh '${text || "你好"}'`,
|
: `/usr/sbin/tts_play.sh '${text || "你好"}'`,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
return res?.exit_code === 0;
|
return res?.exit_code === 0;
|
||||||
@@ -104,7 +104,7 @@ class SpeakerManager implements ISpeaker {
|
|||||||
silent: boolean;
|
silent: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { silent = false } = options ?? {};
|
const { silent = true } = options ?? {};
|
||||||
const command = awake
|
const command = awake
|
||||||
? silent
|
? silent
|
||||||
? `ubus call pnshelper event_notify '{"src":1,"event":0}'`
|
? `ubus call pnshelper event_notify '{"src":1,"event":0}'`
|
||||||
@@ -6,7 +6,7 @@ import { RustServer } from "./open-xiaoai.js";
|
|||||||
import { OpenXiaoAISpeaker } from "./speaker.js";
|
import { OpenXiaoAISpeaker } from "./speaker.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
type OpenXiaoAIConfig = Prettify<EngineConfig<OpenXiaoAIEngine>>;
|
export type OpenXiaoAIConfig = Prettify<EngineConfig<OpenXiaoAIEngine>>;
|
||||||
|
|
||||||
const kDefaultOpenXiaoAIConfig: OpenXiaoAIConfig = {
|
const kDefaultOpenXiaoAIConfig: OpenXiaoAIConfig = {
|
||||||
//
|
//
|
||||||
@@ -57,6 +57,9 @@ class OpenXiaoAIEngine extends MiGPTEngine {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (e.event === "kws") {
|
||||||
|
const keyword = e.data;
|
||||||
|
console.log("🔥 唤醒词识别", keyword);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-xiaoai-node",
|
"name": "open-xiaoai-migpt",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"description": "小爱音箱接入 MiGPT-Next",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,11 +10,11 @@
|
|||||||
"dev": "pnpm build && pnpm start"
|
"dev": "pnpm build && pnpm start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mi-gpt/engine": "1.1.1",
|
"@mi-gpt/engine": "1.2.1",
|
||||||
"@mi-gpt/utils": "1.0.0"
|
"@mi-gpt/utils": "1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mi-gpt/config": "1.0.0",
|
"@mi-gpt/config": "1.1.0",
|
||||||
"@neon-rs/cli": "0.1.82",
|
"@neon-rs/cli": "0.1.82",
|
||||||
"@types/node": "^22.13.11",
|
"@types/node": "^22.13.11",
|
||||||
"cargo-cp-artifact": "^0.1.9",
|
"cargo-cp-artifact": "^0.1.9",
|
||||||
@@ -9,15 +9,15 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mi-gpt/engine':
|
'@mi-gpt/engine':
|
||||||
|
specifier: 1.2.1
|
||||||
|
version: 1.2.1
|
||||||
|
'@mi-gpt/utils':
|
||||||
specifier: 1.1.1
|
specifier: 1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
'@mi-gpt/utils':
|
|
||||||
specifier: 1.0.0
|
|
||||||
version: 1.0.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@mi-gpt/config':
|
'@mi-gpt/config':
|
||||||
specifier: 1.0.0
|
specifier: 1.1.0
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
'@neon-rs/cli':
|
'@neon-rs/cli':
|
||||||
specifier: 0.1.82
|
specifier: 0.1.82
|
||||||
version: 0.1.82
|
version: 0.1.82
|
||||||
@@ -236,28 +236,28 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@mi-gpt/chat@1.0.0':
|
'@mi-gpt/chat@1.1.1':
|
||||||
resolution: {integrity: sha512-PyyQ4Vg3s1OcUxr9GXZTpBgGCRQldhm9vGKc3UpAA4QDyVJWSP6ILhXKfFHO+UfnxDr45y9wlk5PpMcgkmPNkQ==}
|
resolution: {integrity: sha512-C5b498nGC1ZAg6/Nl5XWneOwnUQ9CgK+0wCEqNDHYkj96YWqpbW3uGS6ubgNncpVMg/uXMoc55l1hsUF0lz+oQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@mi-gpt/config@1.0.0':
|
'@mi-gpt/config@1.1.0':
|
||||||
resolution: {integrity: sha512-oo/roYiRcGiHsZESjukwPTsRFqddShTJ8gkccR5ZMYn8SySj07A0nCsEsLEfB9mLkvNY8D6PCO1NxbRDDRBBlA==}
|
resolution: {integrity: sha512-o24iFINsoDuvInnMpfBQ4zCuTObbdodjXyizXmxl3yyYOMjmIAXizO/1UZ9/dv3UVTov3sjL09aCGuBeIVhvtA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@mi-gpt/engine@1.1.1':
|
'@mi-gpt/engine@1.2.1':
|
||||||
resolution: {integrity: sha512-TyA6JCq/yGvJI+ysFuDOwLaDUJ1fjjtxHIGB3Nyr40R0Ujb32Ssz/FoCDpbVmTKj2niebZQAGuUl7/juBuQwtw==}
|
resolution: {integrity: sha512-EmBVS/l1xj8N54ZHRmYcV0jl9vh0QBPsyMdjwmS5h9+cdG40ItUq+shVg9ZHJrwoixKIiagYJ4lAjw+yUQ1DUQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@mi-gpt/openai@1.0.0':
|
'@mi-gpt/openai@1.1.1':
|
||||||
resolution: {integrity: sha512-BZI2gJkcJ4TuHcyyD5fzvFzEMlSkfkkuxpycQ/mSiMC8Z3umQbRSk2z1Tg3ho8DFxf5tYiIsFzbvWCBw2WXlwg==}
|
resolution: {integrity: sha512-pEs9y/GMY/ZDmlFfcBuowuvyE+j3cTKeQY0cMhZ9AobXD/qGNWlP7gQXojsSbVg/D0bB2snhEiWv/X5s1EzJWg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@mi-gpt/stream@1.0.0':
|
'@mi-gpt/stream@1.1.1':
|
||||||
resolution: {integrity: sha512-nzYKGODEhTbBUv6WGmgEY4rioXfWRvs2te99nQWjl+agmN9bT7KTbHKm8tzTDApQcQltx5BTDXrXMairmLVTYQ==}
|
resolution: {integrity: sha512-4zd8brCqGX05tStweUWFdeE4EVpZGtFXHfvfYqvdJWccFK4PZkW3ynvJ0lc4N8SdsJo5Ml6XxOftRpZUnfGJtg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@mi-gpt/utils@1.0.0':
|
'@mi-gpt/utils@1.1.1':
|
||||||
resolution: {integrity: sha512-hAU/uYPNiNJgXWkQrVqRLe/9Uz8AgtoiNwcbbwQs8iXJcyq3NjcSHkkwUseJAnaQFE9iv/kvHZZsx3r2HhlKjw==}
|
resolution: {integrity: sha512-FiGHV2RDBRGE/Xb9Q5H757t3ZqOpo6gL+44VXiRxQb3fQiapOpn038RAWErHJYZajYzkxP2jl8Wggsb+wp77Zg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
'@neon-rs/cli@0.1.82':
|
'@neon-rs/cli@0.1.82':
|
||||||
@@ -671,34 +671,34 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.2':
|
'@esbuild/win32-x64@0.25.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@mi-gpt/chat@1.0.0':
|
'@mi-gpt/chat@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mi-gpt/openai': 1.0.0
|
'@mi-gpt/openai': 1.1.1
|
||||||
'@mi-gpt/stream': 1.0.0
|
'@mi-gpt/stream': 1.1.1
|
||||||
'@mi-gpt/utils': 1.0.0
|
'@mi-gpt/utils': 1.1.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mi-gpt/config@1.0.0': {}
|
'@mi-gpt/config@1.1.0': {}
|
||||||
|
|
||||||
'@mi-gpt/engine@1.1.1':
|
'@mi-gpt/engine@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mi-gpt/chat': 1.0.0
|
'@mi-gpt/chat': 1.1.1
|
||||||
'@mi-gpt/openai': 1.0.0
|
'@mi-gpt/openai': 1.1.1
|
||||||
'@mi-gpt/stream': 1.0.0
|
'@mi-gpt/stream': 1.1.1
|
||||||
'@mi-gpt/utils': 1.0.0
|
'@mi-gpt/utils': 1.1.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mi-gpt/openai@1.0.0':
|
'@mi-gpt/openai@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mi-gpt/utils': 1.0.0
|
'@mi-gpt/utils': 1.1.1
|
||||||
openai: 4.93.0
|
openai: 4.93.0
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -707,11 +707,11 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mi-gpt/stream@1.0.0':
|
'@mi-gpt/stream@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mi-gpt/utils': 1.0.0
|
'@mi-gpt/utils': 1.1.1
|
||||||
|
|
||||||
'@mi-gpt/utils@1.0.0': {}
|
'@mi-gpt/utils@1.1.1': {}
|
||||||
|
|
||||||
'@neon-rs/cli@0.1.82':
|
'@neon-rs/cli@0.1.82':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
"@/*": ["migpt/*"]
|
"@/*": ["migpt/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["migpt"],
|
"include": ["migpt", "config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||