50 Commits
Author SHA1 Message Date
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
55 changed files with 2883 additions and 256 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)
+7 -3
View File
@@ -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"
+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
}
}
+361
View File
@@ -0,0 +1,361 @@
#![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 = 200_000;
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;
}
// 计算该帧应当播放的基准时间(相对于流开始)
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 {
// 情况 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;
+201
View File
@@ -0,0 +1,201 @@
#![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::jitter_buffer::JitterBuffer;
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 mut jitter = JitterBuffer::new(50_000, 3);
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_secs(1)).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;
});
let mut pcm_buf = vec![0i16; config.frame_size];
let mut last_seq: Option<u32> = None;
let mut last_packet_time = now_us();
loop {
// 检查 TCP 是否已断开
if let Ok(_) = disconnect_rx.try_recv() {
return Err(anyhow!("主节点已断开: {}", master_tcp_addr));
}
// 填充 Jitter Buffer
while let Ok(pkt) = audio_rx.try_recv() {
let now = now_us();
// 如果超过 500ms 没有收到包,认为是新流开始,重置状态
if now - last_packet_time > 500_000 {
jitter.clear();
last_seq = None;
codec = OpusCodec::new(&config)?;
let _ = player.prepare();
}
last_packet_time = now;
jitter.push(pkt);
}
let now = now_us();
let current_server_time = clock.lock().await.to_server_time(now);
if let Some((seq, data)) = jitter.pop_frame(current_server_time) {
if let Some(last) = last_seq {
let loss_count = seq.wrapping_sub(last) as i32 - 1;
if loss_count > 0 {
// 1. 优先尝试 FEC 恢复最近丢失的那一帧
// Opus 的 FEC 数据存储在当前包(data)中,用于恢复“前一帧”
if let Ok(len) = codec.decode_fec(&data, &mut pcm_buf) {
let _ = player.write(&pcm_buf[..len]);
}
// 2. 如果丢包超过 1 帧,剩下的帧只能靠丢包补偿(PLC)
for _ in 0..(loss_count - 1) {
if let Ok(len) = codec.decode_loss(&mut pcm_buf) {
let _ = player.write(&pcm_buf[..len]);
}
}
}
}
last_seq = Some(seq);
// 3. 正常解码当前帧
let len = codec.decode(&data, &mut pcm_buf)?;
player.write(&pcm_buf[..len])?;
} else {
tokio::time::sleep(Duration::from_millis(10)).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;
+70
View File
@@ -0,0 +1,70 @@
#![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)?;
// // 设置较大的缓冲区以减少由于调度抖动导致的断音
// // 48000Hz * 0.2s = 9600 samples
// let buffer_size = (sample_rate as f64 * 0.2) as u32;
// let period_size = buffer_size / 4;
// 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 为 buffer_size 的一半,确保缓冲区有足够数据再开始播放
// swp.set_start_threshold(buffer_size as alsa::pcm::Frames / 2)?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
#![cfg(target_os = "linux")]
use crate::audio::config::AudioConfig;
use alsa::Direction;
use alsa::pcm::{Access, Format, HwParams, PCM};
use anyhow::{Context, Result};
pub struct AudioRecorder {
pcm: PCM,
}
impl AudioRecorder {
pub fn new(config: &AudioConfig) -> Result<Self> {
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)
.context("Failed to open capture PCM device")?;
setup_pcm(&pcm, config.sample_rate, config.channels)?;
Ok(Self { pcm })
}
pub fn read(&self, buffer: &mut [i16]) -> Result<usize> {
self.pcm
.io_i16()?
.readi(buffer)
.context("Failed to read from capture device")
}
}
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
hwp.set_access(Access::RWInterleaved)?;
hwp.set_format(Format::s16())?;
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
hwp.set_channels(channels as u32)?;
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
let swp = pcm.sw_params_current()?;
pcm.sw_params(&swp)?;
pcm.prepare()?;
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
mod app;
mod audio;
mod net;
mod utils;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(target_os = "linux")]
{
crate::app::entry::run_stereo().await.unwrap();
}
println!("Only support Linux");
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use crate::net::protocol::ControlPacket;
use anyhow::Result;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
pub const DISCOVERY_PORT: u16 = 53530;
/// 服务发现模块,用于主从节点的自动发现
pub struct Discovery;
impl Discovery {
/// 主节点:启动广播,告知从节点自己的 TCP 端口
pub async fn start_broadcast(tcp_port: u16) -> Result<()> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
let msg = postcard::to_allocvec(&ControlPacket::ServerHello { udp_port: tcp_port })?;
tokio::spawn(async move {
loop {
let _ = socket.send_to(&msg, target).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
Ok(())
}
/// 从节点:监听广播,发现主节点的 IP 和 TCP 端口
pub async fn discover_master() -> Result<(IpAddr, u16)> {
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
let mut buf = [0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buf).await?;
if let Ok(ControlPacket::ServerHello { udp_port }) =
postcard::from_bytes::<ControlPacket>(&buf[..len])
{
return Ok((addr.ip(), udp_port));
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod discovery;
pub mod network;
pub mod protocol;
+126
View File
@@ -0,0 +1,126 @@
use crate::net::protocol::{AudioPacket, ControlPacket};
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
/// UDP 音频传输
pub struct AudioSocket {
socket: Arc<UdpSocket>,
}
impl AudioSocket {
pub async fn bind() -> Result<Self> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
Ok(Self {
socket: Arc::new(socket),
})
}
pub fn local_port(&self) -> Result<u16> {
Ok(self.socket.local_addr()?.port())
}
pub async fn send_packet(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.socket.send_to(&bytes, target).await?;
Ok(())
}
pub async fn recv_packet(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
let (len, addr) = self.socket.recv_from(buf).await?;
let packet = postcard::from_bytes(&buf[..len])?;
Ok((packet, addr))
}
pub async fn punch(&self, target: SocketAddr) -> Result<()> {
self.socket.send_to(&[0u8; 1], target).await?;
Ok(())
}
pub fn clone_inner(&self) -> Arc<UdpSocket> {
self.socket.clone()
}
}
/// TCP 控制连接
pub struct ControlConnection {
stream: TcpStream,
}
impl ControlConnection {
pub fn new(stream: TcpStream) -> Self {
Self { stream }
}
pub async fn send_packet(&mut self, packet: &ControlPacket) -> Result<()> {
let bytes = postcard::to_allocvec(packet)?;
self.stream.write_all(&bytes).await?;
Ok(())
}
pub async fn recv_packet(&mut self, buf: &mut [u8]) -> Result<ControlPacket> {
let len = self.stream.read(buf).await?;
if len == 0 {
return Err(anyhow::anyhow!("连接已关闭"));
}
let packet = postcard::from_bytes(&buf[..len])?;
Ok(packet)
}
pub fn split(
self,
) -> (
tokio::net::tcp::OwnedReadHalf,
tokio::net::tcp::OwnedWriteHalf,
) {
self.stream.into_split()
}
}
/// 主节点网络管理器
pub struct MasterNetwork {
listener: TcpListener,
audio: AudioSocket,
}
impl MasterNetwork {
pub async fn setup(port: u16) -> Result<Self> {
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
let audio = AudioSocket::bind().await?;
Ok(Self { listener, audio })
}
pub async fn accept(&self) -> Result<(ControlConnection, SocketAddr)> {
let (stream, addr) = self.listener.accept().await?;
Ok((ControlConnection::new(stream), addr))
}
pub fn audio_socket(&self) -> &AudioSocket {
&self.audio
}
}
/// 从节点网络管理器
pub struct SlaveNetwork {
control: ControlConnection,
audio: AudioSocket,
}
impl SlaveNetwork {
pub async fn connect(master_addr: SocketAddr) -> Result<Self> {
let stream = TcpStream::connect(master_addr)
.await
.context(format!("无法连接到主节点 TCP 地址: {}", master_addr))?;
let audio = AudioSocket::bind().await?;
Ok(Self {
control: ControlConnection::new(stream),
audio,
})
}
pub fn split(self) -> (ControlConnection, AudioSocket) {
(self.control, self.audio)
}
}
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)]
pub enum ChannelRole {
Left,
Right,
}
impl ChannelRole {
pub fn to_string(&self) -> String {
match self {
ChannelRole::Left => "左声道".to_string(),
ChannelRole::Right => "右声道".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ControlPacket {
// 发现协议
ServerHello {
udp_port: u16, // UDP 音频流端口
},
// 握手协议
ClientIdentify {
role: ChannelRole,
},
// 时间同步 (持续进行)
Ping {
client_ts: u128,
seq: u32,
},
Pong {
client_ts: u128,
server_ts: u128,
seq: u32,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AudioPacket {
pub seq: u32, // 序列号,用于丢包检测
pub timestamp: u128, // 目标播放时间 (主节点时间)
pub data: Vec<u8>, // Opus 编码数据
}
+85
View File
@@ -0,0 +1,85 @@
#![cfg(target_os = "linux")]
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
const FIFO_PATH: &str = "/tmp/stereo_out.fifo";
const REAL_ASOUND_CONF: &str = "/etc/asound.conf";
const TEMP_ASOUND_CONF: &str = "/tmp/asound.stereo.conf";
/// ALSA 音频重定向器,用于拦截系统音频输出到 FIFO 管道
pub struct AlsaRedirector;
impl AlsaRedirector {
pub fn new() -> Result<Self> {
Self::cleanup(); // 确保环境干净
let original_conf = fs::read_to_string(REAL_ASOUND_CONF).unwrap_or_default();
if !original_conf.contains("pcm.original_default") {
// 重命名原有的 default 逻辑,插入拦截器
let mut new_conf = original_conf.replace("pcm.!default", "pcm.original_default");
new_conf.push_str(&format!(
"\npcm.!default {{ type plug slave {{ pcm \"stereo_interceptor\" format S16_LE rate 48000 channels 2 }} }}\n\
pcm.stereo_interceptor {{ type file slave.pcm \"null\" file \"{}\" format \"raw\" }}\n",
FIFO_PATH
));
fs::write(TEMP_ASOUND_CONF, new_conf)?;
// 挂载覆盖 /etc/asound.conf
let status = Command::new("mount")
.arg("--bind")
.arg(TEMP_ASOUND_CONF)
.arg(REAL_ASOUND_CONF)
.status()
.context("执行 mount 命令失败")?;
if !status.success() {
return Err(anyhow::anyhow!("挂载 asound.conf 失败"));
}
Self::restart_applications();
}
// 创建 FIFO 管道
let _ = Command::new("mkfifo").arg(FIFO_PATH).status();
let _ = Command::new("chmod").arg("666").arg(FIFO_PATH).status();
Ok(Self)
}
pub fn cleanup() {
let _ = Command::new("sh")
.arg("-c")
.arg(format!("umount -l {} >/dev/null 2>&1", REAL_ASOUND_CONF))
.status();
let _ = fs::remove_file(TEMP_ASOUND_CONF);
let _ = fs::remove_file(FIFO_PATH);
Self::restart_applications();
}
pub fn fifo_path() -> &'static str {
FIFO_PATH
}
pub fn restart_applications() {
// 重启媒体播放器
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/mediaplayer restart >/dev/null 2>&1")
.status();
// 重启蓝牙
let _ = Command::new("sh")
.arg("-c")
.arg("/etc/init.d/bluetooth restart >/dev/null 2>&1")
.status();
}
}
impl Drop for AlsaRedirector {
fn drop(&mut self) {
Self::cleanup();
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::cmp::Ordering;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use crate::net::protocol::AudioPacket;
#[derive(Debug)]
struct OrderedPacket {
seq: u32,
timestamp: u128,
data: Vec<u8>,
}
// 仅针对序列号进行比较,处理回绕逻辑
impl PartialEq for OrderedPacket {
fn eq(&self, other: &Self) -> bool {
self.seq == other.seq
}
}
impl Eq for OrderedPacket {}
impl PartialOrd for OrderedPacket {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OrderedPacket {
fn cmp(&self, other: &Self) -> Ordering {
// 使用 wrapping_sub 处理 u32 回绕 (Rollover)
let diff = self.seq.wrapping_sub(other.seq) as i32;
diff.cmp(&0)
}
}
pub struct JitterBuffer {
// 使用 Reverse 将 BinaryHeap 变为小顶堆,避免手动实现大量 Trait
buffer: BinaryHeap<Reverse<OrderedPacket>>,
last_played_seq: Option<u32>,
pub target_delay_us: u128,
min_packets: usize, // 最小缓冲包数,防止微小抖动
}
impl JitterBuffer {
pub fn new(target_delay_us: u128, min_packets: usize) -> Self {
Self {
buffer: BinaryHeap::with_capacity(32), // 预分配初始容量
last_played_seq: None,
target_delay_us,
min_packets,
}
}
pub fn push(&mut self, packet: AudioPacket) {
// 1. 处理序列号回绕的丢包逻辑
if let Some(last) = self.last_played_seq {
let diff = packet.seq.wrapping_sub(last) as i32;
if diff <= 0 {
return; // 这是一个延迟到达的旧包,直接丢弃
}
}
self.buffer.push(Reverse(OrderedPacket {
seq: packet.seq,
timestamp: packet.timestamp,
data: packet.data,
}));
}
pub fn pop_frame(&mut self, current_time: u128) -> Option<(u32, Vec<u8>)> {
// 2. 预缓冲逻辑:如果包量太少,先不播放,等待填充
if self.buffer.len() < self.min_packets && self.last_played_seq.is_none() {
return None;
}
// 3. 检查堆顶元素
if let Some(Reverse(pkt)) = self.buffer.peek() {
// 判断是否到达播放时间(考虑目标延迟)
if current_time >= pkt.timestamp + (self.target_delay_us / 1000) {
let Reverse(pkt) = self.buffer.pop().unwrap();
self.last_played_seq = Some(pkt.seq);
return Some((pkt.seq, pkt.data));
}
}
None
}
/// 如果缓冲区堆积过大,可以主动跳帧以降低延迟
pub fn shrink_to_fit_latency(&mut self, max_size: usize) {
while self.buffer.len() > max_size {
self.buffer.pop();
}
}
pub fn clear(&mut self) {
self.buffer.clear();
self.last_played_seq = None;
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod alsa;
pub mod jitter_buffer;
pub mod sync;
+63
View File
@@ -0,0 +1,63 @@
use std::collections::VecDeque;
use std::time::{SystemTime, UNIX_EPOCH};
/// 获取当前微秒级时间戳
pub fn now_us() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("时间倒流")
.as_micros()
}
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
pub struct ClockSync {
offsets: VecDeque<i128>,
pub current_offset: i128,
window_size: usize,
}
impl ClockSync {
pub fn new(window_size: usize) -> Self {
Self {
offsets: VecDeque::with_capacity(window_size),
current_offset: 0,
window_size,
}
}
/// 更新时钟偏移估计
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
let rtt = (client_recv_ts - client_send_ts) as i128;
// 基础过滤:如果 RTT 过大则忽略 (例如局域网内 > 100ms)
if rtt > 100_000 {
return;
}
// 时钟偏移 = 主节点时间 - 从节点时间
// 假设主节点收到 Ping 的时间点在 (发送时间 + 接收时间) / 2
let estimated_server_time = server_ts as i128 + rtt / 2;
let offset = estimated_server_time - client_recv_ts as i128;
self.offsets.push_back(offset);
if self.offsets.len() > self.window_size {
self.offsets.pop_front();
}
// 计算中位数偏移,以增强抗干扰能力
let mut sorted: Vec<i128> = self.offsets.iter().cloned().collect();
sorted.sort_unstable();
if !sorted.is_empty() {
self.current_offset = sorted[sorted.len() / 2];
}
}
/// 将本地时间转换为服务器(主节点)时间
pub fn to_server_time(&self, client_time: u128) -> u128 {
(client_time as i128 + self.current_offset) as u128
}
/// 将服务器(主节点)时间转换为本地时间
pub fn to_client_time(&self, server_time: u128) -> u128 {
(server_time as i128 - self.current_offset) as u128
}
}
+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"
+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"
}
]
}
+2 -2
View File
@@ -14,7 +14,7 @@
你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件:
- [Xiaomi 智能音箱 Pro v1.56.31](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.31)
- [Xiaomi 智能音箱 Pro v1.58.1](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.1)
- [小爱音箱 Pro v1.88.221](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.221)
> [!TIP]
@@ -33,7 +33,7 @@
> [!CAUTION]
> 当前支持的最新固件版本为:
>
> - Xiaomi 智能音箱 Pro 👉 [v1.56.31](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.31)
> - Xiaomi 智能音箱 Pro 👉 [v1.58.1](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.1)
> - 小爱音箱 Pro 👉 [v1.88.221](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.221)
>
> 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。
+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 -1
View File
@@ -22,7 +22,7 @@ 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"
# 清理临时文件
+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"] }
+7 -3
View File
@@ -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"
+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
@@ -52,6 +52,8 @@ chmod +x ./flash
# 第 4 步:刷写固件(注意替换固件文件的实际路径)
./flash system system0 root-patched.squashfs
# PS: 如果提示刷写错误,可以多试几次,不一定是真的无法刷机
```
> [!TIP]
+3
View File
@@ -0,0 +1,3 @@
squashfs-root
root.squashfs
squashfs
+88
View File
@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.4
FROM ubuntu:20.04
# 1. 环境变量合并,减少层数
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/opt/toolchain/bin:/root/.cargo/bin:${PATH}" \
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_SYSROOT_DIR=/opt/sysroot \
PKG_CONFIG_LIBDIR=/opt/sysroot/usr/lib/pkgconfig:/opt/sysroot/lib/pkgconfig \
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc \
CC_armv7_unknown_linux_gnueabihf="arm-linux-gnueabihf-gcc --sysroot=/opt/sysroot"
# 2. 基础依赖与多架构源配置合并
# 使用 --mount=type=cache 优化 apt 操作
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/deb http/deb [arch=amd64] http/g' /etc/apt/sources.list && \
echo "deb [arch=armhf] http://mirrors.aliyun.com/ubuntu-ports/ focal main restricted universe multiverse" > /etc/apt/sources.list.d/armhf.list && \
echo "deb [arch=armhf] http://mirrors.aliyun.com/ubuntu-ports/ focal-updates main restricted universe multiverse" >> /etc/apt/sources.list.d/armhf.list && \
dpkg --add-architecture armhf && \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential qemu-user-static pkg-config libssl-dev xz-utils squashfs-tools \
crossbuild-essential-armhf libasound2-dev:armhf libopus-dev:armhf libssl-dev:armhf && \
# 删除 lists 文件以减小体积
rm -rf /var/lib/apt/lists/*
# 3. 安装 Linaro Toolchain & Rust
# 将下载和清理放在一层,减少体积
RUN mkdir -p /opt/toolchain && \
curl -fSL https://releases.linaro.org/components/toolchain/binaries/latest-7/arm-linux-gnueabihf/gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf.tar.xz | tar -xJ -C /opt/toolchain --strip-components=1 && \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && \
rustup target add armv7-unknown-linux-gnueabihf
WORKDIR /app
# 4. 脚本与 Rootfs 处理
# 使用 --mount=bind 减少一次 COPY 导致的镜像体积增加
COPY run.sh runtime.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/run.sh /usr/local/bin/runtime.sh
# 处理 root.squashfs:利用临时挂载或直接解压并删除
RUN --mount=type=bind,source=root.squashfs,target=/app/root.squashfs \
mkdir -p /opt/sysroot && unsquashfs -d /opt/sysroot -f /app/root.squashfs
# 5. 构建混合 Sysroot 逻辑优化
# 将所有文件操作合并到一个 RUN 块,减少 Metadata 冗余
RUN set -e; \
# 1. 自动为共享库创建 .so 软链接
find /opt/sysroot/lib /opt/sysroot/usr/lib -name "lib*.so.*" | while read -r src; do \
link_name=$(echo "$src" | sed 's/\.so\..*$/.so/'); \
if [ ! -e "$link_name" ]; then \
ln -s "$(basename "$src")" "$link_name"; \
fi; \
done; \
# 2. 拷贝 crt*.o 文件
find /opt/toolchain/arm-linux-gnueabihf/libc -name "*crt*.o" -exec cp {} /opt/sysroot/usr/lib/ \; ; \
# 3. 拷贝 libc_nonshared.a
cp /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libc_nonshared.a /opt/sysroot/usr/lib/; \
if [ -f /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libpthread_nonshared.a ]; then \
cp /opt/toolchain/arm-linux-gnueabihf/libc/usr/lib/libpthread_nonshared.a /opt/sysroot/usr/lib/; \
fi; \
# 4. 生成 Linker Scripts
printf "OUTPUT_FORMAT(elf32-littlearm)\nGROUP ( /lib/libc.so.6 /usr/lib/libc_nonshared.a AS_NEEDED ( /lib/ld-linux-armhf.so.3 ) )\n" > /opt/sysroot/usr/lib/libc.so; \
if [ -f /opt/sysroot/lib/libpthread.so.0 ]; then \
printf "OUTPUT_FORMAT(elf32-littlearm)\nGROUP ( /lib/libpthread.so.0 /usr/lib/libpthread_nonshared.a )\n" > /opt/sysroot/usr/lib/libpthread.so; \
fi; \
# 5. 注入头文件和 pkgconfig
cp -r /usr/include/alsa /opt/sysroot/usr/include/ 2>/dev/null || true; \
cp -r /usr/include/opus /opt/sysroot/usr/include/ 2>/dev/null || true; \
if [ -d /usr/include/arm-linux-gnueabihf ]; then \
cp -r /usr/include/arm-linux-gnueabihf/* /opt/sysroot/usr/include/ 2>/dev/null || true; \
fi; \
if [ -d /usr/lib/arm-linux-gnueabihf/pkgconfig ]; then \
cp -r /usr/lib/arm-linux-gnueabihf/pkgconfig/* /opt/sysroot/usr/lib/pkgconfig/ 2>/dev/null || true; \
fi
# 6. 设置 Rust 链接参数
ENV CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS="\
-C link-arg=--sysroot=/opt/sysroot \
-C link-arg=-Wl,-dynamic-linker,/lib/ld-linux-armhf.so.3 \
-C link-arg=-L/opt/sysroot/usr/lib \
-C link-arg=-L/opt/sysroot/lib \
-C link-arg=-Wl,-rpath-link,/opt/sysroot/usr/lib \
-C link-arg=-Wl,-rpath-link,/opt/sysroot/lib"
CMD ["/bin/bash"]
+37
View File
@@ -0,0 +1,37 @@
# Makefile for open-xiaoai-runtime
# 使用 host.docker.internal 访问宿主机的代理端口(加速构建)
# 注意:代理软件需要开启 "Allow LAN"(允许局域网连接)
PROXY := http://host.docker.internal:7890
IMAGE_NAME := idootop/open-xiaoai-runtime:oh2p
# 显示帮助信息
help:
@echo "Available targets:"
@echo " make build - 构建交叉编译环境镜像"
@echo " make run-x86 - 进入交叉编译环境(Ubuntu x86_64"
@echo " make run-armv7 - 进入小爱音箱模拟环境(OpenWrt ARMv7"
@echo " make help - 显示此帮助信息"
@echo ""
@echo "Variables:"
@echo " PROXY=$(PROXY)"
@echo " IMAGE_NAME=$(IMAGE_NAME)"
# 构建交叉编译环境镜像
build:
@echo "Building Docker image: $(IMAGE_NAME)..."
docker build \
--build-arg http_proxy=$(PROXY) \
--build-arg https_proxy=$(PROXY) \
-t $(IMAGE_NAME) .
# 进入交叉编译环境(Ubuntu x86_64
run-x86:
@echo "Entering cross-compilation environment (Ubuntu x86_64)..."
docker run --rm -it --privileged $(IMAGE_NAME)
# 进入小爱音箱模拟环境(OpenWrt ARMv7
run-armv7:
@echo "Entering XiaoAi speaker simulation environment (OpenWrt ARMv7)..."
docker run --rm -it --privileged $(IMAGE_NAME) run
+83
View File
@@ -0,0 +1,83 @@
# Open-XiaoAI Runtime
小爱音箱 ARMv7 交叉编译 + 模拟运行环境(基于 OH2P v1.58.1 固件)
## 概述
小爱音箱使用的 Linux 系统较旧,glibc 版本为 2.25,需要使用 `gcc-linaro-7.5.0-2019.12` 工具链才能编译出兼容的程序。
```bash
root@OH2P:~# uname -a
Linux OH2P 4.9.61 #1 SMP PREEMPT Fri Jun 27 06:11:47 2025 aarch64 GNU/Linux
root@OH2P:~# file /lib/libc-2.25.so
ELF 32-bit LSB shared object, ARM, EABI5 version 1 (GNU/Linux),
dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, stripped
```
## Rust 交叉编译示例
编译产物可直接在小爱音箱上运行。
```bash
cd /path/to/your-rust-project
# 使用预先构建好的 Docker 环境进行交叉编译
docker run --rm idootop/open-xiaoai-runtime:oh2p \
-v $(shell pwd):/app \
cargo build --target armv7-unknown-linux-gnueabihf --release
```
## 环境说明
**交叉编译环境**Ubuntu 20.04 x86_64
- Linaro GCC 7.5.0 (arm-linux-gnueabihf)
- Rust 稳定版 + armv7-unknown-linux-gnueabihf target
- 包含 ALSA、Opus、OpenSSL 等系统库(ARMv7 版本)
- 从小爱音箱固件提取的完整 Sysroot
**模拟运行环境**QEMU 用户态)
- ARMv7 架构(32-bit ARM
- OpenWrt 定制系统 + glibc 2.25
- 使用小爱音箱真实 rootfs
- 不支持音频录制/播放等操作,需在小爱音箱真机运行
## 自定义配置
### 更换固件
默认使用 OH2P v1.58.1 固件。如需使用其他型号和版本:
```bash
# 克隆代码
git clone https://github.com/idootop/open-xiaoai.git
# 进入当前项目根目录
cd packages/runtime
# 【重要】将新的 `root.squashfs` 放到当前目录
# 构建镜像
make build
```
PS:你可以修改 `Makefile` 中的代理地址加速镜像构建(代理软件需开启局域网访问)。
```makefile
PROXY := http://host.docker.internal:7890 # 对应 0.0.0.0:7890
```
### 其他 Makefile 命令
```bash
# 构建镜像
make build
# 进入交叉编译环境(Ubuntu x86_64
make run-x86
# 进入小爱音箱模拟环境(OpenWrt ARMv7
make run-armv7
```
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# 定义 sysroot 目录
SYSROOT_DIR="/opt/sysroot"
# 检查第一个参数。如果在 sysroot 下存在该文件,则自动补全路径
# 比如 `run /bin/sh` 会被转换为 `run /opt/sysroot/bin/sh`
if [ -n "$1" ] && [ -f "$SYSROOT_DIR$1" ]; then
CMD="$SYSROOT_DIR$1"
shift
set -- "$CMD" "$@"
fi
if [ $# -eq 0 ]; then
# 如果没有提供参数,则默认在 sysroot 中启动 shell
exec runtime
else
exec qemu-arm-static -L "$SYSROOT_DIR" "$@"
fi
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
SYSROOT="/opt/sysroot"
QEMU_BIN="/usr/bin/qemu-arm-static"
echo "正在准备 OpenWrt 模拟环境..."
# 1. 拷贝 QEMU 静态二进制文件
cp "$QEMU_BIN" "$SYSROOT/usr/bin/"
# 2. 挂载必要文件系统
mount_fs() {
for dir in proc sys dev dev/pts tmp; do
mkdir -p "$SYSROOT/$dir"
if ! mountpoint -q "$SYSROOT/$dir"; then
if [[ "$dir" == "proc" ]]; then
mount -t proc proc "$SYSROOT/$dir"
elif [[ "$dir" == "sys" ]]; then
mount -t sysfs sysfs "$SYSROOT/$dir"
else
mount --bind "/$dir" "$SYSROOT/$dir"
fi
fi
done
}
mount_fs
# 3. 执行进入命令
# 注意:OpenWrt 默认没有 bash,通常使用 /bin/sh (busybox)
echo "------------------------------------------------"
echo "成功进入 OpenWrt 环境。输入 'exit' 退出。"
echo "------------------------------------------------"
chroot "$SYSROOT" /usr/bin/$(basename $QEMU_BIN) /bin/sh