80 Commits
Author SHA1 Message Date
Del Wang 5d187f5d5b docs(v2): 开发中,无限期 delay 2026-01-08 09:55:30 +08:00
Del Wang 3cf6771cc9 refactor: 异步 RPC 2026-01-08 09:53:59 +08:00
Del Wang fb6c1fd9f8 refactor: 事件收发与处理 2026-01-06 18:57:14 +08:00
Del Wang 487a5b8c14 refactor: 优化推流播放与录音,支持立体声 2026-01-06 14:06:15 +08:00
Del Wang 0125a1478e refactor: 优化音频读取与编码传输 2026-01-06 12:08:06 +08:00
Del Wang c0bc4b8ae6 fix: 修复服务端推流播放异常的问题 2026-01-05 20:13:25 +08:00
Del Wang d88d63b15b chore: polish code 2026-01-04 15:06:55 +08:00
Del Wang cce3dd630a refactor: 重构 Open-XiaoAI Client V2 2026-01-04 11:24:37 +08:00
Del Wang 052ce06355 chore: polish client/server code 2026-01-04 10:42:06 +08:00
Del Wang eeb442d5b8 refactor: 分离音频管理模块 2026-01-03 19:26:35 +08:00
Del Wang 34924fa06e chore: refine client/server mod 2026-01-03 19:17:13 +08:00
Del Wang c4f7fbec6d chore: test client call server 2026-01-02 17:37:27 +08:00
Del Wang a2d06958e1 chore: 测试播放和录音 2026-01-02 17:23:24 +08:00
Del Wang 24b3fdddf8 fix(stereo): DISCOVERY_MAGIC 2026-01-02 16:25:48 +08:00
Del Wang 6f55021651 refactor: handlers 2026-01-02 15:16:31 +08:00
Del Wang 4ed8d3dca9 chore: 初始化测试场景 2026-01-02 13:04:19 +08:00
Del Wang 33da56dead fix: 修复 runtime 镜像 2026-01-02 12:50:23 +08:00
Del Wang ef28121f5d refactor: 调整模块位置 2026-01-02 10:22:16 +08:00
Del Wang 4d459afd28 chore: 初始化 client v2 2026-01-02 09:59:29 +08:00
Del Wang aa2a5d8474 chore(stereo): 优化播放设备重初始化 2026-01-01 20:27:42 +08:00
Del Wang b69bd5a8f9 fix(stereo): 修复主从节点同步异常的问题 2026-01-01 19:58:04 +08:00
Del Wang f4340f068f chore: 更新固件版本 2026-01-01 17:12:02 +08:00
Del Wang b07e7500c1 chore: 优化开机初始化脚本 2026-01-01 17:06:10 +08:00
Del Wang 2027a61877 feat: 新增不同型号小爱音箱组立体声应用 2026-01-01 14:29:16 +08:00
Del Wang 3017473850 fix: 异步播放提示音,防止阻塞 2026-01-01 14:28:32 +08:00
Del Wang 118c34eb5c chore: 完善从节点连接提示音 2026-01-01 13:05:06 +08:00
Del WangandDel ab872180fc feat(stereo): 新增不同型号的小爱音箱组立体声应用 2025-12-31 22:53:33 +08:00
Del WangandDel 7bdbb30bd2 chore: 清空 apps 目录 2025-12-31 22:53:33 +08:00
Del WangandDel 7e80d6cd74 feat: 新增小爱音箱交叉编译环境 2025-12-31 22:53:33 +08:00
Del WangandDel 2ff214c873 feat: 自动重启音频应用使配置生效/恢复 2025-12-31 22:53:33 +08:00
Del WangandDel cdb4fcff85 chore: 调整从节点等待时间间隔 2025-12-31 22:53:33 +08:00
Del WangandDel ba6eaf6674 Revert "feat: 支持多播放源并发混音"
This reverts commit 32dced2fa2.
2025-12-31 22:53:33 +08:00
Del WangandDel 41be071b60 Revert "chore: 修改间隔"
This reverts commit 0c8fbafd3f.
2025-12-31 22:53:33 +08:00
Del WangandDel 84c332d742 chore: 修改间隔 2025-12-31 22:53:33 +08:00
Del WangandDel 09c625cf32 feat: 支持多播放源并发混音 2025-12-31 22:53:33 +08:00
Del WangandDel aabe882b88 fix: 修复 master 无法正常退出的问题 2025-12-31 22:53:33 +08:00
Del WangandDel 316f61d0bb chore: 整理 AudioConfig 2025-12-31 22:53:33 +08:00
Del WangandDel 4bce9e8d23 chore: 优化 alsa 配置 2025-12-31 22:53:33 +08:00
Del WangandDel 871d01a158 fix: 修复开始播放音频时爆音的问题 2025-12-31 22:53:33 +08:00
Del WangandDel 1caef01836 fix: 支持从节点订阅不同声道 2025-12-31 22:53:33 +08:00
Del WangandDel f4b70088fe fix: 修复 master 退出环境清理流程 2025-12-31 22:53:33 +08:00
Del WangandDel 81ac343098 feat: 支持动态切换主从播放模式 2025-12-31 22:53:33 +08:00
Del WangandDel b277fb7234 chore: 优化连接提示 2025-12-31 22:53:33 +08:00
Del WangandDel 9fe7ebd65d fix: run_slave 支持主节点下线后自动重新连接 2025-12-31 22:53:33 +08:00
Del WangandDel f7dd1586e8 fix: 修复 OpusCodec::decode_loss 2025-12-31 22:53:33 +08:00
Del WangandDel 28d714a2ef refactor: 拆分网络通信模块 2025-12-31 22:53:33 +08:00
Del WangandDel 76260d412c refactor: 拆分 stereo 模块 2025-12-31 22:53:33 +08:00
Del WangandDel 05d2174873 refactor: 使用 UDP 传输音频流 2025-12-31 22:53:33 +08:00
Del WangandDel fbe64f63dd chore: 从设备使用默认输出 2025-12-31 22:53:33 +08:00
Del WangandDel 518436d000 chore: 直接读取本地 /etc/asound.conf 配置文件 2025-12-31 22:53:33 +08:00
Del WangandDel 9b2f61b037 chore: 优化代码 2025-12-31 22:53:33 +08:00
Del WangandDel bd9565577f feat: 优化立体声同步播放 2025-12-31 22:53:33 +08:00
Del WangandDel 962647afea feat: 多设备组立体声 2025-12-31 22:53:33 +08:00
Del WangandDel 8e788f4b47 feat: 优化音频传输参数 2025-12-31 22:53:33 +08:00
Del WangandDel 392d8bc21b feat: 完成立体声 demo 的 client / server 构建 2025-12-31 22:53:33 +08:00
Del WangandDel 06645dc563 feat: 立体声 client 构建 2025-12-31 22:53:33 +08:00
Del WangandDel 61ecb07a99 chore: 拆分 bin 入口 2025-12-31 22:53:33 +08:00
Del WangandDel 3d9d852bda perf: 优化产物体积 2025-12-31 22:53:33 +08:00
Del WangandDel fba40393c2 feat: 动态链接到 opus 和 alsa 基础库,完成音频录制播放和编解码 2025-12-31 22:53:33 +08:00
Del WangandDel c521e274e9 feat: 新增测试 runtime 2025-12-31 22:53:33 +08:00
DelandGitHub 70a64e1eee Merge pull request #90 from hehetoshang/main
ci: 添加 Rust 客户端编译工作流
2025-12-31 21:55:35 +08:00
hehetoshang 77a19c41e8 Merge branch 'main' of https://github.com/hehetoshang/open-xiaoai 2025-12-31 20:10:15 +08:00
hehetoshang b7292dbd12 ci: 添加 Rust 客户端编译工作流
添加用于编译 client-rust 的 GitHub Actions 工作流,当 main 分支的 client-rust 代码变更时自动触发。工作流包含 Rust 工具链安装、交叉编译和二进制文件上传步骤。
2025-12-31 20:10:05 +08:00
Del Wang 0f75a10b9f chore: 更新 xiaozhi 自启动初始化脚本 2025-12-10 18:27:26 +08:00
Del Wang 2d6c17d02f fix: 修复自启动脚本网络连接状态检测 2025-12-10 18:16:51 +08:00
DelandGitHub 871a04ef79 Merge pull request #66 from x1a0b0/refactor
Some refactors
2025-10-13 17:45:29 +08:00
x1a0b0 7b473ad86a Eliminate several singletons
just for better lifecycle management

Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 14:20:11 +08:00
x1a0b0 61943b16ac Apply clippy lint
Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 11:34:44 +08:00
x1a0b0 d43a9588c9 Update deps
Signed-off-by: x1a0b0 <xbtseng@gmail.com>
2025-10-13 11:29:48 +08:00
3D_DLWandDel 5e70f73c1e Update sed command delimiter in patch.sh
Fix sed command to use pipe instead of slash for delimiter.
2025-09-27 14:51:21 +08:00
Del Wang 31e997a192 chore: debug ota 2025-09-01 09:49:07 +08:00
Del Wang 39b2dfe220 feat: 更新 Xiaomi 智能音箱 Pro v1.58.1 固件 2025-08-31 19:29:52 +08:00
Del Wang 066fddbb68 feat: 更新小爱音箱 Pro v1.88.221 固件 2025-08-26 09:38:04 +08:00
Del Wang b5d00db7ca fix: 开机启动延时应不低于 15s,防止设备连接后自动启动,导致连接失败 2025-08-26 09:37:43 +08:00
Del Wang 1405eca23f fix: 优化脚本 Shebang, close #56 2025-08-17 07:18:16 +08:00
Del Wang 43e6739726 chore(patch): 更新固件信息获取依赖版本 2025-08-15 12:43:06 +08:00
Zion PuandDel affd94d1cf Update README.md 2025-07-28 15:14:27 +08:00
NicodeandDel 53f8ab31ee fix: availability of using xiaozhi-esp32-server as backend 2025-06-30 10:14:00 +08:00
Del Wang c5edea969c chore: 更新配置文件 DEVICE_ID 默认值 2025-06-16 21:52:29 +08:00
Del Wang e4dc39ef39 chore: 更新 Xiaomi 智能音箱 Pro v1.56.31 固件 2025-06-08 15:42:01 +08:00
105 changed files with 8250 additions and 345 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Compile Client Rust
on:
push:
branches: [ main ]
paths:
- 'packages/client-rust/**'
pull_request:
branches: [ main ]
paths:
- 'packages/client-rust/**'
jobs:
compile:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
target: armv7-unknown-linux-gnueabihf
- name: Install cross
run: cargo install cross
- name: Compile client-rust
working-directory: packages/client-rust
run: cross build --release --target armv7-unknown-linux-gnueabihf
- name: Upload compiled binary
uses: actions/upload-artifact@v4
with:
name: client-rust-binary
path: packages/client-rust/target/armv7-unknown-linux-gnueabihf/release/client
retention-days: 7
+2 -1
View File
@@ -52,6 +52,7 @@
- 👉 [小爱音箱自定义唤醒词](examples/kws/README.md)
- 👉 [小爱音箱接入 MiGPT(完美版)](examples/migpt/README.md)
- 👉 [小爱音箱接入 Gemini Live API](examples/gemini/README.md)
- 👉 [小爱音箱组立体声(支持不同型号机型)](examples/stereo/README.md)
以上皆为抛砖引玉,你也可以亲手编写自己想要的功能,一切由你定义!
@@ -89,4 +90,4 @@
## License
[MIT](LICENSE) License © 2024-PRESENT Del Wang
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
+8 -4
View File
@@ -1,4 +1,4 @@
#! /bin/sh
#!/bin/sh
exec > /dev/null 2>&1
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
EOF
set -e
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
echo "🤫 等待网络连接中..."
sleep 3
sleep 5
echo "✅ 网络连接成功"
MIN_SPACE_MB=32
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/sh
#!/bin/sh
cat << 'EOF'
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/sh
#!/bin/sh
cat << 'EOF'
+2
View File
@@ -0,0 +1,2 @@
*.mp3
*.wav
+547
View File
@@ -0,0 +1,547 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "audiopus_sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
dependencies = [
"cmake",
"log",
"pkg-config",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "opus"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e"
dependencies = [
"audiopus_sys",
"libc",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stereo"
version = "0.1.0"
dependencies = [
"alsa",
"anyhow",
"opus",
"postcard",
"serde",
"tokio",
]
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "stereo"
version = "0.1.0"
edition = "2024"
[profile.release]
lto = true
opt-level = "s"
codegen-units = 1
panic = "abort"
strip = true
debug = false
[dependencies]
opus = "0.3"
anyhow = "1.0"
tokio = { version = "1.48", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.0", features = ["alloc", "use-std"] }
[target.'cfg(target_os = "linux")'.dependencies]
alsa = "0.11"
+10
View File
@@ -0,0 +1,10 @@
build:
docker run --rm -v $(shell pwd):/app idootop/open-xiaoai-runtime:oh2p \
cargo build --target armv7-unknown-linux-gnueabihf --release
# 部署到小爱音箱(调试自用)
deploy:
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.153 "dd of=/data/stereo"
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.235 "dd of=/data/stereo"
+59
View File
@@ -0,0 +1,59 @@
# Open-XiaoAI Stereo
不同型号的小爱音箱组立体声。
## 功能
- 在小爱音箱上独立运行,无需服务端部署
- 支持不同型号的小爱音箱局域网组立体声
- 支持蓝牙播放和小爱音箱自带的音乐播放
- 支持局域网内主从设备服务发现自动连接
- 支持主从节点“热拔插”,自动切换声道
- 支持前向纠错(FEC)和丢包补偿(PLC)
## 快速开始
> [!NOTE]
> 以下操作需要先将小爱音箱刷机, 然后 SSH 连接到小爱音箱。👉 [教程](../../docs/flash.md)
首先,分别在两台设备上配置主从和声道。
```shell
# 首先确保配置文件路径存在
mkdir -p /data/open-xiaoai || true
# 如果你想让左边的小爱音箱作为主音源
echo master left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
echo slave right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
# 如果你想让右边的小爱音箱作为主音源
# echo master right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
# echo slave left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
```
配置完成后,分别在两台设备上运行下面的启动脚本即可。
```shell
curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/init.sh | sh
```
如果你想要开机自启动,运行以下命令重启小爱音箱即可(需要先按上面的步骤配置主从设备和声道)。
```shell
# 下载 boot.sh 到 /data/init.sh 开机时自启动
curl -L -o /data/init.sh https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/boot.sh
# 重启小爱音箱
reboot
```
## 注意事项
- 不支持同步音量大小(不同型号设备的音量基准不同,请手动调整)
- 不支持多路音频混音(同时播放多个音频听起来像是信号故障、静音卡顿)
- 目前只支持小爱音箱 Pro(LX06)和小米智能音箱 Pro(OH2P)这两个机型(需要刷机)
- 从设备会主动同步主设备的音源,反之不支持(建议禁用从设备的麦克风和操作,防止音频叠加)
## License
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
exec > /dev/null 2>&1
WORK_DIR="/data/open-xiaoai"
APP_BINARY="$WORK_DIR/stereo"
CONFIG_FILE="$WORK_DIR/stereo.conf"
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
▌ - Stereo(立体声)
v1.0.0 by: https://del.wang
EOF
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
sleep 3
echo "✅ 网络连接成功"
main() {
# 1. 确保目录存在
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
# 2. 检查并下载程序
if [ ! -f "$APP_BINARY" ]; then
echo "🚀 正在下载 Stereo 立体声程序..."
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
echo "❌ 下载失败,请检查网络连接。"
exit 1
fi
chmod +x "$APP_BINARY"
echo "✅ 程序下载完毕"
fi
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
if [ $# -gt 0 ]; then
echo "$*" > "$CONFIG_FILE"
fi
# 4. 读取配置
local ARGS=""
if [ -f "$CONFIG_FILE" ]; then
ARGS=$(cat "$CONFIG_FILE")
fi
# 5. 停止旧进程
PID=$(pgrep -f "$APP_BINARY" || true)
if [ -n "$PID" ]; then
kill -9 $PID > /dev/null 2>&1 || true
fi
# 6. 启动程序
echo "🔥 Stereo 立体声程序启动中..."
"$APP_BINARY" $ARGS
}
main "$@"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
WORK_DIR="/data/open-xiaoai"
APP_BINARY="$WORK_DIR/stereo"
CONFIG_FILE="$WORK_DIR/stereo.conf"
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
▌ - Stereo(立体声)
v1.0.0 by: https://del.wang
EOF
main() {
# 1. 确保目录存在
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
# 2. 检查并下载程序
if [ ! -f "$APP_BINARY" ]; then
echo "🚀 正在下载 Stereo 立体声程序..."
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
echo "❌ 下载失败,请检查网络连接。"
exit 1
fi
chmod +x "$APP_BINARY"
echo "✅ 程序下载完毕"
fi
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
if [ $# -gt 0 ]; then
echo "$*" > "$CONFIG_FILE"
fi
# 4. 读取配置
local ARGS=""
if [ -f "$CONFIG_FILE" ]; then
ARGS=$(cat "$CONFIG_FILE")
fi
# 5. 停止旧进程
PID=$(pgrep -f "$APP_BINARY" || true)
if [ -n "$PID" ]; then
kill -9 $PID > /dev/null 2>&1 || true
fi
# 6. 启动程序
echo "🔥 Stereo 立体声程序启动中..."
exec "$APP_BINARY" $ARGS
}
main "$@"
+35
View File
@@ -0,0 +1,35 @@
#![cfg(target_os = "linux")]
use crate::app::master::run_master;
use crate::app::slave::run_slave;
use crate::net::protocol::ChannelRole;
use anyhow::Result;
use std::env;
pub async fn run_stereo() -> Result<()> {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("用法: {} [master|slave] [left|right]", args[0]);
return Ok(());
}
let mode = if args[1].to_lowercase() == "master" {
"主节点"
} else {
"从节点"
};
let role = if args[2].to_lowercase() == "left" {
ChannelRole::Left
} else {
ChannelRole::Right
};
println!("🚗 当前为: {} {}", mode, role.to_string());
if mode == "主节点" {
run_master(role).await
} else {
run_slave(role).await
}
}
+365
View File
@@ -0,0 +1,365 @@
#![cfg(target_os = "linux")]
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::player::AudioPlayer;
use crate::net::discovery::Discovery;
use crate::net::network::{ControlConnection, MasterNetwork};
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
use crate::utils::alsa::AlsaRedirector;
use crate::utils::sync::now_us;
use anyhow::{Result, anyhow};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::signal::unix::{SignalKind, signal};
use tokio::sync::Mutex;
pub const SERVER_TCP_PORT: u16 = 53531;
#[derive(Clone)]
struct SlaveSession {
udp_addr: SocketAddr,
role: ChannelRole,
}
pub async fn run_master(master_role: ChannelRole) -> Result<()> {
// 0. 设置 ALSA 重定向
println!("🔥 启动中,请稍等...");
let _alsa_guard = AlsaRedirector::new()?;
// 1. 设置网络 (UDP + TCP)
let network = MasterNetwork::setup(SERVER_TCP_PORT).await?;
let audio_socket = network.audio_socket().clone_inner();
// 2. 启动服务发现广播
Discovery::start_broadcast(SERVER_TCP_PORT).await?;
println!("✅ 服务已启动,等待连接...");
let shutdown_flag = Arc::new(AtomicBool::new(false));
let slaves = Arc::new(Mutex::new(Vec::<SlaveSession>::new()));
// 3. 启动连接监听任务
let slaves_clone = slaves.clone();
let audio_socket_clone = audio_socket.clone();
tokio::spawn(async move {
loop {
match network.accept().await {
Ok((control_conn, client_addr)) => {
let slaves_for_session = slaves_clone.clone();
let audio_socket_for_session = audio_socket_clone.clone();
tokio::spawn(async move {
if let Err(e) = handle_master_session(
control_conn,
audio_socket_for_session,
slaves_for_session,
client_addr.to_string(),
)
.await
{
eprintln!("❌ 会话错误: {:?}", e);
}
});
}
Err(e) => {
eprintln!("❌ Accept 错误: {:?}", e);
}
}
}
});
// 4. 音频处理主循环
let config = AudioConfig::music();
let encode_config = AudioConfig {
channels: 1,
vbr: true,
..AudioConfig::music()
};
let player = AudioPlayer::new(&AudioConfig {
channels: 2,
playback_device: "plug:original_default".into(),
..config.clone()
})?;
let mut raw_buf = vec![0u8; config.frame_size * 2 * 2];
let mut pcm_out = vec![0i16; config.frame_size * 2];
let mut left_pcm = vec![0i16; config.frame_size];
let mut right_pcm = vec![0i16; config.frame_size];
let mut opus_out = vec![0u8; 1500];
let mut seq = 0u32;
// 播放延迟: 只需覆盖网络延迟 + 时钟偏移
let delay_us = 100_000; // 100ms 基础延迟
let frame_duration_us =
(config.frame_size as f64 / config.sample_rate as f64 * 1_000_000.0) as u128;
let mut stream_start_ts = 0;
let mut stream_start_seq = 0;
let shutdown_flag_clone = shutdown_flag.clone();
let audio_loop = async move {
loop {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
// 打开 FIFO
let mut fifo = match tokio::fs::File::open(AlsaRedirector::fifo_path()).await {
Ok(f) => f,
Err(_) => {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
// 每个新流开始时,重置编码器状态以避免残留音频导致爆音
let mut left_encoder = OpusCodec::new(&encode_config)?;
let mut right_encoder = OpusCodec::new(&encode_config)?;
loop {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
// 从 FIFO 读取
if let Err(_) = fifo.read_exact(&mut raw_buf).await {
break; // FIFO 关闭,重新打开
}
let active_slaves = {
let s = slaves.lock().await;
if s.is_empty() { None } else { Some(s.clone()) }
};
let now = now_us();
if stream_start_ts == 0 {
stream_start_ts = now;
stream_start_seq = seq;
}
// 计算该帧应当播放的目标时间
// target_ts = 数据包发送时间 + 播放延迟
let target_ts = stream_start_ts
+ ((seq - stream_start_seq) as u128 * frame_duration_us)
+ delay_us;
// 提取 PCM 数据
for i in 0..config.frame_size {
left_pcm[i] = i16::from_le_bytes([raw_buf[i * 4], raw_buf[i * 4 + 1]]);
right_pcm[i] = i16::from_le_bytes([raw_buf[i * 4 + 2], raw_buf[i * 4 + 3]]);
}
if let Some(slaves_list) = active_slaves {
// 情况 1: 有从节点,进行网络传输,并本地构造静音声道回放
// 1. 检查各声道是否有从节点需要
let needs_left = slaves_list.iter().any(|s| s.role == ChannelRole::Left);
let needs_right = slaves_list.iter().any(|s| s.role == ChannelRole::Right);
// 2. 编码需要的声道
let mut left_bytes = None;
let mut right_bytes = None;
if needs_left {
let len = left_encoder.encode(&left_pcm, &mut opus_out)?;
let packet = AudioPacket {
seq,
timestamp: target_ts,
data: opus_out[..len].to_vec(),
};
left_bytes = Some(postcard::to_allocvec(&packet)?);
}
if needs_right {
let len = right_encoder.encode(&right_pcm, &mut opus_out)?;
let packet = AudioPacket {
seq,
timestamp: target_ts,
data: opus_out[..len].to_vec(),
};
right_bytes = Some(postcard::to_allocvec(&packet)?);
}
// 3. 发送给对应的从节点
for slave in &slaves_list {
let bytes = match slave.role {
ChannelRole::Left => left_bytes.as_ref(),
ChannelRole::Right => right_bytes.as_ref(),
};
if let Some(b) = bytes {
let _ = audio_socket.send_to(b, slave.udp_addr).await;
}
}
// 4. 将非本节点的声道置为静音
for i in 0..config.frame_size {
match master_role {
ChannelRole::Left => {
pcm_out[i * 2] = left_pcm[i];
pcm_out[i * 2 + 1] = 0;
}
ChannelRole::Right => {
pcm_out[i * 2] = 0;
pcm_out[i * 2 + 1] = right_pcm[i];
}
}
}
// 5. 等待播放
let now = now_us();
if now < target_ts {
let wait = target_ts - now;
if wait > 1000 {
tokio::time::sleep(Duration::from_micros(wait as u64)).await;
} else {
// 小于 1ms,直接播放,让播放时机稍微早一点点
}
}
} else {
// 情况 2: 没有从节点,本地立体声播放
for i in 0..config.frame_size {
pcm_out[i * 2] = left_pcm[i];
pcm_out[i * 2 + 1] = right_pcm[i];
}
}
// 统一写入播放器 (始终是立体声)
if let Err(_) = player.write(&pcm_out) {
if shutdown_flag_clone.load(Ordering::Relaxed) {
break;
}
}
seq += 1;
}
// 重置流计时
stream_start_ts = 0;
}
Ok::<(), anyhow::Error>(())
};
tokio::select! {
res = audio_loop => {
if let Err(e) = res {
eprintln!("❌ 音频循环错误: {:?}", e);
}
},
_ = shutdown_signal() => {
// 设置退出标志,通知音频循环停止
shutdown_flag.store(true, Ordering::Relaxed);
},
}
// 显式清理
println!("👋 正在退出...");
AlsaRedirector::cleanup();
// 强制退出
std::process::exit(0);
}
/// 监听系统退出信号 (SIGINT, SIGTERM, SIGQUIT)
async fn shutdown_signal() {
let mut sigint = signal(SignalKind::interrupt()).expect("无法注册 SIGINT 处理器");
let mut sigterm = signal(SignalKind::terminate()).expect("无法注册 SIGTERM 处理器");
let mut sigquit = signal(SignalKind::quit()).expect("无法注册 SIGQUIT 处理器");
tokio::select! {
_ = sigint.recv() => {},
_ = sigterm.recv() => {},
_ = sigquit.recv() => {},
}
}
/// 处理主节点与从节点的会话
async fn handle_master_session(
mut control: ControlConnection,
audio_socket: Arc<tokio::net::UdpSocket>,
slaves: Arc<Mutex<Vec<SlaveSession>>>,
client_tcp_addr: String,
) -> Result<()> {
let mut buf = [0u8; 1024];
// 握手
let pkt = control.recv_packet(&mut buf).await?;
let slave_role = match pkt {
ControlPacket::ClientIdentify { role } => role,
_ => return Err(anyhow!("无效的握手协议")),
};
let stereo = ControlPacket::ServerHello {
udp_port: audio_socket.local_addr()?.port(),
};
control.send_packet(&stereo).await?;
// 等待 UDP 打洞/确认
let mut buf = [0u8; 128];
let (_, client_udp_addr) = audio_socket.recv_from(&mut buf).await?;
println!(
"✅ 从节点已连接: {} {}",
client_tcp_addr,
slave_role.to_string(),
);
// 添加到从节点列表
let session = SlaveSession {
udp_addr: client_udp_addr,
role: slave_role,
};
{
let mut s = slaves.lock().await;
s.push(session.clone());
}
// 分离 TCP 读写,处理控制消息和心跳
let (mut tcp_rx, mut tcp_tx) = control.split();
let mut buf = [0u8; 1024];
loop {
match tcp_rx.read(&mut buf).await {
Ok(0) | Err(_) => {
break;
}
Ok(n) => {
if let Ok(ControlPacket::Ping { client_ts, seq }) = postcard::from_bytes(&buf[..n])
{
let pong = ControlPacket::Pong {
client_ts,
server_ts: now_us(),
seq,
};
if tcp_tx
.write_all(&postcard::to_allocvec(&pong).unwrap())
.await
.is_err()
{
break;
}
}
}
}
}
println!(
"❌ 从节点已断开: {} {}",
client_tcp_addr,
slave_role.to_string(),
);
// 从列表中移除
{
let mut s = slaves.lock().await;
s.retain(|x| x.udp_addr != client_udp_addr);
}
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entry;
pub mod master;
pub mod slave;
+199
View File
@@ -0,0 +1,199 @@
#![cfg(target_os = "linux")]
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::player::AudioPlayer;
use crate::net::discovery::Discovery;
use crate::net::network::SlaveNetwork;
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
use crate::utils::sync::{ClockSync, now_us};
use anyhow::{Result, anyhow};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, mpsc};
/// 运行从节点模式
pub async fn run_slave(role: ChannelRole) -> Result<()> {
loop {
match handle_connection(role.clone()).await {
Err(e) => {
eprintln!("{:?}", e);
tokio::time::sleep(Duration::from_secs(3)).await;
}
Ok(_) => {}
}
}
}
async fn handle_connection(role: ChannelRole) -> Result<()> {
// 1. 发现主节点
println!("🔍 正在扫描主节点...");
let (master_ip, master_tcp_port) = Discovery::discover_master().await?;
let master_tcp_addr = format!("{}:{}", master_ip, master_tcp_port);
// 2. 建立 TCP 连接
println!("🔥 发现主节点: {}", master_tcp_addr);
let network = SlaveNetwork::connect(master_tcp_addr.parse()?).await?;
let (mut control, audio) = network.split();
// 3. 身份认证
control
.send_packet(&ControlPacket::ClientIdentify { role: role.clone() })
.await?;
let mut buf = [0u8; 1024];
let pkt = control.recv_packet(&mut buf).await?;
let server_udp_port = match pkt {
ControlPacket::ServerHello { udp_port } => udp_port,
_ => return Err(anyhow!("身份认证应答异常")),
};
// 4. UDP 打洞
audio
.punch(format!("{}:{}", master_ip, server_udp_port).parse()?)
.await?;
// 5. 初始化音频与同步组件
let config = AudioConfig {
channels: 1,
..AudioConfig::music()
};
let player = AudioPlayer::new(&config)?;
let mut codec = OpusCodec::new(&config)?;
let clock = Arc::new(Mutex::new(ClockSync::new(100)));
// 用于通知主循环 TCP 已断开的消息通道
let (disconnect_tx, mut disconnect_rx) = mpsc::channel::<()>(1);
// 6. 分离 TCP 读写
let (mut tcp_rx, mut tcp_tx) = control.split();
let clock_updater = clock.clone();
let d_tx_ping = disconnect_tx.clone();
let d_tx_pong = disconnect_tx.clone();
// 定时发送 Ping (心跳 & 时间同步)
let _sync_handle = tokio::spawn(async move {
let mut seq = 0;
loop {
let t1 = now_us();
let msg = ControlPacket::Ping { client_ts: t1, seq };
let data = postcard::to_allocvec(&msg).unwrap();
if tcp_tx.write_all(&data).await.is_err() {
let _ = d_tx_ping.send(()).await; // 通知主线程 TCP 失败
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
seq += 1;
}
});
// 接收 Pong
tokio::spawn(async move {
let mut buf = [0u8; 1024];
loop {
match tcp_rx.read(&mut buf).await {
Ok(n) if n > 0 => {
if let Ok(ControlPacket::Pong {
client_ts,
server_ts,
..
}) = postcard::from_bytes(&buf[..n])
{
let t4 = now_us();
clock_updater.lock().await.update(client_ts, server_ts, t4);
}
}
_ => {
let _ = d_tx_pong.send(()).await; // TCP 断开
break;
}
}
}
});
// 7. 接收音频数据包 (UDP)
let (audio_tx, mut audio_rx) = mpsc::channel(100);
let audio_socket = audio.clone_inner();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
loop {
if let Ok((len, _)) = audio_socket.recv_from(&mut buf).await {
if let Ok(packet) = postcard::from_bytes::<AudioPacket>(&buf[..len]) {
if audio_tx.send(packet).await.is_err() {
break;
}
}
}
}
});
// 8. 播放提示
println!("✅ 主节点已连接,音频串流中...");
let role_str = role.to_string();
tokio::spawn(async move {
let _ = tokio::process::Command::new("sh")
.arg("-c")
.arg(format!(
"/usr/sbin/tts_play.sh \"主节点已连接,{}\" >/dev/null 2>&1",
role_str
))
.status()
.await;
});
// 9. 播放主循环
let mut pcm_buf = vec![0i16; config.frame_size];
let mut last_seq: Option<u32> = None;
loop {
// 检查 TCP 是否已断开
if let Ok(_) = disconnect_rx.try_recv() {
return Err(anyhow!("主节点已断开: {}", master_tcp_addr));
}
// 接收数据包
if let Ok(pkt) = audio_rx.try_recv() {
let now = now_us();
let current_server_time = clock.lock().await.to_server_time(now);
// 检查包是否迟到(目标时间已过)
if current_server_time > pkt.timestamp {
let late_ms = (current_server_time - pkt.timestamp) / 1000;
if late_ms > 50 {
// 迟到超过 50ms,直接丢弃
continue;
}
// 轻微迟到(<50ms),尝试播放
}
last_seq = Some(pkt.seq);
// 精确等待到播放时间
loop {
let now = now_us();
let current_server_time_precise = clock.lock().await.to_server_time(now);
if current_server_time_precise >= pkt.timestamp {
break;
}
let wait_us = (pkt.timestamp - current_server_time_precise) as u64;
if wait_us > 1000 {
tokio::time::sleep(Duration::from_micros(wait_us as u64)).await;
} else {
// 小于 1ms,直接播放,让播放时机稍微早一点点
break;
}
}
// 解码并播放
if let Ok(len) = codec.decode(&pkt.data, &mut pcm_buf) {
let _ = player.write(&pcm_buf[..len]);
}
} else {
tokio::time::sleep(Duration::from_micros(100)).await;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use crate::audio::config::{AudioConfig, AudioScene};
use anyhow::{Context, Result};
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
pub struct OpusCodec {
encoder: Encoder,
decoder: Decoder,
}
impl OpusCodec {
pub fn new(config: &AudioConfig) -> Result<Self> {
let channels = match config.channels {
1 => Channels::Mono,
2 => Channels::Stereo,
_ => return Err(anyhow::anyhow!("Invalid channels: {}", config.channels)),
};
let mode = match config.audio_scene {
AudioScene::Music => Application::Audio,
AudioScene::Voice => Application::Voip,
};
let bitrate = match config.bitrate {
-1 => Bitrate::Max,
0 => Bitrate::Auto,
_ => Bitrate::Bits(config.bitrate),
};
let mut encoder = Encoder::new(config.sample_rate, channels, mode)
.context("Failed to create Opus encoder")?;
encoder.set_bitrate(bitrate)?;
if config.vbr {
encoder.set_vbr(true)?;
}
if config.fec {
encoder.set_inband_fec(true)?; // 内联前向纠错
encoder.set_packet_loss_perc(20)?; // 预期丢包率20%
}
let decoder =
Decoder::new(config.sample_rate, channels).context("Failed to create Opus decoder")?;
Ok(Self { encoder, decoder })
}
pub fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize> {
self.encoder
.encode(pcm, out)
.context("Opus encoding failed")
}
pub fn decode(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(opus, out, false)
.context("Opus decoding failed")
}
/// 前向纠错(FEC)
pub fn decode_fec(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(opus, out, true)
.context("Opus FEC decoding failed")
}
/// 丢包补偿(PLC)
pub fn decode_loss(&mut self, out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(&[], out, false)
.context("Opus PLC (decode_loss) failed")
}
}
+61
View File
@@ -0,0 +1,61 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AudioScene {
Music,
Voice,
}
#[derive(Debug, Clone)]
pub struct AudioConfig {
// ALSA 设备参数,用于录音和播放
pub capture_device: String,
pub playback_device: String,
pub sample_rate: u32,
pub channels: u16,
pub frame_size: usize, // 帧大小,单位为采样点
// Opus 编解码参数,用于音频传输
pub audio_scene: AudioScene,
pub bitrate: i32,
pub vbr: bool, // 是否启用 VBR(动态比特率)
pub fec: bool, // 是否启用 FEC(内联前向纠错)
}
impl AudioConfig {
pub fn music() -> Self {
Self {
audio_scene: AudioScene::Music,
sample_rate: 48_000, // 48kHz
channels: 2,
frame_size: 960, // 20ms at 48kHz
bitrate: 320_000, // 320 kbps
..Default::default()
}
}
pub fn voice() -> Self {
Self {
audio_scene: AudioScene::Voice,
sample_rate: 16_000, // 16kHz
channels: 1,
frame_size: 320, // 20ms at 16kHz
bitrate: 32_000, // 32 kbps
..Default::default()
}
}
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
audio_scene: AudioScene::Voice,
capture_device: "plug:Capture".to_string(),
playback_device: "default".to_string(),
sample_rate: 16_000,
channels: 1,
frame_size: 320, // 20ms at 16kHz
bitrate: 32_000,
vbr: false,
fec: false,
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod codec;
pub mod config;
pub mod player;
pub mod recorder;
+71
View File
@@ -0,0 +1,71 @@
#![cfg(target_os = "linux")]
use crate::audio::config::AudioConfig;
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
use anyhow::{Context, Result};
pub struct AudioPlayer {
pcm: PCM,
}
impl AudioPlayer {
pub fn new(config: &AudioConfig) -> Result<Self> {
let pcm = PCM::new(&config.playback_device, Direction::Playback, false)
.context("Failed to open playback PCM device")?;
setup_pcm(&pcm, config.sample_rate, config.channels)?;
Ok(Self { pcm })
}
pub fn write(&self, buffer: &[i16]) -> Result<usize> {
let res = self.pcm.io_i16()?.writei(buffer);
match res {
Ok(written) => Ok(written),
Err(e) => {
// Buffer Underrun,即播放缓冲区的数据被耗尽,导致音频流中断
if e.errno() == 32 {
// 恢复音频流状态
self.pcm.prepare()?;
// 重新获取 IO 对象并尝试写入数据
self.pcm
.io_i16()?
.writei(buffer)
.context("Failed to write to playback device after recovery")
} else {
Err(e).context("Failed to write to playback device")
}
}
}
}
pub fn prepare(&self) -> Result<()> {
self.pcm.prepare().context("Failed to prepare PCM")
}
}
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(channels as u32)?;
// 设置较大的缓冲区以减少由于调度抖动和设备重初始化导致的断音/卡顿
// 使用 100ms 缓冲区,既能防止 underrun,又不会引入过大延迟
let buffer_size = (sample_rate as f64 * 0.1) as u32; // 100ms 缓冲
let period_size = buffer_size / 4; // 25ms 周期
hwp.set_buffer_size_near(buffer_size as alsa::pcm::Frames)?;
hwp.set_period_size_near(period_size as alsa::pcm::Frames, alsa::ValueOr::Nearest)?;
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
let swp = pcm.sw_params_current()?;
// 设置 start_threshold,当缓冲区有 1 个 period 数据时就开始播放
// 这样可以快速启动,同时保持足够的缓冲余量
swp.set_start_threshold(period_size as alsa::pcm::Frames)?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
#![cfg(target_os = "linux")]
use crate::audio::config::AudioConfig;
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
use anyhow::{Context, Result};
pub struct AudioRecorder {
pcm: PCM,
}
impl AudioRecorder {
pub fn new(config: &AudioConfig) -> Result<Self> {
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)
.context("Failed to open capture PCM device")?;
setup_pcm(&pcm, config.sample_rate, config.channels)?;
Ok(Self { pcm })
}
pub fn read(&self, buffer: &mut [i16]) -> Result<usize> {
self.pcm
.io_i16()?
.readi(buffer)
.context("Failed to read from capture device")
}
}
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(channels as u32)?;
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
let swp = pcm.sw_params_current()?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
mod app;
mod audio;
mod net;
mod utils;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(target_os = "linux")]
{
crate::app::entry::run_stereo().await.unwrap();
}
println!("Only support Linux");
Ok(())
}
+50
View File
@@ -0,0 +1,50 @@
use crate::net::protocol::ControlPacket;
use anyhow::Result;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
pub const DISCOVERY_PORT: u16 = 53530;
const DISCOVERY_MAGIC: &[u8] = b"STEREO_DISCOVERY_V1";
/// 服务发现模块,用于主从节点的自动发现
pub struct Discovery;
impl Discovery {
/// 主节点:启动广播,告知从节点自己的 TCP 端口
pub async fn start_broadcast(tcp_port: u16) -> Result<()> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
let mut msg = DISCOVERY_MAGIC.to_vec();
msg.extend(postcard::to_allocvec(&ControlPacket::ServerHello { udp_port: tcp_port })?);
tokio::spawn(async move {
loop {
let _ = socket.send_to(&msg, target).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
Ok(())
}
/// 从节点:监听广播,发现主节点的 IP 和 TCP 端口
pub async fn discover_master() -> Result<(IpAddr, u16)> {
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
let mut buf = [0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buf).await?;
let data = &buf[..len];
if data.starts_with(DISCOVERY_MAGIC) {
let packet_data = &data[DISCOVERY_MAGIC.len()..];
if let Ok(ControlPacket::ServerHello { udp_port }) = postcard::from_bytes(packet_data) {
return Ok((addr.ip(), udp_port));
}
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod discovery;
pub mod network;
pub mod protocol;
+126
View File
@@ -0,0 +1,126 @@
use crate::net::protocol::{AudioPacket, ControlPacket};
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
/// UDP 音频传输
pub struct AudioSocket {
socket: Arc<UdpSocket>,
}
impl AudioSocket {
pub async fn bind() -> Result<Self> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
Ok(Self {
socket: Arc::new(socket),
})
}
pub fn local_port(&self) -> Result<u16> {
Ok(self.socket.local_addr()?.port())
}
pub async fn send_packet(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.socket.send_to(&bytes, target).await?;
Ok(())
}
pub async fn recv_packet(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
let (len, addr) = self.socket.recv_from(buf).await?;
let packet = postcard::from_bytes(&buf[..len])?;
Ok((packet, addr))
}
pub async fn punch(&self, target: SocketAddr) -> Result<()> {
self.socket.send_to(&[0u8; 1], target).await?;
Ok(())
}
pub fn clone_inner(&self) -> Arc<UdpSocket> {
self.socket.clone()
}
}
/// TCP 控制连接
pub struct ControlConnection {
stream: TcpStream,
}
impl ControlConnection {
pub fn new(stream: TcpStream) -> Self {
Self { stream }
}
pub async fn send_packet(&mut self, packet: &ControlPacket) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.stream.write_all(&bytes).await?;
Ok(())
}
pub async fn recv_packet(&mut self, buf: &mut [u8]) -> Result<ControlPacket> {
let len = self.stream.read(buf).await?;
if len == 0 {
return Err(anyhow::anyhow!("连接已关闭"));
}
let packet = postcard::from_bytes(&buf[..len])?;
Ok(packet)
}
pub fn split(
self,
) -> (
tokio::net::tcp::OwnedReadHalf,
tokio::net::tcp::OwnedWriteHalf,
) {
self.stream.into_split()
}
}
/// 主节点网络管理器
pub struct MasterNetwork {
listener: TcpListener,
audio: AudioSocket,
}
impl MasterNetwork {
pub async fn setup(port: u16) -> Result<Self> {
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
let audio = AudioSocket::bind().await?;
Ok(Self { listener, audio })
}
pub async fn accept(&self) -> Result<(ControlConnection, SocketAddr)> {
let (stream, addr) = self.listener.accept().await?;
Ok((ControlConnection::new(stream), addr))
}
pub fn audio_socket(&self) -> &AudioSocket {
&self.audio
}
}
/// 从节点网络管理器
pub struct SlaveNetwork {
control: ControlConnection,
audio: AudioSocket,
}
impl SlaveNetwork {
pub async fn connect(master_addr: SocketAddr) -> Result<Self> {
let stream = TcpStream::connect(master_addr)
.await
.context(format!("无法连接到主节点 TCP 地址: {}", master_addr))?;
let audio = AudioSocket::bind().await?;
Ok(Self {
control: ControlConnection::new(stream),
audio,
})
}
pub fn split(self) -> (ControlConnection, AudioSocket) {
(self.control, self.audio)
}
}
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)]
pub enum ChannelRole {
Left,
Right,
}
impl ChannelRole {
pub fn to_string(&self) -> String {
match self {
ChannelRole::Left => "左声道".to_string(),
ChannelRole::Right => "右声道".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ControlPacket {
// 发现协议
ServerHello {
udp_port: u16, // UDP 音频流端口
},
// 握手协议
ClientIdentify {
role: ChannelRole,
},
// 时间同步 (持续进行)
Ping {
client_ts: u128,
seq: u32,
},
Pong {
client_ts: u128,
server_ts: u128,
seq: u32,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AudioPacket {
pub seq: u32, // 序列号,用于丢包检测
pub timestamp: u128, // 目标播放时间 (主节点时间)
pub data: Vec<u8>, // Opus 编码数据
}
+85
View File
@@ -0,0 +1,85 @@
#![cfg(target_os = "linux")]
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
const FIFO_PATH: &str = "/tmp/stereo_out.fifo";
const REAL_ASOUND_CONF: &str = "/etc/asound.conf";
const TEMP_ASOUND_CONF: &str = "/tmp/asound.stereo.conf";
/// ALSA 音频重定向器,用于拦截系统音频输出到 FIFO 管道
pub struct AlsaRedirector;
impl AlsaRedirector {
pub fn new() -> Result<Self> {
Self::cleanup(); // 确保环境干净
let original_conf = fs::read_to_string(REAL_ASOUND_CONF).unwrap_or_default();
if !original_conf.contains("pcm.original_default") {
// 重命名原有的 default 逻辑,插入拦截器
let mut new_conf = original_conf.replace("pcm.!default", "pcm.original_default");
new_conf.push_str(&format!(
"\npcm.!default {{ type plug slave {{ pcm \"stereo_interceptor\" format S16_LE rate 48000 channels 2 }} }}\n\
pcm.stereo_interceptor {{ type file slave.pcm \"null\" file \"{}\" format \"raw\" }}\n",
FIFO_PATH
));
fs::write(TEMP_ASOUND_CONF, new_conf)?;
// 挂载覆盖 /etc/asound.conf
let status = Command::new("mount")
.arg("--bind")
.arg(TEMP_ASOUND_CONF)
.arg(REAL_ASOUND_CONF)
.status()
.context("执行 mount 命令失败")?;
if !status.success() {
return Err(anyhow::anyhow!("挂载 asound.conf 失败"));
}
Self::restart_applications();
}
// 创建 FIFO 管道
let _ = Command::new("mkfifo").arg(FIFO_PATH).status();
let _ = Command::new("chmod").arg("666").arg(FIFO_PATH).status();
Ok(Self)
}
pub fn cleanup() {
let _ = Command::new("sh")
.arg("-c")
.arg(format!("umount -l {} >/dev/null 2>&1", REAL_ASOUND_CONF))
.status();
let _ = fs::remove_file(TEMP_ASOUND_CONF);
let _ = fs::remove_file(FIFO_PATH);
Self::restart_applications();
}
pub fn fifo_path() -> &'static str {
FIFO_PATH
}
pub fn restart_applications() {
// 重启媒体播放器
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/mediaplayer restart >/dev/null 2>&1")
.status();
// 重启蓝牙
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/bluetooth restart >/dev/null 2>&1")
.status();
}
}
impl Drop for AlsaRedirector {
fn drop(&mut self) {
Self::cleanup();
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod alsa;
pub mod sync;
+224
View File
@@ -0,0 +1,224 @@
use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
/// 获取当前微秒级时间戳
pub fn now_us() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("时间倒流")
.as_micros()
}
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
/// 采用改进的 NTP 算法 + Kalman 滤波思想
pub struct ClockSync {
/// 偏移量样本窗口
offsets: VecDeque<OffsetSample>,
/// 当前估计的时钟偏移 (server_time - client_time)
pub current_offset: i128,
/// RTT 样本窗口
rtts: VecDeque<i128>,
/// 当前估计的最小 RTT
min_rtt: i128,
/// 窗口大小
window_size: usize,
/// 时钟漂移率 (ppm: parts per million)
/// 正值表示从节点时钟比主节点快
drift_rate: f64,
/// 上次更新时间
last_update_time: u128,
/// 漂移率估计窗口
drift_samples: VecDeque<DriftSample>,
}
#[derive(Clone, Copy)]
struct OffsetSample {
offset: i128,
rtt: i128,
timestamp: u128,
}
#[derive(Clone, Copy)]
struct DriftSample {
offset: i128,
timestamp: u128,
}
impl ClockSync {
pub fn new(window_size: usize) -> Self {
Self {
offsets: VecDeque::with_capacity(window_size),
current_offset: 0,
rtts: VecDeque::with_capacity(window_size),
min_rtt: i128::MAX,
window_size,
drift_rate: 0.0,
last_update_time: now_us(),
drift_samples: VecDeque::with_capacity(60), // 保留 60 秒的样本
}
}
/// 更新时钟偏移估计 (NTP 算法)
///
/// NTP 时间戳标记:
/// t1 = client_send_ts : 客户端发送 Ping 的时间
/// t2 = server_ts : 服务器接收 Ping 的时间
/// t3 = server_ts : 服务器发送 Pong 的时间 (假设处理时间忽略不计)
/// t4 = client_recv_ts : 客户端接收 Pong 的时间
///
/// RTT = (t4 - t1) - (t3 - t2) = (t4 - t1) (因为 t3 = t2)
/// Offset = ((t2 - t1) + (t3 - t4)) / 2 = ((t2 - t1) + (t2 - t4)) / 2
/// = t2 - (t1 + t4) / 2
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
let t1 = client_send_ts as i128;
let t2 = server_ts as i128;
let t4 = client_recv_ts as i128;
let rtt = t4 - t1;
// 过滤异常 RTT (局域网内 > 100ms 视为异常)
if rtt < 0 || rtt > 100_000 {
return;
}
// 计算时钟偏移: offset = server_time - client_time
// offset = t2 - (t1 + t4) / 2
let offset = t2 - (t1 + t4) / 2;
// 更新 RTT 窗口
self.rtts.push_back(rtt);
if self.rtts.len() > self.window_size {
self.rtts.pop_front();
}
self.min_rtt = *self.rtts.iter().min().unwrap_or(&rtt);
// 更新偏移量窗口
let sample = OffsetSample {
offset,
rtt,
timestamp: client_recv_ts,
};
self.offsets.push_back(sample);
if self.offsets.len() > self.window_size {
self.offsets.pop_front();
}
// 偏移量估计: 使用低 RTT 样本的中位数
// 原理: RTT 较小的样本受网络抖动影响小,时间测量更准确
let mut low_rtt_offsets: Vec<i128> = self
.offsets
.iter()
.filter(|s| s.rtt <= self.min_rtt + 5000) // 5ms 容差
.map(|s| s.offset)
.collect();
if !low_rtt_offsets.is_empty() {
low_rtt_offsets.sort_unstable();
let new_offset = low_rtt_offsets[low_rtt_offsets.len() / 2];
// 漂移率估计
self.estimate_drift(new_offset, client_recv_ts);
// 平滑更新偏移量 (避免突变)
let alpha = 0.3; // 低通滤波系数
self.current_offset =
(alpha * new_offset as f64 + (1.0 - alpha) * self.current_offset as f64) as i128;
}
self.last_update_time = client_recv_ts;
}
/// 估计时钟漂移率
/// 时钟漂移率 = d(offset) / dt
fn estimate_drift(&mut self, offset: i128, timestamp: u128) {
self.drift_samples.push_back(DriftSample { offset, timestamp });
if self.drift_samples.len() > 60 {
self.drift_samples.pop_front();
}
// 至少需要 10 秒的数据才能估计漂移
if self.drift_samples.len() < 10 {
return;
}
// 使用线性回归估计漂移率
let first = self.drift_samples.front().unwrap();
let last = self.drift_samples.back().unwrap();
let dt = (last.timestamp - first.timestamp) as f64;
let d_offset = (last.offset - first.offset) as f64;
if dt > 10_000_000.0 {
// 超过 10 秒
// drift_rate 单位: 微秒/秒 = ppm
let new_drift = d_offset / (dt / 1_000_000.0);
// 平滑更新漂移率
let beta = 0.1;
self.drift_rate = beta * new_drift + (1.0 - beta) * self.drift_rate;
}
}
/// 将本地时间转换为服务器(主节点)时间
/// 考虑时钟漂移补偿
pub fn to_server_time(&self, client_time: u128) -> u128 {
let base_server_time = (client_time as i128 + self.current_offset) as u128;
// 漂移补偿: 根据距离上次同步的时间,补偿时钟漂移
let elapsed_since_update = client_time.saturating_sub(self.last_update_time) as f64;
let drift_correction = (self.drift_rate * elapsed_since_update / 1_000_000.0) as i128;
(base_server_time as i128 + drift_correction) as u128
}
/// 将服务器(主节点)时间转换为本地时间
pub fn to_client_time(&self, server_time: u128) -> u128 {
// 简化版本,不考虑漂移补偿 (播放时主要用 to_server_time)
(server_time as i128 - self.current_offset) as u128
}
/// 获取当前估计的 RTT (微秒)
pub fn get_rtt(&self) -> i128 {
self.min_rtt
}
/// 获取当前时钟漂移率 (ppm)
pub fn get_drift_rate(&self) -> f64 {
self.drift_rate
}
/// 获取同步质量评估 (0-100, 越高越好)
pub fn get_sync_quality(&self) -> u8 {
if self.offsets.is_empty() {
return 0;
}
// 基于 RTT 稳定性和偏移量方差评估
let rtt_variance = self.calculate_variance(&self.rtts.iter().copied().collect::<Vec<_>>());
let offset_variance = self.calculate_variance(
&self.offsets.iter().map(|s| s.offset).collect::<Vec<_>>(),
);
// RTT 越稳定,方差越小,质量越高
let rtt_score = ((100_000.0 - rtt_variance.min(100_000.0)) / 100_000.0 * 50.0) as u8;
let offset_score = ((50_000.0 - offset_variance.min(50_000.0)) / 50_000.0 * 50.0) as u8;
rtt_score + offset_score
}
fn calculate_variance(&self, samples: &[i128]) -> f64 {
if samples.is_empty() {
return 0.0;
}
let mean = samples.iter().sum::<i128>() as f64 / samples.len() as f64;
let variance = samples
.iter()
.map(|&x| {
let diff = x as f64 - mean;
diff * diff
})
.sum::<f64>()
/ samples.len() as f64;
variance.sqrt()
}
}
+4
View File
@@ -160,6 +160,10 @@ PS:如果还是不行,建议更换其他更易识别的唤醒词。
由于 ASR 相关模型文件体积较大,并未直接提交在 git 仓库中,你可以在 release 中下载 [VAD + KWS 相关模型](https://github.com/idootop/open-xiaoai/releases/tag/vad-kws-models),然后解压到 `xiaozhi/models` 路径下即可。
## 相关项目
- [oxa-server](https://github.com/pu-007/oxa-server): 提供了更强大易用的 config.py 的配置方式
## 鸣谢
该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。
+3 -3
View File
@@ -65,8 +65,8 @@ APP_CONFIG = {
"xiaozhi": {
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
"WEBSOCKET_ACCESS_TOKEN": "(可选)一般用不到这个值",
"DEVICE_ID": "(可选)默认自动生成",
"VERIFICATION_CODE": "首次登陆时,验证码会在这里更新",
"WEBSOCKET_ACCESS_TOKEN": "", #(可选)一般用不到这个值
"DEVICE_ID": "", #(可选)默认自动生成
"VERIFICATION_CODE": "", # 首次登陆时,验证码会在这里更新
},
}
+46
View File
@@ -0,0 +1,46 @@
#! /bin/sh
exec > /dev/null 2>&1
cat << 'EOF'
▄▖ ▖▖▘ ▄▖▄▖
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
v1.0.0 by: https://del.wang
EOF
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
sleep 3
echo "✅ 网络连接成功"
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/client-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/boot.sh"
fi
if [ ! -f "$WORK_DIR/kws-boot.sh" ]; then
curl -L -# -o "$WORK_DIR/kws-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/boot.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws-boot.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client-boot.sh"
+25
View File
@@ -0,0 +1,25 @@
#! /bin/sh
set -e
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
WORK_DIR="/data/open-xiaoai/scripts"
if [ ! -d "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
fi
if [ ! -f "$WORK_DIR/client.sh" ]; then
curl -L -# -o "$WORK_DIR/client.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/init.sh"
fi
if [ ! -f "$WORK_DIR/kws.sh" ]; then
curl -L -# -o "$WORK_DIR/kws.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/init.sh"
fi
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
sh "$WORK_DIR/kws.sh" --no-monitor > /dev/null 2>&1 &
sh "$WORK_DIR/client.sh"
@@ -201,7 +201,7 @@ class WebsocketProtocol(Protocol):
if self.websocket and get_xiaozhi().device_state == DeviceState.IDLE:
try:
await self.send_text(
json.dumps({"session_id": "", "type": "abort"})
json.dumps({"session_id": "", "type": "ping"})
)
except Exception:
# 发送心跳失败,重新连接
+9 -4
View File
@@ -1,9 +1,14 @@
# 你的小米账号/密码
#你的小米账号/密码
MI_USER=23333333
MI_PASS=xxxxxxxxx
# 你的小爱音箱名称/DID
#你的小爱音箱名称/DID
MI_DID=小爱音箱Pro
# 你的 SSH 登录密码默认为 open-xiaoai
#你的 SSH 登录密码默认为 open-xiaoai
SSH_PASSWORD=open-xiaoai
#账号登录异常时,请取消下面的注释,使用 MI_TOKEN 登录
#MI_TOKEN=你的 passToken 获取教程 👉 https://github.com/idootop/migpt-next/issues/4
# 获取设备异常时,请取消下面的注释,查看设备列表详情
#MI_DEBUG=true
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Test",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/ota.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"cwd": "${workspaceRoot}",
"envFile": "${workspaceFolder}/.env",
"internalConsoleOptions": "openOnSessionStart"
}
]
}
+1
View File
@@ -6,6 +6,7 @@ ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& apk add --no-cache \
bash \
file \
patch \
python3 \
+25 -12
View File
@@ -4,6 +4,7 @@
> 刷机有风险,操作需谨慎。请勿下载使用不明来历的固件!
小爱音箱 Pro 补丁固件制作流程:
- 固件提取(登录小米账号获取 OTA 链接)
- 开启固化 SSH(支持自定义登录密码)
- 禁用系统自动更新(系统更新后需要重新刷机打补丁)
@@ -12,31 +13,31 @@
## 下载固件
你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件:
- [Xiaomi 智能音箱 Pro v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
- [小爱音箱 Pro v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
- [Xiaomi 智能音箱 Pro v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
- [小爱音箱 Pro v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
> [!TIP]
> 里面有两个文件,下载 `patched` 那个:
>
> - `xxx_patched.squashfs` 打补丁后的固件
> - `xxx.squashfs` 原版固件(可用来刷回原系统)
> [!NOTE]
> 默认 SSH 登录密码为 `open-xiaoai`,如需修改请自行制作固件。
> [!IMPORTANT]
> 请下载和你当前小爱音箱版本一致的固件,跨版本刷机可能会出现未知错误,导致设备变砖。
> 如果上面没有你的版本,请升级设备固件到最新版本,或者按照下面的教程自行制作固件。
> [!CAUTION]
> 当前支持的最新固件版本为:
> - Xiaomi 智能音箱 Pro 👉 [v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
> - 小爱音箱 Pro 👉 [v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
>
> - Xiaomi 智能音箱 Pro 👉 [v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
> - 小爱音箱 Pro 👉 [v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
>
> 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。
## 制作固件
你可以按照下面的 2 种方法,制作自定义固件。
@@ -66,13 +67,25 @@ SSH_PASSWORD=open-xiaoai
- Dockerhttps://www.docker.com/get-started/
> [!NOTE]
> Windows 系统推荐使用 [Git Bash](https://git-scm.com/downloads) 终端运行。
>
> CMD 和 PowerShell 终端需要调整下面命令中文件(夹)的实际路径。
> Windows 系统请在 [Git Bash](https://git-scm.com/downloads) 终端运行以下命令
> [!TIP]
> 如果你是 Apple Silicon 芯片,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
```shell
# 克隆代码
git clone https://github.com/idootop/open-xiaoai.git
# 进入当前项目根目录
cd packages/client-patch
# 使用 Docker 进行构建
docker run -it --rm --env-file $(pwd)/.env -v $(pwd)/assets:/app/assets -v $(pwd)/patches:/app/patches idootop/open-xiaoai:latest
docker run -it --rm \
--platform linux/amd64 \
--env-file $(pwd)/.env \
-v $(pwd)/assets:/app/assets \
-v $(pwd)/patches:/app/patches \
idootop/open-xiaoai:latest
# ✅ 打包完成,固件文件已复制到 assets 目录...
# /app/assets/mico_all_92db90ed6_1.88.197/root-patched.squashfs
@@ -113,7 +126,7 @@ npm run build
示例:
```bash
#! /bin/sh
#!/bin/sh
/usr/sbin/tts_play.sh '初始化成功'
```
+20 -20
View File
@@ -8,11 +8,11 @@
"name": "open-xiaoai-patch",
"version": "1.0.0",
"dependencies": {
"@mi-gpt/miot": "^1.0.0"
"@mi-gpt/miot": "^1.2.1"
},
"devDependencies": {
"@mi-gpt/config": "1.0.0",
"@types/node": "^22.13.11",
"@mi-gpt/config": "1.1.0",
"@types/node": "^24.2.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
@@ -443,9 +443,9 @@
}
},
"node_modules/@mi-gpt/config": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.0.0.tgz",
"integrity": "sha512-oo/roYiRcGiHsZESjukwPTsRFqddShTJ8gkccR5ZMYn8SySj07A0nCsEsLEfB9mLkvNY8D6PCO1NxbRDDRBBlA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.1.0.tgz",
"integrity": "sha512-o24iFINsoDuvInnMpfBQ4zCuTObbdodjXyizXmxl3yyYOMjmIAXizO/1UZ9/dv3UVTov3sjL09aCGuBeIVhvtA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -453,12 +453,12 @@
}
},
"node_modules/@mi-gpt/miot": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.0.0.tgz",
"integrity": "sha512-smWhddHzK1du3/LW2FifBrIIyeZNx/RvTSe0Zi4v3lYZwK8NeEwomg1jBjktQCCV4Fh8OuqNbCdNbmiVXmr5kw==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.2.1.tgz",
"integrity": "sha512-DAFNUsLPepXSqJk/b+zfct8NCQHZzcl7f1t4X2WPKzxmDNagkawYlb96uPbk4syvn+DSg6vqle0Wm+gjmJ4kUA==",
"license": "MIT",
"dependencies": {
"@mi-gpt/utils": "1.0.0",
"@mi-gpt/utils": "1.1.1",
"axios": "^1.8.4",
"pako": "^2.1.0"
},
@@ -467,22 +467,22 @@
}
},
"node_modules/@mi-gpt/utils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.0.0.tgz",
"integrity": "sha512-hAU/uYPNiNJgXWkQrVqRLe/9Uz8AgtoiNwcbbwQs8iXJcyq3NjcSHkkwUseJAnaQFE9iv/kvHZZsx3r2HhlKjw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.1.1.tgz",
"integrity": "sha512-FiGHV2RDBRGE/Xb9Q5H757t3ZqOpo6gL+44VXiRxQb3fQiapOpn038RAWErHJYZajYzkxP2jl8Wggsb+wp77Zg==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.10.0"
}
},
"node_modules/asynckit": {
@@ -883,9 +883,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"license": "MIT"
}
+7 -7
View File
@@ -5,17 +5,17 @@
"type": "module",
"scripts": {
"ota": "tsx --env-file=.env src/ota.ts",
"extract": "sh src/extract.sh",
"patch": "sh src/patch.sh",
"squashfs": "sh src/squashfs.sh",
"build": "sh src/build.sh"
"extract": "bash src/extract.sh",
"patch": "bash src/patch.sh",
"squashfs": "bash src/squashfs.sh",
"build": "bash src/build.sh"
},
"dependencies": {
"@mi-gpt/miot": "^1.0.0"
"@mi-gpt/miot": "^1.2.1"
},
"devDependencies": {
"@mi-gpt/config": "1.0.0",
"@types/node": "^22.13.11",
"@mi-gpt/config": "1.1.0",
"@types/node": "^24.2.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
+1 -1
View File
@@ -4,6 +4,6 @@
# Put your custom commands here that should be executed once
# the system init finished. By default this file does nothing.
+[ -f "/data/init.sh" ] && sh /data/init.sh &
+[ -f "/data/init.sh" ] && sh /data/init.sh >/dev/null 2>&1 &
+
exit 0
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
+6 -24
View File
@@ -13,36 +13,18 @@ const kSupportedDevices = [
// 获取 OTA 信息
async function getOTA(channel: "release" | "current" | "stable" = "release") {
const MiNA = await getMiNA({
did: process.env.MI_DID!,
userId: process.env.MI_USER!,
password: process.env.MI_PASS!,
passToken: process.env.MI_TOKEN,
debug: !!process.env.MI_DEBUG,
});
if (!MiNA) {
console.log(`❌ 登录失败,请检查小米账号密码是否正确:`);
console.log(`当前账号:${process.env.MI_USER}`);
console.log(`当前密码:${process.env.MI_PASS}`);
return;
}
const devices = await MiNA.getDevices();
const speaker = devices?.find(
(e: any) =>
e.name === process.env.MI_DID || e.miotDID === process.env.MI_DID
);
if (!speaker) {
console.log(`❌ 找不到设备: ${process.env.MI_DID}`);
console.log(`可用设备列表:`);
console.log(
JSON.stringify(
devices.map((e: any) => ({
name: e.name,
did: e.miotDID,
})),
null,
4
)
);
return;
}
const speaker = MiNA.account.device as any;
if (!kSupportedDevices.includes(speaker.hardware)) {
console.log(
@@ -65,7 +47,7 @@ async function getOTA(channel: "release" | "current" | "stable" = "release") {
}
const model = speaker.hardware;
const time = new Date().getTime();
const time = Date.now();
const sn = process.env.DEBUG_VERSION ? "" : speaker.serialNumber;
const version = process.env.DEBUG_VERSION ?? speaker.romVersion;
const otaInfo = `channel=${channel}&filterID=${sn}&locale=zh_CN&model=${model}&time=${time}&version=${version}&8007236f-a2d6-4847-ac83-c49395ad6d65`;
@@ -88,7 +70,7 @@ async function main() {
} else {
ota = await getOTA();
}
if (!ota.url) {
if (!ota?.url) {
console.log(`❌ 获取设备信息失败`);
process.exit(1);
}
+3 -3
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
@@ -22,13 +22,13 @@ apply_patches() {
# 创建临时文件用于占位符替换
local temp_patch=$(mktemp)
# 将补丁文件中的 {SSH_PASSWORD} 替换为 PASSWORD
sed "s/{SSH_PASSWORD}/$PASSWORD/g" "$file" > "$temp_patch"
sed "s|{SSH_PASSWORD}|$PASSWORD|g" "$file" > "$temp_patch"
# 应用替换后的补丁
patch -p1 < "$temp_patch"
# 清理临时文件
rm "$temp_patch"
elif [[ "$file" == *.sh ]]; then
sh "$file"
bash "$file"
fi
fi
done
+11 -5
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
@@ -40,14 +40,20 @@ elif [ "$MODEL" = "LX06" ]; then
IMAGE_MAX_SIZE=$((0x02800000))
fi
SIZE=`stat -L -c %s $FIRMWARE/root-patched.squashfs`
echo "📊 当前固件大小: $SIZE 字节"
SIZE=$(stat -L -c %s "$FIRMWARE/root-patched.squashfs")
SIZE_MB=$((SIZE / 1024 / 1024))
IMAGE_MAX_SIZE_MB=$((IMAGE_MAX_SIZE / 1024 / 1024))
echo "📊 当前固件大小: $SIZE 字节 ($SIZE_MB MB)"
if [ "$SIZE" -ge "$IMAGE_MAX_SIZE" ]; then
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节"
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节 ($IMAGE_MAX_SIZE_MB MB)"
exit 1
fi
echo "✅ 固件大小检查通过,剩余空间: $((IMAGE_MAX_SIZE - SIZE)) 字节"
REMAINING_BYTES=$((IMAGE_MAX_SIZE - SIZE))
REMAINING_MB=$((REMAINING_BYTES / 1024 / 1024))
echo "✅ 固件大小检查通过,剩余可用空间: $REMAINING_BYTES 字节 ($REMAINING_MB MB)"
cp -rf $FIRMWARE $BASE_DIR/assets/$FIRMWARE
+2
View File
@@ -0,0 +1,2 @@
.idea
.clwb
+208 -147
View File
@@ -4,30 +4,24 @@ version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "backtrace"
version = "0.3.74"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
@@ -35,14 +29,14 @@ dependencies = [
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets",
"windows-link",
]
[[package]]
name = "bitflags"
version = "2.9.0"
version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
name = "block-buffer"
@@ -53,6 +47,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -61,9 +61,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cfg-if"
version = "1.0.0"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cpufeatures"
@@ -86,9 +86,9 @@ dependencies = [
[[package]]
name = "data-encoding"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "digest"
@@ -197,9 +197,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "0.14.7"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
checksum = "1dc8f7d2ded5f9209535e4b3fd4d39c002f30902ff5ce9f64e2c33d549576500"
dependencies = [
"typenum",
"version_check",
@@ -207,21 +207,21 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.3.2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "gimli"
version = "0.31.1"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "http"
@@ -240,6 +240,17 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "io-uring"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags",
"cfg-if",
"libc",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -247,62 +258,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.171"
name = "js-sys"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
dependencies = [
"autocfg",
"scopeguard",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "log"
version = "0.4.27"
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.4"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "miniz_oxide"
version = "0.8.7"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys",
]
[[package]]
name = "object"
version = "0.36.7"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "open-xiaoai"
version = "1.0.0"
@@ -316,29 +333,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -362,33 +356,33 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.94"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.2.0"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
@@ -414,19 +408,16 @@ dependencies = [
]
[[package]]
name = "redox_syscall"
version = "0.5.11"
name = "rustc-demangle"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
dependencies = [
"bitflags",
]
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-demangle"
version = "0.1.24"
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
@@ -435,25 +426,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "scopeguard"
version = "1.2.0"
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde"
version = "1.0.219"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -462,14 +457,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
@@ -485,33 +481,24 @@ dependencies = [
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.9"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "socket2"
version = "0.5.9"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys",
@@ -519,9 +506,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.100"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
@@ -530,18 +517,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.12"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
@@ -550,17 +537,18 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.44.2"
version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
"windows-sys",
@@ -608,15 +596,15 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.18"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "utf-8"
@@ -626,11 +614,13 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "uuid"
version = "1.16.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
@@ -641,24 +631,98 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wit-bindgen-rt",
"wasip2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
@@ -728,28 +792,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "zerocopy"
version = "0.8.24"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.24"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
+4 -4
View File
@@ -16,9 +16,9 @@ image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:0.2.5"
[dependencies]
futures = "0.3.31"
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = "0.26.2"
rand = "0.9.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.15.1", features = ["v4"] }
rand = "0.9.1"
tokio = { version = "1.47", features = ["fs", "macros", "process", "rt-multi-thread", "sync", "time"] }
tokio-tungstenite = "0.26.2"
uuid = { version = "1.18.1", features = ["v4"] }
+1 -1
View File
@@ -59,7 +59,7 @@ cross build --release --target armv7-unknown-linux-gnueabihf
```
> [!TIP]
> 如果你是 Apple silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
> 如果你是 Apple Silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
编译成功后,将构建好的补丁程序 `client` 复制到小爱音箱上
+8 -4
View File
@@ -1,4 +1,4 @@
#! /bin/sh
#!/bin/sh
exec > /dev/null 2>&1
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
EOF
set -e
# 等待能够正常访问 baidu.com
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
echo "🤫 等待网络连接中..."
sleep 1
done
echo "🤫 等待网络连接中..."
sleep 3
sleep 5
echo "✅ 网络连接成功"
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-client"
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/sh
#!/bin/sh
cat << 'EOF'
+48 -36
View File
@@ -16,33 +16,45 @@ use open_xiaoai::services::connect::rpc::RPC;
use open_xiaoai::services::monitor::instruction::InstructionMonitor;
use open_xiaoai::services::monitor::playing::PlayingMonitor;
struct AppClient;
struct AppClient {
kws_monitor: KwsMonitor,
instruction_monitor: InstructionMonitor,
playing_monitor: PlayingMonitor,
}
impl AppClient {
pub async fn connect(url: &str) -> Result<WsStream, AppError> {
pub fn new() -> Self {
Self {
kws_monitor: KwsMonitor::new(),
instruction_monitor: InstructionMonitor::new(),
playing_monitor: PlayingMonitor::new(),
}
}
pub async fn connect(&self, url: &str) -> Result<WsStream, AppError> {
let (ws_stream, _) = connect_async(url).await?;
Ok(WsStream::Client(ws_stream))
}
pub async fn run() {
pub async fn run(&mut self) {
let url = std::env::args().nth(1).expect("❌ 请输入服务器地址");
println!("✅ 已启动");
loop {
let Ok(ws_stream) = AppClient::connect(&url).await else {
let Ok(ws_stream) = self.connect(&url).await else {
sleep(Duration::from_secs(1)).await;
continue;
};
println!("✅ 已连接: {:?}", url);
AppClient::init(ws_stream).await;
self.init(ws_stream).await;
if let Err(e) = MessageManager::instance().process_messages().await {
eprintln!("❌ 消息处理异常: {}", e);
}
AppClient::dispose().await;
self.dispose().await;
eprintln!("❌ 已断开连接");
}
}
async fn init(ws_stream: WsStream) {
async fn init(&mut self, ws_stream: WsStream) {
MessageManager::instance().init(ws_stream).await;
MessageHandler::<Event>::instance()
.set_handler(on_event)
@@ -59,35 +71,38 @@ impl AppClient {
rpc.add_command("start_recording", start_recording).await;
rpc.add_command("stop_recording", stop_recording).await;
InstructionMonitor::start(|event| async move {
MessageManager::instance()
.send_event("instruction", Some(json!(event)))
.await
})
.await;
self.instruction_monitor
.start(|event| async move {
MessageManager::instance()
.send_event("instruction", Some(json!(event)))
.await
})
.await;
PlayingMonitor::start(|event| async move {
MessageManager::instance()
.send_event("playing", Some(json!(event)))
.await
})
.await;
self.playing_monitor
.start(|event| async move {
MessageManager::instance()
.send_event("playing", Some(json!(event)))
.await
})
.await;
KwsMonitor::start(|event| async move {
MessageManager::instance()
.send_event("kws", Some(json!(event)))
.await
})
.await;
self.kws_monitor
.start(|event| async move {
MessageManager::instance()
.send_event("kws", Some(json!(event)))
.await
})
.await;
}
async fn dispose() {
async fn dispose(&mut self) {
MessageManager::instance().dispose().await;
let _ = AudioPlayer::instance().stop().await;
let _ = AudioRecorder::instance().stop_recording().await;
InstructionMonitor::stop().await;
PlayingMonitor::stop().await;
KwsMonitor::stop().await;
self.instruction_monitor.stop().await;
self.playing_monitor.stop().await;
self.kws_monitor.stop().await;
}
}
@@ -147,17 +162,14 @@ async fn on_event(event: Event) -> Result<(), AppError> {
async fn on_stream(stream: Stream) -> Result<(), AppError> {
let Stream { tag, bytes, .. } = stream;
match tag.as_str() {
"play" => {
// 播放接收到的音频流
let _ = AudioPlayer::instance().play(bytes).await;
}
_ => {}
if tag.as_str() == "play" {
// 播放接收到的音频流
let _ = AudioPlayer::instance().play(bytes).await;
}
Ok(())
}
#[tokio::main]
async fn main() {
AppClient::run().await;
AppClient::new().run().await;
}
+10 -9
View File
@@ -29,7 +29,7 @@ async fn on_keyword(_keyword: String) {
.split("\n")
.filter(|line| !line.trim().is_empty())
.collect::<Vec<&str>>();
if replies.len() > 0 {
if !replies.is_empty() {
wakeup_sounds.clear();
wakeup_sounds.extend(replies.iter().map(|s| s.to_string()));
}
@@ -53,14 +53,15 @@ async fn main() {
on_started().await;
}
KwsMonitor::start(|event| async move {
match event {
KwsMonitorEvent::Started => on_started().await,
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await,
}
Ok(())
})
.await;
KwsMonitor::new()
.start(|event| async move {
match event {
KwsMonitorEvent::Started => on_started().await,
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).await,
}
Ok(())
})
.await;
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@@ -39,7 +39,7 @@ impl AudioPlayer {
}
if let Some(task) = self.player_task.lock().await.take() {
let _ = task.abort();
task.abort();
}
if let Some(mut write_thread) = self.write_thread.lock().await.take() {
@@ -1,13 +1,12 @@
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::path::Path;
use std::sync::LazyLock;
use tokio::fs::OpenOptions;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader, SeekFrom};
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
use crate::base::AppError;
use crate::utils::task::TaskManager;
#[derive(Debug, Serialize, Deserialize)]
pub enum FileMonitorEvent {
@@ -15,20 +14,22 @@ pub enum FileMonitorEvent {
NewLine(String),
}
pub struct FileMonitor;
pub struct FileMonitor {
task_holder: Option<JoinHandle<()>>,
}
static INSTANCE: LazyLock<FileMonitor> = LazyLock::new(FileMonitor::new);
impl Default for FileMonitor {
fn default() -> Self {
Self::new()
}
}
impl FileMonitor {
fn new() -> Self {
Self {}
pub fn new() -> Self {
Self { task_holder: None }
}
pub fn instance() -> &'static Self {
&INSTANCE
}
pub async fn start<F, Fut>(&self, file_path: &str, on_update: F)
pub async fn start<F, Fut>(&mut self, file_path: &str, on_update: F)
where
F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static,
@@ -36,18 +37,19 @@ impl FileMonitor {
let file_path_clone = file_path.to_string();
let monitor = tokio::spawn(async move {
let _ = FileMonitor::start_monitor(file_path_clone.as_str(), on_update).await;
let _ = Self::start_monitor(file_path_clone.as_str(), on_update).await;
});
TaskManager::instance()
.add(&format!("FileMonitor-{}", file_path), monitor)
.await;
if let Some(old_task) = self.task_holder.replace(monitor) {
println!("Aborting old file monitor task");
old_task.abort();
}
}
pub async fn stop(&self, file_path: &str) {
TaskManager::instance()
.dispose(&format!("FileMonitor-{}", file_path))
.await;
pub async fn stop(&mut self) {
if let Some(handle) = self.task_holder.take() {
handle.abort();
}
}
async fn start_monitor<F, Fut>(file_path: &str, on_update: F) -> Result<(), AppError>
@@ -6,23 +6,37 @@ use crate::base::AppError;
use super::file::{FileMonitor, FileMonitorEvent};
pub struct InstructionMonitor;
static INSTRUCTION_FILE_PATH: &str = "/tmp/mico_aivs_lab/instruction.log";
pub struct InstructionMonitor {
file_monitor: FileMonitor,
}
impl Default for InstructionMonitor {
fn default() -> Self {
Self::new()
}
}
impl InstructionMonitor {
pub async fn start<F, Fut>(on_update: F)
pub fn new() -> Self {
Self {
file_monitor: FileMonitor::new(),
}
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where
F: Fn(FileMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static,
{
FileMonitor::instance()
self.file_monitor
.start(INSTRUCTION_FILE_PATH, on_update)
.await;
}
pub async fn stop() {
FileMonitor::instance().stop(INSTRUCTION_FILE_PATH).await;
pub async fn stop(&mut self) {
self.file_monitor.stop().await;
}
}
@@ -8,36 +8,52 @@ use crate::base::AppError;
use super::file::{FileMonitor, FileMonitorEvent};
pub static KWS_FILE_PATH: &str = "/tmp/open-xiaoai/kws.log";
#[derive(Debug, Serialize, Deserialize)]
pub enum KwsMonitorEvent {
Started,
Keyword(String),
}
pub struct KwsMonitor;
pub struct KwsMonitor {
file_monitor: FileMonitor,
}
pub static KWS_FILE_PATH: &str = "/tmp/open-xiaoai/kws.log";
static LAST_TIMESTAMP: AtomicU64 = AtomicU64::new(0);
impl Default for KwsMonitor {
fn default() -> Self {
Self::new()
}
}
impl KwsMonitor {
pub async fn start<F, Fut>(on_update: F)
pub fn new() -> Self {
Self {
file_monitor: FileMonitor::new(),
}
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where
F: Fn(KwsMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static,
{
let on_update = Arc::new(on_update);
FileMonitor::instance()
let last_ts_store = Arc::new(AtomicU64::new(0));
self.file_monitor
.start(KWS_FILE_PATH, move |event| {
let on_update = Arc::clone(&on_update);
let last_ts_store = Arc::clone(&last_ts_store);
async move {
if let FileMonitorEvent::NewLine(content) = event {
let data = content.split('@').collect::<Vec<&str>>();
let timestamp = data[0].parse::<u64>().unwrap();
let keyword = data[1].to_string();
let last_timestamp = LAST_TIMESTAMP.load(Ordering::Relaxed);
let last_timestamp = last_ts_store.load(Ordering::Relaxed);
if timestamp != last_timestamp {
LAST_TIMESTAMP.store(timestamp, Ordering::Relaxed);
last_ts_store.store(timestamp, Ordering::Relaxed);
let kws_event = if keyword == "__STARTED__" {
KwsMonitorEvent::Started
} else {
@@ -52,8 +68,7 @@ impl KwsMonitor {
.await;
}
pub async fn stop() {
LAST_TIMESTAMP.store(0, Ordering::Relaxed);
FileMonitor::instance().stop(KWS_FILE_PATH).await;
pub async fn stop(&mut self) {
self.file_monitor.stop().await;
}
}
@@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize};
use std::future::Future;
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
use crate::base::AppError;
use crate::utils::shell::run_shell;
use crate::utils::task::TaskManager;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub enum PlayingMonitorEvent {
@@ -13,10 +13,22 @@ pub enum PlayingMonitorEvent {
Idle,
}
pub struct PlayingMonitor;
pub struct PlayingMonitor {
task_holder: Option<JoinHandle<()>>,
}
impl Default for PlayingMonitor {
fn default() -> Self {
Self::new()
}
}
impl PlayingMonitor {
pub async fn start<F, Fut>(on_update: F)
pub fn new() -> Self {
Self { task_holder: None }
}
pub async fn start<F, Fut>(&mut self, on_update: F)
where
F: Fn(PlayingMonitorEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), AppError>> + Send + 'static,
@@ -25,11 +37,16 @@ impl PlayingMonitor {
let _ = PlayingMonitor::start_monitor(on_update).await;
});
TaskManager::instance().add("PlayingMonitor", monitor).await;
if let Some(old_task) = self.task_holder.replace(monitor) {
println!("Aborting old playing monitor task");
old_task.abort();
};
}
pub async fn stop() {
TaskManager::instance().dispose("PlayingMonitor").await;
pub async fn stop(&mut self) {
if let Some(handle) = self.task_holder.take() {
handle.abort();
}
}
async fn start_monitor<F, Fut>(on_update: F) -> Result<(), AppError>
+1 -4
View File
@@ -73,9 +73,6 @@ impl EventBus {
async fn get_callbacks(&self, event: &str) -> Option<Vec<EventCallback>> {
let subscribers = self.subscribers.lock().await;
match subscribers.get(event) {
Some(callbacks) => Some(callbacks.clone()),
None => None,
}
subscribers.get(event).cloned()
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
pub mod event;
pub mod rand;
pub mod shell;
pub mod task;
pub mod rand;
+1 -1
View File
@@ -35,7 +35,7 @@ impl TaskManager {
let mut tasks = self.tasks.lock().await;
if let Some(handles) = tasks.remove(tag) {
for handle in handles {
let _ = handle.abort();
handle.abort();
}
}
}
+2
View File
@@ -0,0 +1,2 @@
*.mp3
*.wav
+853
View File
@@ -0,0 +1,853 @@
# 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 2.10.0",
"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 = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[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 = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[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 = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "dashmap"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[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 = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[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 = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[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 = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[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 = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[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 2.10.0",
]
[[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 = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[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 = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-adpcm",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[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 = "tokio-util"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"hashbrown 0.15.5",
"pin-project-lite",
"slab",
"tokio",
]
[[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"
[[package]]
name = "xiao"
version = "0.1.0"
dependencies = [
"alsa",
"anyhow",
"dashmap",
"opus",
"parking_lot",
"postcard",
"serde",
"symphonia",
"thiserror",
"tokio",
"tokio-util",
]
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "xiao"
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"] }
tokio-util = { version = "0.7", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.0", features = ["alloc", "use-std"] }
parking_lot = "0.12.5"
dashmap = "6.1.0"
thiserror = "2.0.17"
[target.'cfg(target_os = "linux")'.dependencies]
alsa = "0.11"
[target.'cfg(not(target_os = "linux"))'.dependencies]
symphonia = { version = "0.5", features = ["mp3", "wav", "pcm"] }
+17
View File
@@ -0,0 +1,17 @@
build-server:
cargo build --release --bin server
build-client:
docker run --rm -v $(shell pwd):/app idootop/open-xiaoai-runtime:oh2p \
cargo build --target armv7-unknown-linux-gnueabihf --release --bin client
run-server:
target/release/server
test:
clear && make build-client && make deploy && make build-server && make run-server
# 部署到小爱音箱(调试自用)
deploy:
dd if=target/armv7-unknown-linux-gnueabihf/release/client \
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.235 "dd of=/data/client"
+7
View File
@@ -0,0 +1,7 @@
# Open-XiaoAI Client V2
> 开发中,敬请期待...
## License
MIT License © 2026-PRESENT [Del Wang](https://del.wang)
+351
View File
@@ -0,0 +1,351 @@
//! # Client 模块
//!
//! 音频客户端,支持:
//! - 服务发现和自动连接
//! - 音频录制和播放
//! - RPC 远程调用
//! - 实时事件处理
//!
//! ## 架构
//!
//! ```text
//! ┌─────────────────────────────────────┐
//! │ Client │
//! │ │
//! Server ◀──TCP─────┼──▶ Session │
//! │ ├─ Connection │
//! │ ├─ RPC Manager │
//! │ └─ Active Pipelines │
//! │ │
//! Server ◀──UDP─────┼──▶ Audio Pipelines │
//! │ ├─ RecordPipeline │
//! │ └─ PlaybackPipeline │
//! │ │
//! Events ◀──────────┼──▶ Event Handlers │
//! └─────────────────────────────────────┘
//! ```
mod pipeline;
mod session;
pub use pipeline::{PipelineHandle, PlaybackPipeline, RecordPipeline};
pub use session::Session;
use crate::net::command::CommandResult;
use crate::net::discovery::Discovery;
use crate::net::event::{EventBus, EventBusSubscription, EventData};
use crate::net::network::{AudioSocket, Connection};
use crate::net::protocol::ControlPacket;
use crate::net::rpc::RpcBuilder;
use crate::net::sync::now_us;
use anyhow::{Result, anyhow};
use session::handshake;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
/// 客户端配置
#[derive(Debug, Clone)]
pub struct ClientConfig {
/// 版本
pub version: String,
/// 客户端认证
pub client_auth: String,
/// 服务端认证
pub server_auth: String,
/// 心跳间隔(毫秒)
pub heartbeat_ms: u64,
/// 连接超时(秒)
pub timeout: u64,
/// 客户端型号
pub model: String,
/// 序列号
pub serial_number: String,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
server_auth: std::env::var("XIAO_SERVER_AUTH")
.unwrap_or_else(|_| "xiao-server".to_string()),
client_auth: std::env::var("XIAO_CLIENT_AUTH")
.unwrap_or_else(|_| "xiao-client".to_string()),
heartbeat_ms: 200,
timeout: 60,
// todo 获取设备信息
model: "Open-XiaoAi-V2".to_string(),
serial_number: "00:00:00:00:00:00".to_string(),
}
}
}
/// 音频客户端
pub struct Client {
/// 配置
config: ClientConfig,
/// 当前活动会话
session: RwLock<Option<Arc<Session>>>,
/// 全局取消令牌
cancel: CancellationToken,
/// 事件总线
event_bus: Arc<EventBus>,
}
impl Client {
/// 创建新客户端
pub fn new(config: ClientConfig) -> Self {
Self {
config,
session: RwLock::new(None),
cancel: CancellationToken::new(),
event_bus: Arc::new(EventBus::default()),
}
}
/// 使用默认配置创建客户端
pub fn with_defaults() -> Self {
Self::new(ClientConfig::default())
}
/// 订阅事件
pub fn subscribe_events(&self) -> EventBusSubscription {
self.event_bus.subscribe()
}
/// 运行客户端(自动发现服务器并连接)
pub async fn run(self: Arc<Self>) -> Result<()> {
loop {
tokio::select! {
_ = self.cancel.cancelled() => {
println!("[Client] Shutting down...");
break;
}
result = self.discover_and_connect() => {
if let Err(e) = result {
eprintln!("[Client] Connection error: {}", e);
}
// 连接断开后清理并重试
self.cleanup().await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
Ok(())
}
/// 发现服务器并连接
async fn discover_and_connect(&self) -> Result<()> {
println!("[Client] Searching for server...");
let (ip, tcp_port) = Discovery::listen().await?;
let server_addr = SocketAddr::new(ip, tcp_port);
println!("[Client] Found server at {}", server_addr);
let stream = tokio::net::TcpStream::connect(server_addr).await?;
println!("[Client] Connected to {}", server_addr);
self.handle_session(stream, server_addr).await
}
/// 处理会话
async fn handle_session(
&self,
stream: tokio::net::TcpStream,
server_addr: SocketAddr,
) -> Result<()> {
let conn = Arc::new(Connection::new(stream)?);
let audio_socket = Arc::new(AudioSocket::bind().await?);
// 执行握手
let handshake_result =
handshake(&conn, audio_socket.port(), server_addr, &self.config).await?;
println!(
"[Client] Handshake OK, server audio at {}",
handshake_result.server_audio_addr
);
// 创建会话
let session_cancel = self.cancel.child_token();
let session = Arc::new(Session::new(
conn.clone(),
audio_socket,
handshake_result.server_audio_addr,
session_cancel.clone(),
));
// 存储会话
*self.session.write().await = Some(session.clone());
// 启动心跳
self.spawn_heartbeat(session.clone());
// 运行消息循环
self.message_loop(session).await
}
/// 启动心跳任务
fn spawn_heartbeat(&self, session: Arc<Session>) {
let interval = std::time::Duration::from_millis(self.config.heartbeat_ms);
tokio::spawn(async move {
let mut seq = 0;
let mut ticker = tokio::time::interval(interval);
loop {
tokio::select! {
_ = session.cancel.cancelled() => break,
_ = ticker.tick() => {
let t1 = now_us();
let msg = ControlPacket::Ping { client_ts: t1, seq };
if session.send(&msg).await.is_err() {
break;
}
seq += 1;
}
}
}
});
}
/// 消息主循环
async fn message_loop(&self, session: Arc<Session>) -> Result<()> {
let timeout = std::time::Duration::from_secs(self.config.timeout);
loop {
tokio::select! {
_ = session.cancel.cancelled() => break,
result = tokio::time::timeout(timeout, session.recv()) => {
match result {
Ok(Ok(packet)) => {
self.handle_packet(&session, packet).await?;
}
Ok(Err(e)) => {
return Err(anyhow!("Connection error: {}", e));
}
Err(_) => {
return Err(anyhow!("Connection timeout"));
}
}
}
}
}
Ok(())
}
/// 处理控制包
async fn handle_packet(&self, session: &Arc<Session>, packet: ControlPacket) -> Result<()> {
match packet {
ControlPacket::Pong {
client_ts,
server_ts,
..
} => {
let t4 = now_us();
session.update_clock(client_ts, server_ts, t4);
}
ControlPacket::Event { timestamp, data } => {
self.event_bus.receive(data, timestamp, session.id());
}
ControlPacket::RpcResponse { id, result } => {
session.resolve_rpc(id, result);
}
// 处理 RPC 请求(支持超时和异步控制)
ControlPacket::RpcRequest {
id,
run_async,
timeout,
command,
} => {
let session_clone = session.clone();
session
.rpc_manager
.handle_rpc_request(id, run_async, timeout, command, move |pck| {
let s = session_clone.clone();
async move {
return s.send(&pck).await;
}
})
.await?;
}
ControlPacket::StartRecording { config } => {
println!("[Client] Starting recording...");
let handle = RecordPipeline::spawn(
config,
session.audio_socket.clone(),
session.server_audio_addr,
session.cancel.clone(),
);
session.start_recording(handle);
}
ControlPacket::StopRecording => {
println!("[Client] Stopping recording...");
session.stop_recording();
}
ControlPacket::StartPlayback { config } => {
println!("[Client] Starting playback...");
let handle = PlaybackPipeline::spawn(
config,
session.clock.clone(),
session.audio_socket.clone(),
session.cancel.clone(),
);
session.start_playback(handle);
}
ControlPacket::StopPlayback => {
println!("[Client] Stopping playback...");
session.stop_playback();
}
_ => {}
}
Ok(())
}
/// 清理资源
async fn cleanup(&self) {
let mut session_guard = self.session.write().await;
if let Some(session) = session_guard.take() {
session.cleanup();
println!("[Client] Session cleaned up");
}
}
// ==================== 公开 API ====================
/// 执行 RPC 命令
pub async fn rpc(&self, builder: &RpcBuilder) -> Result<CommandResult> {
let session_guard = self.session.read().await;
if let Some(session) = session_guard.as_ref() {
session.rpc(builder).await
} else {
Err(anyhow!("Not connected"))
}
}
/// 发送事件
pub async fn send_event(&self, event: EventData) -> Result<()> {
let session_guard = self.session.read().await;
if let Some(session) = session_guard.as_ref() {
session
.send(&ControlPacket::Event {
timestamp: now_us(),
data: event,
})
.await
} else {
Err(anyhow!("Not connected"))
}
}
/// 检查是否已连接
pub async fn is_connected(&self) -> bool {
self.session.read().await.is_some()
}
/// 关闭客户端
pub fn shutdown(&self) {
self.cancel.cancel();
}
}
@@ -0,0 +1,280 @@
//! # Audio Pipeline - 音频管道抽象
//!
//! 提供录音和播放的管道化处理。
//!
//! ## 设计
//!
//! ```text
//! RecordPipeline:
//! ┌──────────┐ ┌────────────┐ ┌────────────┐
//! │ ALSA Mic │────▶│ Opus Encode│────▶│ UDP Send │
//! │ (thread) │ │ (async) │ │ (async) │
//! └──────────┘ └────────────┘ └────────────┘
//!
//! PlaybackPipeline:
//! ┌────────────┐ ┌────────────┐ ┌────────────┐
//! │ UDP Recv │────▶│ Opus Decode│────▶│ ALSA Play │
//! │ (async) │ │ (async) │ │ (thread) │
//! └────────────┘ └────────────┘ └────────────┘
//! ```
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::player::AudioPlayer;
use crate::audio::recorder::AudioRecorder;
use crate::net::jitter_buffer::{JitterBuffer, JitterConfig};
use crate::net::network::AudioSocket;
use crate::net::protocol::AudioPacket;
use crate::net::sync::now_us;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
/// 管道句柄 - 用于控制正在运行的音频管道
pub struct PipelineHandle {
cancel: CancellationToken,
}
impl PipelineHandle {
fn new(cancel: CancellationToken) -> Self {
Self { cancel }
}
/// 停止管道
pub fn stop(&self) {
self.cancel.cancel();
}
/// 检查是否已停止
pub fn is_stopped(&self) -> bool {
self.cancel.is_cancelled()
}
}
impl Drop for PipelineHandle {
fn drop(&mut self) {
self.stop();
}
}
/// 录音管道
/// 从麦克风捕获 -> Opus 编码 -> UDP 发送到服务器
pub struct RecordPipeline;
impl RecordPipeline {
/// 启动录音管道
///
/// # Arguments
/// * `config` - 音频配置
/// * `socket` - UDP socket
/// * `target` - 目标服务器地址
/// * `parent_cancel` - 父级取消令牌
pub fn spawn(
config: AudioConfig,
socket: Arc<AudioSocket>,
target: SocketAddr,
parent_cancel: CancellationToken,
) -> PipelineHandle {
let cancel = parent_cancel.child_token();
let handle = PipelineHandle::new(cancel.clone());
let token = cancel.clone();
tokio::spawn(async move {
if let Err(e) = Self::run(config, socket, target, token).await {
eprintln!("[RecordPipeline] Error: {}", e);
}
});
handle
}
async fn run(
config: AudioConfig,
socket: Arc<AudioSocket>,
target: SocketAddr,
cancel: CancellationToken,
) -> anyhow::Result<()> {
// 创建 PCM 数据通道
let (pcm_tx, mut pcm_rx) = mpsc::channel::<Vec<i16>>(32);
// 启动 ALSA 录音线程(阻塞 I/O)
let recorder_config = config.clone();
let channels = recorder_config.channels as usize;
std::thread::spawn(move || {
let recorder = match AudioRecorder::new(&recorder_config) {
Ok(r) => r,
Err(e) => {
eprintln!("[RecordPipeline] Failed to create recorder: {}", e);
return;
}
};
let mut buf =
vec![0i16; recorder_config.frame_size * recorder_config.channels as usize];
loop {
match recorder.read(&mut buf) {
Ok(n) if n > 0 => {
let actual_samples = n * channels;
if pcm_tx
.blocking_send(buf[..actual_samples].to_vec())
.is_err()
{
break;
}
}
Ok(_) => {
// 空读取,继续
}
Err(e) => {
eprintln!("[RecordPipeline] Read error: {}", e);
break;
}
}
}
});
// 创建 Opus 编码器
let mut codec = OpusCodec::new(&config)?;
let mut opus_buf = vec![0u8; 4096];
println!("[RecordPipeline] Started -> {}", target);
// 主循环:从录音线程接收 PCM,编码后发送
loop {
tokio::select! {
_ = cancel.cancelled() => break,
pcm = pcm_rx.recv() => {
match pcm {
Some(samples) => {
if let Ok(encoded_len) = codec.encode(&samples, &mut opus_buf) {
let packet = AudioPacket {
seq: 0,
timestamp: 0,
data: opus_buf[..encoded_len].to_vec(),
};
let _ = socket.send(&packet, target).await;
}
}
None => {
// 录音线程已退出
break;
}
}
}
}
}
println!("[RecordPipeline] Stopped");
Ok(())
}
}
/// 播放管道
/// 从 UDP 接收 -> Opus 解码 -> 扬声器播放
pub struct PlaybackPipeline;
impl PlaybackPipeline {
/// 启动播放管道
///
/// # Arguments
/// * `config` - 音频配置
/// * `socket` - UDP socket
/// * `parent_cancel` - 父级取消令牌
pub fn spawn(
config: AudioConfig,
clock: Arc<parking_lot::Mutex<crate::net::sync::ClockSync>>,
socket: Arc<AudioSocket>,
parent_cancel: CancellationToken,
) -> PipelineHandle {
let cancel = parent_cancel.child_token();
let handle = PipelineHandle::new(cancel.clone());
let token = cancel.clone();
tokio::spawn(async move {
if let Err(e) = Self::run(config, clock, socket, token).await {
eprintln!("[PlaybackPipeline] Error: {}", e);
}
});
handle
}
async fn run(
config: AudioConfig,
clock: Arc<parking_lot::Mutex<crate::net::sync::ClockSync>>,
socket: Arc<AudioSocket>,
cancel: CancellationToken,
) -> anyhow::Result<()> {
// 1. 创建 PCM 通道
let (pcm_tx, mut pcm_rx) = mpsc::channel::<Vec<i16>>(64);
// 2. 专用播放线程
let player_config = config.clone();
std::thread::spawn(move || {
let player = match AudioPlayer::new(&player_config) {
Ok(p) => p,
Err(e) => return eprintln!("[PlaybackPipeline] Player init error: {}", e),
};
// 当 pcm_tx 在异步任务中被 drop,这里会自动退出
while let Some(samples) = pcm_rx.blocking_recv() {
if let Err(e) = player.write(&samples) {
eprintln!("[PlaybackPipeline] Write error: {}", e);
break;
}
}
println!("[PlaybackPipeline] Player thread exited naturally");
});
// 3. Opus 解码器与 Jitter Buffer
let mut codec = OpusCodec::new(&config)?;
let mut jitter_buffer = JitterBuffer::new(JitterConfig::default());
let mut pcm_frame = vec![0i16; config.frame_size * config.channels as usize];
// 提高定时精度
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(5));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
println!("[PlaybackPipeline] Running");
// 4. 主逻辑循环
// 使用 loop + select,当 cancel 触发时直接 break
let mut udp_buf = vec![0u8; 4096];
loop {
tokio::select! {
// 优先级 1: 外部取消
_ = cancel.cancelled() => {
break;
}
// 优先级 2: 网络接收
result = socket.recv(&mut udp_buf) => {
if let Ok((packet,_)) = result {
let arrival_time = clock.lock().to_server_time(now_us());
jitter_buffer.push(packet, arrival_time);
}
}
// 优先级 3: 播放调度
_ = ticker.tick() => {
let current_time = clock.lock().to_server_time(now_us());
while let Some(packet) = jitter_buffer.pop(current_time) {
if let Ok(samples) = codec.decode(&packet.data, &mut pcm_frame) {
let samples = pcm_frame[..samples * config.channels as usize].to_vec();
if pcm_tx.try_send(samples).is_err() {
break;
}
}
}
}
}
}
println!("[PlaybackPipeline] Stopped");
Ok(())
}
}
@@ -0,0 +1,280 @@
//! # Client Session - 客户端会话管理
//!
//! 轻量级的会话结构,负责:
//! - TCP 控制连接
//! - RPC 管理
//! - 会话生命周期
//! - 活动音频流追踪
use crate::net::command::CommandResult;
use crate::net::network::{AudioSocket, Connection};
use crate::net::protocol::{ClientInfo, ControlPacket};
use crate::net::rpc::{RpcBuilder, RpcManager};
use crate::net::sync::ClockSync;
use anyhow::{Context, Result, anyhow};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use super::{ClientConfig, pipeline::PipelineHandle};
/// 活动管道追踪
pub struct ActivePipelines {
pub recorder: Option<PipelineHandle>,
pub player: Option<PipelineHandle>,
}
impl Default for ActivePipelines {
fn default() -> Self {
Self {
recorder: None,
player: None,
}
}
}
impl ActivePipelines {
pub fn stop_all(&mut self) {
if let Some(h) = self.recorder.take() {
h.stop();
}
if let Some(h) = self.player.take() {
h.stop();
}
}
pub fn start_recording(&mut self, handle: PipelineHandle) {
if let Some(h) = self.recorder.take() {
h.stop();
}
self.recorder = Some(handle);
}
pub fn stop_recording(&mut self) {
if let Some(h) = self.recorder.take() {
h.stop();
}
}
pub fn start_playback(&mut self, handle: PipelineHandle) {
if let Some(h) = self.player.take() {
h.stop();
}
self.player = Some(handle);
}
pub fn stop_playback(&mut self) {
if let Some(h) = self.player.take() {
h.stop();
}
}
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
pub fn is_playing(&self) -> bool {
self.player.is_some()
}
}
/// 客户端会话
pub struct Session {
/// TCP 控制连接
pub conn: Arc<Connection>,
/// UDP 音频 socket
pub audio_socket: Arc<AudioSocket>,
/// 服务器音频地址
pub server_audio_addr: SocketAddr,
/// RPC 管理器
pub rpc_manager: Arc<RpcManager>,
/// 会话取消令牌
pub cancel: CancellationToken,
/// 时间同步
pub clock: Arc<parking_lot::Mutex<ClockSync>>,
/// 活动管道
pipelines: parking_lot::Mutex<ActivePipelines>,
/// 会话创建时间
created_at: std::time::Instant,
/// 当前音量
volume: parking_lot::Mutex<u8>,
}
impl Session {
/// 创建新会话
pub fn new(
conn: Arc<Connection>,
audio_socket: Arc<AudioSocket>,
server_audio_addr: SocketAddr,
cancel: CancellationToken,
) -> Self {
Self {
conn,
audio_socket,
server_audio_addr,
rpc_manager: Arc::new(RpcManager::new()),
cancel,
clock: Arc::new(parking_lot::Mutex::new(ClockSync::new(100))),
pipelines: parking_lot::Mutex::new(ActivePipelines::default()),
created_at: std::time::Instant::now(),
volume: parking_lot::Mutex::new(100),
}
}
/// 获取会话 ID(使用 TCP 地址)
pub fn id(&self) -> SocketAddr {
self.conn.peer_addr()
}
/// 检查会话是否仍然有效
pub fn is_alive(&self) -> bool {
!self.cancel.is_cancelled()
}
/// 获取会话运行时间
pub fn uptime_secs(&self) -> u64 {
self.created_at.elapsed().as_secs()
}
/// 发送控制包
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
self.conn.send(packet).await
}
/// 接收控制包
pub async fn recv(&self) -> Result<ControlPacket> {
self.conn.recv().await
}
/// 更新时间
pub fn update_clock(&self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
self.clock
.lock()
.update(client_send_ts, server_ts, client_recv_ts);
}
/// 发起 RPC 调用(支持超时和异步控制)
pub async fn rpc(&self, request: &RpcBuilder) -> Result<CommandResult> {
let result = self
.rpc_manager
.call(request, |pck| async move {
return self.conn.send(&pck).await;
})
.await?;
Ok(result)
}
/// 处理 RPC 响应
pub fn resolve_rpc(&self, id: u32, result: CommandResult) {
self.rpc_manager.resolve(id, result);
}
/// 开始录音管道
pub fn start_recording(&self, handle: PipelineHandle) {
self.pipelines.lock().start_recording(handle);
}
/// 停止录音管道
pub fn stop_recording(&self) {
self.pipelines.lock().stop_recording();
}
/// 开始播放管道
pub fn start_playback(&self, handle: PipelineHandle) {
self.pipelines.lock().start_playback(handle);
}
/// 停止播放管道
pub fn stop_playback(&self) {
self.pipelines.lock().stop_playback();
}
/// 检查是否正在录音
pub fn is_recording(&self) -> bool {
self.pipelines.lock().is_recording()
}
/// 检查是否正在播放
pub fn is_playing(&self) -> bool {
self.pipelines.lock().is_playing()
}
/// 获取当前音量
pub fn volume(&self) -> u8 {
*self.volume.lock()
}
/// 设置音量
pub fn set_volume(&self, vol: u8) -> u8 {
let mut v = self.volume.lock();
let prev = *v;
*v = vol.min(100);
prev
}
/// 清理所有资源
pub fn cleanup(&self) {
self.cancel.cancel();
self.pipelines.lock().stop_all();
}
}
impl Drop for Session {
fn drop(&mut self) {
self.cleanup();
}
}
/// 握手结果
pub struct HandshakeResult {
pub server_audio_addr: SocketAddr,
}
/// 执行客户端握手
pub async fn handshake(
conn: &Connection,
audio_port: u16,
server_addr: SocketAddr,
config: &ClientConfig,
) -> Result<HandshakeResult> {
// 发送 ClientHello
conn.send(&ControlPacket::ClientHello {
udp_port: audio_port,
auth: config.server_auth.clone(),
version: config.version.clone(),
info: ClientInfo {
model: config.model.clone(),
serial_number: config.serial_number.clone(),
},
})
.await?;
// 等待 ServerHello
let server_udp_port = match conn.recv().await? {
ControlPacket::ServerHello {
version: v,
udp_port,
auth,
} => {
if v != config.version {
return Err(anyhow!("Server version mismatch"));
}
if auth != config.client_auth {
return Err(anyhow!("Invalid server auth"));
}
udp_port
}
_ => return Err(anyhow!("Expected ServerHello")),
};
let server_audio_addr = SocketAddr::new(server_addr.ip(), server_udp_port);
Ok(HandshakeResult { server_audio_addr })
}
+2
View File
@@ -0,0 +1,2 @@
pub mod client;
pub mod server;
@@ -0,0 +1,231 @@
//! # AudioBus - 音频总线
//!
//! 核心的音频路由模块,采用发布-订阅模式处理多客户端音频流。
//!
//! ## 设计理念
//!
//! ```text
//! ┌──────────────────────────────────────────┐
//! │ AudioBus │
//! │ │
//! Client A ──UDP──▶│ ┌─────────┐ ┌──────────────────┐ │
//! │ │ Ingress │──────▶ BroadcastChannel │ │──▶ Client B, C...
//! Client B ──UDP──▶│ │ Router │ └──────────────────┘ │
//! │ └─────────┘ │
//! │ │ │
//! │ ▼ │
//! │ ┌─────────────┐ │
//! │ │ Recorder │──▶ WAV File │
//! │ └─────────────┘ │
//! │ │
//! │ ┌─────────────┐ │
//! │ │ Playback │◀── WAV/MP3 File │──▶ Client X
//! │ └─────────────┘ │
//! └──────────────────────────────────────────┘
//! ```
//!
//! ## 核心概念
//!
//! - **Subscriber**: 订阅者,可以是客户端或录音器
//! - **Publisher**: 发布者,可以是客户端麦克风或文件播放器
//! - **Channel**: 频道,用于隔离不同的音频流组
use crate::net::network::AudioSocket;
use crate::net::protocol::AudioPacket;
use dashmap::DashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::broadcast;
/// 订阅者 ID
pub type SubscriberId = u64;
/// 音频包及其来源
#[derive(Clone, Debug)]
pub struct AudioFrame {
/// 音频数据包
pub packet: AudioPacket,
/// 发送者地址(如果来自网络)
pub source: Option<SocketAddr>,
/// 时间戳(单调递增)
pub timestamp: u64,
}
/// 订阅者信息
#[derive(Clone)]
pub struct Subscriber {
pub id: SubscriberId,
/// UDP 目标地址
pub addr: SocketAddr,
/// 是否过滤自己的音频(防止回声)
pub filter_self: bool,
}
/// 音频总线 - 负责音频流的路由和分发
pub struct AudioBus {
/// UDP socket 用于收发音频
socket: Arc<AudioSocket>,
/// 广播通道 - 发布订阅模式的核心
/// 所有接收到的音频都会广播到这个 channel
broadcast_tx: broadcast::Sender<AudioFrame>,
/// 订阅者注册表
/// key: SocketAddr (UDP 地址)
/// value: Subscriber
subscribers: DashMap<SocketAddr, Subscriber>,
/// 地址到订阅者 ID 的反向映射
addr_to_id: DashMap<SocketAddr, SubscriberId>,
/// ID 生成器
next_id: AtomicU64,
/// 全局时间戳
timestamp: AtomicU64,
}
impl AudioBus {
/// 创建新的音频总线
pub async fn new() -> anyhow::Result<Self> {
let socket = Arc::new(AudioSocket::bind().await?);
// 广播 channel 容量,设置较大以容纳多个订阅者
let (broadcast_tx, _) = broadcast::channel(256);
Ok(Self {
socket,
broadcast_tx,
subscribers: DashMap::new(),
addr_to_id: DashMap::new(),
next_id: AtomicU64::new(1),
timestamp: AtomicU64::new(0),
})
}
/// 获取 UDP 端口
pub fn port(&self) -> u16 {
self.socket.port()
}
/// 获取 socket 引用(用于外部发送)
pub fn socket(&self) -> Arc<AudioSocket> {
self.socket.clone()
}
/// 注册一个订阅者
pub fn register(&self, addr: SocketAddr, filter_self: bool) -> SubscriberId {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let subscriber = Subscriber {
id,
addr,
filter_self,
};
self.subscribers.insert(addr, subscriber);
self.addr_to_id.insert(addr, id);
println!("[AudioBus] Subscriber {id} registered at {addr}");
id
}
/// 注销订阅者
pub fn unregister(&self, addr: &SocketAddr) {
if let Some((_, sub)) = self.subscribers.remove(addr) {
self.addr_to_id.remove(addr);
println!("[AudioBus] Subscriber {} unregistered", sub.id);
}
}
/// 订阅广播频道,返回一个 Receiver
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.broadcast_tx.subscribe()
}
/// 发布音频帧到总线
pub fn publish(&self, packet: AudioPacket, source: Option<SocketAddr>) {
let ts = self.timestamp.fetch_add(1, Ordering::SeqCst);
let frame = AudioFrame {
packet,
source,
timestamp: ts,
};
// 忽略没有订阅者的情况
let _ = self.broadcast_tx.send(frame);
}
/// 广播音频帧到所有订阅者(除了发送者自己)
pub async fn broadcast(&self, frame: &AudioFrame) {
for entry in self.subscribers.iter() {
let sub = entry.value();
// 如果启用了自过滤,跳过发送者自己
if sub.filter_self {
if let Some(src) = &frame.source {
if src == &sub.addr {
continue;
}
}
}
// 发送音频包
if let Err(e) = self.socket.send(&frame.packet, sub.addr).await {
eprintln!("[AudioBus] Failed to send to {}: {}", sub.addr, e);
}
}
}
/// 发送音频包到指定地址
pub async fn send_to(&self, packet: &AudioPacket, addr: SocketAddr) -> anyhow::Result<()> {
self.socket.send(packet, addr).await
}
/// 启动 UDP 接收循环
/// 这是一个独立的任务,负责:
/// 1. 接收 UDP 音频包
/// 2. 发布到广播频道
pub async fn run_receiver(&self) {
let mut buf = vec![0u8; 4096];
loop {
match self.socket.recv(&mut buf).await {
Ok((packet, src_addr)) => {
// 只处理已注册的发送者
if self.subscribers.contains_key(&src_addr) {
self.publish(packet, Some(src_addr));
}
}
Err(e) => {
eprintln!("[AudioBus] Recv error: {}", e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
}
/// 启动广播分发循环
/// 订阅广播频道并将音频转发给所有订阅者
pub async fn run_broadcaster(self: Arc<Self>) {
let mut rx = self.subscribe();
loop {
match rx.recv().await {
Ok(frame) => {
self.broadcast(&frame).await;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[AudioBus] Broadcaster lagged {} frames", n);
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
}
}
}
/// 获取当前订阅者数量
pub fn subscriber_count(&self) -> usize {
self.subscribers.len()
}
/// 检查地址是否已注册
pub fn is_registered(&self, addr: &SocketAddr) -> bool {
self.subscribers.contains_key(addr)
}
}
+428
View File
@@ -0,0 +1,428 @@
//! # Server 模块
//!
//! 实时音频流服务器,支持:
//! - 多客户端连接管理
//! - 音频流发布-订阅
//! - 录音和播放
//! - RPC 远程调用
//! - 实时事件推送
//!
//! ## 架构
//!
//! ```text
//! ┌─────────────────────────────────────────────┐
//! │ Server │
//! │ │
//! Client 1 ──TCP────────┼──▶ SessionManager │
//! Client 2 ──TCP────────┼──▶ ├─ Session 1 │
//! Client N ──TCP────────┼──▶ ├─ Session 2 │
//! │ └─ Session N │
//! │ │ │
//! ┌────────────────┼────────────┴────────────────────────┐ │
//! │ │ │ │
//! ▼ │ ▼ │
//! ┌─────────┐ │ ┌─────────────────────────────┐ │
//! │Command │ │ │ AudioBus │ │
//! │Handler │ │ │ │ │
//! └─────────┘ │ │ ┌─────────┐ ┌──────────┐ │ │
//! │ │ │ │Receiver │ │Broadcaster│ │ │
//! ▼ │ │ │ Loop │─▶│ Loop │──┼───┼──▶ All Clients
//! ┌─────────┐ │ │ └─────────┘ └──────────┘ │ │
//! │ Event │ │ └─────────────────────────────┘ │
//! │ Bus │───────────┼─────────────────────────────────────────────┼──▶ Events
//! └─────────┘ │ │
//! └─────────────────────────────────────────────┘
//! ```
mod audio_bus;
mod session;
mod stream;
pub use audio_bus::{AudioBus, AudioFrame};
pub use session::{Session, SessionManager};
pub use stream::{FilePlaybackStream, RecorderStream, StreamHandle};
use crate::audio::config::AudioConfig;
use crate::audio::wav::WavReader;
use crate::net::command::CommandResult;
use crate::net::discovery::Discovery;
use crate::net::event::{EventBus, EventBusSubscription, EventData};
use crate::net::network::Connection;
use crate::net::protocol::ControlPacket;
use crate::net::rpc::RpcBuilder;
use crate::net::sync::now_us;
use anyhow::{Context, Result, anyhow};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
/// 服务端配置
#[derive(Debug, Clone)]
pub struct ServerConfig {
/// 版本
pub version: String,
/// 客户端认证
pub client_auth: String,
/// 服务端认证
pub server_auth: String,
/// 连接超时(秒)
pub timeout: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
server_auth: std::env::var("XIAO_SERVER_AUTH")
.unwrap_or_else(|_| "xiao-server".to_string()),
client_auth: std::env::var("XIAO_CLIENT_AUTH")
.unwrap_or_else(|_| "xiao-client".to_string()),
timeout: 60,
}
}
}
/// 实时音频服务器
pub struct Server {
/// 配置
config: ServerConfig,
/// 会话管理器
sessions: Arc<SessionManager>,
/// 音频总线
audio_bus: Arc<AudioBus>,
/// 服务端事件总线
event_bus: Arc<EventBus>,
/// 服务器取消令牌
cancel: CancellationToken,
/// 服务器启动时间
started_at: std::time::Instant,
}
impl Server {
/// 创建新服务器
pub async fn new(config: ServerConfig) -> Result<Self> {
let audio_bus = Arc::new(AudioBus::new().await?);
Ok(Self {
config,
sessions: Arc::new(SessionManager::new()),
audio_bus,
event_bus: Arc::new(EventBus::default()),
cancel: CancellationToken::new(),
started_at: std::time::Instant::now(),
})
}
/// 订阅事件
pub fn subscribe_events(&self) -> EventBusSubscription {
self.event_bus.subscribe()
}
/// 启动服务器
pub async fn run(self: Arc<Self>, port: u16) -> Result<()> {
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
let addr = listener.local_addr()?;
println!("[Server] Listening on TCP: {}", addr);
println!("[Server] Audio UDP port: {}", self.audio_bus.port());
// 广播服务发现
Discovery::broadcast(port).await?;
// 启动音频总线接收循环
let bus = self.audio_bus.clone();
tokio::spawn(async move {
bus.run_receiver().await;
});
// 启动音频总线广播循环
let bus = self.audio_bus.clone();
tokio::spawn(async move {
bus.run_broadcaster().await;
});
// TCP 连接接受循环
loop {
tokio::select! {
_ = self.cancel.cancelled() => {
println!("[Server] Shutting down...");
break;
}
result = listener.accept() => {
match result {
Ok((stream, addr)) => {
let server = self.clone();
tokio::spawn(async move {
if let Err(e) = server.handle_connection(stream, addr).await {
eprintln!("[Server] Connection {} error: {}", addr, e);
}
});
}
Err(e) => {
eprintln!("[Server] Accept error: {}", e);
}
}
}
}
}
Ok(())
}
/// 处理新连接
async fn handle_connection(
self: Arc<Self>,
stream: tokio::net::TcpStream,
addr: SocketAddr,
) -> Result<()> {
println!("[Server] New connection from {}", addr);
let conn = Arc::new(Connection::new(stream)?);
// --- 握手 ---
let (info, audio_addr) = self.handshake(&conn, addr).await?;
println!(
"[Server] Client identified: {} ({}) audio: {}",
info.model, info.serial_number, audio_addr
);
// --- 创建 Session ---
let session_cancel = self.cancel.child_token();
let session = Arc::new(Session::new(
info.clone(),
conn.clone(),
addr,
audio_addr,
session_cancel.clone(),
));
// 注册到 SessionManager 和 AudioBus
self.sessions.register(session.clone());
self.audio_bus.register(audio_addr, true);
// --- 主循环 ---
let result = self.session_loop(session.clone()).await;
// --- 清理 ---
self.audio_bus.unregister(&audio_addr);
self.sessions.unregister(&addr);
result
}
/// 握手流程
async fn handshake(
&self,
conn: &Arc<Connection>,
addr: SocketAddr,
) -> Result<(crate::net::protocol::ClientInfo, SocketAddr)> {
// 等待客户端 Hello
let (info, client_audio_port) = match conn.recv().await? {
ControlPacket::ClientHello {
auth,
version: v,
udp_port,
info,
} => {
if v != self.config.version {
return Err(anyhow!("Client version mismatch"));
}
if auth != self.config.server_auth {
return Err(anyhow!("Invalid client auth"));
}
(info, udp_port)
}
_ => return Err(anyhow!("Expected ClientHello")),
};
// 发送服务器 Hello
conn.send(&ControlPacket::ServerHello {
auth: self.config.client_auth.clone(),
version: self.config.version.clone(),
udp_port: self.audio_bus.port(),
})
.await?;
let audio_addr = SocketAddr::new(addr.ip(), client_audio_port);
Ok((info, audio_addr))
}
/// Session 消息循环
async fn session_loop(&self, session: Arc<Session>) -> Result<()> {
let timeout = std::time::Duration::from_secs(self.config.timeout);
loop {
tokio::select! {
_ = session.cancel.cancelled() => break,
result = tokio::time::timeout(timeout, session.recv()) => {
match result {
Ok(Ok(packet)) => {
self.handle_packet(&session, packet).await?;
}
Ok(Err(e)) => {
return Err(anyhow!("Connection error: {}", e));
}
Err(_) => {
return Err(anyhow!("Connection timeout"));
}
}
}
}
}
Ok(())
}
/// 处理控制包
async fn handle_packet(&self, session: &Arc<Session>, packet: ControlPacket) -> Result<()> {
match packet {
ControlPacket::Ping { client_ts, seq } => {
let pong = ControlPacket::Pong {
client_ts,
server_ts: now_us(),
seq,
};
session.send(&pong).await?;
}
ControlPacket::Event { timestamp, data } => {
self.event_bus.receive(data, timestamp, session.id());
}
ControlPacket::RpcResponse { id, result } => {
session.resolve_rpc(id, result);
}
ControlPacket::RpcRequest {
id,
run_async,
timeout,
command,
} => {
let session_clone = session.clone();
session
.rpc_manager
.handle_rpc_request(id, run_async, timeout, command, move |pck| {
let s = session_clone.clone();
async move {
return s.send(&pck).await;
}
})
.await?;
}
_ => {}
}
Ok(())
}
// ==================== 公开 API ====================
/// 获取所有已连接的客户端地址
pub async fn get_clients(&self) -> Vec<SocketAddr> {
self.sessions.all_addrs()
}
/// 获取客户端数量
pub fn client_count(&self) -> usize {
self.sessions.count()
}
/// 获取服务器运行时间
pub fn uptime_secs(&self) -> u64 {
self.started_at.elapsed().as_secs()
}
/// 向客户端发起 RPC
pub async fn rpc(&self, addr: SocketAddr, builder: &RpcBuilder) -> Result<CommandResult> {
let session = self.sessions.get(&addr).context("Session not found")?;
session.rpc(builder).await
}
/// 发送事件
pub async fn send_event(&self, addr: SocketAddr, event: EventData) -> Result<()> {
let session = self.sessions.get(&addr).context("Session not found")?;
session
.send(&ControlPacket::Event {
timestamp: now_us(),
data: event,
})
.await?;
Ok(())
}
/// 开始录音
pub async fn start_record(&self, addr: SocketAddr, config: AudioConfig) -> Result<()> {
let session = self.sessions.get(&addr).context("Session not found")?;
let filename = format!(
"temp/recorded_{}.wav",
session.info.serial_number.replace(":", "")
);
// 创建录音流,订阅音频总线
let handle = RecorderStream::spawn(
config.clone(),
filename.clone(),
self.audio_bus.subscribe(),
Some(session.audio_addr),
session.cancel.clone(),
);
session.start_recording(handle, config.clone());
// 通知客户端开始发送音频
session
.send(&ControlPacket::StartRecording { config })
.await?;
println!("[Server] Recording started for {} -> {}", addr, filename);
Ok(())
}
/// 停止录音
pub async fn stop_record(&self, addr: SocketAddr) -> Result<()> {
let session = self.sessions.get(&addr).context("Session not found")?;
session.stop_recording();
session.send(&ControlPacket::StopRecording).await?;
println!("[Server] Recording stopped for {}", addr);
Ok(())
}
/// 开始播放
pub async fn start_play(&self, addr: SocketAddr, file_path: &str) -> Result<()> {
let session = self.sessions.get(&addr).context("Session not found")?;
let reader = WavReader::open(file_path)?;
// 通知客户端准备接收音频
session
.send(&ControlPacket::StartPlayback {
config: reader.config.clone(),
})
.await?;
// 创建播放流
let handle = FilePlaybackStream::spawn(
reader,
self.audio_bus.socket(),
session.audio_addr,
session.cancel.clone(),
);
session.start_playback(handle);
println!("[Server] Playback started for {} from {}", addr, file_path);
Ok(())
}
/// 停止播放
pub async fn stop_play(&self, addr: SocketAddr) -> Result<()> {
let session = self.sessions.get(&addr).context("Session not found")?;
session.stop_playback();
session.send(&ControlPacket::StopPlayback).await?;
println!("[Server] Playback stopped for {}", addr);
Ok(())
}
/// 关闭服务器
pub fn shutdown(&self) {
self.cancel.cancel();
}
}
@@ -0,0 +1,328 @@
//! # Session - 客户端会话管理
//!
//! 轻量级的会话结构,专注于:
//! - TCP 控制连接
//! - RPC 管理
//! - 会话生命周期
//!
//! 音频流的实际处理由 AudioBus 和 Stream 模块负责。
use crate::audio::config::AudioConfig;
use crate::net::command::CommandResult;
use crate::net::network::Connection;
use crate::net::protocol::{ClientInfo, ControlPacket};
use crate::net::rpc::{RpcBuilder, RpcManager};
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use super::stream::StreamHandle;
/// 活动流追踪
/// 用于追踪当前 session 的活动音频流
pub struct ActiveStreams {
/// 当前录音流句柄
pub recorder: Option<StreamHandle>,
/// 当前播放流句柄
pub playback: Option<StreamHandle>,
}
impl Default for ActiveStreams {
fn default() -> Self {
Self {
recorder: None,
playback: None,
}
}
}
impl ActiveStreams {
/// 停止所有活动流
pub fn stop_all(&mut self) {
if let Some(h) = self.recorder.take() {
h.stop();
}
if let Some(h) = self.playback.take() {
h.stop();
}
}
/// 开始录音(停止之前的录音)
pub fn start_recording(&mut self, handle: StreamHandle) {
if let Some(h) = self.recorder.take() {
h.stop();
}
self.recorder = Some(handle);
}
/// 停止录音
pub fn stop_recording(&mut self) {
if let Some(h) = self.recorder.take() {
h.stop();
}
}
/// 开始播放(停止之前的播放)
pub fn start_playback(&mut self, handle: StreamHandle) {
if let Some(h) = self.playback.take() {
h.stop();
}
self.playback = Some(handle);
}
/// 停止播放
pub fn stop_playback(&mut self) {
if let Some(h) = self.playback.take() {
h.stop();
}
}
/// 检查是否正在录音
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
/// 检查是否正在播放
pub fn is_playing(&self) -> bool {
self.playback.is_some()
}
}
/// 客户端会话
pub struct Session {
/// 客户端信息
pub info: ClientInfo,
/// TCP 控制连接
pub conn: Arc<Connection>,
/// RPC 管理器
pub rpc_manager: Arc<RpcManager>,
/// TCP 地址(用作会话 ID
pub tcp_addr: SocketAddr,
/// UDP 音频地址
pub audio_addr: SocketAddr,
/// 会话取消令牌
pub cancel: CancellationToken,
/// 活动流
streams: parking_lot::Mutex<ActiveStreams>,
/// 当前录音配置(如果正在录音)
recording_config: parking_lot::Mutex<Option<AudioConfig>>,
/// 会话创建时间
created_at: std::time::Instant,
}
impl Session {
/// 创建新会话
pub fn new(
info: ClientInfo,
conn: Arc<Connection>,
tcp_addr: SocketAddr,
audio_addr: SocketAddr,
cancel: CancellationToken,
) -> Self {
Self {
info,
conn,
rpc_manager: Arc::new(RpcManager::new()),
tcp_addr,
audio_addr,
cancel,
streams: parking_lot::Mutex::new(ActiveStreams::default()),
recording_config: parking_lot::Mutex::new(None),
created_at: std::time::Instant::now(),
}
}
/// 获取会话 ID(使用 TCP 地址)
pub fn id(&self) -> SocketAddr {
self.tcp_addr
}
/// 检查会话是否仍然有效
pub fn is_alive(&self) -> bool {
!self.cancel.is_cancelled()
}
/// 获取会话运行时间(秒)
pub fn uptime_secs(&self) -> u64 {
self.created_at.elapsed().as_secs()
}
/// 发送控制包
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
self.conn.send(packet).await
}
/// 接收控制包
pub async fn recv(&self) -> Result<ControlPacket> {
self.conn.recv().await
}
/// 发起 RPC 调用(支持超时和异步控制)
pub async fn rpc(&self, request: &RpcBuilder) -> Result<CommandResult> {
let result = self
.rpc_manager
.call(request, |pck| async move {
return self.conn.send(&pck).await;
})
.await?;
Ok(result)
}
/// 处理 RPC 响应
pub fn resolve_rpc(&self, id: u32, result: CommandResult) {
self.rpc_manager.resolve(id, result);
}
/// 开始录音流
pub fn start_recording(&self, handle: StreamHandle, config: AudioConfig) {
self.streams.lock().start_recording(handle);
*self.recording_config.lock() = Some(config);
}
/// 停止录音流
pub fn stop_recording(&self) {
self.streams.lock().stop_recording();
*self.recording_config.lock() = None;
}
/// 开始播放流
pub fn start_playback(&self, handle: StreamHandle) {
self.streams.lock().start_playback(handle);
}
/// 停止播放流
pub fn stop_playback(&self) {
self.streams.lock().stop_playback();
}
/// 检查是否正在录音
pub fn is_recording(&self) -> bool {
self.streams.lock().is_recording()
}
/// 检查是否正在播放
pub fn is_playing(&self) -> bool {
self.streams.lock().is_playing()
}
/// 清理所有资源
pub fn cleanup(&self) {
self.cancel.cancel();
self.streams.lock().stop_all();
}
/// 获取当前录音配置
pub fn recording_config(&self) -> Option<AudioConfig> {
self.recording_config.lock().clone()
}
}
impl Drop for Session {
fn drop(&mut self) {
self.cleanup();
}
}
/// 会话管理器
/// 负责管理所有客户端会话
pub struct SessionManager {
sessions: dashmap::DashMap<SocketAddr, Arc<Session>>,
/// 从 UDP 地址到 TCP 地址的映射
udp_to_tcp: dashmap::DashMap<SocketAddr, SocketAddr>,
}
impl SessionManager {
pub fn new() -> Self {
Self {
sessions: dashmap::DashMap::new(),
udp_to_tcp: dashmap::DashMap::new(),
}
}
/// 注册新会话
pub fn register(&self, session: Arc<Session>) {
let tcp_addr = session.tcp_addr;
let audio_addr = session.audio_addr;
self.udp_to_tcp.insert(audio_addr, tcp_addr);
self.sessions.insert(tcp_addr, session);
println!(
"[SessionManager] Registered: {} (audio: {})",
tcp_addr, audio_addr
);
}
/// 注销会话
pub fn unregister(&self, tcp_addr: &SocketAddr) -> Option<Arc<Session>> {
if let Some((_, session)) = self.sessions.remove(tcp_addr) {
self.udp_to_tcp.remove(&session.audio_addr);
session.cleanup();
println!(
"[SessionManager] Unregistered: {} ({})",
tcp_addr, session.info.model
);
Some(session)
} else {
None
}
}
/// 通过 TCP 地址获取会话
pub fn get(&self, tcp_addr: &SocketAddr) -> Option<Arc<Session>> {
self.sessions.get(tcp_addr).map(|r| r.value().clone())
}
/// 通过 UDP 地址获取会话
pub fn get_by_udp(&self, udp_addr: &SocketAddr) -> Option<Arc<Session>> {
self.udp_to_tcp
.get(udp_addr)
.and_then(|tcp_addr| self.get(tcp_addr.value()))
}
/// 获取所有会话地址
pub fn all_addrs(&self) -> Vec<SocketAddr> {
self.sessions.iter().map(|r| *r.key()).collect()
}
/// 获取所有会话
pub fn all_sessions(&self) -> Vec<Arc<Session>> {
self.sessions.iter().map(|r| r.value().clone()).collect()
}
/// 获取会话数量
pub fn count(&self) -> usize {
self.sessions.len()
}
/// 广播控制包到所有会话
pub async fn broadcast(&self, packet: &ControlPacket) {
for entry in self.sessions.iter() {
let _ = entry.value().send(packet).await;
}
}
/// 广播控制包到所有会话(除了指定的)
pub async fn broadcast_except(&self, packet: &ControlPacket, except: &SocketAddr) {
for entry in self.sessions.iter() {
if entry.key() != except {
let _ = entry.value().send(packet).await;
}
}
}
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}
+304
View File
@@ -0,0 +1,304 @@
//! # AudioStream - 音频流抽象
//!
//! 提供统一的音频流处理接口,支持多种输入源和输出目标。
//!
//! ## 设计
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ Stream Types │
//! │ │
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
//! │ │ FileSource │ │ BusSource │ │ NetworkSink │ │
//! │ │ (WAV Reader) │ │ (From Bus) │ │ (To Client) │ │
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
//! │ │
//! │ ┌──────────────┐ ┌──────────────┐ │
//! │ │ FileSink │ │ BusSink │ │
//! │ │ (WAV Writer) │ │ (To Bus) │ │
//! │ └──────────────┘ └──────────────┘ │
//! └─────────────────────────────────────────────────────────────┘
//! ```
use crate::audio::codec::OpusCodec;
use crate::audio::config::AudioConfig;
use crate::audio::wav::{WavReader, WavWriter};
use crate::net::network::AudioSocket;
use crate::net::protocol::AudioPacket;
use crate::net::sync::now_us;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use super::audio_bus::AudioFrame;
/// 音频流任务句柄
/// 用于控制正在运行的音频流任务
pub struct StreamHandle {
cancel: CancellationToken,
}
impl StreamHandle {
pub fn new(cancel: CancellationToken) -> Self {
Self { cancel }
}
/// 停止流
pub fn stop(&self) {
self.cancel.cancel();
}
/// 检查是否已停止
pub fn is_stopped(&self) -> bool {
self.cancel.is_cancelled()
}
}
impl Drop for StreamHandle {
fn drop(&mut self) {
self.cancel.cancel();
}
}
/// 音频流发送控制器
pub struct StreamSender {
socket: Arc<AudioSocket>,
target: SocketAddr,
codec: OpusCodec,
// 缓存区:避免重复分配内存
opus_buffer: Vec<u8>,
// 状态变量
seq: u32,
stream_start_ts: Option<u128>,
// 配置参数
input_len: usize,
frame_duration_us: u128,
max_lead_us: u128, // 允许的最大超前时间,例如 500_000 (1s)
}
impl StreamSender {
pub fn new(
config: AudioConfig,
socket: Arc<AudioSocket>,
target: SocketAddr,
) -> anyhow::Result<Self> {
let codec = OpusCodec::new(&config)?;
let input_len: usize = config.frame_size * (config.channels as usize);
// 预分配缓冲区
let opus_buffer = vec![0u8; 4096];
let frame_duration_us =
(config.frame_size as f64 / config.sample_rate as f64 * 1_000_000.0) as u128;
Ok(Self {
socket,
target,
codec,
opus_buffer,
seq: 0,
stream_start_ts: None,
input_len,
frame_duration_us,
max_lead_us: 500_000,
})
}
/// 发送音频帧
pub async fn send(&mut self, pcm_input: &[i16]) -> anyhow::Result<()> {
// 0. 处理输入(不足时填充静音)
let pcm_buffer = if pcm_input.len() < self.input_len {
let mut pcm_buffer = vec![0i16; self.input_len];
pcm_buffer[..pcm_input.len()].copy_from_slice(pcm_input);
pcm_buffer
} else {
pcm_input.to_vec()
};
// 1. 编码
let encoded_len = self.codec.encode(&pcm_buffer, &mut self.opus_buffer)?;
// 2. 时间戳处理
let now = now_us();
let start_ts = *self.stream_start_ts.get_or_insert(now);
let target_ts = start_ts + (self.seq as u128 * self.frame_duration_us);
// 3. 构建并发送
let packet = AudioPacket {
seq: self.seq,
timestamp: target_ts,
data: self.opus_buffer[..encoded_len].to_vec(),
};
self.socket.send(&packet, self.target).await?;
self.seq += 1;
// 4. 平滑流控
// 如果发送进度超过当前时间 + 允许的缓冲量,则进行睡眠
if target_ts > now + self.max_lead_us {
let drift = target_ts - (now + self.max_lead_us);
let sleep_ms = (drift / 1000).min(100) as u64;
if sleep_ms > 0 {
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
}
}
Ok(())
}
}
/// 文件播放流 - 从 WAV 文件读取并发送到指定客户端
pub struct FilePlaybackStream;
impl FilePlaybackStream {
/// 启动文件播放流
///
/// # Arguments
/// * `config` - 音频配置(用于 Opus 编码)
/// * `reader` - WAV 文件读取器
/// * `socket` - UDP socket
/// * `target` - 目标客户端地址
/// * `parent_cancel` - 父级取消令牌(用于 session 级别取消)
pub fn spawn(
reader: WavReader,
socket: Arc<AudioSocket>,
target: SocketAddr,
parent_cancel: CancellationToken,
) -> StreamHandle {
let cancel = parent_cancel.child_token();
let token = cancel.clone();
tokio::spawn(async move {
if let Err(e) = Self::run(reader, socket, target, token).await {
eprintln!("[FilePlayback] Error: {}", e);
}
});
StreamHandle::new(cancel)
}
async fn run(
mut reader: WavReader,
socket: Arc<AudioSocket>,
target: SocketAddr,
cancel: CancellationToken,
) -> anyhow::Result<()> {
println!("[FilePlayback] Started -> {}", target);
let mut sender = StreamSender::new(reader.config.clone(), socket, target)?;
loop {
tokio::select! {
_ = cancel.cancelled() => break,
result = async {
// 读取一帧 PCM 数据
if let Some(pcm) = reader.read_one_frame()? {
// 编码并发送
sender.send(pcm).await
} else {
Err(anyhow::anyhow!("EOF"))
}
} => {
if let Err(e) = result {
if e.to_string() != "EOF" {
eprintln!("[FilePlayback] Error: {}", e);
}
break;
}
}
}
}
println!("[FilePlayback] Stopped");
Ok(())
}
}
/// 录音流 - 从总线订阅音频并写入 WAV 文件
pub struct RecorderStream;
impl RecorderStream {
/// 启动录音流
///
/// # Arguments
/// * `config` - 音频配置
/// * `filename` - 输出文件路径
/// * `bus_rx` - 音频总线接收器
/// * `source_filter` - 仅录制来自此地址的音频(None 表示全部录制)
/// * `parent_cancel` - 父级取消令牌
pub fn spawn(
config: AudioConfig,
filename: String,
bus_rx: broadcast::Receiver<AudioFrame>,
source_filter: Option<SocketAddr>,
parent_cancel: CancellationToken,
) -> StreamHandle {
let cancel = parent_cancel.child_token();
let token = cancel.clone();
tokio::spawn(async move {
if let Err(e) = Self::run(config, filename, bus_rx, source_filter, token).await {
eprintln!("[Recorder] Error: {}", e);
}
});
StreamHandle::new(cancel)
}
async fn run(
config: AudioConfig,
filename: String,
mut bus_rx: broadcast::Receiver<AudioFrame>,
source_filter: Option<SocketAddr>,
cancel: CancellationToken,
) -> anyhow::Result<()> {
let mut writer = WavWriter::create(&filename, config.sample_rate, config.channels)?;
let mut codec = OpusCodec::new(&config)?;
let mut pcm = vec![0i16; config.frame_size * config.channels as usize];
println!(
"[Recorder] Started -> {} (filter: {:?})",
filename, source_filter
);
loop {
tokio::select! {
_ = cancel.cancelled() => break,
result = bus_rx.recv() => {
match result {
Ok(frame) => {
// 应用源过滤
if let Some(filter_addr) = &source_filter {
if frame.source.as_ref() != Some(filter_addr) {
continue;
}
}
// 解码并写入
if let Ok(samples_per_channel) = codec.decode(&frame.packet.data, &mut pcm) {
let total_samples = samples_per_channel * config.channels as usize;
let _ = writer.write_samples(&pcm[..total_samples])?;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[Recorder] Lagged {} frames", n);
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
}
}
}
}
// 确保正确关闭文件
writer.finalize()?;
println!("[Recorder] Stopped, file saved: {}", filename);
Ok(())
}
}
+65
View File
@@ -0,0 +1,65 @@
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 opus_rate = match config.sample_rate {
16000 => 16000,
48000 => 48000,
_ => {
return Err(anyhow::anyhow!(
"Unsupported sample rate for Opus: {}",
config.sample_rate
));
}
};
let channels = match config.channels {
1 => Channels::Mono,
2 => Channels::Stereo,
_ => return Err(anyhow::anyhow!("Unsupported channels: {}", config.channels)),
};
let mode = match config.audio_scene {
AudioScene::Music => Application::Audio,
AudioScene::Voice => Application::Voip,
};
let mut encoder =
Encoder::new(opus_rate, channels, mode).context("Opus encoder init failed")?;
let bitrate = if config.bitrate <= 0 {
Bitrate::Auto
} else {
Bitrate::Bits(config.bitrate)
};
encoder.set_bitrate(bitrate)?;
encoder.set_vbr(config.vbr)?;
if config.fec {
encoder.set_inband_fec(true)?;
encoder.set_packet_loss_perc(10)?;
}
let decoder =
Decoder::new(config.sample_rate, channels).context("Opus decoder init failed")?;
Ok(Self { encoder, decoder })
}
pub fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize> {
self.encoder.encode(pcm, out).context("Opus encode failed")
}
pub fn decode(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
self.decoder
.decode(opus, out, false)
.context("Opus decode failed")
}
}
+56
View File
@@ -0,0 +1,56 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AudioScene {
Music,
Voice,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
pub capture_device: String,
pub playback_device: String,
pub sample_rate: u32,
pub channels: u16,
pub frame_size: usize,
pub audio_scene: AudioScene,
pub bitrate: i32,
pub vbr: bool,
pub fec: bool,
}
impl AudioConfig {
pub fn voice_16k() -> Self {
Self {
capture_device: "plug:Capture".to_string(),
playback_device: "default".to_string(),
sample_rate: 16_000,
channels: 1,
frame_size: 320, // 20ms
audio_scene: AudioScene::Voice,
bitrate: 32_000,
vbr: true,
fec: false,
}
}
pub fn music_48k() -> Self {
Self {
capture_device: "plug:Capture".to_string(),
playback_device: "default".to_string(),
sample_rate: 48_000,
channels: 2,
frame_size: 960, // 20ms
audio_scene: AudioScene::Music,
bitrate: 320_000,
vbr: true,
fec: false,
}
}
}
impl Default for AudioConfig {
fn default() -> Self {
Self::voice_16k()
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod codec;
pub mod config;
pub mod player;
pub mod recorder;
pub mod wav;
pub mod reader;
+58
View File
@@ -0,0 +1,58 @@
use crate::audio::config::AudioConfig;
use anyhow::{Context, Result, anyhow};
pub struct AudioPlayer {
#[cfg(target_os = "linux")]
pcm: alsa::pcm::PCM,
}
impl AudioPlayer {
pub fn new(config: &AudioConfig) -> Result<Self> {
#[cfg(target_os = "linux")]
{
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
let pcm = PCM::new(&config.playback_device, Direction::Playback, false)
.context("Failed to open playback PCM device")?;
{
let hwp = HwParams::any(&pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(config.sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(config.channels as u32)?;
pcm.hw_params(&hwp)?;
pcm.prepare().context("Failed to prepare PCM")?;
}
Ok(Self { pcm })
}
#[cfg(not(target_os = "linux"))]
{
Err(anyhow!("Linux Only"))
}
}
pub fn write(&self, buf: &[i16]) -> Result<usize> {
#[cfg(target_os = "linux")]
{
let res = self.pcm.io_i16()?.writei(buf);
match res {
Ok(n) => Ok(n),
Err(e) if e.errno() == 32 => {
println!("ALSA write underrun, preparing PCM");
// Broken pipe (underrun)
self.pcm.prepare()?;
self.pcm
.io_i16()?
.writei(buf)
.context("ALSA write retry failed")
}
Err(e) => Err(e.into()),
}
}
#[cfg(not(target_os = "linux"))]
{
Err(anyhow!("Linux Only"))
}
}
}
+143
View File
@@ -0,0 +1,143 @@
#![cfg(not(target_os = "linux"))]
use anyhow::{Context, Result};
use std::fs::File;
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions};
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
pub struct AudioReader {
format: Box<dyn FormatReader>,
decoder: Box<dyn Decoder>,
track_id: u32,
sample_buf: Option<SampleBuffer<i16>>,
left_buffer: Vec<i16>,
right_buffer: Vec<i16>,
pub channels: usize,
pub sample_rate: u32,
}
impl AudioReader {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
let path_ref = path.as_ref();
let src =
File::open(path_ref).context(format!("Failed to open audio file: {:?}", path_ref))?;
let mss = MediaSourceStream::new(Box::new(src), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path_ref.extension().and_then(|s| s.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.context("Failed to probe audio format")?;
let format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
.context("No supported audio track found")?;
let track_id = track.id;
let decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.context("Failed to create decoder")?;
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(1);
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
Ok(Self {
format,
decoder,
track_id,
sample_buf: None,
channels,
sample_rate,
left_buffer: Vec::new(),
right_buffer: Vec::new(),
})
}
pub fn read_chunk(&mut self, chunk_size: usize) -> Result<Option<(Vec<i16>, Vec<i16>)>> {
while self.left_buffer.len() < chunk_size {
if let Some((l, r)) = self.read_frame_internal()? {
self.left_buffer.extend(l);
self.right_buffer.extend(r);
} else {
break;
}
}
if self.left_buffer.is_empty() {
return Ok(None);
}
let actual_size = std::cmp::min(chunk_size, self.left_buffer.len());
let left = self.left_buffer.drain(0..actual_size).collect();
let right = self.right_buffer.drain(0..actual_size).collect();
Ok(Some((left, right)))
}
fn read_frame_internal(&mut self) -> Result<Option<(Vec<i16>, Vec<i16>)>> {
loop {
let packet = match self.format.next_packet() {
Ok(packet) => packet,
Err(symphonia::core::errors::Error::IoError(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
return Ok(None);
}
Err(e) => return Err(e.into()),
};
if packet.track_id() != self.track_id {
continue;
}
let decoded = self
.decoder
.decode(&packet)
.context("Failed to decode packet")?;
if self.sample_buf.is_none() {
self.sample_buf = Some(SampleBuffer::<i16>::new(
decoded.capacity() as u64,
*decoded.spec(),
));
}
if let Some(buf) = self.sample_buf.as_mut() {
buf.copy_interleaved_ref(decoded);
let samples = buf.samples();
let mut left = Vec::with_capacity(samples.len() / self.channels);
let mut right = Vec::with_capacity(samples.len() / self.channels);
if self.channels == 2 {
for i in (0..samples.len()).step_by(2) {
left.push(samples[i]);
right.push(samples[i + 1]);
}
} else {
for &s in samples {
left.push(s);
right.push(s);
}
}
return Ok(Some((left, right)));
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::audio::config::AudioConfig;
use anyhow::{Context, Result, anyhow};
pub struct AudioRecorder {
#[cfg(target_os = "linux")]
pcm: alsa::pcm::PCM,
}
impl AudioRecorder {
pub fn new(config: &AudioConfig) -> Result<Self> {
#[cfg(target_os = "linux")]
{
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)?;
{
let hwp = HwParams::any(&pcm)?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate_near(config.sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels_near(config.channels as u32)?;
pcm.hw_params(&hwp)?;
}
pcm.prepare()?;
Ok(Self { pcm })
}
#[cfg(not(target_os = "linux"))]
{
Err(anyhow!("Linux Only"))
}
}
pub fn read(&self, buf: &mut [i16]) -> Result<usize> {
#[cfg(target_os = "linux")]
{
match self.pcm.io_i16()?.readi(buf) {
Ok(n) => Ok(n),
Err(e) if e.errno() == 32 => {
// 32 = Broken pipe (Overrun)
println!("ALSA recording overrun, recovering...");
self.pcm.prepare()?;
self.pcm
.io_i16()?
.readi(buf)
.context("ALSA read retry failed")
}
Err(e) => Err(e.into()),
}
}
#[cfg(not(target_os = "linux"))]
{
Err(anyhow!("Linux Only"))
}
}
}
+131
View File
@@ -0,0 +1,131 @@
use crate::audio::config::AudioConfig;
use anyhow::Result;
use std::fs::File;
use std::io::{BufWriter, Seek, SeekFrom, Write};
pub struct WavWriter {
writer: BufWriter<File>,
data_size: u32,
sample_rate: u32,
channels: u16,
}
impl WavWriter {
pub fn create(path: &str, sample_rate: u32, channels: u16) -> Result<Self> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
// Header placeholder
writer.write_all(&[0u8; 44])?;
Ok(Self {
writer,
data_size: 0,
sample_rate,
channels,
})
}
pub fn write_samples(&mut self, samples: &[i16]) -> Result<()> {
for &sample in samples {
self.writer.write_all(&sample.to_le_bytes())?;
self.data_size += 2;
}
Ok(())
}
pub fn finalize(mut self) -> Result<()> {
self.writer.flush()?;
let mut file = self.writer.into_inner()?;
file.seek(SeekFrom::Start(0))?;
let file_size = 36 + self.data_size;
let byte_rate = self.sample_rate * self.channels as u32 * 2;
let block_align = self.channels * 2;
let mut header = [0u8; 44];
header[0..4].copy_from_slice(b"RIFF");
header[4..8].copy_from_slice(&file_size.to_le_bytes());
header[8..12].copy_from_slice(b"WAVE");
header[12..16].copy_from_slice(b"fmt ");
header[16..20].copy_from_slice(&16u32.to_le_bytes());
header[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM
header[22..24].copy_from_slice(&self.channels.to_le_bytes());
header[24..28].copy_from_slice(&self.sample_rate.to_le_bytes());
header[28..32].copy_from_slice(&byte_rate.to_le_bytes());
header[32..34].copy_from_slice(&block_align.to_le_bytes());
header[34..36].copy_from_slice(&16u16.to_le_bytes()); // bits per sample
header[36..40].copy_from_slice(b"data");
header[40..44].copy_from_slice(&self.data_size.to_le_bytes());
file.write_all(&header)?;
Ok(())
}
}
pub struct WavReader {
#[cfg(not(target_os = "linux"))]
reader: crate::audio::reader::AudioReader,
pcm_buffer: Vec<i16>,
pub config: AudioConfig,
}
impl WavReader {
pub fn open(path: &str) -> Result<Self> {
#[cfg(not(target_os = "linux"))]
{
let reader = crate::audio::reader::AudioReader::new(path)?;
let channels = reader.channels as u16;
let config = AudioConfig {
channels,
sample_rate: reader.sample_rate,
frame_size: reader.sample_rate as usize / 50, // 20ms
..AudioConfig::music_48k()
};
let input_len = config.frame_size * (channels as usize);
Ok(Self {
reader,
config,
pcm_buffer: vec![0i16; input_len],
})
}
#[cfg(target_os = "linux")]
{
Err(anyhow::anyhow!(
"WavReader is only supported on non-Linux platforms"
))
}
}
pub fn read_one_frame(&mut self) -> Result<Option<&[i16]>> {
#[cfg(not(target_os = "linux"))]
{
match self.reader.read_chunk(self.config.frame_size)? {
Some((left_pcm, right_pcm)) => {
let actual_len = left_pcm.len();
if self.config.channels == 2 {
for i in 0..actual_len {
self.pcm_buffer[i * 2] = left_pcm[i];
self.pcm_buffer[i * 2 + 1] = right_pcm[i];
}
} else {
self.pcm_buffer[..actual_len].copy_from_slice(&left_pcm);
}
let total_len = actual_len * (self.config.channels as usize);
Ok(Some(&self.pcm_buffer[..total_len]))
}
None => Ok(None),
}
}
#[cfg(target_os = "linux")]
{
Err(anyhow::anyhow!(
"WavReader is only supported on non-Linux platforms"
))
}
}
}
+99
View File
@@ -0,0 +1,99 @@
//! # Client Demo
//!
//! 演示客户端的主要功能:
//! - 自动服务发现
//! - 响应 RPC 调用
//! - 音频录制和播放
//! - 事件处理
use std::sync::Arc;
use xiao::app::client::{Client, ClientConfig};
use xiao::net::command::Command;
use xiao::net::event::EventData;
use xiao::net::rpc::RpcBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("╔═══════════════════════════════════════════════════════╗");
println!(
"║ XiaoAi Audio Client v{}",
env!("CARGO_PKG_VERSION")
);
println!("╚═══════════════════════════════════════════════════════╝");
println!();
// 创建客户端
let config = ClientConfig::default();
let client = Arc::new(Client::new(config));
// 启动事件监听器
let event_client = client.clone();
tokio::spawn(async move {
let mut rx = event_client.subscribe_events();
while let Some((event, ts, addr)) = rx.recv().await {
match event {
EventData::Hello { message, .. } => {
println!("[Event] Hello: {} ts:{} addr:{}", message, ts, addr);
}
_ => {}
}
}
});
// 启动客户端主循环
let run_client = client.clone();
tokio::spawn(async move {
if let Err(e) = run_client.run().await {
eprintln!("Client error: {}", e);
}
});
println!("Client is running, searching for server...\n");
// 等待连接
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if client.is_connected().await {
break;
}
}
println!("\n═══════════════════════════════════════════════════════");
println!("Connected to server!");
println!("Running client-side tests...\n");
// 1. 测试简单的 Shell 命令
println!("1️⃣ Testing Shell command...");
match client
.rpc(&RpcBuilder::default(Command::Shell(
"echo 'Hello from client'".to_string(),
)))
.await
{
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
Err(e) => println!(" ❌ Command failed: {}", e),
}
// 2. 发送客户端事件
println!("\n2️⃣ Sending event to server...");
match client
.send_event(EventData::Hello {
message: "from client!".to_string(),
})
.await
{
Ok(_) => println!(" ✅ Event sent"),
Err(e) => println!(" ❌ Failed to send event: {}", e),
}
println!("\n═══════════════════════════════════════════════════════");
println!("✅ Client tests completed!");
println!("\nClient is now ready to receive commands from server.");
println!("Press Ctrl+C to exit.\n");
// 保持运行,等待服务器命令
tokio::signal::ctrl_c().await?;
println!("\nShutting down client...");
client.shutdown();
Ok(())
}
+153
View File
@@ -0,0 +1,153 @@
//! # Server Demo
//!
//! 演示服务端的主要功能:
//! - 多客户端管理
//! - RPC 调用
//! - 音频录制和播放
//! - 事件广播
use std::sync::Arc;
use xiao::app::server::{Server, ServerConfig};
use xiao::audio::config::AudioConfig;
use xiao::net::command::Command;
use xiao::net::event::EventData;
use xiao::net::rpc::RpcBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("╔═══════════════════════════════════════════════════════╗");
println!(
"║ XiaoAi Audio Server v{}",
env!("CARGO_PKG_VERSION")
);
println!("╚═══════════════════════════════════════════════════════╝");
println!();
// 创建服务端
let config = ServerConfig::default();
let server = Arc::new(Server::new(config).await?);
let s = server.clone();
// 启动服务器
tokio::spawn(async move {
if let Err(e) = s.run(8080).await {
eprintln!("Server error: {}", e);
}
});
// 启动事件监听器
let event_server = server.clone();
tokio::spawn(async move {
let mut rx = event_server.subscribe_events();
while let Some((event, ts, addr)) = rx.recv().await {
match event {
EventData::Hello { message, .. } => {
println!("[Event] Hello: {} ts:{} addr:{}", message, ts, addr);
}
_ => {}
}
}
});
println!("Server is running on port 8080");
println!("Waiting for clients to connect...\n");
// 主循环:等待客户端并执行测试
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let clients = server.get_clients().await;
if clients.is_empty() {
continue;
}
let addr = clients[0];
println!("\n═══════════════════════════════════════════════════════");
println!("Client connected: {}", addr);
println!("Running demo tests...\n");
// 1. 测试 Shell 命令(同步)
println!("1️⃣ Testing Shell RPC (sync)...");
match server
.rpc(
addr,
&RpcBuilder::default(Command::Shell("echo 'Hello from server'".to_string())),
)
.await
{
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
Err(e) => println!(" ❌ Command failed: {}", e),
}
// 2. 测试 Shell 命令(带超时)
println!("\n2️⃣ Testing Shell RPC with timeout (1s)...");
match server
.rpc(
addr,
&RpcBuilder::default(Command::Shell("sleep 2".to_string())).set_timeout(1000),
)
.await
{
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
Err(e) => println!(" ❌ Command failed: {}", e),
}
// 3. 测试事件
println!("\n3️⃣ Broadcasting notification event...");
match server
.send_event(
addr,
EventData::Hello {
message: "from server!".to_string(),
},
)
.await
{
Ok(_) => println!(" ✅ Event sent"),
Err(e) => println!(" ❌ Failed to send event: {}", e),
}
// 5. 测试音频录制
println!("\n5️⃣ Testing Audio Recording (10 seconds)...");
match server.start_record(addr, AudioConfig::voice_16k()).await {
Ok(_) => {
println!(" ⏺️ Recording started...");
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
server.stop_record(addr).await?;
println!(" ⏹️ Recording stopped");
}
Err(e) => println!(" ❌ Recording failed: {}", e),
}
// 6. 测试音频播放(如果有测试文件)
println!("\n6️⃣ Testing Audio Playback...");
if std::path::Path::new("temp/test.wav").exists() {
match server.start_play(addr, "temp/test.wav").await {
Ok(_) => {
println!(" ▶️ Playback started...");
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
server.stop_play(addr).await?;
println!(" ⏹️ Playback stopped");
}
Err(e) => println!(" ❌ Playback failed: {}", e),
}
} else {
println!(" ⚠️ No test file found at temp/test.wav, skipping...");
}
println!("\n═══════════════════════════════════════════════════════");
println!("✅ All tests completed!");
println!("\nServer status:");
println!(" • Connected clients: {}", server.client_count());
println!(" • Uptime: {} seconds", server.uptime_secs());
println!("\nPress Ctrl+C to exit.");
break;
}
// 等待退出信号
tokio::signal::ctrl_c().await?;
println!("\nShutting down server...");
server.shutdown();
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
pub mod app;
pub mod audio;
pub mod net;
pub mod utils;
+121
View File
@@ -0,0 +1,121 @@
//! # Command - RPC 命令类型系统
//!
//! 支持多种类型的命令,每种命令有独立的请求和响应结构。
//!
//! ## 设计
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ Command Types │
//! │ │
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
//! │ │ Shell │ │ GetInfo │ │ SetVolume │ ... │
//! │ │ cmd → out │ │ () → Info │ │ vol → () │ │
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
//! │ │
//! │ ┌──────────────────┐ │
//! │ │ RpcRequest │ │
//! │ │ id + Command │ │
//! │ └────────┬─────────┘ │
//! │ │ │
//! │ ▼ │
//! │ ┌──────────────────┐ │
//! │ │ RpcResponse │ │
//! │ │ id + Result │ │
//! │ └──────────────────┘ │
//! └─────────────────────────────────────────────────────────────┘
//! ```
use serde::{Deserialize, Serialize};
use crate::utils::shell::{ShellRequest, ShellResponse};
// ==================== 命令请求类型 ====================
// ==================== 统一命令枚举 ====================
/// RPC 命令 - 统一的请求类型
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum Command {
/// 执行 Shell 命令
Shell(ShellRequest),
}
/// RPC 结果 - 统一的响应类型
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum CommandResult {
/// 错误
Error(CommandError),
/// Shell 命令结果
Shell(ShellResponse),
}
/// 命令错误
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CommandError {
pub code: i32,
pub message: String,
}
impl CommandError {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn not_found(msg: impl Into<String>) -> Self {
Self::new(-1, msg)
}
pub fn invalid_args(msg: impl Into<String>) -> Self {
Self::new(-2, msg)
}
pub fn permission_denied(msg: impl Into<String>) -> Self {
Self::new(-3, msg)
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::new(-500, msg)
}
pub fn timeout(msg: impl Into<String>) -> Self {
Self::new(-408, msg)
}
pub fn not_implemented() -> Self {
Self::new(-501, "Not implemented")
}
}
impl std::fmt::Display for CommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for CommandError {}
// ==================== 便捷构造方法 ====================
impl CommandResult {
/// 创建错误响应
pub fn error(err: CommandError) -> Self {
Self::Error(err)
}
/// 检查是否是错误
pub fn is_error(&self) -> bool {
matches!(self, Self::Error(_))
}
/// 获取错误(如果有)
pub fn as_error(&self) -> Option<&CommandError> {
match self {
Self::Error(e) => Some(e),
_ => None,
}
}
}
+46
View File
@@ -0,0 +1,46 @@
use crate::net::protocol::ControlPacket;
use anyhow::Result;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
const DISCOVERY_PROTOCOL: &str = "XIAO_V2";
pub const DISCOVERY_PORT: u16 = 53530;
pub struct Discovery;
impl Discovery {
pub async fn broadcast(port: u16) -> Result<()> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
let msg = postcard::to_allocvec(&ControlPacket::Discovery {
protocol: DISCOVERY_PROTOCOL.to_string(),
port,
})?;
tokio::spawn(async move {
loop {
let _ = socket.send_to(&msg, target).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
Ok(())
}
pub async fn listen() -> Result<(IpAddr, u16)> {
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
let mut buf = [0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buf).await?;
let data = &buf[..len];
if let Ok(ControlPacket::Discovery { protocol, port }) = postcard::from_bytes(data) {
if protocol == DISCOVERY_PROTOCOL {
return Ok((addr.ip(), port));
}
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! # Event - 实时事件系统
//!
//! 支持双向的实时事件推送,包括:
//! - 服务端事件(推送给客户端)
//! - 客户端事件(推送给服务端)
//! - 事件订阅和过滤
//!
//! ## 设计
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ Event System │
//! │ │
//! │ Server Events: │
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
//! │ │ AudioStatus │ │ ClientJoined │ │ Message │ ... │
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
//! │ │
//! │ Client Events: │
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
//! │ │ StatusUpdate │ │ Metrics │ │ Alert │ ... │
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
//! │ │
//! │ ┌─────────────────────┐ │
//! │ │ EventBus │ │
//! │ │ broadcast channel │ │
//! │ └─────────────────────┘ │
//! └─────────────────────────────────────────────────────────────┘
//! ```
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tokio::sync::broadcast;
// ==================== 事件类型 ====================
/// 客户端事件(Client → Server
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum EventData {
/// 测试事件
Hello { message: String },
}
// ==================== 事件总线 ====================
/// 事件订阅者信息
pub struct EventSubscription<E> {
pub receiver: broadcast::Receiver<E>,
}
impl<E: Clone> EventSubscription<E> {
/// 接收下一个事件
pub async fn recv(&mut self) -> Option<E> {
match self.receiver.recv().await {
Ok(event) => Some(event),
Err(broadcast::error::RecvError::Lagged(_)) => {
// 跳过丢失的事件,继续接收
Box::pin(self.recv()).await
}
Err(broadcast::error::RecvError::Closed) => None,
}
}
}
/// 事件总线(单向,只接收转发)
pub struct EventBus {
tx: broadcast::Sender<(EventData, u128, SocketAddr)>,
}
pub type EventBusSubscription = EventSubscription<(EventData, u128, SocketAddr)>;
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
/// 收到事件
pub fn receive(&self, event: EventData, timestamp: u128, addr: SocketAddr) {
let _ = self.tx.send((event, timestamp, addr));
}
/// 订阅事件
pub fn subscribe(&self) -> EventBusSubscription {
EventSubscription {
receiver: self.tx.subscribe(),
}
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new(128)
}
}
+334
View File
@@ -0,0 +1,334 @@
//! # Jitter Buffer - 抖动缓冲区
//!
//! 用于音频流的抖动缓冲和包重排序。
//!
//! ## 功能
//! - 自适应缓冲区大小
//! - 乱序包重排
//! - 丢包检测和统计
//! - 延迟统计
use crate::net::protocol::AudioPacket;
use std::cmp::Ordering;
use std::collections::{BTreeMap, VecDeque};
impl PartialEq for AudioPacket {
fn eq(&self, other: &Self) -> bool {
self.timestamp == other.timestamp
}
}
impl Eq for AudioPacket {}
impl Ord for AudioPacket {
fn cmp(&self, other: &Self) -> Ordering {
other.timestamp.cmp(&self.timestamp)
}
}
impl PartialOrd for AudioPacket {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Jitter Buffer 统计信息
#[derive(Debug, Clone, Default)]
pub struct JitterStats {
/// 总接收包数
pub received: u64,
/// 总丢包数
pub lost: u64,
/// 总播放包数
pub played: u64,
/// 迟到的包数(到达时已过播放时间)
pub late: u64,
/// 重复包数
pub duplicate: u64,
/// 当前缓冲区大小(包数)
pub buffer_size: usize,
/// 最小延迟(微秒)
pub min_delay: u128,
/// 最大延迟(微秒)
pub max_delay: u128,
/// 平均延迟(微秒)
pub avg_delay: u128,
}
impl JitterStats {
/// 计算丢包率(百分比)
pub fn loss_rate(&self) -> f64 {
if self.received + self.lost == 0 {
return 0.0;
}
(self.lost as f64 / (self.received + self.lost) as f64) * 100.0
}
}
/// Jitter Buffer 配置
#[derive(Debug, Clone)]
pub struct JitterConfig {
/// 最小缓冲区大小(包数)
pub min_buffer_size: usize,
/// 最大缓冲区大小(包数)
pub max_buffer_size: usize,
/// 目标缓冲区大小(包数)
pub target_buffer_size: usize,
/// 自适应调整间隔(包数)
pub adapt_interval: usize,
/// 最大容忍延迟(微秒)
pub max_tolerable_delay: u128,
}
impl Default for JitterConfig {
fn default() -> Self {
Self {
min_buffer_size: 2,
max_buffer_size: 20,
target_buffer_size: 5,
adapt_interval: 50,
max_tolerable_delay: 100_000, // 100ms
}
}
}
/// Jitter Buffer - 用于音频流的抖动缓冲
pub struct JitterBuffer {
/// 配置
config: JitterConfig,
/// 缓冲区(按时间戳排序)
buffer: BTreeMap<u128, AudioPacket>,
/// 统计信息
stats: JitterStats,
/// 期望的下一个序列号
expected_seq: u32,
/// 是否已接收第一个包
first_packet_received: bool,
/// 延迟样本窗口(用于自适应)
delay_samples: VecDeque<u128>,
/// 自适应计数器
adapt_counter: usize,
/// 上次播放的时间戳
last_played_timestamp: u128,
}
impl JitterBuffer {
/// 创建新的 Jitter Buffer
pub fn new(config: JitterConfig) -> Self {
Self {
config,
buffer: BTreeMap::new(),
stats: JitterStats::default(),
expected_seq: 0,
first_packet_received: false,
delay_samples: VecDeque::with_capacity(100),
adapt_counter: 0,
last_played_timestamp: 0,
}
}
/// 使用默认配置创建
pub fn default() -> Self {
Self::new(JitterConfig::default())
}
/// 插入音频包
pub fn push(&mut self, packet: AudioPacket, arrival_time: u128) {
// 检测重复包
if self.buffer.contains_key(&packet.timestamp) {
self.stats.duplicate += 1;
return;
}
// 初始化序列号
if !self.first_packet_received {
self.expected_seq = packet.seq.wrapping_add(1);
self.first_packet_received = true;
} else {
// 检测丢包
let seq_diff = packet.seq.wrapping_sub(self.expected_seq);
if seq_diff > 0 && seq_diff < 1000 {
// 允许一定的序列号跳跃(处理回环)
self.stats.lost += seq_diff as u64;
}
self.expected_seq = packet.seq.wrapping_add(1);
}
// 检查是否迟到
if self.last_played_timestamp > 0 && packet.timestamp < self.last_played_timestamp {
self.stats.late += 1;
return;
}
// 超过最大容忍延迟,直接丢弃
if packet.timestamp < arrival_time
&& arrival_time - packet.timestamp > self.config.max_tolerable_delay
{
self.stats.late += 1;
return;
}
let delay = if packet.timestamp >= arrival_time {
packet.timestamp - arrival_time
} else {
0 // 包到达时已经过了播放时间
};
self.update_delay_stats(delay);
// 插入缓冲区
self.buffer.insert(packet.timestamp, packet);
self.stats.received += 1;
self.stats.buffer_size = self.buffer.len();
// 自适应调整
self.adapt_counter += 1;
if self.adapt_counter >= self.config.adapt_interval {
self.adapt_buffer_size();
self.adapt_counter = 0;
}
}
/// 获取下一个应该播放的包
///
/// # Arguments
/// * `current_time` - 当前服务器时间
///
/// # Returns
/// * `Some(packet)` - 如果有包应该播放
/// * `None` - 如果缓冲区为空或还没到播放时间
pub fn pop(&mut self, current_time: u128) -> Option<AudioPacket> {
// 如果缓冲区为空,直接返回
if self.buffer.is_empty() {
return None;
}
// 检查缓冲区是否达到最小大小
if self.buffer.len() < self.config.target_buffer_size && !self.should_drain() {
return None;
}
// 获取最早的包
if let Some((&timestamp, _)) = self.buffer.iter().next() {
// 检查是否到达播放时间
if current_time >= timestamp {
let packet = self.buffer.remove(&timestamp).unwrap();
self.last_played_timestamp = timestamp;
self.stats.played += 1;
self.stats.buffer_size = self.buffer.len();
return Some(packet);
}
}
None
}
/// 强制获取下一个包(无论时间)
pub fn pop_next(&mut self) -> Option<AudioPacket> {
if let Some((&timestamp, _)) = self.buffer.iter().next() {
let packet = self.buffer.remove(&timestamp).unwrap();
self.last_played_timestamp = timestamp;
self.stats.played += 1;
self.stats.buffer_size = self.buffer.len();
return Some(packet);
}
None
}
/// 查看下一个包的播放时间(不移除)
pub fn peek_next_timestamp(&self) -> Option<u128> {
self.buffer.keys().next().copied()
}
/// 获取缓冲区大小
pub fn len(&self) -> usize {
self.buffer.len()
}
/// 检查缓冲区是否为空
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
/// 获取统计信息
pub fn stats(&self) -> &JitterStats {
&self.stats
}
/// 重置统计信息
pub fn reset_stats(&mut self) {
self.stats = JitterStats::default();
self.stats.buffer_size = self.buffer.len();
}
/// 清空缓冲区
pub fn clear(&mut self) {
self.buffer.clear();
self.stats.buffer_size = 0;
self.first_packet_received = false;
self.last_played_timestamp = 0;
}
/// 检查是否应该排空缓冲区(处理长时间没有新包的情况)
fn should_drain(&self) -> bool {
// 如果缓冲区有包且已经等了很久,就开始播放
!self.buffer.is_empty() && self.buffer.len() >= self.config.min_buffer_size
}
/// 更新延迟统计
fn update_delay_stats(&mut self, delay: u128) {
// 更新最小/最大延迟
if self.stats.received == 0 {
self.stats.min_delay = delay;
self.stats.max_delay = delay;
self.stats.avg_delay = delay;
} else {
self.stats.min_delay = self.stats.min_delay.min(delay);
self.stats.max_delay = self.stats.max_delay.max(delay);
// 滑动平均
self.stats.avg_delay = (self.stats.avg_delay * 9 + delay) / 10;
}
// 保存延迟样本用于自适应
self.delay_samples.push_back(delay);
if self.delay_samples.len() > 100 {
self.delay_samples.pop_front();
}
}
/// 自适应调整缓冲区大小
fn adapt_buffer_size(&mut self) {
if self.delay_samples.len() < 10 {
return;
}
// 计算延迟方差(抖动)
let avg = self.stats.avg_delay;
let variance: f64 = self
.delay_samples
.iter()
.map(|&d| {
let diff = d as i128 - avg as i128;
(diff * diff) as f64
})
.sum::<f64>()
/ self.delay_samples.len() as f64;
let jitter = variance.sqrt();
// 根据抖动调整目标缓冲区大小
// 抖动大 -> 增加缓冲区
// 抖动小 -> 减少缓冲区
let target = if jitter > 50_000.0 {
// 高抖动(>50ms 标准差)
self.config.target_buffer_size + 2
} else if jitter < 10_000.0 {
// 低抖动(<10ms 标准差)
self.config.target_buffer_size.saturating_sub(1)
} else {
self.config.target_buffer_size
};
// 限制在最小/最大范围内
self.config.target_buffer_size = target
.max(self.config.min_buffer_size)
.min(self.config.max_buffer_size);
}
}
+20
View File
@@ -0,0 +1,20 @@
//! # Net 模块
//!
//! 网络通信相关模块:
//! - `command` - RPC 命令类型系统
//! - `discovery` - 服务发现
//! - `event` - 实时事件系统
//! - `network` - 底层网络连接
//! - `protocol` - 通信协议定义
//! - `rpc` - RPC 调用管理
//! - `sync` - 时间同步工具
//! - `jitter_buffer` - 抖动缓冲区实现
pub mod command;
pub mod discovery;
pub mod event;
pub mod jitter_buffer;
pub mod network;
pub mod protocol;
pub mod rpc;
pub mod sync;
+81
View File
@@ -0,0 +1,81 @@
use crate::net::protocol::{AudioPacket, ControlPacket};
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use tokio::sync::Mutex;
/// A unified control connection over TCP
pub struct Connection {
reader: Mutex<tokio::net::tcp::OwnedReadHalf>,
writer: Mutex<tokio::net::tcp::OwnedWriteHalf>,
peer_addr: SocketAddr,
}
impl Connection {
pub fn new(stream: TcpStream) -> Result<Self> {
let peer_addr = stream.peer_addr()?;
let (r, w) = stream.into_split();
Ok(Self {
reader: Mutex::new(r),
writer: Mutex::new(w),
peer_addr,
})
}
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
let mut writer = self.writer.lock().await;
writer.write_u32(bytes.len() as u32).await?;
writer.write_all(&bytes).await?;
writer.flush().await?;
Ok(())
}
pub async fn recv(&self) -> Result<ControlPacket> {
let mut reader = self.reader.lock().await;
let len = reader.read_u32().await? as usize;
if len > 1024 * 1024 {
return Err(anyhow::anyhow!("Packet too large: {}", len));
}
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).await?;
let packet = postcard::from_bytes(&buf)?;
Ok(packet)
}
pub fn peer_addr(&self) -> SocketAddr {
self.peer_addr
}
}
/// UDP Socket for audio transmission
pub struct AudioSocket {
socket: Arc<UdpSocket>,
}
impl AudioSocket {
pub async fn bind() -> Result<Self> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
Ok(Self {
socket: Arc::new(socket),
})
}
pub fn port(&self) -> u16 {
self.socket.local_addr().unwrap().port()
}
pub async fn send(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.socket.send_to(&bytes, target).await?;
Ok(())
}
pub async fn recv(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
let (len, addr) = self.socket.recv_from(buf).await?;
let packet = postcard::from_bytes(&buf[..len])?;
Ok((packet, addr))
}
}
+100
View File
@@ -0,0 +1,100 @@
//! # Protocol - 通信协议定义
//!
//! 定义 Client 和 Server 之间的所有通信协议。
use crate::audio::config::AudioConfig;
use crate::net::command::{Command, CommandResult};
use crate::net::event::EventData;
use serde::{Deserialize, Serialize};
// ==================== 基础类型 ====================
/// 客户端信息
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ClientInfo {
pub model: String,
pub serial_number: String,
}
/// 音频数据包 - UDP 通道传输
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AudioPacket {
pub seq: u32, // 序列号,用于丢包检测
pub timestamp: u128, // 目标播放时间 (主节点时间)
pub data: Vec<u8>, // Opus 编码数据
}
// ==================== 控制包 ====================
/// 控制包 - TCP 通道传输的所有消息类型
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ControlPacket {
// ========== 服务发现 ==========
/// 服务发现广播
Discovery {
protocol: String,
port: u16,
},
// ========== 握手 ==========
/// 服务端握手
ServerHello {
auth: String,
version: String,
udp_port: u16,
},
/// 客户端握手
ClientHello {
auth: String,
version: String,
udp_port: u16,
info: ClientInfo,
},
// ========== 心跳 ==========
Ping {
client_ts: u128,
seq: u32,
},
Pong {
client_ts: u128,
server_ts: u128,
seq: u32,
},
// ========== RPC ==========
RpcRequest {
id: u32,
/// 是否异步运行
run_async: bool,
/// 超时(毫秒)
timeout: Option<u64>,
/// 请求命令
command: Command,
},
RpcResponse {
id: u32,
result: CommandResult,
},
// ========== 事件 ==========
Event {
/// 微秒时间戳
timestamp: u128,
data: EventData,
},
// ========== 音频控制 ==========
/// 开始录音
StartRecording {
config: AudioConfig,
},
/// 停止录音
StopRecording,
/// 开始播放
StartPlayback {
config: AudioConfig,
},
/// 停止播放
StopPlayback,
}
+229
View File
@@ -0,0 +1,229 @@
//! # RPC Manager - RPC 调用管理
//!
//! 管理 RPC 请求的生命周期,包括:
//! - 请求 ID 生成
//! - 请求/响应匹配
//! - 超时处理
//! - 异步任务管理
use crate::net::command::{Command, CommandError, CommandResult};
use crate::net::protocol::ControlPacket;
use crate::utils::shell::Shell;
use anyhow::Result;
use dashmap::DashMap;
use std::future::Future;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use thiserror::Error;
use tokio::sync::oneshot;
#[derive(Debug, Error)]
pub enum RpcError {
#[error("RPC timeout")]
Timeout,
#[error("RPC cancelled")]
Cancelled,
#[error("RPC internal error: {0}")]
Internal(String),
}
pub struct RpcManager {
handler: RpcHandler,
next_id: AtomicU32,
pending: Arc<DashMap<u32, oneshot::Sender<CommandResult>>>,
}
impl RpcManager {
pub fn new() -> Self {
Self {
handler: RpcHandler::new(),
next_id: AtomicU32::new(1),
pending: Arc::new(DashMap::new()),
}
}
pub fn resolve(&self, id: u32, result: CommandResult) {
if let Some((_, tx)) = self.pending.remove(&id) {
let _ = tx.send(result);
}
}
pub fn cancel(&self, id: u32) {
self.pending.remove(&id);
}
/// 发起异步 RPC 调用
///
/// F: 异步闭包,输入 ID,返回 Future
pub async fn call<F, Fut>(
&self,
builder: &RpcBuilder,
send_fn: F,
) -> Result<CommandResult, RpcError>
where
F: FnOnce(ControlPacket) -> Fut,
Fut: Future<Output = Result<()>>,
{
let command = builder.command.clone();
let timeout = builder.timeout;
let run_async = builder.run_async;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
// 1. 注册请求
self.pending.insert(id, tx);
// 2. RAII 守卫:确保无论因超时、报错还是外部 Drop,ID 都会被清理
let _guard = DropGuard {
id,
pending: Arc::clone(&self.pending),
};
// 3. 执行异步发送逻辑
if let Err(e) = send_fn(ControlPacket::RpcRequest {
id,
command,
timeout,
run_async,
})
.await
{
return Err(RpcError::Internal(e.to_string()));
}
// 4. 等待响应或超时
match timeout {
Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), rx).await {
Ok(Ok(res)) => Ok(res),
Ok(Err(_)) => Err(RpcError::Cancelled), // tx 关闭,任务被取消
Err(_) => Err(RpcError::Timeout),
},
None => rx.await.map_err(|_| RpcError::Cancelled), // tx 关闭,任务被取消
}
}
/// 处理 RPC 调用
///
/// F: 异步闭包,输入 ID,返回 Future
pub async fn handle_rpc_request<F, Fut>(
&self,
id: u32,
run_async: bool,
timeout_ms: Option<u64>,
command: Command,
send_fn: F,
) -> Result<()>
where
F: Fn(ControlPacket) -> Fut + Send + Sync + 'static, // 异步执行需要 'static
Fut: Future<Output = Result<()>> + Send,
{
let sender = Arc::new(send_fn);
let handler = self.handler.clone();
let task = async move {
let execute_future = handler.handle(command, timeout_ms);
match timeout_ms {
None => {
// 无超时逻辑
let result = execute_future.await;
sender(ControlPacket::RpcResponse { id, result }).await
}
Some(ms) => {
let d = Duration::from_millis(ms);
match tokio::time::timeout(d, execute_future).await {
Ok(result) => {
// 1. 正常完成
sender(ControlPacket::RpcResponse { id, result }).await
}
Err(_) => {
// 2. 触发超时:发送超时错误消息
let err_result = CommandResult::error(CommandError::timeout(format!(
"Task {} timed out after {}ms",
id, ms
)));
sender(ControlPacket::RpcResponse {
id,
result: err_result,
})
.await
}
}
}
}
};
if run_async {
tokio::spawn(task);
Ok(())
} else {
task.await
}
}
}
#[derive(Debug, Clone)]
pub struct RpcBuilder {
pub command: Command,
pub run_async: bool,
pub timeout: Option<u64>,
}
impl RpcBuilder {
/// 基础构造器
pub fn new(command: Command) -> Self {
Self {
command,
run_async: false,
timeout: None,
}
}
/// 便捷方法:默认异步配置
pub fn default(command: Command) -> Self {
Self::new(command).set_async(true).set_timeout(10_000)
}
/// 设置为异步运行
pub fn set_async(mut self, is_async: bool) -> Self {
self.run_async = is_async;
self
}
/// 设置超时时长(毫秒)
pub fn set_timeout(mut self, ms: u64) -> Self {
self.timeout = Some(ms);
self
}
}
#[derive(Debug, Clone)]
pub struct RpcHandler;
impl RpcHandler {
fn new() -> Self {
Self {}
}
async fn handle(&self, command: Command, timeout_ms: Option<u64>) -> CommandResult {
match command {
Command::Shell(req) => match Shell::run(req, timeout_ms).await {
Ok(resp) => CommandResult::Shell(resp),
Err(e) => CommandResult::Error(CommandError::internal(e.to_string())),
},
_ => CommandResult::Error(CommandError::not_implemented()),
}
}
}
struct DropGuard {
id: u32,
pending: Arc<DashMap<u32, oneshot::Sender<CommandResult>>>,
}
impl Drop for DropGuard {
fn drop(&mut self) {
self.pending.remove(&self.id);
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
/// 获取当前微秒级时间戳
pub fn now_us() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("时间倒流")
.as_micros()
}
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
/// 采用改进的 NTP 算法 + Kalman 滤波思想
pub struct ClockSync {
/// 偏移量样本窗口
offsets: VecDeque<OffsetSample>,
/// 当前估计的时钟偏移 (server_time - client_time)
pub current_offset: i128,
/// RTT 样本窗口
rtts: VecDeque<i128>,
/// 当前估计的最小 RTT
min_rtt: i128,
/// 窗口大小
window_size: usize,
/// 时钟漂移率 (ppm: parts per million)
/// 正值表示从节点时钟比主节点快
drift_rate: f64,
/// 上次更新时间
last_update_time: u128,
/// 漂移率估计窗口
drift_samples: VecDeque<DriftSample>,
}
#[derive(Clone, Copy)]
struct OffsetSample {
offset: i128,
rtt: i128,
}
#[derive(Clone, Copy)]
struct DriftSample {
offset: i128,
timestamp: u128,
}
impl ClockSync {
pub fn new(window_size: usize) -> Self {
Self {
offsets: VecDeque::with_capacity(window_size),
current_offset: 0,
rtts: VecDeque::with_capacity(window_size),
min_rtt: i128::MAX,
window_size,
drift_rate: 0.0,
last_update_time: now_us(),
drift_samples: VecDeque::with_capacity(60), // 保留 60 秒的样本
}
}
/// 更新时钟偏移估计 (NTP 算法)
///
/// NTP 时间戳标记:
/// t1 = client_send_ts : 客户端发送 Ping 的时间
/// t2 = server_ts : 服务器接收 Ping 的时间
/// t3 = server_ts : 服务器发送 Pong 的时间 (假设处理时间忽略不计)
/// t4 = client_recv_ts : 客户端接收 Pong 的时间
///
/// RTT = (t4 - t1) - (t3 - t2) = (t4 - t1) (因为 t3 = t2)
/// Offset = ((t2 - t1) + (t3 - t4)) / 2 = ((t2 - t1) + (t2 - t4)) / 2
/// = t2 - (t1 + t4) / 2
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
let t1 = client_send_ts as i128;
let t2 = server_ts as i128;
let t4 = client_recv_ts as i128;
let rtt = t4 - t1;
// 过滤异常 RTT (局域网内 > 100ms 视为异常)
if rtt < 0 || rtt > 100_000 {
return;
}
// 计算时钟偏移: offset = server_time - client_time
// offset = t2 - (t1 + t4) / 2
let offset = t2 - (t1 + t4) / 2;
// 更新 RTT 窗口
self.rtts.push_back(rtt);
if self.rtts.len() > self.window_size {
self.rtts.pop_front();
}
self.min_rtt = *self.rtts.iter().min().unwrap_or(&rtt);
// 更新偏移量窗口
let sample = OffsetSample {
offset,
rtt,
};
self.offsets.push_back(sample);
if self.offsets.len() > self.window_size {
self.offsets.pop_front();
}
// 偏移量估计: 使用低 RTT 样本的中位数
// 原理: RTT 较小的样本受网络抖动影响小,时间测量更准确
let mut low_rtt_offsets: Vec<i128> = self
.offsets
.iter()
.filter(|s| s.rtt <= self.min_rtt + 5000) // 5ms 容差
.map(|s| s.offset)
.collect();
if !low_rtt_offsets.is_empty() {
low_rtt_offsets.sort_unstable();
let new_offset = low_rtt_offsets[low_rtt_offsets.len() / 2];
// 漂移率估计
self.estimate_drift(new_offset, client_recv_ts);
// 平滑更新偏移量 (避免突变)
let alpha = 0.3; // 低通滤波系数
self.current_offset =
(alpha * new_offset as f64 + (1.0 - alpha) * self.current_offset as f64) as i128;
}
self.last_update_time = client_recv_ts;
}
/// 估计时钟漂移率
/// 时钟漂移率 = d(offset) / dt
fn estimate_drift(&mut self, offset: i128, timestamp: u128) {
self.drift_samples
.push_back(DriftSample { offset, timestamp });
if self.drift_samples.len() > 60 {
self.drift_samples.pop_front();
}
// 至少需要 10 秒的数据才能估计漂移
if self.drift_samples.len() < 10 {
return;
}
// 使用线性回归估计漂移率
let first = self.drift_samples.front().unwrap();
let last = self.drift_samples.back().unwrap();
let dt = (last.timestamp - first.timestamp) as f64;
let d_offset = (last.offset - first.offset) as f64;
if dt > 10_000_000.0 {
// 超过 10 秒
// drift_rate 单位: 微秒/秒 = ppm
let new_drift = d_offset / (dt / 1_000_000.0);
// 平滑更新漂移率
let beta = 0.1;
self.drift_rate = beta * new_drift + (1.0 - beta) * self.drift_rate;
}
}
/// 将本地时间转换为服务器(主节点)时间
/// 考虑时钟漂移补偿
pub fn to_server_time(&self, client_time: u128) -> u128 {
let base_server_time = (client_time as i128 + self.current_offset) as u128;
// 漂移补偿: 根据距离上次同步的时间,补偿时钟漂移
let elapsed_since_update = client_time.saturating_sub(self.last_update_time) as f64;
let drift_correction = (self.drift_rate * elapsed_since_update / 1_000_000.0) as i128;
(base_server_time as i128 + drift_correction) as u128
}
/// 将服务器(主节点)时间转换为本地时间
pub fn to_client_time(&self, server_time: u128) -> u128 {
// 简化版本,不考虑漂移补偿 (播放时主要用 to_server_time)
(server_time as i128 - self.current_offset) as u128
}
/// 获取当前估计的 RTT (微秒)
pub fn get_rtt(&self) -> i128 {
self.min_rtt
}
/// 获取当前时钟漂移率 (ppm)
pub fn get_drift_rate(&self) -> f64 {
self.drift_rate
}
/// 获取同步质量评估 (0-100, 越高越好)
pub fn get_sync_quality(&self) -> u8 {
if self.offsets.is_empty() {
return 0;
}
// 基于 RTT 稳定性和偏移量方差评估
let rtt_variance = self.calculate_variance(&self.rtts.iter().copied().collect::<Vec<_>>());
let offset_variance =
self.calculate_variance(&self.offsets.iter().map(|s| s.offset).collect::<Vec<_>>());
// RTT 越稳定,方差越小,质量越高
let rtt_score = ((100_000.0 - rtt_variance.min(100_000.0)) / 100_000.0 * 50.0) as u8;
let offset_score = ((50_000.0 - offset_variance.min(50_000.0)) / 50_000.0 * 50.0) as u8;
rtt_score + offset_score
}
fn calculate_variance(&self, samples: &[i128]) -> f64 {
if samples.is_empty() {
return 0.0;
}
let mean = samples.iter().sum::<i128>() as f64 / samples.len() as f64;
let variance = samples
.iter()
.map(|&x| {
let diff = x as f64 - mean;
diff * diff
})
.sum::<f64>()
/ samples.len() as f64;
variance.sqrt()
}
}
+1
View File
@@ -0,0 +1 @@
pub mod shell;
+68
View File
@@ -0,0 +1,68 @@
use anyhow::{Context, Result, anyhow};
use core::str;
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
pub type ShellRequest = String;
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct ShellResponse {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub struct Shell;
impl Shell {
pub async fn run(command: String, timeout_ms: Option<u64>) -> Result<ShellResponse> {
// 1. 配置 Command
let mut child = Command::new("sh")
.arg("-c")
.arg(&command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// 当 Child 结构体被 Drop 时,自动杀死子进程(SIGKILL)
.kill_on_drop(true)
.spawn()
.context("Failed to spawn shell command")?;
let mut stdout_reader = child.stdout.take().context("Failed to open stdout")?;
let mut stderr_reader = child.stderr.take().context("Failed to open stderr")?;
// 2. 定义执行逻辑
let run_logic = async move {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
// 使用 join! 并发读取流并等待进程结束
let (res_out, res_err, res_status) = tokio::join!(
stdout_reader.read_to_end(&mut stdout_buf),
stderr_reader.read_to_end(&mut stderr_buf),
child.wait()
);
let status = res_status.context("Wait for child failed")?;
res_out.context("Read stdout failed")?;
res_err.context("Read stderr failed")?;
Ok(ShellResponse {
stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
exit_code: status.code().unwrap_or(-1),
})
};
// 3. 应用超时
if let Some(ms) = timeout_ms {
tokio::time::timeout(Duration::from_millis(ms), run_logic)
.await
.map_err(|_| anyhow!("Shell execution timed out"))?
} else {
run_logic.await
}
}
}
+4 -2
View File
@@ -44,14 +44,16 @@ chmod +x ./flash
# 执行命令后拔掉小爱音箱的电源线,重新插上电源,等待设备连接
./flash connect
# 第 2 步:设置启动延时(5 秒)
./flash delay 5
# 第 2 步:设置启动延时(15 秒)
./flash delay 15
# 第 3 步:切换启动分区
./flash switch boot0
# 第 4 步:刷写固件(注意替换固件文件的实际路径)
./flash system system0 root-patched.squashfs
# PS: 如果提示刷写错误,可以多试几次,不一定是真的无法刷机
```
> [!TIP]
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
RED='\033[0;31m'
+3
View File
@@ -0,0 +1,3 @@
squashfs-root
root.squashfs
squashfs

Some files were not shown because too many files have changed in this diff Show More