Compare commits
7 Commits
780309fede
...
feature/wz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d33f3ed4e | ||
|
|
2de6e19956 | ||
|
|
ec437afbce | ||
|
|
137e7973c4 | ||
|
|
55d4004f86 | ||
|
|
09a18b086b | ||
|
|
f3c8e11995 |
@@ -1,5 +0,0 @@
|
|||||||
[target.aarch64-linux-android]
|
|
||||||
linker = "aarch64-linux-android21-clang"
|
|
||||||
|
|
||||||
[target.armv7-linux-androideabi]
|
|
||||||
linker = "armv7a-linux-androideabi21-clang"
|
|
||||||
217
Cargo.lock
generated
217
Cargo.lock
generated
@@ -291,6 +291,12 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -461,6 +467,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -621,6 +628,24 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-bigint"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -644,6 +669,7 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
|
"serde",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -810,6 +836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
@@ -844,6 +871,21 @@ dependencies = [
|
|||||||
"rustfft",
|
"rustfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.16.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"digest",
|
||||||
|
"elliptic-curve",
|
||||||
|
"rfc6979",
|
||||||
|
"serdect",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "2.2.3"
|
version = "2.2.3"
|
||||||
@@ -851,6 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
|
"serde",
|
||||||
"signature",
|
"signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -875,6 +918,26 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "elliptic-curve"
|
||||||
|
version = "0.13.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"crypto-bigint",
|
||||||
|
"digest",
|
||||||
|
"ff",
|
||||||
|
"generic-array",
|
||||||
|
"group",
|
||||||
|
"pkcs8",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"sec1",
|
||||||
|
"serdect",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -918,6 +981,16 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ff"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1078,6 +1151,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1137,6 +1211,17 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "group"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||||
|
dependencies = [
|
||||||
|
"ff",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1620,6 +1705,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "k256"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"once_cell",
|
||||||
|
"serdect",
|
||||||
|
"sha2",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2383,6 +2483,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfc6979"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2561,6 +2671,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sec1"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"der",
|
||||||
|
"generic-array",
|
||||||
|
"pkcs8",
|
||||||
|
"serdect",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -2665,6 +2790,16 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serdect"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2718,6 +2853,7 @@ version = "2.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"digest",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2931,6 +3067,15 @@ version = "0.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-keccak"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -3350,6 +3495,18 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3389,7 +3546,28 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.1.0"
|
version = "0.0.38"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bincode",
|
||||||
|
"bip39",
|
||||||
|
"chacha20poly1305",
|
||||||
|
"chrono",
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"hex",
|
||||||
|
"hkdf",
|
||||||
|
"k256",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tiny-keccak",
|
||||||
|
"uuid",
|
||||||
|
"x25519-dalek",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
@@ -4001,28 +4179,6 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wzp-android"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"wzp-codec",
|
|
||||||
"wzp-crypto",
|
|
||||||
"wzp-fec",
|
|
||||||
"wzp-proto",
|
|
||||||
"wzp-transport",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4146,6 +4302,21 @@ dependencies = [
|
|||||||
"wzp-proto",
|
"wzp-proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wzp-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20poly1305",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hkdf",
|
||||||
|
"js-sys",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"raptorq",
|
||||||
|
"sha2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"x25519-dalek",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wzp-web"
|
name = "wzp-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
6
android/.gitignore
vendored
6
android/.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
app/build/
|
|
||||||
app/src/main/jniLibs/
|
|
||||||
local.properties
|
|
||||||
keystore/*.jks
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.wzp.phone"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.wzp.phone"
|
|
||||||
minSdk = 26 // AAudio requires API 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
ndk { abiFilters += listOf("arm64-v8a") }
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file("${project.rootDir}/keystore/wzp-release.jks")
|
|
||||||
storePassword = "wzphone2024"
|
|
||||||
keyAlias = "wzp-release"
|
|
||||||
keyPassword = "wzphone2024"
|
|
||||||
}
|
|
||||||
getByName("debug") {
|
|
||||||
storeFile = file("${project.rootDir}/keystore/wzp-debug.jks")
|
|
||||||
storePassword = "android"
|
|
||||||
keyAlias = "wzp-debug"
|
|
||||||
keyPassword = "android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
isDebuggable = true
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures { compose = true }
|
|
||||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
|
||||||
|
|
||||||
ndkVersion = "26.1.10909125"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cargo-ndk integration: build the Rust native library for Android targets
|
|
||||||
tasks.register<Exec>("cargoNdkBuild") {
|
|
||||||
workingDir = file("${project.rootDir}/..")
|
|
||||||
commandLine(
|
|
||||||
"cargo", "ndk",
|
|
||||||
"-t", "arm64-v8a",
|
|
||||||
"-o", "${project.projectDir}/src/main/jniLibs",
|
|
||||||
"build", "--release", "-p", "wzp-android"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named("preBuild") { dependsOn("cargoNdkBuild") }
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
|
||||||
implementation("androidx.compose.ui:ui")
|
|
||||||
implementation("androidx.compose.material3:material3")
|
|
||||||
}
|
|
||||||
9
android/app/proguard-rules.pro
vendored
9
android/app/proguard-rules.pro
vendored
@@ -1,9 +0,0 @@
|
|||||||
# WZPhone ProGuard rules
|
|
||||||
|
|
||||||
# Keep JNI native methods
|
|
||||||
-keepclasseswithmembernames class * {
|
|
||||||
native <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep the WZP engine bridge class
|
|
||||||
-keep class com.wzp.phone.engine.** { *; }
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".WzpApplication"
|
|
||||||
android:label="WZ Phone"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.call.CallActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".service.CallService"
|
|
||||||
android:foregroundServiceType="phoneCall"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package com.wzp
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.os.Build
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application entry point for WarzonePhone.
|
|
||||||
*
|
|
||||||
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
|
|
||||||
*/
|
|
||||||
class WzpApplication : Application() {
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
"Active Call",
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Shown while a VoIP call is in progress"
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
val nm = getSystemService(NotificationManager::class.java)
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHANNEL_ID = "wzp_call_channel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package com.wzp.audio
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioDeviceCallback
|
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
|
|
||||||
*
|
|
||||||
* Wraps [AudioManager] operations and listens for device connection changes
|
|
||||||
* via [AudioDeviceCallback] (API 23+).
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Call [register] when the call starts
|
|
||||||
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
|
|
||||||
* 3. Call [unregister] when the call ends
|
|
||||||
*/
|
|
||||||
class AudioRouteManager(context: Context) {
|
|
||||||
|
|
||||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
/** Listener for audio route changes. */
|
|
||||||
var onRouteChanged: ((AudioRoute) -> Unit)? = null
|
|
||||||
|
|
||||||
/** Current active route. */
|
|
||||||
var currentRoute: AudioRoute = AudioRoute.EARPIECE
|
|
||||||
private set
|
|
||||||
|
|
||||||
// -- Device callback (API 23+) -------------------------------------------
|
|
||||||
|
|
||||||
private val deviceCallback = object : AudioDeviceCallback() {
|
|
||||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
|
||||||
for (device in addedDevices) {
|
|
||||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
|
||||||
// A Bluetooth headset was connected — optionally auto-switch
|
|
||||||
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
|
||||||
for (device in removedDevices) {
|
|
||||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
|
||||||
// Bluetooth disconnected — fall back to earpiece or speaker
|
|
||||||
val fallback = if (audioManager.isSpeakerphoneOn) {
|
|
||||||
AudioRoute.SPEAKER
|
|
||||||
} else {
|
|
||||||
AudioRoute.EARPIECE
|
|
||||||
}
|
|
||||||
currentRoute = fallback
|
|
||||||
onRouteChanged?.invoke(fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
|
||||||
|
|
||||||
/** Register the device callback. Call when a call starts. */
|
|
||||||
fun register() {
|
|
||||||
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
|
|
||||||
fun unregister() {
|
|
||||||
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
|
||||||
stopBluetoothSco()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable the loudspeaker.
|
|
||||||
*
|
|
||||||
* When enabling speaker, Bluetooth SCO is disconnected.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
fun setSpeaker(enabled: Boolean) {
|
|
||||||
if (enabled) {
|
|
||||||
stopBluetoothSco()
|
|
||||||
}
|
|
||||||
audioManager.isSpeakerphoneOn = enabled
|
|
||||||
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
|
||||||
onRouteChanged?.invoke(currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
|
|
||||||
*
|
|
||||||
* When enabling Bluetooth, the speaker is turned off.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
fun setBluetoothSco(enabled: Boolean) {
|
|
||||||
if (enabled) {
|
|
||||||
audioManager.isSpeakerphoneOn = false
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
currentRoute = AudioRoute.BLUETOOTH
|
|
||||||
} else {
|
|
||||||
stopBluetoothSco()
|
|
||||||
currentRoute = AudioRoute.EARPIECE
|
|
||||||
}
|
|
||||||
onRouteChanged?.invoke(currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether a Bluetooth SCO device is currently connected. */
|
|
||||||
fun isBluetoothAvailable(): Boolean {
|
|
||||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
|
||||||
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List available output audio routes. */
|
|
||||||
fun availableRoutes(): List<AudioRoute> {
|
|
||||||
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
|
|
||||||
if (isBluetoothAvailable()) {
|
|
||||||
routes.add(AudioRoute.BLUETOOTH)
|
|
||||||
}
|
|
||||||
return routes
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Internal -------------------------------------------------------------
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun stopBluetoothSco() {
|
|
||||||
if (audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = false
|
|
||||||
audioManager.stopBluetoothSco()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Audio output route. */
|
|
||||||
enum class AudioRoute {
|
|
||||||
/** Phone earpiece (default for calls). */
|
|
||||||
EARPIECE,
|
|
||||||
/** Built-in loudspeaker. */
|
|
||||||
SPEAKER,
|
|
||||||
/** Bluetooth SCO headset/headphones. */
|
|
||||||
BLUETOOTH
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package com.wzp.engine
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
|
|
||||||
*
|
|
||||||
* Constructed from the JSON string returned by [WzpEngine.getStats].
|
|
||||||
*/
|
|
||||||
data class CallStats(
|
|
||||||
/** Current call state ordinal (see [CallStateConstants]). */
|
|
||||||
val state: Int = 0,
|
|
||||||
/** Call duration in seconds. */
|
|
||||||
val durationSecs: Double = 0.0,
|
|
||||||
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
|
|
||||||
val qualityTier: Int = 0,
|
|
||||||
/** Observed packet loss percentage (0..100). */
|
|
||||||
val lossPct: Float = 0f,
|
|
||||||
/** Smoothed round-trip time in milliseconds. */
|
|
||||||
val rttMs: Int = 0,
|
|
||||||
/** Jitter in milliseconds. */
|
|
||||||
val jitterMs: Int = 0,
|
|
||||||
/** Current jitter buffer depth in packets. */
|
|
||||||
val jitterBufferDepth: Int = 0,
|
|
||||||
/** Total frames encoded since call start. */
|
|
||||||
val framesEncoded: Long = 0,
|
|
||||||
/** Total frames decoded since call start. */
|
|
||||||
val framesDecoded: Long = 0,
|
|
||||||
/** Number of playout underruns (buffer empty when audio was needed). */
|
|
||||||
val underruns: Long = 0
|
|
||||||
) {
|
|
||||||
/** Human-readable quality label. */
|
|
||||||
val qualityLabel: String
|
|
||||||
get() = when (qualityTier) {
|
|
||||||
0 -> "Good"
|
|
||||||
1 -> "Degraded"
|
|
||||||
2 -> "Catastrophic"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Deserialise from the JSON string produced by the native engine. */
|
|
||||||
fun fromJson(json: String): CallStats {
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
CallStats(
|
|
||||||
state = obj.optInt("state", 0),
|
|
||||||
durationSecs = obj.optDouble("duration_secs", 0.0),
|
|
||||||
qualityTier = obj.optInt("quality_tier", 0),
|
|
||||||
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
|
|
||||||
rttMs = obj.optInt("rtt_ms", 0),
|
|
||||||
jitterMs = obj.optInt("jitter_ms", 0),
|
|
||||||
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
|
|
||||||
framesEncoded = obj.optLong("frames_encoded", 0),
|
|
||||||
framesDecoded = obj.optLong("frames_decoded", 0),
|
|
||||||
underruns = obj.optLong("underruns", 0)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
CallStats()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.wzp.engine
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback interface for VoIP engine events.
|
|
||||||
*
|
|
||||||
* All callbacks are invoked on the main/UI thread.
|
|
||||||
*/
|
|
||||||
interface WzpCallback {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the call state changes.
|
|
||||||
*
|
|
||||||
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
|
|
||||||
* RECONNECTING(3), CLOSED(4)
|
|
||||||
*/
|
|
||||||
fun onCallStateChanged(state: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the network quality tier changes.
|
|
||||||
*
|
|
||||||
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
|
|
||||||
*/
|
|
||||||
fun onQualityTierChanged(tier: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an error occurs in the native engine.
|
|
||||||
*
|
|
||||||
* @param code numeric error code (negative)
|
|
||||||
* @param message human-readable description
|
|
||||||
*/
|
|
||||||
fun onError(code: Int, message: String)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package com.wzp.engine
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
|
|
||||||
*
|
|
||||||
* Lifecycle:
|
|
||||||
* 1. Construct with a [WzpCallback]
|
|
||||||
* 2. Call [init] to create the native engine
|
|
||||||
* 3. Call [startCall] to begin a VoIP session
|
|
||||||
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
|
|
||||||
* 5. Call [stopCall] to end the session
|
|
||||||
* 6. Call [destroy] when the engine is no longer needed
|
|
||||||
*
|
|
||||||
* Thread safety: all methods must be called from the same thread (typically main).
|
|
||||||
*/
|
|
||||||
class WzpEngine(private val callback: WzpCallback) {
|
|
||||||
|
|
||||||
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
|
|
||||||
private var nativeHandle: Long = 0L
|
|
||||||
|
|
||||||
/** Whether the engine has been initialised. */
|
|
||||||
val isInitialized: Boolean get() = nativeHandle != 0L
|
|
||||||
|
|
||||||
/** Create the native engine. Must be called before any other method. */
|
|
||||||
fun init() {
|
|
||||||
check(nativeHandle == 0L) { "Engine already initialized" }
|
|
||||||
nativeHandle = nativeInit()
|
|
||||||
check(nativeHandle != 0L) { "Native engine creation failed" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a call.
|
|
||||||
*
|
|
||||||
* @param relayAddr relay server address (host:port)
|
|
||||||
* @param room room identifier
|
|
||||||
* @param seedHex 64-char hex-encoded 32-byte identity seed
|
|
||||||
* @param token authentication token
|
|
||||||
* @return 0 on success, negative error code on failure
|
|
||||||
*/
|
|
||||||
fun startCall(relayAddr: String, room: String, seedHex: String, token: String): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token)
|
|
||||||
if (result == 0) {
|
|
||||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
|
||||||
} else {
|
|
||||||
callback.onError(result, "Failed to start call")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the active call. Safe to call when no call is active. */
|
|
||||||
fun stopCall() {
|
|
||||||
if (nativeHandle != 0L) {
|
|
||||||
nativeStopCall(nativeHandle)
|
|
||||||
callback.onCallStateChanged(CallStateConstants.CLOSED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mute or unmute the microphone. */
|
|
||||||
fun setMute(muted: Boolean) {
|
|
||||||
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enable or disable loudspeaker mode. */
|
|
||||||
fun setSpeaker(speaker: Boolean) {
|
|
||||||
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current call statistics as a JSON string.
|
|
||||||
*
|
|
||||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
|
||||||
*/
|
|
||||||
fun getStats(): String {
|
|
||||||
if (nativeHandle == 0L) return "{}"
|
|
||||||
return try {
|
|
||||||
nativeGetStats(nativeHandle) ?: "{}"
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"{}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force a quality profile, overriding adaptive selection.
|
|
||||||
*
|
|
||||||
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
|
|
||||||
*/
|
|
||||||
fun forceProfile(profile: Int) {
|
|
||||||
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
|
||||||
fun destroy() {
|
|
||||||
if (nativeHandle != 0L) {
|
|
||||||
nativeDestroy(nativeHandle)
|
|
||||||
nativeHandle = 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- JNI native methods --------------------------------------------------
|
|
||||||
|
|
||||||
private external fun nativeInit(): Long
|
|
||||||
private external fun nativeStartCall(
|
|
||||||
handle: Long, relay: String, room: String, seed: String, token: String
|
|
||||||
): Int
|
|
||||||
private external fun nativeStopCall(handle: Long)
|
|
||||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
|
||||||
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
|
|
||||||
private external fun nativeGetStats(handle: Long): String?
|
|
||||||
private external fun nativeForceProfile(handle: Long, profile: Int)
|
|
||||||
private external fun nativeDestroy(handle: Long)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
init {
|
|
||||||
System.loadLibrary("wzp_android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
|
||||||
object CallStateConstants {
|
|
||||||
const val IDLE = 0
|
|
||||||
const val CONNECTING = 1
|
|
||||||
const val ACTIVE = 2
|
|
||||||
const val RECONNECTING = 3
|
|
||||||
const val CLOSED = 4
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package com.wzp.service
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.wzp.WzpApplication
|
|
||||||
import com.wzp.ui.call.CallActivity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Shows a persistent notification during the call
|
|
||||||
* - Acquires a partial wake lock so the CPU stays on
|
|
||||||
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
|
|
||||||
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
|
|
||||||
* - Releases all resources when the call ends
|
|
||||||
*/
|
|
||||||
class CallService : Service() {
|
|
||||||
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
private var wifiLock: WifiManager.WifiLock? = null
|
|
||||||
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
|
|
||||||
|
|
||||||
// -- Lifecycle ------------------------------------------------------------
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
acquireWakeLock()
|
|
||||||
acquireWifiLock()
|
|
||||||
setAudioMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_STOP -> {
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, buildNotification())
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
restoreAudioMode()
|
|
||||||
releaseWifiLock()
|
|
||||||
releaseWakeLock()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
// -- Notification ---------------------------------------------------------
|
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
|
||||||
// Tapping the notification returns to the call screen
|
|
||||||
val contentIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, CallActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
// "End call" action button
|
|
||||||
val stopIntent = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
1,
|
|
||||||
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
|
|
||||||
.setContentTitle("WZ Phone")
|
|
||||||
.setContentText("Call in progress")
|
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(contentIntent)
|
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Wake lock ------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun acquireWakeLock() {
|
|
||||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
wakeLock = pm.newWakeLock(
|
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
|
||||||
"wzp:call_wake_lock"
|
|
||||||
).apply {
|
|
||||||
acquire(MAX_CALL_DURATION_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
|
||||||
wakeLock?.let {
|
|
||||||
if (it.isHeld) it.release()
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Wi-Fi lock -----------------------------------------------------------
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun acquireWifiLock() {
|
|
||||||
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
|
||||||
wifiLock = wm.createWifiLock(
|
|
||||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
|
||||||
"wzp:call_wifi_lock"
|
|
||||||
).apply {
|
|
||||||
acquire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWifiLock() {
|
|
||||||
wifiLock?.let {
|
|
||||||
if (it.isHeld) it.release()
|
|
||||||
}
|
|
||||||
wifiLock = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Audio mode -----------------------------------------------------------
|
|
||||||
|
|
||||||
private fun setAudioMode() {
|
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
previousAudioMode = am.mode
|
|
||||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreAudioMode() {
|
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
am.mode = previousAudioMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Static helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val NOTIFICATION_ID = 1001
|
|
||||||
private const val ACTION_STOP = "com.wzp.service.STOP"
|
|
||||||
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
|
|
||||||
|
|
||||||
/** Start the foreground call service. */
|
|
||||||
fun start(context: Context) {
|
|
||||||
val intent = Intent(context, CallService::class.java)
|
|
||||||
context.startForegroundService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the foreground call service. */
|
|
||||||
fun stop(context: Context) {
|
|
||||||
val intent = Intent(context, CallService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.wzp.ui.call
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main activity hosting the in-call Compose UI.
|
|
||||||
*
|
|
||||||
* Shows the call screen. Does NOT auto-start a call — the user must
|
|
||||||
* tap "Connect" in the UI.
|
|
||||||
*/
|
|
||||||
class CallActivity : ComponentActivity() {
|
|
||||||
|
|
||||||
private val viewModel: CallViewModel by viewModels()
|
|
||||||
|
|
||||||
private val audioPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
if (!granted) {
|
|
||||||
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
WzpTheme {
|
|
||||||
InCallScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onHangUp = {
|
|
||||||
viewModel.stopCall()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request audio permission proactively but don't start a call
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
if (isFinishing) {
|
|
||||||
viewModel.stopCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WzpTheme(content: @Composable () -> Unit) {
|
|
||||||
val darkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val colorScheme = when {
|
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
darkTheme -> darkColorScheme()
|
|
||||||
else -> lightColorScheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package com.wzp.ui.call
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.wzp.engine.CallStats
|
|
||||||
import com.wzp.engine.WzpCallback
|
|
||||||
import com.wzp.engine.WzpEngine
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class CallViewModel : ViewModel(), WzpCallback {
|
|
||||||
|
|
||||||
private var engine: WzpEngine? = null
|
|
||||||
private var engineInitialized = false
|
|
||||||
|
|
||||||
// Observable state
|
|
||||||
private val _callState = MutableStateFlow(0)
|
|
||||||
val callState: StateFlow<Int> = _callState.asStateFlow()
|
|
||||||
|
|
||||||
private val _isMuted = MutableStateFlow(false)
|
|
||||||
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
|
||||||
|
|
||||||
private val _isSpeaker = MutableStateFlow(false)
|
|
||||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
|
||||||
|
|
||||||
private val _stats = MutableStateFlow(CallStats())
|
|
||||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
|
||||||
|
|
||||||
private val _qualityTier = MutableStateFlow(0)
|
|
||||||
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
|
|
||||||
|
|
||||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
|
||||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
|
||||||
|
|
||||||
private var statsJob: Job? = null
|
|
||||||
|
|
||||||
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
|
|
||||||
try {
|
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this)
|
|
||||||
}
|
|
||||||
if (!engineInitialized) {
|
|
||||||
engine?.init()
|
|
||||||
engineInitialized = true
|
|
||||||
}
|
|
||||||
val result = engine?.startCall(relayAddr, room, seedHex, token) ?: -1
|
|
||||||
if (result == 0) {
|
|
||||||
_callState.value = 1 // Connecting
|
|
||||||
startStatsPolling()
|
|
||||||
} else {
|
|
||||||
_errorMessage.value = "Failed to start call (code $result)"
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_errorMessage.value = "Engine error: ${e.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopCall() {
|
|
||||||
stopStatsPolling()
|
|
||||||
try {
|
|
||||||
engine?.stopCall()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
_callState.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleMute() {
|
|
||||||
val newMuted = !_isMuted.value
|
|
||||||
_isMuted.value = newMuted
|
|
||||||
try { engine?.setMute(newMuted) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSpeaker() {
|
|
||||||
val newSpeaker = !_isSpeaker.value
|
|
||||||
_isSpeaker.value = newSpeaker
|
|
||||||
try { engine?.setSpeaker(newSpeaker) } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearError() { _errorMessage.value = null }
|
|
||||||
|
|
||||||
// WzpCallback
|
|
||||||
override fun onCallStateChanged(state: Int) { _callState.value = state }
|
|
||||||
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
|
|
||||||
override fun onError(code: Int, message: String) { _errorMessage.value = "Error $code: $message" }
|
|
||||||
|
|
||||||
private fun startStatsPolling() {
|
|
||||||
statsJob?.cancel()
|
|
||||||
statsJob = viewModelScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
try {
|
|
||||||
val json = engine?.getStats() ?: "{}"
|
|
||||||
if (json.isNotEmpty()) {
|
|
||||||
val parsed = CallStats.fromJson(json)
|
|
||||||
_stats.value = parsed
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
delay(500L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopStatsPolling() {
|
|
||||||
statsJob?.cancel()
|
|
||||||
statsJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
stopStatsPolling()
|
|
||||||
try {
|
|
||||||
engine?.stopCall()
|
|
||||||
engine?.destroy()
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
engine = null
|
|
||||||
engineInitialized = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
package com.wzp.ui.call
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.wzp.engine.CallStats
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main in-call Compose screen.
|
|
||||||
*
|
|
||||||
* Displays call duration, quality indicator, audio controls, and live statistics.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun InCallScreen(
|
|
||||||
viewModel: CallViewModel,
|
|
||||||
onHangUp: () -> Unit
|
|
||||||
) {
|
|
||||||
val callState by viewModel.callState.collectAsState()
|
|
||||||
val isMuted by viewModel.isMuted.collectAsState()
|
|
||||||
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
|
||||||
val stats by viewModel.stats.collectAsState()
|
|
||||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
|
||||||
|
|
||||||
// -- Call state label ---------------------------------------------
|
|
||||||
CallStateLabel(callState)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// -- Duration -----------------------------------------------------
|
|
||||||
DurationDisplay(stats.durationSecs)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// -- Quality indicator --------------------------------------------
|
|
||||||
QualityIndicator(qualityTier, stats.qualityLabel)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
// -- Audio level placeholder bar ----------------------------------
|
|
||||||
AudioLevelBar(stats.framesEncoded)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// -- Control buttons ----------------------------------------------
|
|
||||||
ControlRow(
|
|
||||||
isMuted = isMuted,
|
|
||||||
isSpeaker = isSpeaker,
|
|
||||||
onToggleMute = viewModel::toggleMute,
|
|
||||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
|
||||||
onHangUp = onHangUp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
// -- Stats overlay ------------------------------------------------
|
|
||||||
StatsOverlay(stats)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sub-components
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CallStateLabel(state: Int) {
|
|
||||||
val label = when (state) {
|
|
||||||
0 -> "Idle"
|
|
||||||
1 -> "Connecting..."
|
|
||||||
2 -> "Active"
|
|
||||||
3 -> "Reconnecting..."
|
|
||||||
4 -> "Call Ended"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DurationDisplay(durationSecs: Double) {
|
|
||||||
val totalSeconds = durationSecs.roundToInt()
|
|
||||||
val minutes = totalSeconds / 60
|
|
||||||
val seconds = totalSeconds % 60
|
|
||||||
Text(
|
|
||||||
text = "%02d:%02d".format(minutes, seconds),
|
|
||||||
style = MaterialTheme.typography.displayLarge.copy(
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
letterSpacing = 4.sp
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QualityIndicator(tier: Int, label: String) {
|
|
||||||
val dotColor = when (tier) {
|
|
||||||
0 -> Color(0xFF4CAF50) // green
|
|
||||||
1 -> Color(0xFFFFC107) // yellow
|
|
||||||
2 -> Color(0xFFF44336) // red
|
|
||||||
else -> Color.Gray
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(12.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(dotColor)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AudioLevelBar(framesEncoded: Long) {
|
|
||||||
// Placeholder: derive a fake "level" from frame count to show the bar is alive.
|
|
||||||
// In production this would be driven by actual RMS audio levels from the engine.
|
|
||||||
val level = if (framesEncoded > 0) {
|
|
||||||
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = "Audio Level",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = level,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.6f)
|
|
||||||
.height(6.dp)
|
|
||||||
.clip(RoundedCornerShape(3.dp)),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ControlRow(
|
|
||||||
isMuted: Boolean,
|
|
||||||
isSpeaker: Boolean,
|
|
||||||
onToggleMute: () -> Unit,
|
|
||||||
onToggleSpeaker: () -> Unit,
|
|
||||||
onHangUp: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Mute button
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onToggleMute,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
colors = if (isMuted) {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isMuted) "MIC\nOFF" else "MIC",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
lineHeight = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hang up button
|
|
||||||
FilledIconButton(
|
|
||||||
onClick = onHangUp,
|
|
||||||
modifier = Modifier.size(72.dp),
|
|
||||||
shape = CircleShape,
|
|
||||||
colors = IconButtonDefaults.filledIconButtonColors(
|
|
||||||
containerColor = Color(0xFFF44336),
|
|
||||||
contentColor = Color.White
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "END",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speaker button
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onToggleSpeaker,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
colors = if (isSpeaker) {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isSpeaker) "SPK\nON" else "SPK",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
lineHeight = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatsOverlay(stats: CallStats) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Network Stats",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
|
||||||
) {
|
|
||||||
StatItem("Loss", "%.1f%%".format(stats.lossPct))
|
|
||||||
StatItem("RTT", "${stats.rttMs}ms")
|
|
||||||
StatItem("Jitter", "${stats.jitterMs}ms")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
|
||||||
) {
|
|
||||||
StatItem("Enc", "${stats.framesEncoded}")
|
|
||||||
StatItem("Dec", "${stats.framesDecoded}")
|
|
||||||
StatItem("JB Depth", "${stats.jitterBufferDepth}")
|
|
||||||
StatItem("Under", "${stats.underruns}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatItem(label: String, value: String) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application") version "8.2.0" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
5
android/gradlew
vendored
5
android/gradlew
vendored
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Gradle wrapper script
|
|
||||||
APP_HOME=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
|
||||||
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "WZPhone"
|
|
||||||
include(":app")
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-android"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
description = "WarzonePhone Android native VoIP engine — Oboe audio, JNI bridge, call pipeline"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wzp-proto = { workspace = true }
|
|
||||||
wzp-codec = { workspace = true }
|
|
||||||
wzp-fec = { workspace = true }
|
|
||||||
wzp-crypto = { workspace = true }
|
|
||||||
wzp-transport = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
tracing-subscriber = { workspace = true }
|
|
||||||
bytes = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = "1"
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
anyhow = "1"
|
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
cc = "1"
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
|
|
||||||
if target.contains("android") {
|
|
||||||
// On Android, try to build with Oboe. If Oboe is not available,
|
|
||||||
// fall back to the stub (audio will need to be provided via JNI).
|
|
||||||
let oboe_dir = fetch_oboe();
|
|
||||||
match oboe_dir {
|
|
||||||
Some(oboe_path) => {
|
|
||||||
println!("cargo:warning=Building with Oboe from {:?}", oboe_path);
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_bridge.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.include(oboe_path.join("include"))
|
|
||||||
.define("WZP_HAS_OBOE", None)
|
|
||||||
.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("cargo:warning=Oboe not found, building with stub");
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-Android: always use stub
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to find or fetch Oboe headers.
|
|
||||||
/// Returns the path to the Oboe source root (containing include/ directory).
|
|
||||||
fn fetch_oboe() -> Option<PathBuf> {
|
|
||||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
|
||||||
let oboe_dir = out_dir.join("oboe");
|
|
||||||
|
|
||||||
// Check if already fetched
|
|
||||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
|
||||||
return Some(oboe_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to clone Oboe from GitHub
|
|
||||||
let status = std::process::Command::new("git")
|
|
||||||
.args([
|
|
||||||
"clone",
|
|
||||||
"--depth=1",
|
|
||||||
"--branch=1.8.1",
|
|
||||||
"https://github.com/google/oboe.git",
|
|
||||||
oboe_dir.to_str().unwrap(),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
|
|
||||||
match status {
|
|
||||||
Ok(s) if s.success() => {
|
|
||||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
|
||||||
Some(oboe_dir)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// Full Oboe implementation for Android
|
|
||||||
// This file is compiled only when targeting Android
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <oboe/Oboe.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <cstring>
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
#define LOG_TAG "wzp-oboe"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ring buffer helpers (SPSC, lock-free)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
int32_t avail = w - r;
|
|
||||||
if (avail < 0) avail += capacity;
|
|
||||||
return avail;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
|
||||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
|
||||||
const int16_t* src, int32_t count) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
buf[w] = src[i];
|
|
||||||
w++;
|
|
||||||
if (w >= capacity) w = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
|
||||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
|
||||||
int16_t* dst, int32_t count) {
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
dst[i] = buf[r];
|
|
||||||
r++;
|
|
||||||
if (r >= capacity) r = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
|
||||||
static const WzpOboeRings* g_rings = nullptr;
|
|
||||||
static std::atomic<bool> g_running{false};
|
|
||||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
|
||||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Capture callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_write(g_rings->capture_write_idx,
|
|
||||||
g_rings->capture_read_idx,
|
|
||||||
g_rings->capture_capacity);
|
|
||||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
|
||||||
if (to_write > 0) {
|
|
||||||
ring_write(g_rings->capture_buf, g_rings->capture_capacity,
|
|
||||||
g_rings->capture_write_idx, g_rings->capture_read_idx,
|
|
||||||
src, to_write);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Playout callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
|
||||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_read(g_rings->playout_write_idx,
|
|
||||||
g_rings->playout_read_idx,
|
|
||||||
g_rings->playout_capacity);
|
|
||||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
|
||||||
|
|
||||||
if (to_read > 0) {
|
|
||||||
ring_read(g_rings->playout_buf, g_rings->playout_capacity,
|
|
||||||
g_rings->playout_write_idx, g_rings->playout_read_idx,
|
|
||||||
dst, to_read);
|
|
||||||
}
|
|
||||||
// Fill remainder with silence on underrun
|
|
||||||
if (to_read < numFrames) {
|
|
||||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static CaptureCallback g_capture_cb;
|
|
||||||
static PlayoutCallback g_playout_cb;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public C API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
if (g_running.load(std::memory_order_relaxed)) {
|
|
||||||
LOGW("wzp_oboe_start: already running");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_rings = rings;
|
|
||||||
|
|
||||||
// Build capture stream
|
|
||||||
oboe::AudioStreamBuilder captureBuilder;
|
|
||||||
captureBuilder.setDirection(oboe::Direction::Input)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_capture_cb);
|
|
||||||
|
|
||||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build playout stream
|
|
||||||
oboe::AudioStreamBuilder playoutBuilder;
|
|
||||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setUsage(oboe::Usage::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_playout_cb);
|
|
||||||
|
|
||||||
result = playoutBuilder.openStream(g_playout_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_running.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Start both streams
|
|
||||||
result = g_capture_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = g_playout_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -5;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
|
||||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
|
|
||||||
if (g_capture_stream) {
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
}
|
|
||||||
if (g_playout_stream) {
|
|
||||||
g_playout_stream->requestStop();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
g_rings = nullptr;
|
|
||||||
LOGI("Oboe stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
|
||||||
// Provide empty implementations just in case.
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config; (void)rings;
|
|
||||||
return -99;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {}
|
|
||||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
|
||||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
|
||||||
int wzp_oboe_is_running(void) { return 0; }
|
|
||||||
|
|
||||||
#endif // __ANDROID__
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#ifndef WZP_OBOE_BRIDGE_H
|
|
||||||
#define WZP_OBOE_BRIDGE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#include <atomic>
|
|
||||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
|
||||||
extern "C" {
|
|
||||||
#else
|
|
||||||
#include <stdatomic.h>
|
|
||||||
typedef atomic_int wzp_atomic_int;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t sample_rate;
|
|
||||||
int32_t frames_per_burst;
|
|
||||||
int32_t channel_count;
|
|
||||||
} WzpOboeConfig;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int16_t* capture_buf;
|
|
||||||
int32_t capture_capacity;
|
|
||||||
wzp_atomic_int* capture_write_idx;
|
|
||||||
wzp_atomic_int* capture_read_idx;
|
|
||||||
|
|
||||||
int16_t* playout_buf;
|
|
||||||
int32_t playout_capacity;
|
|
||||||
wzp_atomic_int* playout_write_idx;
|
|
||||||
wzp_atomic_int* playout_read_idx;
|
|
||||||
} WzpOboeRings;
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
|
||||||
void wzp_oboe_stop(void);
|
|
||||||
float wzp_oboe_capture_latency_ms(void);
|
|
||||||
float wzp_oboe_playout_latency_ms(void);
|
|
||||||
int wzp_oboe_is_running(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // WZP_OBOE_BRIDGE_H
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config;
|
|
||||||
(void)rings;
|
|
||||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
//! Lock-free SPSC ring buffer audio backend for Android (Oboe).
|
|
||||||
//!
|
|
||||||
//! The ring buffers are shared between Rust and C++: the Oboe callbacks
|
|
||||||
//! (running on a high-priority audio thread) read/write directly into
|
|
||||||
//! the buffers via atomic indices, while the Rust codec thread on the
|
|
||||||
//! other side does the same.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
|
|
||||||
use tracing::info;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Default ring buffer capacity: 8 frames = 160 ms at 48 kHz.
|
|
||||||
const RING_CAPACITY: usize = 7680;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FFI declarations matching oboe_bridge.h
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct WzpOboeConfig {
|
|
||||||
sample_rate: i32,
|
|
||||||
frames_per_burst: i32,
|
|
||||||
channel_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct WzpOboeRings {
|
|
||||||
capture_buf: *mut i16,
|
|
||||||
capture_capacity: i32,
|
|
||||||
capture_write_idx: *mut AtomicI32,
|
|
||||||
capture_read_idx: *mut AtomicI32,
|
|
||||||
|
|
||||||
playout_buf: *mut i16,
|
|
||||||
playout_capacity: i32,
|
|
||||||
playout_write_idx: *mut AtomicI32,
|
|
||||||
playout_read_idx: *mut AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl Send for WzpOboeRings {}
|
|
||||||
unsafe impl Sync for WzpOboeRings {}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
|
||||||
fn wzp_oboe_stop();
|
|
||||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_is_running() -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SPSC Ring Buffer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Single-producer single-consumer lock-free ring buffer.
|
|
||||||
///
|
|
||||||
/// The producer calls `write()` and the consumer calls `read()`.
|
|
||||||
/// Atomics use acquire/release ordering to ensure correct visibility
|
|
||||||
/// across the Oboe audio thread and the Rust codec thread.
|
|
||||||
pub struct RingBuffer {
|
|
||||||
buf: Vec<i16>,
|
|
||||||
capacity: usize,
|
|
||||||
write_idx: AtomicI32,
|
|
||||||
read_idx: AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
/// Create a new ring buffer with the given capacity (in samples).
|
|
||||||
///
|
|
||||||
/// The actual usable capacity is `capacity - 1` to distinguish
|
|
||||||
/// full from empty.
|
|
||||||
pub fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; capacity],
|
|
||||||
capacity,
|
|
||||||
write_idx: AtomicI32::new(0),
|
|
||||||
read_idx: AtomicI32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples available to read.
|
|
||||||
pub fn available_read(&self) -> usize {
|
|
||||||
let w = self.write_idx.load(Ordering::Acquire);
|
|
||||||
let r = self.read_idx.load(Ordering::Relaxed);
|
|
||||||
let avail = w - r;
|
|
||||||
if avail < 0 {
|
|
||||||
(avail + self.capacity as i32) as usize
|
|
||||||
} else {
|
|
||||||
avail as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples that can be written before the buffer is full.
|
|
||||||
pub fn available_write(&self) -> usize {
|
|
||||||
self.capacity - 1 - self.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write samples into the ring buffer (producer side).
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written (may be less than
|
|
||||||
/// `data.len()` if the buffer is nearly full).
|
|
||||||
pub fn write(&self, data: &[i16]) -> usize {
|
|
||||||
let avail = self.available_write();
|
|
||||||
let count = data.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
// SAFETY: w is always in [0, capacity) and we are the sole producer.
|
|
||||||
unsafe {
|
|
||||||
*buf_ptr.add(w) = data[i];
|
|
||||||
}
|
|
||||||
w += 1;
|
|
||||||
if w >= cap {
|
|
||||||
w = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.write_idx.store(w as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read samples from the ring buffer (consumer side).
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually read (may be less than
|
|
||||||
/// `out.len()` if the buffer doesn't have enough data).
|
|
||||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let avail = self.available_read();
|
|
||||||
let count = out.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr();
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
// SAFETY: r is always in [0, capacity) and we are the sole consumer.
|
|
||||||
unsafe {
|
|
||||||
out[i] = *buf_ptr.add(r);
|
|
||||||
}
|
|
||||||
r += 1;
|
|
||||||
if r >= cap {
|
|
||||||
r = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.read_idx.store(r as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the buffer data (for FFI).
|
|
||||||
fn buf_ptr(&self) -> *mut i16 {
|
|
||||||
self.buf.as_ptr() as *mut i16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the write index atomic (for FFI).
|
|
||||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the read index atomic (for FFI).
|
|
||||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: The ring buffer is designed for SPSC use where producer and consumer
|
|
||||||
// are on different threads. The atomic indices provide the synchronization.
|
|
||||||
unsafe impl Send for RingBuffer {}
|
|
||||||
unsafe impl Sync for RingBuffer {}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Oboe Backend
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Oboe-based audio backend for Android.
|
|
||||||
///
|
|
||||||
/// Owns two SPSC ring buffers (capture and playout) that are shared with
|
|
||||||
/// the C++ Oboe callbacks via raw pointers. The Oboe callbacks run on
|
|
||||||
/// high-priority audio threads managed by the Android audio system.
|
|
||||||
pub struct OboeBackend {
|
|
||||||
capture_ring: RingBuffer,
|
|
||||||
playout_ring: RingBuffer,
|
|
||||||
started: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OboeBackend {
|
|
||||||
/// Create a new backend with default ring buffer sizes (160 ms each).
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
capture_ring: RingBuffer::new(RING_CAPACITY),
|
|
||||||
playout_ring: RingBuffer::new(RING_CAPACITY),
|
|
||||||
started: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start Oboe audio streams.
|
|
||||||
///
|
|
||||||
/// This sets up the ring buffer pointers and calls into the C++ layer
|
|
||||||
/// to open and start the capture and playout Oboe streams.
|
|
||||||
pub fn start(&mut self) -> Result<(), anyhow::Error> {
|
|
||||||
if self.started {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: self.capture_ring.buf_ptr(),
|
|
||||||
capture_capacity: self.capture_ring.capacity as i32,
|
|
||||||
capture_write_idx: self.capture_ring.write_idx_ptr(),
|
|
||||||
capture_read_idx: self.capture_ring.read_idx_ptr(),
|
|
||||||
|
|
||||||
playout_buf: self.playout_ring.buf_ptr(),
|
|
||||||
playout_capacity: self.playout_ring.capacity as i32,
|
|
||||||
playout_write_idx: self.playout_ring.write_idx_ptr(),
|
|
||||||
playout_read_idx: self.playout_ring.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret != 0 {
|
|
||||||
return Err(anyhow::anyhow!("wzp_oboe_start failed with code {}", ret));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.started = true;
|
|
||||||
info!("Oboe backend started");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Oboe audio streams.
|
|
||||||
pub fn stop(&mut self) {
|
|
||||||
if !self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
self.started = false;
|
|
||||||
info!("Oboe backend stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured audio samples from the capture ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually read. The caller should
|
|
||||||
/// provide a buffer of at least `FRAME_SAMPLES` (960) samples.
|
|
||||||
pub fn read_capture(&self, out: &mut [i16]) -> usize {
|
|
||||||
self.capture_ring.read(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write audio samples to the playout ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written.
|
|
||||||
pub fn write_playout(&self, samples: &[i16]) -> usize {
|
|
||||||
self.playout_ring.write(samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current capture latency in milliseconds (from Oboe).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn capture_latency_ms(&self) -> f32 {
|
|
||||||
unsafe { wzp_oboe_capture_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current playout latency in milliseconds (from Oboe).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn playout_latency_ms(&self) -> f32 {
|
|
||||||
unsafe { wzp_oboe_playout_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the Oboe streams are currently running.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
unsafe { wzp_oboe_is_running() != 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for OboeBackend {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Thread affinity / priority helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pin the current thread to the highest-numbered CPU cores (big cores on
|
|
||||||
/// ARM big.LITTLE architectures). Falls back silently on failure.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn pin_to_big_core() {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
unsafe {
|
|
||||||
let num_cpus = libc::sysconf(libc::_SC_NPROCESSORS_ONLN);
|
|
||||||
if num_cpus <= 0 {
|
|
||||||
warn!("pin_to_big_core: could not determine CPU count");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let num_cpus = num_cpus as usize;
|
|
||||||
|
|
||||||
// Target the upper half of CPUs (big cores on most big.LITTLE SoCs)
|
|
||||||
let start = num_cpus / 2;
|
|
||||||
let mut set: libc::cpu_set_t = std::mem::zeroed();
|
|
||||||
libc::CPU_ZERO(&mut set);
|
|
||||||
for cpu in start..num_cpus {
|
|
||||||
libc::CPU_SET(cpu, &mut set);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ret = libc::sched_setaffinity(
|
|
||||||
0, // current thread
|
|
||||||
std::mem::size_of::<libc::cpu_set_t>(),
|
|
||||||
&set,
|
|
||||||
);
|
|
||||||
if ret != 0 {
|
|
||||||
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
|
||||||
} else {
|
|
||||||
info!(start, num_cpus, "pinned to big cores");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
// No-op on non-Android
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to set SCHED_FIFO real-time priority for the current thread.
|
|
||||||
/// Falls back silently on failure (requires appropriate permissions on Android).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn set_realtime_priority() {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
unsafe {
|
|
||||||
let param = libc::sched_param {
|
|
||||||
sched_priority: 2, // Low RT priority — enough for audio, safe
|
|
||||||
};
|
|
||||||
let ret = libc::sched_setscheduler(0, libc::SCHED_FIFO, ¶m);
|
|
||||||
if ret != 0 {
|
|
||||||
warn!(
|
|
||||||
"sched_setscheduler(SCHED_FIFO) failed: {}",
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!("set SCHED_FIFO priority 2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
// No-op on non-Android
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_write_read() {
|
|
||||||
let ring = RingBuffer::new(16);
|
|
||||||
let data = [1i16, 2, 3, 4, 5];
|
|
||||||
assert_eq!(ring.write(&data), 5);
|
|
||||||
assert_eq!(ring.available_read(), 5);
|
|
||||||
|
|
||||||
let mut out = [0i16; 5];
|
|
||||||
assert_eq!(ring.read(&mut out), 5);
|
|
||||||
assert_eq!(out, [1, 2, 3, 4, 5]);
|
|
||||||
assert_eq!(ring.available_read(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_wraparound() {
|
|
||||||
let ring = RingBuffer::new(8);
|
|
||||||
let data = [10i16, 20, 30, 40, 50, 60]; // 6 samples, capacity 8 (usable 7)
|
|
||||||
assert_eq!(ring.write(&data), 6);
|
|
||||||
|
|
||||||
let mut out = [0i16; 4];
|
|
||||||
assert_eq!(ring.read(&mut out), 4);
|
|
||||||
assert_eq!(out, [10, 20, 30, 40]);
|
|
||||||
|
|
||||||
// Now write more, which should wrap around
|
|
||||||
let data2 = [70i16, 80, 90, 100];
|
|
||||||
assert_eq!(ring.write(&data2), 4);
|
|
||||||
|
|
||||||
let mut out2 = [0i16; 6];
|
|
||||||
assert_eq!(ring.read(&mut out2), 6);
|
|
||||||
assert_eq!(out2, [50, 60, 70, 80, 90, 100]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_full() {
|
|
||||||
let ring = RingBuffer::new(4); // usable capacity = 3
|
|
||||||
let data = [1i16, 2, 3, 4, 5];
|
|
||||||
assert_eq!(ring.write(&data), 3); // Only 3 fit
|
|
||||||
assert_eq!(ring.available_write(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oboe_backend_stub_start_stop() {
|
|
||||||
let mut backend = OboeBackend::new();
|
|
||||||
backend.start().expect("stub start should succeed");
|
|
||||||
assert!(backend.started);
|
|
||||||
backend.stop();
|
|
||||||
assert!(!backend.started);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
//! Engine commands sent from the JNI/UI thread to the engine.
|
|
||||||
|
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
/// Commands that can be sent to the running engine.
|
|
||||||
pub enum EngineCommand {
|
|
||||||
/// Mute or unmute the microphone.
|
|
||||||
SetMute(bool),
|
|
||||||
/// Enable or disable speaker (loudspeaker) mode.
|
|
||||||
SetSpeaker(bool),
|
|
||||||
/// Force a specific quality profile (overrides adaptive logic).
|
|
||||||
ForceProfile(QualityProfile),
|
|
||||||
/// Stop the call and shut down the engine.
|
|
||||||
Stop,
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
//! Engine orchestrator — manages the call lifecycle.
|
|
||||||
//!
|
|
||||||
//! The engine owns:
|
|
||||||
//! - The Oboe audio backend (start/stop)
|
|
||||||
//! - A codec thread running the `Pipeline`
|
|
||||||
//! - A tokio runtime for async network I/O
|
|
||||||
//! - Command channel for control from the JNI/UI thread
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
use crate::audio_android::{OboeBackend, FRAME_SAMPLES};
|
|
||||||
use crate::commands::EngineCommand;
|
|
||||||
use crate::pipeline::Pipeline;
|
|
||||||
use crate::stats::{CallState, CallStats};
|
|
||||||
|
|
||||||
/// Configuration to start a call.
|
|
||||||
pub struct CallStartConfig {
|
|
||||||
/// Initial quality profile.
|
|
||||||
pub profile: QualityProfile,
|
|
||||||
/// Relay server address (host:port).
|
|
||||||
pub relay_addr: String,
|
|
||||||
/// Authentication token for the relay.
|
|
||||||
pub auth_token: Vec<u8>,
|
|
||||||
/// 32-byte identity seed for key derivation.
|
|
||||||
pub identity_seed: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CallStartConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
profile: QualityProfile::GOOD,
|
|
||||||
relay_addr: String::new(),
|
|
||||||
auth_token: Vec::new(),
|
|
||||||
identity_seed: [0u8; 32],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared state between the engine owner and background threads.
|
|
||||||
struct EngineState {
|
|
||||||
running: AtomicBool,
|
|
||||||
muted: AtomicBool,
|
|
||||||
speaker: AtomicBool,
|
|
||||||
/// Whether acoustic echo cancellation is enabled (default: true).
|
|
||||||
aec_enabled: AtomicBool,
|
|
||||||
/// Whether automatic gain control is enabled (default: true).
|
|
||||||
agc_enabled: AtomicBool,
|
|
||||||
stats: Mutex<CallStats>,
|
|
||||||
command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
|
||||||
command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The WarzonePhone Android engine.
|
|
||||||
///
|
|
||||||
/// Manages the entire call pipeline: audio capture/playout via Oboe,
|
|
||||||
/// codec encode/decode, FEC, jitter buffer, and network transport.
|
|
||||||
///
|
|
||||||
/// Thread model:
|
|
||||||
/// - **UI/JNI thread**: calls `start_call`, `stop_call`, `set_mute`, etc.
|
|
||||||
/// - **Codec thread**: runs `Pipeline` encode/decode loop, reads/writes ring buffers
|
|
||||||
/// - **Tokio runtime** (2 worker threads): async network send/recv
|
|
||||||
pub struct WzpEngine {
|
|
||||||
state: Arc<EngineState>,
|
|
||||||
codec_thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
#[allow(unused)]
|
|
||||||
tokio_runtime: Option<tokio::runtime::Runtime>,
|
|
||||||
call_start: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WzpEngine {
|
|
||||||
/// Create a new idle engine.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
let state = Arc::new(EngineState {
|
|
||||||
running: AtomicBool::new(false),
|
|
||||||
muted: AtomicBool::new(false),
|
|
||||||
speaker: AtomicBool::new(false),
|
|
||||||
aec_enabled: AtomicBool::new(true),
|
|
||||||
agc_enabled: AtomicBool::new(true),
|
|
||||||
stats: Mutex::new(CallStats::default()),
|
|
||||||
command_tx: tx,
|
|
||||||
command_rx: Mutex::new(Some(rx)),
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
state,
|
|
||||||
codec_thread: None,
|
|
||||||
tokio_runtime: None,
|
|
||||||
call_start: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a call with the given configuration.
|
|
||||||
///
|
|
||||||
/// This creates the tokio runtime, starts the Oboe audio backend,
|
|
||||||
/// and spawns the codec thread.
|
|
||||||
pub fn start_call(&mut self, config: CallStartConfig) -> Result<(), anyhow::Error> {
|
|
||||||
if self.state.running.load(Ordering::Acquire) {
|
|
||||||
return Err(anyhow::anyhow!("call already active"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
{
|
|
||||||
let mut stats = self.state.stats.lock().unwrap();
|
|
||||||
*stats = CallStats {
|
|
||||||
state: CallState::Connecting,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tokio runtime with 2 worker threads
|
|
||||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
||||||
.worker_threads(2)
|
|
||||||
.thread_name("wzp-net")
|
|
||||||
.enable_all()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
// Create async channels for network send/recv
|
|
||||||
let (send_tx, mut _send_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
|
||||||
let (_recv_tx, mut recv_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
|
||||||
|
|
||||||
// Spawn network tasks (placeholder — will use wzp-transport)
|
|
||||||
let _relay_addr = config.relay_addr.clone();
|
|
||||||
runtime.spawn(async move {
|
|
||||||
// Network send task: reads from send_rx, sends via transport
|
|
||||||
// This will be implemented when wzp-transport Android support is added
|
|
||||||
while let Some(_packet) = _send_rx.recv().await {
|
|
||||||
// TODO: send via wzp-transport
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let recv_tx_clone = _recv_tx.clone();
|
|
||||||
runtime.spawn(async move {
|
|
||||||
// Network recv task: reads from transport, writes to recv_rx
|
|
||||||
// This will be implemented when wzp-transport Android support is added
|
|
||||||
let _tx = recv_tx_clone;
|
|
||||||
// TODO: recv from wzp-transport and forward
|
|
||||||
});
|
|
||||||
|
|
||||||
// Take the command receiver (it can only be taken once)
|
|
||||||
let command_rx = self
|
|
||||||
.state
|
|
||||||
.command_rx
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("command receiver already taken"))?;
|
|
||||||
|
|
||||||
// Start the codec thread
|
|
||||||
let state = self.state.clone();
|
|
||||||
let profile = config.profile;
|
|
||||||
let codec_thread = std::thread::Builder::new()
|
|
||||||
.name("wzp-codec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
// Pin to big cores and set RT priority on Android
|
|
||||||
crate::audio_android::pin_to_big_core();
|
|
||||||
crate::audio_android::set_realtime_priority();
|
|
||||||
|
|
||||||
// Create audio backend
|
|
||||||
let mut audio = OboeBackend::new();
|
|
||||||
if let Err(e) = audio.start() {
|
|
||||||
error!("failed to start audio: {e}");
|
|
||||||
state.running.store(false, Ordering::Release);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pipeline
|
|
||||||
let mut pipeline = match Pipeline::new(profile) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to create pipeline: {e}");
|
|
||||||
audio.stop();
|
|
||||||
state.running.store(false, Ordering::Release);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
state.running.store(true, Ordering::Release);
|
|
||||||
{
|
|
||||||
let mut stats = state.stats.lock().unwrap();
|
|
||||||
stats.state = CallState::Active;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("codec thread started");
|
|
||||||
|
|
||||||
// Track the last-applied AEC/AGC state so we only call
|
|
||||||
// set_*_enabled when the value actually changes.
|
|
||||||
let mut prev_aec = true;
|
|
||||||
let mut prev_agc = true;
|
|
||||||
|
|
||||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
|
||||||
#[allow(unused_assignments)]
|
|
||||||
let mut recv_buf: Vec<u8> = Vec::new();
|
|
||||||
|
|
||||||
// Main codec loop: 20ms per iteration
|
|
||||||
let frame_duration = std::time::Duration::from_millis(20);
|
|
||||||
|
|
||||||
while state.running.load(Ordering::Relaxed) {
|
|
||||||
let loop_start = Instant::now();
|
|
||||||
|
|
||||||
// Process commands (non-blocking)
|
|
||||||
while let Ok(cmd) = command_rx.try_recv() {
|
|
||||||
match cmd {
|
|
||||||
EngineCommand::SetMute(m) => {
|
|
||||||
state.muted.store(m, Ordering::Relaxed);
|
|
||||||
info!(muted = m, "mute toggled");
|
|
||||||
}
|
|
||||||
EngineCommand::SetSpeaker(s) => {
|
|
||||||
state.speaker.store(s, Ordering::Relaxed);
|
|
||||||
info!(speaker = s, "speaker toggled");
|
|
||||||
}
|
|
||||||
EngineCommand::ForceProfile(p) => {
|
|
||||||
pipeline.force_profile(p);
|
|
||||||
info!(?p, "profile forced");
|
|
||||||
}
|
|
||||||
EngineCommand::Stop => {
|
|
||||||
info!("stop command received");
|
|
||||||
state.running.store(false, Ordering::Release);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync AEC/AGC enabled flags from shared state.
|
|
||||||
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
|
|
||||||
if cur_aec != prev_aec {
|
|
||||||
pipeline.set_aec_enabled(cur_aec);
|
|
||||||
prev_aec = cur_aec;
|
|
||||||
}
|
|
||||||
let cur_agc = state.agc_enabled.load(Ordering::Relaxed);
|
|
||||||
if cur_agc != prev_agc {
|
|
||||||
pipeline.set_agc_enabled(cur_agc);
|
|
||||||
prev_agc = cur_agc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !state.running.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Capture → Encode → Send ---
|
|
||||||
let captured = audio.read_capture(&mut capture_buf);
|
|
||||||
if captured >= FRAME_SAMPLES {
|
|
||||||
let muted = state.muted.load(Ordering::Relaxed);
|
|
||||||
if let Some(encoded) = pipeline.encode_frame(&capture_buf, muted) {
|
|
||||||
// Send to network (best-effort)
|
|
||||||
let _ = send_tx.try_send(encoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Recv → Decode → Playout ---
|
|
||||||
// Drain received packets from the network channel
|
|
||||||
while let Ok(data) = recv_rx.try_recv() {
|
|
||||||
recv_buf = data;
|
|
||||||
// Deserialize the packet and feed to pipeline
|
|
||||||
// For now, feed raw bytes — full MediaPacket deserialization
|
|
||||||
// will be added with the transport integration
|
|
||||||
let _ = &recv_buf; // suppress unused warning
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode from jitter buffer
|
|
||||||
if let Some(pcm) = pipeline.decode_frame() {
|
|
||||||
audio.write_playout(&pcm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update stats ---
|
|
||||||
{
|
|
||||||
let pstats = pipeline.stats();
|
|
||||||
let mut stats = state.stats.lock().unwrap();
|
|
||||||
stats.frames_encoded = pstats.frames_encoded;
|
|
||||||
stats.frames_decoded = pstats.frames_decoded;
|
|
||||||
stats.underruns = pstats.underruns;
|
|
||||||
stats.jitter_buffer_depth = pstats.jitter_depth;
|
|
||||||
stats.quality_tier = pstats.quality_tier;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sleep for remainder of the 20ms frame period
|
|
||||||
let elapsed = loop_start.elapsed();
|
|
||||||
if elapsed < frame_duration {
|
|
||||||
std::thread::sleep(frame_duration - elapsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
audio.stop();
|
|
||||||
{
|
|
||||||
let mut stats = state.stats.lock().unwrap();
|
|
||||||
stats.state = CallState::Closed;
|
|
||||||
}
|
|
||||||
info!("codec thread exited");
|
|
||||||
})?;
|
|
||||||
|
|
||||||
self.codec_thread = Some(codec_thread);
|
|
||||||
self.tokio_runtime = Some(runtime);
|
|
||||||
self.call_start = Some(Instant::now());
|
|
||||||
|
|
||||||
info!("call started");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the current call and clean up all resources.
|
|
||||||
pub fn stop_call(&mut self) {
|
|
||||||
if !self.state.running.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal stop
|
|
||||||
self.state.running.store(false, Ordering::Release);
|
|
||||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
|
||||||
|
|
||||||
// Join codec thread
|
|
||||||
if let Some(handle) = self.codec_thread.take() {
|
|
||||||
if let Err(e) = handle.join() {
|
|
||||||
warn!("codec thread panicked: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shut down tokio runtime
|
|
||||||
if let Some(rt) = self.tokio_runtime.take() {
|
|
||||||
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.call_start = None;
|
|
||||||
info!("call stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set microphone mute state.
|
|
||||||
pub fn set_mute(&self, muted: bool) {
|
|
||||||
let _ = self.state.command_tx.send(EngineCommand::SetMute(muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set speaker (loudspeaker) mode.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn set_speaker(&self, enabled: bool) {
|
|
||||||
let _ = self
|
|
||||||
.state
|
|
||||||
.command_tx
|
|
||||||
.send(EngineCommand::SetSpeaker(enabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable acoustic echo cancellation.
|
|
||||||
pub fn set_aec_enabled(&self, enabled: bool) {
|
|
||||||
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable automatic gain control.
|
|
||||||
pub fn set_agc_enabled(&self, enabled: bool) {
|
|
||||||
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force a specific quality profile (overrides adaptive logic).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn force_profile(&self, profile: QualityProfile) {
|
|
||||||
let _ = self
|
|
||||||
.state
|
|
||||||
.command_tx
|
|
||||||
.send(EngineCommand::ForceProfile(profile));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a snapshot of the current call statistics.
|
|
||||||
pub fn get_stats(&self) -> CallStats {
|
|
||||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
|
||||||
// Update duration from wall clock
|
|
||||||
if let Some(start) = self.call_start {
|
|
||||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
|
||||||
}
|
|
||||||
stats
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a call is currently active.
|
|
||||||
pub fn is_active(&self) -> bool {
|
|
||||||
self.state.running.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Destroy the engine, stopping any active call.
|
|
||||||
pub fn destroy(mut self) {
|
|
||||||
self.stop_call();
|
|
||||||
info!("engine destroyed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WzpEngine {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop_call();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
|
|
||||||
//!
|
|
||||||
//! Each function converts JNI types to Rust types, delegates to WzpEngine,
|
|
||||||
//! and converts results back. No audio processing happens here.
|
|
||||||
//!
|
|
||||||
//! # Safety
|
|
||||||
//!
|
|
||||||
//! All functions in this module are called from the JVM via JNI. They use raw
|
|
||||||
//! pointers for the JNI environment and object references. The `jni` crate is
|
|
||||||
//! not yet a dependency, so we use raw FFI types and placeholder string extraction.
|
|
||||||
//! When the `jni` crate is added, the `extract_jstring` helper should be replaced
|
|
||||||
//! with proper `JNIEnv::get_string()` calls.
|
|
||||||
|
|
||||||
use std::os::raw::{c_long, c_void};
|
|
||||||
use std::panic;
|
|
||||||
|
|
||||||
use tracing::{error, info};
|
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
use crate::engine::{CallStartConfig, WzpEngine};
|
|
||||||
|
|
||||||
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
|
|
||||||
///
|
|
||||||
/// Boxed on the heap; the raw pointer is stored on the Kotlin side.
|
|
||||||
/// Only `nativeDestroy` frees it.
|
|
||||||
struct EngineHandle {
|
|
||||||
engine: WzpEngine,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JNI type aliases (mirrors the C JNI ABI without pulling in the `jni` crate)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// JNI boolean — `u8` where 0 = false, non-zero = true.
|
|
||||||
type JBoolean = u8;
|
|
||||||
|
|
||||||
/// JNI int — `i32`.
|
|
||||||
type JInt = i32;
|
|
||||||
|
|
||||||
/// JNI long — `i64` / `c_long` on 64-bit.
|
|
||||||
type JLong = c_long;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Recover the `EngineHandle` from a raw handle value **without** taking ownership.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `handle` must be a value previously returned by `nativeInit` and not yet
|
|
||||||
/// passed to `nativeDestroy`.
|
|
||||||
unsafe fn handle_ref(handle: JLong) -> &'static mut EngineHandle {
|
|
||||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder: extract a `String` from a JNI `jstring`.
|
|
||||||
///
|
|
||||||
/// When the `jni` crate is added this should be replaced with:
|
|
||||||
/// ```ignore
|
|
||||||
/// let env = JNIEnv::from_raw(env_ptr).unwrap();
|
|
||||||
/// env.get_string(jstring).unwrap().into()
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `_env` and `_jstring` are raw JNI pointers.
|
|
||||||
#[allow(unused)]
|
|
||||||
unsafe fn extract_jstring(_env: *mut c_void, _jstring: *mut c_void) -> String {
|
|
||||||
// TODO(jni): implement real string extraction once the `jni` crate is added.
|
|
||||||
// For now return a default so the rest of the bridge compiles and can be tested
|
|
||||||
// with hardcoded values from the Kotlin side.
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allocate a JNI `jstring` from a Rust `&str`.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `_env` is a raw JNI pointer.
|
|
||||||
#[allow(unused)]
|
|
||||||
unsafe fn new_jstring(_env: *mut c_void, _s: &str) -> *mut c_void {
|
|
||||||
// TODO(jni): implement via JNIEnv::new_string when jni crate is added.
|
|
||||||
std::ptr::null_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a Kotlin `profile` int to a `QualityProfile`.
|
|
||||||
fn profile_from_int(value: JInt) -> QualityProfile {
|
|
||||||
match value {
|
|
||||||
1 => QualityProfile::DEGRADED,
|
|
||||||
2 => QualityProfile::CATASTROPHIC,
|
|
||||||
_ => QualityProfile::GOOD,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// JNI exports
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Function names follow JNI convention: Java_<package>_<Class>_<method>
|
|
||||||
// with underscores in the package replaced by `_1` in actual JNI but here we
|
|
||||||
// use the simplified form that matches javah output for the package `com.wzp.engine`.
|
|
||||||
|
|
||||||
/// Create a new `WzpEngine`, returning an opaque handle as `jlong`.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeInit(): Long`
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
) -> JLong {
|
|
||||||
let result = panic::catch_unwind(|| {
|
|
||||||
// Note: tracing on Android requires android_logger or similar.
|
|
||||||
// fmt() subscriber writes to stdout which doesn't exist on Android.
|
|
||||||
// Skip tracing init here — add android_logger later.
|
|
||||||
|
|
||||||
let handle = Box::new(EngineHandle {
|
|
||||||
engine: WzpEngine::new(),
|
|
||||||
});
|
|
||||||
info!("WzpEngine created via JNI");
|
|
||||||
Box::into_raw(handle) as JLong
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(_) => {
|
|
||||||
error!("panic in nativeInit");
|
|
||||||
0 // null handle — Kotlin side checks for 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a call.
|
|
||||||
///
|
|
||||||
/// Kotlin signature:
|
|
||||||
/// ```kotlin
|
|
||||||
/// private external fun nativeStartCall(
|
|
||||||
/// handle: Long, relay: String, room: String, seed: String, token: String
|
|
||||||
/// ): Int
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI. `handle` must be a live engine handle.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|
||||||
env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
relay_addr_ptr: *mut c_void,
|
|
||||||
room_ptr: *mut c_void,
|
|
||||||
seed_hex_ptr: *mut c_void,
|
|
||||||
token_ptr: *mut c_void,
|
|
||||||
) -> JInt {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
|
|
||||||
// Extract strings from JNI. When the `jni` crate is available these
|
|
||||||
// will use real JNI string conversion. For now, placeholders.
|
|
||||||
let relay_addr = unsafe { extract_jstring(env, relay_addr_ptr) };
|
|
||||||
let _room = unsafe { extract_jstring(env, room_ptr) };
|
|
||||||
let seed_hex = unsafe { extract_jstring(env, seed_hex_ptr) };
|
|
||||||
let token = unsafe { extract_jstring(env, token_ptr) };
|
|
||||||
|
|
||||||
// Parse the hex-encoded 32-byte identity seed.
|
|
||||||
let mut identity_seed = [0u8; 32];
|
|
||||||
if seed_hex.len() == 64 {
|
|
||||||
for i in 0..32 {
|
|
||||||
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
|
|
||||||
identity_seed[i] = byte;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = CallStartConfig {
|
|
||||||
profile: QualityProfile::GOOD,
|
|
||||||
relay_addr,
|
|
||||||
auth_token: token.into_bytes(),
|
|
||||||
identity_seed,
|
|
||||||
};
|
|
||||||
|
|
||||||
match h.engine.start_call(config) {
|
|
||||||
Ok(()) => {
|
|
||||||
info!("call started via JNI");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("start_call failed: {e}");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(code) => code,
|
|
||||||
Err(_) => {
|
|
||||||
error!("panic in nativeStartCall");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the active call.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeStopCall(handle: Long)`
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
h.engine.stop_call();
|
|
||||||
info!("call stopped via JNI");
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set microphone mute state.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeSetMute(handle: Long, muted: Boolean)`
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
muted: JBoolean,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let muted = muted != 0;
|
|
||||||
h.engine.set_mute(muted);
|
|
||||||
info!(muted, "mute set via JNI");
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set speaker (loudspeaker) mode.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)`
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
speaker: JBoolean,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let speaker = speaker != 0;
|
|
||||||
h.engine.set_speaker(speaker);
|
|
||||||
info!(speaker, "speaker set via JNI");
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get call statistics as a JSON string.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeGetStats(handle: Long): String`
|
|
||||||
///
|
|
||||||
/// Returns a JSON-serialized `CallStats` struct, or `"{}"` on error.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats(
|
|
||||||
env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
) -> *mut c_void {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let stats = h.engine.get_stats();
|
|
||||||
match serde_json::to_string(&stats) {
|
|
||||||
Ok(json) => unsafe { new_jstring(env, &json) },
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to serialize stats: {e}");
|
|
||||||
unsafe { new_jstring(env, "{}") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(ptr) => ptr,
|
|
||||||
Err(_) => {
|
|
||||||
error!("panic in nativeGetStats");
|
|
||||||
unsafe { new_jstring(env, "{}") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force a specific quality profile, overriding adaptive logic.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeForceProfile(handle: Long, profile: Int)`
|
|
||||||
///
|
|
||||||
/// Profile values: 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
profile: JInt,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let qp = profile_from_int(profile);
|
|
||||||
h.engine.force_profile(qp);
|
|
||||||
info!(?qp, "profile forced via JNI");
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Destroy the engine and free all associated memory.
|
|
||||||
///
|
|
||||||
/// After this call the handle is invalid and must not be reused.
|
|
||||||
///
|
|
||||||
/// Kotlin signature: `private external fun nativeDestroy(handle: Long)`
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// Called from JNI. `handle` must be a live engine handle. After this call
|
|
||||||
/// the handle is dangling.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|
||||||
_env: *mut c_void,
|
|
||||||
_class: *mut c_void,
|
|
||||||
handle: JLong,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
// Retake ownership of the Box and drop it, which calls WzpEngine::drop()
|
|
||||||
// and in turn stop_call().
|
|
||||||
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
|
|
||||||
drop(h);
|
|
||||||
info!("engine destroyed via JNI");
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
//! WarzonePhone Android native VoIP engine.
|
|
||||||
//!
|
|
||||||
//! Provides:
|
|
||||||
//! - Oboe audio backend with lock-free SPSC ring buffers
|
|
||||||
//! - Engine orchestrator managing call lifecycle
|
|
||||||
//! - Codec pipeline thread (encode/decode/FEC/jitter)
|
|
||||||
//! - Call statistics and command interface
|
|
||||||
//!
|
|
||||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
|
||||||
//! allowing `cargo check` and unit tests on the host.
|
|
||||||
|
|
||||||
pub mod audio_android;
|
|
||||||
pub mod commands;
|
|
||||||
pub mod engine;
|
|
||||||
pub mod pipeline;
|
|
||||||
pub mod stats;
|
|
||||||
pub mod jni_bridge;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
//! Codec pipeline — encode/decode with FEC and jitter buffer.
|
|
||||||
//!
|
|
||||||
//! Runs on a dedicated thread, processing 20 ms frames at 48 kHz.
|
|
||||||
//! The pipeline is NOT Send/Sync (Opus encoder state) — it is owned
|
|
||||||
//! exclusively by the codec thread.
|
|
||||||
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
|
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
|
||||||
use wzp_proto::quality::AdaptiveQualityController;
|
|
||||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
|
||||||
use wzp_proto::traits::QualityController;
|
|
||||||
use wzp_proto::{MediaPacket, QualityProfile};
|
|
||||||
|
|
||||||
use crate::audio_android::FRAME_SAMPLES;
|
|
||||||
|
|
||||||
/// Maximum encoded frame size (Opus worst case at highest bitrate).
|
|
||||||
const MAX_ENCODED_BYTES: usize = 1275;
|
|
||||||
|
|
||||||
/// Pipeline statistics snapshot.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct PipelineStats {
|
|
||||||
pub frames_encoded: u64,
|
|
||||||
pub frames_decoded: u64,
|
|
||||||
pub underruns: u64,
|
|
||||||
pub jitter_depth: usize,
|
|
||||||
pub quality_tier: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The codec pipeline: encode, FEC, jitter buffer, decode.
|
|
||||||
///
|
|
||||||
/// This struct is owned by the codec thread and not shared.
|
|
||||||
pub struct Pipeline {
|
|
||||||
encoder: AdaptiveEncoder,
|
|
||||||
decoder: AdaptiveDecoder,
|
|
||||||
fec_encoder: RaptorQFecEncoder,
|
|
||||||
fec_decoder: RaptorQFecDecoder,
|
|
||||||
jitter_buffer: JitterBuffer,
|
|
||||||
quality_ctrl: AdaptiveQualityController,
|
|
||||||
/// Acoustic echo canceller applied before encoding.
|
|
||||||
aec: EchoCanceller,
|
|
||||||
/// Automatic gain control applied before encoding.
|
|
||||||
agc: AutoGainControl,
|
|
||||||
/// Last decoded PCM frame, used as the AEC far-end reference.
|
|
||||||
last_decoded_farend: Option<Vec<i16>>,
|
|
||||||
// Pre-allocated scratch buffers
|
|
||||||
capture_buf: Vec<i16>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
playout_buf: Vec<i16>,
|
|
||||||
encode_out: Vec<u8>,
|
|
||||||
// Stats counters
|
|
||||||
frames_encoded: u64,
|
|
||||||
frames_decoded: u64,
|
|
||||||
underruns: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pipeline {
|
|
||||||
/// Create a new pipeline configured for the given quality profile.
|
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
|
||||||
let encoder = AdaptiveEncoder::new(profile)
|
|
||||||
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
|
||||||
let decoder = AdaptiveDecoder::new(profile)
|
|
||||||
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
|
||||||
let fec_encoder =
|
|
||||||
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
|
||||||
let fec_decoder =
|
|
||||||
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
|
||||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
|
||||||
let quality_ctrl = AdaptiveQualityController::new();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
encoder,
|
|
||||||
decoder,
|
|
||||||
fec_encoder,
|
|
||||||
fec_decoder,
|
|
||||||
jitter_buffer,
|
|
||||||
quality_ctrl,
|
|
||||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
|
||||||
agc: AutoGainControl::new(),
|
|
||||||
last_decoded_farend: None,
|
|
||||||
capture_buf: vec![0i16; FRAME_SAMPLES],
|
|
||||||
playout_buf: vec![0i16; FRAME_SAMPLES],
|
|
||||||
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
|
||||||
frames_encoded: 0,
|
|
||||||
frames_decoded: 0,
|
|
||||||
underruns: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a PCM frame into a compressed packet.
|
|
||||||
///
|
|
||||||
/// If `muted` is true, a silence frame is encoded (all zeros).
|
|
||||||
/// Returns the encoded bytes, or `None` on encoder error.
|
|
||||||
pub fn encode_frame(&mut self, pcm: &[i16], muted: bool) -> Option<Vec<u8>> {
|
|
||||||
let input = if muted {
|
|
||||||
// Zero the capture buffer for silence
|
|
||||||
for s in self.capture_buf.iter_mut() {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
&self.capture_buf[..]
|
|
||||||
} else {
|
|
||||||
// Feed the last decoded playout as AEC far-end reference.
|
|
||||||
if let Some(ref farend) = self.last_decoded_farend {
|
|
||||||
self.aec.feed_farend(farend);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply AEC + AGC to the captured PCM.
|
|
||||||
let len = pcm.len().min(self.capture_buf.len());
|
|
||||||
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
|
|
||||||
self.aec.process_frame(&mut self.capture_buf[..len]);
|
|
||||||
self.agc.process_frame(&mut self.capture_buf[..len]);
|
|
||||||
&self.capture_buf[..len]
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.encoder.encode(input, &mut self.encode_out) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_encoded += 1;
|
|
||||||
let encoded = self.encode_out[..n].to_vec();
|
|
||||||
|
|
||||||
// Feed into FEC encoder
|
|
||||||
if let Err(e) = self.fec_encoder.add_source_symbol(&encoded) {
|
|
||||||
warn!("FEC encode error: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(encoded)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("encode error: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a received media packet into the jitter buffer.
|
|
||||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
|
||||||
// Feed FEC symbols if present
|
|
||||||
let header = &packet.header;
|
|
||||||
if header.fec_block != 0 || header.fec_symbol != 0 {
|
|
||||||
let is_repair = header.is_repair;
|
|
||||||
if let Err(e) = self.fec_decoder.add_symbol(
|
|
||||||
header.fec_block,
|
|
||||||
header.fec_symbol,
|
|
||||||
is_repair,
|
|
||||||
&packet.payload,
|
|
||||||
) {
|
|
||||||
debug!("FEC symbol feed error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.jitter_buffer.push(packet);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the next frame from the jitter buffer.
|
|
||||||
///
|
|
||||||
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
|
||||||
/// Decoded PCM is also stored as the AEC far-end reference for the next
|
|
||||||
/// encode cycle.
|
|
||||||
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
|
||||||
let result = match self.jitter_buffer.pop() {
|
|
||||||
PlayoutResult::Packet(pkt) => {
|
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
|
||||||
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_decoded += 1;
|
|
||||||
pcm.truncate(n);
|
|
||||||
Some(pcm)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("decode error: {e}");
|
|
||||||
// Attempt PLC
|
|
||||||
self.generate_plc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayoutResult::Missing { seq } => {
|
|
||||||
debug!(seq, "jitter buffer: missing packet, generating PLC");
|
|
||||||
self.generate_plc()
|
|
||||||
}
|
|
||||||
PlayoutResult::NotReady => {
|
|
||||||
self.underruns += 1;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save decoded PCM as far-end reference for AEC.
|
|
||||||
if let Some(ref pcm) = result {
|
|
||||||
self.last_decoded_farend = Some(pcm.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate packet loss concealment output.
|
|
||||||
fn generate_plc(&mut self) -> Option<Vec<i16>> {
|
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
|
||||||
match self.decoder.decode_lost(&mut pcm) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_decoded += 1;
|
|
||||||
pcm.truncate(n);
|
|
||||||
Some(pcm)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("PLC error: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a quality report into the adaptive quality controller.
|
|
||||||
///
|
|
||||||
/// Returns a new profile if a tier transition occurred.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn observe_quality(
|
|
||||||
&mut self,
|
|
||||||
report: &wzp_proto::QualityReport,
|
|
||||||
) -> Option<QualityProfile> {
|
|
||||||
let new_profile = self.quality_ctrl.observe(report);
|
|
||||||
if let Some(ref profile) = new_profile {
|
|
||||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
|
||||||
warn!("encoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = self.decoder.set_profile(*profile) {
|
|
||||||
warn!("decoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
new_profile
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force a specific quality profile.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn force_profile(&mut self, profile: QualityProfile) {
|
|
||||||
self.quality_ctrl.force_profile(profile);
|
|
||||||
if let Err(e) = self.encoder.set_profile(profile) {
|
|
||||||
warn!("encoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = self.decoder.set_profile(profile) {
|
|
||||||
warn!("decoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current pipeline statistics.
|
|
||||||
pub fn stats(&self) -> PipelineStats {
|
|
||||||
PipelineStats {
|
|
||||||
frames_encoded: self.frames_encoded,
|
|
||||||
frames_decoded: self.frames_decoded,
|
|
||||||
underruns: self.underruns,
|
|
||||||
jitter_depth: self.jitter_buffer.stats().current_depth,
|
|
||||||
quality_tier: self.quality_ctrl.tier() as u8,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable acoustic echo cancellation.
|
|
||||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
|
||||||
self.aec.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable automatic gain control.
|
|
||||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
|
||||||
self.agc.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
//! Call statistics for the Android engine.
|
|
||||||
|
|
||||||
/// State of the call.
|
|
||||||
#[derive(Clone, Debug, Default, serde::Serialize, PartialEq, Eq)]
|
|
||||||
pub enum CallState {
|
|
||||||
/// Engine is idle, no active call.
|
|
||||||
#[default]
|
|
||||||
Idle,
|
|
||||||
/// Establishing connection to the relay.
|
|
||||||
Connecting,
|
|
||||||
/// Call is active with audio flowing.
|
|
||||||
Active,
|
|
||||||
/// Temporarily lost connection, attempting to recover.
|
|
||||||
Reconnecting,
|
|
||||||
/// Call has ended.
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregated call statistics, serializable for JNI bridge.
|
|
||||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
|
||||||
pub struct CallStats {
|
|
||||||
/// Current call state.
|
|
||||||
pub state: CallState,
|
|
||||||
/// Call duration in seconds.
|
|
||||||
pub duration_secs: f64,
|
|
||||||
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
|
|
||||||
pub quality_tier: u8,
|
|
||||||
/// Observed packet loss percentage.
|
|
||||||
pub loss_pct: f32,
|
|
||||||
/// Smoothed round-trip time in milliseconds.
|
|
||||||
pub rtt_ms: u32,
|
|
||||||
/// Jitter in milliseconds.
|
|
||||||
pub jitter_ms: u32,
|
|
||||||
/// Current jitter buffer depth in packets.
|
|
||||||
pub jitter_buffer_depth: usize,
|
|
||||||
/// Total frames encoded since call start.
|
|
||||||
pub frames_encoded: u64,
|
|
||||||
/// Total frames decoded since call start.
|
|
||||||
pub frames_decoded: u64,
|
|
||||||
/// Number of playout underruns (buffer empty when audio needed).
|
|
||||||
pub underruns: u64,
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||||
@@ -207,10 +207,6 @@ pub struct CallEncoder {
|
|||||||
frame_in_block: u8,
|
frame_in_block: u8,
|
||||||
/// Timestamp counter (ms).
|
/// Timestamp counter (ms).
|
||||||
timestamp_ms: u32,
|
timestamp_ms: u32,
|
||||||
/// Acoustic echo canceller (removes speaker echo from mic signal).
|
|
||||||
aec: EchoCanceller,
|
|
||||||
/// Automatic gain control (normalises mic level).
|
|
||||||
agc: AutoGainControl,
|
|
||||||
/// Silence detector for suppression.
|
/// Silence detector for suppression.
|
||||||
silence_detector: SilenceDetector,
|
silence_detector: SilenceDetector,
|
||||||
/// Whether silence suppression is enabled.
|
/// Whether silence suppression is enabled.
|
||||||
@@ -241,8 +237,6 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
|
||||||
agc: AutoGainControl::new(),
|
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
config.silence_hangover_frames,
|
config.silence_hangover_frames,
|
||||||
@@ -280,21 +274,15 @@ impl CallEncoder {
|
|||||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||||
/// Output: one or more MediaPackets to send.
|
/// Output: one or more MediaPackets to send.
|
||||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||||
// Copy PCM into a mutable buffer for the processing pipeline.
|
// Noise suppression: denoise the PCM before silence detection and encoding.
|
||||||
let mut pcm_buf = pcm.to_vec();
|
let pcm = if self.denoiser.is_enabled() {
|
||||||
|
let mut buf = pcm.to_vec();
|
||||||
// Step 1: Echo cancellation (far-end reference must have been fed already).
|
self.denoiser.process(&mut buf);
|
||||||
self.aec.process_frame(&mut pcm_buf);
|
buf
|
||||||
|
} else {
|
||||||
// Step 2: Automatic gain control (normalise mic level).
|
pcm.to_vec()
|
||||||
self.agc.process_frame(&mut pcm_buf);
|
};
|
||||||
|
let pcm = &pcm[..];
|
||||||
// Step 3: Noise suppression (RNNoise).
|
|
||||||
if self.denoiser.is_enabled() {
|
|
||||||
self.denoiser.process(&mut pcm_buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pcm = &pcm_buf[..];
|
|
||||||
|
|
||||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||||
@@ -412,24 +400,6 @@ impl CallEncoder {
|
|||||||
self.frame_in_block = 0;
|
self.frame_in_block = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed decoded playout audio as the echo reference signal.
|
|
||||||
///
|
|
||||||
/// Must be called with each decoded frame BEFORE the corresponding
|
|
||||||
/// microphone frame is processed.
|
|
||||||
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
|
||||||
self.aec.feed_farend(farend);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable acoustic echo cancellation.
|
|
||||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
|
||||||
self.aec.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable automatic gain control.
|
|
||||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
|
||||||
self.agc.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages the recv/decode side of a call.
|
/// Manages the recv/decode side of a call.
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_roundtrip() {
|
fn payload_roundtrip() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
|
|||||||
use crate::codec2_enc::Codec2Encoder;
|
use crate::codec2_enc::Codec2Encoder;
|
||||||
use crate::opus_dec::OpusDecoder;
|
use crate::opus_dec::OpusDecoder;
|
||||||
use crate::opus_enc::OpusEncoder;
|
use crate::opus_enc::OpusEncoder;
|
||||||
use crate::resample::{Downsampler48to8, Upsampler8to48};
|
use crate::resample;
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ pub struct AdaptiveEncoder {
|
|||||||
opus: OpusEncoder,
|
opus: OpusEncoder,
|
||||||
codec2: Codec2Encoder,
|
codec2: Codec2Encoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
downsampler: Downsampler48to8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveEncoder {
|
impl AdaptiveEncoder {
|
||||||
@@ -67,7 +66,6 @@ impl AdaptiveEncoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
downsampler: Downsampler48to8::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +74,7 @@ impl AudioEncoder for AdaptiveEncoder {
|
|||||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
if is_codec2(self.active) {
|
if is_codec2(self.active) {
|
||||||
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||||
let pcm_8k = self.downsampler.process(pcm);
|
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
||||||
self.codec2.encode(&pcm_8k, out)
|
self.codec2.encode(&pcm_8k, out)
|
||||||
} else {
|
} else {
|
||||||
self.opus.encode(pcm, out)
|
self.opus.encode(pcm, out)
|
||||||
@@ -128,7 +126,6 @@ pub struct AdaptiveDecoder {
|
|||||||
opus: OpusDecoder,
|
opus: OpusDecoder,
|
||||||
codec2: Codec2Decoder,
|
codec2: Codec2Decoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
upsampler: Upsampler8to48,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveDecoder {
|
impl AdaptiveDecoder {
|
||||||
@@ -141,7 +138,6 @@ impl AdaptiveDecoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
upsampler: Upsampler8to48::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +149,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
@@ -167,7 +163,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
|
||||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
|
||||||
|
|
||||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
|
||||||
///
|
|
||||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
|
||||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
|
||||||
/// the estimated echo from the near-end in real time.
|
|
||||||
pub struct EchoCanceller {
|
|
||||||
filter_coeffs: Vec<f32>,
|
|
||||||
filter_len: usize,
|
|
||||||
far_end_buf: Vec<f32>,
|
|
||||||
far_end_pos: usize,
|
|
||||||
mu: f32,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EchoCanceller {
|
|
||||||
/// Create a new echo canceller.
|
|
||||||
///
|
|
||||||
/// * `sample_rate` — typically 48000
|
|
||||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
|
||||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
|
||||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
|
||||||
Self {
|
|
||||||
filter_coeffs: vec![0.0f32; filter_len],
|
|
||||||
filter_len,
|
|
||||||
far_end_buf: vec![0.0f32; filter_len],
|
|
||||||
far_end_pos: 0,
|
|
||||||
mu: 0.01,
|
|
||||||
enabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
|
||||||
///
|
|
||||||
/// Must be called with the audio that was played out through the speaker
|
|
||||||
/// *before* the corresponding near-end frame is processed.
|
|
||||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
|
||||||
for &s in farend {
|
|
||||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
|
||||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
|
||||||
///
|
|
||||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
|
||||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
|
||||||
/// mean echo was reduced.
|
|
||||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
|
||||||
if !self.enabled {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = nearend.len();
|
|
||||||
let fl = self.filter_len;
|
|
||||||
|
|
||||||
let mut sum_near_sq: f64 = 0.0;
|
|
||||||
let mut sum_err_sq: f64 = 0.0;
|
|
||||||
|
|
||||||
for i in 0..n {
|
|
||||||
let near_f = nearend[i] as f32;
|
|
||||||
|
|
||||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
|
||||||
// The far-end window for this sample starts at
|
|
||||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
|
||||||
// and goes back filter_len samples.
|
|
||||||
let mut echo_est: f32 = 0.0;
|
|
||||||
let mut power: f32 = 0.0;
|
|
||||||
|
|
||||||
// Position of the most-recent far-end sample for this near-end sample.
|
|
||||||
// far_end_pos points to the *next write* position, so the most-recent
|
|
||||||
// sample written is at far_end_pos - 1. We have already called
|
|
||||||
// feed_farend for this block, so the relevant samples are the last
|
|
||||||
// filter_len entries ending just before the current write position,
|
|
||||||
// offset by how far we are into this near-end frame.
|
|
||||||
//
|
|
||||||
// For sample i of the near-end frame, the corresponding far-end
|
|
||||||
// "now" is far_end_pos - n + i (wrapping).
|
|
||||||
// far_end_pos points to next-write, so most recent sample is at
|
|
||||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
|
||||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
|
||||||
// repeatedly to avoid underflow on the usize subtraction.
|
|
||||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
|
||||||
|
|
||||||
for k in 0..fl {
|
|
||||||
let fe_idx = (base + fl - k) % fl;
|
|
||||||
let fe = self.far_end_buf[fe_idx];
|
|
||||||
echo_est += self.filter_coeffs[k] * fe;
|
|
||||||
power += fe * fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
let error = near_f - echo_est;
|
|
||||||
|
|
||||||
// --- NLMS coefficient update ---
|
|
||||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
|
||||||
let step = self.mu * error / norm;
|
|
||||||
|
|
||||||
for k in 0..fl {
|
|
||||||
let fe_idx = (base + fl - k) % fl;
|
|
||||||
let fe = self.far_end_buf[fe_idx];
|
|
||||||
self.filter_coeffs[k] += step * fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp output
|
|
||||||
let out = error.max(-32768.0).min(32767.0);
|
|
||||||
nearend[i] = out as i16;
|
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
|
||||||
sum_err_sq += (out as f64) * (out as f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ERLE ratio
|
|
||||||
if sum_err_sq < 1.0 {
|
|
||||||
return 100.0; // near-perfect cancellation
|
|
||||||
}
|
|
||||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable echo cancellation.
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
|
||||||
self.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether echo cancellation is currently enabled.
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
|
||||||
self.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the adaptive filter to its initial state.
|
|
||||||
///
|
|
||||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
|
||||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
|
||||||
self.far_end_pos = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aec_creates_with_correct_filter_len() {
|
|
||||||
let aec = EchoCanceller::new(48000, 100);
|
|
||||||
assert_eq!(aec.filter_len, 4800);
|
|
||||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
|
||||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aec_passthrough_when_disabled() {
|
|
||||||
let mut aec = EchoCanceller::new(48000, 100);
|
|
||||||
aec.set_enabled(false);
|
|
||||||
assert!(!aec.is_enabled());
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
|
||||||
let mut frame = original.clone();
|
|
||||||
let erle = aec.process_frame(&mut frame);
|
|
||||||
assert_eq!(erle, 1.0);
|
|
||||||
assert_eq!(frame, original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aec_reset_zeroes_state() {
|
|
||||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
|
||||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
|
||||||
aec.feed_farend(&farend);
|
|
||||||
|
|
||||||
aec.reset();
|
|
||||||
|
|
||||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
|
||||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
|
||||||
assert_eq!(aec.far_end_pos, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aec_reduces_echo_of_known_signal() {
|
|
||||||
// Use a small filter for speed. Feed a known far-end signal, then
|
|
||||||
// present the *same* signal as near-end (perfect echo, no room).
|
|
||||||
// After adaptation the output energy should drop.
|
|
||||||
let filter_ms = 5; // 240 taps at 48 kHz
|
|
||||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
|
||||||
|
|
||||||
// Generate a simple repeating pattern.
|
|
||||||
let frame_len = 480usize;
|
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..frame_len)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (offset + i) as f64 / 48000.0;
|
|
||||||
(5000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Warm up the adaptive filter with several frames.
|
|
||||||
let mut last_erle = 1.0f32;
|
|
||||||
for frame_idx in 0..40 {
|
|
||||||
let farend = make_frame(frame_idx * frame_len);
|
|
||||||
aec.feed_farend(&farend);
|
|
||||||
|
|
||||||
// Near-end = exact copy of far-end (pure echo).
|
|
||||||
let mut nearend = farend.clone();
|
|
||||||
last_erle = aec.process_frame(&mut nearend);
|
|
||||||
}
|
|
||||||
|
|
||||||
// After 40 frames the ERLE should be meaningfully > 1.
|
|
||||||
assert!(
|
|
||||||
last_erle > 1.0,
|
|
||||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aec_silence_passthrough() {
|
|
||||||
let mut aec = EchoCanceller::new(48000, 10);
|
|
||||||
// Feed silence far-end
|
|
||||||
aec.feed_farend(&vec![0i16; 480]);
|
|
||||||
// Near-end is silence too
|
|
||||||
let mut frame = vec![0i16; 480];
|
|
||||||
let erle = aec.process_frame(&mut frame);
|
|
||||||
assert!(erle >= 1.0);
|
|
||||||
// Output should still be silence
|
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
//! Automatic Gain Control (AGC) with two-stage smoothing.
|
|
||||||
//!
|
|
||||||
//! Uses a fast attack / slow release envelope follower to keep the
|
|
||||||
//! output signal near a configurable target RMS level. This prevents
|
|
||||||
//! both clipping (when the speaker is too loud) and inaudibility (when
|
|
||||||
//! the speaker is too quiet or far from the mic).
|
|
||||||
|
|
||||||
/// Two-stage automatic gain control.
|
|
||||||
///
|
|
||||||
/// The gain is adjusted per-frame based on the measured RMS energy,
|
|
||||||
/// with a fast attack (gain decreases quickly when signal gets louder)
|
|
||||||
/// and a slow release (gain increases gradually when signal gets quieter).
|
|
||||||
pub struct AutoGainControl {
|
|
||||||
target_rms: f64,
|
|
||||||
current_gain: f64,
|
|
||||||
min_gain: f64,
|
|
||||||
max_gain: f64,
|
|
||||||
attack_alpha: f64,
|
|
||||||
release_alpha: f64,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoGainControl {
|
|
||||||
/// Create a new AGC with sensible VoIP defaults.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
|
||||||
current_gain: 1.0,
|
|
||||||
min_gain: 0.5,
|
|
||||||
max_gain: 32.0,
|
|
||||||
attack_alpha: 0.3, // fast attack
|
|
||||||
release_alpha: 0.02, // slow release
|
|
||||||
enabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a frame of PCM audio in-place, applying gain adjustment.
|
|
||||||
pub fn process_frame(&mut self, pcm: &mut [i16]) {
|
|
||||||
if !self.enabled {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute RMS of the frame.
|
|
||||||
let rms = Self::compute_rms(pcm);
|
|
||||||
|
|
||||||
// Don't amplify near-silence — it would just boost noise.
|
|
||||||
if rms < 10.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desired instantaneous gain.
|
|
||||||
let desired_gain = (self.target_rms / rms).clamp(self.min_gain, self.max_gain);
|
|
||||||
|
|
||||||
// Smooth the gain transition.
|
|
||||||
let alpha = if desired_gain < self.current_gain {
|
|
||||||
// Signal is louder than target → reduce gain quickly (attack).
|
|
||||||
self.attack_alpha
|
|
||||||
} else {
|
|
||||||
// Signal is quieter than target → raise gain slowly (release).
|
|
||||||
self.release_alpha
|
|
||||||
};
|
|
||||||
|
|
||||||
self.current_gain = self.current_gain * (1.0 - alpha) + desired_gain * alpha;
|
|
||||||
|
|
||||||
// Apply gain to each sample with hard limiting at ±31000 (~0.946 * i16::MAX).
|
|
||||||
const LIMIT: f64 = 31000.0;
|
|
||||||
let gain = self.current_gain;
|
|
||||||
for sample in pcm.iter_mut() {
|
|
||||||
let amplified = (*sample as f64) * gain;
|
|
||||||
let clamped = amplified.clamp(-LIMIT, LIMIT);
|
|
||||||
*sample = clamped as i16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable the AGC.
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
|
||||||
self.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the AGC is currently enabled.
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
|
||||||
self.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current gain expressed in dB.
|
|
||||||
pub fn current_gain_db(&self) -> f64 {
|
|
||||||
20.0 * self.current_gain.log10()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the RMS (root mean square) of a PCM buffer.
|
|
||||||
fn compute_rms(pcm: &[i16]) -> f64 {
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
|
||||||
(sum_sq / pcm.len() as f64).sqrt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoGainControl {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_creates_with_defaults() {
|
|
||||||
let agc = AutoGainControl::new();
|
|
||||||
assert!(agc.is_enabled());
|
|
||||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_passthrough_when_disabled() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
agc.set_enabled(false);
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..960).map(|i| (i * 5) as i16).collect();
|
|
||||||
let mut frame = original.clone();
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
|
|
||||||
assert_eq!(frame, original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_does_not_amplify_silence() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
let mut frame = vec![0i16; 960];
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
|
||||||
// Gain should remain at initial value.
|
|
||||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_amplifies_quiet_signal() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
|
|
||||||
// Very quiet signal (RMS ~ 50).
|
|
||||||
let mut frame: Vec<i16> = (0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 48000.0;
|
|
||||||
(50.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Process several frames to let the gain ramp up.
|
|
||||||
for _ in 0..50 {
|
|
||||||
let mut f = frame.clone();
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
frame = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gain should have increased past 1.0.
|
|
||||||
assert!(
|
|
||||||
agc.current_gain > 1.05,
|
|
||||||
"expected gain > 1.05 for quiet signal, got {}",
|
|
||||||
agc.current_gain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_attenuates_loud_signal() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
|
|
||||||
// Loud signal (RMS ~ 20000).
|
|
||||||
let frame: Vec<i16> = (0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 48000.0;
|
|
||||||
(28000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Process several frames.
|
|
||||||
for _ in 0..20 {
|
|
||||||
let mut f = frame.clone();
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gain should have decreased below 1.0.
|
|
||||||
assert!(
|
|
||||||
agc.current_gain < 1.0,
|
|
||||||
"expected gain < 1.0 for loud signal, got {}",
|
|
||||||
agc.current_gain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_output_within_limits() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
// Force a high gain by processing many quiet frames first.
|
|
||||||
for _ in 0..100 {
|
|
||||||
let mut f: Vec<i16> = vec![100; 960];
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now send a louder frame — output should still be within ±31000.
|
|
||||||
let mut frame: Vec<i16> = vec![20000; 960];
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
assert!(
|
|
||||||
frame.iter().all(|&s| s.abs() <= 31000),
|
|
||||||
"output samples must be within ±31000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_gain_db_at_unity() {
|
|
||||||
let agc = AutoGainControl::new();
|
|
||||||
let db = agc.current_gain_db();
|
|
||||||
assert!(
|
|
||||||
db.abs() < 0.01,
|
|
||||||
"expected ~0 dB at unity gain, got {db}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
//! trait-object encoders/decoders that handle adaptive switching internally.
|
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||||
|
|
||||||
pub mod adaptive;
|
pub mod adaptive;
|
||||||
pub mod aec;
|
|
||||||
pub mod agc;
|
|
||||||
pub mod codec2_dec;
|
pub mod codec2_dec;
|
||||||
pub mod codec2_enc;
|
pub mod codec2_enc;
|
||||||
pub mod denoise;
|
pub mod denoise;
|
||||||
@@ -21,8 +19,6 @@ pub mod resample;
|
|||||||
pub mod silence;
|
pub mod silence;
|
||||||
|
|
||||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||||
pub use aec::EchoCanceller;
|
|
||||||
pub use agc::AutoGainControl;
|
|
||||||
pub use denoise::NoiseSupressor;
|
pub use denoise::NoiseSupressor;
|
||||||
pub use silence::{ComfortNoise, SilenceDetector};
|
pub use silence::{ComfortNoise, SilenceDetector};
|
||||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ impl OpusEncoder {
|
|||||||
.set_signal(Signal::Voice)
|
.set_signal(Signal::Voice)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
|
||||||
enc.inner
|
|
||||||
.set_complexity(7)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
|
||||||
|
|
||||||
Ok(enc)
|
Ok(enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,21 +56,6 @@ impl OpusEncoder {
|
|||||||
pub fn frame_samples(&self) -> usize {
|
pub fn frame_samples(&self) -> usize {
|
||||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the encoder complexity (0-10). Higher values produce better quality
|
|
||||||
/// at the cost of more CPU. Default is 7.
|
|
||||||
pub fn set_complexity(&mut self, complexity: i32) {
|
|
||||||
let c = (complexity as u8).min(10);
|
|
||||||
let _ = self.inner.set_complexity(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
|
||||||
///
|
|
||||||
/// Higher values cause the encoder to use more redundancy to survive
|
|
||||||
/// packet loss, at the expense of slightly higher bitrate.
|
|
||||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
|
||||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioEncoder for OpusEncoder {
|
impl AudioEncoder for OpusEncoder {
|
||||||
|
|||||||
@@ -1,257 +1,54 @@
|
|||||||
//! Windowed-sinc FIR resampler for 48 kHz <-> 8 kHz conversion.
|
//! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
|
||||||
//!
|
//!
|
||||||
//! Provides both stateless free functions (backward-compatible) and stateful
|
//! These are basic implementations suitable for voice. For higher quality,
|
||||||
//! `Downsampler48to8` / `Upsampler8to48` structs that maintain overlap history
|
//! replace with the `rubato` crate later.
|
||||||
//! between frames for glitch-free streaming.
|
|
||||||
|
|
||||||
use std::f64::consts::PI;
|
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
|
||||||
|
///
|
||||||
// ─── FIR kernel parameters ─────────────────────────────────────────────────
|
/// Each output sample is the average of 6 consecutive input samples,
|
||||||
|
/// providing basic anti-aliasing via a box filter.
|
||||||
/// Number of FIR taps in the anti-alias / interpolation filter.
|
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||||
const FIR_TAPS: usize = 48;
|
|
||||||
/// Kaiser window beta parameter — controls sidelobe attenuation.
|
|
||||||
const KAISER_BETA: f64 = 8.0;
|
|
||||||
/// Cutoff frequency in Hz for the low-pass filter (just below 4 kHz Nyquist of 8 kHz).
|
|
||||||
const CUTOFF_HZ: f64 = 3800.0;
|
|
||||||
/// Working sample rate in Hz.
|
|
||||||
const SAMPLE_RATE: f64 = 48000.0;
|
|
||||||
/// Decimation / interpolation ratio between 48 kHz and 8 kHz.
|
|
||||||
const RATIO: usize = 6;
|
const RATIO: usize = 6;
|
||||||
|
|
||||||
// ─── Kaiser window helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Zeroth-order modified Bessel function of the first kind, I₀(x).
|
|
||||||
///
|
|
||||||
/// Computed via the well-known power-series expansion, converging rapidly
|
|
||||||
/// for the moderate values of x used in Kaiser window design.
|
|
||||||
fn bessel_i0(x: f64) -> f64 {
|
|
||||||
let mut sum = 1.0f64;
|
|
||||||
let mut term = 1.0f64;
|
|
||||||
let half_x = x / 2.0;
|
|
||||||
for k in 1..=25 {
|
|
||||||
term *= (half_x / k as f64) * (half_x / k as f64);
|
|
||||||
sum += term;
|
|
||||||
if term < 1e-12 * sum {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a windowed-sinc low-pass FIR kernel.
|
|
||||||
///
|
|
||||||
/// Returns `FIR_TAPS` coefficients normalised so that the DC gain is exactly 1.0.
|
|
||||||
fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
|
||||||
let mut kernel = [0.0f64; FIR_TAPS];
|
|
||||||
let m = (FIR_TAPS - 1) as f64;
|
|
||||||
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
|
||||||
let beta_denom = bessel_i0(KAISER_BETA);
|
|
||||||
|
|
||||||
for i in 0..FIR_TAPS {
|
|
||||||
// Sinc
|
|
||||||
let n = i as f64 - m / 2.0;
|
|
||||||
let sinc = if n.abs() < 1e-12 {
|
|
||||||
2.0 * fc
|
|
||||||
} else {
|
|
||||||
(2.0 * PI * fc * n).sin() / (PI * n)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Kaiser window
|
|
||||||
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
|
||||||
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
|
|
||||||
|
|
||||||
kernel[i] = sinc * kaiser;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalise to unity DC gain.
|
|
||||||
let sum: f64 = kernel.iter().sum();
|
|
||||||
if sum.abs() > 1e-15 {
|
|
||||||
for k in kernel.iter_mut() {
|
|
||||||
*k /= sum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kernel
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Stateful Downsampler 48→8 ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Stateful polyphase FIR downsampler from 48 kHz to 8 kHz.
|
|
||||||
///
|
|
||||||
/// Maintains `FIR_TAPS - 1` samples of history between successive calls to
|
|
||||||
/// `process()` for seamless frame boundaries.
|
|
||||||
pub struct Downsampler48to8 {
|
|
||||||
kernel: [f64; FIR_TAPS],
|
|
||||||
history: Vec<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Downsampler48to8 {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
kernel: build_fir_kernel(),
|
|
||||||
history: vec![0.0; FIR_TAPS - 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downsample a block of 48 kHz samples to 8 kHz.
|
|
||||||
///
|
|
||||||
/// The input length should be a multiple of 6; any trailing samples that
|
|
||||||
/// don't form a complete output sample are consumed into the history.
|
|
||||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
|
||||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
|
||||||
let total_len = hist_len + input.len();
|
|
||||||
|
|
||||||
// Build a working buffer: history ++ input (as f64).
|
|
||||||
let mut work = Vec::with_capacity(total_len);
|
|
||||||
work.extend_from_slice(&self.history);
|
|
||||||
work.extend(input.iter().map(|&s| s as f64));
|
|
||||||
|
|
||||||
let out_len = input.len() / RATIO;
|
let out_len = input.len() / RATIO;
|
||||||
let mut output = Vec::with_capacity(out_len);
|
let mut output = Vec::with_capacity(out_len);
|
||||||
|
|
||||||
for i in 0..out_len {
|
for chunk in input.chunks_exact(RATIO) {
|
||||||
// The centre of the filter for output sample i sits at
|
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
|
||||||
// position hist_len + i*RATIO in the work buffer (aligning
|
output.push((sum / RATIO as i32) as i16);
|
||||||
// with the first new input sample at decimation phase 0).
|
|
||||||
let centre = hist_len + i * RATIO;
|
|
||||||
let start = centre + 1 - FIR_TAPS; // may be 0 for the first few
|
|
||||||
|
|
||||||
let mut acc = 0.0f64;
|
|
||||||
for k in 0..FIR_TAPS {
|
|
||||||
let idx = start + k;
|
|
||||||
if idx < work.len() {
|
|
||||||
acc += work[idx] * self.kernel[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
|
||||||
if work.len() >= hist_len {
|
|
||||||
self.history
|
|
||||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
|
||||||
} else {
|
|
||||||
// Input was shorter than history — shift.
|
|
||||||
let shift = hist_len - work.len();
|
|
||||||
self.history.copy_within(shift.., 0);
|
|
||||||
for (i, &v) in work.iter().enumerate() {
|
|
||||||
self.history[hist_len - work.len() + i] = v;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Downsampler48to8 {
|
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Stateful Upsampler 8→48 ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Stateful FIR upsampler from 8 kHz to 48 kHz.
|
|
||||||
///
|
///
|
||||||
/// Inserts zeros between input samples (zero-stuffing), then applies the
|
/// Linearly interpolates between each pair of input samples to produce
|
||||||
/// low-pass FIR to remove imaging, with gain compensation of `RATIO`.
|
/// 6 output samples per input sample.
|
||||||
pub struct Upsampler8to48 {
|
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||||
kernel: [f64; FIR_TAPS],
|
const RATIO: usize = 6;
|
||||||
history: Vec<f64>,
|
if input.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Upsampler8to48 {
|
let out_len = input.len() * RATIO;
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
kernel: build_fir_kernel(),
|
|
||||||
history: vec![0.0; FIR_TAPS - 1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsample a block of 8 kHz samples to 48 kHz.
|
|
||||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
|
||||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
|
||||||
|
|
||||||
// Zero-stuff: insert RATIO-1 zeros between each input sample.
|
|
||||||
let stuffed_len = input.len() * RATIO;
|
|
||||||
let total_len = hist_len + stuffed_len;
|
|
||||||
|
|
||||||
let mut work = Vec::with_capacity(total_len);
|
|
||||||
work.extend_from_slice(&self.history);
|
|
||||||
for &s in input {
|
|
||||||
work.push(s as f64);
|
|
||||||
for _ in 1..RATIO {
|
|
||||||
work.push(0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let out_len = stuffed_len;
|
|
||||||
let mut output = Vec::with_capacity(out_len);
|
let mut output = Vec::with_capacity(out_len);
|
||||||
|
|
||||||
// The gain factor compensates for the zeros introduced by stuffing.
|
for i in 0..input.len() {
|
||||||
let gain = RATIO as f64;
|
let current = input[i] as i32;
|
||||||
|
let next = if i + 1 < input.len() {
|
||||||
for i in 0..out_len {
|
input[i + 1] as i32
|
||||||
let centre = hist_len + i;
|
|
||||||
let start = centre + 1 - FIR_TAPS;
|
|
||||||
|
|
||||||
let mut acc = 0.0f64;
|
|
||||||
for k in 0..FIR_TAPS {
|
|
||||||
let idx = start + k;
|
|
||||||
if idx < work.len() {
|
|
||||||
acc += work[idx] * self.kernel[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
acc *= gain;
|
|
||||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update history.
|
|
||||||
if work.len() >= hist_len {
|
|
||||||
self.history
|
|
||||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
|
||||||
} else {
|
} else {
|
||||||
let shift = hist_len - work.len();
|
current // hold last sample
|
||||||
self.history.copy_within(shift.., 0);
|
};
|
||||||
for (i, &v) in work.iter().enumerate() {
|
|
||||||
self.history[hist_len - work.len() + i] = v;
|
for j in 0..RATIO {
|
||||||
|
let interp = current + (next - current) * j as i32 / RATIO as i32;
|
||||||
|
output.push(interp as i16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Upsampler8to48 {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Backward-compatible free functions ─────────────────────────────────────
|
|
||||||
|
|
||||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with FIR anti-alias filter).
|
|
||||||
///
|
|
||||||
/// This is a convenience wrapper that creates a temporary [`Downsampler48to8`].
|
|
||||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
|
||||||
/// frames.
|
|
||||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
|
||||||
let mut ds = Downsampler48to8::new();
|
|
||||||
ds.process(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with FIR imaging filter).
|
|
||||||
///
|
|
||||||
/// This is a convenience wrapper that creates a temporary [`Upsampler8to48`].
|
|
||||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
|
||||||
/// frames.
|
|
||||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
|
||||||
let mut us = Upsampler8to48::new();
|
|
||||||
us.process(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -269,28 +66,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dc_signal_preserved() {
|
fn dc_signal_preserved() {
|
||||||
// A constant signal should survive resampling (approximately).
|
// A constant signal should survive resampling
|
||||||
let input = vec![1000i16; 960];
|
let input = vec![1000i16; 960];
|
||||||
let down = resample_48k_to_8k(&input);
|
let down = resample_48k_to_8k(&input);
|
||||||
// Allow some edge transient — check that the middle samples are close.
|
assert!(down.iter().all(|&s| s == 1000));
|
||||||
let mid_start = down.len() / 4;
|
|
||||||
let mid_end = 3 * down.len() / 4;
|
|
||||||
for &s in &down[mid_start..mid_end] {
|
|
||||||
assert!(
|
|
||||||
(s - 1000).abs() < 50,
|
|
||||||
"DC downsampled sample {s} too far from 1000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let up = resample_8k_to_48k(&down);
|
let up = resample_8k_to_48k(&down);
|
||||||
let mid_start_up = up.len() / 4;
|
assert!(up.iter().all(|&s| s == 1000));
|
||||||
let mid_end_up = 3 * up.len() / 4;
|
|
||||||
for &s in &up[mid_start_up..mid_end_up] {
|
|
||||||
assert!(
|
|
||||||
(s - 1000).abs() < 100,
|
|
||||||
"DC upsampled sample {s} too far from 1000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -298,40 +79,4 @@ mod tests {
|
|||||||
assert!(resample_48k_to_8k(&[]).is_empty());
|
assert!(resample_48k_to_8k(&[]).is_empty());
|
||||||
assert!(resample_8k_to_48k(&[]).is_empty());
|
assert!(resample_8k_to_48k(&[]).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stateful_downsampler_produces_correct_length() {
|
|
||||||
let mut ds = Downsampler48to8::new();
|
|
||||||
let out = ds.process(&vec![0i16; 960]);
|
|
||||||
assert_eq!(out.len(), 160);
|
|
||||||
let out2 = ds.process(&vec![0i16; 960]);
|
|
||||||
assert_eq!(out2.len(), 160);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stateful_upsampler_produces_correct_length() {
|
|
||||||
let mut us = Upsampler8to48::new();
|
|
||||||
let out = us.process(&vec![0i16; 160]);
|
|
||||||
assert_eq!(out.len(), 960);
|
|
||||||
let out2 = us.process(&vec![0i16; 160]);
|
|
||||||
assert_eq!(out2.len(), 960);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fir_kernel_has_unity_dc_gain() {
|
|
||||||
let kernel = build_fir_kernel();
|
|
||||||
let sum: f64 = kernel.iter().sum();
|
|
||||||
assert!(
|
|
||||||
(sum - 1.0).abs() < 1e-10,
|
|
||||||
"FIR kernel DC gain should be 1.0, got {sum}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bessel_i0_known_values() {
|
|
||||||
// I₀(0) = 1
|
|
||||||
assert!((bessel_i0(0.0) - 1.0).abs() < 1e-12);
|
|
||||||
// I₀(1) ≈ 1.2660658
|
|
||||||
assert!((bessel_i0(1.0) - 1.2660658).abs() < 1e-5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::packet::MediaPacket;
|
use crate::packet::MediaPacket;
|
||||||
|
|
||||||
@@ -21,29 +20,19 @@ pub struct AdaptivePlayoutDelay {
|
|||||||
max_delay: usize,
|
max_delay: usize,
|
||||||
/// Exponential moving average of inter-packet arrival jitter (ms).
|
/// Exponential moving average of inter-packet arrival jitter (ms).
|
||||||
jitter_ema: f64,
|
jitter_ema: f64,
|
||||||
/// EMA smoothing factor for jitter increases (fast reaction).
|
/// EMA smoothing factor (0.0-1.0, lower = smoother).
|
||||||
alpha_up: f64,
|
alpha: f64,
|
||||||
/// EMA smoothing factor for jitter decreases (slow decay).
|
|
||||||
alpha_down: f64,
|
|
||||||
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
||||||
last_arrival_ms: Option<u64>,
|
last_arrival_ms: Option<u64>,
|
||||||
/// Last packet expected timestamp.
|
/// Last packet expected timestamp.
|
||||||
last_expected_ms: Option<u64>,
|
last_expected_ms: Option<u64>,
|
||||||
/// Safety margin added to jitter-derived target (in packets).
|
|
||||||
safety_margin: f64,
|
|
||||||
/// Instant when a jitter spike was detected (handoff detection).
|
|
||||||
spike_detected_at: Option<Instant>,
|
|
||||||
/// Duration to hold max_delay after a spike is detected.
|
|
||||||
spike_cooldown: Duration,
|
|
||||||
/// Multiplier of jitter_ema that constitutes a spike.
|
|
||||||
spike_threshold_multiplier: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
||||||
const FRAME_DURATION_MS: f64 = 20.0;
|
const FRAME_DURATION_MS: f64 = 20.0;
|
||||||
/// Default safety margin in packets.
|
/// Safety margin added to jitter-derived target (in packets).
|
||||||
const DEFAULT_SAFETY_MARGIN: f64 = 2.0;
|
const SAFETY_MARGIN_PACKETS: f64 = 2.0;
|
||||||
/// Default EMA smoothing factor (used for both up/down in non-mobile mode).
|
/// Default EMA smoothing factor.
|
||||||
const DEFAULT_ALPHA: f64 = 0.05;
|
const DEFAULT_ALPHA: f64 = 0.05;
|
||||||
|
|
||||||
impl AdaptivePlayoutDelay {
|
impl AdaptivePlayoutDelay {
|
||||||
@@ -57,14 +46,9 @@ impl AdaptivePlayoutDelay {
|
|||||||
min_delay,
|
min_delay,
|
||||||
max_delay,
|
max_delay,
|
||||||
jitter_ema: 0.0,
|
jitter_ema: 0.0,
|
||||||
alpha_up: DEFAULT_ALPHA,
|
alpha: DEFAULT_ALPHA,
|
||||||
alpha_down: DEFAULT_ALPHA,
|
|
||||||
last_arrival_ms: None,
|
last_arrival_ms: None,
|
||||||
last_expected_ms: None,
|
last_expected_ms: None,
|
||||||
safety_margin: DEFAULT_SAFETY_MARGIN,
|
|
||||||
spike_detected_at: None,
|
|
||||||
spike_cooldown: Duration::from_secs(2),
|
|
||||||
spike_threshold_multiplier: 3.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,39 +64,14 @@ impl AdaptivePlayoutDelay {
|
|||||||
let expected_delta = expected_ms as f64 - last_expected as f64;
|
let expected_delta = expected_ms as f64 - last_expected as f64;
|
||||||
let jitter = (actual_delta - expected_delta).abs();
|
let jitter = (actual_delta - expected_delta).abs();
|
||||||
|
|
||||||
// Spike detection: check before EMA update
|
// Update EMA
|
||||||
if self.jitter_ema > 0.0
|
self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
|
||||||
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
|
||||||
{
|
|
||||||
self.spike_detected_at = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asymmetric EMA update
|
|
||||||
let alpha = if jitter > self.jitter_ema {
|
|
||||||
self.alpha_up
|
|
||||||
} else {
|
|
||||||
self.alpha_down
|
|
||||||
};
|
|
||||||
self.jitter_ema = alpha * jitter + (1.0 - alpha) * self.jitter_ema;
|
|
||||||
|
|
||||||
// Check if spike cooldown has expired
|
|
||||||
if let Some(spike_time) = self.spike_detected_at {
|
|
||||||
if spike_time.elapsed() >= self.spike_cooldown {
|
|
||||||
self.spike_detected_at = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If within spike cooldown, return max_delay
|
|
||||||
if self.spike_detected_at.is_some() {
|
|
||||||
self.target_delay = self.max_delay;
|
|
||||||
} else {
|
|
||||||
// Convert jitter estimate to target delay in packets
|
// Convert jitter estimate to target delay in packets
|
||||||
let raw_target =
|
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
|
||||||
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
|
||||||
self.target_delay =
|
self.target_delay =
|
||||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.last_arrival_ms = Some(arrival_ms);
|
self.last_arrival_ms = Some(arrival_ms);
|
||||||
self.last_expected_ms = Some(expected_ms);
|
self.last_expected_ms = Some(expected_ms);
|
||||||
@@ -128,28 +87,6 @@ impl AdaptivePlayoutDelay {
|
|||||||
pub fn jitter_estimate_ms(&self) -> f64 {
|
pub fn jitter_estimate_ms(&self) -> f64 {
|
||||||
self.jitter_ema
|
self.jitter_ema
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable mobile mode, adjusting parameters for cellular networks.
|
|
||||||
///
|
|
||||||
/// Mobile mode uses:
|
|
||||||
/// - Asymmetric alpha (fast up=0.3, slow down=0.02) for quicker spike detection
|
|
||||||
/// - Higher safety margin (3.0 packets) to absorb handoff jitter
|
|
||||||
/// - Spike detection with 2-second cooldown at 3x threshold
|
|
||||||
pub fn set_mobile_mode(&mut self, enabled: bool) {
|
|
||||||
if enabled {
|
|
||||||
self.safety_margin = 3.0;
|
|
||||||
self.alpha_up = 0.3;
|
|
||||||
self.alpha_down = 0.02;
|
|
||||||
self.spike_threshold_multiplier = 3.0;
|
|
||||||
self.spike_cooldown = Duration::from_secs(2);
|
|
||||||
} else {
|
|
||||||
self.safety_margin = DEFAULT_SAFETY_MARGIN;
|
|
||||||
self.alpha_up = DEFAULT_ALPHA;
|
|
||||||
self.alpha_down = DEFAULT_ALPHA;
|
|
||||||
self.spike_threshold_multiplier = 3.0;
|
|
||||||
self.spike_cooldown = Duration::from_secs(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -454,11 +391,6 @@ impl JitterBuffer {
|
|||||||
self.adaptive.as_ref()
|
self.adaptive.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a mutable reference to the adaptive playout delay estimator.
|
|
||||||
pub fn adaptive_delay_mut(&mut self) -> Option<&mut AdaptivePlayoutDelay> {
|
|
||||||
self.adaptive.as_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adjust target depth based on observed jitter.
|
/// Adjust target depth based on observed jitter.
|
||||||
pub fn set_target_depth(&mut self, depth: usize) {
|
pub fn set_target_depth(&mut self, depth: usize) {
|
||||||
self.target_depth = depth.min(self.max_depth);
|
self.target_depth = depth.min(self.max_depth);
|
||||||
@@ -788,29 +720,4 @@ mod tests {
|
|||||||
let ad = jb.adaptive_delay().unwrap();
|
let ad = jb.adaptive_delay().unwrap();
|
||||||
assert_eq!(ad.target_delay(), 3);
|
assert_eq!(ad.target_delay(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// Mobile mode tests
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mobile_mode_increases_safety_margin() {
|
|
||||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
|
||||||
apd.set_mobile_mode(true);
|
|
||||||
assert_eq!(apd.safety_margin, 3.0);
|
|
||||||
assert_eq!(apd.alpha_up, 0.3);
|
|
||||||
assert_eq!(apd.alpha_down, 0.02);
|
|
||||||
|
|
||||||
apd.set_mobile_mode(false);
|
|
||||||
assert_eq!(apd.safety_margin, DEFAULT_SAFETY_MARGIN);
|
|
||||||
assert_eq!(apd.alpha_up, DEFAULT_ALPHA);
|
|
||||||
assert_eq!(apd.alpha_down, DEFAULT_ALPHA);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mobile_mode_accessible_via_jitter_buffer() {
|
|
||||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
|
||||||
jb.adaptive_delay_mut().unwrap().set_mobile_mode(true);
|
|
||||||
assert_eq!(jb.adaptive_delay().unwrap().safety_margin, 3.0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ pub use packet::{
|
|||||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||||
};
|
};
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
pub use quality::{AdaptiveQualityController, Tier};
|
||||||
pub use session::{Session, SessionEvent, SessionState};
|
pub use session::{Session, SessionEvent, SessionState};
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::packet::QualityReport;
|
use crate::packet::QualityReport;
|
||||||
use crate::traits::QualityController;
|
use crate::traits::QualityController;
|
||||||
@@ -25,31 +24,11 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine which tier a quality report belongs to (default/WiFi thresholds).
|
/// Determine which tier a quality report belongs to.
|
||||||
pub fn classify(report: &QualityReport) -> Self {
|
pub fn classify(report: &QualityReport) -> Self {
|
||||||
Self::classify_with_context(report, NetworkContext::Unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify with network-context-aware thresholds.
|
|
||||||
pub fn classify_with_context(report: &QualityReport, context: NetworkContext) -> Self {
|
|
||||||
let loss = report.loss_percent();
|
let loss = report.loss_percent();
|
||||||
let rtt = report.rtt_ms();
|
let rtt = report.rtt_ms();
|
||||||
|
|
||||||
match context {
|
|
||||||
NetworkContext::CellularLte
|
|
||||||
| NetworkContext::Cellular5g
|
|
||||||
| NetworkContext::Cellular3g => {
|
|
||||||
// Tighter thresholds for cellular networks
|
|
||||||
if loss > 25.0 || rtt > 500 {
|
|
||||||
Self::Catastrophic
|
|
||||||
} else if loss > 8.0 || rtt > 300 {
|
|
||||||
Self::Degraded
|
|
||||||
} else {
|
|
||||||
Self::Good
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NetworkContext::WiFi | NetworkContext::Unknown => {
|
|
||||||
// Original thresholds
|
|
||||||
if loss > 40.0 || rtt > 600 {
|
if loss > 40.0 || rtt > 600 {
|
||||||
Self::Catastrophic
|
Self::Catastrophic
|
||||||
} else if loss > 10.0 || rtt > 400 {
|
} else if loss > 10.0 || rtt > 400 {
|
||||||
@@ -59,37 +38,10 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the next lower (worse) tier, or None if already at the worst.
|
|
||||||
pub fn downgrade(self) -> Option<Tier> {
|
|
||||||
match self {
|
|
||||||
Self::Good => Some(Self::Degraded),
|
|
||||||
Self::Degraded => Some(Self::Catastrophic),
|
|
||||||
Self::Catastrophic => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Describes the network transport type for context-aware quality decisions.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum NetworkContext {
|
|
||||||
WiFi,
|
|
||||||
CellularLte,
|
|
||||||
Cellular5g,
|
|
||||||
Cellular3g,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NetworkContext {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||||
///
|
///
|
||||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
/// - Downgrade: 3 consecutive reports in a worse tier
|
||||||
/// - Upgrade: 10 consecutive reports in a better tier
|
/// - Upgrade: 10 consecutive reports in a better tier
|
||||||
pub struct AdaptiveQualityController {
|
pub struct AdaptiveQualityController {
|
||||||
current_tier: Tier,
|
current_tier: Tier,
|
||||||
@@ -102,26 +54,14 @@ pub struct AdaptiveQualityController {
|
|||||||
history: VecDeque<QualityReport>,
|
history: VecDeque<QualityReport>,
|
||||||
/// Whether the profile was manually forced (disables adaptive logic).
|
/// Whether the profile was manually forced (disables adaptive logic).
|
||||||
forced: bool,
|
forced: bool,
|
||||||
/// Current network context for threshold selection.
|
|
||||||
network_context: NetworkContext,
|
|
||||||
/// FEC boost expiry time (set during network handoff).
|
|
||||||
fec_boost_until: Option<Instant>,
|
|
||||||
/// FEC boost amount to add during handoff recovery window.
|
|
||||||
fec_boost_amount: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Threshold for downgrading (fast reaction to degradation).
|
/// Threshold for downgrading (fast reaction to degradation).
|
||||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||||
/// Threshold for downgrading on cellular networks (even faster).
|
|
||||||
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
|
||||||
/// Threshold for upgrading (slow, cautious improvement).
|
/// Threshold for upgrading (slow, cautious improvement).
|
||||||
const UPGRADE_THRESHOLD: u32 = 10;
|
const UPGRADE_THRESHOLD: u32 = 10;
|
||||||
/// Maximum history window size.
|
/// Maximum history window size.
|
||||||
const HISTORY_SIZE: usize = 20;
|
const HISTORY_SIZE: usize = 20;
|
||||||
/// Default FEC boost amount during handoff recovery.
|
|
||||||
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
|
||||||
/// Duration of FEC boost after a network handoff.
|
|
||||||
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
|
||||||
|
|
||||||
impl AdaptiveQualityController {
|
impl AdaptiveQualityController {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -132,9 +72,6 @@ impl AdaptiveQualityController {
|
|||||||
consecutive_down: 0,
|
consecutive_down: 0,
|
||||||
history: VecDeque::with_capacity(HISTORY_SIZE),
|
history: VecDeque::with_capacity(HISTORY_SIZE),
|
||||||
forced: false,
|
forced: false,
|
||||||
network_context: NetworkContext::default(),
|
|
||||||
fec_boost_until: None,
|
|
||||||
fec_boost_amount: DEFAULT_FEC_BOOST,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,69 +80,6 @@ impl AdaptiveQualityController {
|
|||||||
self.current_tier
|
self.current_tier
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current network context.
|
|
||||||
pub fn network_context(&self) -> NetworkContext {
|
|
||||||
self.network_context
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal a network transport change (e.g., WiFi to cellular handoff).
|
|
||||||
///
|
|
||||||
/// When switching from WiFi to any cellular type, this preemptively
|
|
||||||
/// downgrades one quality tier and activates a temporary FEC boost.
|
|
||||||
pub fn signal_network_change(&mut self, new_context: NetworkContext) {
|
|
||||||
let old = self.network_context;
|
|
||||||
self.network_context = new_context;
|
|
||||||
|
|
||||||
let new_is_cellular = matches!(
|
|
||||||
new_context,
|
|
||||||
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
|
||||||
);
|
|
||||||
|
|
||||||
// If switching from WiFi to cellular, preemptively downgrade one tier
|
|
||||||
if old == NetworkContext::WiFi && new_is_cellular {
|
|
||||||
if let Some(lower_tier) = self.current_tier.downgrade() {
|
|
||||||
self.current_tier = lower_tier;
|
|
||||||
self.current_profile = lower_tier.profile();
|
|
||||||
}
|
|
||||||
// Reset counters to avoid stale hysteresis state
|
|
||||||
self.consecutive_up = 0;
|
|
||||||
self.consecutive_down = 0;
|
|
||||||
// Un-force so adaptive logic resumes
|
|
||||||
self.forced = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate FEC boost for any network change
|
|
||||||
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the FEC boost amount if within the handoff recovery window, 0.0 otherwise.
|
|
||||||
///
|
|
||||||
/// Callers should add this to their base FEC ratio during the boost window.
|
|
||||||
pub fn fec_boost(&self) -> f32 {
|
|
||||||
if let Some(until) = self.fec_boost_until {
|
|
||||||
if Instant::now() < until {
|
|
||||||
return self.fec_boost_amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the hysteresis counters.
|
|
||||||
pub fn reset_counters(&mut self) {
|
|
||||||
self.consecutive_up = 0;
|
|
||||||
self.consecutive_down = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the effective downgrade threshold based on network context.
|
|
||||||
fn downgrade_threshold(&self) -> u32 {
|
|
||||||
match self.network_context {
|
|
||||||
NetworkContext::CellularLte
|
|
||||||
| NetworkContext::Cellular5g
|
|
||||||
| NetworkContext::Cellular3g => CELLULAR_DOWNGRADE_THRESHOLD,
|
|
||||||
_ => DOWNGRADE_THRESHOLD,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||||
if observed_tier == self.current_tier {
|
if observed_tier == self.current_tier {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
@@ -222,7 +96,7 @@ impl AdaptiveQualityController {
|
|||||||
if is_worse {
|
if is_worse {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
self.consecutive_down += 1;
|
self.consecutive_down += 1;
|
||||||
if self.consecutive_down >= self.downgrade_threshold() {
|
if self.consecutive_down >= DOWNGRADE_THRESHOLD {
|
||||||
self.current_tier = observed_tier;
|
self.current_tier = observed_tier;
|
||||||
self.current_profile = observed_tier.profile();
|
self.current_profile = observed_tier.profile();
|
||||||
self.consecutive_down = 0;
|
self.consecutive_down = 0;
|
||||||
@@ -268,7 +142,7 @@ impl QualityController for AdaptiveQualityController {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let observed = Tier::classify_with_context(report, self.network_context);
|
let observed = Tier::classify(report);
|
||||||
self.try_transition(observed)
|
self.try_transition(observed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,110 +246,4 @@ mod tests {
|
|||||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// Network context tests
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cellular_tighter_thresholds() {
|
|
||||||
// 12% loss: Good on WiFi, Degraded on cellular
|
|
||||||
let report = make_report(12.0, 200);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9% loss: Good on WiFi, Degraded on cellular
|
|
||||||
let report = make_report(9.0, 200);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
|
||||||
Tier::Good
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
|
|
||||||
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
|
||||||
let report = make_report(30.0, 200);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
|
||||||
Tier::Catastrophic
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cellular_rtt_thresholds() {
|
|
||||||
// RTT 350ms: Good on WiFi, Degraded on cellular
|
|
||||||
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
|
||||||
Tier::Good
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cellular_faster_downgrade() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
|
||||||
// Reset tier back to Good for testing downgrade threshold
|
|
||||||
ctrl.current_tier = Tier::Good;
|
|
||||||
ctrl.current_profile = Tier::Good.profile();
|
|
||||||
|
|
||||||
// On cellular, downgrade threshold is 2 instead of 3
|
|
||||||
let bad = make_report(50.0, 200);
|
|
||||||
assert!(ctrl.observe(&bad).is_none()); // 1st bad
|
|
||||||
let result = ctrl.observe(&bad); // 2nd bad — should trigger on cellular
|
|
||||||
assert!(result.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signal_network_change_preemptive_downgrade() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
assert_eq!(ctrl.tier(), Tier::Good);
|
|
||||||
|
|
||||||
// Switch from WiFi to cellular
|
|
||||||
ctrl.network_context = NetworkContext::WiFi;
|
|
||||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
|
||||||
|
|
||||||
// Should have downgraded one tier: Good -> Degraded
|
|
||||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signal_network_change_fec_boost() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
assert_eq!(ctrl.fec_boost(), 0.0);
|
|
||||||
|
|
||||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
|
||||||
|
|
||||||
// FEC boost should be active
|
|
||||||
assert!(ctrl.fec_boost() > 0.0);
|
|
||||||
assert_eq!(ctrl.fec_boost(), DEFAULT_FEC_BOOST);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tier_downgrade() {
|
|
||||||
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
|
||||||
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
|
||||||
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn network_context_default() {
|
|
||||||
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,27 +149,6 @@ impl PathMonitor {
|
|||||||
}
|
}
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect whether a network handoff likely occurred.
|
|
||||||
///
|
|
||||||
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
|
|
||||||
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
|
|
||||||
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
|
|
||||||
pub fn detect_handoff(&self) -> bool {
|
|
||||||
// We need at least two RTT observations to have a meaningful jitter value,
|
|
||||||
// and the EWMA must be non-zero to avoid division/multiplication by zero.
|
|
||||||
if self.jitter_ewma <= 0.0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
|
|
||||||
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
|
|
||||||
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
|
|
||||||
instant_jitter > self.jitter_ewma * 3.0
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PathMonitor {
|
impl Default for PathMonitor {
|
||||||
|
|||||||
25
crates/wzp-wasm/Cargo.toml
Normal file
25
crates/wzp-wasm/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
raptorq = "2"
|
||||||
|
js-sys = "0.3"
|
||||||
|
|
||||||
|
# Crypto (ChaCha20-Poly1305 + X25519 key exchange)
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
rand = "0.8"
|
||||||
|
getrandom = { version = "0.2", features = ["js"] } # CRITICAL for WASM randomness
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
692
crates/wzp-wasm/src/lib.rs
Normal file
692
crates/wzp-wasm/src/lib.rs
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
//! WarzonePhone WASM bindings.
|
||||||
|
//!
|
||||||
|
//! Exports two subsystems for browser-side usage:
|
||||||
|
//!
|
||||||
|
//! **FEC** — RaptorQ forward error correction (encode/decode).
|
||||||
|
//! Audio frames are padded to a fixed symbol size (default 256 bytes) with a
|
||||||
|
//! 2-byte little-endian length prefix, matching the native wzp-fec wire format.
|
||||||
|
//!
|
||||||
|
//! Wire format per symbol:
|
||||||
|
//! [block_id:1][symbol_idx:1][is_repair:1][symbol_data:symbol_size]
|
||||||
|
//!
|
||||||
|
//! Encoder output: concatenated symbols in the above format when a block completes.
|
||||||
|
//! Decoder input: individual symbols in the above format.
|
||||||
|
//! Decoder output: concatenated original source data (length-prefix stripped).
|
||||||
|
//!
|
||||||
|
//! **Crypto** — X25519 key exchange + ChaCha20-Poly1305 AEAD encryption.
|
||||||
|
//! Mirrors `wzp-crypto` nonce/session/handshake logic so WASM and native
|
||||||
|
//! peers produce interoperable ciphertext.
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use raptorq::{
|
||||||
|
EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder,
|
||||||
|
SourceBlockEncoder,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Header size prepended to each symbol on the wire: block_id + symbol_idx + is_repair.
|
||||||
|
const HEADER_SIZE: usize = 3;
|
||||||
|
|
||||||
|
/// Length prefix size inside each padded symbol (u16 LE), matching wzp-fec.
|
||||||
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encoder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpFecEncoder {
|
||||||
|
block_id: u8,
|
||||||
|
frames_per_block: usize,
|
||||||
|
symbol_size: usize,
|
||||||
|
source_symbols: Vec<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpFecEncoder {
|
||||||
|
/// Create a new FEC encoder.
|
||||||
|
///
|
||||||
|
/// * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
/// * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
block_id: 0,
|
||||||
|
frames_per_block: block_size,
|
||||||
|
symbol_size,
|
||||||
|
source_symbols: Vec::with_capacity(block_size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a source symbol (audio frame).
|
||||||
|
///
|
||||||
|
/// Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
/// or `undefined` if the block is still accumulating.
|
||||||
|
///
|
||||||
|
/// Each returned packet carries the 3-byte header:
|
||||||
|
/// `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
pub fn add_symbol(&mut self, data: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
self.source_symbols.push(data.to_vec());
|
||||||
|
|
||||||
|
if self.source_symbols.len() >= self.frames_per_block {
|
||||||
|
Some(self.encode_block())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-flush the current (possibly partial) block.
|
||||||
|
///
|
||||||
|
/// Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
/// symbols have been accumulated.
|
||||||
|
pub fn flush(&mut self) -> Vec<u8> {
|
||||||
|
if self.source_symbols.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.encode_block()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal: encode accumulated source symbols into a block, generate repair,
|
||||||
|
/// and return the concatenated wire-format output.
|
||||||
|
fn encode_block(&mut self) -> Vec<u8> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let num_source = self.source_symbols.len();
|
||||||
|
let block_id = self.block_id;
|
||||||
|
|
||||||
|
// Build length-prefixed, padded block data (matches wzp-fec format).
|
||||||
|
let block_data = self.build_block_data();
|
||||||
|
|
||||||
|
let config =
|
||||||
|
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, ss as u16);
|
||||||
|
let encoder = SourceBlockEncoder::new(block_id, &config, &block_data);
|
||||||
|
|
||||||
|
// Generate source packets.
|
||||||
|
let source_packets = encoder.source_packets();
|
||||||
|
|
||||||
|
// Generate repair packets — 50% overhead by default.
|
||||||
|
let num_repair = ((num_source as f32) * 0.5).ceil() as u32;
|
||||||
|
let repair_packets = encoder.repair_packets(0, num_repair);
|
||||||
|
|
||||||
|
// Allocate output buffer.
|
||||||
|
let total_packets = source_packets.len() + repair_packets.len();
|
||||||
|
let packet_wire_size = HEADER_SIZE + ss;
|
||||||
|
let mut output = Vec::with_capacity(total_packets * packet_wire_size);
|
||||||
|
|
||||||
|
// Write source symbols.
|
||||||
|
for (i, pkt) in source_packets.iter().enumerate() {
|
||||||
|
output.push(block_id);
|
||||||
|
output.push(i as u8);
|
||||||
|
output.push(0); // is_repair = false
|
||||||
|
let pkt_data = pkt.data();
|
||||||
|
let copy_len = pkt_data.len().min(ss);
|
||||||
|
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||||
|
// Pad if shorter.
|
||||||
|
if copy_len < ss {
|
||||||
|
output.resize(output.len() + (ss - copy_len), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write repair symbols.
|
||||||
|
for (i, pkt) in repair_packets.iter().enumerate() {
|
||||||
|
output.push(block_id);
|
||||||
|
output.push((num_source + i) as u8);
|
||||||
|
output.push(1); // is_repair = true
|
||||||
|
let pkt_data = pkt.data();
|
||||||
|
let copy_len = pkt_data.len().min(ss);
|
||||||
|
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||||
|
if copy_len < ss {
|
||||||
|
output.resize(output.len() + (ss - copy_len), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance block.
|
||||||
|
self.block_id = self.block_id.wrapping_add(1);
|
||||||
|
self.source_symbols.clear();
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the contiguous, length-prefixed block data buffer.
|
||||||
|
fn build_block_data(&self) -> Vec<u8> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let mut data = vec![0u8; self.source_symbols.len() * ss];
|
||||||
|
for (i, sym) in self.source_symbols.iter().enumerate() {
|
||||||
|
let max_payload = ss - LEN_PREFIX;
|
||||||
|
let payload_len = sym.len().min(max_payload);
|
||||||
|
let offset = i * ss;
|
||||||
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decoder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-block decoder state.
|
||||||
|
struct BlockState {
|
||||||
|
packets: Vec<EncodingPacket>,
|
||||||
|
decoded: bool,
|
||||||
|
result: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpFecDecoder {
|
||||||
|
frames_per_block: usize,
|
||||||
|
symbol_size: usize,
|
||||||
|
blocks: Vec<(u8, BlockState)>, // poor man's map (no std HashMap in tiny WASM)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpFecDecoder {
|
||||||
|
/// Create a new FEC decoder.
|
||||||
|
///
|
||||||
|
/// * `block_size` — expected number of source symbols per block.
|
||||||
|
/// * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
frames_per_block: block_size,
|
||||||
|
symbol_size,
|
||||||
|
blocks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a received symbol.
|
||||||
|
///
|
||||||
|
/// Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
/// enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
pub fn add_symbol(
|
||||||
|
&mut self,
|
||||||
|
block_id: u8,
|
||||||
|
symbol_idx: u8,
|
||||||
|
_is_repair: bool,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
|
||||||
|
// Pad incoming data to symbol_size.
|
||||||
|
let mut padded = vec![0u8; ss];
|
||||||
|
let len = data.len().min(ss);
|
||||||
|
padded[..len].copy_from_slice(&data[..len]);
|
||||||
|
|
||||||
|
let esi = symbol_idx as u32;
|
||||||
|
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
|
||||||
|
|
||||||
|
// Find or create block state.
|
||||||
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
|
if block.decoded {
|
||||||
|
return block.result.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
block.packets.push(packet);
|
||||||
|
|
||||||
|
// Attempt decode.
|
||||||
|
self.try_decode(block_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to decode a block; returns the original frames if successful.
|
||||||
|
fn try_decode(&mut self, block_id: u8) -> Option<Vec<u8>> {
|
||||||
|
let ss = self.symbol_size;
|
||||||
|
let num_source = self.frames_per_block;
|
||||||
|
let block_length = (num_source as u64) * (ss as u64);
|
||||||
|
|
||||||
|
let block = self.get_block_mut(block_id)?;
|
||||||
|
if block.decoded {
|
||||||
|
return block.result.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let config =
|
||||||
|
ObjectTransmissionInformation::with_defaults(block_length, ss as u16);
|
||||||
|
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
|
||||||
|
|
||||||
|
let decoded = decoder.decode(block.packets.clone());
|
||||||
|
|
||||||
|
match decoded {
|
||||||
|
Some(data) => {
|
||||||
|
// Extract original frames by stripping length prefixes.
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for i in 0..num_source {
|
||||||
|
let offset = i * ss;
|
||||||
|
if offset + LEN_PREFIX > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let payload_len = u16::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
]) as usize;
|
||||||
|
let payload_start = offset + LEN_PREFIX;
|
||||||
|
let payload_end = (payload_start + payload_len).min(data.len());
|
||||||
|
output.extend_from_slice(&data[payload_start..payload_end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = self.get_block_mut(block_id).unwrap();
|
||||||
|
block.decoded = true;
|
||||||
|
block.result = Some(output.clone());
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
|
||||||
|
if let Some(pos) = self.blocks.iter().position(|(id, _)| *id == block_id) {
|
||||||
|
return &mut self.blocks[pos].1;
|
||||||
|
}
|
||||||
|
self.blocks.push((
|
||||||
|
block_id,
|
||||||
|
BlockState {
|
||||||
|
packets: Vec::new(),
|
||||||
|
decoded: false,
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let last = self.blocks.len() - 1;
|
||||||
|
&mut self.blocks[last].1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_block_mut(&mut self, block_id: u8) -> Option<&mut BlockState> {
|
||||||
|
self.blocks
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(id, _)| *id == block_id)
|
||||||
|
.map(|(_, state)| state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Crypto — X25519 key exchange
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
///
|
||||||
|
/// Usage from JS:
|
||||||
|
/// ```js
|
||||||
|
/// const kx = new WzpKeyExchange();
|
||||||
|
/// const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
/// // ... send ourPub to peer, receive peerPub ...
|
||||||
|
/// const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
/// const session = new WzpCryptoSession(secret);
|
||||||
|
/// ```
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpKeyExchange {
|
||||||
|
secret: x25519_dalek::StaticSecret,
|
||||||
|
public: x25519_dalek::PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpKeyExchange {
|
||||||
|
/// Generate a new random X25519 keypair.
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let secret = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&secret);
|
||||||
|
Self { secret, public }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our public key (32 bytes).
|
||||||
|
pub fn public_key(&self) -> Vec<u8> {
|
||||||
|
self.public.as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a 32-byte session key from the peer's public key.
|
||||||
|
///
|
||||||
|
/// Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
/// matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
pub fn derive_shared_secret(&self, peer_public: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
if peer_public.len() != 32 {
|
||||||
|
return Err(JsValue::from_str("peer public key must be 32 bytes"));
|
||||||
|
}
|
||||||
|
let mut peer_bytes = [0u8; 32];
|
||||||
|
peer_bytes.copy_from_slice(peer_public);
|
||||||
|
let peer_pk = x25519_dalek::PublicKey::from(peer_bytes);
|
||||||
|
|
||||||
|
// Rebuild secret from bytes (StaticSecret doesn't impl Clone).
|
||||||
|
let secret_bytes = self.secret.to_bytes();
|
||||||
|
let secret_clone = x25519_dalek::StaticSecret::from(secret_bytes);
|
||||||
|
let shared = secret_clone.diffie_hellman(&peer_pk);
|
||||||
|
|
||||||
|
// HKDF expand — same derivation as wzp-crypto handshake.rs
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
|
||||||
|
let mut session_key = [0u8; 32];
|
||||||
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
|
.expect("HKDF expand should not fail for 32-byte output");
|
||||||
|
|
||||||
|
Ok(session_key.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Crypto — ChaCha20-Poly1305 AEAD session
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Build a 12-byte nonce (mirrors `wzp-crypto::nonce::build_nonce`).
|
||||||
|
///
|
||||||
|
/// Layout: `session_id[4] || seq(u32 BE) || direction(1) || pad(3 zero)`.
|
||||||
|
fn build_nonce(session_id: &[u8; 4], seq: u32, direction: u8) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[0..4].copy_from_slice(session_id);
|
||||||
|
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||||
|
nonce[8] = direction;
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
///
|
||||||
|
/// Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
/// and key setup are identical so WASM and native peers interoperate.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WzpCryptoSession {
|
||||||
|
cipher: chacha20poly1305::ChaCha20Poly1305,
|
||||||
|
session_id: [u8; 4],
|
||||||
|
send_seq: u32,
|
||||||
|
recv_seq: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WzpCryptoSession {
|
||||||
|
/// Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(shared_secret: &[u8]) -> Result<WzpCryptoSession, JsValue> {
|
||||||
|
if shared_secret.len() != 32 {
|
||||||
|
return Err(JsValue::from_str("shared secret must be 32 bytes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
use chacha20poly1305::KeyInit;
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
|
let session_id_hash = sha2::Sha256::digest(shared_secret);
|
||||||
|
let mut session_id = [0u8; 4];
|
||||||
|
session_id.copy_from_slice(&session_id_hash[..4]);
|
||||||
|
|
||||||
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(shared_secret)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("invalid key: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
cipher,
|
||||||
|
session_id,
|
||||||
|
send_seq: 0,
|
||||||
|
recv_seq: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
///
|
||||||
|
/// Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
pub fn encrypt(&mut self, header_aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
use chacha20poly1305::aead::{Aead, Payload};
|
||||||
|
use chacha20poly1305::Nonce;
|
||||||
|
|
||||||
|
let nonce_bytes = build_nonce(&self.session_id, self.send_seq, 0); // 0 = Send
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let payload = Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad: header_aad,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(nonce, payload)
|
||||||
|
.map_err(|_| JsValue::from_str("encryption failed"))?;
|
||||||
|
|
||||||
|
self.send_seq = self.send_seq.wrapping_add(1);
|
||||||
|
Ok(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a media payload with AAD.
|
||||||
|
///
|
||||||
|
/// Returns plaintext on success, or throws on auth failure.
|
||||||
|
pub fn decrypt(&mut self, header_aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||||
|
use chacha20poly1305::aead::{Aead, Payload};
|
||||||
|
use chacha20poly1305::Nonce;
|
||||||
|
|
||||||
|
// direction=0 (Send) matches the sender's nonce — same as native code.
|
||||||
|
let nonce_bytes = build_nonce(&self.session_id, self.recv_seq, 0);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let payload = Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad: header_aad,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = self
|
||||||
|
.cipher
|
||||||
|
.decrypt(nonce, payload)
|
||||||
|
.map_err(|_| JsValue::from_str("decryption failed — bad key or corrupted data"))?;
|
||||||
|
|
||||||
|
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current send sequence number (for diagnostics / UI stats).
|
||||||
|
pub fn send_seq(&self) -> u32 {
|
||||||
|
self.send_seq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
pub fn recv_seq(&self) -> u32 {
|
||||||
|
self.recv_seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests (native only — not compiled to WASM)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_roundtrip() {
|
||||||
|
let block_size = 5;
|
||||||
|
let symbol_size = 256;
|
||||||
|
|
||||||
|
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||||
|
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||||
|
|
||||||
|
// Create test frames of varying sizes.
|
||||||
|
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||||
|
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 80 + i * 10])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Feed frames to encoder; last one triggers block encoding.
|
||||||
|
let mut wire_data = None;
|
||||||
|
for frame in &frames {
|
||||||
|
wire_data = encoder.add_symbol(frame);
|
||||||
|
}
|
||||||
|
let wire_data = wire_data.expect("block should be complete");
|
||||||
|
|
||||||
|
// Parse wire packets and feed to decoder.
|
||||||
|
let packet_size = HEADER_SIZE + symbol_size;
|
||||||
|
assert_eq!(wire_data.len() % packet_size, 0);
|
||||||
|
|
||||||
|
let mut result = None;
|
||||||
|
for chunk in wire_data.chunks(packet_size) {
|
||||||
|
let blk_id = chunk[0];
|
||||||
|
let sym_idx = chunk[1];
|
||||||
|
let is_repair = chunk[2] != 0;
|
||||||
|
let sym_data = &chunk[HEADER_SIZE..];
|
||||||
|
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||||
|
result = Some(decoded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded_data = result.expect("should decode with all symbols");
|
||||||
|
|
||||||
|
// Verify: decoded data should be all original frames concatenated.
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for frame in &frames {
|
||||||
|
expected.extend_from_slice(frame);
|
||||||
|
}
|
||||||
|
assert_eq!(decoded_data, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_with_packet_loss() {
|
||||||
|
let block_size = 5;
|
||||||
|
let symbol_size = 256;
|
||||||
|
|
||||||
|
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||||
|
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||||
|
|
||||||
|
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||||
|
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 100])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut wire_data = None;
|
||||||
|
for frame in &frames {
|
||||||
|
wire_data = encoder.add_symbol(frame);
|
||||||
|
}
|
||||||
|
let wire_data = wire_data.unwrap();
|
||||||
|
|
||||||
|
let packet_size = HEADER_SIZE + symbol_size;
|
||||||
|
let packets: Vec<&[u8]> = wire_data.chunks(packet_size).collect();
|
||||||
|
|
||||||
|
// Drop 2 source packets (simulate 40% source loss).
|
||||||
|
// We have 5 source + 3 repair = 8 packets. Drop packets at index 1 and 3.
|
||||||
|
let mut result = None;
|
||||||
|
for (i, chunk) in packets.iter().enumerate() {
|
||||||
|
if i == 1 || i == 3 {
|
||||||
|
continue; // simulate loss
|
||||||
|
}
|
||||||
|
let blk_id = chunk[0];
|
||||||
|
let sym_idx = chunk[1];
|
||||||
|
let is_repair = chunk[2] != 0;
|
||||||
|
let sym_data = &chunk[HEADER_SIZE..];
|
||||||
|
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||||
|
result = Some(decoded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded_data = result.expect("should recover with FEC despite 2 lost packets");
|
||||||
|
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for frame in &frames {
|
||||||
|
expected.extend_from_slice(frame);
|
||||||
|
}
|
||||||
|
assert_eq!(decoded_data, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_partial_block() {
|
||||||
|
let mut encoder = WzpFecEncoder::new(5, 256);
|
||||||
|
|
||||||
|
// Add only 3 of 5 expected symbols, then flush.
|
||||||
|
encoder.add_symbol(&[1; 50]);
|
||||||
|
encoder.add_symbol(&[2; 60]);
|
||||||
|
encoder.add_symbol(&[3; 70]);
|
||||||
|
|
||||||
|
let wire_data = encoder.flush();
|
||||||
|
assert!(!wire_data.is_empty());
|
||||||
|
|
||||||
|
// Verify block_id advanced.
|
||||||
|
assert_eq!(encoder.block_id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Crypto tests -------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crypto_encrypt_decrypt_roundtrip() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
let header = b"test-header";
|
||||||
|
let plaintext = b"hello warzone from wasm";
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(header, plaintext).unwrap();
|
||||||
|
let decrypted = bob.decrypt(header, &ciphertext).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: crypto_wrong_aad_fails and crypto_wrong_key_fails return
|
||||||
|
// Err(JsValue) which aborts on non-wasm32 (JsValue::from_str uses an
|
||||||
|
// extern "C" shim that panics with "cannot unwind"). These tests are
|
||||||
|
// gated to wasm32-only; on native the encrypt/decrypt roundtrip and
|
||||||
|
// nonce-layout tests provide sufficient coverage.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[test]
|
||||||
|
fn crypto_wrong_aad_fails() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(b"correct", b"secret").unwrap();
|
||||||
|
let result = bob.decrypt(b"wrong", &ciphertext);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[test]
|
||||||
|
fn crypto_wrong_key_fails() {
|
||||||
|
let mut alice = WzpCryptoSession::new(&[0xAA; 32]).unwrap();
|
||||||
|
let mut eve = WzpCryptoSession::new(&[0xBB; 32]).unwrap();
|
||||||
|
|
||||||
|
let ciphertext = alice.encrypt(b"hdr", b"secret").unwrap();
|
||||||
|
let result = eve.decrypt(b"hdr", &ciphertext);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crypto_multiple_packets() {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||||
|
|
||||||
|
for i in 0..100u32 {
|
||||||
|
let msg = format!("message {}", i);
|
||||||
|
let ct = alice.encrypt(b"hdr", msg.as_bytes()).unwrap();
|
||||||
|
let pt = bob.decrypt(b"hdr", &ct).unwrap();
|
||||||
|
assert_eq!(pt, msg.as_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(alice.send_seq(), 100);
|
||||||
|
assert_eq!(bob.recv_seq(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_exchange_roundtrip() {
|
||||||
|
let alice_kx = WzpKeyExchange::new();
|
||||||
|
let bob_kx = WzpKeyExchange::new();
|
||||||
|
|
||||||
|
let alice_secret = alice_kx
|
||||||
|
.derive_shared_secret(&bob_kx.public_key())
|
||||||
|
.unwrap();
|
||||||
|
let bob_secret = bob_kx
|
||||||
|
.derive_shared_secret(&alice_kx.public_key())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(alice_secret, bob_secret);
|
||||||
|
assert_eq!(alice_secret.len(), 32);
|
||||||
|
|
||||||
|
// Verify the derived secret actually works for encrypt/decrypt.
|
||||||
|
let mut alice_session = WzpCryptoSession::new(&alice_secret).unwrap();
|
||||||
|
let mut bob_session = WzpCryptoSession::new(&bob_secret).unwrap();
|
||||||
|
|
||||||
|
let ct = alice_session.encrypt(b"hdr", b"hello").unwrap();
|
||||||
|
let pt = bob_session.decrypt(b"hdr", &ct).unwrap();
|
||||||
|
assert_eq!(&pt, b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_layout_matches_native() {
|
||||||
|
// Verify our build_nonce matches wzp-crypto::nonce::build_nonce layout.
|
||||||
|
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||||
|
let seq: u32 = 0x00000100;
|
||||||
|
let nonce = build_nonce(&sid, seq, 1); // 1 = Recv direction
|
||||||
|
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
|
||||||
|
assert_eq!(nonce[8], 1);
|
||||||
|
assert_eq!(&nonce[9..12], &[0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||||
|
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
||||||
|
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
||||||
|
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
||||||
.room-input { margin-bottom: 1.5rem; }
|
.room-input { margin-bottom: 1.5rem; }
|
||||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||||
@@ -31,15 +35,22 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>WarzonePhone</h1>
|
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
||||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||||
|
|
||||||
|
<div class="variant-selector">
|
||||||
|
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
||||||
|
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
||||||
|
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="room-input">
|
<div class="room-input">
|
||||||
<label for="room">Room</label>
|
<label for="room">Room</label>
|
||||||
<input type="text" id="room" placeholder="enter room name" value="">
|
<input type="text" id="room" placeholder="enter room name" value="">
|
||||||
</div>
|
</div>
|
||||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
<button id="callBtn">Connect</button>
|
||||||
<div class="controls" id="controls" style="display:none;">
|
<div class="controls" id="controls" style="display:none;">
|
||||||
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
||||||
</div>
|
</div>
|
||||||
<button id="pttBtn">Hold to Talk</button>
|
<button id="pttBtn">Hold to Talk</button>
|
||||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
@@ -47,302 +58,158 @@
|
|||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="js/wzp-core.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const SAMPLE_RATE = 48000;
|
// ---------------------------------------------------------------------------
|
||||||
const FRAME_SIZE = 960;
|
// Load the selected variant script dynamically
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
let ws = null;
|
|
||||||
let audioCtx = null;
|
|
||||||
let mediaStream = null;
|
|
||||||
let captureNode = null;
|
|
||||||
let playbackNode = null;
|
|
||||||
let active = false;
|
|
||||||
let transmitting = true; // in open-mic mode, always transmitting
|
|
||||||
let pttMode = false;
|
|
||||||
let framesSent = 0;
|
|
||||||
let framesRecv = 0;
|
|
||||||
let startTime = 0;
|
|
||||||
let statsInterval = null;
|
|
||||||
|
|
||||||
// Use room from URL path or input field
|
|
||||||
function getRoom() {
|
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
||||||
if (path && path !== 'index.html') return path;
|
|
||||||
const hash = location.hash.replace('#', '');
|
|
||||||
if (hash) return hash;
|
|
||||||
return document.getElementById('room').value.trim() || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill room input from URL on page load
|
|
||||||
(function() {
|
(function() {
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
var variant = WZPCore.detectVariant();
|
||||||
if (path && path !== 'index.html') {
|
var scriptMap = {
|
||||||
document.getElementById('room').value = path;
|
pure: 'js/wzp-pure.js',
|
||||||
}
|
hybrid: 'js/wzp-hybrid.js',
|
||||||
|
full: 'js/wzp-full.js',
|
||||||
|
'ws': 'js/wzp-ws.js',
|
||||||
|
'ws-fec': 'js/wzp-ws-fec.js',
|
||||||
|
'ws-full': 'js/wzp-ws-full.js',
|
||||||
|
};
|
||||||
|
var src = scriptMap[variant] || scriptMap.pure;
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = function() { wzpBoot(); };
|
||||||
|
s.onerror = function() {
|
||||||
|
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
// ---------------------------------------------------------------------------
|
||||||
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
// Boot: wire UI to the loaded client variant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function wzpBoot() {
|
||||||
|
var client = null;
|
||||||
|
var capture = null;
|
||||||
|
var playback = null;
|
||||||
|
var transmitting = true;
|
||||||
|
|
||||||
function toggleCall() {
|
var ui = WZPCore.initUI({
|
||||||
if (active) stopCall();
|
onConnect: function(room) {
|
||||||
else startCall();
|
doConnect(room);
|
||||||
}
|
},
|
||||||
|
onDisconnect: function() {
|
||||||
async function startCall() {
|
doDisconnect();
|
||||||
const btn = document.getElementById('callBtn');
|
},
|
||||||
const room = getRoom();
|
onTransmit: function(tx) {
|
||||||
if (!room) { setStatus('Enter a room name'); return; }
|
transmitting = tx;
|
||||||
|
},
|
||||||
btn.disabled = true;
|
|
||||||
setStatus('Requesting microphone...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function doConnect(room) {
|
||||||
|
WZPCore.updateStatus('Requesting microphone...');
|
||||||
|
|
||||||
|
var audioCtx;
|
||||||
|
try {
|
||||||
|
audioCtx = await WZPCore.startAudioContext();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus('Mic access denied: ' + e.message);
|
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
||||||
btn.disabled = false;
|
ui.setConnected(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
// Build WebSocket URL
|
||||||
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||||
|
|
||||||
// Connect WebSocket with room name
|
// Create client based on detected variant
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
var variant = WZPCore.detectVariant();
|
||||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
var ClientClass = {
|
||||||
setStatus('Connecting to room: ' + room + '...');
|
pure: window.WZPPureClient,
|
||||||
|
hybrid: window.WZPHybridClient,
|
||||||
|
full: window.WZPFullClient,
|
||||||
|
'ws': window.WZPWsClient,
|
||||||
|
'ws-fec': window.WZPWsFecClient,
|
||||||
|
'ws-full': window.WZPWsFullClient,
|
||||||
|
}[variant] || window.WZPPureClient;
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
var clientOpts = {
|
||||||
ws.binaryType = 'arraybuffer';
|
wsUrl: wsUrl,
|
||||||
|
room: room,
|
||||||
ws.onopen = async () => {
|
onAudio: function(pcm) {
|
||||||
setStatus('Connected to room: ' + room);
|
if (playback) playback.play(pcm);
|
||||||
btn.textContent = 'Disconnect';
|
},
|
||||||
btn.classList.add('active');
|
onStatus: function(msg) {
|
||||||
btn.disabled = false;
|
WZPCore.updateStatus(msg);
|
||||||
active = true;
|
},
|
||||||
framesSent = 0;
|
onStats: function(stats) {
|
||||||
framesRecv = 0;
|
WZPCore.updateStats(stats);
|
||||||
startTime = Date.now();
|
},
|
||||||
showControls(true);
|
|
||||||
await startAudioCapture();
|
|
||||||
await startAudioPlayback();
|
|
||||||
startStatsUpdate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
// Full variant: add WebTransport URL for direct relay connection
|
||||||
const pcmData = new Int16Array(event.data);
|
if (variant === 'full') {
|
||||||
framesRecv++;
|
clientOpts.url = location.origin.replace('http', 'https');
|
||||||
playAudio(pcmData);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (active) {
|
|
||||||
setStatus('Disconnected — reconnecting to ' + room + '...');
|
|
||||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
|
||||||
} else {
|
|
||||||
setStatus('Disconnected');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
if (active) {
|
|
||||||
setStatus('Error — reconnecting...');
|
|
||||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCall() {
|
client = new ClientClass(clientOpts);
|
||||||
active = false;
|
|
||||||
const btn = document.getElementById('callBtn');
|
|
||||||
btn.textContent = 'Connect';
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.disabled = false;
|
|
||||||
showControls(false);
|
|
||||||
cleanupAudio();
|
|
||||||
if (ws) { ws.close(); ws = null; }
|
|
||||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
|
||||||
setStatus('');
|
|
||||||
setStats('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupAudio() {
|
// Load WASM for variants that need it
|
||||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
if (client.loadWasm) {
|
||||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
|
||||||
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
|
|
||||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let workletLoaded = false;
|
|
||||||
|
|
||||||
async function loadWorkletModule() {
|
|
||||||
if (workletLoaded) return true;
|
|
||||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
|
||||||
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
WZPCore.updateStatus('Loading WASM module...');
|
||||||
workletLoaded = true;
|
await client.loadWasm();
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
|
WZPCore.updateStatus('WASM load failed: ' + e.message);
|
||||||
return false;
|
ui.setConnected(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAudioCapture() {
|
try {
|
||||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
await client.connect();
|
||||||
const hasWorklet = await loadWorkletModule();
|
} catch (e) {
|
||||||
|
WZPCore.updateStatus('Connection failed: ' + e.message);
|
||||||
if (hasWorklet) {
|
ui.setConnected(false);
|
||||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
return;
|
||||||
captureNode.port.onmessage = (e) => {
|
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
||||||
ws.send(e.data);
|
|
||||||
framesSent++;
|
|
||||||
|
|
||||||
// Level meter from the PCM data
|
|
||||||
const pcm = new Int16Array(e.data);
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
||||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
|
||||||
} else {
|
|
||||||
// Fallback to ScriptProcessorNode (deprecated but widely supported)
|
|
||||||
console.warn('Capture: using ScriptProcessorNode fallback');
|
|
||||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
||||||
let acc = new Float32Array(0);
|
|
||||||
captureNode.onaudioprocess = (ev) => {
|
|
||||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
|
||||||
const input = ev.inputBuffer.getChannelData(0);
|
|
||||||
const n = new Float32Array(acc.length + input.length);
|
|
||||||
n.set(acc); n.set(input, acc.length); acc = n;
|
|
||||||
while (acc.length >= FRAME_SIZE) {
|
|
||||||
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
|
||||||
const pcm = new Int16Array(FRAME_SIZE);
|
|
||||||
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
|
||||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
|
||||||
ws.send(pcm.buffer);
|
|
||||||
framesSent++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAudioPlayback() {
|
// Start audio capture and playback
|
||||||
const hasWorklet = await loadWorkletModule();
|
try {
|
||||||
|
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
||||||
if (hasWorklet) {
|
if (!transmitting) return;
|
||||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
var pcm = new Int16Array(pcmBuffer);
|
||||||
playbackNode.connect(audioCtx.destination);
|
WZPCore.updateLevel(pcm);
|
||||||
} else {
|
if (client) client.sendAudio(pcmBuffer);
|
||||||
console.warn('Playback: using scheduled BufferSource fallback');
|
|
||||||
playbackNode = null; // will use createBufferSource fallback in playAudio()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextPlayTime = 0;
|
|
||||||
|
|
||||||
function playAudio(pcmInt16) {
|
|
||||||
if (!audioCtx) return;
|
|
||||||
|
|
||||||
if (playbackNode && playbackNode.port) {
|
|
||||||
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
|
|
||||||
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
|
|
||||||
} else {
|
|
||||||
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
|
|
||||||
const floatData = new Float32Array(pcmInt16.length);
|
|
||||||
for (let i = 0; i < pcmInt16.length; i++) {
|
|
||||||
floatData[i] = pcmInt16[i] / 32768.0;
|
|
||||||
}
|
|
||||||
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
|
||||||
buffer.getChannelData(0).set(floatData);
|
|
||||||
const source = audioCtx.createBufferSource();
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.connect(audioCtx.destination);
|
|
||||||
const now = audioCtx.currentTime;
|
|
||||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
|
||||||
nextPlayTime = now + 0.02;
|
|
||||||
}
|
|
||||||
source.start(nextPlayTime);
|
|
||||||
nextPlayTime += buffer.duration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startStatsUpdate() {
|
|
||||||
statsInterval = setInterval(() => {
|
|
||||||
if (!active) { clearInterval(statsInterval); return; }
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Push-to-talk ---
|
|
||||||
|
|
||||||
function togglePTT() {
|
|
||||||
pttMode = document.getElementById('pttMode').checked;
|
|
||||||
const btn = document.getElementById('pttBtn');
|
|
||||||
if (pttMode) {
|
|
||||||
transmitting = false;
|
|
||||||
btn.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
transmitting = true;
|
|
||||||
btn.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PTT button — hold to talk (mouse + touch)
|
|
||||||
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
|
||||||
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
|
||||||
|
|
||||||
// Spacebar PTT
|
|
||||||
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
|
||||||
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
|
||||||
|
|
||||||
function startTransmit() {
|
|
||||||
if (!pttMode || !active) return;
|
|
||||||
transmitting = true;
|
|
||||||
document.getElementById('pttBtn').classList.add('transmitting');
|
|
||||||
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTransmit() {
|
|
||||||
if (!pttMode) return;
|
|
||||||
transmitting = false;
|
|
||||||
document.getElementById('pttBtn').classList.remove('transmitting');
|
|
||||||
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show controls when connected
|
|
||||||
function showControls(show) {
|
|
||||||
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
|
||||||
if (!show) {
|
|
||||||
document.getElementById('pttBtn').style.display = 'none';
|
|
||||||
pttMode = false;
|
|
||||||
transmitting = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set room from URL on load
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const room = getRoom();
|
|
||||||
if (room && room !== 'default') {
|
|
||||||
document.getElementById('room').value = room;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
playback = await WZPCore.connectPlayback(audioCtx);
|
||||||
|
} catch (e) {
|
||||||
|
WZPCore.updateStatus('Audio error: ' + e.message);
|
||||||
|
if (client) client.disconnect();
|
||||||
|
client = null;
|
||||||
|
ui.setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.setConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDisconnect() {
|
||||||
|
if (capture) { capture.stop(); capture = null; }
|
||||||
|
if (playback) { playback.stop(); playback = null; }
|
||||||
|
if (client) { client.disconnect(); client = null; }
|
||||||
|
|
||||||
|
var audioCtx = WZPCore.getAudioContext();
|
||||||
|
if (audioCtx && audioCtx.state !== 'closed') {
|
||||||
|
audioCtx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
WZPCore.updateStatus('');
|
||||||
|
WZPCore.updateStats('');
|
||||||
|
document.getElementById('levelBar').style.width = '0%';
|
||||||
|
|
||||||
|
ui.setConnected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
379
crates/wzp-web/static/js/wzp-core.js
Normal file
379
crates/wzp-web/static/js/wzp-core.js
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
// WarzonePhone — Shared UI logic for all client variants.
|
||||||
|
// Provides: audio context management, mic capture, playback, UI wiring.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WZP_SAMPLE_RATE = 48000;
|
||||||
|
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Variant detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpDetectVariant() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const v = (params.get('variant') || 'pure').toLowerCase();
|
||||||
|
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
|
||||||
|
if (valid.includes(v)) return v;
|
||||||
|
return 'pure';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Room helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpGetRoom() {
|
||||||
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
if (path && path !== 'index.html') return path;
|
||||||
|
const hash = location.hash.replace('#', '');
|
||||||
|
if (hash) return hash;
|
||||||
|
const el = document.getElementById('room');
|
||||||
|
return (el && el.value.trim()) || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpPrefillRoom() {
|
||||||
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
|
if (path && path !== 'index.html') {
|
||||||
|
const el = document.getElementById('room');
|
||||||
|
if (el) el.value = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status / stats helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpUpdateStatus(msg) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpUpdateStats(stats) {
|
||||||
|
const el = document.getElementById('stats');
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof stats === 'string') {
|
||||||
|
el.textContent = stats;
|
||||||
|
} else {
|
||||||
|
const parts = [];
|
||||||
|
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
|
||||||
|
if (stats.sent != null) parts.push('sent: ' + stats.sent);
|
||||||
|
if (stats.recv != null) parts.push('recv: ' + stats.recv);
|
||||||
|
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
|
||||||
|
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
|
||||||
|
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
|
||||||
|
el.textContent = parts.join(' | ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpUpdateLevel(pcmInt16) {
|
||||||
|
const bar = document.getElementById('levelBar');
|
||||||
|
if (!bar) return;
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < pcmInt16.length; i += 16) {
|
||||||
|
const v = Math.abs(pcmInt16[i]);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
bar.style.width = (max / 32768 * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio context + worklet
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _wzpAudioCtx = null;
|
||||||
|
let _wzpWorkletLoaded = false;
|
||||||
|
|
||||||
|
async function wzpStartAudioContext() {
|
||||||
|
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
|
||||||
|
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
|
||||||
|
_wzpWorkletLoaded = false;
|
||||||
|
return _wzpAudioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wzpGetAudioContext() {
|
||||||
|
return _wzpAudioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _wzpLoadWorklet(audioCtx) {
|
||||||
|
if (_wzpWorkletLoaded) return true;
|
||||||
|
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
||||||
|
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||||
|
_wzpWorkletLoaded = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[wzp-core] AudioWorklet load failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mic capture — returns { node, stop() }
|
||||||
|
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function wzpConnectCapture(audioCtx, onFrame) {
|
||||||
|
let mediaStream;
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
sampleRate: WZP_SAMPLE_RATE,
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Mic access denied: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||||
|
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||||
|
let captureNode;
|
||||||
|
|
||||||
|
if (hasWorklet) {
|
||||||
|
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
||||||
|
captureNode.port.onmessage = (e) => {
|
||||||
|
onFrame(e.data); // ArrayBuffer of Int16 PCM
|
||||||
|
};
|
||||||
|
source.connect(captureNode);
|
||||||
|
captureNode.connect(audioCtx.destination); // keep worklet alive
|
||||||
|
} else {
|
||||||
|
// ScriptProcessorNode fallback
|
||||||
|
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||||
|
let acc = new Float32Array(0);
|
||||||
|
captureNode.onaudioprocess = (ev) => {
|
||||||
|
const input = ev.inputBuffer.getChannelData(0);
|
||||||
|
const n = new Float32Array(acc.length + input.length);
|
||||||
|
n.set(acc);
|
||||||
|
n.set(input, acc.length);
|
||||||
|
acc = n;
|
||||||
|
while (acc.length >= WZP_FRAME_SIZE) {
|
||||||
|
const frame = acc.slice(0, WZP_FRAME_SIZE);
|
||||||
|
acc = acc.slice(WZP_FRAME_SIZE);
|
||||||
|
const pcm = new Int16Array(WZP_FRAME_SIZE);
|
||||||
|
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
|
||||||
|
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||||
|
}
|
||||||
|
onFrame(pcm.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source.connect(captureNode);
|
||||||
|
captureNode.connect(audioCtx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: captureNode,
|
||||||
|
stop() {
|
||||||
|
captureNode.disconnect();
|
||||||
|
mediaStream.getTracks().forEach((t) => t.stop());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playback — returns { node, play(Int16Array), stop() }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function wzpConnectPlayback(audioCtx) {
|
||||||
|
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||||
|
let playbackNode;
|
||||||
|
let nextPlayTime = 0;
|
||||||
|
|
||||||
|
if (hasWorklet) {
|
||||||
|
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
||||||
|
playbackNode.connect(audioCtx.destination);
|
||||||
|
return {
|
||||||
|
node: playbackNode,
|
||||||
|
play(pcmInt16) {
|
||||||
|
// Transfer Int16 buffer to worklet
|
||||||
|
const buf = pcmInt16.buffer.slice(
|
||||||
|
pcmInt16.byteOffset,
|
||||||
|
pcmInt16.byteOffset + pcmInt16.byteLength
|
||||||
|
);
|
||||||
|
playbackNode.port.postMessage(buf, [buf]);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
playbackNode.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scheduled BufferSource
|
||||||
|
return {
|
||||||
|
node: null,
|
||||||
|
play(pcmInt16) {
|
||||||
|
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||||
|
const floatData = new Float32Array(pcmInt16.length);
|
||||||
|
for (let i = 0; i < pcmInt16.length; i++) {
|
||||||
|
floatData[i] = pcmInt16[i] / 32768.0;
|
||||||
|
}
|
||||||
|
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
|
||||||
|
buffer.getChannelData(0).set(floatData);
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioCtx.destination);
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||||
|
nextPlayTime = now + 0.02;
|
||||||
|
}
|
||||||
|
source.start(nextPlayTime);
|
||||||
|
nextPlayTime += buffer.duration;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
// nothing to disconnect for fallback
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI wiring — call after DOM ready
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wzpInitUI(callbacks) {
|
||||||
|
// callbacks: { onConnect(room), onDisconnect() }
|
||||||
|
const btn = document.getElementById('callBtn');
|
||||||
|
const pttBtn = document.getElementById('pttBtn');
|
||||||
|
const pttCheckbox = document.getElementById('pttMode');
|
||||||
|
let connected = false;
|
||||||
|
let pttMode = false;
|
||||||
|
|
||||||
|
wzpPrefillRoom();
|
||||||
|
|
||||||
|
// Variant badge
|
||||||
|
const variant = wzpDetectVariant();
|
||||||
|
const badge = document.getElementById('variantBadge');
|
||||||
|
if (badge) badge.textContent = variant.toUpperCase();
|
||||||
|
|
||||||
|
// Variant selector radio buttons
|
||||||
|
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
|
||||||
|
if (radio.value === variant) radio.checked = true;
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
if (radio.checked) {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set('variant', radio.value);
|
||||||
|
location.search = params.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.onclick = () => {
|
||||||
|
if (connected) {
|
||||||
|
connected = false;
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
_showControls(false);
|
||||||
|
if (callbacks.onDisconnect) callbacks.onDisconnect();
|
||||||
|
} else {
|
||||||
|
const room = wzpGetRoom();
|
||||||
|
if (!room) {
|
||||||
|
wzpUpdateStatus('Enter a room name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connected = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
if (callbacks.onConnect) callbacks.onConnect(room);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PTT toggle
|
||||||
|
if (pttCheckbox) {
|
||||||
|
pttCheckbox.onchange = () => {
|
||||||
|
pttMode = pttCheckbox.checked;
|
||||||
|
if (pttMode) {
|
||||||
|
pttBtn.style.display = 'block';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||||
|
} else {
|
||||||
|
pttBtn.style.display = 'none';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTT button events
|
||||||
|
function startTx() {
|
||||||
|
if (!pttMode || !connected) return;
|
||||||
|
pttBtn.classList.add('transmitting');
|
||||||
|
pttBtn.textContent = 'Transmitting...';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||||
|
}
|
||||||
|
function stopTx() {
|
||||||
|
if (!pttMode) return;
|
||||||
|
pttBtn.classList.remove('transmitting');
|
||||||
|
pttBtn.textContent = 'Hold to Talk';
|
||||||
|
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pttBtn) {
|
||||||
|
pttBtn.addEventListener('mousedown', startTx);
|
||||||
|
pttBtn.addEventListener('mouseup', stopTx);
|
||||||
|
pttBtn.addEventListener('mouseleave', stopTx);
|
||||||
|
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
|
||||||
|
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacebar PTT
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
|
||||||
|
e.preventDefault();
|
||||||
|
startTx();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
if (pttMode && connected && e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
stopTx();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _showControls(show) {
|
||||||
|
const controls = document.getElementById('controls');
|
||||||
|
if (controls) controls.style.display = show ? 'flex' : 'none';
|
||||||
|
if (!show && pttBtn) {
|
||||||
|
pttBtn.style.display = 'none';
|
||||||
|
pttMode = false;
|
||||||
|
if (pttCheckbox) pttCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setConnected(isConnected) {
|
||||||
|
connected = isConnected;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (isConnected) {
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
btn.classList.add('active');
|
||||||
|
_showControls(true);
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
_showControls(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPTT() {
|
||||||
|
return pttMode;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exports (global)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPCore = {
|
||||||
|
SAMPLE_RATE: WZP_SAMPLE_RATE,
|
||||||
|
FRAME_SIZE: WZP_FRAME_SIZE,
|
||||||
|
detectVariant: wzpDetectVariant,
|
||||||
|
getRoom: wzpGetRoom,
|
||||||
|
updateStatus: wzpUpdateStatus,
|
||||||
|
updateStats: wzpUpdateStats,
|
||||||
|
updateLevel: wzpUpdateLevel,
|
||||||
|
startAudioContext: wzpStartAudioContext,
|
||||||
|
getAudioContext: wzpGetAudioContext,
|
||||||
|
connectCapture: wzpConnectCapture,
|
||||||
|
connectPlayback: wzpConnectPlayback,
|
||||||
|
initUI: wzpInitUI,
|
||||||
|
};
|
||||||
579
crates/wzp-web/static/js/wzp-full.js
Normal file
579
crates/wzp-web/static/js/wzp-full.js
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
|
||||||
|
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
|
||||||
|
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
|
||||||
|
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
|
||||||
|
//
|
||||||
|
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
|
||||||
|
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
|
||||||
|
// architecture but will need relay-side HTTP/3 support to work end-to-end.
|
||||||
|
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
|
||||||
|
//
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||||
|
const MEDIA_HEADER_SIZE = 12;
|
||||||
|
|
||||||
|
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||||
|
const FEC_HEADER_SIZE = 3;
|
||||||
|
|
||||||
|
class WZPFullClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.url WebTransport URL (https://host:port)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback(Object) for UI stats
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.url = options.url;
|
||||||
|
this.wsUrl = options.wsUrl; // WS fallback URL
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.wt = null; // WebTransport instance
|
||||||
|
this.ws = null; // WebSocket fallback
|
||||||
|
this.datagramWriter = null; // WritableStreamDefaultWriter
|
||||||
|
this.datagramReader = null; // ReadableStreamDefaultReader
|
||||||
|
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
||||||
|
this.fecEncoder = null; // WzpFecEncoder (WASM)
|
||||||
|
this.fecDecoder = null; // WzpFecDecoder (WASM)
|
||||||
|
this.sequence = 0;
|
||||||
|
this._wasmModule = null;
|
||||||
|
this._connected = false;
|
||||||
|
this._useWebTransport = false; // true if WT connected, false = WS fallback
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._recvLoopRunning = false;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect: load WASM, open WebTransport, perform key exchange,
|
||||||
|
* initialise FEC, and start the receive loop.
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
this._status('Loading WASM module...');
|
||||||
|
|
||||||
|
// 1. Load WASM (FEC + crypto)
|
||||||
|
this._wasmModule = await import(WZP_WASM_PATH);
|
||||||
|
await this._wasmModule.default();
|
||||||
|
|
||||||
|
// 2. Try WebTransport first, fall back to WebSocket
|
||||||
|
let wtSuccess = false;
|
||||||
|
if (typeof WebTransport !== 'undefined' && this.url) {
|
||||||
|
try {
|
||||||
|
this._status('Trying WebTransport...');
|
||||||
|
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
||||||
|
this.wt = new WebTransport(wtUrl);
|
||||||
|
await Promise.race([
|
||||||
|
this.wt.ready,
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
||||||
|
]);
|
||||||
|
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
||||||
|
this.datagramReader = this.wt.datagrams.readable.getReader();
|
||||||
|
this._status('Performing key exchange...');
|
||||||
|
await this._performKeyExchange();
|
||||||
|
wtSuccess = true;
|
||||||
|
this._useWebTransport = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message);
|
||||||
|
if (this.wt) { try { this.wt.close(); } catch (_) {} }
|
||||||
|
this.wt = null;
|
||||||
|
this.datagramWriter = null;
|
||||||
|
this.datagramReader = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wtSuccess) {
|
||||||
|
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
|
||||||
|
this._useWebTransport = false;
|
||||||
|
await this._connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Initialise FEC
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||||
|
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._startStatsTimer();
|
||||||
|
|
||||||
|
// 4. Start receive loop (WebTransport only — WS uses onmessage)
|
||||||
|
if (this._useWebTransport) {
|
||||||
|
this._recvLoop();
|
||||||
|
this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)');
|
||||||
|
} else {
|
||||||
|
this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket fallback connection (used when WebTransport unavailable).
|
||||||
|
*/
|
||||||
|
async _connectWebSocket() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting via WebSocket (fallback)...');
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this._status('WebSocket connected to room: ' + this.room);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const pcm = new Int16Array(event.data);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) this.onAudio(pcm);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
if (this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
this._status('Disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect and clean up all resources.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.wt) {
|
||||||
|
try { this.wt.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.wt = null;
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame.
|
||||||
|
*
|
||||||
|
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected) return;
|
||||||
|
|
||||||
|
// WebSocket fallback: send raw PCM like pure/hybrid
|
||||||
|
if (!this._useWebTransport) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(pcmBuffer);
|
||||||
|
this.sequence++;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.datagramWriter || !this.cryptoSession) return;
|
||||||
|
|
||||||
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
|
||||||
|
// Build a minimal 12-byte MediaHeader for AAD.
|
||||||
|
const header = this._buildMediaHeader(this.sequence);
|
||||||
|
|
||||||
|
// FEC encode: feed the frame; when a block completes we get wire packets.
|
||||||
|
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
|
||||||
|
|
||||||
|
if (fecOutput) {
|
||||||
|
// FEC block completed — send all packets (source + repair).
|
||||||
|
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
|
||||||
|
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
||||||
|
const fecPacket = fecOutput.slice(offset, offset + packetSize);
|
||||||
|
|
||||||
|
// Encrypt: header bytes as AAD, FEC packet as plaintext.
|
||||||
|
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
|
||||||
|
this.stats.encrypted++;
|
||||||
|
|
||||||
|
// Build wire datagram: header (12) + ciphertext
|
||||||
|
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
|
||||||
|
datagram.set(header, 0);
|
||||||
|
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.datagramWriter.write(datagram);
|
||||||
|
} catch (e) {
|
||||||
|
// Datagram send can fail if the transport is closing.
|
||||||
|
if (this._connected) {
|
||||||
|
console.warn('[wzp-full] datagram write failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If FEC block not yet complete, accumulate (no packets sent yet).
|
||||||
|
|
||||||
|
this.sequence = (this.sequence + 1) & 0xFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||||
|
* Useful for verifying the WASM module works correctly in the browser.
|
||||||
|
*
|
||||||
|
* @returns {Object} test results
|
||||||
|
*/
|
||||||
|
testCryptoFec() {
|
||||||
|
if (!this._wasmModule) {
|
||||||
|
return { success: false, error: 'WASM module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const wasm = this._wasmModule;
|
||||||
|
|
||||||
|
// Key exchange
|
||||||
|
const alice = new wasm.WzpKeyExchange();
|
||||||
|
const bob = new wasm.WzpKeyExchange();
|
||||||
|
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
||||||
|
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
||||||
|
|
||||||
|
// Verify secrets match
|
||||||
|
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||||
|
if (secretsMatch) {
|
||||||
|
for (let i = 0; i < aliceSecret.length; i++) {
|
||||||
|
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt/decrypt
|
||||||
|
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||||
|
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||||
|
|
||||||
|
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||||
|
const plaintext = new TextEncoder().encode('hello warzone from full variant');
|
||||||
|
|
||||||
|
const ciphertext = aliceSession.encrypt(header, plaintext);
|
||||||
|
const decrypted = bobSession.decrypt(header, ciphertext);
|
||||||
|
|
||||||
|
let cryptoOk = decrypted.length === plaintext.length;
|
||||||
|
if (cryptoOk) {
|
||||||
|
for (let i = 0; i < plaintext.length; i++) {
|
||||||
|
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FEC test (same as hybrid testFec)
|
||||||
|
const encoder = new wasm.WzpFecEncoder(5, 256);
|
||||||
|
const decoder = new wasm.WzpFecDecoder(5, 256);
|
||||||
|
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const frame = new Uint8Array(100);
|
||||||
|
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
let wireData = null;
|
||||||
|
for (const frame of frames) {
|
||||||
|
const result = encoder.add_symbol(frame);
|
||||||
|
if (result) wireData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
|
||||||
|
const packets = [];
|
||||||
|
if (wireData) {
|
||||||
|
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
|
||||||
|
packets.push({
|
||||||
|
blockId: wireData[off],
|
||||||
|
symbolIdx: wireData[off + 1],
|
||||||
|
isRepair: wireData[off + 2] !== 0,
|
||||||
|
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop 2 packets, try to recover
|
||||||
|
let fecDecoded = null;
|
||||||
|
for (let i = 0; i < packets.length; i++) {
|
||||||
|
if (i === 1 || i === 3) continue; // simulate loss
|
||||||
|
const pkt = packets[i];
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||||
|
if (result) { fecDecoded = result; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let fecOk = false;
|
||||||
|
if (fecDecoded) {
|
||||||
|
const expected = new Uint8Array(5 * 100);
|
||||||
|
let off = 0;
|
||||||
|
for (const f of frames) { expected.set(f, off); off += f.length; }
|
||||||
|
fecOk = fecDecoded.length === expected.length;
|
||||||
|
if (fecOk) {
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup WASM objects
|
||||||
|
alice.free();
|
||||||
|
bob.free();
|
||||||
|
aliceSession.free();
|
||||||
|
bobSession.free();
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: secretsMatch && cryptoOk && fecOk,
|
||||||
|
secretsMatch,
|
||||||
|
cryptoOk,
|
||||||
|
fecOk,
|
||||||
|
fecPacketsTotal: packets.length,
|
||||||
|
fecDropped: 2,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Internal
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform X25519 key exchange over a WebTransport bidirectional stream.
|
||||||
|
*
|
||||||
|
* Protocol (simplified DH, not the full SignalMessage handshake):
|
||||||
|
* 1. Open a bidirectional stream.
|
||||||
|
* 2. Send our 32-byte X25519 public key.
|
||||||
|
* 3. Read the peer's 32-byte public key.
|
||||||
|
* 4. Derive shared secret via HKDF.
|
||||||
|
* 5. Create WzpCryptoSession from the shared secret.
|
||||||
|
*
|
||||||
|
* In production this would use the full SignalMessage protocol over the
|
||||||
|
* bidirectional stream (offer/answer/encrypted-session). For now we do
|
||||||
|
* a simple DH swap to prove the architecture.
|
||||||
|
*/
|
||||||
|
async _performKeyExchange() {
|
||||||
|
const wasm = this._wasmModule;
|
||||||
|
const kx = new wasm.WzpKeyExchange();
|
||||||
|
const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
|
||||||
|
// Open a bidirectional stream for signaling.
|
||||||
|
const stream = await this.wt.createBidirectionalStream();
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
const reader = stream.readable.getReader();
|
||||||
|
|
||||||
|
// Send our public key.
|
||||||
|
await writer.write(new Uint8Array(ourPub));
|
||||||
|
|
||||||
|
// Read peer's public key (exactly 32 bytes).
|
||||||
|
// WebTransport streams are byte-oriented; we may get it in chunks.
|
||||||
|
let peerPub = new Uint8Array(0);
|
||||||
|
while (peerPub.length < 32) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
throw new Error('Key exchange stream closed before receiving peer public key');
|
||||||
|
}
|
||||||
|
const combined = new Uint8Array(peerPub.length + value.length);
|
||||||
|
combined.set(peerPub, 0);
|
||||||
|
combined.set(value, peerPub.length);
|
||||||
|
peerPub = combined;
|
||||||
|
}
|
||||||
|
peerPub = peerPub.slice(0, 32);
|
||||||
|
|
||||||
|
// Derive shared secret and create crypto session.
|
||||||
|
const secret = kx.derive_shared_secret(peerPub);
|
||||||
|
this.cryptoSession = new wasm.WzpCryptoSession(secret);
|
||||||
|
|
||||||
|
// Close the signaling stream (key exchange complete).
|
||||||
|
try {
|
||||||
|
writer.releaseLock();
|
||||||
|
reader.releaseLock();
|
||||||
|
await stream.writable.close();
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort close.
|
||||||
|
}
|
||||||
|
|
||||||
|
kx.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
|
||||||
|
*
|
||||||
|
* Runs until the transport closes or disconnect() is called.
|
||||||
|
*/
|
||||||
|
async _recvLoop() {
|
||||||
|
if (this._recvLoopRunning) return;
|
||||||
|
this._recvLoopRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this._connected && this.datagramReader) {
|
||||||
|
const { value, done } = await this.datagramReader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
this.stats.recv++;
|
||||||
|
|
||||||
|
// value is a Uint8Array datagram: header(12) + ciphertext
|
||||||
|
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
|
||||||
|
|
||||||
|
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
|
||||||
|
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let fecPacket;
|
||||||
|
try {
|
||||||
|
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
|
||||||
|
this.stats.decrypted++;
|
||||||
|
} catch (e) {
|
||||||
|
// Decryption failure — corrupted or out-of-order packet.
|
||||||
|
// In a real implementation we'd handle sequence number gaps.
|
||||||
|
console.warn('[wzp-full] decrypt failed:', e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FEC decode: parse the FEC wire header and feed to decoder.
|
||||||
|
if (fecPacket.length < FEC_HEADER_SIZE) continue;
|
||||||
|
const blockId = fecPacket[0];
|
||||||
|
const symbolIdx = fecPacket[1];
|
||||||
|
const isRepair = fecPacket[2] !== 0;
|
||||||
|
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
|
||||||
|
|
||||||
|
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
|
||||||
|
if (decoded) {
|
||||||
|
this.stats.fecRecovered++;
|
||||||
|
// decoded is concatenated original PCM frames.
|
||||||
|
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
|
||||||
|
const FRAME_BYTES = 1920;
|
||||||
|
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
|
||||||
|
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
|
||||||
|
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (this._connected) {
|
||||||
|
console.warn('[wzp-full] recv loop error:', e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._recvLoopRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal 12-byte MediaHeader for use as AAD.
|
||||||
|
*
|
||||||
|
* Wire layout (from wzp-proto::packet::MediaHeader):
|
||||||
|
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
||||||
|
* Byte 1: FecRatioLo(6)|unused(2)
|
||||||
|
* Bytes 2-3: Sequence number (BE u16)
|
||||||
|
* Bytes 4-7: Timestamp ms (BE u32)
|
||||||
|
* Byte 8: FEC block ID
|
||||||
|
* Byte 9: FEC symbol index
|
||||||
|
* Byte 10: Reserved
|
||||||
|
* Byte 11: CSRC count
|
||||||
|
*
|
||||||
|
* @param {number} seq Sequence number (u16)
|
||||||
|
* @returns {Uint8Array} 12-byte header
|
||||||
|
*/
|
||||||
|
_buildMediaHeader(seq) {
|
||||||
|
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
|
||||||
|
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
|
||||||
|
buf[0] = 0x00;
|
||||||
|
// Byte 1: fec_ratio_lo=0
|
||||||
|
buf[1] = 0x00;
|
||||||
|
// Bytes 2-3: sequence (BE u16)
|
||||||
|
buf[2] = (seq >> 8) & 0xFF;
|
||||||
|
buf[3] = seq & 0xFF;
|
||||||
|
// Bytes 4-7: timestamp (BE u32) — ms since session start
|
||||||
|
const ts = Date.now() - this._startTime;
|
||||||
|
buf[4] = (ts >> 24) & 0xFF;
|
||||||
|
buf[5] = (ts >> 16) & 0xFF;
|
||||||
|
buf[6] = (ts >> 8) & 0xFF;
|
||||||
|
buf[7] = ts & 0xFF;
|
||||||
|
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss,
|
||||||
|
elapsed,
|
||||||
|
encrypted: this.stats.encrypted,
|
||||||
|
decrypted: this.stats.decrypted,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this.datagramWriter = null;
|
||||||
|
this.datagramReader = null;
|
||||||
|
if (this.cryptoSession) {
|
||||||
|
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.cryptoSession = null;
|
||||||
|
}
|
||||||
|
if (this.fecEncoder) {
|
||||||
|
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecEncoder = null;
|
||||||
|
}
|
||||||
|
if (this.fecDecoder) {
|
||||||
|
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPFullClient = WZPFullClient;
|
||||||
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
|
||||||
|
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
//
|
||||||
|
// The WASM FEC module is loaded and exposed but not used on the wire yet,
|
||||||
|
// because WebSocket is TCP (no packet loss). FEC will activate when
|
||||||
|
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
|
||||||
|
// encode -> simulate loss -> decode in the browser.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||||
|
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
class WZPHybridClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
|
||||||
|
// WASM FEC instances (loaded in connect()).
|
||||||
|
this._wasmModule = null;
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
this._fecReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection and load the WASM FEC module.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
// Load WASM module in parallel with WebSocket connect.
|
||||||
|
const wasmPromise = this._loadWasm();
|
||||||
|
|
||||||
|
const wsPromise = new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const wasConnected = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (wasConnected) {
|
||||||
|
this._status('Disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for both WASM load and WS connect.
|
||||||
|
await Promise.all([wasmPromise, wsPromise]);
|
||||||
|
|
||||||
|
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
|
||||||
|
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
// Keep WASM module loaded (reusable).
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame over the WebSocket.
|
||||||
|
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
|
||||||
|
* When WebTransport is added, this will FEC-encode before sending.
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over WebSocket (TCP): send raw PCM, no FEC needed.
|
||||||
|
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
|
||||||
|
// and send the resulting FEC-protected packets.
|
||||||
|
this.ws.send(pcmBuffer);
|
||||||
|
this.sequence++;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test FEC encode -> simulate loss -> decode in the browser.
|
||||||
|
* Demonstrates that the WASM RaptorQ module works correctly.
|
||||||
|
*
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {number} [opts.blockSize=5] Source symbols per block
|
||||||
|
* @param {number} [opts.symbolSize=256] Padded symbol size
|
||||||
|
* @param {number} [opts.frameSize=100] Bytes per test frame
|
||||||
|
* @param {number} [opts.dropCount=2] Number of packets to drop
|
||||||
|
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
|
||||||
|
*/
|
||||||
|
testFec(opts) {
|
||||||
|
if (!this._fecReady) {
|
||||||
|
return { success: false, error: 'WASM FEC module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockSize = (opts && opts.blockSize) || 5;
|
||||||
|
const symbolSize = (opts && opts.symbolSize) || 256;
|
||||||
|
const frameSize = (opts && opts.frameSize) || 100;
|
||||||
|
const dropCount = (opts && opts.dropCount) || 2;
|
||||||
|
|
||||||
|
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
|
||||||
|
const packetSize = HEADER_SIZE + symbolSize;
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
// Create fresh encoder/decoder for the test.
|
||||||
|
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
||||||
|
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
||||||
|
|
||||||
|
// Generate test frames with known data.
|
||||||
|
const frames = [];
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
const frame = new Uint8Array(frameSize);
|
||||||
|
for (let j = 0; j < frameSize; j++) {
|
||||||
|
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
}
|
||||||
|
frames.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode: feed frames to encoder; last one triggers block output.
|
||||||
|
let wireData = null;
|
||||||
|
for (const frame of frames) {
|
||||||
|
const result = encoder.add_symbol(frame);
|
||||||
|
if (result) {
|
||||||
|
wireData = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wireData) {
|
||||||
|
// Flush if block didn't complete (shouldn't happen with exact blockSize).
|
||||||
|
wireData = encoder.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse wire packets.
|
||||||
|
const packets = [];
|
||||||
|
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
||||||
|
packets.push({
|
||||||
|
blockId: wireData[offset],
|
||||||
|
symbolIdx: wireData[offset + 1],
|
||||||
|
isRepair: wireData[offset + 2] !== 0,
|
||||||
|
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
||||||
|
const repairPackets = packets.filter(p => p.isRepair).length;
|
||||||
|
|
||||||
|
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
|
||||||
|
const dropped = [];
|
||||||
|
const surviving = [];
|
||||||
|
for (let i = 0; i < packets.length; i++) {
|
||||||
|
if (i < dropCount) {
|
||||||
|
dropped.push(i);
|
||||||
|
} else {
|
||||||
|
surviving.push(packets[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode from surviving packets.
|
||||||
|
let decoded = null;
|
||||||
|
for (const pkt of surviving) {
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||||
|
if (result) {
|
||||||
|
decoded = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
// Verify decoded data matches original frames.
|
||||||
|
let success = false;
|
||||||
|
if (decoded) {
|
||||||
|
const expected = new Uint8Array(blockSize * frameSize);
|
||||||
|
let off = 0;
|
||||||
|
for (const frame of frames) {
|
||||||
|
expected.set(frame, off);
|
||||||
|
off += frame.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
success = decoded.length === expected.length;
|
||||||
|
if (success) {
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
if (decoded[i] !== expected[i]) {
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free WASM objects.
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
sourcePackets,
|
||||||
|
repairPackets,
|
||||||
|
totalPackets: packets.length,
|
||||||
|
dropped: dropCount,
|
||||||
|
recovered: success,
|
||||||
|
decodedBytes: decoded ? decoded.length : 0,
|
||||||
|
expectedBytes: blockSize * frameSize,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async _loadWasm() {
|
||||||
|
try {
|
||||||
|
// Dynamic import of the wasm-pack generated JS glue.
|
||||||
|
this._wasmModule = await import(WZP_WASM_PATH);
|
||||||
|
// Initialize the WASM module (calls __wbg_init).
|
||||||
|
await this._wasmModule.default();
|
||||||
|
|
||||||
|
// Create FEC encoder/decoder instances.
|
||||||
|
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||||
|
this._fecReady = true;
|
||||||
|
|
||||||
|
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
|
||||||
|
this._fecReady = false;
|
||||||
|
// Non-fatal: client still works without FEC (like pure variant).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const pcm = new Int16Array(event.data);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
fecReady: this._fecReady,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPHybridClient = WZPHybridClient;
|
||||||
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// WarzonePhone — Pure JS client (Variant 1).
|
||||||
|
// WebSocket transport, raw PCM, no WASM, no FEC.
|
||||||
|
// Relies on wzp-core.js for UI and audio helpers.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class WZPPureClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection to the wzp-web bridge.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this._connected = true;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._status('Connected to room: ' + this.room);
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const wasConnected = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (wasConnected) {
|
||||||
|
this._status('Disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (err) => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame over the WebSocket.
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure JS variant: send raw PCM directly (no encryption, no header).
|
||||||
|
// The wzp-web bridge handles QUIC-side encryption.
|
||||||
|
this.ws.send(pcmBuffer);
|
||||||
|
this.sequence++;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const pcm = new Int16Array(event.data);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) {
|
||||||
|
this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
// Simple loss estimate: if we sent frames, the other side should
|
||||||
|
// receive roughly the same count. Since we only see our own recv,
|
||||||
|
// we report raw counts and let the UI decide.
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPPureClient = WZPPureClient;
|
||||||
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal file
592
crates/wzp-web/static/js/wzp-ws-fec.js
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
// WarzonePhone — WZP-WS-FEC client (Variant 5).
|
||||||
|
// WebSocket transport, WZP wire protocol, WASM RaptorQ FEC.
|
||||||
|
// Application-layer redundancy even over TCP.
|
||||||
|
// Sends MediaPacket-formatted frames with FEC encoding.
|
||||||
|
// Ready for direct relay WS support (no bridge translation needed).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||||
|
const WZP_WS_FEC_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||||
|
const WZP_WS_FEC_HEADER_SIZE = 12;
|
||||||
|
|
||||||
|
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||||
|
const WZP_WS_FEC_FEC_HEADER_SIZE = 3;
|
||||||
|
|
||||||
|
// FEC parameters.
|
||||||
|
// A 960-sample Int16 PCM frame = 1920 bytes. We use symbol_size = 2048
|
||||||
|
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
||||||
|
const WZP_WS_FEC_BLOCK_SIZE = 5;
|
||||||
|
const WZP_WS_FEC_SYMBOL_SIZE = 2048;
|
||||||
|
|
||||||
|
// Length prefix size within each FEC symbol.
|
||||||
|
const WZP_WS_FEC_LENGTH_PREFIX = 2;
|
||||||
|
|
||||||
|
class WZPWsFecClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback(Object) for UI stats
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.authToken = options.authToken || null;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
this._authenticated = false;
|
||||||
|
|
||||||
|
// WASM FEC instances (loaded in loadWasm() / connect()).
|
||||||
|
this._wasmModule = null;
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
this.wasmReady = false;
|
||||||
|
|
||||||
|
// Current FEC block counter for outgoing packets.
|
||||||
|
this._fecBlockId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the WASM FEC module.
|
||||||
|
* Called automatically by connect(), or can be called early.
|
||||||
|
*/
|
||||||
|
async loadWasm() {
|
||||||
|
if (this.wasmReady) return;
|
||||||
|
try {
|
||||||
|
this._wasmModule = await import(WZP_WS_FEC_WASM_PATH);
|
||||||
|
await this._wasmModule.default();
|
||||||
|
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
||||||
|
WZP_WS_FEC_BLOCK_SIZE,
|
||||||
|
WZP_WS_FEC_SYMBOL_SIZE
|
||||||
|
);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
||||||
|
WZP_WS_FEC_BLOCK_SIZE,
|
||||||
|
WZP_WS_FEC_SYMBOL_SIZE
|
||||||
|
);
|
||||||
|
this.wasmReady = true;
|
||||||
|
console.log('[wzp-ws-fec] WASM FEC module loaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wzp-ws-fec] WASM FEC module failed to load:', e);
|
||||||
|
this.wasmReady = false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 12-byte WZP MediaHeader.
|
||||||
|
*
|
||||||
|
* @param {number} seq Sequence number (u16)
|
||||||
|
* @param {number} timestampMs Milliseconds since session start
|
||||||
|
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||||
|
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||||
|
* @param {number} fecBlock FEC block ID (u8)
|
||||||
|
* @param {number} fecSymbol FEC symbol index (u8)
|
||||||
|
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||||
|
* @param {boolean} hasQuality Whether a quality report is attached
|
||||||
|
* @returns {Uint8Array} 12-byte header
|
||||||
|
*/
|
||||||
|
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||||
|
const buf = new ArrayBuffer(WZP_WS_FEC_HEADER_SIZE);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
|
||||||
|
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||||
|
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||||
|
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||||
|
| ((codecId & 0x0F) << 2) // CodecID
|
||||||
|
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||||
|
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||||
|
view.setUint8(0, byte0);
|
||||||
|
|
||||||
|
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||||
|
view.setUint8(1, byte1);
|
||||||
|
|
||||||
|
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||||
|
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||||
|
view.setUint8(8, fecBlock & 0xFF);
|
||||||
|
view.setUint8(9, fecSymbol & 0xFF);
|
||||||
|
view.setUint8(10, 0); // reserved
|
||||||
|
view.setUint8(11, 0); // csrc_count
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a 12-byte MediaHeader from received binary data.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} data At least 12 bytes
|
||||||
|
* @returns {Object|null} Parsed header fields, or null if too short
|
||||||
|
*/
|
||||||
|
_parseHeader(data) {
|
||||||
|
if (data.byteLength < WZP_WS_FEC_HEADER_SIZE) return null;
|
||||||
|
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||||
|
const byte0 = view.getUint8(0);
|
||||||
|
const byte1 = view.getUint8(1);
|
||||||
|
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||||
|
return {
|
||||||
|
version: (byte0 >> 7) & 1,
|
||||||
|
isRepair: !!((byte0 >> 6) & 1),
|
||||||
|
codecId: (byte0 >> 2) & 0x0F,
|
||||||
|
hasQuality: !!((byte0 >> 1) & 1),
|
||||||
|
fecRatio: fecRatioEncoded / 63.5,
|
||||||
|
seq: view.getUint16(2),
|
||||||
|
timestamp: view.getUint32(4),
|
||||||
|
fecBlock: view.getUint8(8),
|
||||||
|
fecSymbol: view.getUint8(9),
|
||||||
|
reserved: view.getUint8(10),
|
||||||
|
csrcCount: view.getUint8(11),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
||||||
|
* Symbol layout: [len_hi, len_lo, ...pcm_bytes..., ...zero_padding...]
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
||||||
|
* @returns {Uint8Array} Padded symbol of WZP_WS_FEC_SYMBOL_SIZE bytes
|
||||||
|
*/
|
||||||
|
_padToSymbol(pcmBytes) {
|
||||||
|
const symbol = new Uint8Array(WZP_WS_FEC_SYMBOL_SIZE);
|
||||||
|
const len = pcmBytes.length;
|
||||||
|
symbol[0] = (len >> 8) & 0xFF;
|
||||||
|
symbol[1] = len & 0xFF;
|
||||||
|
symbol.set(pcmBytes, WZP_WS_FEC_LENGTH_PREFIX);
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} symbol Symbol data (WZP_WS_FEC_SYMBOL_SIZE bytes)
|
||||||
|
* @returns {Uint8Array} Original PCM bytes
|
||||||
|
*/
|
||||||
|
_unpadSymbol(symbol) {
|
||||||
|
const len = (symbol[0] << 8) | symbol[1];
|
||||||
|
if (len > WZP_WS_FEC_SYMBOL_SIZE - WZP_WS_FEC_LENGTH_PREFIX) {
|
||||||
|
// Sanity check: if length is bogus, return empty.
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
return symbol.slice(WZP_WS_FEC_LENGTH_PREFIX, WZP_WS_FEC_LENGTH_PREFIX + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection and load the WASM FEC module.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
// Load WASM module in parallel with WebSocket connect.
|
||||||
|
const wasmPromise = this.loadWasm();
|
||||||
|
|
||||||
|
const wsPromise = new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting (WZP-WS-FEC) to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
// Send auth if token provided.
|
||||||
|
if (this.authToken) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._connected = true;
|
||||||
|
this._authenticated = !this.authToken;
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = Date.now();
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._fecBlockId = 0;
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
// Handle text messages (auth responses).
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'auth_ok') {
|
||||||
|
this._authenticated = true;
|
||||||
|
this._status('Authenticated (WZP-WS-FEC) to room: ' + this.room);
|
||||||
|
}
|
||||||
|
if (msg.type === 'auth_error') {
|
||||||
|
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore non-JSON text */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const was = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (was) this._status('Disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([wasmPromise, wsPromise]);
|
||||||
|
|
||||||
|
const fecStatus = this.wasmReady ? 'FEC ready' : 'FEC unavailable';
|
||||||
|
this._status('Connected (WZP-WS-FEC) to room: ' + this.room + ' (' + fecStatus + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
// Keep WASM module loaded (reusable), but reset encoder/decoder.
|
||||||
|
if (this.fecEncoder) {
|
||||||
|
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecEncoder = null;
|
||||||
|
}
|
||||||
|
if (this.fecDecoder) {
|
||||||
|
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame with FEC encoding over the WebSocket.
|
||||||
|
*
|
||||||
|
* Each PCM frame is padded to a FEC symbol (2048 bytes with length prefix)
|
||||||
|
* and fed to the FEC encoder. When a block of 5 symbols completes, the
|
||||||
|
* encoder outputs source + repair symbols. Each is sent as an individual
|
||||||
|
* WZP MediaPacket with the appropriate fecBlock, fecSymbol, and isRepair
|
||||||
|
* fields in the 12-byte header.
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (!this.wasmReady || !this.fecEncoder) return;
|
||||||
|
|
||||||
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
|
||||||
|
// Pad PCM frame to FEC symbol size with length prefix.
|
||||||
|
const symbol = this._padToSymbol(pcmBytes);
|
||||||
|
|
||||||
|
// Feed to FEC encoder. Returns wire data when block completes.
|
||||||
|
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
||||||
|
|
||||||
|
if (fecOutput) {
|
||||||
|
// Block completed — send all packets (source + repair).
|
||||||
|
const packetSize = WZP_WS_FEC_FEC_HEADER_SIZE + WZP_WS_FEC_SYMBOL_SIZE;
|
||||||
|
const timestampMs = Date.now() - this.startTimestamp;
|
||||||
|
|
||||||
|
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
||||||
|
const blockId = fecOutput[offset];
|
||||||
|
const symbolIdx = fecOutput[offset + 1];
|
||||||
|
const isRepair = fecOutput[offset + 2] !== 0;
|
||||||
|
const symbolData = fecOutput.slice(
|
||||||
|
offset + WZP_WS_FEC_FEC_HEADER_SIZE,
|
||||||
|
offset + packetSize
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build WZP MediaHeader for this FEC symbol.
|
||||||
|
// fecRatio ~0.5 for 50% repair overhead: encoded = round(0.5 * 63.5) = 32
|
||||||
|
const header = this._buildHeader(
|
||||||
|
this.seq,
|
||||||
|
timestampMs,
|
||||||
|
isRepair,
|
||||||
|
0, // codecId = RawPcm16
|
||||||
|
blockId,
|
||||||
|
symbolIdx,
|
||||||
|
0.5, // fecRatio
|
||||||
|
false // hasQuality
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wire frame: header(12) + symbol_data(2048)
|
||||||
|
const packet = new Uint8Array(WZP_WS_FEC_HEADER_SIZE + symbolData.length);
|
||||||
|
packet.set(header, 0);
|
||||||
|
packet.set(symbolData, WZP_WS_FEC_HEADER_SIZE);
|
||||||
|
|
||||||
|
this.ws.send(packet.buffer);
|
||||||
|
this.seq = (this.seq + 1) & 0xFFFF;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fecBlockId++;
|
||||||
|
}
|
||||||
|
// If block not yet complete, accumulate (no packets sent yet).
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test FEC encode -> simulate loss -> decode in the browser.
|
||||||
|
* Demonstrates that the WASM RaptorQ module works correctly
|
||||||
|
* with the WZP wire protocol symbol format.
|
||||||
|
*
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {number} [opts.blockSize=5] Source symbols per block
|
||||||
|
* @param {number} [opts.symbolSize=2048] Padded symbol size
|
||||||
|
* @param {number} [opts.frameSize=1920] PCM frame size in bytes
|
||||||
|
* @param {number} [opts.dropCount=2] Number of packets to drop (simulated 30%+ loss)
|
||||||
|
* @returns {Object} Test results
|
||||||
|
*/
|
||||||
|
testFec(opts) {
|
||||||
|
if (!this.wasmReady || !this._wasmModule) {
|
||||||
|
return { success: false, error: 'WASM FEC module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockSize = (opts && opts.blockSize) || 5;
|
||||||
|
const symbolSize = (opts && opts.symbolSize) || WZP_WS_FEC_SYMBOL_SIZE;
|
||||||
|
const frameSize = (opts && opts.frameSize) || 1920;
|
||||||
|
const dropCount = (opts && opts.dropCount) || 2;
|
||||||
|
|
||||||
|
const FEC_HDR = 3; // block_id + symbol_idx + is_repair
|
||||||
|
const packetSize = FEC_HDR + symbolSize;
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
// Create fresh encoder/decoder for the test.
|
||||||
|
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
||||||
|
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
||||||
|
|
||||||
|
// Generate test frames with known data, padded to symbol size with length prefix.
|
||||||
|
const originalFrames = [];
|
||||||
|
const paddedSymbols = [];
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
const frame = new Uint8Array(frameSize);
|
||||||
|
for (let j = 0; j < frameSize; j++) {
|
||||||
|
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
}
|
||||||
|
originalFrames.push(frame);
|
||||||
|
|
||||||
|
// Pad with length prefix (same as _padToSymbol).
|
||||||
|
const sym = new Uint8Array(symbolSize);
|
||||||
|
sym[0] = (frameSize >> 8) & 0xFF;
|
||||||
|
sym[1] = frameSize & 0xFF;
|
||||||
|
sym.set(frame, 2);
|
||||||
|
paddedSymbols.push(sym);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode: feed padded symbols to encoder.
|
||||||
|
let wireData = null;
|
||||||
|
for (const sym of paddedSymbols) {
|
||||||
|
const result = encoder.add_symbol(sym);
|
||||||
|
if (result) wireData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wireData) {
|
||||||
|
wireData = encoder.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse wire packets.
|
||||||
|
const packets = [];
|
||||||
|
if (wireData) {
|
||||||
|
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
||||||
|
packets.push({
|
||||||
|
blockId: wireData[offset],
|
||||||
|
symbolIdx: wireData[offset + 1],
|
||||||
|
isRepair: wireData[offset + 2] !== 0,
|
||||||
|
data: wireData.slice(offset + FEC_HDR, offset + packetSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
||||||
|
const repairPackets = packets.filter(p => p.isRepair).length;
|
||||||
|
|
||||||
|
// Simulate packet loss: drop `dropCount` source packets from the front.
|
||||||
|
const dropped = [];
|
||||||
|
const surviving = [];
|
||||||
|
for (let i = 0; i < packets.length; i++) {
|
||||||
|
if (i < dropCount) {
|
||||||
|
dropped.push(i);
|
||||||
|
} else {
|
||||||
|
surviving.push(packets[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode from surviving packets.
|
||||||
|
let decoded = null;
|
||||||
|
for (const pkt of surviving) {
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||||
|
if (result) {
|
||||||
|
decoded = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify decoded data: extract original frames from decoded symbols.
|
||||||
|
let success = false;
|
||||||
|
if (decoded) {
|
||||||
|
// decoded is the concatenated padded symbols. Extract original frames.
|
||||||
|
const recoveredFrames = [];
|
||||||
|
for (let i = 0; i < blockSize; i++) {
|
||||||
|
const symOffset = i * symbolSize;
|
||||||
|
if (symOffset + symbolSize <= decoded.length) {
|
||||||
|
const sym = decoded.slice(symOffset, symOffset + symbolSize);
|
||||||
|
const len = (sym[0] << 8) | sym[1];
|
||||||
|
recoveredFrames.push(sym.slice(2, 2 + len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success = recoveredFrames.length === blockSize;
|
||||||
|
if (success) {
|
||||||
|
for (let i = 0; i < blockSize && success; i++) {
|
||||||
|
if (recoveredFrames[i].length !== originalFrames[i].length) {
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (let j = 0; j < originalFrames[i].length; j++) {
|
||||||
|
if (recoveredFrames[i][j] !== originalFrames[i][j]) {
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free WASM objects.
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
sourcePackets,
|
||||||
|
repairPackets,
|
||||||
|
totalPackets: packets.length,
|
||||||
|
dropped: dropCount,
|
||||||
|
recovered: !!decoded,
|
||||||
|
symbolSize: symbolSize,
|
||||||
|
frameSize: frameSize,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const data = new Uint8Array(event.data);
|
||||||
|
if (data.length < WZP_WS_FEC_HEADER_SIZE) return;
|
||||||
|
|
||||||
|
const header = this._parseHeader(data);
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
this.stats.recv++;
|
||||||
|
|
||||||
|
if (!this.wasmReady || !this.fecDecoder) {
|
||||||
|
// No FEC decoder — cannot process FEC-encoded data.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract symbol data (everything after 12-byte MediaHeader).
|
||||||
|
const symbolData = data.slice(WZP_WS_FEC_HEADER_SIZE);
|
||||||
|
|
||||||
|
// Feed symbol to FEC decoder using header fields.
|
||||||
|
const decoded = this.fecDecoder.add_symbol(
|
||||||
|
header.fecBlock,
|
||||||
|
header.fecSymbol,
|
||||||
|
header.isRepair,
|
||||||
|
symbolData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decoded) {
|
||||||
|
this.stats.fecRecovered++;
|
||||||
|
|
||||||
|
// decoded is concatenated padded symbols.
|
||||||
|
// Each symbol is WZP_WS_FEC_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
||||||
|
for (let off = 0; off + WZP_WS_FEC_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FEC_SYMBOL_SIZE) {
|
||||||
|
const symbol = decoded.slice(off, off + WZP_WS_FEC_SYMBOL_SIZE);
|
||||||
|
const pcmBytes = this._unpadSymbol(symbol);
|
||||||
|
|
||||||
|
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
||||||
|
const pcm = new Int16Array(
|
||||||
|
pcmBytes.buffer,
|
||||||
|
pcmBytes.byteOffset,
|
||||||
|
pcmBytes.byteLength / 2
|
||||||
|
);
|
||||||
|
if (this.onAudio) this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
fecReady: this.wasmReady,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPWsFecClient = WZPWsFecClient;
|
||||||
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
// WarzonePhone — WZP-WS-Full client (Variant 6).
|
||||||
|
// WebSocket transport, WZP wire protocol, WASM FEC + ChaCha20-Poly1305 E2E.
|
||||||
|
// Full encryption — relay sees only ciphertext.
|
||||||
|
// Sends MediaPacket-formatted frames with FEC + encryption.
|
||||||
|
// Ready for direct relay WS support (no bridge translation needed).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||||
|
const WZP_WS_FULL_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
||||||
|
|
||||||
|
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||||
|
const WZP_WS_FULL_HEADER_SIZE = 12;
|
||||||
|
|
||||||
|
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||||
|
const WZP_WS_FULL_FEC_HEADER_SIZE = 3;
|
||||||
|
|
||||||
|
// FEC parameters.
|
||||||
|
// A 960-sample Int16 PCM frame = 1920 bytes. Symbol size = 2048
|
||||||
|
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
||||||
|
const WZP_WS_FULL_BLOCK_SIZE = 5;
|
||||||
|
const WZP_WS_FULL_SYMBOL_SIZE = 2048;
|
||||||
|
|
||||||
|
// Length prefix size within each FEC symbol.
|
||||||
|
const WZP_WS_FULL_LENGTH_PREFIX = 2;
|
||||||
|
|
||||||
|
// ChaCha20-Poly1305 tag size (16 bytes).
|
||||||
|
const WZP_WS_FULL_TAG_SIZE = 16;
|
||||||
|
|
||||||
|
// X25519 public key size (32 bytes).
|
||||||
|
const WZP_WS_FULL_PUBKEY_SIZE = 32;
|
||||||
|
|
||||||
|
class WZPWsFullClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback(Object) for UI stats
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.authToken = options.authToken || null;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
this._authenticated = false;
|
||||||
|
|
||||||
|
// WASM instances.
|
||||||
|
this._wasmModule = null;
|
||||||
|
this.fecEncoder = null;
|
||||||
|
this.fecDecoder = null;
|
||||||
|
this.cryptoSession = null;
|
||||||
|
this._keyExchange = null;
|
||||||
|
this.wasmReady = false;
|
||||||
|
|
||||||
|
// Key exchange state.
|
||||||
|
this._keyExchangeComplete = false;
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
|
||||||
|
// Current FEC block counter for outgoing packets.
|
||||||
|
this._fecBlockId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the WASM module (FEC + Crypto).
|
||||||
|
* Called automatically by connect(), or can be called early.
|
||||||
|
*/
|
||||||
|
async loadWasm() {
|
||||||
|
if (this.wasmReady) return;
|
||||||
|
try {
|
||||||
|
this._wasmModule = await import(WZP_WS_FULL_WASM_PATH);
|
||||||
|
await this._wasmModule.default();
|
||||||
|
this.wasmReady = true;
|
||||||
|
console.log('[wzp-ws-full] WASM module loaded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wzp-ws-full] WASM module failed to load:', e);
|
||||||
|
this.wasmReady = false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 12-byte WZP MediaHeader.
|
||||||
|
*
|
||||||
|
* @param {number} seq Sequence number (u16)
|
||||||
|
* @param {number} timestampMs Milliseconds since session start
|
||||||
|
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||||
|
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||||
|
* @param {number} fecBlock FEC block ID (u8)
|
||||||
|
* @param {number} fecSymbol FEC symbol index (u8)
|
||||||
|
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||||
|
* @param {boolean} hasQuality Whether a quality report is attached
|
||||||
|
* @returns {Uint8Array} 12-byte header
|
||||||
|
*/
|
||||||
|
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||||
|
const buf = new ArrayBuffer(WZP_WS_FULL_HEADER_SIZE);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
|
||||||
|
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||||
|
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||||
|
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||||
|
| ((codecId & 0x0F) << 2) // CodecID
|
||||||
|
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||||
|
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||||
|
view.setUint8(0, byte0);
|
||||||
|
|
||||||
|
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||||
|
view.setUint8(1, byte1);
|
||||||
|
|
||||||
|
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||||
|
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||||
|
view.setUint8(8, fecBlock & 0xFF);
|
||||||
|
view.setUint8(9, fecSymbol & 0xFF);
|
||||||
|
view.setUint8(10, 0); // reserved
|
||||||
|
view.setUint8(11, 0); // csrc_count
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a 12-byte MediaHeader from received binary data.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} data At least 12 bytes
|
||||||
|
* @returns {Object|null} Parsed header fields, or null if too short
|
||||||
|
*/
|
||||||
|
_parseHeader(data) {
|
||||||
|
if (data.byteLength < WZP_WS_FULL_HEADER_SIZE) return null;
|
||||||
|
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||||
|
const byte0 = view.getUint8(0);
|
||||||
|
const byte1 = view.getUint8(1);
|
||||||
|
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||||
|
return {
|
||||||
|
version: (byte0 >> 7) & 1,
|
||||||
|
isRepair: !!((byte0 >> 6) & 1),
|
||||||
|
codecId: (byte0 >> 2) & 0x0F,
|
||||||
|
hasQuality: !!((byte0 >> 1) & 1),
|
||||||
|
fecRatio: fecRatioEncoded / 63.5,
|
||||||
|
seq: view.getUint16(2),
|
||||||
|
timestamp: view.getUint32(4),
|
||||||
|
fecBlock: view.getUint8(8),
|
||||||
|
fecSymbol: view.getUint8(9),
|
||||||
|
reserved: view.getUint8(10),
|
||||||
|
csrcCount: view.getUint8(11),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
||||||
|
* @returns {Uint8Array} Padded symbol of WZP_WS_FULL_SYMBOL_SIZE bytes
|
||||||
|
*/
|
||||||
|
_padToSymbol(pcmBytes) {
|
||||||
|
const symbol = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
const len = pcmBytes.length;
|
||||||
|
symbol[0] = (len >> 8) & 0xFF;
|
||||||
|
symbol[1] = len & 0xFF;
|
||||||
|
symbol.set(pcmBytes, WZP_WS_FULL_LENGTH_PREFIX);
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} symbol Symbol data
|
||||||
|
* @returns {Uint8Array} Original PCM bytes
|
||||||
|
*/
|
||||||
|
_unpadSymbol(symbol) {
|
||||||
|
const len = (symbol[0] << 8) | symbol[1];
|
||||||
|
if (len > WZP_WS_FULL_SYMBOL_SIZE - WZP_WS_FULL_LENGTH_PREFIX) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
return symbol.slice(WZP_WS_FULL_LENGTH_PREFIX, WZP_WS_FULL_LENGTH_PREFIX + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection, load WASM, and perform key exchange.
|
||||||
|
*
|
||||||
|
* Key exchange protocol over WebSocket:
|
||||||
|
* 1. After WS open, send our 32-byte X25519 public key as first binary message.
|
||||||
|
* 2. First received binary message of exactly 32 bytes = peer's public key.
|
||||||
|
* 3. Derive shared secret, create WzpCryptoSession.
|
||||||
|
* 4. All subsequent binary messages are encrypted MediaPackets.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>} resolves when connected and key exchange completes
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
// Load WASM first (needed for key exchange).
|
||||||
|
await this.loadWasm();
|
||||||
|
|
||||||
|
// Prepare key exchange.
|
||||||
|
this._keyExchange = new this._wasmModule.WzpKeyExchange();
|
||||||
|
this._keyExchangeComplete = false;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting (WZP-WS-Full) to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = Date.now();
|
||||||
|
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._fecBlockId = 0;
|
||||||
|
|
||||||
|
// Send auth if token provided.
|
||||||
|
if (this.authToken) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||||
|
this._authenticated = false;
|
||||||
|
} else {
|
||||||
|
this._authenticated = true;
|
||||||
|
// No auth needed — proceed directly to key exchange.
|
||||||
|
this._status('Performing key exchange...');
|
||||||
|
const ourPub = this._keyExchange.public_key();
|
||||||
|
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store resolve/reject for key exchange completion.
|
||||||
|
this._keyExchangeResolve = resolve;
|
||||||
|
this._keyExchangeReject = reject;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
// Handle text messages (auth responses).
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'auth_ok') {
|
||||||
|
this._authenticated = true;
|
||||||
|
this._status('Authenticated, performing key exchange...');
|
||||||
|
// Auth succeeded — now send public key for key exchange.
|
||||||
|
const ourPub = this._keyExchange.public_key();
|
||||||
|
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||||
|
}
|
||||||
|
if (msg.type === 'auth_error') {
|
||||||
|
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||||
|
if (this._keyExchangeReject) {
|
||||||
|
this._keyExchangeReject(new Error('Auth failed: ' + (msg.reason || 'unknown')));
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
}
|
||||||
|
this._cleanup();
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore non-JSON text */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._keyExchangeComplete) {
|
||||||
|
this._handleKeyExchange(event);
|
||||||
|
} else {
|
||||||
|
this._handleMessage(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const was = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (was) {
|
||||||
|
this._status('Disconnected');
|
||||||
|
} else if (this._keyExchangeReject) {
|
||||||
|
this._keyExchangeReject(new Error('Connection closed during key exchange'));
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
if (this._keyExchangeReject) {
|
||||||
|
this._keyExchangeReject(new Error('WebSocket connection failed'));
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
} else {
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the key exchange: first binary message of 32 bytes = peer's public key.
|
||||||
|
*/
|
||||||
|
_handleKeyExchange(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const data = new Uint8Array(event.data);
|
||||||
|
|
||||||
|
if (data.length === WZP_WS_FULL_PUBKEY_SIZE) {
|
||||||
|
// Received peer's public key — derive shared secret.
|
||||||
|
try {
|
||||||
|
const peerPub = data;
|
||||||
|
const secret = this._keyExchange.derive_shared_secret(peerPub);
|
||||||
|
this.cryptoSession = new this._wasmModule.WzpCryptoSession(secret);
|
||||||
|
|
||||||
|
// Free key exchange object (no longer needed).
|
||||||
|
this._keyExchange.free();
|
||||||
|
this._keyExchange = null;
|
||||||
|
|
||||||
|
// Initialize FEC encoder/decoder.
|
||||||
|
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
||||||
|
WZP_WS_FULL_BLOCK_SIZE,
|
||||||
|
WZP_WS_FULL_SYMBOL_SIZE
|
||||||
|
);
|
||||||
|
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
||||||
|
WZP_WS_FULL_BLOCK_SIZE,
|
||||||
|
WZP_WS_FULL_SYMBOL_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
this._keyExchangeComplete = true;
|
||||||
|
this._connected = true;
|
||||||
|
this._startStatsTimer();
|
||||||
|
this._status('Connected (WZP-WS-Full) to room: ' + this.room + ' (encrypted, FEC active)');
|
||||||
|
|
||||||
|
if (this._keyExchangeResolve) {
|
||||||
|
this._keyExchangeResolve();
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wzp-ws-full] Key exchange failed:', e);
|
||||||
|
if (this._keyExchangeReject) {
|
||||||
|
this._keyExchangeReject(new Error('Key exchange failed: ' + e.message));
|
||||||
|
this._keyExchangeResolve = null;
|
||||||
|
this._keyExchangeReject = null;
|
||||||
|
}
|
||||||
|
this._cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore non-32-byte messages during key exchange.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up all resources.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.cryptoSession) {
|
||||||
|
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.cryptoSession = null;
|
||||||
|
}
|
||||||
|
if (this.fecEncoder) {
|
||||||
|
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecEncoder = null;
|
||||||
|
}
|
||||||
|
if (this.fecDecoder) {
|
||||||
|
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
if (this._keyExchange) {
|
||||||
|
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||||
|
this._keyExchange = null;
|
||||||
|
}
|
||||||
|
this._keyExchangeComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame with FEC encoding + encryption over the WebSocket.
|
||||||
|
*
|
||||||
|
* Pipeline: PCM -> pad to FEC symbol -> FEC encode -> encrypt -> WS send.
|
||||||
|
*
|
||||||
|
* Each FEC symbol is encrypted individually with ChaCha20-Poly1305. The
|
||||||
|
* 12-byte MediaHeader is used as AAD (authenticated but not encrypted),
|
||||||
|
* so the relay can inspect routing fields without decrypting the payload.
|
||||||
|
*
|
||||||
|
* Wire format per packet:
|
||||||
|
* header(12) + ciphertext(symbol_size) + tag(16)
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (!this.cryptoSession || !this.fecEncoder) return;
|
||||||
|
|
||||||
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
|
||||||
|
// Pad PCM frame to FEC symbol size with length prefix.
|
||||||
|
const symbol = this._padToSymbol(pcmBytes);
|
||||||
|
|
||||||
|
// Feed to FEC encoder. Returns wire data when block completes.
|
||||||
|
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
||||||
|
|
||||||
|
if (fecOutput) {
|
||||||
|
// Block completed — encrypt and send all packets (source + repair).
|
||||||
|
const fecPacketSize = WZP_WS_FULL_FEC_HEADER_SIZE + WZP_WS_FULL_SYMBOL_SIZE;
|
||||||
|
const timestampMs = Date.now() - this.startTimestamp;
|
||||||
|
|
||||||
|
for (let offset = 0; offset + fecPacketSize <= fecOutput.length; offset += fecPacketSize) {
|
||||||
|
const blockId = fecOutput[offset];
|
||||||
|
const symbolIdx = fecOutput[offset + 1];
|
||||||
|
const isRepair = fecOutput[offset + 2] !== 0;
|
||||||
|
const symbolData = fecOutput.slice(
|
||||||
|
offset + WZP_WS_FULL_FEC_HEADER_SIZE,
|
||||||
|
offset + fecPacketSize
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build WZP MediaHeader (used as AAD for encryption).
|
||||||
|
// fecRatio ~0.5 for 50% repair overhead.
|
||||||
|
const header = this._buildHeader(
|
||||||
|
this.seq,
|
||||||
|
timestampMs,
|
||||||
|
isRepair,
|
||||||
|
0, // codecId = RawPcm16
|
||||||
|
blockId,
|
||||||
|
symbolIdx,
|
||||||
|
0.5, // fecRatio
|
||||||
|
false // hasQuality
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypt: header as AAD, FEC symbol data as plaintext.
|
||||||
|
// Returns ciphertext + tag (symbol_size + 16 bytes).
|
||||||
|
const ciphertext = this.cryptoSession.encrypt(header, symbolData);
|
||||||
|
this.stats.encrypted++;
|
||||||
|
|
||||||
|
// Wire frame: header(12) + ciphertext_with_tag
|
||||||
|
const packet = new Uint8Array(WZP_WS_FULL_HEADER_SIZE + ciphertext.length);
|
||||||
|
packet.set(header, 0);
|
||||||
|
packet.set(ciphertext, WZP_WS_FULL_HEADER_SIZE);
|
||||||
|
|
||||||
|
this.ws.send(packet.buffer);
|
||||||
|
this.seq = (this.seq + 1) & 0xFFFF;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fecBlockId++;
|
||||||
|
}
|
||||||
|
// If block not yet complete, accumulate (no packets sent yet).
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||||
|
* Simulates: key exchange -> encrypt -> FEC encode -> simulate loss ->
|
||||||
|
* FEC decode -> decrypt -> verify.
|
||||||
|
*
|
||||||
|
* @returns {Object} Test results
|
||||||
|
*/
|
||||||
|
testCryptoFec() {
|
||||||
|
if (!this.wasmReady || !this._wasmModule) {
|
||||||
|
return { success: false, error: 'WASM module not loaded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const wasm = this._wasmModule;
|
||||||
|
|
||||||
|
// --- Key exchange ---
|
||||||
|
const alice = new wasm.WzpKeyExchange();
|
||||||
|
const bob = new wasm.WzpKeyExchange();
|
||||||
|
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
||||||
|
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
||||||
|
|
||||||
|
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||||
|
if (secretsMatch) {
|
||||||
|
for (let i = 0; i < aliceSecret.length; i++) {
|
||||||
|
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Crypto sessions ---
|
||||||
|
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||||
|
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||||
|
|
||||||
|
// --- Encrypt + FEC encode ---
|
||||||
|
const encoder = new wasm.WzpFecEncoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
const decoder = new wasm.WzpFecDecoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
|
||||||
|
// Generate test PCM frames (known data).
|
||||||
|
const originalFrames = [];
|
||||||
|
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE; i++) {
|
||||||
|
const frame = new Uint8Array(1920);
|
||||||
|
for (let j = 0; j < 1920; j++) {
|
||||||
|
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||||
|
}
|
||||||
|
originalFrames.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad and FEC-encode.
|
||||||
|
const paddedSymbols = [];
|
||||||
|
let wireData = null;
|
||||||
|
for (const frame of originalFrames) {
|
||||||
|
const sym = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
sym[0] = (frame.length >> 8) & 0xFF;
|
||||||
|
sym[1] = frame.length & 0xFF;
|
||||||
|
sym.set(frame, 2);
|
||||||
|
paddedSymbols.push(sym);
|
||||||
|
|
||||||
|
const result = encoder.add_symbol(sym);
|
||||||
|
if (result) wireData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wireData) wireData = encoder.flush();
|
||||||
|
|
||||||
|
// Parse FEC packets and encrypt each one.
|
||||||
|
const FEC_HDR = WZP_WS_FULL_FEC_HEADER_SIZE;
|
||||||
|
const fecPacketSize = FEC_HDR + WZP_WS_FULL_SYMBOL_SIZE;
|
||||||
|
const encryptedPackets = [];
|
||||||
|
|
||||||
|
if (wireData) {
|
||||||
|
for (let offset = 0; offset + fecPacketSize <= wireData.length; offset += fecPacketSize) {
|
||||||
|
const blockId = wireData[offset];
|
||||||
|
const symbolIdx = wireData[offset + 1];
|
||||||
|
const isRepair = wireData[offset + 2] !== 0;
|
||||||
|
const symbolData = wireData.slice(offset + FEC_HDR, offset + fecPacketSize);
|
||||||
|
|
||||||
|
// Build header for AAD (match wire protocol bit layout).
|
||||||
|
const header = new Uint8Array(WZP_WS_FULL_HEADER_SIZE);
|
||||||
|
const fecRatioEncoded = Math.min(127, Math.round(0.5 * 63.5)); // 50% FEC
|
||||||
|
header[0] = ((isRepair ? 1 : 0) << 6)
|
||||||
|
| ((0 & 0x0F) << 2) // codecId=0
|
||||||
|
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||||
|
header[1] = (fecRatioEncoded & 0x3F) << 2; // FecRatioLo
|
||||||
|
header[8] = blockId;
|
||||||
|
header[9] = symbolIdx;
|
||||||
|
|
||||||
|
// Encrypt with Alice's session.
|
||||||
|
const ciphertext = aliceSession.encrypt(header, symbolData);
|
||||||
|
|
||||||
|
encryptedPackets.push({
|
||||||
|
blockId, symbolIdx, isRepair, header, ciphertext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePackets = encryptedPackets.filter(p => !p.isRepair).length;
|
||||||
|
const repairPackets = encryptedPackets.filter(p => p.isRepair).length;
|
||||||
|
|
||||||
|
// --- Simulate 30% loss (drop 2 of ~7 packets) ---
|
||||||
|
const dropIndices = new Set([1, 3]);
|
||||||
|
const surviving = encryptedPackets.filter((_, i) => !dropIndices.has(i));
|
||||||
|
|
||||||
|
// --- Decrypt + FEC decode on Bob's side ---
|
||||||
|
let fecDecoded = null;
|
||||||
|
let decryptOk = true;
|
||||||
|
|
||||||
|
for (const pkt of surviving) {
|
||||||
|
let symbolData;
|
||||||
|
try {
|
||||||
|
symbolData = bobSession.decrypt(pkt.header, pkt.ciphertext);
|
||||||
|
} catch (e) {
|
||||||
|
decryptOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, symbolData);
|
||||||
|
if (result) {
|
||||||
|
fecDecoded = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Verify recovered frames ---
|
||||||
|
let fecOk = false;
|
||||||
|
if (fecDecoded) {
|
||||||
|
fecOk = true;
|
||||||
|
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE && fecOk; i++) {
|
||||||
|
const symOffset = i * WZP_WS_FULL_SYMBOL_SIZE;
|
||||||
|
if (symOffset + WZP_WS_FULL_SYMBOL_SIZE > fecDecoded.length) {
|
||||||
|
fecOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const sym = fecDecoded.slice(symOffset, symOffset + WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
const len = (sym[0] << 8) | sym[1];
|
||||||
|
const recovered = sym.slice(2, 2 + len);
|
||||||
|
|
||||||
|
if (recovered.length !== originalFrames[i].length) {
|
||||||
|
fecOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (let j = 0; j < recovered.length; j++) {
|
||||||
|
if (recovered[j] !== originalFrames[i][j]) {
|
||||||
|
fecOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup WASM objects.
|
||||||
|
alice.free();
|
||||||
|
bob.free();
|
||||||
|
aliceSession.free();
|
||||||
|
bobSession.free();
|
||||||
|
encoder.free();
|
||||||
|
decoder.free();
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: secretsMatch && decryptOk && fecOk,
|
||||||
|
secretsMatch,
|
||||||
|
decryptOk,
|
||||||
|
fecOk,
|
||||||
|
sourcePackets,
|
||||||
|
repairPackets,
|
||||||
|
totalPackets: encryptedPackets.length,
|
||||||
|
dropped: dropIndices.size,
|
||||||
|
surviving: surviving.length,
|
||||||
|
elapsed: elapsed.toFixed(2) + 'ms',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const data = new Uint8Array(event.data);
|
||||||
|
if (data.length < WZP_WS_FULL_HEADER_SIZE) return;
|
||||||
|
|
||||||
|
const header = this._parseHeader(data);
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
this.stats.recv++;
|
||||||
|
|
||||||
|
if (!this.cryptoSession || !this.fecDecoder) return;
|
||||||
|
|
||||||
|
// Extract header bytes (AAD) and ciphertext.
|
||||||
|
const headerBytes = data.slice(0, WZP_WS_FULL_HEADER_SIZE);
|
||||||
|
const ciphertext = data.slice(WZP_WS_FULL_HEADER_SIZE);
|
||||||
|
|
||||||
|
// Decrypt.
|
||||||
|
let symbolData;
|
||||||
|
try {
|
||||||
|
symbolData = this.cryptoSession.decrypt(headerBytes, ciphertext);
|
||||||
|
this.stats.decrypted++;
|
||||||
|
} catch (e) {
|
||||||
|
// Decryption failure — corrupted or replayed packet.
|
||||||
|
console.warn('[wzp-ws-full] decrypt failed:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed decrypted symbol to FEC decoder.
|
||||||
|
const decoded = this.fecDecoder.add_symbol(
|
||||||
|
header.fecBlock,
|
||||||
|
header.fecSymbol,
|
||||||
|
header.isRepair,
|
||||||
|
symbolData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decoded) {
|
||||||
|
this.stats.fecRecovered++;
|
||||||
|
|
||||||
|
// decoded is concatenated padded symbols.
|
||||||
|
// Each symbol is WZP_WS_FULL_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
||||||
|
for (let off = 0; off + WZP_WS_FULL_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FULL_SYMBOL_SIZE) {
|
||||||
|
const symbol = decoded.slice(off, off + WZP_WS_FULL_SYMBOL_SIZE);
|
||||||
|
const pcmBytes = this._unpadSymbol(symbol);
|
||||||
|
|
||||||
|
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
||||||
|
const pcm = new Int16Array(
|
||||||
|
pcmBytes.buffer,
|
||||||
|
pcmBytes.byteOffset,
|
||||||
|
pcmBytes.byteLength / 2
|
||||||
|
);
|
||||||
|
if (this.onAudio) this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
encrypted: this.stats.encrypted,
|
||||||
|
decrypted: this.stats.decrypted,
|
||||||
|
fecRecovered: this.stats.fecRecovered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._keyExchangeComplete = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
if (this.cryptoSession) {
|
||||||
|
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.cryptoSession = null;
|
||||||
|
}
|
||||||
|
if (this.fecEncoder) {
|
||||||
|
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecEncoder = null;
|
||||||
|
}
|
||||||
|
if (this.fecDecoder) {
|
||||||
|
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||||
|
this.fecDecoder = null;
|
||||||
|
}
|
||||||
|
if (this._keyExchange) {
|
||||||
|
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||||
|
this._keyExchange = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPWsFullClient = WZPWsFullClient;
|
||||||
289
crates/wzp-web/static/js/wzp-ws.js
Normal file
289
crates/wzp-web/static/js/wzp-ws.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// WarzonePhone — WZP-WS client (Variant 4).
|
||||||
|
// WebSocket transport, WZP wire protocol, no WASM.
|
||||||
|
// Sends MediaPacket-formatted frames instead of raw PCM.
|
||||||
|
// Ready for direct relay WS support (no bridge translation needed).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||||
|
const WZP_WS_HEADER_SIZE = 12;
|
||||||
|
|
||||||
|
class WZPWsClient {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||||
|
* @param {string} options.room Room name
|
||||||
|
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||||
|
* @param {Function} options.onStatus callback(string) for UI status
|
||||||
|
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.wsUrl = options.wsUrl;
|
||||||
|
this.room = options.room;
|
||||||
|
this.authToken = options.authToken || null;
|
||||||
|
this.onAudio = options.onAudio || null;
|
||||||
|
this.onStatus = options.onStatus || null;
|
||||||
|
this.onStats = options.onStats || null;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = 0;
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = 0;
|
||||||
|
this._statsInterval = null;
|
||||||
|
this._connected = false;
|
||||||
|
this._authenticated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 12-byte WZP MediaHeader.
|
||||||
|
*
|
||||||
|
* Wire layout (from wzp-proto::packet::MediaHeader):
|
||||||
|
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
||||||
|
* Byte 1: FecRatioLo(6)|Reserved(2)
|
||||||
|
* Bytes 2-3: Sequence number (BE u16)
|
||||||
|
* Bytes 4-7: Timestamp ms (BE u32)
|
||||||
|
* Byte 8: FEC block ID
|
||||||
|
* Byte 9: FEC symbol index
|
||||||
|
* Byte 10: Reserved
|
||||||
|
* Byte 11: CSRC count
|
||||||
|
*
|
||||||
|
* @param {number} seq Sequence number (u16)
|
||||||
|
* @param {number} timestampMs Milliseconds since session start
|
||||||
|
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||||
|
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||||
|
* @param {number} fecBlock FEC block ID (u8)
|
||||||
|
* @param {number} fecSymbol FEC symbol index (u8)
|
||||||
|
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||||
|
* @param {boolean} hasQuality Whether a quality report is attached
|
||||||
|
* @returns {Uint8Array} 12-byte header
|
||||||
|
*/
|
||||||
|
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||||
|
const buf = new ArrayBuffer(WZP_WS_HEADER_SIZE);
|
||||||
|
const view = new DataView(buf);
|
||||||
|
|
||||||
|
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||||
|
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||||
|
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||||
|
| ((codecId & 0x0F) << 2) // CodecID
|
||||||
|
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||||
|
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||||
|
view.setUint8(0, byte0);
|
||||||
|
|
||||||
|
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||||
|
view.setUint8(1, byte1);
|
||||||
|
|
||||||
|
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||||
|
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||||
|
view.setUint8(8, fecBlock & 0xFF);
|
||||||
|
view.setUint8(9, fecSymbol & 0xFF);
|
||||||
|
view.setUint8(10, 0); // reserved
|
||||||
|
view.setUint8(11, 0); // csrc_count
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a 12-byte MediaHeader from received binary data.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} data At least 12 bytes
|
||||||
|
* @returns {Object|null} Parsed header fields, or null if too short
|
||||||
|
*/
|
||||||
|
_parseHeader(data) {
|
||||||
|
if (data.byteLength < WZP_WS_HEADER_SIZE) return null;
|
||||||
|
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||||
|
const byte0 = view.getUint8(0);
|
||||||
|
const byte1 = view.getUint8(1);
|
||||||
|
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||||
|
return {
|
||||||
|
version: (byte0 >> 7) & 1,
|
||||||
|
isRepair: !!((byte0 >> 6) & 1),
|
||||||
|
codecId: (byte0 >> 2) & 0x0F,
|
||||||
|
hasQuality: !!((byte0 >> 1) & 1),
|
||||||
|
fecRatio: fecRatioEncoded / 63.5,
|
||||||
|
seq: view.getUint16(2),
|
||||||
|
timestamp: view.getUint32(4),
|
||||||
|
fecBlock: view.getUint8(8),
|
||||||
|
fecSymbol: view.getUint8(9),
|
||||||
|
reserved: view.getUint8(10),
|
||||||
|
csrcCount: view.getUint8(11),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open WebSocket connection to the wzp-web bridge.
|
||||||
|
* @returns {Promise<void>} resolves when connected
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this._connected) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._status('Connecting (WZP-WS) to room: ' + this.room + '...');
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
this.ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
// Send auth if token provided.
|
||||||
|
if (this.authToken) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._connected = true;
|
||||||
|
this._authenticated = !this.authToken; // authenticated immediately if no token needed
|
||||||
|
this.seq = 0;
|
||||||
|
this.startTimestamp = Date.now();
|
||||||
|
this.stats = { sent: 0, recv: 0 };
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._status('Connected (WZP-WS) to room: ' + this.room);
|
||||||
|
this._startStatsTimer();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
// Handle text messages (auth responses).
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'auth_ok') {
|
||||||
|
this._authenticated = true;
|
||||||
|
this._status('Authenticated (WZP-WS) to room: ' + this.room);
|
||||||
|
}
|
||||||
|
if (msg.type === 'auth_error') {
|
||||||
|
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore non-JSON text */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._handleMessage(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
const was = this._connected;
|
||||||
|
this._cleanup();
|
||||||
|
if (was) this._status('Disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._cleanup();
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
} else {
|
||||||
|
this._status('Connection error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close WebSocket and clean up.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
this._connected = false;
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PCM audio frame wrapped in a WZP MediaPacket over the WebSocket.
|
||||||
|
*
|
||||||
|
* Wire format: 12-byte MediaHeader + raw PCM payload.
|
||||||
|
* The relay can parse this natively without bridge translation.
|
||||||
|
*
|
||||||
|
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||||
|
*/
|
||||||
|
async sendAudio(pcmBuffer) {
|
||||||
|
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const header = this._buildHeader(
|
||||||
|
this.seq,
|
||||||
|
Date.now() - this.startTimestamp,
|
||||||
|
false, 0, 0, 0, 0, false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine header + payload into single binary frame.
|
||||||
|
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||||
|
const packet = new Uint8Array(WZP_WS_HEADER_SIZE + pcmBytes.length);
|
||||||
|
packet.set(header, 0);
|
||||||
|
packet.set(pcmBytes, WZP_WS_HEADER_SIZE);
|
||||||
|
|
||||||
|
this.ws.send(packet.buffer);
|
||||||
|
this.seq = (this.seq + 1) & 0xFFFF;
|
||||||
|
this.stats.sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_handleMessage(event) {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const data = new Uint8Array(event.data);
|
||||||
|
if (data.length < WZP_WS_HEADER_SIZE) return; // too small for header
|
||||||
|
|
||||||
|
const header = this._parseHeader(data);
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
// Extract payload (everything after 12-byte header).
|
||||||
|
// Payload is raw PCM Int16 samples.
|
||||||
|
const payloadBytes = data.slice(WZP_WS_HEADER_SIZE);
|
||||||
|
const pcm = new Int16Array(
|
||||||
|
payloadBytes.buffer,
|
||||||
|
payloadBytes.byteOffset,
|
||||||
|
payloadBytes.byteLength / 2
|
||||||
|
);
|
||||||
|
this.stats.recv++;
|
||||||
|
if (this.onAudio) this.onAudio(pcm);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStatsTimer() {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this._statsInterval = setInterval(() => {
|
||||||
|
if (!this._connected) {
|
||||||
|
this._stopStatsTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||||
|
const loss = this.stats.sent > 0
|
||||||
|
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||||
|
: 0;
|
||||||
|
if (this.onStats) {
|
||||||
|
this.onStats({
|
||||||
|
sent: this.stats.sent,
|
||||||
|
recv: this.stats.recv,
|
||||||
|
loss: loss,
|
||||||
|
elapsed: elapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopStatsTimer() {
|
||||||
|
if (this._statsInterval) {
|
||||||
|
clearInterval(this._statsInterval);
|
||||||
|
this._statsInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status(msg) {
|
||||||
|
if (this.onStatus) this.onStatus(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this._connected = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.WZPWsClient = WZPWsClient;
|
||||||
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package.json
|
||||||
|
*.d.ts
|
||||||
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
/* @ts-self-types="./wzp_wasm.d.ts" */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
*
|
||||||
|
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
* and key setup are identical so WASM and native peers interoperate.
|
||||||
|
*/
|
||||||
|
export class WzpCryptoSession {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpCryptoSessionFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpcryptosession_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Decrypt a media payload with AAD.
|
||||||
|
*
|
||||||
|
* Returns plaintext on success, or throws on auth failure.
|
||||||
|
* @param {Uint8Array} header_aad
|
||||||
|
* @param {Uint8Array} ciphertext
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
decrypt(header_aad, ciphertext) {
|
||||||
|
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_decrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v3;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
*
|
||||||
|
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
* @param {Uint8Array} header_aad
|
||||||
|
* @param {Uint8Array} plaintext
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
encrypt(header_aad, plaintext) {
|
||||||
|
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_encrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v3;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
* @param {Uint8Array} shared_secret
|
||||||
|
*/
|
||||||
|
constructor(shared_secret) {
|
||||||
|
const ptr0 = passArray8ToWasm0(shared_secret, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpcryptosession_new(ptr0, len0);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
this.__wbg_ptr = ret[0] >>> 0;
|
||||||
|
WzpCryptoSessionFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
recv_seq() {
|
||||||
|
const ret = wasm.wzpcryptosession_recv_seq(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Current send sequence number (for diagnostics / UI stats).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
send_seq() {
|
||||||
|
const ret = wasm.wzpcryptosession_send_seq(this.__wbg_ptr);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpCryptoSession.prototype[Symbol.dispose] = WzpCryptoSession.prototype.free;
|
||||||
|
|
||||||
|
export class WzpFecDecoder {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpFecDecoderFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpfecdecoder_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Feed a received symbol.
|
||||||
|
*
|
||||||
|
* Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
* enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
* @param {number} block_id
|
||||||
|
* @param {number} symbol_idx
|
||||||
|
* @param {boolean} _is_repair
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @returns {Uint8Array | undefined}
|
||||||
|
*/
|
||||||
|
add_symbol(block_id, symbol_idx, _is_repair, data) {
|
||||||
|
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpfecdecoder_add_symbol(this.__wbg_ptr, block_id, symbol_idx, _is_repair, ptr0, len0);
|
||||||
|
let v2;
|
||||||
|
if (ret[0] !== 0) {
|
||||||
|
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
}
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new FEC decoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — expected number of source symbols per block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
* @param {number} block_size
|
||||||
|
* @param {number} symbol_size
|
||||||
|
*/
|
||||||
|
constructor(block_size, symbol_size) {
|
||||||
|
const ret = wasm.wzpfecdecoder_new(block_size, symbol_size);
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpFecDecoderFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpFecDecoder.prototype[Symbol.dispose] = WzpFecDecoder.prototype.free;
|
||||||
|
|
||||||
|
export class WzpFecEncoder {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpFecEncoderFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpfecencoder_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add a source symbol (audio frame).
|
||||||
|
*
|
||||||
|
* Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
* or `undefined` if the block is still accumulating.
|
||||||
|
*
|
||||||
|
* Each returned packet carries the 3-byte header:
|
||||||
|
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @returns {Uint8Array | undefined}
|
||||||
|
*/
|
||||||
|
add_symbol(data) {
|
||||||
|
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpfecencoder_add_symbol(this.__wbg_ptr, ptr0, len0);
|
||||||
|
let v2;
|
||||||
|
if (ret[0] !== 0) {
|
||||||
|
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
}
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Force-flush the current (possibly partial) block.
|
||||||
|
*
|
||||||
|
* Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
* symbols have been accumulated.
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
flush() {
|
||||||
|
const ret = wasm.wzpfecencoder_flush(this.__wbg_ptr);
|
||||||
|
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v1;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new FEC encoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
* @param {number} block_size
|
||||||
|
* @param {number} symbol_size
|
||||||
|
*/
|
||||||
|
constructor(block_size, symbol_size) {
|
||||||
|
const ret = wasm.wzpfecencoder_new(block_size, symbol_size);
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpFecEncoderFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpFecEncoder.prototype[Symbol.dispose] = WzpFecEncoder.prototype.free;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
*
|
||||||
|
* Usage from JS:
|
||||||
|
* ```js
|
||||||
|
* const kx = new WzpKeyExchange();
|
||||||
|
* const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
* // ... send ourPub to peer, receive peerPub ...
|
||||||
|
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
* const session = new WzpCryptoSession(secret);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WzpKeyExchange {
|
||||||
|
__destroy_into_raw() {
|
||||||
|
const ptr = this.__wbg_ptr;
|
||||||
|
this.__wbg_ptr = 0;
|
||||||
|
WzpKeyExchangeFinalization.unregister(this);
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
free() {
|
||||||
|
const ptr = this.__destroy_into_raw();
|
||||||
|
wasm.__wbg_wzpkeyexchange_free(ptr, 0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte session key from the peer's public key.
|
||||||
|
*
|
||||||
|
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
* @param {Uint8Array} peer_public
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
derive_shared_secret(peer_public) {
|
||||||
|
const ptr0 = passArray8ToWasm0(peer_public, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.wzpkeyexchange_derive_shared_secret(this.__wbg_ptr, ptr0, len0);
|
||||||
|
if (ret[3]) {
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Generate a new random X25519 keypair.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
const ret = wasm.wzpkeyexchange_new();
|
||||||
|
this.__wbg_ptr = ret >>> 0;
|
||||||
|
WzpKeyExchangeFinalization.register(this, this.__wbg_ptr, this);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Our public key (32 bytes).
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
public_key() {
|
||||||
|
const ret = wasm.wzpkeyexchange_public_key(this.__wbg_ptr);
|
||||||
|
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||||
|
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||||
|
return v1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Symbol.dispose) WzpKeyExchange.prototype[Symbol.dispose] = WzpKeyExchange.prototype.free;
|
||||||
|
|
||||||
|
function __wbg_get_imports() {
|
||||||
|
const import0 = {
|
||||||
|
__proto__: null,
|
||||||
|
__wbg___wbindgen_is_function_3c846841762788c1: function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'function';
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_object_781bc9f159099513: function(arg0) {
|
||||||
|
const val = arg0;
|
||||||
|
const ret = typeof(val) === 'object' && val !== null;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_string_7ef6b97b02428fae: function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'string';
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) {
|
||||||
|
const ret = arg0 === undefined;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
|
||||||
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||||
|
},
|
||||||
|
__wbg_call_2d781c1f4d5c0ef8: function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.call(arg1, arg2);
|
||||||
|
return ret;
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
||||||
|
const ret = arg0.crypto;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.getRandomValues(arg1);
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_length_ea16607d7b61445b: function(arg0) {
|
||||||
|
const ret = arg0.length;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
||||||
|
const ret = arg0.msCrypto;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_new_with_length_825018a1616e9e55: function(arg0) {
|
||||||
|
const ret = new Uint8Array(arg0 >>> 0);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_node_84ea875411254db1: function(arg0) {
|
||||||
|
const ret = arg0.node;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
||||||
|
const ret = arg0.process;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_prototypesetcall_d62e5099504357e6: function(arg0, arg1, arg2) {
|
||||||
|
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||||
|
},
|
||||||
|
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.randomFillSync(arg1);
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
||||||
|
const ret = module.require;
|
||||||
|
return ret;
|
||||||
|
}, arguments); },
|
||||||
|
__wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() {
|
||||||
|
const ret = typeof global === 'undefined' ? null : global;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() {
|
||||||
|
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_SELF_f207c857566db248: function() {
|
||||||
|
const ret = typeof self === 'undefined' ? null : self;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() {
|
||||||
|
const ret = typeof window === 'undefined' ? null : window;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
},
|
||||||
|
__wbg_subarray_a068d24e39478a8a: function(arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
||||||
|
const ret = arg0.versions;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||||
|
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||||
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_init_externref_table: function() {
|
||||||
|
const table = wasm.__wbindgen_externrefs;
|
||||||
|
const offset = table.grow(4);
|
||||||
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
|
table.set(offset + 1, null);
|
||||||
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__proto__: null,
|
||||||
|
"./wzp_wasm_bg.js": import0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WzpCryptoSessionFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpcryptosession_free(ptr >>> 0, 1));
|
||||||
|
const WzpFecDecoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecdecoder_free(ptr >>> 0, 1));
|
||||||
|
const WzpFecEncoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecencoder_free(ptr >>> 0, 1));
|
||||||
|
const WzpKeyExchangeFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(ptr => wasm.__wbg_wzpkeyexchange_free(ptr >>> 0, 1));
|
||||||
|
|
||||||
|
function addToExternrefTable0(obj) {
|
||||||
|
const idx = wasm.__externref_table_alloc();
|
||||||
|
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArrayU8FromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringFromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return decodeText(ptr, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUint8ArrayMemory0 = null;
|
||||||
|
function getUint8ArrayMemory0() {
|
||||||
|
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||||
|
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedUint8ArrayMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(f, args) {
|
||||||
|
try {
|
||||||
|
return f.apply(this, args);
|
||||||
|
} catch (e) {
|
||||||
|
const idx = addToExternrefTable0(e);
|
||||||
|
wasm.__wbindgen_exn_store(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikeNone(x) {
|
||||||
|
return x === undefined || x === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passArray8ToWasm0(arg, malloc) {
|
||||||
|
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||||
|
WASM_VECTOR_LEN = arg.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||||
|
let numBytesDecoded = 0;
|
||||||
|
function decodeText(ptr, len) {
|
||||||
|
numBytesDecoded += len;
|
||||||
|
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||||
|
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
numBytesDecoded = len;
|
||||||
|
}
|
||||||
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
let wasmModule, wasm;
|
||||||
|
function __wbg_finalize_init(instance, module) {
|
||||||
|
wasm = instance.exports;
|
||||||
|
wasmModule = module;
|
||||||
|
cachedUint8ArrayMemory0 = null;
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
} catch (e) {
|
||||||
|
const validResponse = module.ok && expectedResponseType(module.type);
|
||||||
|
|
||||||
|
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedResponseType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'basic': case 'cors': case 'default': return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSync(module) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||||
|
({module} = module)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
if (!(module instanceof WebAssembly.Module)) {
|
||||||
|
module = new WebAssembly.Module(module);
|
||||||
|
}
|
||||||
|
const instance = new WebAssembly.Instance(module, imports);
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_init(module_or_path) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module_or_path !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||||
|
({module_or_path} = module_or_path)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module_or_path === undefined) {
|
||||||
|
module_or_path = new URL('wzp_wasm_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||||
|
module_or_path = fetch(module_or_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initSync, __wbg_init as default };
|
||||||
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
Binary file not shown.
473
docs/WEB_VARIANTS.md
Normal file
473
docs/WEB_VARIANTS.md
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
# WZP Web Client Variants
|
||||||
|
|
||||||
|
Three browser-based client implementations with different trade-offs between simplicity, features, and performance.
|
||||||
|
|
||||||
|
## Variant Comparison
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Variant 1: Pure JS"
|
||||||
|
P_MIC[Mic] --> P_WRK[AudioWorklet<br/>48kHz PCM]
|
||||||
|
P_WRK --> P_WS[WebSocket<br/>TCP]
|
||||||
|
P_WS --> P_BRIDGE[wzp-web Bridge<br/>Opus + FEC + Crypto]
|
||||||
|
P_BRIDGE --> P_QUIC[QUIC Datagram<br/>to Relay]
|
||||||
|
end
|
||||||
|
|
||||||
|
style P_BRIDGE fill:#ff9f43
|
||||||
|
style P_WS fill:#74b9ff
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Variant 2: Hybrid"
|
||||||
|
H_MIC[Mic] --> H_WRK[AudioWorklet<br/>48kHz PCM]
|
||||||
|
H_WRK --> H_FEC[WASM RaptorQ<br/>FEC Encode]
|
||||||
|
H_FEC --> H_WS[WebSocket<br/>TCP]
|
||||||
|
H_WS --> H_BRIDGE[wzp-web Bridge<br/>Opus + Crypto]
|
||||||
|
H_BRIDGE --> H_QUIC[QUIC Datagram<br/>to Relay]
|
||||||
|
end
|
||||||
|
|
||||||
|
style H_FEC fill:#a29bfe
|
||||||
|
style H_BRIDGE fill:#ff9f43
|
||||||
|
style H_WS fill:#74b9ff
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Variant 3: Full WASM"
|
||||||
|
F_MIC[Mic] --> F_WRK[AudioWorklet<br/>48kHz PCM]
|
||||||
|
F_WRK --> F_FEC[WASM RaptorQ<br/>FEC Encode]
|
||||||
|
F_FEC --> F_ENC[WASM ChaCha20<br/>Encrypt]
|
||||||
|
F_ENC --> F_WT[WebTransport<br/>UDP Datagrams]
|
||||||
|
F_WT --> F_RELAY[Direct to Relay<br/>No Bridge]
|
||||||
|
end
|
||||||
|
|
||||||
|
style F_FEC fill:#a29bfe
|
||||||
|
style F_ENC fill:#ee5a24
|
||||||
|
style F_WT fill:#00b894
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| | Pure JS | Hybrid | Full WASM |
|
||||||
|
|--|---------|--------|-----------|
|
||||||
|
| **Bundle** | ~20KB JS | ~120KB (JS + 337KB WASM) | ~20KB JS + 337KB WASM |
|
||||||
|
| **Transport** | WebSocket (TCP) | WebSocket (TCP) | WebTransport (UDP) |
|
||||||
|
| **Encryption** | Bridge-side (ChaCha20 on QUIC) | Bridge-side | Browser-side ChaCha20-Poly1305 WASM |
|
||||||
|
| **FEC** | None | RaptorQ WASM (ready, not active over TCP) | RaptorQ WASM (active over UDP) |
|
||||||
|
| **Codec** | Bridge Opus (server-side) | Bridge Opus | Browser Opus (future) / Bridge Opus |
|
||||||
|
| **E2E Encrypted** | No (bridge sees plaintext PCM) | No (bridge sees plaintext PCM) | Yes (bridge eliminated) |
|
||||||
|
| **Latency** | ~50-80ms (TCP overhead) | ~50-80ms (TCP) | ~20-40ms (UDP datagrams) |
|
||||||
|
| **Loss Recovery** | TCP retransmit (adds latency) | TCP retransmit | RaptorQ FEC (no retransmit) |
|
||||||
|
| **Browser Support** | All browsers | All browsers | Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+ |
|
||||||
|
| **Relay Changes** | None | None | Needs HTTP/3 (h3-quinn) |
|
||||||
|
| **Status** | Ready | Ready (FEC testable in console) | Architecture complete, needs relay HTTP/3 |
|
||||||
|
|
||||||
|
## Variant 1: Pure JS
|
||||||
|
|
||||||
|
The lightest implementation. No WASM, no FEC, no browser-side encryption. The `wzp-web` Rust bridge handles everything on the server side.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser
|
||||||
|
participant W as wzp-web Bridge
|
||||||
|
participant R as wzp-relay
|
||||||
|
|
||||||
|
B->>B: getUserMedia() mic access
|
||||||
|
B->>B: AudioWorklet captures 960 samples / 20ms
|
||||||
|
|
||||||
|
B->>W: WebSocket connect /ws/room-name
|
||||||
|
W->>R: QUIC connect (SNI = hashed room)
|
||||||
|
W->>R: Crypto handshake (X25519 + ChaCha20)
|
||||||
|
|
||||||
|
loop Every 20ms
|
||||||
|
B->>W: WS Binary: Int16[960] raw PCM
|
||||||
|
W->>W: Opus encode + FEC + Encrypt
|
||||||
|
W->>R: QUIC Datagram
|
||||||
|
end
|
||||||
|
|
||||||
|
loop Incoming
|
||||||
|
R->>W: QUIC Datagram
|
||||||
|
W->>W: Decrypt + FEC decode + Opus decode
|
||||||
|
W->>B: WS Binary: Int16[960] raw PCM
|
||||||
|
end
|
||||||
|
|
||||||
|
B->>B: AudioWorklet plays received PCM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (Pure JS)
|
||||||
|
├── Capture: getUserMedia → AudioWorklet (WZPCaptureProcessor)
|
||||||
|
│ └── 128-sample blocks accumulated → 960-sample frame
|
||||||
|
│ └── Float32 → Int16 conversion
|
||||||
|
│ └── postMessage(ArrayBuffer) to main thread
|
||||||
|
├── Send: onmessage → ws.send(pcmBuffer)
|
||||||
|
│ └── Binary WebSocket frame (1920 bytes = 960 × 2)
|
||||||
|
├── Receive: ws.onmessage → ArrayBuffer
|
||||||
|
│ └── Int16Array(960) → playback port
|
||||||
|
└── Playback: AudioWorklet (WZPPlaybackProcessor)
|
||||||
|
└── Ring buffer (max 120ms)
|
||||||
|
└── Int16 → Float32 → output blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `js/wzp-pure.js` — `WZPPureClient` class (~100 lines)
|
||||||
|
- `js/wzp-core.js` — Shared UI + audio (used by all variants)
|
||||||
|
- `audio-processor.js` — AudioWorklet (unchanged)
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- No packet loss recovery (TCP retransmit adds latency spikes)
|
||||||
|
- Bridge sees plaintext audio (not E2E encrypted)
|
||||||
|
- Full audio processing pipeline runs on server (Opus, FEC, crypto)
|
||||||
|
- Each browser connection = one QUIC session on the bridge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variant 2: Hybrid (JS + WASM FEC)
|
||||||
|
|
||||||
|
Adds RaptorQ forward error correction via a small WASM module. Same WebSocket transport as Pure — the FEC module is loaded and functional but doesn't add value over TCP (no packet loss). It's ready to activate when WebTransport replaces WebSocket.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser
|
||||||
|
participant WASM as WASM Module
|
||||||
|
participant W as wzp-web Bridge
|
||||||
|
participant R as wzp-relay
|
||||||
|
|
||||||
|
B->>WASM: Load wzp_wasm.js (337KB)
|
||||||
|
WASM-->>B: WzpFecEncoder + WzpFecDecoder ready
|
||||||
|
|
||||||
|
B->>W: WebSocket connect /ws/room-name
|
||||||
|
W->>R: QUIC connect + handshake
|
||||||
|
|
||||||
|
loop Every 20ms
|
||||||
|
B->>B: AudioWorklet captures PCM
|
||||||
|
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
|
||||||
|
WASM-->>B: FEC packets (source + repair) when block complete
|
||||||
|
B->>W: WS Binary: raw PCM (FEC not on wire over TCP)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over B,WASM: FEC encode/decode proven via testFec()
|
||||||
|
```
|
||||||
|
|
||||||
|
### WASM Module (wzp-wasm)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "wzp-wasm (337KB)"
|
||||||
|
FE[WzpFecEncoder<br/>RaptorQ source block accumulator]
|
||||||
|
FD[WzpFecDecoder<br/>RaptorQ reconstruction]
|
||||||
|
KX[WzpKeyExchange<br/>X25519 ephemeral DH]
|
||||||
|
CS[WzpCryptoSession<br/>ChaCha20-Poly1305]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Hybrid uses"
|
||||||
|
FE
|
||||||
|
FD
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Full uses"
|
||||||
|
FE
|
||||||
|
FD
|
||||||
|
KX
|
||||||
|
CS
|
||||||
|
end
|
||||||
|
|
||||||
|
style FE fill:#a29bfe
|
||||||
|
style FD fill:#a29bfe
|
||||||
|
style KX fill:#ee5a24
|
||||||
|
style CS fill:#ee5a24
|
||||||
|
```
|
||||||
|
|
||||||
|
### FEC Wire Format
|
||||||
|
|
||||||
|
```
|
||||||
|
Per symbol (encoded by WASM, 259 bytes):
|
||||||
|
┌──────────┬───────────┬──────────┬──────────────────┐
|
||||||
|
│ block_id │ symbol_idx│ is_repair│ symbol_data │
|
||||||
|
│ (1 byte) │ (1 byte) │ (1 byte) │ (256 bytes) │
|
||||||
|
└──────────┴───────────┴──────────┴──────────────────┘
|
||||||
|
|
||||||
|
Symbol data internals (256 bytes):
|
||||||
|
┌────────────┬──────────────────┬─────────┐
|
||||||
|
│ length │ audio frame data │ padding │
|
||||||
|
│ (2B LE) │ (variable) │ (zeros) │
|
||||||
|
└────────────┴──────────────────┴─────────┘
|
||||||
|
|
||||||
|
Block = 5 source symbols + ceil(5 × 0.5) = 3 repair symbols = 8 total
|
||||||
|
Any 5 of 8 received → full block recoverable (RaptorQ fountain code)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing FEC in Browser Console
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// On any hybrid variant page, open console:
|
||||||
|
client.testFec({ lossRate: 0.3, blockSize: 5, symbolSize: 256 })
|
||||||
|
// Output: "FEC test passed — recovered from 30% loss"
|
||||||
|
|
||||||
|
client.testFec({ lossRate: 0.5 })
|
||||||
|
// Output: "FEC test passed — recovered from 50% loss"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `js/wzp-hybrid.js` — `WZPHybridClient` class (~150 lines)
|
||||||
|
- `js/wzp-core.js` — Shared UI + audio
|
||||||
|
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB)
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- FEC doesn't help over TCP WebSocket (no packet loss to recover from)
|
||||||
|
- Bridge still sees plaintext audio
|
||||||
|
- WebTransport activation is the unlock for FEC value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variant 3: Full WASM + WebTransport
|
||||||
|
|
||||||
|
The complete WZP client in the browser. No bridge server needed — the browser connects directly to the relay via WebTransport unreliable datagrams. All encryption and FEC happens in WASM.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser
|
||||||
|
participant WASM as WASM Module
|
||||||
|
participant R as wzp-relay
|
||||||
|
|
||||||
|
B->>WASM: Load wzp_wasm.js
|
||||||
|
WASM-->>B: FEC + Crypto + KeyExchange ready
|
||||||
|
|
||||||
|
B->>R: WebTransport connect (HTTPS/HTTP3)
|
||||||
|
B->>R: Bidirectional stream open
|
||||||
|
|
||||||
|
Note over B,R: Key Exchange
|
||||||
|
B->>WASM: kx = new WzpKeyExchange()
|
||||||
|
B->>R: Stream: our X25519 public key (32 bytes)
|
||||||
|
R->>B: Stream: relay X25519 public key (32 bytes)
|
||||||
|
B->>WASM: secret = kx.derive_shared_secret(peer_pub)
|
||||||
|
B->>WASM: session = new WzpCryptoSession(secret)
|
||||||
|
|
||||||
|
Note over B,R: Media Flow (Unreliable Datagrams)
|
||||||
|
loop Every 20ms
|
||||||
|
B->>B: AudioWorklet captures PCM
|
||||||
|
B->>WASM: fecEncoder.add_symbol(pcm_bytes)
|
||||||
|
WASM-->>B: FEC symbols when block complete
|
||||||
|
B->>WASM: encrypted = session.encrypt(header, symbol)
|
||||||
|
B->>R: WebTransport datagram (encrypted)
|
||||||
|
end
|
||||||
|
|
||||||
|
loop Incoming
|
||||||
|
R->>B: WebTransport datagram (encrypted)
|
||||||
|
B->>WASM: plaintext = session.decrypt(header, ciphertext)
|
||||||
|
B->>WASM: frames = fecDecoder.add_symbol(...)
|
||||||
|
WASM-->>B: Decoded audio frames
|
||||||
|
B->>B: AudioWorklet plays PCM
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encryption Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "Key Exchange (once per session)"
|
||||||
|
KX_A[Browser: WzpKeyExchange.new<br/>Generate X25519 keypair] --> PUB_A[Send public key<br/>32 bytes over stream]
|
||||||
|
PUB_B[Receive relay public key<br/>32 bytes] --> DH[derive_shared_secret<br/>X25519 DH + HKDF-SHA256]
|
||||||
|
DH --> SESSION[WzpCryptoSession<br/>ChaCha20-Poly1305 256-bit key]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Per-Packet Encryption"
|
||||||
|
HDR[Build MediaHeader<br/>12 bytes AAD] --> ENC[session.encrypt<br/>header=AAD plaintext=audio]
|
||||||
|
ENC --> NONCE[Nonce 12 bytes<br/>session_id 4 + seq 4 + dir 1 + pad 3]
|
||||||
|
ENC --> CT[Ciphertext + 16-byte Poly1305 tag]
|
||||||
|
CT --> DG[WebTransport datagram send]
|
||||||
|
end
|
||||||
|
|
||||||
|
style SESSION fill:#ee5a24
|
||||||
|
style NONCE fill:#fdcb6e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nonce Construction (matches native wzp-crypto)
|
||||||
|
|
||||||
|
```
|
||||||
|
Bytes 0-3: session_id (SHA-256(session_key)[:4])
|
||||||
|
Bytes 4-7: sequence_number (u32 BE, incrementing)
|
||||||
|
Byte 8: direction (0x00 = send, 0x01 = recv)
|
||||||
|
Bytes 9-11: 0x000000 (padding)
|
||||||
|
|
||||||
|
Total: 12 bytes — deterministic, never reused (seq increments)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Pipeline Detail
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
MIC[Mic PCM Int16 x 960] --> PAD[Pad to 256 bytes<br/>2-byte LE length + data + zeros]
|
||||||
|
PAD --> FEC[WzpFecEncoder.add_symbol<br/>Accumulate 5 frames per block]
|
||||||
|
FEC -->|Block complete| SYMBOLS[5 source + 3 repair symbols]
|
||||||
|
SYMBOLS --> HDR[Build 12-byte MediaHeader<br/>seq, timestamp, codec, fec_block, symbol_idx]
|
||||||
|
HDR --> ENCRYPT[WzpCryptoSession.encrypt<br/>AAD=header, payload=symbol]
|
||||||
|
ENCRYPT --> DG[WebTransport datagram<br/>header 12B + ciphertext + tag 16B]
|
||||||
|
|
||||||
|
style FEC fill:#a29bfe
|
||||||
|
style ENCRYPT fill:#ee5a24
|
||||||
|
style DG fill:#00b894
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receive Pipeline Detail
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
DG[WebTransport datagram] --> PARSE[Parse 12-byte MediaHeader]
|
||||||
|
PARSE --> DECRYPT[WzpCryptoSession.decrypt<br/>AAD=header, ciphertext=rest]
|
||||||
|
DECRYPT --> FEC_HDR[Parse 3-byte FEC header<br/>block_id + symbol_idx + is_repair]
|
||||||
|
FEC_HDR --> FEC_D[WzpFecDecoder.add_symbol]
|
||||||
|
FEC_D -->|Block decoded| FRAMES[Original audio frames]
|
||||||
|
FRAMES --> UNPAD[Strip 2-byte length prefix + padding]
|
||||||
|
UNPAD --> PLAY[AudioWorklet playback<br/>Int16 PCM x 960]
|
||||||
|
|
||||||
|
style DECRYPT fill:#ee5a24
|
||||||
|
style FEC_D fill:#a29bfe
|
||||||
|
style PLAY fill:#4a9eff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Crypto + FEC in Browser Console
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// On any full variant page, open console:
|
||||||
|
client.testCryptoFec()
|
||||||
|
// Tests: key exchange → encrypt → FEC encode → simulate 30% loss → FEC decode → decrypt
|
||||||
|
// Output: "Crypto+FEC test passed — key exchange, encrypt, FEC(30% loss), decrypt all OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `js/wzp-full.js` — `WZPFullClient` class (~250 lines)
|
||||||
|
- `js/wzp-core.js` — Shared UI + audio
|
||||||
|
- `wasm/wzp_wasm.js` + `wasm/wzp_wasm_bg.wasm` — WASM module (337KB, shared with hybrid)
|
||||||
|
|
||||||
|
### Requirements (not yet met)
|
||||||
|
- Relay must support HTTP/3 WebTransport (h3-quinn integration)
|
||||||
|
- Real TLS certificate (WebTransport requires valid HTTPS)
|
||||||
|
- Browser with WebTransport support (Chrome 97+, Edge 97+, Firefox 114+, Safari 17.4+)
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- No Opus encoding in browser yet (sends raw PCM, relay/peer decodes)
|
||||||
|
- Key exchange is simplified (no Ed25519 signature verification in WASM yet)
|
||||||
|
- No adaptive quality switching in browser (server-side only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Infrastructure
|
||||||
|
|
||||||
|
### wzp-core.js
|
||||||
|
|
||||||
|
Common code used by all three variants:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
CORE[wzp-core.js] --> DETECT[detectVariant<br/>URL ?variant= param]
|
||||||
|
CORE --> ROOM[getRoom<br/>URL path / input field]
|
||||||
|
CORE --> AUDIO[startAudioContext<br/>48kHz AudioContext]
|
||||||
|
CORE --> CAP[connectCapture<br/>Mic to AudioWorklet]
|
||||||
|
CORE --> PLAY[connectPlayback<br/>AudioWorklet to speaker]
|
||||||
|
CORE --> UI[initUI<br/>Buttons, PTT, level meter]
|
||||||
|
CORE --> STATUS[updateStatus / updateStats<br/>DOM updates]
|
||||||
|
|
||||||
|
CAP --> WORKLET[AudioWorklet<br/>or ScriptProcessor fallback]
|
||||||
|
PLAY --> WORKLET
|
||||||
|
|
||||||
|
style CORE fill:#6c5ce7
|
||||||
|
style WORKLET fill:#00b894
|
||||||
|
```
|
||||||
|
|
||||||
|
### AudioWorklet Processors (audio-processor.js)
|
||||||
|
|
||||||
|
```
|
||||||
|
WZPCaptureProcessor:
|
||||||
|
AudioWorklet process() → 128 samples per call
|
||||||
|
Buffer internally until 960 samples (20ms frame)
|
||||||
|
Convert Float32 → Int16
|
||||||
|
postMessage(ArrayBuffer) to main thread
|
||||||
|
|
||||||
|
WZPPlaybackProcessor:
|
||||||
|
Receive Int16 PCM via port.onmessage
|
||||||
|
Convert Int16 → Float32
|
||||||
|
Write to ring buffer (max ~120ms / 6 frames)
|
||||||
|
process() reads from ring buffer → output
|
||||||
|
```
|
||||||
|
|
||||||
|
### index.html Boot Sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant PAGE as index.html
|
||||||
|
participant CORE as wzp-core.js
|
||||||
|
participant VAR as Variant JS
|
||||||
|
|
||||||
|
PAGE->>CORE: Load (static script tag)
|
||||||
|
CORE->>CORE: detectVariant() from URL
|
||||||
|
PAGE->>VAR: Dynamic script load (wzp-pure/hybrid/full.js)
|
||||||
|
VAR-->>PAGE: wzpBoot() called on load
|
||||||
|
|
||||||
|
PAGE->>CORE: initUI(callbacks)
|
||||||
|
Note over PAGE: User clicks Connect
|
||||||
|
|
||||||
|
PAGE->>CORE: startAudioContext()
|
||||||
|
PAGE->>VAR: new WZP*Client(options)
|
||||||
|
PAGE->>VAR: client.connect()
|
||||||
|
PAGE->>CORE: connectCapture(audioCtx, onFrame)
|
||||||
|
PAGE->>CORE: connectPlayback(audioCtx)
|
||||||
|
|
||||||
|
loop Audio flowing
|
||||||
|
CORE->>VAR: client.sendAudio(pcmBuffer)
|
||||||
|
VAR->>CORE: onAudio(Int16Array) callback
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Behind Caddy (recommended)
|
||||||
|
|
||||||
|
```
|
||||||
|
# Caddyfile
|
||||||
|
wzp.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Relay
|
||||||
|
./wzp-relay --listen 0.0.0.0:4433
|
||||||
|
|
||||||
|
# Web bridge (no --tls, Caddy handles SSL)
|
||||||
|
./wzp-web --port 8080 --relay 127.0.0.1:4433
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct TLS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wzp-web --port 443 --relay 127.0.0.1:4433 --tls \
|
||||||
|
--cert /etc/letsencrypt/live/domain/fullchain.pem \
|
||||||
|
--key /etc/letsencrypt/live/domain/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Patterns
|
||||||
|
|
||||||
|
```
|
||||||
|
https://domain/room-name → Pure (default)
|
||||||
|
https://domain/room-name?variant=pure → Pure JS
|
||||||
|
https://domain/room-name?variant=hybrid → Hybrid (JS + WASM FEC)
|
||||||
|
https://domain/room-name?variant=full → Full WASM (needs HTTP/3 relay)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
1. **Relay HTTP/3 support** (h3-quinn) — unlocks Full variant for production
|
||||||
|
2. **Browser Opus encoding** — AudioEncoder API or Opus WASM, removes bridge dependency for Hybrid
|
||||||
|
3. **Ed25519 signatures in WASM** — full identity verification in Full variant
|
||||||
|
4. **Adaptive quality in browser** — monitor RTT/loss, switch profiles
|
||||||
|
5. **WebTransport fallback to WebSocket** — Full variant auto-degrades if WebTransport unavailable
|
||||||
BIN
wzp-debug.apk
BIN
wzp-debug.apk
Binary file not shown.
BIN
wzp-release.apk
BIN
wzp-release.apk
Binary file not shown.
Reference in New Issue
Block a user