Compare commits
80
Commits
OH2P_1.56.31
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d187f5d5b | ||
|
|
3cf6771cc9 | ||
|
|
fb6c1fd9f8 | ||
|
|
487a5b8c14 | ||
|
|
0125a1478e | ||
|
|
c0bc4b8ae6 | ||
|
|
d88d63b15b | ||
|
|
cce3dd630a | ||
|
|
052ce06355 | ||
|
|
eeb442d5b8 | ||
|
|
34924fa06e | ||
|
|
c4f7fbec6d | ||
|
|
a2d06958e1 | ||
|
|
24b3fdddf8 | ||
|
|
6f55021651 | ||
|
|
4ed8d3dca9 | ||
|
|
33da56dead | ||
|
|
ef28121f5d | ||
|
|
4d459afd28 | ||
|
|
aa2a5d8474 | ||
|
|
b69bd5a8f9 | ||
|
|
f4340f068f | ||
|
|
b07e7500c1 | ||
|
|
2027a61877 | ||
|
|
3017473850 | ||
|
|
118c34eb5c | ||
|
|
ab872180fc | ||
|
|
7bdbb30bd2 | ||
|
|
7e80d6cd74 | ||
|
|
2ff214c873 | ||
|
|
cdb4fcff85 | ||
|
|
ba6eaf6674 | ||
|
|
41be071b60 | ||
|
|
84c332d742 | ||
|
|
09c625cf32 | ||
|
|
aabe882b88 | ||
|
|
316f61d0bb | ||
|
|
4bce9e8d23 | ||
|
|
871d01a158 | ||
|
|
1caef01836 | ||
|
|
f4b70088fe | ||
|
|
81ac343098 | ||
|
|
b277fb7234 | ||
|
|
9fe7ebd65d | ||
|
|
f7dd1586e8 | ||
|
|
28d714a2ef | ||
|
|
76260d412c | ||
|
|
05d2174873 | ||
|
|
fbe64f63dd | ||
|
|
518436d000 | ||
|
|
9b2f61b037 | ||
|
|
bd9565577f | ||
|
|
962647afea | ||
|
|
8e788f4b47 | ||
|
|
392d8bc21b | ||
|
|
06645dc563 | ||
|
|
61ecb07a99 | ||
|
|
3d9d852bda | ||
|
|
fba40393c2 | ||
|
|
c521e274e9 | ||
|
|
70a64e1eee | ||
|
|
77a19c41e8 | ||
|
|
b7292dbd12 | ||
|
|
0f75a10b9f | ||
|
|
2d6c17d02f | ||
|
|
871a04ef79 | ||
|
|
7b473ad86a | ||
|
|
61943b16ac | ||
|
|
d43a9588c9 | ||
|
|
5e70f73c1e | ||
|
|
31e997a192 | ||
|
|
39b2dfe220 | ||
|
|
066fddbb68 | ||
|
|
b5d00db7ca | ||
|
|
1405eca23f | ||
|
|
43e6739726 | ||
|
|
affd94d1cf | ||
|
|
53f8ab31ee | ||
|
|
c5edea969c | ||
|
|
e4dc39ef39 |
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
exec > /dev/null 2>&1
|
||||
|
||||
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
set -e
|
||||
# 等待能够正常访问 baidu.com
|
||||
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 3
|
||||
|
||||
sleep 5
|
||||
echo "✅ 网络连接成功"
|
||||
|
||||
MIN_SPACE_MB=32
|
||||
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.mp3
|
||||
*.wav
|
||||
Generated
+547
@@ -0,0 +1,547 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-polyfill"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
|
||||
dependencies = [
|
||||
"atomic-polyfill",
|
||||
"hash32",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"spin",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opus"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e"
|
||||
dependencies = [
|
||||
"audiopus_sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"heapless",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stereo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"anyhow",
|
||||
"opus",
|
||||
"postcard",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "stereo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
debug = false
|
||||
|
||||
[dependencies]
|
||||
opus = "0.3"
|
||||
anyhow = "1.0"
|
||||
tokio = { version = "1.48", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.0", features = ["alloc", "use-std"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
alsa = "0.11"
|
||||
@@ -0,0 +1,10 @@
|
||||
build:
|
||||
docker run --rm -v $(shell pwd):/app idootop/open-xiaoai-runtime:oh2p \
|
||||
cargo build --target armv7-unknown-linux-gnueabihf --release
|
||||
|
||||
# 部署到小爱音箱(调试自用)
|
||||
deploy:
|
||||
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
|
||||
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.153 "dd of=/data/stereo"
|
||||
dd if=target/armv7-unknown-linux-gnueabihf/release/stereo \
|
||||
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.235 "dd of=/data/stereo"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Open-XiaoAI Stereo
|
||||
|
||||
不同型号的小爱音箱组立体声。
|
||||
|
||||
## 功能
|
||||
|
||||
- 在小爱音箱上独立运行,无需服务端部署
|
||||
- 支持不同型号的小爱音箱局域网组立体声
|
||||
- 支持蓝牙播放和小爱音箱自带的音乐播放
|
||||
- 支持局域网内主从设备服务发现自动连接
|
||||
- 支持主从节点“热拔插”,自动切换声道
|
||||
- 支持前向纠错(FEC)和丢包补偿(PLC)
|
||||
|
||||
## 快速开始
|
||||
|
||||
> [!NOTE]
|
||||
> 以下操作需要先将小爱音箱刷机, 然后 SSH 连接到小爱音箱。👉 [教程](../../docs/flash.md)
|
||||
|
||||
首先,分别在两台设备上配置主从和声道。
|
||||
|
||||
```shell
|
||||
# 首先确保配置文件路径存在
|
||||
mkdir -p /data/open-xiaoai || true
|
||||
|
||||
# 如果你想让左边的小爱音箱作为主音源
|
||||
echo master left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
|
||||
echo slave right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
|
||||
|
||||
# 如果你想让右边的小爱音箱作为主音源
|
||||
# echo master right > /data/open-xiaoai/stereo.conf # 在右边的小爱音箱上执行
|
||||
# echo slave left > /data/open-xiaoai/stereo.conf # 在左边的小爱音箱上执行
|
||||
```
|
||||
|
||||
配置完成后,分别在两台设备上运行下面的启动脚本即可。
|
||||
|
||||
```shell
|
||||
curl -sSfL https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/init.sh | sh
|
||||
```
|
||||
|
||||
如果你想要开机自启动,运行以下命令重启小爱音箱即可(需要先按上面的步骤配置主从设备和声道)。
|
||||
|
||||
```shell
|
||||
# 下载 boot.sh 到 /data/init.sh 开机时自启动
|
||||
curl -L -o /data/init.sh https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/boot.sh
|
||||
|
||||
# 重启小爱音箱
|
||||
reboot
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不支持同步音量大小(不同型号设备的音量基准不同,请手动调整)
|
||||
- 不支持多路音频混音(同时播放多个音频听起来像是信号故障、静音卡顿)
|
||||
- 目前只支持小爱音箱 Pro(LX06)和小米智能音箱 Pro(OH2P)这两个机型(需要刷机)
|
||||
- 从设备会主动同步主设备的音源,反之不支持(建议禁用从设备的麦克风和操作,防止音频叠加)
|
||||
|
||||
## License
|
||||
|
||||
MIT License © 2024-PRESENT [Del Wang](https://del.wang)
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
|
||||
exec > /dev/null 2>&1
|
||||
|
||||
WORK_DIR="/data/open-xiaoai"
|
||||
APP_BINARY="$WORK_DIR/stereo"
|
||||
CONFIG_FILE="$WORK_DIR/stereo.conf"
|
||||
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
▄▖ ▖▖▘ ▄▖▄▖
|
||||
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||
▌ - Stereo(立体声)
|
||||
|
||||
v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
# 等待能够正常访问 baidu.com
|
||||
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "✅ 网络连接成功"
|
||||
|
||||
main() {
|
||||
# 1. 确保目录存在
|
||||
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
|
||||
|
||||
# 2. 检查并下载程序
|
||||
if [ ! -f "$APP_BINARY" ]; then
|
||||
echo "🚀 正在下载 Stereo 立体声程序..."
|
||||
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
|
||||
echo "❌ 下载失败,请检查网络连接。"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$APP_BINARY"
|
||||
echo "✅ 程序下载完毕"
|
||||
fi
|
||||
|
||||
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "$*" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 4. 读取配置
|
||||
local ARGS=""
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
ARGS=$(cat "$CONFIG_FILE")
|
||||
fi
|
||||
|
||||
# 5. 停止旧进程
|
||||
PID=$(pgrep -f "$APP_BINARY" || true)
|
||||
if [ -n "$PID" ]; then
|
||||
kill -9 $PID > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# 6. 启动程序
|
||||
echo "🔥 Stereo 立体声程序启动中..."
|
||||
"$APP_BINARY" $ARGS
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
WORK_DIR="/data/open-xiaoai"
|
||||
APP_BINARY="$WORK_DIR/stereo"
|
||||
CONFIG_FILE="$WORK_DIR/stereo.conf"
|
||||
DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-stereo/stereo"
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
▄▖ ▖▖▘ ▄▖▄▖
|
||||
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||
▌ - Stereo(立体声)
|
||||
|
||||
v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
main() {
|
||||
# 1. 确保目录存在
|
||||
[ -d "$WORK_DIR" ] || mkdir -p "$WORK_DIR"
|
||||
|
||||
# 2. 检查并下载程序
|
||||
if [ ! -f "$APP_BINARY" ]; then
|
||||
echo "🚀 正在下载 Stereo 立体声程序..."
|
||||
if ! curl -L -# -f -o "$APP_BINARY" "$DOWNLOAD_URL"; then
|
||||
echo "❌ 下载失败,请检查网络连接。"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$APP_BINARY"
|
||||
echo "✅ 程序下载完毕"
|
||||
fi
|
||||
|
||||
# 3. 如果运行脚本时带了参数,则覆盖更新配置文件
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "$*" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 4. 读取配置
|
||||
local ARGS=""
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
ARGS=$(cat "$CONFIG_FILE")
|
||||
fi
|
||||
|
||||
# 5. 停止旧进程
|
||||
PID=$(pgrep -f "$APP_BINARY" || true)
|
||||
if [ -n "$PID" ]; then
|
||||
kill -9 $PID > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# 6. 启动程序
|
||||
echo "🔥 Stereo 立体声程序启动中..."
|
||||
exec "$APP_BINARY" $ARGS
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,35 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::app::master::run_master;
|
||||
use crate::app::slave::run_slave;
|
||||
use crate::net::protocol::ChannelRole;
|
||||
use anyhow::Result;
|
||||
use std::env;
|
||||
|
||||
pub async fn run_stereo() -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("用法: {} [master|slave] [left|right]", args[0]);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mode = if args[1].to_lowercase() == "master" {
|
||||
"主节点"
|
||||
} else {
|
||||
"从节点"
|
||||
};
|
||||
|
||||
let role = if args[2].to_lowercase() == "left" {
|
||||
ChannelRole::Left
|
||||
} else {
|
||||
ChannelRole::Right
|
||||
};
|
||||
|
||||
println!("🚗 当前为: {} {}", mode, role.to_string());
|
||||
|
||||
if mode == "主节点" {
|
||||
run_master(role).await
|
||||
} else {
|
||||
run_slave(role).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::audio::codec::OpusCodec;
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::audio::player::AudioPlayer;
|
||||
use crate::net::discovery::Discovery;
|
||||
use crate::net::network::{ControlConnection, MasterNetwork};
|
||||
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
|
||||
use crate::utils::alsa::AlsaRedirector;
|
||||
use crate::utils::sync::now_us;
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub const SERVER_TCP_PORT: u16 = 53531;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SlaveSession {
|
||||
udp_addr: SocketAddr,
|
||||
role: ChannelRole,
|
||||
}
|
||||
|
||||
pub async fn run_master(master_role: ChannelRole) -> Result<()> {
|
||||
// 0. 设置 ALSA 重定向
|
||||
println!("🔥 启动中,请稍等...");
|
||||
let _alsa_guard = AlsaRedirector::new()?;
|
||||
|
||||
// 1. 设置网络 (UDP + TCP)
|
||||
let network = MasterNetwork::setup(SERVER_TCP_PORT).await?;
|
||||
let audio_socket = network.audio_socket().clone_inner();
|
||||
|
||||
// 2. 启动服务发现广播
|
||||
Discovery::start_broadcast(SERVER_TCP_PORT).await?;
|
||||
|
||||
println!("✅ 服务已启动,等待连接...");
|
||||
|
||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
let slaves = Arc::new(Mutex::new(Vec::<SlaveSession>::new()));
|
||||
|
||||
// 3. 启动连接监听任务
|
||||
let slaves_clone = slaves.clone();
|
||||
let audio_socket_clone = audio_socket.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match network.accept().await {
|
||||
Ok((control_conn, client_addr)) => {
|
||||
let slaves_for_session = slaves_clone.clone();
|
||||
let audio_socket_for_session = audio_socket_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_master_session(
|
||||
control_conn,
|
||||
audio_socket_for_session,
|
||||
slaves_for_session,
|
||||
client_addr.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("❌ 会话错误: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ Accept 错误: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 音频处理主循环
|
||||
let config = AudioConfig::music();
|
||||
let encode_config = AudioConfig {
|
||||
channels: 1,
|
||||
vbr: true,
|
||||
..AudioConfig::music()
|
||||
};
|
||||
let player = AudioPlayer::new(&AudioConfig {
|
||||
channels: 2,
|
||||
playback_device: "plug:original_default".into(),
|
||||
..config.clone()
|
||||
})?;
|
||||
|
||||
let mut raw_buf = vec![0u8; config.frame_size * 2 * 2];
|
||||
let mut pcm_out = vec![0i16; config.frame_size * 2];
|
||||
let mut left_pcm = vec![0i16; config.frame_size];
|
||||
let mut right_pcm = vec![0i16; config.frame_size];
|
||||
let mut opus_out = vec![0u8; 1500];
|
||||
let mut seq = 0u32;
|
||||
|
||||
// 播放延迟: 只需覆盖网络延迟 + 时钟偏移
|
||||
let delay_us = 100_000; // 100ms 基础延迟
|
||||
let frame_duration_us =
|
||||
(config.frame_size as f64 / config.sample_rate as f64 * 1_000_000.0) as u128;
|
||||
|
||||
let mut stream_start_ts = 0;
|
||||
let mut stream_start_seq = 0;
|
||||
|
||||
let shutdown_flag_clone = shutdown_flag.clone();
|
||||
let audio_loop = async move {
|
||||
loop {
|
||||
if shutdown_flag_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 打开 FIFO
|
||||
let mut fifo = match tokio::fs::File::open(AlsaRedirector::fifo_path()).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
if shutdown_flag_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 每个新流开始时,重置编码器状态以避免残留音频导致爆音
|
||||
let mut left_encoder = OpusCodec::new(&encode_config)?;
|
||||
let mut right_encoder = OpusCodec::new(&encode_config)?;
|
||||
|
||||
loop {
|
||||
if shutdown_flag_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 从 FIFO 读取
|
||||
if let Err(_) = fifo.read_exact(&mut raw_buf).await {
|
||||
break; // FIFO 关闭,重新打开
|
||||
}
|
||||
|
||||
let active_slaves = {
|
||||
let s = slaves.lock().await;
|
||||
if s.is_empty() { None } else { Some(s.clone()) }
|
||||
};
|
||||
|
||||
let now = now_us();
|
||||
if stream_start_ts == 0 {
|
||||
stream_start_ts = now;
|
||||
stream_start_seq = seq;
|
||||
}
|
||||
|
||||
// 计算该帧应当播放的目标时间
|
||||
// target_ts = 数据包发送时间 + 播放延迟
|
||||
let target_ts = stream_start_ts
|
||||
+ ((seq - stream_start_seq) as u128 * frame_duration_us)
|
||||
+ delay_us;
|
||||
|
||||
// 提取 PCM 数据
|
||||
for i in 0..config.frame_size {
|
||||
left_pcm[i] = i16::from_le_bytes([raw_buf[i * 4], raw_buf[i * 4 + 1]]);
|
||||
right_pcm[i] = i16::from_le_bytes([raw_buf[i * 4 + 2], raw_buf[i * 4 + 3]]);
|
||||
}
|
||||
|
||||
if let Some(slaves_list) = active_slaves {
|
||||
// 情况 1: 有从节点,进行网络传输,并本地构造静音声道回放
|
||||
|
||||
// 1. 检查各声道是否有从节点需要
|
||||
let needs_left = slaves_list.iter().any(|s| s.role == ChannelRole::Left);
|
||||
let needs_right = slaves_list.iter().any(|s| s.role == ChannelRole::Right);
|
||||
|
||||
// 2. 编码需要的声道
|
||||
let mut left_bytes = None;
|
||||
let mut right_bytes = None;
|
||||
|
||||
if needs_left {
|
||||
let len = left_encoder.encode(&left_pcm, &mut opus_out)?;
|
||||
let packet = AudioPacket {
|
||||
seq,
|
||||
timestamp: target_ts,
|
||||
data: opus_out[..len].to_vec(),
|
||||
};
|
||||
left_bytes = Some(postcard::to_allocvec(&packet)?);
|
||||
}
|
||||
|
||||
if needs_right {
|
||||
let len = right_encoder.encode(&right_pcm, &mut opus_out)?;
|
||||
let packet = AudioPacket {
|
||||
seq,
|
||||
timestamp: target_ts,
|
||||
data: opus_out[..len].to_vec(),
|
||||
};
|
||||
right_bytes = Some(postcard::to_allocvec(&packet)?);
|
||||
}
|
||||
|
||||
// 3. 发送给对应的从节点
|
||||
for slave in &slaves_list {
|
||||
let bytes = match slave.role {
|
||||
ChannelRole::Left => left_bytes.as_ref(),
|
||||
ChannelRole::Right => right_bytes.as_ref(),
|
||||
};
|
||||
if let Some(b) = bytes {
|
||||
let _ = audio_socket.send_to(b, slave.udp_addr).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 将非本节点的声道置为静音
|
||||
for i in 0..config.frame_size {
|
||||
match master_role {
|
||||
ChannelRole::Left => {
|
||||
pcm_out[i * 2] = left_pcm[i];
|
||||
pcm_out[i * 2 + 1] = 0;
|
||||
}
|
||||
ChannelRole::Right => {
|
||||
pcm_out[i * 2] = 0;
|
||||
pcm_out[i * 2 + 1] = right_pcm[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 等待播放
|
||||
let now = now_us();
|
||||
if now < target_ts {
|
||||
let wait = target_ts - now;
|
||||
if wait > 1000 {
|
||||
tokio::time::sleep(Duration::from_micros(wait as u64)).await;
|
||||
} else {
|
||||
// 小于 1ms,直接播放,让播放时机稍微早一点点
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 情况 2: 没有从节点,本地立体声播放
|
||||
for i in 0..config.frame_size {
|
||||
pcm_out[i * 2] = left_pcm[i];
|
||||
pcm_out[i * 2 + 1] = right_pcm[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 统一写入播放器 (始终是立体声)
|
||||
if let Err(_) = player.write(&pcm_out) {
|
||||
if shutdown_flag_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
seq += 1;
|
||||
}
|
||||
|
||||
// 重置流计时
|
||||
stream_start_ts = 0;
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
res = audio_loop => {
|
||||
if let Err(e) = res {
|
||||
eprintln!("❌ 音频循环错误: {:?}", e);
|
||||
}
|
||||
},
|
||||
_ = shutdown_signal() => {
|
||||
// 设置退出标志,通知音频循环停止
|
||||
shutdown_flag.store(true, Ordering::Relaxed);
|
||||
},
|
||||
}
|
||||
|
||||
// 显式清理
|
||||
println!("👋 正在退出...");
|
||||
AlsaRedirector::cleanup();
|
||||
|
||||
// 强制退出
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// 监听系统退出信号 (SIGINT, SIGTERM, SIGQUIT)
|
||||
async fn shutdown_signal() {
|
||||
let mut sigint = signal(SignalKind::interrupt()).expect("无法注册 SIGINT 处理器");
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("无法注册 SIGTERM 处理器");
|
||||
let mut sigquit = signal(SignalKind::quit()).expect("无法注册 SIGQUIT 处理器");
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {},
|
||||
_ = sigterm.recv() => {},
|
||||
_ = sigquit.recv() => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理主节点与从节点的会话
|
||||
async fn handle_master_session(
|
||||
mut control: ControlConnection,
|
||||
audio_socket: Arc<tokio::net::UdpSocket>,
|
||||
slaves: Arc<Mutex<Vec<SlaveSession>>>,
|
||||
client_tcp_addr: String,
|
||||
) -> Result<()> {
|
||||
let mut buf = [0u8; 1024];
|
||||
|
||||
// 握手
|
||||
let pkt = control.recv_packet(&mut buf).await?;
|
||||
let slave_role = match pkt {
|
||||
ControlPacket::ClientIdentify { role } => role,
|
||||
_ => return Err(anyhow!("无效的握手协议")),
|
||||
};
|
||||
|
||||
let stereo = ControlPacket::ServerHello {
|
||||
udp_port: audio_socket.local_addr()?.port(),
|
||||
};
|
||||
control.send_packet(&stereo).await?;
|
||||
|
||||
// 等待 UDP 打洞/确认
|
||||
let mut buf = [0u8; 128];
|
||||
let (_, client_udp_addr) = audio_socket.recv_from(&mut buf).await?;
|
||||
|
||||
println!(
|
||||
"✅ 从节点已连接: {} {}",
|
||||
client_tcp_addr,
|
||||
slave_role.to_string(),
|
||||
);
|
||||
|
||||
// 添加到从节点列表
|
||||
let session = SlaveSession {
|
||||
udp_addr: client_udp_addr,
|
||||
role: slave_role,
|
||||
};
|
||||
{
|
||||
let mut s = slaves.lock().await;
|
||||
s.push(session.clone());
|
||||
}
|
||||
|
||||
// 分离 TCP 读写,处理控制消息和心跳
|
||||
let (mut tcp_rx, mut tcp_tx) = control.split();
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
match tcp_rx.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => {
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
if let Ok(ControlPacket::Ping { client_ts, seq }) = postcard::from_bytes(&buf[..n])
|
||||
{
|
||||
let pong = ControlPacket::Pong {
|
||||
client_ts,
|
||||
server_ts: now_us(),
|
||||
seq,
|
||||
};
|
||||
if tcp_tx
|
||||
.write_all(&postcard::to_allocvec(&pong).unwrap())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"❌ 从节点已断开: {} {}",
|
||||
client_tcp_addr,
|
||||
slave_role.to_string(),
|
||||
);
|
||||
|
||||
// 从列表中移除
|
||||
{
|
||||
let mut s = slaves.lock().await;
|
||||
s.retain(|x| x.udp_addr != client_udp_addr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod entry;
|
||||
pub mod master;
|
||||
pub mod slave;
|
||||
@@ -0,0 +1,199 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::audio::codec::OpusCodec;
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::audio::player::AudioPlayer;
|
||||
use crate::net::discovery::Discovery;
|
||||
use crate::net::network::SlaveNetwork;
|
||||
use crate::net::protocol::{AudioPacket, ChannelRole, ControlPacket};
|
||||
use crate::utils::sync::{ClockSync, now_us};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
/// 运行从节点模式
|
||||
pub async fn run_slave(role: ChannelRole) -> Result<()> {
|
||||
loop {
|
||||
match handle_connection(role.clone()).await {
|
||||
Err(e) => {
|
||||
eprintln!("❌ {:?}", e);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(role: ChannelRole) -> Result<()> {
|
||||
// 1. 发现主节点
|
||||
println!("🔍 正在扫描主节点...");
|
||||
let (master_ip, master_tcp_port) = Discovery::discover_master().await?;
|
||||
let master_tcp_addr = format!("{}:{}", master_ip, master_tcp_port);
|
||||
|
||||
// 2. 建立 TCP 连接
|
||||
println!("🔥 发现主节点: {}", master_tcp_addr);
|
||||
let network = SlaveNetwork::connect(master_tcp_addr.parse()?).await?;
|
||||
let (mut control, audio) = network.split();
|
||||
|
||||
// 3. 身份认证
|
||||
control
|
||||
.send_packet(&ControlPacket::ClientIdentify { role: role.clone() })
|
||||
.await?;
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let pkt = control.recv_packet(&mut buf).await?;
|
||||
let server_udp_port = match pkt {
|
||||
ControlPacket::ServerHello { udp_port } => udp_port,
|
||||
_ => return Err(anyhow!("身份认证应答异常")),
|
||||
};
|
||||
|
||||
// 4. UDP 打洞
|
||||
audio
|
||||
.punch(format!("{}:{}", master_ip, server_udp_port).parse()?)
|
||||
.await?;
|
||||
|
||||
// 5. 初始化音频与同步组件
|
||||
let config = AudioConfig {
|
||||
channels: 1,
|
||||
..AudioConfig::music()
|
||||
};
|
||||
let player = AudioPlayer::new(&config)?;
|
||||
let mut codec = OpusCodec::new(&config)?;
|
||||
let clock = Arc::new(Mutex::new(ClockSync::new(100)));
|
||||
|
||||
// 用于通知主循环 TCP 已断开的消息通道
|
||||
let (disconnect_tx, mut disconnect_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// 6. 分离 TCP 读写
|
||||
let (mut tcp_rx, mut tcp_tx) = control.split();
|
||||
let clock_updater = clock.clone();
|
||||
let d_tx_ping = disconnect_tx.clone();
|
||||
let d_tx_pong = disconnect_tx.clone();
|
||||
|
||||
// 定时发送 Ping (心跳 & 时间同步)
|
||||
let _sync_handle = tokio::spawn(async move {
|
||||
let mut seq = 0;
|
||||
loop {
|
||||
let t1 = now_us();
|
||||
let msg = ControlPacket::Ping { client_ts: t1, seq };
|
||||
let data = postcard::to_allocvec(&msg).unwrap();
|
||||
if tcp_tx.write_all(&data).await.is_err() {
|
||||
let _ = d_tx_ping.send(()).await; // 通知主线程 TCP 失败
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
seq += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// 接收 Pong
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
match tcp_rx.read(&mut buf).await {
|
||||
Ok(n) if n > 0 => {
|
||||
if let Ok(ControlPacket::Pong {
|
||||
client_ts,
|
||||
server_ts,
|
||||
..
|
||||
}) = postcard::from_bytes(&buf[..n])
|
||||
{
|
||||
let t4 = now_us();
|
||||
clock_updater.lock().await.update(client_ts, server_ts, t4);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = d_tx_pong.send(()).await; // TCP 断开
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 7. 接收音频数据包 (UDP)
|
||||
let (audio_tx, mut audio_rx) = mpsc::channel(100);
|
||||
let audio_socket = audio.clone_inner();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
if let Ok((len, _)) = audio_socket.recv_from(&mut buf).await {
|
||||
if let Ok(packet) = postcard::from_bytes::<AudioPacket>(&buf[..len]) {
|
||||
if audio_tx.send(packet).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 8. 播放提示
|
||||
println!("✅ 主节点已连接,音频串流中...");
|
||||
let role_str = role.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"/usr/sbin/tts_play.sh \"主节点已连接,{}\" >/dev/null 2>&1",
|
||||
role_str
|
||||
))
|
||||
.status()
|
||||
.await;
|
||||
});
|
||||
|
||||
// 9. 播放主循环
|
||||
let mut pcm_buf = vec![0i16; config.frame_size];
|
||||
let mut last_seq: Option<u32> = None;
|
||||
|
||||
loop {
|
||||
// 检查 TCP 是否已断开
|
||||
if let Ok(_) = disconnect_rx.try_recv() {
|
||||
return Err(anyhow!("主节点已断开: {}", master_tcp_addr));
|
||||
}
|
||||
|
||||
// 接收数据包
|
||||
if let Ok(pkt) = audio_rx.try_recv() {
|
||||
let now = now_us();
|
||||
let current_server_time = clock.lock().await.to_server_time(now);
|
||||
|
||||
// 检查包是否迟到(目标时间已过)
|
||||
if current_server_time > pkt.timestamp {
|
||||
let late_ms = (current_server_time - pkt.timestamp) / 1000;
|
||||
if late_ms > 50 {
|
||||
// 迟到超过 50ms,直接丢弃
|
||||
continue;
|
||||
}
|
||||
// 轻微迟到(<50ms),尝试播放
|
||||
}
|
||||
|
||||
last_seq = Some(pkt.seq);
|
||||
|
||||
// 精确等待到播放时间
|
||||
loop {
|
||||
let now = now_us();
|
||||
let current_server_time_precise = clock.lock().await.to_server_time(now);
|
||||
|
||||
if current_server_time_precise >= pkt.timestamp {
|
||||
break;
|
||||
}
|
||||
|
||||
let wait_us = (pkt.timestamp - current_server_time_precise) as u64;
|
||||
|
||||
if wait_us > 1000 {
|
||||
tokio::time::sleep(Duration::from_micros(wait_us as u64)).await;
|
||||
} else {
|
||||
// 小于 1ms,直接播放,让播放时机稍微早一点点
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码并播放
|
||||
if let Ok(len) = codec.decode(&pkt.data, &mut pcm_buf) {
|
||||
let _ = player.write(&pcm_buf[..len]);
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep(Duration::from_micros(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use crate::audio::config::{AudioConfig, AudioScene};
|
||||
use anyhow::{Context, Result};
|
||||
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
|
||||
|
||||
pub struct OpusCodec {
|
||||
encoder: Encoder,
|
||||
decoder: Decoder,
|
||||
}
|
||||
|
||||
impl OpusCodec {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
let channels = match config.channels {
|
||||
1 => Channels::Mono,
|
||||
2 => Channels::Stereo,
|
||||
_ => return Err(anyhow::anyhow!("Invalid channels: {}", config.channels)),
|
||||
};
|
||||
let mode = match config.audio_scene {
|
||||
AudioScene::Music => Application::Audio,
|
||||
AudioScene::Voice => Application::Voip,
|
||||
};
|
||||
let bitrate = match config.bitrate {
|
||||
-1 => Bitrate::Max,
|
||||
0 => Bitrate::Auto,
|
||||
_ => Bitrate::Bits(config.bitrate),
|
||||
};
|
||||
|
||||
let mut encoder = Encoder::new(config.sample_rate, channels, mode)
|
||||
.context("Failed to create Opus encoder")?;
|
||||
|
||||
encoder.set_bitrate(bitrate)?;
|
||||
if config.vbr {
|
||||
encoder.set_vbr(true)?;
|
||||
}
|
||||
if config.fec {
|
||||
encoder.set_inband_fec(true)?; // 内联前向纠错
|
||||
encoder.set_packet_loss_perc(20)?; // 预期丢包率20%
|
||||
}
|
||||
|
||||
let decoder =
|
||||
Decoder::new(config.sample_rate, channels).context("Failed to create Opus decoder")?;
|
||||
|
||||
Ok(Self { encoder, decoder })
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize> {
|
||||
self.encoder
|
||||
.encode(pcm, out)
|
||||
.context("Opus encoding failed")
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
|
||||
self.decoder
|
||||
.decode(opus, out, false)
|
||||
.context("Opus decoding failed")
|
||||
}
|
||||
|
||||
/// 前向纠错(FEC)
|
||||
pub fn decode_fec(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
|
||||
self.decoder
|
||||
.decode(opus, out, true)
|
||||
.context("Opus FEC decoding failed")
|
||||
}
|
||||
|
||||
/// 丢包补偿(PLC)
|
||||
pub fn decode_loss(&mut self, out: &mut [i16]) -> Result<usize> {
|
||||
self.decoder
|
||||
.decode(&[], out, false)
|
||||
.context("Opus PLC (decode_loss) failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum AudioScene {
|
||||
Music,
|
||||
Voice,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioConfig {
|
||||
// ALSA 设备参数,用于录音和播放
|
||||
pub capture_device: String,
|
||||
pub playback_device: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub frame_size: usize, // 帧大小,单位为采样点
|
||||
|
||||
// Opus 编解码参数,用于音频传输
|
||||
pub audio_scene: AudioScene,
|
||||
pub bitrate: i32,
|
||||
pub vbr: bool, // 是否启用 VBR(动态比特率)
|
||||
pub fec: bool, // 是否启用 FEC(内联前向纠错)
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
pub fn music() -> Self {
|
||||
Self {
|
||||
audio_scene: AudioScene::Music,
|
||||
sample_rate: 48_000, // 48kHz
|
||||
channels: 2,
|
||||
frame_size: 960, // 20ms at 48kHz
|
||||
bitrate: 320_000, // 320 kbps
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice() -> Self {
|
||||
Self {
|
||||
audio_scene: AudioScene::Voice,
|
||||
sample_rate: 16_000, // 16kHz
|
||||
channels: 1,
|
||||
frame_size: 320, // 20ms at 16kHz
|
||||
bitrate: 32_000, // 32 kbps
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio_scene: AudioScene::Voice,
|
||||
capture_device: "plug:Capture".to_string(),
|
||||
playback_device: "default".to_string(),
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
frame_size: 320, // 20ms at 16kHz
|
||||
bitrate: 32_000,
|
||||
vbr: false,
|
||||
fec: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
pub mod player;
|
||||
pub mod recorder;
|
||||
@@ -0,0 +1,71 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::audio::config::AudioConfig;
|
||||
use alsa::Direction;
|
||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub struct AudioPlayer {
|
||||
pcm: PCM,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
let pcm = PCM::new(&config.playback_device, Direction::Playback, false)
|
||||
.context("Failed to open playback PCM device")?;
|
||||
|
||||
setup_pcm(&pcm, config.sample_rate, config.channels)?;
|
||||
Ok(Self { pcm })
|
||||
}
|
||||
|
||||
pub fn write(&self, buffer: &[i16]) -> Result<usize> {
|
||||
let res = self.pcm.io_i16()?.writei(buffer);
|
||||
|
||||
match res {
|
||||
Ok(written) => Ok(written),
|
||||
Err(e) => {
|
||||
// Buffer Underrun,即播放缓冲区的数据被耗尽,导致音频流中断
|
||||
if e.errno() == 32 {
|
||||
// 恢复音频流状态
|
||||
self.pcm.prepare()?;
|
||||
// 重新获取 IO 对象并尝试写入数据
|
||||
self.pcm
|
||||
.io_i16()?
|
||||
.writei(buffer)
|
||||
.context("Failed to write to playback device after recovery")
|
||||
} else {
|
||||
Err(e).context("Failed to write to playback device")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(&self) -> Result<()> {
|
||||
self.pcm.prepare().context("Failed to prepare PCM")
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
|
||||
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
|
||||
hwp.set_access(Access::RWInterleaved)?;
|
||||
hwp.set_format(Format::s16())?;
|
||||
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
|
||||
hwp.set_channels(channels as u32)?;
|
||||
|
||||
// 设置较大的缓冲区以减少由于调度抖动和设备重初始化导致的断音/卡顿
|
||||
// 使用 100ms 缓冲区,既能防止 underrun,又不会引入过大延迟
|
||||
let buffer_size = (sample_rate as f64 * 0.1) as u32; // 100ms 缓冲
|
||||
let period_size = buffer_size / 4; // 25ms 周期
|
||||
hwp.set_buffer_size_near(buffer_size as alsa::pcm::Frames)?;
|
||||
hwp.set_period_size_near(period_size as alsa::pcm::Frames, alsa::ValueOr::Nearest)?;
|
||||
|
||||
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
|
||||
|
||||
let swp = pcm.sw_params_current()?;
|
||||
// 设置 start_threshold,当缓冲区有 1 个 period 数据时就开始播放
|
||||
// 这样可以快速启动,同时保持足够的缓冲余量
|
||||
swp.set_start_threshold(period_size as alsa::pcm::Frames)?;
|
||||
pcm.sw_params(&swp)?;
|
||||
pcm.prepare()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use crate::audio::config::AudioConfig;
|
||||
use alsa::Direction;
|
||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub struct AudioRecorder {
|
||||
pcm: PCM,
|
||||
}
|
||||
|
||||
impl AudioRecorder {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)
|
||||
.context("Failed to open capture PCM device")?;
|
||||
|
||||
setup_pcm(&pcm, config.sample_rate, config.channels)?;
|
||||
Ok(Self { pcm })
|
||||
}
|
||||
|
||||
pub fn read(&self, buffer: &mut [i16]) -> Result<usize> {
|
||||
self.pcm
|
||||
.io_i16()?
|
||||
.readi(buffer)
|
||||
.context("Failed to read from capture device")
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_pcm(pcm: &PCM, sample_rate: u32, channels: u16) -> Result<()> {
|
||||
let hwp = HwParams::any(pcm).context("Failed to get HwParams")?;
|
||||
hwp.set_access(Access::RWInterleaved)?;
|
||||
hwp.set_format(Format::s16())?;
|
||||
hwp.set_rate(sample_rate, alsa::ValueOr::Nearest)?;
|
||||
hwp.set_channels(channels as u32)?;
|
||||
pcm.hw_params(&hwp).context("Failed to set HwParams")?;
|
||||
|
||||
let swp = pcm.sw_params_current()?;
|
||||
pcm.sw_params(&swp)?;
|
||||
pcm.prepare()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
mod app;
|
||||
mod audio;
|
||||
mod net;
|
||||
mod utils;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::app::entry::run_stereo().await.unwrap();
|
||||
}
|
||||
println!("Only support Linux");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::net::protocol::ControlPacket;
|
||||
use anyhow::Result;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
pub const DISCOVERY_PORT: u16 = 53530;
|
||||
const DISCOVERY_MAGIC: &[u8] = b"STEREO_DISCOVERY_V1";
|
||||
|
||||
/// 服务发现模块,用于主从节点的自动发现
|
||||
pub struct Discovery;
|
||||
|
||||
impl Discovery {
|
||||
/// 主节点:启动广播,告知从节点自己的 TCP 端口
|
||||
pub async fn start_broadcast(tcp_port: u16) -> Result<()> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.set_broadcast(true)?;
|
||||
|
||||
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
|
||||
|
||||
let mut msg = DISCOVERY_MAGIC.to_vec();
|
||||
msg.extend(postcard::to_allocvec(&ControlPacket::ServerHello { udp_port: tcp_port })?);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let _ = socket.send_to(&msg, target).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从节点:监听广播,发现主节点的 IP 和 TCP 端口
|
||||
pub async fn discover_master() -> Result<(IpAddr, u16)> {
|
||||
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
|
||||
let mut buf = [0u8; 1024];
|
||||
|
||||
loop {
|
||||
let (len, addr) = socket.recv_from(&mut buf).await?;
|
||||
let data = &buf[..len];
|
||||
if data.starts_with(DISCOVERY_MAGIC) {
|
||||
let packet_data = &data[DISCOVERY_MAGIC.len()..];
|
||||
if let Ok(ControlPacket::ServerHello { udp_port }) = postcard::from_bytes(packet_data) {
|
||||
return Ok((addr.ip(), udp_port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod discovery;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
@@ -0,0 +1,126 @@
|
||||
use crate::net::protocol::{AudioPacket, ControlPacket};
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||
|
||||
/// UDP 音频传输
|
||||
pub struct AudioSocket {
|
||||
socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
impl AudioSocket {
|
||||
pub async fn bind() -> Result<Self> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
Ok(Self {
|
||||
socket: Arc::new(socket),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_port(&self) -> Result<u16> {
|
||||
Ok(self.socket.local_addr()?.port())
|
||||
}
|
||||
|
||||
pub async fn send_packet(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(packet)?;
|
||||
self.socket.send_to(&bytes, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv_packet(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
|
||||
let (len, addr) = self.socket.recv_from(buf).await?;
|
||||
let packet = postcard::from_bytes(&buf[..len])?;
|
||||
Ok((packet, addr))
|
||||
}
|
||||
|
||||
pub async fn punch(&self, target: SocketAddr) -> Result<()> {
|
||||
self.socket.send_to(&[0u8; 1], target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clone_inner(&self) -> Arc<UdpSocket> {
|
||||
self.socket.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// TCP 控制连接
|
||||
pub struct ControlConnection {
|
||||
stream: TcpStream,
|
||||
}
|
||||
|
||||
impl ControlConnection {
|
||||
pub fn new(stream: TcpStream) -> Self {
|
||||
Self { stream }
|
||||
}
|
||||
|
||||
pub async fn send_packet(&mut self, packet: &ControlPacket) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(packet)?;
|
||||
self.stream.write_all(&bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv_packet(&mut self, buf: &mut [u8]) -> Result<ControlPacket> {
|
||||
let len = self.stream.read(buf).await?;
|
||||
if len == 0 {
|
||||
return Err(anyhow::anyhow!("连接已关闭"));
|
||||
}
|
||||
let packet = postcard::from_bytes(&buf[..len])?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
pub fn split(
|
||||
self,
|
||||
) -> (
|
||||
tokio::net::tcp::OwnedReadHalf,
|
||||
tokio::net::tcp::OwnedWriteHalf,
|
||||
) {
|
||||
self.stream.into_split()
|
||||
}
|
||||
}
|
||||
|
||||
/// 主节点网络管理器
|
||||
pub struct MasterNetwork {
|
||||
listener: TcpListener,
|
||||
audio: AudioSocket,
|
||||
}
|
||||
|
||||
impl MasterNetwork {
|
||||
pub async fn setup(port: u16) -> Result<Self> {
|
||||
let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
|
||||
let audio = AudioSocket::bind().await?;
|
||||
Ok(Self { listener, audio })
|
||||
}
|
||||
|
||||
pub async fn accept(&self) -> Result<(ControlConnection, SocketAddr)> {
|
||||
let (stream, addr) = self.listener.accept().await?;
|
||||
Ok((ControlConnection::new(stream), addr))
|
||||
}
|
||||
|
||||
pub fn audio_socket(&self) -> &AudioSocket {
|
||||
&self.audio
|
||||
}
|
||||
}
|
||||
|
||||
/// 从节点网络管理器
|
||||
pub struct SlaveNetwork {
|
||||
control: ControlConnection,
|
||||
audio: AudioSocket,
|
||||
}
|
||||
|
||||
impl SlaveNetwork {
|
||||
pub async fn connect(master_addr: SocketAddr) -> Result<Self> {
|
||||
let stream = TcpStream::connect(master_addr)
|
||||
.await
|
||||
.context(format!("无法连接到主节点 TCP 地址: {}", master_addr))?;
|
||||
let audio = AudioSocket::bind().await?;
|
||||
Ok(Self {
|
||||
control: ControlConnection::new(stream),
|
||||
audio,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn split(self) -> (ControlConnection, AudioSocket) {
|
||||
(self.control, self.audio)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)]
|
||||
pub enum ChannelRole {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl ChannelRole {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
ChannelRole::Left => "左声道".to_string(),
|
||||
ChannelRole::Right => "右声道".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum ControlPacket {
|
||||
// 发现协议
|
||||
ServerHello {
|
||||
udp_port: u16, // UDP 音频流端口
|
||||
},
|
||||
// 握手协议
|
||||
ClientIdentify {
|
||||
role: ChannelRole,
|
||||
},
|
||||
// 时间同步 (持续进行)
|
||||
Ping {
|
||||
client_ts: u128,
|
||||
seq: u32,
|
||||
},
|
||||
Pong {
|
||||
client_ts: u128,
|
||||
server_ts: u128,
|
||||
seq: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AudioPacket {
|
||||
pub seq: u32, // 序列号,用于丢包检测
|
||||
pub timestamp: u128, // 目标播放时间 (主节点时间)
|
||||
pub data: Vec<u8>, // Opus 编码数据
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
const FIFO_PATH: &str = "/tmp/stereo_out.fifo";
|
||||
const REAL_ASOUND_CONF: &str = "/etc/asound.conf";
|
||||
const TEMP_ASOUND_CONF: &str = "/tmp/asound.stereo.conf";
|
||||
|
||||
/// ALSA 音频重定向器,用于拦截系统音频输出到 FIFO 管道
|
||||
pub struct AlsaRedirector;
|
||||
|
||||
impl AlsaRedirector {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::cleanup(); // 确保环境干净
|
||||
|
||||
let original_conf = fs::read_to_string(REAL_ASOUND_CONF).unwrap_or_default();
|
||||
|
||||
if !original_conf.contains("pcm.original_default") {
|
||||
// 重命名原有的 default 逻辑,插入拦截器
|
||||
let mut new_conf = original_conf.replace("pcm.!default", "pcm.original_default");
|
||||
new_conf.push_str(&format!(
|
||||
"\npcm.!default {{ type plug slave {{ pcm \"stereo_interceptor\" format S16_LE rate 48000 channels 2 }} }}\n\
|
||||
pcm.stereo_interceptor {{ type file slave.pcm \"null\" file \"{}\" format \"raw\" }}\n",
|
||||
FIFO_PATH
|
||||
));
|
||||
|
||||
fs::write(TEMP_ASOUND_CONF, new_conf)?;
|
||||
|
||||
// 挂载覆盖 /etc/asound.conf
|
||||
let status = Command::new("mount")
|
||||
.arg("--bind")
|
||||
.arg(TEMP_ASOUND_CONF)
|
||||
.arg(REAL_ASOUND_CONF)
|
||||
.status()
|
||||
.context("执行 mount 命令失败")?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow::anyhow!("挂载 asound.conf 失败"));
|
||||
}
|
||||
|
||||
Self::restart_applications();
|
||||
}
|
||||
|
||||
// 创建 FIFO 管道
|
||||
let _ = Command::new("mkfifo").arg(FIFO_PATH).status();
|
||||
let _ = Command::new("chmod").arg("666").arg(FIFO_PATH).status();
|
||||
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn cleanup() {
|
||||
let _ = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("umount -l {} >/dev/null 2>&1", REAL_ASOUND_CONF))
|
||||
.status();
|
||||
let _ = fs::remove_file(TEMP_ASOUND_CONF);
|
||||
let _ = fs::remove_file(FIFO_PATH);
|
||||
Self::restart_applications();
|
||||
}
|
||||
|
||||
pub fn fifo_path() -> &'static str {
|
||||
FIFO_PATH
|
||||
}
|
||||
|
||||
pub fn restart_applications() {
|
||||
// 重启媒体播放器
|
||||
let _ = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("/etc/init.d/mediaplayer restart >/dev/null 2>&1")
|
||||
.status();
|
||||
// 重启蓝牙
|
||||
let _ = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("/etc/init.d/bluetooth restart >/dev/null 2>&1")
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AlsaRedirector {
|
||||
fn drop(&mut self) {
|
||||
Self::cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod alsa;
|
||||
pub mod sync;
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 获取当前微秒级时间戳
|
||||
pub fn now_us() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("时间倒流")
|
||||
.as_micros()
|
||||
}
|
||||
|
||||
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
|
||||
/// 采用改进的 NTP 算法 + Kalman 滤波思想
|
||||
pub struct ClockSync {
|
||||
/// 偏移量样本窗口
|
||||
offsets: VecDeque<OffsetSample>,
|
||||
/// 当前估计的时钟偏移 (server_time - client_time)
|
||||
pub current_offset: i128,
|
||||
/// RTT 样本窗口
|
||||
rtts: VecDeque<i128>,
|
||||
/// 当前估计的最小 RTT
|
||||
min_rtt: i128,
|
||||
/// 窗口大小
|
||||
window_size: usize,
|
||||
/// 时钟漂移率 (ppm: parts per million)
|
||||
/// 正值表示从节点时钟比主节点快
|
||||
drift_rate: f64,
|
||||
/// 上次更新时间
|
||||
last_update_time: u128,
|
||||
/// 漂移率估计窗口
|
||||
drift_samples: VecDeque<DriftSample>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OffsetSample {
|
||||
offset: i128,
|
||||
rtt: i128,
|
||||
timestamp: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DriftSample {
|
||||
offset: i128,
|
||||
timestamp: u128,
|
||||
}
|
||||
|
||||
impl ClockSync {
|
||||
pub fn new(window_size: usize) -> Self {
|
||||
Self {
|
||||
offsets: VecDeque::with_capacity(window_size),
|
||||
current_offset: 0,
|
||||
rtts: VecDeque::with_capacity(window_size),
|
||||
min_rtt: i128::MAX,
|
||||
window_size,
|
||||
drift_rate: 0.0,
|
||||
last_update_time: now_us(),
|
||||
drift_samples: VecDeque::with_capacity(60), // 保留 60 秒的样本
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新时钟偏移估计 (NTP 算法)
|
||||
///
|
||||
/// NTP 时间戳标记:
|
||||
/// t1 = client_send_ts : 客户端发送 Ping 的时间
|
||||
/// t2 = server_ts : 服务器接收 Ping 的时间
|
||||
/// t3 = server_ts : 服务器发送 Pong 的时间 (假设处理时间忽略不计)
|
||||
/// t4 = client_recv_ts : 客户端接收 Pong 的时间
|
||||
///
|
||||
/// RTT = (t4 - t1) - (t3 - t2) = (t4 - t1) (因为 t3 = t2)
|
||||
/// Offset = ((t2 - t1) + (t3 - t4)) / 2 = ((t2 - t1) + (t2 - t4)) / 2
|
||||
/// = t2 - (t1 + t4) / 2
|
||||
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
|
||||
let t1 = client_send_ts as i128;
|
||||
let t2 = server_ts as i128;
|
||||
let t4 = client_recv_ts as i128;
|
||||
|
||||
let rtt = t4 - t1;
|
||||
|
||||
// 过滤异常 RTT (局域网内 > 100ms 视为异常)
|
||||
if rtt < 0 || rtt > 100_000 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算时钟偏移: offset = server_time - client_time
|
||||
// offset = t2 - (t1 + t4) / 2
|
||||
let offset = t2 - (t1 + t4) / 2;
|
||||
|
||||
// 更新 RTT 窗口
|
||||
self.rtts.push_back(rtt);
|
||||
if self.rtts.len() > self.window_size {
|
||||
self.rtts.pop_front();
|
||||
}
|
||||
self.min_rtt = *self.rtts.iter().min().unwrap_or(&rtt);
|
||||
|
||||
// 更新偏移量窗口
|
||||
let sample = OffsetSample {
|
||||
offset,
|
||||
rtt,
|
||||
timestamp: client_recv_ts,
|
||||
};
|
||||
self.offsets.push_back(sample);
|
||||
if self.offsets.len() > self.window_size {
|
||||
self.offsets.pop_front();
|
||||
}
|
||||
|
||||
// 偏移量估计: 使用低 RTT 样本的中位数
|
||||
// 原理: RTT 较小的样本受网络抖动影响小,时间测量更准确
|
||||
let mut low_rtt_offsets: Vec<i128> = self
|
||||
.offsets
|
||||
.iter()
|
||||
.filter(|s| s.rtt <= self.min_rtt + 5000) // 5ms 容差
|
||||
.map(|s| s.offset)
|
||||
.collect();
|
||||
|
||||
if !low_rtt_offsets.is_empty() {
|
||||
low_rtt_offsets.sort_unstable();
|
||||
let new_offset = low_rtt_offsets[low_rtt_offsets.len() / 2];
|
||||
|
||||
// 漂移率估计
|
||||
self.estimate_drift(new_offset, client_recv_ts);
|
||||
|
||||
// 平滑更新偏移量 (避免突变)
|
||||
let alpha = 0.3; // 低通滤波系数
|
||||
self.current_offset =
|
||||
(alpha * new_offset as f64 + (1.0 - alpha) * self.current_offset as f64) as i128;
|
||||
}
|
||||
|
||||
self.last_update_time = client_recv_ts;
|
||||
}
|
||||
|
||||
/// 估计时钟漂移率
|
||||
/// 时钟漂移率 = d(offset) / dt
|
||||
fn estimate_drift(&mut self, offset: i128, timestamp: u128) {
|
||||
self.drift_samples.push_back(DriftSample { offset, timestamp });
|
||||
if self.drift_samples.len() > 60 {
|
||||
self.drift_samples.pop_front();
|
||||
}
|
||||
|
||||
// 至少需要 10 秒的数据才能估计漂移
|
||||
if self.drift_samples.len() < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用线性回归估计漂移率
|
||||
let first = self.drift_samples.front().unwrap();
|
||||
let last = self.drift_samples.back().unwrap();
|
||||
|
||||
let dt = (last.timestamp - first.timestamp) as f64;
|
||||
let d_offset = (last.offset - first.offset) as f64;
|
||||
|
||||
if dt > 10_000_000.0 {
|
||||
// 超过 10 秒
|
||||
// drift_rate 单位: 微秒/秒 = ppm
|
||||
let new_drift = d_offset / (dt / 1_000_000.0);
|
||||
|
||||
// 平滑更新漂移率
|
||||
let beta = 0.1;
|
||||
self.drift_rate = beta * new_drift + (1.0 - beta) * self.drift_rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将本地时间转换为服务器(主节点)时间
|
||||
/// 考虑时钟漂移补偿
|
||||
pub fn to_server_time(&self, client_time: u128) -> u128 {
|
||||
let base_server_time = (client_time as i128 + self.current_offset) as u128;
|
||||
|
||||
// 漂移补偿: 根据距离上次同步的时间,补偿时钟漂移
|
||||
let elapsed_since_update = client_time.saturating_sub(self.last_update_time) as f64;
|
||||
let drift_correction = (self.drift_rate * elapsed_since_update / 1_000_000.0) as i128;
|
||||
|
||||
(base_server_time as i128 + drift_correction) as u128
|
||||
}
|
||||
|
||||
/// 将服务器(主节点)时间转换为本地时间
|
||||
pub fn to_client_time(&self, server_time: u128) -> u128 {
|
||||
// 简化版本,不考虑漂移补偿 (播放时主要用 to_server_time)
|
||||
(server_time as i128 - self.current_offset) as u128
|
||||
}
|
||||
|
||||
/// 获取当前估计的 RTT (微秒)
|
||||
pub fn get_rtt(&self) -> i128 {
|
||||
self.min_rtt
|
||||
}
|
||||
|
||||
/// 获取当前时钟漂移率 (ppm)
|
||||
pub fn get_drift_rate(&self) -> f64 {
|
||||
self.drift_rate
|
||||
}
|
||||
|
||||
/// 获取同步质量评估 (0-100, 越高越好)
|
||||
pub fn get_sync_quality(&self) -> u8 {
|
||||
if self.offsets.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 基于 RTT 稳定性和偏移量方差评估
|
||||
let rtt_variance = self.calculate_variance(&self.rtts.iter().copied().collect::<Vec<_>>());
|
||||
let offset_variance = self.calculate_variance(
|
||||
&self.offsets.iter().map(|s| s.offset).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// RTT 越稳定,方差越小,质量越高
|
||||
let rtt_score = ((100_000.0 - rtt_variance.min(100_000.0)) / 100_000.0 * 50.0) as u8;
|
||||
let offset_score = ((50_000.0 - offset_variance.min(50_000.0)) / 50_000.0 * 50.0) as u8;
|
||||
|
||||
rtt_score + offset_score
|
||||
}
|
||||
|
||||
fn calculate_variance(&self, samples: &[i128]) -> f64 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = samples.iter().sum::<i128>() as f64 / samples.len() as f64;
|
||||
let variance = samples
|
||||
.iter()
|
||||
.map(|&x| {
|
||||
let diff = x as f64 - mean;
|
||||
diff * diff
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ samples.len() as f64;
|
||||
variance.sqrt()
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,10 @@ PS:如果还是不行,建议更换其他更易识别的唤醒词。
|
||||
|
||||
由于 ASR 相关模型文件体积较大,并未直接提交在 git 仓库中,你可以在 release 中下载 [VAD + KWS 相关模型](https://github.com/idootop/open-xiaoai/releases/tag/vad-kws-models),然后解压到 `xiaozhi/models` 路径下即可。
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [oxa-server](https://github.com/pu-007/oxa-server): 提供了更强大易用的 config.py 的配置方式
|
||||
|
||||
## 鸣谢
|
||||
|
||||
该演示使用的 Python 版小智 AI 客户端基于 [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) 项目,特此鸣谢。
|
||||
|
||||
@@ -65,8 +65,8 @@ APP_CONFIG = {
|
||||
"xiaozhi": {
|
||||
"OTA_URL": "https://api.tenclass.net/xiaozhi/ota/",
|
||||
"WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/",
|
||||
"WEBSOCKET_ACCESS_TOKEN": "(可选)一般用不到这个值",
|
||||
"DEVICE_ID": "(可选)默认自动生成",
|
||||
"VERIFICATION_CODE": "首次登陆时,验证码会在这里更新",
|
||||
"WEBSOCKET_ACCESS_TOKEN": "", #(可选)一般用不到这个值
|
||||
"DEVICE_ID": "", #(可选)默认自动生成
|
||||
"VERIFICATION_CODE": "", # 首次登陆时,验证码会在这里更新
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#! /bin/sh
|
||||
|
||||
exec > /dev/null 2>&1
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
▄▖ ▖▖▘ ▄▖▄▖
|
||||
▌▌▛▌█▌▛▌▚▘▌▀▌▛▌▌▌▐
|
||||
▙▌▙▌▙▖▌▌▌▌▌█▌▙▌▛▌▟▖
|
||||
▌
|
||||
|
||||
v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
# 等待能够正常访问 baidu.com
|
||||
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "✅ 网络连接成功"
|
||||
|
||||
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
|
||||
|
||||
WORK_DIR="/data/open-xiaoai/scripts"
|
||||
|
||||
if [ ! -d "$WORK_DIR" ]; then
|
||||
mkdir -p "$WORK_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORK_DIR/client-boot.sh" ]; then
|
||||
curl -L -# -o "$WORK_DIR/client-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/boot.sh"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORK_DIR/kws-boot.sh" ]; then
|
||||
curl -L -# -o "$WORK_DIR/kws-boot.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/boot.sh"
|
||||
fi
|
||||
|
||||
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||
|
||||
sh "$WORK_DIR/kws-boot.sh" --no-monitor > /dev/null 2>&1 &
|
||||
|
||||
sh "$WORK_DIR/client-boot.sh"
|
||||
@@ -0,0 +1,25 @@
|
||||
#! /bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download"
|
||||
|
||||
WORK_DIR="/data/open-xiaoai/scripts"
|
||||
|
||||
if [ ! -d "$WORK_DIR" ]; then
|
||||
mkdir -p "$WORK_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORK_DIR/client.sh" ]; then
|
||||
curl -L -# -o "$WORK_DIR/client.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-client/init.sh"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORK_DIR/kws.sh" ]; then
|
||||
curl -L -# -o "$WORK_DIR/kws.sh" "$DOWNLOAD_BASE_URL/open-xiaoai-kws/init.sh"
|
||||
fi
|
||||
|
||||
kill -9 `ps|grep "open-xiaoai/kws/monitor"|grep -v grep|awk '{print $1}'` > /dev/null 2>&1 || true
|
||||
|
||||
sh "$WORK_DIR/kws.sh" --no-monitor > /dev/null 2>&1 &
|
||||
|
||||
sh "$WORK_DIR/client.sh"
|
||||
@@ -201,7 +201,7 @@ class WebsocketProtocol(Protocol):
|
||||
if self.websocket and get_xiaozhi().device_state == DeviceState.IDLE:
|
||||
try:
|
||||
await self.send_text(
|
||||
json.dumps({"session_id": "", "type": "abort"})
|
||||
json.dumps({"session_id": "", "type": "ping"})
|
||||
)
|
||||
except Exception:
|
||||
# 发送心跳失败,重新连接
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# 你的小米账号/密码
|
||||
#你的小米账号/密码
|
||||
MI_USER=23333333
|
||||
MI_PASS=xxxxxxxxx
|
||||
|
||||
# 你的小爱音箱名称/DID
|
||||
#你的小爱音箱名称/DID
|
||||
MI_DID=小爱音箱Pro
|
||||
|
||||
# 你的 SSH 登录密码(默认为 open-xiaoai)
|
||||
#你的 SSH 登录密码,默认为 open-xiaoai
|
||||
SSH_PASSWORD=open-xiaoai
|
||||
|
||||
#账号登录异常时,请取消下面的注释,使用 MI_TOKEN 登录
|
||||
#MI_TOKEN=你的 passToken 获取教程 👉 https://github.com/idootop/migpt-next/issues/4
|
||||
|
||||
# 获取设备异常时,请取消下面的注释,查看设备列表详情
|
||||
#MI_DEBUG=true
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Test",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/src/ota.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& apk add --no-cache \
|
||||
bash \
|
||||
file \
|
||||
patch \
|
||||
python3 \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> 刷机有风险,操作需谨慎。请勿下载使用不明来历的固件!
|
||||
|
||||
小爱音箱 Pro 补丁固件制作流程:
|
||||
|
||||
- 固件提取(登录小米账号获取 OTA 链接)
|
||||
- 开启固化 SSH(支持自定义登录密码)
|
||||
- 禁用系统自动更新(系统更新后需要重新刷机打补丁)
|
||||
@@ -12,31 +13,31 @@
|
||||
## 下载固件
|
||||
|
||||
你可以直接在 [Github Releases](https://github.com/idootop/open-xiaoai/releases) 页面下载打包好的固件:
|
||||
- [Xiaomi 智能音箱 Pro v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
|
||||
- [小爱音箱 Pro v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
|
||||
|
||||
- [Xiaomi 智能音箱 Pro v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
|
||||
- [小爱音箱 Pro v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
|
||||
|
||||
> [!TIP]
|
||||
> 里面有两个文件,下载 `patched` 那个:
|
||||
>
|
||||
> - `xxx_patched.squashfs` 打补丁后的固件
|
||||
> - `xxx.squashfs` 原版固件(可用来刷回原系统)
|
||||
|
||||
> [!NOTE]
|
||||
> 默认 SSH 登录密码为 `open-xiaoai`,如需修改请自行制作固件。
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请下载和你当前小爱音箱版本一致的固件,跨版本刷机可能会出现未知错误,导致设备变砖。
|
||||
> 如果上面没有你的版本,请升级设备固件到最新版本,或者按照下面的教程自行制作固件。
|
||||
|
||||
> [!CAUTION]
|
||||
> 当前支持的最新固件版本为:
|
||||
> - Xiaomi 智能音箱 Pro 👉 [v1.56.19](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.56.19)
|
||||
> - 小爱音箱 Pro 👉 [v1.88.206](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.88.206)
|
||||
>
|
||||
> - Xiaomi 智能音箱 Pro 👉 [v1.58.6](https://github.com/idootop/open-xiaoai/releases/tag/OH2P_1.58.6)
|
||||
> - 小爱音箱 Pro 👉 [v1.94.13](https://github.com/idootop/open-xiaoai/releases/tag/LX06_1.94.13)
|
||||
>
|
||||
> 更新版本的固件可能存在变化,导致刷机失败,设备变砖,请自行评估风险。
|
||||
|
||||
|
||||
|
||||
## 制作固件
|
||||
|
||||
你可以按照下面的 2 种方法,制作自定义固件。
|
||||
@@ -66,13 +67,25 @@ SSH_PASSWORD=open-xiaoai
|
||||
- Docker:https://www.docker.com/get-started/
|
||||
|
||||
> [!NOTE]
|
||||
> Windows 系统推荐使用 [Git Bash](https://git-scm.com/downloads) 终端运行。
|
||||
>
|
||||
> CMD 和 PowerShell 终端需要调整下面命令中文件(夹)的实际路径。
|
||||
> Windows 系统请在 [Git Bash](https://git-scm.com/downloads) 终端中运行以下命令。
|
||||
|
||||
> [!TIP]
|
||||
> 如果你是 Apple Silicon 芯片,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
|
||||
```shell
|
||||
# 克隆代码
|
||||
git clone https://github.com/idootop/open-xiaoai.git
|
||||
|
||||
# 进入当前项目根目录
|
||||
cd packages/client-patch
|
||||
|
||||
# 使用 Docker 进行构建
|
||||
docker run -it --rm --env-file $(pwd)/.env -v $(pwd)/assets:/app/assets -v $(pwd)/patches:/app/patches idootop/open-xiaoai:latest
|
||||
docker run -it --rm \
|
||||
--platform linux/amd64 \
|
||||
--env-file $(pwd)/.env \
|
||||
-v $(pwd)/assets:/app/assets \
|
||||
-v $(pwd)/patches:/app/patches \
|
||||
idootop/open-xiaoai:latest
|
||||
|
||||
# ✅ 打包完成,固件文件已复制到 assets 目录...
|
||||
# /app/assets/mico_all_92db90ed6_1.88.197/root-patched.squashfs
|
||||
@@ -113,7 +126,7 @@ npm run build
|
||||
示例:
|
||||
|
||||
```bash
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
/usr/sbin/tts_play.sh '初始化成功'
|
||||
```
|
||||
Generated
+20
-20
@@ -8,11 +8,11 @@
|
||||
"name": "open-xiaoai-patch",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@mi-gpt/miot": "^1.0.0"
|
||||
"@mi-gpt/miot": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mi-gpt/config": "1.0.0",
|
||||
"@types/node": "^22.13.11",
|
||||
"@mi-gpt/config": "1.1.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
@@ -443,9 +443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/config": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.0.0.tgz",
|
||||
"integrity": "sha512-oo/roYiRcGiHsZESjukwPTsRFqddShTJ8gkccR5ZMYn8SySj07A0nCsEsLEfB9mLkvNY8D6PCO1NxbRDDRBBlA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/config/-/config-1.1.0.tgz",
|
||||
"integrity": "sha512-o24iFINsoDuvInnMpfBQ4zCuTObbdodjXyizXmxl3yyYOMjmIAXizO/1UZ9/dv3UVTov3sjL09aCGuBeIVhvtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -453,12 +453,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/miot": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.0.0.tgz",
|
||||
"integrity": "sha512-smWhddHzK1du3/LW2FifBrIIyeZNx/RvTSe0Zi4v3lYZwK8NeEwomg1jBjktQCCV4Fh8OuqNbCdNbmiVXmr5kw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/miot/-/miot-1.2.1.tgz",
|
||||
"integrity": "sha512-DAFNUsLPepXSqJk/b+zfct8NCQHZzcl7f1t4X2WPKzxmDNagkawYlb96uPbk4syvn+DSg6vqle0Wm+gjmJ4kUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mi-gpt/utils": "1.0.0",
|
||||
"@mi-gpt/utils": "1.1.1",
|
||||
"axios": "^1.8.4",
|
||||
"pako": "^2.1.0"
|
||||
},
|
||||
@@ -467,22 +467,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mi-gpt/utils": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.0.0.tgz",
|
||||
"integrity": "sha512-hAU/uYPNiNJgXWkQrVqRLe/9Uz8AgtoiNwcbbwQs8iXJcyq3NjcSHkkwUseJAnaQFE9iv/kvHZZsx3r2HhlKjw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mi-gpt/utils/-/utils-1.1.1.tgz",
|
||||
"integrity": "sha512-FiGHV2RDBRGE/Xb9Q5H757t3ZqOpo6gL+44VXiRxQb3fQiapOpn038RAWErHJYZajYzkxP2jl8Wggsb+wp77Zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
@@ -883,9 +883,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"ota": "tsx --env-file=.env src/ota.ts",
|
||||
"extract": "sh src/extract.sh",
|
||||
"patch": "sh src/patch.sh",
|
||||
"squashfs": "sh src/squashfs.sh",
|
||||
"build": "sh src/build.sh"
|
||||
"extract": "bash src/extract.sh",
|
||||
"patch": "bash src/patch.sh",
|
||||
"squashfs": "bash src/squashfs.sh",
|
||||
"build": "bash src/build.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mi-gpt/miot": "^1.0.0"
|
||||
"@mi-gpt/miot": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mi-gpt/config": "1.0.0",
|
||||
"@types/node": "^22.13.11",
|
||||
"@mi-gpt/config": "1.1.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
# Put your custom commands here that should be executed once
|
||||
# the system init finished. By default this file does nothing.
|
||||
|
||||
+[ -f "/data/init.sh" ] && sh /data/init.sh &
|
||||
+[ -f "/data/init.sh" ] && sh /data/init.sh >/dev/null 2>&1 &
|
||||
+
|
||||
exit 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -13,36 +13,18 @@ const kSupportedDevices = [
|
||||
// 获取 OTA 信息
|
||||
async function getOTA(channel: "release" | "current" | "stable" = "release") {
|
||||
const MiNA = await getMiNA({
|
||||
did: process.env.MI_DID!,
|
||||
userId: process.env.MI_USER!,
|
||||
password: process.env.MI_PASS!,
|
||||
passToken: process.env.MI_TOKEN,
|
||||
debug: !!process.env.MI_DEBUG,
|
||||
});
|
||||
if (!MiNA) {
|
||||
console.log(`❌ 登录失败,请检查小米账号密码是否正确:`);
|
||||
console.log(`当前账号:${process.env.MI_USER}`);
|
||||
console.log(`当前密码:${process.env.MI_PASS}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await MiNA.getDevices();
|
||||
const speaker = devices?.find(
|
||||
(e: any) =>
|
||||
e.name === process.env.MI_DID || e.miotDID === process.env.MI_DID
|
||||
);
|
||||
if (!speaker) {
|
||||
console.log(`❌ 找不到设备: ${process.env.MI_DID}`);
|
||||
console.log(`可用设备列表:`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
devices.map((e: any) => ({
|
||||
name: e.name,
|
||||
did: e.miotDID,
|
||||
})),
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const speaker = MiNA.account.device as any;
|
||||
|
||||
if (!kSupportedDevices.includes(speaker.hardware)) {
|
||||
console.log(
|
||||
@@ -65,7 +47,7 @@ async function getOTA(channel: "release" | "current" | "stable" = "release") {
|
||||
}
|
||||
|
||||
const model = speaker.hardware;
|
||||
const time = new Date().getTime();
|
||||
const time = Date.now();
|
||||
const sn = process.env.DEBUG_VERSION ? "" : speaker.serialNumber;
|
||||
const version = process.env.DEBUG_VERSION ?? speaker.romVersion;
|
||||
const otaInfo = `channel=${channel}&filterID=${sn}&locale=zh_CN&model=${model}&time=${time}&version=${version}&8007236f-a2d6-4847-ac83-c49395ad6d65`;
|
||||
@@ -88,7 +70,7 @@ async function main() {
|
||||
} else {
|
||||
ota = await getOTA();
|
||||
}
|
||||
if (!ota.url) {
|
||||
if (!ota?.url) {
|
||||
console.log(`❌ 获取设备信息失败`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -22,13 +22,13 @@ apply_patches() {
|
||||
# 创建临时文件用于占位符替换
|
||||
local temp_patch=$(mktemp)
|
||||
# 将补丁文件中的 {SSH_PASSWORD} 替换为 PASSWORD
|
||||
sed "s/{SSH_PASSWORD}/$PASSWORD/g" "$file" > "$temp_patch"
|
||||
sed "s|{SSH_PASSWORD}|$PASSWORD|g" "$file" > "$temp_patch"
|
||||
# 应用替换后的补丁
|
||||
patch -p1 < "$temp_patch"
|
||||
# 清理临时文件
|
||||
rm "$temp_patch"
|
||||
elif [[ "$file" == *.sh ]]; then
|
||||
sh "$file"
|
||||
bash "$file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -40,14 +40,20 @@ elif [ "$MODEL" = "LX06" ]; then
|
||||
IMAGE_MAX_SIZE=$((0x02800000))
|
||||
fi
|
||||
|
||||
SIZE=`stat -L -c %s $FIRMWARE/root-patched.squashfs`
|
||||
echo "📊 当前固件大小: $SIZE 字节"
|
||||
SIZE=$(stat -L -c %s "$FIRMWARE/root-patched.squashfs")
|
||||
SIZE_MB=$((SIZE / 1024 / 1024))
|
||||
IMAGE_MAX_SIZE_MB=$((IMAGE_MAX_SIZE / 1024 / 1024))
|
||||
|
||||
echo "📊 当前固件大小: $SIZE 字节 ($SIZE_MB MB)"
|
||||
|
||||
if [ "$SIZE" -ge "$IMAGE_MAX_SIZE" ]; then
|
||||
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节"
|
||||
echo "❌ 固件大小超过允许的最大值:$IMAGE_MAX_SIZE 字节 ($IMAGE_MAX_SIZE_MB MB)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 固件大小检查通过,剩余空间: $((IMAGE_MAX_SIZE - SIZE)) 字节"
|
||||
REMAINING_BYTES=$((IMAGE_MAX_SIZE - SIZE))
|
||||
REMAINING_MB=$((REMAINING_BYTES / 1024 / 1024))
|
||||
echo "✅ 固件大小检查通过,剩余可用空间: $REMAINING_BYTES 字节 ($REMAINING_MB MB)"
|
||||
|
||||
cp -rf $FIRMWARE $BASE_DIR/assets/$FIRMWARE
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
.clwb
|
||||
Generated
+208
-147
@@ -4,30 +4,24 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
|
||||
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.74"
|
||||
version = "0.3.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
|
||||
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"cfg-if",
|
||||
@@ -35,14 +29,14 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-targets",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -53,6 +47,12 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
@@ -61,9 +61,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
@@ -86,9 +86,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
@@ -197,9 +197,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "1dc8f7d2ded5f9209535e4b3fd4d39c002f30902ff5ce9f64e2c33d549576500"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -207,21 +207,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
@@ -240,6 +240,17 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -247,62 +258,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.171"
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
|
||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"scopeguard",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.7"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "open-xiaoai"
|
||||
version = "1.0.0"
|
||||
@@ -316,29 +333,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@@ -362,33 +356,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.94"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
version = "1.0.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.2.0"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
@@ -414,19 +408,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.11"
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -435,25 +426,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -462,14 +457,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
version = "1.0.145"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -485,33 +481,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.2"
|
||||
version = "1.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
|
||||
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
|
||||
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
@@ -519,9 +506,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -530,18 +517,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -550,17 +537,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.44.2"
|
||||
version = "1.47.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
|
||||
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
@@ -608,15 +596,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
@@ -626,11 +614,13 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.16.0"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -641,24 +631,98 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.2+wasi-0.2.4"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
@@ -728,28 +792,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.24"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
|
||||
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.24"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
|
||||
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -16,9 +16,9 @@ image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:0.2.5"
|
||||
|
||||
[dependencies]
|
||||
futures = "0.3.31"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-tungstenite = "0.26.2"
|
||||
rand = "0.9.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.15.1", features = ["v4"] }
|
||||
rand = "0.9.1"
|
||||
tokio = { version = "1.47", features = ["fs", "macros", "process", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-tungstenite = "0.26.2"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
|
||||
@@ -59,7 +59,7 @@ cross build --release --target armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 如果你是 Apple silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
> 如果你是 Apple Silicon 芯片,为了能够正常使用 cross 交叉编译镜像,请先在 Docker Desktop - Settings - General - Virtual Machine Options 中打开 Apple Virtual framework 选项,然后开启 `Use Rosetta for x86_64/amd64 emulation on Apple Silicon`
|
||||
|
||||
编译成功后,将构建好的补丁程序 `client` 复制到小爱音箱上
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
exec > /dev/null 2>&1
|
||||
|
||||
@@ -13,11 +13,15 @@ v1.0.0 by: https://del.wang
|
||||
|
||||
EOF
|
||||
|
||||
set -e
|
||||
# 等待能够正常访问 baidu.com
|
||||
while ! ping -c 1 baidu.com > /dev/null 2>&1; do
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🤫 等待网络连接中..."
|
||||
sleep 3
|
||||
|
||||
sleep 5
|
||||
echo "✅ 网络连接成功"
|
||||
|
||||
DOWNLOAD_BASE_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-client"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#! /bin/sh
|
||||
#!/bin/sh
|
||||
|
||||
cat << 'EOF'
|
||||
|
||||
|
||||
@@ -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,21 +71,24 @@ impl AppClient {
|
||||
rpc.add_command("start_recording", start_recording).await;
|
||||
rpc.add_command("stop_recording", stop_recording).await;
|
||||
|
||||
InstructionMonitor::start(|event| async move {
|
||||
self.instruction_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("instruction", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
PlayingMonitor::start(|event| async move {
|
||||
self.playing_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("playing", Some(json!(event)))
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
KwsMonitor::start(|event| async move {
|
||||
self.kws_monitor
|
||||
.start(|event| async move {
|
||||
MessageManager::instance()
|
||||
.send_event("kws", Some(json!(event)))
|
||||
.await
|
||||
@@ -81,13 +96,13 @@ impl AppClient {
|
||||
.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" => {
|
||||
if tag.as_str() == "play" {
|
||||
// 播放接收到的音频流
|
||||
let _ = AudioPlayer::instance().play(bytes).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
AppClient::run().await;
|
||||
AppClient::new().run().await;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async fn on_keyword(_keyword: String) {
|
||||
.split("\n")
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
if replies.len() > 0 {
|
||||
if !replies.is_empty() {
|
||||
wakeup_sounds.clear();
|
||||
wakeup_sounds.extend(replies.iter().map(|s| s.to_string()));
|
||||
}
|
||||
@@ -53,7 +53,8 @@ async fn main() {
|
||||
on_started().await;
|
||||
}
|
||||
|
||||
KwsMonitor::start(|event| async move {
|
||||
KwsMonitor::new()
|
||||
.start(|event| async move {
|
||||
match event {
|
||||
KwsMonitorEvent::Started => on_started().await,
|
||||
KwsMonitorEvent::Keyword(keyword) => on_keyword(keyword).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>
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
pub mod event;
|
||||
pub mod rand;
|
||||
pub mod shell;
|
||||
pub mod task;
|
||||
pub mod rand;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.mp3
|
||||
*.wav
|
||||
Generated
+853
@@ -0,0 +1,853 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-polyfill"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
|
||||
dependencies = [
|
||||
"atomic-polyfill",
|
||||
"hash32",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"spin",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opus"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e"
|
||||
dependencies = [
|
||||
"audiopus_sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"heapless",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-adpcm",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-mkv",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-adpcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-mkv"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "xiao"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"anyhow",
|
||||
"dashmap",
|
||||
"opus",
|
||||
"parking_lot",
|
||||
"postcard",
|
||||
"serde",
|
||||
"symphonia",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "xiao"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
debug = false
|
||||
|
||||
[dependencies]
|
||||
opus = "0.3"
|
||||
anyhow = "1.0"
|
||||
tokio = { version = "1.48", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
postcard = { version = "1.0", features = ["alloc", "use-std"] }
|
||||
parking_lot = "0.12.5"
|
||||
dashmap = "6.1.0"
|
||||
thiserror = "2.0.17"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
alsa = "0.11"
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
symphonia = { version = "0.5", features = ["mp3", "wav", "pcm"] }
|
||||
@@ -0,0 +1,17 @@
|
||||
build-server:
|
||||
cargo build --release --bin server
|
||||
|
||||
build-client:
|
||||
docker run --rm -v $(shell pwd):/app idootop/open-xiaoai-runtime:oh2p \
|
||||
cargo build --target armv7-unknown-linux-gnueabihf --release --bin client
|
||||
|
||||
run-server:
|
||||
target/release/server
|
||||
|
||||
test:
|
||||
clear && make build-client && make deploy && make build-server && make run-server
|
||||
|
||||
# 部署到小爱音箱(调试自用)
|
||||
deploy:
|
||||
dd if=target/armv7-unknown-linux-gnueabihf/release/client \
|
||||
| sshpass -p open-xiaoai ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.31.235 "dd of=/data/client"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Open-XiaoAI Client V2
|
||||
|
||||
> 开发中,敬请期待...
|
||||
|
||||
## License
|
||||
|
||||
MIT License © 2026-PRESENT [Del Wang](https://del.wang)
|
||||
@@ -0,0 +1,351 @@
|
||||
//! # Client 模块
|
||||
//!
|
||||
//! 音频客户端,支持:
|
||||
//! - 服务发现和自动连接
|
||||
//! - 音频录制和播放
|
||||
//! - RPC 远程调用
|
||||
//! - 实时事件处理
|
||||
//!
|
||||
//! ## 架构
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────┐
|
||||
//! │ Client │
|
||||
//! │ │
|
||||
//! Server ◀──TCP─────┼──▶ Session │
|
||||
//! │ ├─ Connection │
|
||||
//! │ ├─ RPC Manager │
|
||||
//! │ └─ Active Pipelines │
|
||||
//! │ │
|
||||
//! Server ◀──UDP─────┼──▶ Audio Pipelines │
|
||||
//! │ ├─ RecordPipeline │
|
||||
//! │ └─ PlaybackPipeline │
|
||||
//! │ │
|
||||
//! Events ◀──────────┼──▶ Event Handlers │
|
||||
//! └─────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
mod pipeline;
|
||||
mod session;
|
||||
|
||||
pub use pipeline::{PipelineHandle, PlaybackPipeline, RecordPipeline};
|
||||
pub use session::Session;
|
||||
|
||||
use crate::net::command::CommandResult;
|
||||
use crate::net::discovery::Discovery;
|
||||
use crate::net::event::{EventBus, EventBusSubscription, EventData};
|
||||
use crate::net::network::{AudioSocket, Connection};
|
||||
use crate::net::protocol::ControlPacket;
|
||||
use crate::net::rpc::RpcBuilder;
|
||||
use crate::net::sync::now_us;
|
||||
use anyhow::{Result, anyhow};
|
||||
use session::handshake;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// 客户端配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientConfig {
|
||||
/// 版本
|
||||
pub version: String,
|
||||
/// 客户端认证
|
||||
pub client_auth: String,
|
||||
/// 服务端认证
|
||||
pub server_auth: String,
|
||||
/// 心跳间隔(毫秒)
|
||||
pub heartbeat_ms: u64,
|
||||
/// 连接超时(秒)
|
||||
pub timeout: u64,
|
||||
/// 客户端型号
|
||||
pub model: String,
|
||||
/// 序列号
|
||||
pub serial_number: String,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
server_auth: std::env::var("XIAO_SERVER_AUTH")
|
||||
.unwrap_or_else(|_| "xiao-server".to_string()),
|
||||
client_auth: std::env::var("XIAO_CLIENT_AUTH")
|
||||
.unwrap_or_else(|_| "xiao-client".to_string()),
|
||||
heartbeat_ms: 200,
|
||||
timeout: 60,
|
||||
// todo 获取设备信息
|
||||
model: "Open-XiaoAi-V2".to_string(),
|
||||
serial_number: "00:00:00:00:00:00".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 音频客户端
|
||||
pub struct Client {
|
||||
/// 配置
|
||||
config: ClientConfig,
|
||||
/// 当前活动会话
|
||||
session: RwLock<Option<Arc<Session>>>,
|
||||
/// 全局取消令牌
|
||||
cancel: CancellationToken,
|
||||
/// 事件总线
|
||||
event_bus: Arc<EventBus>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// 创建新客户端
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
session: RwLock::new(None),
|
||||
cancel: CancellationToken::new(),
|
||||
event_bus: Arc::new(EventBus::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用默认配置创建客户端
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(ClientConfig::default())
|
||||
}
|
||||
|
||||
/// 订阅事件
|
||||
pub fn subscribe_events(&self) -> EventBusSubscription {
|
||||
self.event_bus.subscribe()
|
||||
}
|
||||
|
||||
/// 运行客户端(自动发现服务器并连接)
|
||||
pub async fn run(self: Arc<Self>) -> Result<()> {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel.cancelled() => {
|
||||
println!("[Client] Shutting down...");
|
||||
break;
|
||||
}
|
||||
result = self.discover_and_connect() => {
|
||||
if let Err(e) = result {
|
||||
eprintln!("[Client] Connection error: {}", e);
|
||||
}
|
||||
// 连接断开后清理并重试
|
||||
self.cleanup().await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发现服务器并连接
|
||||
async fn discover_and_connect(&self) -> Result<()> {
|
||||
println!("[Client] Searching for server...");
|
||||
|
||||
let (ip, tcp_port) = Discovery::listen().await?;
|
||||
let server_addr = SocketAddr::new(ip, tcp_port);
|
||||
println!("[Client] Found server at {}", server_addr);
|
||||
|
||||
let stream = tokio::net::TcpStream::connect(server_addr).await?;
|
||||
println!("[Client] Connected to {}", server_addr);
|
||||
|
||||
self.handle_session(stream, server_addr).await
|
||||
}
|
||||
|
||||
/// 处理会话
|
||||
async fn handle_session(
|
||||
&self,
|
||||
stream: tokio::net::TcpStream,
|
||||
server_addr: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let conn = Arc::new(Connection::new(stream)?);
|
||||
let audio_socket = Arc::new(AudioSocket::bind().await?);
|
||||
|
||||
// 执行握手
|
||||
let handshake_result =
|
||||
handshake(&conn, audio_socket.port(), server_addr, &self.config).await?;
|
||||
|
||||
println!(
|
||||
"[Client] Handshake OK, server audio at {}",
|
||||
handshake_result.server_audio_addr
|
||||
);
|
||||
|
||||
// 创建会话
|
||||
let session_cancel = self.cancel.child_token();
|
||||
let session = Arc::new(Session::new(
|
||||
conn.clone(),
|
||||
audio_socket,
|
||||
handshake_result.server_audio_addr,
|
||||
session_cancel.clone(),
|
||||
));
|
||||
|
||||
// 存储会话
|
||||
*self.session.write().await = Some(session.clone());
|
||||
|
||||
// 启动心跳
|
||||
self.spawn_heartbeat(session.clone());
|
||||
|
||||
// 运行消息循环
|
||||
self.message_loop(session).await
|
||||
}
|
||||
|
||||
/// 启动心跳任务
|
||||
fn spawn_heartbeat(&self, session: Arc<Session>) {
|
||||
let interval = std::time::Duration::from_millis(self.config.heartbeat_ms);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut seq = 0;
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = session.cancel.cancelled() => break,
|
||||
_ = ticker.tick() => {
|
||||
let t1 = now_us();
|
||||
let msg = ControlPacket::Ping { client_ts: t1, seq };
|
||||
if session.send(&msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
seq += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 消息主循环
|
||||
async fn message_loop(&self, session: Arc<Session>) -> Result<()> {
|
||||
let timeout = std::time::Duration::from_secs(self.config.timeout);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = session.cancel.cancelled() => break,
|
||||
result = tokio::time::timeout(timeout, session.recv()) => {
|
||||
match result {
|
||||
Ok(Ok(packet)) => {
|
||||
self.handle_packet(&session, packet).await?;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(anyhow!("Connection error: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow!("Connection timeout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理控制包
|
||||
async fn handle_packet(&self, session: &Arc<Session>, packet: ControlPacket) -> Result<()> {
|
||||
match packet {
|
||||
ControlPacket::Pong {
|
||||
client_ts,
|
||||
server_ts,
|
||||
..
|
||||
} => {
|
||||
let t4 = now_us();
|
||||
session.update_clock(client_ts, server_ts, t4);
|
||||
}
|
||||
ControlPacket::Event { timestamp, data } => {
|
||||
self.event_bus.receive(data, timestamp, session.id());
|
||||
}
|
||||
ControlPacket::RpcResponse { id, result } => {
|
||||
session.resolve_rpc(id, result);
|
||||
}
|
||||
// 处理 RPC 请求(支持超时和异步控制)
|
||||
ControlPacket::RpcRequest {
|
||||
id,
|
||||
run_async,
|
||||
timeout,
|
||||
command,
|
||||
} => {
|
||||
let session_clone = session.clone();
|
||||
session
|
||||
.rpc_manager
|
||||
.handle_rpc_request(id, run_async, timeout, command, move |pck| {
|
||||
let s = session_clone.clone();
|
||||
async move {
|
||||
return s.send(&pck).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
ControlPacket::StartRecording { config } => {
|
||||
println!("[Client] Starting recording...");
|
||||
let handle = RecordPipeline::spawn(
|
||||
config,
|
||||
session.audio_socket.clone(),
|
||||
session.server_audio_addr,
|
||||
session.cancel.clone(),
|
||||
);
|
||||
session.start_recording(handle);
|
||||
}
|
||||
ControlPacket::StopRecording => {
|
||||
println!("[Client] Stopping recording...");
|
||||
session.stop_recording();
|
||||
}
|
||||
ControlPacket::StartPlayback { config } => {
|
||||
println!("[Client] Starting playback...");
|
||||
let handle = PlaybackPipeline::spawn(
|
||||
config,
|
||||
session.clock.clone(),
|
||||
session.audio_socket.clone(),
|
||||
session.cancel.clone(),
|
||||
);
|
||||
session.start_playback(handle);
|
||||
}
|
||||
ControlPacket::StopPlayback => {
|
||||
println!("[Client] Stopping playback...");
|
||||
session.stop_playback();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理资源
|
||||
async fn cleanup(&self) {
|
||||
let mut session_guard = self.session.write().await;
|
||||
if let Some(session) = session_guard.take() {
|
||||
session.cleanup();
|
||||
println!("[Client] Session cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公开 API ====================
|
||||
|
||||
/// 执行 RPC 命令
|
||||
pub async fn rpc(&self, builder: &RpcBuilder) -> Result<CommandResult> {
|
||||
let session_guard = self.session.read().await;
|
||||
if let Some(session) = session_guard.as_ref() {
|
||||
session.rpc(builder).await
|
||||
} else {
|
||||
Err(anyhow!("Not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送事件
|
||||
pub async fn send_event(&self, event: EventData) -> Result<()> {
|
||||
let session_guard = self.session.read().await;
|
||||
if let Some(session) = session_guard.as_ref() {
|
||||
session
|
||||
.send(&ControlPacket::Event {
|
||||
timestamp: now_us(),
|
||||
data: event,
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
Err(anyhow!("Not connected"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否已连接
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.session.read().await.is_some()
|
||||
}
|
||||
|
||||
/// 关闭客户端
|
||||
pub fn shutdown(&self) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! # Audio Pipeline - 音频管道抽象
|
||||
//!
|
||||
//! 提供录音和播放的管道化处理。
|
||||
//!
|
||||
//! ## 设计
|
||||
//!
|
||||
//! ```text
|
||||
//! RecordPipeline:
|
||||
//! ┌──────────┐ ┌────────────┐ ┌────────────┐
|
||||
//! │ ALSA Mic │────▶│ Opus Encode│────▶│ UDP Send │
|
||||
//! │ (thread) │ │ (async) │ │ (async) │
|
||||
//! └──────────┘ └────────────┘ └────────────┘
|
||||
//!
|
||||
//! PlaybackPipeline:
|
||||
//! ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
//! │ UDP Recv │────▶│ Opus Decode│────▶│ ALSA Play │
|
||||
//! │ (async) │ │ (async) │ │ (thread) │
|
||||
//! └────────────┘ └────────────┘ └────────────┘
|
||||
//! ```
|
||||
|
||||
use crate::audio::codec::OpusCodec;
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::audio::player::AudioPlayer;
|
||||
use crate::audio::recorder::AudioRecorder;
|
||||
use crate::net::jitter_buffer::{JitterBuffer, JitterConfig};
|
||||
use crate::net::network::AudioSocket;
|
||||
use crate::net::protocol::AudioPacket;
|
||||
use crate::net::sync::now_us;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// 管道句柄 - 用于控制正在运行的音频管道
|
||||
pub struct PipelineHandle {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl PipelineHandle {
|
||||
fn new(cancel: CancellationToken) -> Self {
|
||||
Self { cancel }
|
||||
}
|
||||
|
||||
/// 停止管道
|
||||
pub fn stop(&self) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
|
||||
/// 检查是否已停止
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipelineHandle {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 录音管道
|
||||
/// 从麦克风捕获 -> Opus 编码 -> UDP 发送到服务器
|
||||
pub struct RecordPipeline;
|
||||
|
||||
impl RecordPipeline {
|
||||
/// 启动录音管道
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 音频配置
|
||||
/// * `socket` - UDP socket
|
||||
/// * `target` - 目标服务器地址
|
||||
/// * `parent_cancel` - 父级取消令牌
|
||||
pub fn spawn(
|
||||
config: AudioConfig,
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
parent_cancel: CancellationToken,
|
||||
) -> PipelineHandle {
|
||||
let cancel = parent_cancel.child_token();
|
||||
let handle = PipelineHandle::new(cancel.clone());
|
||||
|
||||
let token = cancel.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run(config, socket, target, token).await {
|
||||
eprintln!("[RecordPipeline] Error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: AudioConfig,
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
cancel: CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
// 创建 PCM 数据通道
|
||||
let (pcm_tx, mut pcm_rx) = mpsc::channel::<Vec<i16>>(32);
|
||||
|
||||
// 启动 ALSA 录音线程(阻塞 I/O)
|
||||
let recorder_config = config.clone();
|
||||
let channels = recorder_config.channels as usize;
|
||||
std::thread::spawn(move || {
|
||||
let recorder = match AudioRecorder::new(&recorder_config) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("[RecordPipeline] Failed to create recorder: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf =
|
||||
vec![0i16; recorder_config.frame_size * recorder_config.channels as usize];
|
||||
|
||||
loop {
|
||||
match recorder.read(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
let actual_samples = n * channels;
|
||||
if pcm_tx
|
||||
.blocking_send(buf[..actual_samples].to_vec())
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// 空读取,继续
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[RecordPipeline] Read error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建 Opus 编码器
|
||||
let mut codec = OpusCodec::new(&config)?;
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
|
||||
println!("[RecordPipeline] Started -> {}", target);
|
||||
|
||||
// 主循环:从录音线程接收 PCM,编码后发送
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
pcm = pcm_rx.recv() => {
|
||||
match pcm {
|
||||
Some(samples) => {
|
||||
if let Ok(encoded_len) = codec.encode(&samples, &mut opus_buf) {
|
||||
let packet = AudioPacket {
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
data: opus_buf[..encoded_len].to_vec(),
|
||||
};
|
||||
let _ = socket.send(&packet, target).await;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 录音线程已退出
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[RecordPipeline] Stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放管道
|
||||
/// 从 UDP 接收 -> Opus 解码 -> 扬声器播放
|
||||
pub struct PlaybackPipeline;
|
||||
|
||||
impl PlaybackPipeline {
|
||||
/// 启动播放管道
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 音频配置
|
||||
/// * `socket` - UDP socket
|
||||
/// * `parent_cancel` - 父级取消令牌
|
||||
pub fn spawn(
|
||||
config: AudioConfig,
|
||||
clock: Arc<parking_lot::Mutex<crate::net::sync::ClockSync>>,
|
||||
socket: Arc<AudioSocket>,
|
||||
parent_cancel: CancellationToken,
|
||||
) -> PipelineHandle {
|
||||
let cancel = parent_cancel.child_token();
|
||||
let handle = PipelineHandle::new(cancel.clone());
|
||||
|
||||
let token = cancel.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run(config, clock, socket, token).await {
|
||||
eprintln!("[PlaybackPipeline] Error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: AudioConfig,
|
||||
clock: Arc<parking_lot::Mutex<crate::net::sync::ClockSync>>,
|
||||
socket: Arc<AudioSocket>,
|
||||
cancel: CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
// 1. 创建 PCM 通道
|
||||
let (pcm_tx, mut pcm_rx) = mpsc::channel::<Vec<i16>>(64);
|
||||
|
||||
// 2. 专用播放线程
|
||||
let player_config = config.clone();
|
||||
std::thread::spawn(move || {
|
||||
let player = match AudioPlayer::new(&player_config) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return eprintln!("[PlaybackPipeline] Player init error: {}", e),
|
||||
};
|
||||
|
||||
// 当 pcm_tx 在异步任务中被 drop,这里会自动退出
|
||||
while let Some(samples) = pcm_rx.blocking_recv() {
|
||||
if let Err(e) = player.write(&samples) {
|
||||
eprintln!("[PlaybackPipeline] Write error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
println!("[PlaybackPipeline] Player thread exited naturally");
|
||||
});
|
||||
|
||||
// 3. Opus 解码器与 Jitter Buffer
|
||||
let mut codec = OpusCodec::new(&config)?;
|
||||
let mut jitter_buffer = JitterBuffer::new(JitterConfig::default());
|
||||
let mut pcm_frame = vec![0i16; config.frame_size * config.channels as usize];
|
||||
|
||||
// 提高定时精度
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(5));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
println!("[PlaybackPipeline] Running");
|
||||
|
||||
// 4. 主逻辑循环
|
||||
// 使用 loop + select,当 cancel 触发时直接 break
|
||||
let mut udp_buf = vec![0u8; 4096];
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 优先级 1: 外部取消
|
||||
_ = cancel.cancelled() => {
|
||||
break;
|
||||
}
|
||||
|
||||
// 优先级 2: 网络接收
|
||||
result = socket.recv(&mut udp_buf) => {
|
||||
if let Ok((packet,_)) = result {
|
||||
let arrival_time = clock.lock().to_server_time(now_us());
|
||||
jitter_buffer.push(packet, arrival_time);
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级 3: 播放调度
|
||||
_ = ticker.tick() => {
|
||||
let current_time = clock.lock().to_server_time(now_us());
|
||||
while let Some(packet) = jitter_buffer.pop(current_time) {
|
||||
if let Ok(samples) = codec.decode(&packet.data, &mut pcm_frame) {
|
||||
let samples = pcm_frame[..samples * config.channels as usize].to_vec();
|
||||
if pcm_tx.try_send(samples).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[PlaybackPipeline] Stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! # Client Session - 客户端会话管理
|
||||
//!
|
||||
//! 轻量级的会话结构,负责:
|
||||
//! - TCP 控制连接
|
||||
//! - RPC 管理
|
||||
//! - 会话生命周期
|
||||
//! - 活动音频流追踪
|
||||
|
||||
use crate::net::command::CommandResult;
|
||||
use crate::net::network::{AudioSocket, Connection};
|
||||
use crate::net::protocol::{ClientInfo, ControlPacket};
|
||||
use crate::net::rpc::{RpcBuilder, RpcManager};
|
||||
use crate::net::sync::ClockSync;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{ClientConfig, pipeline::PipelineHandle};
|
||||
|
||||
/// 活动管道追踪
|
||||
pub struct ActivePipelines {
|
||||
pub recorder: Option<PipelineHandle>,
|
||||
pub player: Option<PipelineHandle>,
|
||||
}
|
||||
|
||||
impl Default for ActivePipelines {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
recorder: None,
|
||||
player: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActivePipelines {
|
||||
pub fn stop_all(&mut self) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
if let Some(h) = self.player.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_recording(&mut self, handle: PipelineHandle) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
self.recorder = Some(handle);
|
||||
}
|
||||
|
||||
pub fn stop_recording(&mut self) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_playback(&mut self, handle: PipelineHandle) {
|
||||
if let Some(h) = self.player.take() {
|
||||
h.stop();
|
||||
}
|
||||
self.player = Some(handle);
|
||||
}
|
||||
|
||||
pub fn stop_playback(&mut self) {
|
||||
if let Some(h) = self.player.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recorder.is_some()
|
||||
}
|
||||
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.player.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端会话
|
||||
pub struct Session {
|
||||
/// TCP 控制连接
|
||||
pub conn: Arc<Connection>,
|
||||
|
||||
/// UDP 音频 socket
|
||||
pub audio_socket: Arc<AudioSocket>,
|
||||
|
||||
/// 服务器音频地址
|
||||
pub server_audio_addr: SocketAddr,
|
||||
|
||||
/// RPC 管理器
|
||||
pub rpc_manager: Arc<RpcManager>,
|
||||
|
||||
/// 会话取消令牌
|
||||
pub cancel: CancellationToken,
|
||||
|
||||
/// 时间同步
|
||||
pub clock: Arc<parking_lot::Mutex<ClockSync>>,
|
||||
|
||||
/// 活动管道
|
||||
pipelines: parking_lot::Mutex<ActivePipelines>,
|
||||
|
||||
/// 会话创建时间
|
||||
created_at: std::time::Instant,
|
||||
|
||||
/// 当前音量
|
||||
volume: parking_lot::Mutex<u8>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// 创建新会话
|
||||
pub fn new(
|
||||
conn: Arc<Connection>,
|
||||
audio_socket: Arc<AudioSocket>,
|
||||
server_audio_addr: SocketAddr,
|
||||
cancel: CancellationToken,
|
||||
) -> Self {
|
||||
Self {
|
||||
conn,
|
||||
audio_socket,
|
||||
server_audio_addr,
|
||||
rpc_manager: Arc::new(RpcManager::new()),
|
||||
cancel,
|
||||
clock: Arc::new(parking_lot::Mutex::new(ClockSync::new(100))),
|
||||
pipelines: parking_lot::Mutex::new(ActivePipelines::default()),
|
||||
created_at: std::time::Instant::now(),
|
||||
volume: parking_lot::Mutex::new(100),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取会话 ID(使用 TCP 地址)
|
||||
pub fn id(&self) -> SocketAddr {
|
||||
self.conn.peer_addr()
|
||||
}
|
||||
|
||||
/// 检查会话是否仍然有效
|
||||
pub fn is_alive(&self) -> bool {
|
||||
!self.cancel.is_cancelled()
|
||||
}
|
||||
|
||||
/// 获取会话运行时间
|
||||
pub fn uptime_secs(&self) -> u64 {
|
||||
self.created_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
/// 发送控制包
|
||||
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
|
||||
self.conn.send(packet).await
|
||||
}
|
||||
|
||||
/// 接收控制包
|
||||
pub async fn recv(&self) -> Result<ControlPacket> {
|
||||
self.conn.recv().await
|
||||
}
|
||||
|
||||
/// 更新时间
|
||||
pub fn update_clock(&self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
|
||||
self.clock
|
||||
.lock()
|
||||
.update(client_send_ts, server_ts, client_recv_ts);
|
||||
}
|
||||
|
||||
/// 发起 RPC 调用(支持超时和异步控制)
|
||||
pub async fn rpc(&self, request: &RpcBuilder) -> Result<CommandResult> {
|
||||
let result = self
|
||||
.rpc_manager
|
||||
.call(request, |pck| async move {
|
||||
return self.conn.send(&pck).await;
|
||||
})
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 处理 RPC 响应
|
||||
pub fn resolve_rpc(&self, id: u32, result: CommandResult) {
|
||||
self.rpc_manager.resolve(id, result);
|
||||
}
|
||||
|
||||
/// 开始录音管道
|
||||
pub fn start_recording(&self, handle: PipelineHandle) {
|
||||
self.pipelines.lock().start_recording(handle);
|
||||
}
|
||||
|
||||
/// 停止录音管道
|
||||
pub fn stop_recording(&self) {
|
||||
self.pipelines.lock().stop_recording();
|
||||
}
|
||||
|
||||
/// 开始播放管道
|
||||
pub fn start_playback(&self, handle: PipelineHandle) {
|
||||
self.pipelines.lock().start_playback(handle);
|
||||
}
|
||||
|
||||
/// 停止播放管道
|
||||
pub fn stop_playback(&self) {
|
||||
self.pipelines.lock().stop_playback();
|
||||
}
|
||||
|
||||
/// 检查是否正在录音
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.pipelines.lock().is_recording()
|
||||
}
|
||||
|
||||
/// 检查是否正在播放
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.pipelines.lock().is_playing()
|
||||
}
|
||||
|
||||
/// 获取当前音量
|
||||
pub fn volume(&self) -> u8 {
|
||||
*self.volume.lock()
|
||||
}
|
||||
|
||||
/// 设置音量
|
||||
pub fn set_volume(&self, vol: u8) -> u8 {
|
||||
let mut v = self.volume.lock();
|
||||
let prev = *v;
|
||||
*v = vol.min(100);
|
||||
prev
|
||||
}
|
||||
|
||||
/// 清理所有资源
|
||||
pub fn cleanup(&self) {
|
||||
self.cancel.cancel();
|
||||
self.pipelines.lock().stop_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// 握手结果
|
||||
pub struct HandshakeResult {
|
||||
pub server_audio_addr: SocketAddr,
|
||||
}
|
||||
|
||||
/// 执行客户端握手
|
||||
pub async fn handshake(
|
||||
conn: &Connection,
|
||||
audio_port: u16,
|
||||
server_addr: SocketAddr,
|
||||
config: &ClientConfig,
|
||||
) -> Result<HandshakeResult> {
|
||||
// 发送 ClientHello
|
||||
conn.send(&ControlPacket::ClientHello {
|
||||
udp_port: audio_port,
|
||||
auth: config.server_auth.clone(),
|
||||
version: config.version.clone(),
|
||||
info: ClientInfo {
|
||||
model: config.model.clone(),
|
||||
serial_number: config.serial_number.clone(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 等待 ServerHello
|
||||
let server_udp_port = match conn.recv().await? {
|
||||
ControlPacket::ServerHello {
|
||||
version: v,
|
||||
udp_port,
|
||||
auth,
|
||||
} => {
|
||||
if v != config.version {
|
||||
return Err(anyhow!("Server version mismatch"));
|
||||
}
|
||||
if auth != config.client_auth {
|
||||
return Err(anyhow!("Invalid server auth"));
|
||||
}
|
||||
udp_port
|
||||
}
|
||||
_ => return Err(anyhow!("Expected ServerHello")),
|
||||
};
|
||||
|
||||
let server_audio_addr = SocketAddr::new(server_addr.ip(), server_udp_port);
|
||||
Ok(HandshakeResult { server_audio_addr })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod client;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,231 @@
|
||||
//! # AudioBus - 音频总线
|
||||
//!
|
||||
//! 核心的音频路由模块,采用发布-订阅模式处理多客户端音频流。
|
||||
//!
|
||||
//! ## 设计理念
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────────────────────────────────┐
|
||||
//! │ AudioBus │
|
||||
//! │ │
|
||||
//! Client A ──UDP──▶│ ┌─────────┐ ┌──────────────────┐ │
|
||||
//! │ │ Ingress │──────▶ BroadcastChannel │ │──▶ Client B, C...
|
||||
//! Client B ──UDP──▶│ │ Router │ └──────────────────┘ │
|
||||
//! │ └─────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ ┌─────────────┐ │
|
||||
//! │ │ Recorder │──▶ WAV File │
|
||||
//! │ └─────────────┘ │
|
||||
//! │ │
|
||||
//! │ ┌─────────────┐ │
|
||||
//! │ │ Playback │◀── WAV/MP3 File │──▶ Client X
|
||||
//! │ └─────────────┘ │
|
||||
//! └──────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## 核心概念
|
||||
//!
|
||||
//! - **Subscriber**: 订阅者,可以是客户端或录音器
|
||||
//! - **Publisher**: 发布者,可以是客户端麦克风或文件播放器
|
||||
//! - **Channel**: 频道,用于隔离不同的音频流组
|
||||
|
||||
use crate::net::network::AudioSocket;
|
||||
use crate::net::protocol::AudioPacket;
|
||||
use dashmap::DashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// 订阅者 ID
|
||||
pub type SubscriberId = u64;
|
||||
|
||||
/// 音频包及其来源
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioFrame {
|
||||
/// 音频数据包
|
||||
pub packet: AudioPacket,
|
||||
/// 发送者地址(如果来自网络)
|
||||
pub source: Option<SocketAddr>,
|
||||
/// 时间戳(单调递增)
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// 订阅者信息
|
||||
#[derive(Clone)]
|
||||
pub struct Subscriber {
|
||||
pub id: SubscriberId,
|
||||
/// UDP 目标地址
|
||||
pub addr: SocketAddr,
|
||||
/// 是否过滤自己的音频(防止回声)
|
||||
pub filter_self: bool,
|
||||
}
|
||||
|
||||
/// 音频总线 - 负责音频流的路由和分发
|
||||
pub struct AudioBus {
|
||||
/// UDP socket 用于收发音频
|
||||
socket: Arc<AudioSocket>,
|
||||
|
||||
/// 广播通道 - 发布订阅模式的核心
|
||||
/// 所有接收到的音频都会广播到这个 channel
|
||||
broadcast_tx: broadcast::Sender<AudioFrame>,
|
||||
|
||||
/// 订阅者注册表
|
||||
/// key: SocketAddr (UDP 地址)
|
||||
/// value: Subscriber
|
||||
subscribers: DashMap<SocketAddr, Subscriber>,
|
||||
|
||||
/// 地址到订阅者 ID 的反向映射
|
||||
addr_to_id: DashMap<SocketAddr, SubscriberId>,
|
||||
|
||||
/// ID 生成器
|
||||
next_id: AtomicU64,
|
||||
|
||||
/// 全局时间戳
|
||||
timestamp: AtomicU64,
|
||||
}
|
||||
|
||||
impl AudioBus {
|
||||
/// 创建新的音频总线
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
let socket = Arc::new(AudioSocket::bind().await?);
|
||||
// 广播 channel 容量,设置较大以容纳多个订阅者
|
||||
let (broadcast_tx, _) = broadcast::channel(256);
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
broadcast_tx,
|
||||
subscribers: DashMap::new(),
|
||||
addr_to_id: DashMap::new(),
|
||||
next_id: AtomicU64::new(1),
|
||||
timestamp: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 UDP 端口
|
||||
pub fn port(&self) -> u16 {
|
||||
self.socket.port()
|
||||
}
|
||||
|
||||
/// 获取 socket 引用(用于外部发送)
|
||||
pub fn socket(&self) -> Arc<AudioSocket> {
|
||||
self.socket.clone()
|
||||
}
|
||||
|
||||
/// 注册一个订阅者
|
||||
pub fn register(&self, addr: SocketAddr, filter_self: bool) -> SubscriberId {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let subscriber = Subscriber {
|
||||
id,
|
||||
addr,
|
||||
filter_self,
|
||||
};
|
||||
self.subscribers.insert(addr, subscriber);
|
||||
self.addr_to_id.insert(addr, id);
|
||||
println!("[AudioBus] Subscriber {id} registered at {addr}");
|
||||
id
|
||||
}
|
||||
|
||||
/// 注销订阅者
|
||||
pub fn unregister(&self, addr: &SocketAddr) {
|
||||
if let Some((_, sub)) = self.subscribers.remove(addr) {
|
||||
self.addr_to_id.remove(addr);
|
||||
println!("[AudioBus] Subscriber {} unregistered", sub.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅广播频道,返回一个 Receiver
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||
self.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
/// 发布音频帧到总线
|
||||
pub fn publish(&self, packet: AudioPacket, source: Option<SocketAddr>) {
|
||||
let ts = self.timestamp.fetch_add(1, Ordering::SeqCst);
|
||||
let frame = AudioFrame {
|
||||
packet,
|
||||
source,
|
||||
timestamp: ts,
|
||||
};
|
||||
// 忽略没有订阅者的情况
|
||||
let _ = self.broadcast_tx.send(frame);
|
||||
}
|
||||
|
||||
/// 广播音频帧到所有订阅者(除了发送者自己)
|
||||
pub async fn broadcast(&self, frame: &AudioFrame) {
|
||||
for entry in self.subscribers.iter() {
|
||||
let sub = entry.value();
|
||||
|
||||
// 如果启用了自过滤,跳过发送者自己
|
||||
if sub.filter_self {
|
||||
if let Some(src) = &frame.source {
|
||||
if src == &sub.addr {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送音频包
|
||||
if let Err(e) = self.socket.send(&frame.packet, sub.addr).await {
|
||||
eprintln!("[AudioBus] Failed to send to {}: {}", sub.addr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送音频包到指定地址
|
||||
pub async fn send_to(&self, packet: &AudioPacket, addr: SocketAddr) -> anyhow::Result<()> {
|
||||
self.socket.send(packet, addr).await
|
||||
}
|
||||
|
||||
/// 启动 UDP 接收循环
|
||||
/// 这是一个独立的任务,负责:
|
||||
/// 1. 接收 UDP 音频包
|
||||
/// 2. 发布到广播频道
|
||||
pub async fn run_receiver(&self) {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
match self.socket.recv(&mut buf).await {
|
||||
Ok((packet, src_addr)) => {
|
||||
// 只处理已注册的发送者
|
||||
if self.subscribers.contains_key(&src_addr) {
|
||||
self.publish(packet, Some(src_addr));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[AudioBus] Recv error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动广播分发循环
|
||||
/// 订阅广播频道并将音频转发给所有订阅者
|
||||
pub async fn run_broadcaster(self: Arc<Self>) {
|
||||
let mut rx = self.subscribe();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
self.broadcast(&frame).await;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("[AudioBus] Broadcaster lagged {} frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前订阅者数量
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.subscribers.len()
|
||||
}
|
||||
|
||||
/// 检查地址是否已注册
|
||||
pub fn is_registered(&self, addr: &SocketAddr) -> bool {
|
||||
self.subscribers.contains_key(addr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! # Server 模块
|
||||
//!
|
||||
//! 实时音频流服务器,支持:
|
||||
//! - 多客户端连接管理
|
||||
//! - 音频流发布-订阅
|
||||
//! - 录音和播放
|
||||
//! - RPC 远程调用
|
||||
//! - 实时事件推送
|
||||
//!
|
||||
//! ## 架构
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────┐
|
||||
//! │ Server │
|
||||
//! │ │
|
||||
//! Client 1 ──TCP────────┼──▶ SessionManager │
|
||||
//! Client 2 ──TCP────────┼──▶ ├─ Session 1 │
|
||||
//! Client N ──TCP────────┼──▶ ├─ Session 2 │
|
||||
//! │ └─ Session N │
|
||||
//! │ │ │
|
||||
//! ┌────────────────┼────────────┴────────────────────────┐ │
|
||||
//! │ │ │ │
|
||||
//! ▼ │ ▼ │
|
||||
//! ┌─────────┐ │ ┌─────────────────────────────┐ │
|
||||
//! │Command │ │ │ AudioBus │ │
|
||||
//! │Handler │ │ │ │ │
|
||||
//! └─────────┘ │ │ ┌─────────┐ ┌──────────┐ │ │
|
||||
//! │ │ │ │Receiver │ │Broadcaster│ │ │
|
||||
//! ▼ │ │ │ Loop │─▶│ Loop │──┼───┼──▶ All Clients
|
||||
//! ┌─────────┐ │ │ └─────────┘ └──────────┘ │ │
|
||||
//! │ Event │ │ └─────────────────────────────┘ │
|
||||
//! │ Bus │───────────┼─────────────────────────────────────────────┼──▶ Events
|
||||
//! └─────────┘ │ │
|
||||
//! └─────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
mod audio_bus;
|
||||
mod session;
|
||||
mod stream;
|
||||
|
||||
pub use audio_bus::{AudioBus, AudioFrame};
|
||||
pub use session::{Session, SessionManager};
|
||||
pub use stream::{FilePlaybackStream, RecorderStream, StreamHandle};
|
||||
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::audio::wav::WavReader;
|
||||
use crate::net::command::CommandResult;
|
||||
use crate::net::discovery::Discovery;
|
||||
use crate::net::event::{EventBus, EventBusSubscription, EventData};
|
||||
use crate::net::network::Connection;
|
||||
use crate::net::protocol::ControlPacket;
|
||||
use crate::net::rpc::RpcBuilder;
|
||||
use crate::net::sync::now_us;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// 服务端配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// 版本
|
||||
pub version: String,
|
||||
/// 客户端认证
|
||||
pub client_auth: String,
|
||||
/// 服务端认证
|
||||
pub server_auth: String,
|
||||
/// 连接超时(秒)
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
server_auth: std::env::var("XIAO_SERVER_AUTH")
|
||||
.unwrap_or_else(|_| "xiao-server".to_string()),
|
||||
client_auth: std::env::var("XIAO_CLIENT_AUTH")
|
||||
.unwrap_or_else(|_| "xiao-client".to_string()),
|
||||
timeout: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时音频服务器
|
||||
pub struct Server {
|
||||
/// 配置
|
||||
config: ServerConfig,
|
||||
/// 会话管理器
|
||||
sessions: Arc<SessionManager>,
|
||||
/// 音频总线
|
||||
audio_bus: Arc<AudioBus>,
|
||||
/// 服务端事件总线
|
||||
event_bus: Arc<EventBus>,
|
||||
/// 服务器取消令牌
|
||||
cancel: CancellationToken,
|
||||
/// 服务器启动时间
|
||||
started_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// 创建新服务器
|
||||
pub async fn new(config: ServerConfig) -> Result<Self> {
|
||||
let audio_bus = Arc::new(AudioBus::new().await?);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
sessions: Arc::new(SessionManager::new()),
|
||||
audio_bus,
|
||||
event_bus: Arc::new(EventBus::default()),
|
||||
cancel: CancellationToken::new(),
|
||||
started_at: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 订阅事件
|
||||
pub fn subscribe_events(&self) -> EventBusSubscription {
|
||||
self.event_bus.subscribe()
|
||||
}
|
||||
|
||||
/// 启动服务器
|
||||
pub async fn run(self: Arc<Self>, port: u16) -> Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
|
||||
let addr = listener.local_addr()?;
|
||||
println!("[Server] Listening on TCP: {}", addr);
|
||||
println!("[Server] Audio UDP port: {}", self.audio_bus.port());
|
||||
|
||||
// 广播服务发现
|
||||
Discovery::broadcast(port).await?;
|
||||
|
||||
// 启动音频总线接收循环
|
||||
let bus = self.audio_bus.clone();
|
||||
tokio::spawn(async move {
|
||||
bus.run_receiver().await;
|
||||
});
|
||||
|
||||
// 启动音频总线广播循环
|
||||
let bus = self.audio_bus.clone();
|
||||
tokio::spawn(async move {
|
||||
bus.run_broadcaster().await;
|
||||
});
|
||||
|
||||
// TCP 连接接受循环
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel.cancelled() => {
|
||||
println!("[Server] Shutting down...");
|
||||
break;
|
||||
}
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, addr)) => {
|
||||
let server = self.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = server.handle_connection(stream, addr).await {
|
||||
eprintln!("[Server] Connection {} error: {}", addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Server] Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理新连接
|
||||
async fn handle_connection(
|
||||
self: Arc<Self>,
|
||||
stream: tokio::net::TcpStream,
|
||||
addr: SocketAddr,
|
||||
) -> Result<()> {
|
||||
println!("[Server] New connection from {}", addr);
|
||||
let conn = Arc::new(Connection::new(stream)?);
|
||||
|
||||
// --- 握手 ---
|
||||
let (info, audio_addr) = self.handshake(&conn, addr).await?;
|
||||
println!(
|
||||
"[Server] Client identified: {} ({}) audio: {}",
|
||||
info.model, info.serial_number, audio_addr
|
||||
);
|
||||
|
||||
// --- 创建 Session ---
|
||||
let session_cancel = self.cancel.child_token();
|
||||
let session = Arc::new(Session::new(
|
||||
info.clone(),
|
||||
conn.clone(),
|
||||
addr,
|
||||
audio_addr,
|
||||
session_cancel.clone(),
|
||||
));
|
||||
|
||||
// 注册到 SessionManager 和 AudioBus
|
||||
self.sessions.register(session.clone());
|
||||
self.audio_bus.register(audio_addr, true);
|
||||
|
||||
// --- 主循环 ---
|
||||
let result = self.session_loop(session.clone()).await;
|
||||
|
||||
// --- 清理 ---
|
||||
self.audio_bus.unregister(&audio_addr);
|
||||
self.sessions.unregister(&addr);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 握手流程
|
||||
async fn handshake(
|
||||
&self,
|
||||
conn: &Arc<Connection>,
|
||||
addr: SocketAddr,
|
||||
) -> Result<(crate::net::protocol::ClientInfo, SocketAddr)> {
|
||||
// 等待客户端 Hello
|
||||
let (info, client_audio_port) = match conn.recv().await? {
|
||||
ControlPacket::ClientHello {
|
||||
auth,
|
||||
version: v,
|
||||
udp_port,
|
||||
info,
|
||||
} => {
|
||||
if v != self.config.version {
|
||||
return Err(anyhow!("Client version mismatch"));
|
||||
}
|
||||
if auth != self.config.server_auth {
|
||||
return Err(anyhow!("Invalid client auth"));
|
||||
}
|
||||
(info, udp_port)
|
||||
}
|
||||
_ => return Err(anyhow!("Expected ClientHello")),
|
||||
};
|
||||
|
||||
// 发送服务器 Hello
|
||||
conn.send(&ControlPacket::ServerHello {
|
||||
auth: self.config.client_auth.clone(),
|
||||
version: self.config.version.clone(),
|
||||
udp_port: self.audio_bus.port(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let audio_addr = SocketAddr::new(addr.ip(), client_audio_port);
|
||||
Ok((info, audio_addr))
|
||||
}
|
||||
|
||||
/// Session 消息循环
|
||||
async fn session_loop(&self, session: Arc<Session>) -> Result<()> {
|
||||
let timeout = std::time::Duration::from_secs(self.config.timeout);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = session.cancel.cancelled() => break,
|
||||
result = tokio::time::timeout(timeout, session.recv()) => {
|
||||
match result {
|
||||
Ok(Ok(packet)) => {
|
||||
self.handle_packet(&session, packet).await?;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(anyhow!("Connection error: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow!("Connection timeout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理控制包
|
||||
async fn handle_packet(&self, session: &Arc<Session>, packet: ControlPacket) -> Result<()> {
|
||||
match packet {
|
||||
ControlPacket::Ping { client_ts, seq } => {
|
||||
let pong = ControlPacket::Pong {
|
||||
client_ts,
|
||||
server_ts: now_us(),
|
||||
seq,
|
||||
};
|
||||
session.send(&pong).await?;
|
||||
}
|
||||
ControlPacket::Event { timestamp, data } => {
|
||||
self.event_bus.receive(data, timestamp, session.id());
|
||||
}
|
||||
ControlPacket::RpcResponse { id, result } => {
|
||||
session.resolve_rpc(id, result);
|
||||
}
|
||||
ControlPacket::RpcRequest {
|
||||
id,
|
||||
run_async,
|
||||
timeout,
|
||||
command,
|
||||
} => {
|
||||
let session_clone = session.clone();
|
||||
session
|
||||
.rpc_manager
|
||||
.handle_rpc_request(id, run_async, timeout, command, move |pck| {
|
||||
let s = session_clone.clone();
|
||||
async move {
|
||||
return s.send(&pck).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== 公开 API ====================
|
||||
|
||||
/// 获取所有已连接的客户端地址
|
||||
pub async fn get_clients(&self) -> Vec<SocketAddr> {
|
||||
self.sessions.all_addrs()
|
||||
}
|
||||
|
||||
/// 获取客户端数量
|
||||
pub fn client_count(&self) -> usize {
|
||||
self.sessions.count()
|
||||
}
|
||||
|
||||
/// 获取服务器运行时间
|
||||
pub fn uptime_secs(&self) -> u64 {
|
||||
self.started_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
/// 向客户端发起 RPC
|
||||
pub async fn rpc(&self, addr: SocketAddr, builder: &RpcBuilder) -> Result<CommandResult> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
session.rpc(builder).await
|
||||
}
|
||||
|
||||
/// 发送事件
|
||||
pub async fn send_event(&self, addr: SocketAddr, event: EventData) -> Result<()> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
session
|
||||
.send(&ControlPacket::Event {
|
||||
timestamp: now_us(),
|
||||
data: event,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开始录音
|
||||
pub async fn start_record(&self, addr: SocketAddr, config: AudioConfig) -> Result<()> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
|
||||
let filename = format!(
|
||||
"temp/recorded_{}.wav",
|
||||
session.info.serial_number.replace(":", "")
|
||||
);
|
||||
|
||||
// 创建录音流,订阅音频总线
|
||||
let handle = RecorderStream::spawn(
|
||||
config.clone(),
|
||||
filename.clone(),
|
||||
self.audio_bus.subscribe(),
|
||||
Some(session.audio_addr),
|
||||
session.cancel.clone(),
|
||||
);
|
||||
|
||||
session.start_recording(handle, config.clone());
|
||||
|
||||
// 通知客户端开始发送音频
|
||||
session
|
||||
.send(&ControlPacket::StartRecording { config })
|
||||
.await?;
|
||||
|
||||
println!("[Server] Recording started for {} -> {}", addr, filename);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止录音
|
||||
pub async fn stop_record(&self, addr: SocketAddr) -> Result<()> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
session.stop_recording();
|
||||
session.send(&ControlPacket::StopRecording).await?;
|
||||
|
||||
println!("[Server] Recording stopped for {}", addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开始播放
|
||||
pub async fn start_play(&self, addr: SocketAddr, file_path: &str) -> Result<()> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
|
||||
let reader = WavReader::open(file_path)?;
|
||||
|
||||
// 通知客户端准备接收音频
|
||||
session
|
||||
.send(&ControlPacket::StartPlayback {
|
||||
config: reader.config.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// 创建播放流
|
||||
let handle = FilePlaybackStream::spawn(
|
||||
reader,
|
||||
self.audio_bus.socket(),
|
||||
session.audio_addr,
|
||||
session.cancel.clone(),
|
||||
);
|
||||
|
||||
session.start_playback(handle);
|
||||
|
||||
println!("[Server] Playback started for {} from {}", addr, file_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止播放
|
||||
pub async fn stop_play(&self, addr: SocketAddr) -> Result<()> {
|
||||
let session = self.sessions.get(&addr).context("Session not found")?;
|
||||
session.stop_playback();
|
||||
session.send(&ControlPacket::StopPlayback).await?;
|
||||
|
||||
println!("[Server] Playback stopped for {}", addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 关闭服务器
|
||||
pub fn shutdown(&self) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! # Session - 客户端会话管理
|
||||
//!
|
||||
//! 轻量级的会话结构,专注于:
|
||||
//! - TCP 控制连接
|
||||
//! - RPC 管理
|
||||
//! - 会话生命周期
|
||||
//!
|
||||
//! 音频流的实际处理由 AudioBus 和 Stream 模块负责。
|
||||
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::net::command::CommandResult;
|
||||
use crate::net::network::Connection;
|
||||
use crate::net::protocol::{ClientInfo, ControlPacket};
|
||||
use crate::net::rpc::{RpcBuilder, RpcManager};
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::stream::StreamHandle;
|
||||
|
||||
/// 活动流追踪
|
||||
/// 用于追踪当前 session 的活动音频流
|
||||
pub struct ActiveStreams {
|
||||
/// 当前录音流句柄
|
||||
pub recorder: Option<StreamHandle>,
|
||||
/// 当前播放流句柄
|
||||
pub playback: Option<StreamHandle>,
|
||||
}
|
||||
|
||||
impl Default for ActiveStreams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
recorder: None,
|
||||
playback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveStreams {
|
||||
/// 停止所有活动流
|
||||
pub fn stop_all(&mut self) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
if let Some(h) = self.playback.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始录音(停止之前的录音)
|
||||
pub fn start_recording(&mut self, handle: StreamHandle) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
self.recorder = Some(handle);
|
||||
}
|
||||
|
||||
/// 停止录音
|
||||
pub fn stop_recording(&mut self) {
|
||||
if let Some(h) = self.recorder.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始播放(停止之前的播放)
|
||||
pub fn start_playback(&mut self, handle: StreamHandle) {
|
||||
if let Some(h) = self.playback.take() {
|
||||
h.stop();
|
||||
}
|
||||
self.playback = Some(handle);
|
||||
}
|
||||
|
||||
/// 停止播放
|
||||
pub fn stop_playback(&mut self) {
|
||||
if let Some(h) = self.playback.take() {
|
||||
h.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在录音
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recorder.is_some()
|
||||
}
|
||||
|
||||
/// 检查是否正在播放
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.playback.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端会话
|
||||
pub struct Session {
|
||||
/// 客户端信息
|
||||
pub info: ClientInfo,
|
||||
|
||||
/// TCP 控制连接
|
||||
pub conn: Arc<Connection>,
|
||||
|
||||
/// RPC 管理器
|
||||
pub rpc_manager: Arc<RpcManager>,
|
||||
|
||||
/// TCP 地址(用作会话 ID)
|
||||
pub tcp_addr: SocketAddr,
|
||||
|
||||
/// UDP 音频地址
|
||||
pub audio_addr: SocketAddr,
|
||||
|
||||
/// 会话取消令牌
|
||||
pub cancel: CancellationToken,
|
||||
|
||||
/// 活动流
|
||||
streams: parking_lot::Mutex<ActiveStreams>,
|
||||
|
||||
/// 当前录音配置(如果正在录音)
|
||||
recording_config: parking_lot::Mutex<Option<AudioConfig>>,
|
||||
|
||||
/// 会话创建时间
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// 创建新会话
|
||||
pub fn new(
|
||||
info: ClientInfo,
|
||||
conn: Arc<Connection>,
|
||||
tcp_addr: SocketAddr,
|
||||
audio_addr: SocketAddr,
|
||||
cancel: CancellationToken,
|
||||
) -> Self {
|
||||
Self {
|
||||
info,
|
||||
conn,
|
||||
rpc_manager: Arc::new(RpcManager::new()),
|
||||
tcp_addr,
|
||||
audio_addr,
|
||||
cancel,
|
||||
streams: parking_lot::Mutex::new(ActiveStreams::default()),
|
||||
recording_config: parking_lot::Mutex::new(None),
|
||||
created_at: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取会话 ID(使用 TCP 地址)
|
||||
pub fn id(&self) -> SocketAddr {
|
||||
self.tcp_addr
|
||||
}
|
||||
|
||||
/// 检查会话是否仍然有效
|
||||
pub fn is_alive(&self) -> bool {
|
||||
!self.cancel.is_cancelled()
|
||||
}
|
||||
|
||||
/// 获取会话运行时间(秒)
|
||||
pub fn uptime_secs(&self) -> u64 {
|
||||
self.created_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
/// 发送控制包
|
||||
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
|
||||
self.conn.send(packet).await
|
||||
}
|
||||
|
||||
/// 接收控制包
|
||||
pub async fn recv(&self) -> Result<ControlPacket> {
|
||||
self.conn.recv().await
|
||||
}
|
||||
|
||||
/// 发起 RPC 调用(支持超时和异步控制)
|
||||
pub async fn rpc(&self, request: &RpcBuilder) -> Result<CommandResult> {
|
||||
let result = self
|
||||
.rpc_manager
|
||||
.call(request, |pck| async move {
|
||||
return self.conn.send(&pck).await;
|
||||
})
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 处理 RPC 响应
|
||||
pub fn resolve_rpc(&self, id: u32, result: CommandResult) {
|
||||
self.rpc_manager.resolve(id, result);
|
||||
}
|
||||
|
||||
/// 开始录音流
|
||||
pub fn start_recording(&self, handle: StreamHandle, config: AudioConfig) {
|
||||
self.streams.lock().start_recording(handle);
|
||||
*self.recording_config.lock() = Some(config);
|
||||
}
|
||||
|
||||
/// 停止录音流
|
||||
pub fn stop_recording(&self) {
|
||||
self.streams.lock().stop_recording();
|
||||
*self.recording_config.lock() = None;
|
||||
}
|
||||
|
||||
/// 开始播放流
|
||||
pub fn start_playback(&self, handle: StreamHandle) {
|
||||
self.streams.lock().start_playback(handle);
|
||||
}
|
||||
|
||||
/// 停止播放流
|
||||
pub fn stop_playback(&self) {
|
||||
self.streams.lock().stop_playback();
|
||||
}
|
||||
|
||||
/// 检查是否正在录音
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.streams.lock().is_recording()
|
||||
}
|
||||
|
||||
/// 检查是否正在播放
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.streams.lock().is_playing()
|
||||
}
|
||||
|
||||
/// 清理所有资源
|
||||
pub fn cleanup(&self) {
|
||||
self.cancel.cancel();
|
||||
self.streams.lock().stop_all();
|
||||
}
|
||||
|
||||
/// 获取当前录音配置
|
||||
pub fn recording_config(&self) -> Option<AudioConfig> {
|
||||
self.recording_config.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话管理器
|
||||
/// 负责管理所有客户端会话
|
||||
pub struct SessionManager {
|
||||
sessions: dashmap::DashMap<SocketAddr, Arc<Session>>,
|
||||
/// 从 UDP 地址到 TCP 地址的映射
|
||||
udp_to_tcp: dashmap::DashMap<SocketAddr, SocketAddr>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: dashmap::DashMap::new(),
|
||||
udp_to_tcp: dashmap::DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册新会话
|
||||
pub fn register(&self, session: Arc<Session>) {
|
||||
let tcp_addr = session.tcp_addr;
|
||||
let audio_addr = session.audio_addr;
|
||||
|
||||
self.udp_to_tcp.insert(audio_addr, tcp_addr);
|
||||
self.sessions.insert(tcp_addr, session);
|
||||
|
||||
println!(
|
||||
"[SessionManager] Registered: {} (audio: {})",
|
||||
tcp_addr, audio_addr
|
||||
);
|
||||
}
|
||||
|
||||
/// 注销会话
|
||||
pub fn unregister(&self, tcp_addr: &SocketAddr) -> Option<Arc<Session>> {
|
||||
if let Some((_, session)) = self.sessions.remove(tcp_addr) {
|
||||
self.udp_to_tcp.remove(&session.audio_addr);
|
||||
session.cleanup();
|
||||
println!(
|
||||
"[SessionManager] Unregistered: {} ({})",
|
||||
tcp_addr, session.info.model
|
||||
);
|
||||
Some(session)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 TCP 地址获取会话
|
||||
pub fn get(&self, tcp_addr: &SocketAddr) -> Option<Arc<Session>> {
|
||||
self.sessions.get(tcp_addr).map(|r| r.value().clone())
|
||||
}
|
||||
|
||||
/// 通过 UDP 地址获取会话
|
||||
pub fn get_by_udp(&self, udp_addr: &SocketAddr) -> Option<Arc<Session>> {
|
||||
self.udp_to_tcp
|
||||
.get(udp_addr)
|
||||
.and_then(|tcp_addr| self.get(tcp_addr.value()))
|
||||
}
|
||||
|
||||
/// 获取所有会话地址
|
||||
pub fn all_addrs(&self) -> Vec<SocketAddr> {
|
||||
self.sessions.iter().map(|r| *r.key()).collect()
|
||||
}
|
||||
|
||||
/// 获取所有会话
|
||||
pub fn all_sessions(&self) -> Vec<Arc<Session>> {
|
||||
self.sessions.iter().map(|r| r.value().clone()).collect()
|
||||
}
|
||||
|
||||
/// 获取会话数量
|
||||
pub fn count(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
/// 广播控制包到所有会话
|
||||
pub async fn broadcast(&self, packet: &ControlPacket) {
|
||||
for entry in self.sessions.iter() {
|
||||
let _ = entry.value().send(packet).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 广播控制包到所有会话(除了指定的)
|
||||
pub async fn broadcast_except(&self, packet: &ControlPacket, except: &SocketAddr) {
|
||||
for entry in self.sessions.iter() {
|
||||
if entry.key() != except {
|
||||
let _ = entry.value().send(packet).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! # AudioStream - 音频流抽象
|
||||
//!
|
||||
//! 提供统一的音频流处理接口,支持多种输入源和输出目标。
|
||||
//!
|
||||
//! ## 设计
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────┐
|
||||
//! │ Stream Types │
|
||||
//! │ │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ FileSource │ │ BusSource │ │ NetworkSink │ │
|
||||
//! │ │ (WAV Reader) │ │ (From Bus) │ │ (To Client) │ │
|
||||
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
//! │ │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ FileSink │ │ BusSink │ │
|
||||
//! │ │ (WAV Writer) │ │ (To Bus) │ │
|
||||
//! │ └──────────────┘ └──────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
use crate::audio::codec::OpusCodec;
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::audio::wav::{WavReader, WavWriter};
|
||||
use crate::net::network::AudioSocket;
|
||||
use crate::net::protocol::AudioPacket;
|
||||
use crate::net::sync::now_us;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::audio_bus::AudioFrame;
|
||||
|
||||
/// 音频流任务句柄
|
||||
/// 用于控制正在运行的音频流任务
|
||||
pub struct StreamHandle {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl StreamHandle {
|
||||
pub fn new(cancel: CancellationToken) -> Self {
|
||||
Self { cancel }
|
||||
}
|
||||
|
||||
/// 停止流
|
||||
pub fn stop(&self) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
|
||||
/// 检查是否已停止
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamHandle {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 音频流发送控制器
|
||||
pub struct StreamSender {
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
codec: OpusCodec,
|
||||
|
||||
// 缓存区:避免重复分配内存
|
||||
opus_buffer: Vec<u8>,
|
||||
|
||||
// 状态变量
|
||||
seq: u32,
|
||||
stream_start_ts: Option<u128>,
|
||||
|
||||
// 配置参数
|
||||
input_len: usize,
|
||||
frame_duration_us: u128,
|
||||
max_lead_us: u128, // 允许的最大超前时间,例如 500_000 (1s)
|
||||
}
|
||||
|
||||
impl StreamSender {
|
||||
pub fn new(
|
||||
config: AudioConfig,
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
) -> anyhow::Result<Self> {
|
||||
let codec = OpusCodec::new(&config)?;
|
||||
let input_len: usize = config.frame_size * (config.channels as usize);
|
||||
|
||||
// 预分配缓冲区
|
||||
let opus_buffer = vec![0u8; 4096];
|
||||
|
||||
let frame_duration_us =
|
||||
(config.frame_size as f64 / config.sample_rate as f64 * 1_000_000.0) as u128;
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
target,
|
||||
codec,
|
||||
opus_buffer,
|
||||
seq: 0,
|
||||
stream_start_ts: None,
|
||||
input_len,
|
||||
frame_duration_us,
|
||||
max_lead_us: 500_000,
|
||||
})
|
||||
}
|
||||
|
||||
/// 发送音频帧
|
||||
pub async fn send(&mut self, pcm_input: &[i16]) -> anyhow::Result<()> {
|
||||
// 0. 处理输入(不足时填充静音)
|
||||
let pcm_buffer = if pcm_input.len() < self.input_len {
|
||||
let mut pcm_buffer = vec![0i16; self.input_len];
|
||||
pcm_buffer[..pcm_input.len()].copy_from_slice(pcm_input);
|
||||
pcm_buffer
|
||||
} else {
|
||||
pcm_input.to_vec()
|
||||
};
|
||||
|
||||
// 1. 编码
|
||||
let encoded_len = self.codec.encode(&pcm_buffer, &mut self.opus_buffer)?;
|
||||
|
||||
// 2. 时间戳处理
|
||||
let now = now_us();
|
||||
let start_ts = *self.stream_start_ts.get_or_insert(now);
|
||||
let target_ts = start_ts + (self.seq as u128 * self.frame_duration_us);
|
||||
|
||||
// 3. 构建并发送
|
||||
let packet = AudioPacket {
|
||||
seq: self.seq,
|
||||
timestamp: target_ts,
|
||||
data: self.opus_buffer[..encoded_len].to_vec(),
|
||||
};
|
||||
|
||||
self.socket.send(&packet, self.target).await?;
|
||||
self.seq += 1;
|
||||
|
||||
// 4. 平滑流控
|
||||
// 如果发送进度超过当前时间 + 允许的缓冲量,则进行睡眠
|
||||
if target_ts > now + self.max_lead_us {
|
||||
let drift = target_ts - (now + self.max_lead_us);
|
||||
let sleep_ms = (drift / 1000).min(100) as u64;
|
||||
if sleep_ms > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件播放流 - 从 WAV 文件读取并发送到指定客户端
|
||||
pub struct FilePlaybackStream;
|
||||
|
||||
impl FilePlaybackStream {
|
||||
/// 启动文件播放流
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 音频配置(用于 Opus 编码)
|
||||
/// * `reader` - WAV 文件读取器
|
||||
/// * `socket` - UDP socket
|
||||
/// * `target` - 目标客户端地址
|
||||
/// * `parent_cancel` - 父级取消令牌(用于 session 级别取消)
|
||||
pub fn spawn(
|
||||
reader: WavReader,
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
parent_cancel: CancellationToken,
|
||||
) -> StreamHandle {
|
||||
let cancel = parent_cancel.child_token();
|
||||
let token = cancel.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run(reader, socket, target, token).await {
|
||||
eprintln!("[FilePlayback] Error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
StreamHandle::new(cancel)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut reader: WavReader,
|
||||
socket: Arc<AudioSocket>,
|
||||
target: SocketAddr,
|
||||
cancel: CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
println!("[FilePlayback] Started -> {}", target);
|
||||
|
||||
let mut sender = StreamSender::new(reader.config.clone(), socket, target)?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
result = async {
|
||||
// 读取一帧 PCM 数据
|
||||
if let Some(pcm) = reader.read_one_frame()? {
|
||||
// 编码并发送
|
||||
sender.send(pcm).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("EOF"))
|
||||
}
|
||||
} => {
|
||||
if let Err(e) = result {
|
||||
if e.to_string() != "EOF" {
|
||||
eprintln!("[FilePlayback] Error: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[FilePlayback] Stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 录音流 - 从总线订阅音频并写入 WAV 文件
|
||||
pub struct RecorderStream;
|
||||
|
||||
impl RecorderStream {
|
||||
/// 启动录音流
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 音频配置
|
||||
/// * `filename` - 输出文件路径
|
||||
/// * `bus_rx` - 音频总线接收器
|
||||
/// * `source_filter` - 仅录制来自此地址的音频(None 表示全部录制)
|
||||
/// * `parent_cancel` - 父级取消令牌
|
||||
pub fn spawn(
|
||||
config: AudioConfig,
|
||||
filename: String,
|
||||
bus_rx: broadcast::Receiver<AudioFrame>,
|
||||
source_filter: Option<SocketAddr>,
|
||||
parent_cancel: CancellationToken,
|
||||
) -> StreamHandle {
|
||||
let cancel = parent_cancel.child_token();
|
||||
let token = cancel.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run(config, filename, bus_rx, source_filter, token).await {
|
||||
eprintln!("[Recorder] Error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
StreamHandle::new(cancel)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: AudioConfig,
|
||||
filename: String,
|
||||
mut bus_rx: broadcast::Receiver<AudioFrame>,
|
||||
source_filter: Option<SocketAddr>,
|
||||
cancel: CancellationToken,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut writer = WavWriter::create(&filename, config.sample_rate, config.channels)?;
|
||||
let mut codec = OpusCodec::new(&config)?;
|
||||
let mut pcm = vec![0i16; config.frame_size * config.channels as usize];
|
||||
|
||||
println!(
|
||||
"[Recorder] Started -> {} (filter: {:?})",
|
||||
filename, source_filter
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
result = bus_rx.recv() => {
|
||||
match result {
|
||||
Ok(frame) => {
|
||||
// 应用源过滤
|
||||
if let Some(filter_addr) = &source_filter {
|
||||
if frame.source.as_ref() != Some(filter_addr) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码并写入
|
||||
if let Ok(samples_per_channel) = codec.decode(&frame.packet.data, &mut pcm) {
|
||||
let total_samples = samples_per_channel * config.channels as usize;
|
||||
let _ = writer.write_samples(&pcm[..total_samples])?;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("[Recorder] Lagged {} frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保正确关闭文件
|
||||
writer.finalize()?;
|
||||
println!("[Recorder] Stopped, file saved: {}", filename);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::audio::config::{AudioConfig, AudioScene};
|
||||
use anyhow::{Context, Result};
|
||||
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
|
||||
|
||||
pub struct OpusCodec {
|
||||
encoder: Encoder,
|
||||
decoder: Decoder,
|
||||
}
|
||||
|
||||
impl OpusCodec {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
let opus_rate = match config.sample_rate {
|
||||
16000 => 16000,
|
||||
48000 => 48000,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unsupported sample rate for Opus: {}",
|
||||
config.sample_rate
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let channels = match config.channels {
|
||||
1 => Channels::Mono,
|
||||
2 => Channels::Stereo,
|
||||
_ => return Err(anyhow::anyhow!("Unsupported channels: {}", config.channels)),
|
||||
};
|
||||
|
||||
let mode = match config.audio_scene {
|
||||
AudioScene::Music => Application::Audio,
|
||||
AudioScene::Voice => Application::Voip,
|
||||
};
|
||||
|
||||
let mut encoder =
|
||||
Encoder::new(opus_rate, channels, mode).context("Opus encoder init failed")?;
|
||||
|
||||
let bitrate = if config.bitrate <= 0 {
|
||||
Bitrate::Auto
|
||||
} else {
|
||||
Bitrate::Bits(config.bitrate)
|
||||
};
|
||||
|
||||
encoder.set_bitrate(bitrate)?;
|
||||
encoder.set_vbr(config.vbr)?;
|
||||
if config.fec {
|
||||
encoder.set_inband_fec(true)?;
|
||||
encoder.set_packet_loss_perc(10)?;
|
||||
}
|
||||
|
||||
let decoder =
|
||||
Decoder::new(config.sample_rate, channels).context("Opus decoder init failed")?;
|
||||
|
||||
Ok(Self { encoder, decoder })
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize> {
|
||||
self.encoder.encode(pcm, out).context("Opus encode failed")
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, opus: &[u8], out: &mut [i16]) -> Result<usize> {
|
||||
self.decoder
|
||||
.decode(opus, out, false)
|
||||
.context("Opus decode failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AudioScene {
|
||||
Music,
|
||||
Voice,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioConfig {
|
||||
pub capture_device: String,
|
||||
pub playback_device: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub frame_size: usize,
|
||||
pub audio_scene: AudioScene,
|
||||
pub bitrate: i32,
|
||||
pub vbr: bool,
|
||||
pub fec: bool,
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
pub fn voice_16k() -> Self {
|
||||
Self {
|
||||
capture_device: "plug:Capture".to_string(),
|
||||
playback_device: "default".to_string(),
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
frame_size: 320, // 20ms
|
||||
audio_scene: AudioScene::Voice,
|
||||
bitrate: 32_000,
|
||||
vbr: true,
|
||||
fec: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn music_48k() -> Self {
|
||||
Self {
|
||||
capture_device: "plug:Capture".to_string(),
|
||||
playback_device: "default".to_string(),
|
||||
sample_rate: 48_000,
|
||||
channels: 2,
|
||||
frame_size: 960, // 20ms
|
||||
audio_scene: AudioScene::Music,
|
||||
bitrate: 320_000,
|
||||
vbr: true,
|
||||
fec: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self::voice_16k()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
pub mod player;
|
||||
pub mod recorder;
|
||||
pub mod wav;
|
||||
pub mod reader;
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::audio::config::AudioConfig;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
pub struct AudioPlayer {
|
||||
#[cfg(target_os = "linux")]
|
||||
pcm: alsa::pcm::PCM,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use alsa::Direction;
|
||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||
|
||||
let pcm = PCM::new(&config.playback_device, Direction::Playback, false)
|
||||
.context("Failed to open playback PCM device")?;
|
||||
{
|
||||
let hwp = HwParams::any(&pcm).context("Failed to get HwParams")?;
|
||||
hwp.set_access(Access::RWInterleaved)?;
|
||||
hwp.set_format(Format::s16())?;
|
||||
hwp.set_rate(config.sample_rate, alsa::ValueOr::Nearest)?;
|
||||
hwp.set_channels(config.channels as u32)?;
|
||||
pcm.hw_params(&hwp)?;
|
||||
pcm.prepare().context("Failed to prepare PCM")?;
|
||||
}
|
||||
Ok(Self { pcm })
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err(anyhow!("Linux Only"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, buf: &[i16]) -> Result<usize> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let res = self.pcm.io_i16()?.writei(buf);
|
||||
match res {
|
||||
Ok(n) => Ok(n),
|
||||
Err(e) if e.errno() == 32 => {
|
||||
println!("ALSA write underrun, preparing PCM");
|
||||
// Broken pipe (underrun)
|
||||
self.pcm.prepare()?;
|
||||
self.pcm
|
||||
.io_i16()?
|
||||
.writei(buf)
|
||||
.context("ALSA write retry failed")
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err(anyhow!("Linux Only"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#![cfg(not(target_os = "linux"))]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
pub struct AudioReader {
|
||||
format: Box<dyn FormatReader>,
|
||||
decoder: Box<dyn Decoder>,
|
||||
track_id: u32,
|
||||
sample_buf: Option<SampleBuffer<i16>>,
|
||||
left_buffer: Vec<i16>,
|
||||
right_buffer: Vec<i16>,
|
||||
pub channels: usize,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
impl AudioReader {
|
||||
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path_ref = path.as_ref();
|
||||
let src =
|
||||
File::open(path_ref).context(format!("Failed to open audio file: {:?}", path_ref))?;
|
||||
let mss = MediaSourceStream::new(Box::new(src), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path_ref.extension().and_then(|s| s.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.context("Failed to probe audio format")?;
|
||||
|
||||
let format = probed.format;
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||
.context("No supported audio track found")?;
|
||||
|
||||
let track_id = track.id;
|
||||
let decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.context("Failed to create decoder")?;
|
||||
|
||||
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(1);
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||
|
||||
Ok(Self {
|
||||
format,
|
||||
decoder,
|
||||
track_id,
|
||||
sample_buf: None,
|
||||
channels,
|
||||
sample_rate,
|
||||
left_buffer: Vec::new(),
|
||||
right_buffer: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_chunk(&mut self, chunk_size: usize) -> Result<Option<(Vec<i16>, Vec<i16>)>> {
|
||||
while self.left_buffer.len() < chunk_size {
|
||||
if let Some((l, r)) = self.read_frame_internal()? {
|
||||
self.left_buffer.extend(l);
|
||||
self.right_buffer.extend(r);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if self.left_buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let actual_size = std::cmp::min(chunk_size, self.left_buffer.len());
|
||||
let left = self.left_buffer.drain(0..actual_size).collect();
|
||||
let right = self.right_buffer.drain(0..actual_size).collect();
|
||||
|
||||
Ok(Some((left, right)))
|
||||
}
|
||||
|
||||
fn read_frame_internal(&mut self) -> Result<Option<(Vec<i16>, Vec<i16>)>> {
|
||||
loop {
|
||||
let packet = match self.format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(symphonia::core::errors::Error::IoError(ref e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if packet.track_id() != self.track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let decoded = self
|
||||
.decoder
|
||||
.decode(&packet)
|
||||
.context("Failed to decode packet")?;
|
||||
|
||||
if self.sample_buf.is_none() {
|
||||
self.sample_buf = Some(SampleBuffer::<i16>::new(
|
||||
decoded.capacity() as u64,
|
||||
*decoded.spec(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(buf) = self.sample_buf.as_mut() {
|
||||
buf.copy_interleaved_ref(decoded);
|
||||
let samples = buf.samples();
|
||||
|
||||
let mut left = Vec::with_capacity(samples.len() / self.channels);
|
||||
let mut right = Vec::with_capacity(samples.len() / self.channels);
|
||||
|
||||
if self.channels == 2 {
|
||||
for i in (0..samples.len()).step_by(2) {
|
||||
left.push(samples[i]);
|
||||
right.push(samples[i + 1]);
|
||||
}
|
||||
} else {
|
||||
for &s in samples {
|
||||
left.push(s);
|
||||
right.push(s);
|
||||
}
|
||||
}
|
||||
return Ok(Some((left, right)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::audio::config::AudioConfig;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
pub struct AudioRecorder {
|
||||
#[cfg(target_os = "linux")]
|
||||
pcm: alsa::pcm::PCM,
|
||||
}
|
||||
|
||||
impl AudioRecorder {
|
||||
pub fn new(config: &AudioConfig) -> Result<Self> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use alsa::Direction;
|
||||
use alsa::pcm::{Access, Format, HwParams, PCM};
|
||||
|
||||
let pcm = PCM::new(&config.capture_device, Direction::Capture, false)?;
|
||||
{
|
||||
let hwp = HwParams::any(&pcm)?;
|
||||
hwp.set_access(Access::RWInterleaved)?;
|
||||
hwp.set_format(Format::s16())?;
|
||||
hwp.set_rate_near(config.sample_rate, alsa::ValueOr::Nearest)?;
|
||||
hwp.set_channels_near(config.channels as u32)?;
|
||||
pcm.hw_params(&hwp)?;
|
||||
}
|
||||
pcm.prepare()?;
|
||||
Ok(Self { pcm })
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err(anyhow!("Linux Only"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, buf: &mut [i16]) -> Result<usize> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match self.pcm.io_i16()?.readi(buf) {
|
||||
Ok(n) => Ok(n),
|
||||
Err(e) if e.errno() == 32 => {
|
||||
// 32 = Broken pipe (Overrun)
|
||||
println!("ALSA recording overrun, recovering...");
|
||||
self.pcm.prepare()?;
|
||||
self.pcm
|
||||
.io_i16()?
|
||||
.readi(buf)
|
||||
.context("ALSA read retry failed")
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err(anyhow!("Linux Only"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use crate::audio::config::AudioConfig;
|
||||
use anyhow::Result;
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Seek, SeekFrom, Write};
|
||||
|
||||
pub struct WavWriter {
|
||||
writer: BufWriter<File>,
|
||||
data_size: u32,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
impl WavWriter {
|
||||
pub fn create(path: &str, sample_rate: u32, channels: u16) -> Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
// Header placeholder
|
||||
writer.write_all(&[0u8; 44])?;
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
data_size: 0,
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_samples(&mut self, samples: &[i16]) -> Result<()> {
|
||||
for &sample in samples {
|
||||
self.writer.write_all(&sample.to_le_bytes())?;
|
||||
self.data_size += 2;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finalize(mut self) -> Result<()> {
|
||||
self.writer.flush()?;
|
||||
let mut file = self.writer.into_inner()?;
|
||||
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
|
||||
let file_size = 36 + self.data_size;
|
||||
let byte_rate = self.sample_rate * self.channels as u32 * 2;
|
||||
let block_align = self.channels * 2;
|
||||
|
||||
let mut header = [0u8; 44];
|
||||
header[0..4].copy_from_slice(b"RIFF");
|
||||
header[4..8].copy_from_slice(&file_size.to_le_bytes());
|
||||
header[8..12].copy_from_slice(b"WAVE");
|
||||
header[12..16].copy_from_slice(b"fmt ");
|
||||
header[16..20].copy_from_slice(&16u32.to_le_bytes());
|
||||
header[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
header[22..24].copy_from_slice(&self.channels.to_le_bytes());
|
||||
header[24..28].copy_from_slice(&self.sample_rate.to_le_bytes());
|
||||
header[28..32].copy_from_slice(&byte_rate.to_le_bytes());
|
||||
header[32..34].copy_from_slice(&block_align.to_le_bytes());
|
||||
header[34..36].copy_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
header[36..40].copy_from_slice(b"data");
|
||||
header[40..44].copy_from_slice(&self.data_size.to_le_bytes());
|
||||
|
||||
file.write_all(&header)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WavReader {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
reader: crate::audio::reader::AudioReader,
|
||||
pcm_buffer: Vec<i16>,
|
||||
pub config: AudioConfig,
|
||||
}
|
||||
|
||||
impl WavReader {
|
||||
pub fn open(path: &str) -> Result<Self> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let reader = crate::audio::reader::AudioReader::new(path)?;
|
||||
let channels = reader.channels as u16;
|
||||
|
||||
let config = AudioConfig {
|
||||
channels,
|
||||
sample_rate: reader.sample_rate,
|
||||
frame_size: reader.sample_rate as usize / 50, // 20ms
|
||||
..AudioConfig::music_48k()
|
||||
};
|
||||
|
||||
let input_len = config.frame_size * (channels as usize);
|
||||
|
||||
Ok(Self {
|
||||
reader,
|
||||
config,
|
||||
pcm_buffer: vec![0i16; input_len],
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Err(anyhow::anyhow!(
|
||||
"WavReader is only supported on non-Linux platforms"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_one_frame(&mut self) -> Result<Option<&[i16]>> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
match self.reader.read_chunk(self.config.frame_size)? {
|
||||
Some((left_pcm, right_pcm)) => {
|
||||
let actual_len = left_pcm.len();
|
||||
if self.config.channels == 2 {
|
||||
for i in 0..actual_len {
|
||||
self.pcm_buffer[i * 2] = left_pcm[i];
|
||||
self.pcm_buffer[i * 2 + 1] = right_pcm[i];
|
||||
}
|
||||
} else {
|
||||
self.pcm_buffer[..actual_len].copy_from_slice(&left_pcm);
|
||||
}
|
||||
let total_len = actual_len * (self.config.channels as usize);
|
||||
Ok(Some(&self.pcm_buffer[..total_len]))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Err(anyhow::anyhow!(
|
||||
"WavReader is only supported on non-Linux platforms"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! # Client Demo
|
||||
//!
|
||||
//! 演示客户端的主要功能:
|
||||
//! - 自动服务发现
|
||||
//! - 响应 RPC 调用
|
||||
//! - 音频录制和播放
|
||||
//! - 事件处理
|
||||
|
||||
use std::sync::Arc;
|
||||
use xiao::app::client::{Client, ClientConfig};
|
||||
use xiao::net::command::Command;
|
||||
use xiao::net::event::EventData;
|
||||
use xiao::net::rpc::RpcBuilder;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("╔═══════════════════════════════════════════════════════╗");
|
||||
println!(
|
||||
"║ XiaoAi Audio Client v{} ║",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
println!("╚═══════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// 创建客户端
|
||||
let config = ClientConfig::default();
|
||||
let client = Arc::new(Client::new(config));
|
||||
|
||||
// 启动事件监听器
|
||||
let event_client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = event_client.subscribe_events();
|
||||
while let Some((event, ts, addr)) = rx.recv().await {
|
||||
match event {
|
||||
EventData::Hello { message, .. } => {
|
||||
println!("[Event] Hello: {} ts:{} addr:{}", message, ts, addr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 启动客户端主循环
|
||||
let run_client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_client.run().await {
|
||||
eprintln!("Client error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
println!("Client is running, searching for server...\n");
|
||||
|
||||
// 等待连接
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
if client.is_connected().await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n═══════════════════════════════════════════════════════");
|
||||
println!("Connected to server!");
|
||||
println!("Running client-side tests...\n");
|
||||
|
||||
// 1. 测试简单的 Shell 命令
|
||||
println!("1️⃣ Testing Shell command...");
|
||||
match client
|
||||
.rpc(&RpcBuilder::default(Command::Shell(
|
||||
"echo 'Hello from client'".to_string(),
|
||||
)))
|
||||
.await
|
||||
{
|
||||
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
|
||||
Err(e) => println!(" ❌ Command failed: {}", e),
|
||||
}
|
||||
|
||||
// 2. 发送客户端事件
|
||||
println!("\n2️⃣ Sending event to server...");
|
||||
match client
|
||||
.send_event(EventData::Hello {
|
||||
message: "from client!".to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!(" ✅ Event sent"),
|
||||
Err(e) => println!(" ❌ Failed to send event: {}", e),
|
||||
}
|
||||
|
||||
println!("\n═══════════════════════════════════════════════════════");
|
||||
println!("✅ Client tests completed!");
|
||||
println!("\nClient is now ready to receive commands from server.");
|
||||
println!("Press Ctrl+C to exit.\n");
|
||||
|
||||
// 保持运行,等待服务器命令
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("\nShutting down client...");
|
||||
client.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! # Server Demo
|
||||
//!
|
||||
//! 演示服务端的主要功能:
|
||||
//! - 多客户端管理
|
||||
//! - RPC 调用
|
||||
//! - 音频录制和播放
|
||||
//! - 事件广播
|
||||
|
||||
use std::sync::Arc;
|
||||
use xiao::app::server::{Server, ServerConfig};
|
||||
use xiao::audio::config::AudioConfig;
|
||||
use xiao::net::command::Command;
|
||||
use xiao::net::event::EventData;
|
||||
use xiao::net::rpc::RpcBuilder;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("╔═══════════════════════════════════════════════════════╗");
|
||||
println!(
|
||||
"║ XiaoAi Audio Server v{} ║",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
println!("╚═══════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// 创建服务端
|
||||
let config = ServerConfig::default();
|
||||
let server = Arc::new(Server::new(config).await?);
|
||||
let s = server.clone();
|
||||
|
||||
// 启动服务器
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = s.run(8080).await {
|
||||
eprintln!("Server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 启动事件监听器
|
||||
let event_server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = event_server.subscribe_events();
|
||||
while let Some((event, ts, addr)) = rx.recv().await {
|
||||
match event {
|
||||
EventData::Hello { message, .. } => {
|
||||
println!("[Event] Hello: {} ts:{} addr:{}", message, ts, addr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!("Server is running on port 8080");
|
||||
println!("Waiting for clients to connect...\n");
|
||||
|
||||
// 主循环:等待客户端并执行测试
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let clients = server.get_clients().await;
|
||||
if clients.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let addr = clients[0];
|
||||
println!("\n═══════════════════════════════════════════════════════");
|
||||
println!("Client connected: {}", addr);
|
||||
println!("Running demo tests...\n");
|
||||
|
||||
// 1. 测试 Shell 命令(同步)
|
||||
println!("1️⃣ Testing Shell RPC (sync)...");
|
||||
match server
|
||||
.rpc(
|
||||
addr,
|
||||
&RpcBuilder::default(Command::Shell("echo 'Hello from server'".to_string())),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
|
||||
Err(e) => println!(" ❌ Command failed: {}", e),
|
||||
}
|
||||
|
||||
// 2. 测试 Shell 命令(带超时)
|
||||
println!("\n2️⃣ Testing Shell RPC with timeout (1s)...");
|
||||
match server
|
||||
.rpc(
|
||||
addr,
|
||||
&RpcBuilder::default(Command::Shell("sleep 2".to_string())).set_timeout(1000),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => println!(" ✅ Command executed: {:?}", resp),
|
||||
Err(e) => println!(" ❌ Command failed: {}", e),
|
||||
}
|
||||
|
||||
// 3. 测试事件
|
||||
println!("\n3️⃣ Broadcasting notification event...");
|
||||
match server
|
||||
.send_event(
|
||||
addr,
|
||||
EventData::Hello {
|
||||
message: "from server!".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!(" ✅ Event sent"),
|
||||
Err(e) => println!(" ❌ Failed to send event: {}", e),
|
||||
}
|
||||
|
||||
// 5. 测试音频录制
|
||||
println!("\n5️⃣ Testing Audio Recording (10 seconds)...");
|
||||
match server.start_record(addr, AudioConfig::voice_16k()).await {
|
||||
Ok(_) => {
|
||||
println!(" ⏺️ Recording started...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
server.stop_record(addr).await?;
|
||||
println!(" ⏹️ Recording stopped");
|
||||
}
|
||||
Err(e) => println!(" ❌ Recording failed: {}", e),
|
||||
}
|
||||
|
||||
// 6. 测试音频播放(如果有测试文件)
|
||||
println!("\n6️⃣ Testing Audio Playback...");
|
||||
if std::path::Path::new("temp/test.wav").exists() {
|
||||
match server.start_play(addr, "temp/test.wav").await {
|
||||
Ok(_) => {
|
||||
println!(" ▶️ Playback started...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
server.stop_play(addr).await?;
|
||||
println!(" ⏹️ Playback stopped");
|
||||
}
|
||||
Err(e) => println!(" ❌ Playback failed: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!(" ⚠️ No test file found at temp/test.wav, skipping...");
|
||||
}
|
||||
|
||||
println!("\n═══════════════════════════════════════════════════════");
|
||||
println!("✅ All tests completed!");
|
||||
println!("\nServer status:");
|
||||
println!(" • Connected clients: {}", server.client_count());
|
||||
println!(" • Uptime: {} seconds", server.uptime_secs());
|
||||
println!("\nPress Ctrl+C to exit.");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// 等待退出信号
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("\nShutting down server...");
|
||||
server.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod net;
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,121 @@
|
||||
//! # Command - RPC 命令类型系统
|
||||
//!
|
||||
//! 支持多种类型的命令,每种命令有独立的请求和响应结构。
|
||||
//!
|
||||
//! ## 设计
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────┐
|
||||
//! │ Command Types │
|
||||
//! │ │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ Shell │ │ GetInfo │ │ SetVolume │ ... │
|
||||
//! │ │ cmd → out │ │ () → Info │ │ vol → () │ │
|
||||
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
//! │ │
|
||||
//! │ ┌──────────────────┐ │
|
||||
//! │ │ RpcRequest │ │
|
||||
//! │ │ id + Command │ │
|
||||
//! │ └────────┬─────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ ┌──────────────────┐ │
|
||||
//! │ │ RpcResponse │ │
|
||||
//! │ │ id + Result │ │
|
||||
//! │ └──────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::shell::{ShellRequest, ShellResponse};
|
||||
|
||||
// ==================== 命令请求类型 ====================
|
||||
|
||||
// ==================== 统一命令枚举 ====================
|
||||
|
||||
/// RPC 命令 - 统一的请求类型
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum Command {
|
||||
/// 执行 Shell 命令
|
||||
Shell(ShellRequest),
|
||||
}
|
||||
|
||||
/// RPC 结果 - 统一的响应类型
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum CommandResult {
|
||||
/// 错误
|
||||
Error(CommandError),
|
||||
/// Shell 命令结果
|
||||
Shell(ShellResponse),
|
||||
}
|
||||
|
||||
/// 命令错误
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct CommandError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self::new(-1, msg)
|
||||
}
|
||||
|
||||
pub fn invalid_args(msg: impl Into<String>) -> Self {
|
||||
Self::new(-2, msg)
|
||||
}
|
||||
|
||||
pub fn permission_denied(msg: impl Into<String>) -> Self {
|
||||
Self::new(-3, msg)
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::new(-500, msg)
|
||||
}
|
||||
|
||||
pub fn timeout(msg: impl Into<String>) -> Self {
|
||||
Self::new(-408, msg)
|
||||
}
|
||||
|
||||
pub fn not_implemented() -> Self {
|
||||
Self::new(-501, "Not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommandError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandError {}
|
||||
|
||||
// ==================== 便捷构造方法 ====================
|
||||
|
||||
impl CommandResult {
|
||||
/// 创建错误响应
|
||||
pub fn error(err: CommandError) -> Self {
|
||||
Self::Error(err)
|
||||
}
|
||||
|
||||
/// 检查是否是错误
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, Self::Error(_))
|
||||
}
|
||||
|
||||
/// 获取错误(如果有)
|
||||
pub fn as_error(&self) -> Option<&CommandError> {
|
||||
match self {
|
||||
Self::Error(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use crate::net::protocol::ControlPacket;
|
||||
use anyhow::Result;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
const DISCOVERY_PROTOCOL: &str = "XIAO_V2";
|
||||
|
||||
pub const DISCOVERY_PORT: u16 = 53530;
|
||||
|
||||
pub struct Discovery;
|
||||
|
||||
impl Discovery {
|
||||
pub async fn broadcast(port: u16) -> Result<()> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.set_broadcast(true)?;
|
||||
let target: SocketAddr = format!("255.255.255.255:{}", DISCOVERY_PORT).parse()?;
|
||||
|
||||
let msg = postcard::to_allocvec(&ControlPacket::Discovery {
|
||||
protocol: DISCOVERY_PROTOCOL.to_string(),
|
||||
port,
|
||||
})?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let _ = socket.send_to(&msg, target).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn listen() -> Result<(IpAddr, u16)> {
|
||||
let socket = UdpSocket::bind(format!("0.0.0.0:{}", DISCOVERY_PORT)).await?;
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
let (len, addr) = socket.recv_from(&mut buf).await?;
|
||||
let data = &buf[..len];
|
||||
if let Ok(ControlPacket::Discovery { protocol, port }) = postcard::from_bytes(data) {
|
||||
if protocol == DISCOVERY_PROTOCOL {
|
||||
return Ok((addr.ip(), port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! # Event - 实时事件系统
|
||||
//!
|
||||
//! 支持双向的实时事件推送,包括:
|
||||
//! - 服务端事件(推送给客户端)
|
||||
//! - 客户端事件(推送给服务端)
|
||||
//! - 事件订阅和过滤
|
||||
//!
|
||||
//! ## 设计
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────┐
|
||||
//! │ Event System │
|
||||
//! │ │
|
||||
//! │ Server Events: │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ AudioStatus │ │ ClientJoined │ │ Message │ ... │
|
||||
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
//! │ │
|
||||
//! │ Client Events: │
|
||||
//! │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
//! │ │ StatusUpdate │ │ Metrics │ │ Alert │ ... │
|
||||
//! │ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
//! │ │
|
||||
//! │ ┌─────────────────────┐ │
|
||||
//! │ │ EventBus │ │
|
||||
//! │ │ broadcast channel │ │
|
||||
//! │ └─────────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
// ==================== 事件类型 ====================
|
||||
|
||||
/// 客户端事件(Client → Server)
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum EventData {
|
||||
/// 测试事件
|
||||
Hello { message: String },
|
||||
}
|
||||
|
||||
// ==================== 事件总线 ====================
|
||||
|
||||
/// 事件订阅者信息
|
||||
pub struct EventSubscription<E> {
|
||||
pub receiver: broadcast::Receiver<E>,
|
||||
}
|
||||
|
||||
impl<E: Clone> EventSubscription<E> {
|
||||
/// 接收下一个事件
|
||||
pub async fn recv(&mut self) -> Option<E> {
|
||||
match self.receiver.recv().await {
|
||||
Ok(event) => Some(event),
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
// 跳过丢失的事件,继续接收
|
||||
Box::pin(self.recv()).await
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件总线(单向,只接收转发)
|
||||
pub struct EventBus {
|
||||
tx: broadcast::Sender<(EventData, u128, SocketAddr)>,
|
||||
}
|
||||
|
||||
pub type EventBusSubscription = EventSubscription<(EventData, u128, SocketAddr)>;
|
||||
|
||||
impl EventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// 收到事件
|
||||
pub fn receive(&self, event: EventData, timestamp: u128, addr: SocketAddr) {
|
||||
let _ = self.tx.send((event, timestamp, addr));
|
||||
}
|
||||
|
||||
/// 订阅事件
|
||||
pub fn subscribe(&self) -> EventBusSubscription {
|
||||
EventSubscription {
|
||||
receiver: self.tx.subscribe(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new(128)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! # Jitter Buffer - 抖动缓冲区
|
||||
//!
|
||||
//! 用于音频流的抖动缓冲和包重排序。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 自适应缓冲区大小
|
||||
//! - 乱序包重排
|
||||
//! - 丢包检测和统计
|
||||
//! - 延迟统计
|
||||
|
||||
use crate::net::protocol::AudioPacket;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
impl PartialEq for AudioPacket {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.timestamp == other.timestamp
|
||||
}
|
||||
}
|
||||
impl Eq for AudioPacket {}
|
||||
impl Ord for AudioPacket {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
other.timestamp.cmp(&self.timestamp)
|
||||
}
|
||||
}
|
||||
impl PartialOrd for AudioPacket {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Jitter Buffer 统计信息
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JitterStats {
|
||||
/// 总接收包数
|
||||
pub received: u64,
|
||||
/// 总丢包数
|
||||
pub lost: u64,
|
||||
/// 总播放包数
|
||||
pub played: u64,
|
||||
/// 迟到的包数(到达时已过播放时间)
|
||||
pub late: u64,
|
||||
/// 重复包数
|
||||
pub duplicate: u64,
|
||||
/// 当前缓冲区大小(包数)
|
||||
pub buffer_size: usize,
|
||||
/// 最小延迟(微秒)
|
||||
pub min_delay: u128,
|
||||
/// 最大延迟(微秒)
|
||||
pub max_delay: u128,
|
||||
/// 平均延迟(微秒)
|
||||
pub avg_delay: u128,
|
||||
}
|
||||
|
||||
impl JitterStats {
|
||||
/// 计算丢包率(百分比)
|
||||
pub fn loss_rate(&self) -> f64 {
|
||||
if self.received + self.lost == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.lost as f64 / (self.received + self.lost) as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Jitter Buffer 配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JitterConfig {
|
||||
/// 最小缓冲区大小(包数)
|
||||
pub min_buffer_size: usize,
|
||||
/// 最大缓冲区大小(包数)
|
||||
pub max_buffer_size: usize,
|
||||
/// 目标缓冲区大小(包数)
|
||||
pub target_buffer_size: usize,
|
||||
/// 自适应调整间隔(包数)
|
||||
pub adapt_interval: usize,
|
||||
/// 最大容忍延迟(微秒)
|
||||
pub max_tolerable_delay: u128,
|
||||
}
|
||||
|
||||
impl Default for JitterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_buffer_size: 2,
|
||||
max_buffer_size: 20,
|
||||
target_buffer_size: 5,
|
||||
adapt_interval: 50,
|
||||
max_tolerable_delay: 100_000, // 100ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Jitter Buffer - 用于音频流的抖动缓冲
|
||||
pub struct JitterBuffer {
|
||||
/// 配置
|
||||
config: JitterConfig,
|
||||
/// 缓冲区(按时间戳排序)
|
||||
buffer: BTreeMap<u128, AudioPacket>,
|
||||
/// 统计信息
|
||||
stats: JitterStats,
|
||||
/// 期望的下一个序列号
|
||||
expected_seq: u32,
|
||||
/// 是否已接收第一个包
|
||||
first_packet_received: bool,
|
||||
/// 延迟样本窗口(用于自适应)
|
||||
delay_samples: VecDeque<u128>,
|
||||
/// 自适应计数器
|
||||
adapt_counter: usize,
|
||||
/// 上次播放的时间戳
|
||||
last_played_timestamp: u128,
|
||||
}
|
||||
|
||||
impl JitterBuffer {
|
||||
/// 创建新的 Jitter Buffer
|
||||
pub fn new(config: JitterConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
buffer: BTreeMap::new(),
|
||||
stats: JitterStats::default(),
|
||||
expected_seq: 0,
|
||||
first_packet_received: false,
|
||||
delay_samples: VecDeque::with_capacity(100),
|
||||
adapt_counter: 0,
|
||||
last_played_timestamp: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用默认配置创建
|
||||
pub fn default() -> Self {
|
||||
Self::new(JitterConfig::default())
|
||||
}
|
||||
|
||||
/// 插入音频包
|
||||
pub fn push(&mut self, packet: AudioPacket, arrival_time: u128) {
|
||||
// 检测重复包
|
||||
if self.buffer.contains_key(&packet.timestamp) {
|
||||
self.stats.duplicate += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化序列号
|
||||
if !self.first_packet_received {
|
||||
self.expected_seq = packet.seq.wrapping_add(1);
|
||||
self.first_packet_received = true;
|
||||
} else {
|
||||
// 检测丢包
|
||||
let seq_diff = packet.seq.wrapping_sub(self.expected_seq);
|
||||
if seq_diff > 0 && seq_diff < 1000 {
|
||||
// 允许一定的序列号跳跃(处理回环)
|
||||
self.stats.lost += seq_diff as u64;
|
||||
}
|
||||
self.expected_seq = packet.seq.wrapping_add(1);
|
||||
}
|
||||
|
||||
// 检查是否迟到
|
||||
if self.last_played_timestamp > 0 && packet.timestamp < self.last_played_timestamp {
|
||||
self.stats.late += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 超过最大容忍延迟,直接丢弃
|
||||
if packet.timestamp < arrival_time
|
||||
&& arrival_time - packet.timestamp > self.config.max_tolerable_delay
|
||||
{
|
||||
self.stats.late += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let delay = if packet.timestamp >= arrival_time {
|
||||
packet.timestamp - arrival_time
|
||||
} else {
|
||||
0 // 包到达时已经过了播放时间
|
||||
};
|
||||
|
||||
self.update_delay_stats(delay);
|
||||
|
||||
// 插入缓冲区
|
||||
self.buffer.insert(packet.timestamp, packet);
|
||||
self.stats.received += 1;
|
||||
self.stats.buffer_size = self.buffer.len();
|
||||
|
||||
// 自适应调整
|
||||
self.adapt_counter += 1;
|
||||
if self.adapt_counter >= self.config.adapt_interval {
|
||||
self.adapt_buffer_size();
|
||||
self.adapt_counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取下一个应该播放的包
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_time` - 当前服务器时间
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(packet)` - 如果有包应该播放
|
||||
/// * `None` - 如果缓冲区为空或还没到播放时间
|
||||
pub fn pop(&mut self, current_time: u128) -> Option<AudioPacket> {
|
||||
// 如果缓冲区为空,直接返回
|
||||
if self.buffer.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 检查缓冲区是否达到最小大小
|
||||
if self.buffer.len() < self.config.target_buffer_size && !self.should_drain() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 获取最早的包
|
||||
if let Some((×tamp, _)) = self.buffer.iter().next() {
|
||||
// 检查是否到达播放时间
|
||||
if current_time >= timestamp {
|
||||
let packet = self.buffer.remove(×tamp).unwrap();
|
||||
self.last_played_timestamp = timestamp;
|
||||
self.stats.played += 1;
|
||||
self.stats.buffer_size = self.buffer.len();
|
||||
return Some(packet);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 强制获取下一个包(无论时间)
|
||||
pub fn pop_next(&mut self) -> Option<AudioPacket> {
|
||||
if let Some((×tamp, _)) = self.buffer.iter().next() {
|
||||
let packet = self.buffer.remove(×tamp).unwrap();
|
||||
self.last_played_timestamp = timestamp;
|
||||
self.stats.played += 1;
|
||||
self.stats.buffer_size = self.buffer.len();
|
||||
return Some(packet);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 查看下一个包的播放时间(不移除)
|
||||
pub fn peek_next_timestamp(&self) -> Option<u128> {
|
||||
self.buffer.keys().next().copied()
|
||||
}
|
||||
|
||||
/// 获取缓冲区大小
|
||||
pub fn len(&self) -> usize {
|
||||
self.buffer.len()
|
||||
}
|
||||
|
||||
/// 检查缓冲区是否为空
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buffer.is_empty()
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
pub fn stats(&self) -> &JitterStats {
|
||||
&self.stats
|
||||
}
|
||||
|
||||
/// 重置统计信息
|
||||
pub fn reset_stats(&mut self) {
|
||||
self.stats = JitterStats::default();
|
||||
self.stats.buffer_size = self.buffer.len();
|
||||
}
|
||||
|
||||
/// 清空缓冲区
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.stats.buffer_size = 0;
|
||||
self.first_packet_received = false;
|
||||
self.last_played_timestamp = 0;
|
||||
}
|
||||
|
||||
/// 检查是否应该排空缓冲区(处理长时间没有新包的情况)
|
||||
fn should_drain(&self) -> bool {
|
||||
// 如果缓冲区有包且已经等了很久,就开始播放
|
||||
!self.buffer.is_empty() && self.buffer.len() >= self.config.min_buffer_size
|
||||
}
|
||||
|
||||
/// 更新延迟统计
|
||||
fn update_delay_stats(&mut self, delay: u128) {
|
||||
// 更新最小/最大延迟
|
||||
if self.stats.received == 0 {
|
||||
self.stats.min_delay = delay;
|
||||
self.stats.max_delay = delay;
|
||||
self.stats.avg_delay = delay;
|
||||
} else {
|
||||
self.stats.min_delay = self.stats.min_delay.min(delay);
|
||||
self.stats.max_delay = self.stats.max_delay.max(delay);
|
||||
// 滑动平均
|
||||
self.stats.avg_delay = (self.stats.avg_delay * 9 + delay) / 10;
|
||||
}
|
||||
|
||||
// 保存延迟样本用于自适应
|
||||
self.delay_samples.push_back(delay);
|
||||
if self.delay_samples.len() > 100 {
|
||||
self.delay_samples.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应调整缓冲区大小
|
||||
fn adapt_buffer_size(&mut self) {
|
||||
if self.delay_samples.len() < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算延迟方差(抖动)
|
||||
let avg = self.stats.avg_delay;
|
||||
let variance: f64 = self
|
||||
.delay_samples
|
||||
.iter()
|
||||
.map(|&d| {
|
||||
let diff = d as i128 - avg as i128;
|
||||
(diff * diff) as f64
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ self.delay_samples.len() as f64;
|
||||
|
||||
let jitter = variance.sqrt();
|
||||
|
||||
// 根据抖动调整目标缓冲区大小
|
||||
// 抖动大 -> 增加缓冲区
|
||||
// 抖动小 -> 减少缓冲区
|
||||
let target = if jitter > 50_000.0 {
|
||||
// 高抖动(>50ms 标准差)
|
||||
self.config.target_buffer_size + 2
|
||||
} else if jitter < 10_000.0 {
|
||||
// 低抖动(<10ms 标准差)
|
||||
self.config.target_buffer_size.saturating_sub(1)
|
||||
} else {
|
||||
self.config.target_buffer_size
|
||||
};
|
||||
|
||||
// 限制在最小/最大范围内
|
||||
self.config.target_buffer_size = target
|
||||
.max(self.config.min_buffer_size)
|
||||
.min(self.config.max_buffer_size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! # Net 模块
|
||||
//!
|
||||
//! 网络通信相关模块:
|
||||
//! - `command` - RPC 命令类型系统
|
||||
//! - `discovery` - 服务发现
|
||||
//! - `event` - 实时事件系统
|
||||
//! - `network` - 底层网络连接
|
||||
//! - `protocol` - 通信协议定义
|
||||
//! - `rpc` - RPC 调用管理
|
||||
//! - `sync` - 时间同步工具
|
||||
//! - `jitter_buffer` - 抖动缓冲区实现
|
||||
|
||||
pub mod command;
|
||||
pub mod discovery;
|
||||
pub mod event;
|
||||
pub mod jitter_buffer;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
pub mod rpc;
|
||||
pub mod sync;
|
||||
@@ -0,0 +1,81 @@
|
||||
use crate::net::protocol::{AudioPacket, ControlPacket};
|
||||
use anyhow::Result;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A unified control connection over TCP
|
||||
pub struct Connection {
|
||||
reader: Mutex<tokio::net::tcp::OwnedReadHalf>,
|
||||
writer: Mutex<tokio::net::tcp::OwnedWriteHalf>,
|
||||
peer_addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(stream: TcpStream) -> Result<Self> {
|
||||
let peer_addr = stream.peer_addr()?;
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(Self {
|
||||
reader: Mutex::new(r),
|
||||
writer: Mutex::new(w),
|
||||
peer_addr,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send(&self, packet: &ControlPacket) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(packet)?;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_u32(bytes.len() as u32).await?;
|
||||
writer.write_all(&bytes).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv(&self) -> Result<ControlPacket> {
|
||||
let mut reader = self.reader.lock().await;
|
||||
let len = reader.read_u32().await? as usize;
|
||||
if len > 1024 * 1024 {
|
||||
return Err(anyhow::anyhow!("Packet too large: {}", len));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
reader.read_exact(&mut buf).await?;
|
||||
let packet = postcard::from_bytes(&buf)?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
pub fn peer_addr(&self) -> SocketAddr {
|
||||
self.peer_addr
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP Socket for audio transmission
|
||||
pub struct AudioSocket {
|
||||
socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
impl AudioSocket {
|
||||
pub async fn bind() -> Result<Self> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
Ok(Self {
|
||||
socket: Arc::new(socket),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
pub async fn send(&self, packet: &AudioPacket, target: SocketAddr) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(packet)?;
|
||||
self.socket.send_to(&bytes, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv(&self, buf: &mut [u8]) -> Result<(AudioPacket, SocketAddr)> {
|
||||
let (len, addr) = self.socket.recv_from(buf).await?;
|
||||
let packet = postcard::from_bytes(&buf[..len])?;
|
||||
Ok((packet, addr))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! # Protocol - 通信协议定义
|
||||
//!
|
||||
//! 定义 Client 和 Server 之间的所有通信协议。
|
||||
|
||||
use crate::audio::config::AudioConfig;
|
||||
use crate::net::command::{Command, CommandResult};
|
||||
use crate::net::event::EventData;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ==================== 基础类型 ====================
|
||||
|
||||
/// 客户端信息
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ClientInfo {
|
||||
pub model: String,
|
||||
pub serial_number: String,
|
||||
}
|
||||
|
||||
/// 音频数据包 - UDP 通道传输
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AudioPacket {
|
||||
pub seq: u32, // 序列号,用于丢包检测
|
||||
pub timestamp: u128, // 目标播放时间 (主节点时间)
|
||||
pub data: Vec<u8>, // Opus 编码数据
|
||||
}
|
||||
|
||||
// ==================== 控制包 ====================
|
||||
|
||||
/// 控制包 - TCP 通道传输的所有消息类型
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub enum ControlPacket {
|
||||
// ========== 服务发现 ==========
|
||||
/// 服务发现广播
|
||||
Discovery {
|
||||
protocol: String,
|
||||
port: u16,
|
||||
},
|
||||
|
||||
// ========== 握手 ==========
|
||||
/// 服务端握手
|
||||
ServerHello {
|
||||
auth: String,
|
||||
version: String,
|
||||
udp_port: u16,
|
||||
},
|
||||
/// 客户端握手
|
||||
ClientHello {
|
||||
auth: String,
|
||||
version: String,
|
||||
udp_port: u16,
|
||||
info: ClientInfo,
|
||||
},
|
||||
|
||||
// ========== 心跳 ==========
|
||||
Ping {
|
||||
client_ts: u128,
|
||||
seq: u32,
|
||||
},
|
||||
Pong {
|
||||
client_ts: u128,
|
||||
server_ts: u128,
|
||||
seq: u32,
|
||||
},
|
||||
|
||||
// ========== RPC ==========
|
||||
RpcRequest {
|
||||
id: u32,
|
||||
/// 是否异步运行
|
||||
run_async: bool,
|
||||
/// 超时(毫秒)
|
||||
timeout: Option<u64>,
|
||||
/// 请求命令
|
||||
command: Command,
|
||||
},
|
||||
RpcResponse {
|
||||
id: u32,
|
||||
result: CommandResult,
|
||||
},
|
||||
|
||||
// ========== 事件 ==========
|
||||
Event {
|
||||
/// 微秒时间戳
|
||||
timestamp: u128,
|
||||
data: EventData,
|
||||
},
|
||||
|
||||
// ========== 音频控制 ==========
|
||||
/// 开始录音
|
||||
StartRecording {
|
||||
config: AudioConfig,
|
||||
},
|
||||
/// 停止录音
|
||||
StopRecording,
|
||||
/// 开始播放
|
||||
StartPlayback {
|
||||
config: AudioConfig,
|
||||
},
|
||||
/// 停止播放
|
||||
StopPlayback,
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! # RPC Manager - RPC 调用管理
|
||||
//!
|
||||
//! 管理 RPC 请求的生命周期,包括:
|
||||
//! - 请求 ID 生成
|
||||
//! - 请求/响应匹配
|
||||
//! - 超时处理
|
||||
//! - 异步任务管理
|
||||
|
||||
use crate::net::command::{Command, CommandError, CommandResult};
|
||||
use crate::net::protocol::ControlPacket;
|
||||
use crate::utils::shell::Shell;
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RpcError {
|
||||
#[error("RPC timeout")]
|
||||
Timeout,
|
||||
#[error("RPC cancelled")]
|
||||
Cancelled,
|
||||
#[error("RPC internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub struct RpcManager {
|
||||
handler: RpcHandler,
|
||||
next_id: AtomicU32,
|
||||
pending: Arc<DashMap<u32, oneshot::Sender<CommandResult>>>,
|
||||
}
|
||||
|
||||
impl RpcManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handler: RpcHandler::new(),
|
||||
next_id: AtomicU32::new(1),
|
||||
pending: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self, id: u32, result: CommandResult) {
|
||||
if let Some((_, tx)) = self.pending.remove(&id) {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(&self, id: u32) {
|
||||
self.pending.remove(&id);
|
||||
}
|
||||
|
||||
/// 发起异步 RPC 调用
|
||||
///
|
||||
/// F: 异步闭包,输入 ID,返回 Future
|
||||
pub async fn call<F, Fut>(
|
||||
&self,
|
||||
builder: &RpcBuilder,
|
||||
send_fn: F,
|
||||
) -> Result<CommandResult, RpcError>
|
||||
where
|
||||
F: FnOnce(ControlPacket) -> Fut,
|
||||
Fut: Future<Output = Result<()>>,
|
||||
{
|
||||
let command = builder.command.clone();
|
||||
let timeout = builder.timeout;
|
||||
let run_async = builder.run_async;
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// 1. 注册请求
|
||||
self.pending.insert(id, tx);
|
||||
|
||||
// 2. RAII 守卫:确保无论因超时、报错还是外部 Drop,ID 都会被清理
|
||||
let _guard = DropGuard {
|
||||
id,
|
||||
pending: Arc::clone(&self.pending),
|
||||
};
|
||||
|
||||
// 3. 执行异步发送逻辑
|
||||
if let Err(e) = send_fn(ControlPacket::RpcRequest {
|
||||
id,
|
||||
command,
|
||||
timeout,
|
||||
run_async,
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Err(RpcError::Internal(e.to_string()));
|
||||
}
|
||||
|
||||
// 4. 等待响应或超时
|
||||
match timeout {
|
||||
Some(ms) => match tokio::time::timeout(Duration::from_millis(ms), rx).await {
|
||||
Ok(Ok(res)) => Ok(res),
|
||||
Ok(Err(_)) => Err(RpcError::Cancelled), // tx 关闭,任务被取消
|
||||
Err(_) => Err(RpcError::Timeout),
|
||||
},
|
||||
None => rx.await.map_err(|_| RpcError::Cancelled), // tx 关闭,任务被取消
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 RPC 调用
|
||||
///
|
||||
/// F: 异步闭包,输入 ID,返回 Future
|
||||
pub async fn handle_rpc_request<F, Fut>(
|
||||
&self,
|
||||
id: u32,
|
||||
run_async: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
command: Command,
|
||||
send_fn: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: Fn(ControlPacket) -> Fut + Send + Sync + 'static, // 异步执行需要 'static
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
{
|
||||
let sender = Arc::new(send_fn);
|
||||
let handler = self.handler.clone();
|
||||
|
||||
let task = async move {
|
||||
let execute_future = handler.handle(command, timeout_ms);
|
||||
|
||||
match timeout_ms {
|
||||
None => {
|
||||
// 无超时逻辑
|
||||
let result = execute_future.await;
|
||||
sender(ControlPacket::RpcResponse { id, result }).await
|
||||
}
|
||||
Some(ms) => {
|
||||
let d = Duration::from_millis(ms);
|
||||
match tokio::time::timeout(d, execute_future).await {
|
||||
Ok(result) => {
|
||||
// 1. 正常完成
|
||||
sender(ControlPacket::RpcResponse { id, result }).await
|
||||
}
|
||||
Err(_) => {
|
||||
// 2. 触发超时:发送超时错误消息
|
||||
let err_result = CommandResult::error(CommandError::timeout(format!(
|
||||
"Task {} timed out after {}ms",
|
||||
id, ms
|
||||
)));
|
||||
sender(ControlPacket::RpcResponse {
|
||||
id,
|
||||
result: err_result,
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if run_async {
|
||||
tokio::spawn(task);
|
||||
Ok(())
|
||||
} else {
|
||||
task.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RpcBuilder {
|
||||
pub command: Command,
|
||||
pub run_async: bool,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl RpcBuilder {
|
||||
/// 基础构造器
|
||||
pub fn new(command: Command) -> Self {
|
||||
Self {
|
||||
command,
|
||||
run_async: false,
|
||||
timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷方法:默认异步配置
|
||||
pub fn default(command: Command) -> Self {
|
||||
Self::new(command).set_async(true).set_timeout(10_000)
|
||||
}
|
||||
|
||||
/// 设置为异步运行
|
||||
pub fn set_async(mut self, is_async: bool) -> Self {
|
||||
self.run_async = is_async;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置超时时长(毫秒)
|
||||
pub fn set_timeout(mut self, ms: u64) -> Self {
|
||||
self.timeout = Some(ms);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RpcHandler;
|
||||
|
||||
impl RpcHandler {
|
||||
fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
async fn handle(&self, command: Command, timeout_ms: Option<u64>) -> CommandResult {
|
||||
match command {
|
||||
Command::Shell(req) => match Shell::run(req, timeout_ms).await {
|
||||
Ok(resp) => CommandResult::Shell(resp),
|
||||
Err(e) => CommandResult::Error(CommandError::internal(e.to_string())),
|
||||
},
|
||||
_ => CommandResult::Error(CommandError::not_implemented()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DropGuard {
|
||||
id: u32,
|
||||
pending: Arc<DashMap<u32, oneshot::Sender<CommandResult>>>,
|
||||
}
|
||||
|
||||
impl Drop for DropGuard {
|
||||
fn drop(&mut self) {
|
||||
self.pending.remove(&self.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 获取当前微秒级时间戳
|
||||
pub fn now_us() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("时间倒流")
|
||||
.as_micros()
|
||||
}
|
||||
|
||||
/// 时钟同步管理器,用于计算主从节点间的时钟偏移
|
||||
/// 采用改进的 NTP 算法 + Kalman 滤波思想
|
||||
pub struct ClockSync {
|
||||
/// 偏移量样本窗口
|
||||
offsets: VecDeque<OffsetSample>,
|
||||
/// 当前估计的时钟偏移 (server_time - client_time)
|
||||
pub current_offset: i128,
|
||||
/// RTT 样本窗口
|
||||
rtts: VecDeque<i128>,
|
||||
/// 当前估计的最小 RTT
|
||||
min_rtt: i128,
|
||||
/// 窗口大小
|
||||
window_size: usize,
|
||||
/// 时钟漂移率 (ppm: parts per million)
|
||||
/// 正值表示从节点时钟比主节点快
|
||||
drift_rate: f64,
|
||||
/// 上次更新时间
|
||||
last_update_time: u128,
|
||||
/// 漂移率估计窗口
|
||||
drift_samples: VecDeque<DriftSample>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OffsetSample {
|
||||
offset: i128,
|
||||
rtt: i128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DriftSample {
|
||||
offset: i128,
|
||||
timestamp: u128,
|
||||
}
|
||||
|
||||
impl ClockSync {
|
||||
pub fn new(window_size: usize) -> Self {
|
||||
Self {
|
||||
offsets: VecDeque::with_capacity(window_size),
|
||||
current_offset: 0,
|
||||
rtts: VecDeque::with_capacity(window_size),
|
||||
min_rtt: i128::MAX,
|
||||
window_size,
|
||||
drift_rate: 0.0,
|
||||
last_update_time: now_us(),
|
||||
drift_samples: VecDeque::with_capacity(60), // 保留 60 秒的样本
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新时钟偏移估计 (NTP 算法)
|
||||
///
|
||||
/// NTP 时间戳标记:
|
||||
/// t1 = client_send_ts : 客户端发送 Ping 的时间
|
||||
/// t2 = server_ts : 服务器接收 Ping 的时间
|
||||
/// t3 = server_ts : 服务器发送 Pong 的时间 (假设处理时间忽略不计)
|
||||
/// t4 = client_recv_ts : 客户端接收 Pong 的时间
|
||||
///
|
||||
/// RTT = (t4 - t1) - (t3 - t2) = (t4 - t1) (因为 t3 = t2)
|
||||
/// Offset = ((t2 - t1) + (t3 - t4)) / 2 = ((t2 - t1) + (t2 - t4)) / 2
|
||||
/// = t2 - (t1 + t4) / 2
|
||||
pub fn update(&mut self, client_send_ts: u128, server_ts: u128, client_recv_ts: u128) {
|
||||
let t1 = client_send_ts as i128;
|
||||
let t2 = server_ts as i128;
|
||||
let t4 = client_recv_ts as i128;
|
||||
|
||||
let rtt = t4 - t1;
|
||||
|
||||
// 过滤异常 RTT (局域网内 > 100ms 视为异常)
|
||||
if rtt < 0 || rtt > 100_000 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算时钟偏移: offset = server_time - client_time
|
||||
// offset = t2 - (t1 + t4) / 2
|
||||
let offset = t2 - (t1 + t4) / 2;
|
||||
|
||||
// 更新 RTT 窗口
|
||||
self.rtts.push_back(rtt);
|
||||
if self.rtts.len() > self.window_size {
|
||||
self.rtts.pop_front();
|
||||
}
|
||||
self.min_rtt = *self.rtts.iter().min().unwrap_or(&rtt);
|
||||
|
||||
// 更新偏移量窗口
|
||||
let sample = OffsetSample {
|
||||
offset,
|
||||
rtt,
|
||||
};
|
||||
self.offsets.push_back(sample);
|
||||
if self.offsets.len() > self.window_size {
|
||||
self.offsets.pop_front();
|
||||
}
|
||||
|
||||
// 偏移量估计: 使用低 RTT 样本的中位数
|
||||
// 原理: RTT 较小的样本受网络抖动影响小,时间测量更准确
|
||||
let mut low_rtt_offsets: Vec<i128> = self
|
||||
.offsets
|
||||
.iter()
|
||||
.filter(|s| s.rtt <= self.min_rtt + 5000) // 5ms 容差
|
||||
.map(|s| s.offset)
|
||||
.collect();
|
||||
|
||||
if !low_rtt_offsets.is_empty() {
|
||||
low_rtt_offsets.sort_unstable();
|
||||
let new_offset = low_rtt_offsets[low_rtt_offsets.len() / 2];
|
||||
|
||||
// 漂移率估计
|
||||
self.estimate_drift(new_offset, client_recv_ts);
|
||||
|
||||
// 平滑更新偏移量 (避免突变)
|
||||
let alpha = 0.3; // 低通滤波系数
|
||||
self.current_offset =
|
||||
(alpha * new_offset as f64 + (1.0 - alpha) * self.current_offset as f64) as i128;
|
||||
}
|
||||
|
||||
self.last_update_time = client_recv_ts;
|
||||
}
|
||||
|
||||
/// 估计时钟漂移率
|
||||
/// 时钟漂移率 = d(offset) / dt
|
||||
fn estimate_drift(&mut self, offset: i128, timestamp: u128) {
|
||||
self.drift_samples
|
||||
.push_back(DriftSample { offset, timestamp });
|
||||
if self.drift_samples.len() > 60 {
|
||||
self.drift_samples.pop_front();
|
||||
}
|
||||
|
||||
// 至少需要 10 秒的数据才能估计漂移
|
||||
if self.drift_samples.len() < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用线性回归估计漂移率
|
||||
let first = self.drift_samples.front().unwrap();
|
||||
let last = self.drift_samples.back().unwrap();
|
||||
|
||||
let dt = (last.timestamp - first.timestamp) as f64;
|
||||
let d_offset = (last.offset - first.offset) as f64;
|
||||
|
||||
if dt > 10_000_000.0 {
|
||||
// 超过 10 秒
|
||||
// drift_rate 单位: 微秒/秒 = ppm
|
||||
let new_drift = d_offset / (dt / 1_000_000.0);
|
||||
|
||||
// 平滑更新漂移率
|
||||
let beta = 0.1;
|
||||
self.drift_rate = beta * new_drift + (1.0 - beta) * self.drift_rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将本地时间转换为服务器(主节点)时间
|
||||
/// 考虑时钟漂移补偿
|
||||
pub fn to_server_time(&self, client_time: u128) -> u128 {
|
||||
let base_server_time = (client_time as i128 + self.current_offset) as u128;
|
||||
|
||||
// 漂移补偿: 根据距离上次同步的时间,补偿时钟漂移
|
||||
let elapsed_since_update = client_time.saturating_sub(self.last_update_time) as f64;
|
||||
let drift_correction = (self.drift_rate * elapsed_since_update / 1_000_000.0) as i128;
|
||||
|
||||
(base_server_time as i128 + drift_correction) as u128
|
||||
}
|
||||
|
||||
/// 将服务器(主节点)时间转换为本地时间
|
||||
pub fn to_client_time(&self, server_time: u128) -> u128 {
|
||||
// 简化版本,不考虑漂移补偿 (播放时主要用 to_server_time)
|
||||
(server_time as i128 - self.current_offset) as u128
|
||||
}
|
||||
|
||||
/// 获取当前估计的 RTT (微秒)
|
||||
pub fn get_rtt(&self) -> i128 {
|
||||
self.min_rtt
|
||||
}
|
||||
|
||||
/// 获取当前时钟漂移率 (ppm)
|
||||
pub fn get_drift_rate(&self) -> f64 {
|
||||
self.drift_rate
|
||||
}
|
||||
|
||||
/// 获取同步质量评估 (0-100, 越高越好)
|
||||
pub fn get_sync_quality(&self) -> u8 {
|
||||
if self.offsets.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 基于 RTT 稳定性和偏移量方差评估
|
||||
let rtt_variance = self.calculate_variance(&self.rtts.iter().copied().collect::<Vec<_>>());
|
||||
let offset_variance =
|
||||
self.calculate_variance(&self.offsets.iter().map(|s| s.offset).collect::<Vec<_>>());
|
||||
|
||||
// RTT 越稳定,方差越小,质量越高
|
||||
let rtt_score = ((100_000.0 - rtt_variance.min(100_000.0)) / 100_000.0 * 50.0) as u8;
|
||||
let offset_score = ((50_000.0 - offset_variance.min(50_000.0)) / 50_000.0 * 50.0) as u8;
|
||||
|
||||
rtt_score + offset_score
|
||||
}
|
||||
|
||||
fn calculate_variance(&self, samples: &[i128]) -> f64 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = samples.iter().sum::<i128>() as f64 / samples.len() as f64;
|
||||
let variance = samples
|
||||
.iter()
|
||||
.map(|&x| {
|
||||
let diff = x as f64 - mean;
|
||||
diff * diff
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ samples.len() as f64;
|
||||
variance.sqrt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod shell;
|
||||
@@ -0,0 +1,68 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use core::str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub type ShellRequest = String;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct ShellResponse {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
pub struct Shell;
|
||||
|
||||
impl Shell {
|
||||
pub async fn run(command: String, timeout_ms: Option<u64>) -> Result<ShellResponse> {
|
||||
// 1. 配置 Command
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
// 当 Child 结构体被 Drop 时,自动杀死子进程(SIGKILL)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("Failed to spawn shell command")?;
|
||||
|
||||
let mut stdout_reader = child.stdout.take().context("Failed to open stdout")?;
|
||||
let mut stderr_reader = child.stderr.take().context("Failed to open stderr")?;
|
||||
|
||||
// 2. 定义执行逻辑
|
||||
let run_logic = async move {
|
||||
let mut stdout_buf = Vec::new();
|
||||
let mut stderr_buf = Vec::new();
|
||||
|
||||
// 使用 join! 并发读取流并等待进程结束
|
||||
let (res_out, res_err, res_status) = tokio::join!(
|
||||
stdout_reader.read_to_end(&mut stdout_buf),
|
||||
stderr_reader.read_to_end(&mut stderr_buf),
|
||||
child.wait()
|
||||
);
|
||||
|
||||
let status = res_status.context("Wait for child failed")?;
|
||||
res_out.context("Read stdout failed")?;
|
||||
res_err.context("Read stderr failed")?;
|
||||
|
||||
Ok(ShellResponse {
|
||||
stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
|
||||
stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
|
||||
exit_code: status.code().unwrap_or(-1),
|
||||
})
|
||||
};
|
||||
|
||||
// 3. 应用超时
|
||||
if let Some(ms) = timeout_ms {
|
||||
tokio::time::timeout(Duration::from_millis(ms), run_logic)
|
||||
.await
|
||||
.map_err(|_| anyhow!("Shell execution timed out"))?
|
||||
} else {
|
||||
run_logic.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,16 @@ chmod +x ./flash
|
||||
# 执行命令后拔掉小爱音箱的电源线,重新插上电源,等待设备连接
|
||||
./flash connect
|
||||
|
||||
# 第 2 步:设置启动延时(5 秒)
|
||||
./flash delay 5
|
||||
# 第 2 步:设置启动延时(15 秒)
|
||||
./flash delay 15
|
||||
|
||||
# 第 3 步:切换启动分区
|
||||
./flash switch boot0
|
||||
|
||||
# 第 4 步:刷写固件(注意替换固件文件的实际路径)
|
||||
./flash system system0 root-patched.squashfs
|
||||
|
||||
# PS: 如果提示刷写错误,可以多试几次,不一定是真的无法刷机
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
RED='\033[0;31m'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
squashfs-root
|
||||
root.squashfs
|
||||
squashfs
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user