From 66f10baa1b754ea86d5ec5f2be5cfcf3f5fb61d9 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 15:46:30 +0000 Subject: [PATCH] Implement native WebRtcTest validation (#96) --- .gitea/workflows/rust-workspace.yml | 6 +- Cargo.lock | 1335 +++++++++++- crates/libremetaverse-voice-webrtc/Cargo.toml | 14 + crates/libremetaverse-voice-webrtc/src/lib.rs | 2 + .../libremetaverse-voice-webrtc/src/native.rs | 1852 +++++++++++++++++ docs/webrtc.md | 80 + programs/Cargo.toml | 5 + programs/README.md | 38 +- programs/src/bin/webrtc_test.rs | 2 +- programs/src/lib.rs | 1 + programs/src/webrtc_test.rs | 801 +++++++ programs/tests/webrtc_test_cli.rs | 103 + tests/compat/Cargo.toml | 1 + .../compat/tests/webrtc_protocol_semantics.rs | 57 + 14 files changed, 4280 insertions(+), 17 deletions(-) create mode 100644 crates/libremetaverse-voice-webrtc/src/native.rs create mode 100644 docs/webrtc.md create mode 100644 programs/src/webrtc_test.rs create mode 100644 programs/tests/webrtc_test_cli.rs create mode 100644 tests/compat/tests/webrtc_protocol_semantics.rs diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index f143d6c..16f48a4 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -68,7 +68,7 @@ jobs: - name: Install audited OpenJPEG 2.5.4 run: | sudo apt-get update - sudo apt-get install --yes build-essential cmake pkg-config + sudo apt-get install --yes build-essential cmake pkg-config libopus-dev libasound2-dev tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX" - name: Check formatting run: cargo fmt --all -- --check @@ -99,6 +99,10 @@ jobs: run: python3 tools/test_milestone_10.py - name: Compile every workspace target with bounded memory run: cargo check --workspace --all-targets --locked -j 1 + - name: Check optional cross-platform real audio backend + run: | + cargo check -p libremetaverse-voice-webrtc --all-targets --features real-audio --locked -j 1 + cargo check -p libremetaverse-programs --bin webrtc-test --features real-audio --locked -j 1 - name: Lint every native extension target with bounded memory run: cargo clippy -p libremetaverse-rendering-simple -p libremetaverse-rendering-mesh-foundry -p libremetaverse-rlv -p libremetaverse-lsl-tools --all-targets --locked -j 1 -- -D warnings - name: Document every native extension crate diff --git a/Cargo.lock b/Cargo.lock index 184ee51..2c6c3c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -17,6 +52,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -86,12 +143,76 @@ dependencies = [ "ogg_next_sys", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audiopus" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3743519567e9135cf6f9f1a509851cb0c8e4cb9d66feb286668afb1923bec458" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "audiopus_sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927791de46f70facea982dbfaf19719a41ce6064443403be631a85de6a58fff9" +dependencies = [ + "log", + "pkg-config", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -105,6 +226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -121,12 +243,24 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bcdec_rs" version = "0.2.0" @@ -153,6 +287,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -210,13 +353,25 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -237,6 +392,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -245,7 +411,31 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", ] [[package]] @@ -324,6 +514,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -346,6 +542,50 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -364,6 +604,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -373,6 +628,18 @@ dependencies = [ "cfg-if", ] +[[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]] name = "crypto-common" version = "0.1.7" @@ -380,6 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -392,6 +660,97 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -399,7 +758,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -409,10 +770,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] +[[package]] +name = "dimpl" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6aa42b0c64c3e5311a2afad224b32db1ee129d21c63daaaf8ea747b846cdbc" +dependencies = [ + "aes", + "aes-gcm", + "arrayvec", + "aws-lc-rs", + "ccm", + "chacha20 0.9.1", + "chacha20poly1305", + "der", + "ecdsa", + "generic-array", + "hkdf", + "hmac", + "log", + "nom 8.0.0", + "once_cell", + "p256", + "p384", + "pkcs8", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen", + "sec1", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "time", + "x25519-dalek", + "x509-cert", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -440,12 +838,47 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[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 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -462,6 +895,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -478,6 +933,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -571,6 +1032,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -586,6 +1048,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -595,17 +1069,38 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[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]] name = "hashbrown" version = "0.17.1" @@ -618,6 +1113,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "http" version = "1.5.0" @@ -837,12 +1356,34 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08f9118d003d441f79c1070e84d0a2a89f35721ca8ae0cd5e1f9026dc4a2517" +dependencies = [ + "crc", + "serde", + "str0m-proto", + "subtle", + "tracing", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -873,7 +1414,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.20", @@ -894,6 +1435,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -934,6 +1484,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -980,7 +1536,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "syn 2.0.119", ] @@ -1001,6 +1557,7 @@ dependencies = [ "libremetaverse-utilities", "libremetaverse-voice-vivox", "libremetaverse-voice-webrtc", + "serde_json", "tokio", ] @@ -1058,6 +1615,7 @@ dependencies = [ "libremetaverse-imaging-skia", "libremetaverse-structured-data", "libremetaverse-voice-vivox", + "libremetaverse-voice-webrtc", "regex", "roxmltree", "tokio", @@ -1112,7 +1670,7 @@ dependencies = [ "getrandom 0.4.3", "md-5", "sha1", - "sha2", + "sha2 0.11.0", "stats_alloc", "uuid", ] @@ -1141,9 +1699,16 @@ dependencies = [ name = "libremetaverse-voice-webrtc" version = "0.0.1" dependencies = [ + "audiopus", + "cpal", + "hound", "libremetaverse", "libremetaverse-structured-data", "libremetaverse-types", + "serde", + "serde_json", + "str0m", + "tokio", ] [[package]] @@ -1182,6 +1747,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "md-5" version = "0.10.6" @@ -1234,6 +1805,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.28.0" @@ -1269,6 +1869,82 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "objc2" version = "0.6.4" @@ -1278,6 +1954,32 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -1289,6 +1991,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -1306,7 +2031,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags", + "block2", "dispatch2", + "libc", "objc2", ] @@ -1437,6 +2164,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1449,6 +2185,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1470,6 +2212,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1482,12 +2257,45 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1497,6 +2305,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1507,6 +2330,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1546,7 +2387,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1582,21 +2423,65 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1611,7 +2496,20 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "aws-lc-rs", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", ] [[package]] @@ -1681,6 +2579,16 @@ dependencies = [ "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]] name = "ring" version = "0.17.14" @@ -1691,7 +2599,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1719,6 +2627,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1804,7 +2721,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1831,6 +2748,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sctp-proto" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f895c3c33ae20283f9129bd09db2ca69138798b372ef2b98bd2946d23ea4819b" +dependencies = [ + "bytes", + "crc", + "log", + "rand 0.9.5", + "rustc-hash", + "slab", + "thiserror 2.0.20", +] + +[[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", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1923,6 +2869,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1956,6 +2913,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2041,6 +3008,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2053,6 +3030,59 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec" +[[package]] +name = "str0m" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4656b60e74d1a0cf7c407b0d992c5bc141c3e75193f4b1399817d54a180505f3" +dependencies = [ + "arrayvec", + "base64ct", + "combine", + "dimpl", + "fastrand", + "is", + "sctp-proto", + "serde", + "str0m-proto", + "str0m-rust-crypto", + "subtle", + "time", + "tracing", +] + +[[package]] +name = "str0m-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f3d99f2cf6c76a502e45feb896ea71e54787ede8744bcdb215acfbfcb905dd" +dependencies = [ + "base64ct", + "dimpl", + "fastrand", + "serde", + "subtle", + "time", +] + +[[package]] +name = "str0m-rust-crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947c2417bf43e47504911f92f47c22b6125a57d162e1661f1c77b78ebb3aa9d3" +dependencies = [ + "aes", + "aes-gcm", + "ctr", + "dimpl", + "hmac", + "p256", + "sha1", + "sha2 0.10.9", + "str0m-proto", + "time", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2158,6 +3188,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2257,6 +3317,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -2324,9 +3396,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2360,6 +3444,22 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2452,6 +3552,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2564,12 +3673,107 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2604,6 +3808,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2657,6 +3870,15 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -2664,6 +3886,47 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -2674,6 +3937,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2697,6 +3970,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2723,6 +4016,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/crates/libremetaverse-voice-webrtc/Cargo.toml b/crates/libremetaverse-voice-webrtc/Cargo.toml index 5814aa6..2093097 100644 --- a/crates/libremetaverse-voice-webrtc/Cargo.toml +++ b/crates/libremetaverse-voice-webrtc/Cargo.toml @@ -8,9 +8,23 @@ repository.workspace = true description = "WebRTC voice shims for the MetaCrate LibreMetaverse rewrite" [dependencies] +audiopus = "0.2" +hound = "3.5" libremetaverse = { path = "../libremetaverse" } libremetaverse-structured-data = { path = "../libremetaverse-structured-data" } libremetaverse-types = { path = "../libremetaverse-types" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +str0m = { version = "0.22", default-features = false, features = ["rust-crypto"] } +tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] } + +[features] +default = [] +real-audio = ["dep:cpal"] + +[dependencies.cpal] +version = "0.18" +optional = true [lints] workspace = true diff --git a/crates/libremetaverse-voice-webrtc/src/lib.rs b/crates/libremetaverse-voice-webrtc/src/lib.rs index eaa44f7..5b6300b 100644 --- a/crates/libremetaverse-voice-webrtc/src/lib.rs +++ b/crates/libremetaverse-voice-webrtc/src/lib.rs @@ -3,11 +3,13 @@ extern crate self as libremetaverse_voice_webrtc; mod generated; +mod native; pub use generated::*; pub use libremetaverse as core; pub use libremetaverse_structured_data as structured_data; pub use libremetaverse_types::Error; +pub use native::*; /// Project-owned boundary types for external JSON signatures. pub mod signaling { diff --git a/crates/libremetaverse-voice-webrtc/src/native.rs b/crates/libremetaverse-voice-webrtc/src/native.rs new file mode 100644 index 0000000..f1f49dc --- /dev/null +++ b/crates/libremetaverse-voice-webrtc/src/native.rs @@ -0,0 +1,1852 @@ +//! Native WebRTC voice transport, signaling, peer protocol, and virtual audio. + +#![allow(clippy::cast_possible_truncation)] // Audio normalization clamps before conversion. +#![allow(clippy::cast_precision_loss)] // Sample/rate conversion intentionally uses floating point. +#![allow(clippy::cast_sign_loss)] // Resampler positions are constructed from non-negative indices. +#![allow(clippy::missing_errors_doc)] // The error enum documents the shared failure contract. +#![allow(clippy::too_many_arguments)] // The RTC loop receives independent owned I/O resources. +#![allow(clippy::too_many_lines)] // Linear loops preserve the WebRTC mutation/drain ordering. + +use audiopus::coder::{Decoder, Encoder}; +use audiopus::{Application, Channels, SampleRate}; +use libremetaverse_types::UUID; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +#[cfg(feature = "real-audio")] +use std::collections::VecDeque; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::future::Future; +use std::io::Cursor; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use str0m::change::{SdpAnswer, SdpOffer}; +use str0m::channel::ChannelId; +use str0m::format::Codec; +use str0m::media::{Direction, MediaKind, MediaTime, Mid}; +use str0m::net::{Protocol, Receive}; +use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig}; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; + +const DATA_CHANNEL_LABEL: &str = "SLData"; +const SAMPLE_RATE: u32 = 48_000; +const FRAME_SAMPLES: usize = 960; +const FRAME_DURATION: Duration = Duration::from_millis(20); +const MAX_SIGNALING_BYTES: usize = 1024 * 1024; +const MAX_DATA_BYTES: usize = 64 * 1024; + +/// Errors produced by the native WebRTC adapter. +#[derive(Debug)] +pub enum WebRtcError { + InvalidInput(&'static str), + Io(std::io::Error), + Signaling(String), + Protocol(&'static str), + Rtc(String), + Audio(String), + Timeout(&'static str), + Closed, +} + +impl fmt::Display for WebRtcError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(message) | Self::Protocol(message) => formatter.write_str(message), + Self::Io(error) => error.fmt(formatter), + Self::Signaling(message) => write!(formatter, "voice signaling failed: {message}"), + Self::Rtc(message) => write!(formatter, "WebRTC transport failed: {message}"), + Self::Audio(message) => write!(formatter, "voice audio failed: {message}"), + Self::Timeout(action) => write!(formatter, "{action} timed out"), + Self::Closed => formatter.write_str("WebRTC voice session is closed"), + } + } +} + +impl std::error::Error for WebRtcError {} + +impl From for WebRtcError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +/// A credential that is deliberately opaque and redacted from diagnostics. +#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct VoiceSecret(String); + +impl VoiceSecret { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.len() > 16 * 1024 { + return Err(WebRtcError::InvalidInput("voice credential is too large")); + } + Ok(Self(value)) + } + + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for VoiceSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VoiceSecret()") + } +} + +/// Initial `ProvisionVoiceAccountRequest` body. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProvisionRequest { + pub jsep: Jsep, + pub channel_type: String, + pub voice_server_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parcel_local_id: Option, +} + +/// SDP envelope used by the grid voice service. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Jsep { + #[serde(rename = "type")] + pub kind: String, + pub sdp: String, +} + +/// Provisioning response with credentials protected from `Debug` output. +#[derive(Clone)] +pub struct ProvisionResponse { + pub answer_sdp: String, + pub viewer_session: UUID, + pub channel: Option, + pub credentials: Option, +} + +impl fmt::Debug for ProvisionResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProvisionResponse") + .field("answer_sdp", &"") + .field("viewer_session", &self.viewer_session) + .field("channel", &self.channel.as_ref().map(|_| "")) + .field("credentials", &self.credentials) + .finish() + } +} + +/// Candidate-completion body for `VoiceSignalingRequest`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignalingCompleteRequest { + pub voice_server_type: String, + pub viewer_session: String, + pub candidate: SignalingComplete, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct SignalingComplete { + pub completed: bool, +} + +/// Logout body sent to `ProvisionVoiceAccountRequest` during teardown. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CloseRequest { + pub logout: bool, + pub voice_server_type: String, + pub viewer_session: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credentials: Option, +} + +type SignalFuture<'a, T> = Pin> + Send + 'a>>; + +/// Transport-neutral grid capability boundary. Implementations may use LLSD XML, +/// JSON fixtures, or another native Rust HTTP client without exposing credentials. +pub trait VoiceSignaling: Send + Sync { + fn provision(&self, request: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse>; + fn complete(&self, request: SignalingCompleteRequest) -> SignalFuture<'_, ()>; + fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()>; +} + +/// Selectable audio endpoint. Virtual endpoints are always available in CI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AudioDeviceInfo { + pub id: String, + pub name: String, + pub input: bool, + pub is_default: bool, + pub is_virtual: bool, +} + +/// Cross-platform device catalog. Real devices are compiled only with +/// `--features real-audio`; virtual input/output remain deterministic otherwise. +pub fn audio_devices() -> Result, WebRtcError> { + #[allow(unused_mut)] + let mut devices = vec![ + AudioDeviceInfo { + id: "virtual:microphone".into(), + name: "Virtual microphone (48 kHz mono)".into(), + input: true, + is_default: true, + is_virtual: true, + }, + AudioDeviceInfo { + id: "virtual:speaker".into(), + name: "Virtual speaker (decoded PCM sink)".into(), + input: false, + is_default: true, + is_virtual: true, + }, + ]; + #[cfg(feature = "real-audio")] + let _ = enumerate_real_devices(&mut devices); + Ok(devices) +} + +#[cfg(feature = "real-audio")] +fn enumerate_real_devices(devices: &mut Vec) -> Result<(), WebRtcError> { + use cpal::traits::{DeviceTrait, HostTrait}; + let host = cpal::default_host(); + let default_input = host + .default_input_device() + .and_then(|device| device.id().ok()); + let default_output = host + .default_output_device() + .and_then(|device| device.id().ok()); + for device in host + .input_devices() + .map_err(|error| WebRtcError::Audio(error.to_string()))? + { + let name = device.to_string(); + let id = device + .id() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + devices.push(AudioDeviceInfo { + id: format!("cpal:input:{id}"), + is_default: default_input.as_ref() == Some(&id), + name, + input: true, + is_virtual: false, + }); + } + for device in host + .output_devices() + .map_err(|error| WebRtcError::Audio(error.to_string()))? + { + let name = device.to_string(); + let id = device + .id() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + devices.push(AudioDeviceInfo { + id: format!("cpal:output:{id}"), + is_default: default_output.as_ref() == Some(&id), + name, + input: false, + is_virtual: false, + }); + } + Ok(()) +} + +/// One 20 ms, 48 kHz mono Opus packet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OpusFrame(pub Vec); + +/// Encodes PCM into WebRTC Opus frames using native libopus. +pub fn encode_pcm_48k_mono(samples: &[i16]) -> Result, WebRtcError> { + let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip) + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + let mut padded = samples.to_vec(); + let remainder = padded.len() % FRAME_SAMPLES; + if remainder != 0 { + padded.resize(padded.len() + FRAME_SAMPLES - remainder, 0); + } + if padded.is_empty() { + padded.resize(FRAME_SAMPLES, 0); + } + let mut frames = Vec::with_capacity(padded.len() / FRAME_SAMPLES); + for pcm in padded.chunks_exact(FRAME_SAMPLES) { + let mut packet = vec![0_u8; 4_000]; + let count = encoder + .encode(pcm, &mut packet) + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + packet.truncate(count); + frames.push(OpusFrame(packet)); + } + Ok(frames) +} + +/// Loads integer or float PCM WAV data, downmixes and linearly resamples it to +/// 48 kHz mono, then encodes it into paced Opus frames. +pub fn encode_wav(path: &Path) -> Result, WebRtcError> { + let bytes = std::fs::read(path)?; + encode_wav_bytes(&bytes) +} + +pub fn encode_wav_bytes(bytes: &[u8]) -> Result, WebRtcError> { + if bytes.len() > 128 * 1024 * 1024 { + return Err(WebRtcError::InvalidInput("WAV file exceeds 128 MiB")); + } + let mut reader = hound::WavReader::new(Cursor::new(bytes)) + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + let spec = reader.spec(); + if spec.channels == 0 || spec.sample_rate == 0 || spec.channels > 32 { + return Err(WebRtcError::InvalidInput( + "unsupported WAV channel/rate metadata", + )); + } + let raw: Vec = match spec.sample_format { + hound::SampleFormat::Float => reader + .samples::() + .map(|sample| sample.map_err(|error| WebRtcError::Audio(error.to_string()))) + .collect::>()?, + hound::SampleFormat::Int => { + let scale = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1))); + reader + .samples::() + .map(|sample| { + sample + .map(|value| value as f32 / scale) + .map_err(|error| WebRtcError::Audio(error.to_string())) + }) + .collect::>()? + } + }; + let channels = usize::from(spec.channels); + let mono: Vec = raw + .chunks(channels) + .map(|frame| frame.iter().copied().sum::() / frame.len() as f32) + .collect(); + if mono.is_empty() { + return Err(WebRtcError::InvalidInput( + "WAV file contains no audio samples", + )); + } + let output_len = mono + .len() + .saturating_mul(SAMPLE_RATE as usize) + .div_ceil(spec.sample_rate as usize); + let mut pcm = Vec::with_capacity(output_len); + for output_index in 0..output_len { + let position = output_index as f64 * f64::from(spec.sample_rate) / f64::from(SAMPLE_RATE); + let left = position.floor() as usize; + let fraction = (position - left as f64) as f32; + let a = mono[left.min(mono.len() - 1)]; + let b = mono[(left + 1).min(mono.len() - 1)]; + let value = a + (b - a) * fraction; + pcm.push((value.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16); + } + encode_pcm_48k_mono(&pcm) +} + +/// Peer audio state delivered over `SLData`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PeerAudioState { + pub power: Option, + pub voice_active: Option, + pub moderator_muted: Option, + pub joined_primary: Option, + pub left: bool, + pub ssrc: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Int3 { + pub x: i32, + pub y: i32, + pub z: i32, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Int4 { + pub x: i32, + pub y: i32, + pub z: i32, + pub w: i32, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AvatarPosition { + pub sender_position: Option, + pub sender_heading: Option, + pub listener_position: Option, + pub listener_heading: Option, +} + +/// Observable session events. None contains capability URLs or credentials. +#[derive(Clone, Debug, PartialEq)] +pub enum VoiceEvent { + IceState(IceConnectionState), + Connected, + DataChannelReady, + PeerAudio(UUID, PeerAudioState), + PeerPosition(UUID, AvatarPosition), + MuteMap(HashMap), + GainMap(HashMap), + AudioFrame { samples: usize, peak: i16 }, + Diagnostic(String), + Closed, +} + +#[derive(Clone, Debug, Default)] +pub struct SessionSnapshot { + pub connected: bool, + pub data_channel_ready: bool, + pub known_peers: HashSet, + pub received_audio_frames: u64, + pub decoded_samples: u64, + pub sent_audio_frames: u64, + pub selected_input: String, + pub selected_output: String, + pub active_tasks: usize, +} + +/// Session construction parameters. +#[derive(Clone, Debug)] +pub struct VoiceSessionConfig { + pub bind_ip: IpAddr, + pub parcel_local_id: Option, + pub timeout: Duration, + pub input_device: String, + pub output_device: String, +} + +impl Default for VoiceSessionConfig { + fn default() -> Self { + Self { + bind_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + parcel_local_id: None, + timeout: Duration::from_secs(45), + input_device: "virtual:microphone".into(), + output_device: "virtual:speaker".into(), + } + } +} + +enum Command { + Data(String, oneshot::Sender>), + Play( + Vec, + bool, + oneshot::Sender>, + ), + Stop(oneshot::Sender<()>), + Shutdown(oneshot::Sender<()>), +} + +struct Playback { + frames: Vec, + index: usize, + looping: bool, + next_frame: Instant, + media_elapsed: Duration, +} + +/// Owned native WebRTC voice session. +pub struct WebRtcVoiceSession { + command: mpsc::Sender, + events: mpsc::Receiver, + snapshot: Arc>, + signaling: Arc, + viewer_session: UUID, + channel: Option, + credentials: Option, + task: Option>, + timeout: Duration, + #[cfg(feature = "real-audio")] + hardware_audio: Option, +} + +impl WebRtcVoiceSession { + /// Creates an SDP offer with Opus audio and ordered `SLData`, provisions it, + /// applies the answer, and starts the single peer/audio run loop. + pub async fn connect( + signaling: Arc, + config: VoiceSessionConfig, + ) -> Result { + validate_device(&config.input_device, true)?; + validate_device(&config.output_device, false)?; + let socket = UdpSocket::bind(SocketAddr::new(config.bind_ip, 0)).await?; + let local_addr = socket.local_addr()?; + if local_addr.ip().is_unspecified() { + return Err(WebRtcError::InvalidInput( + "bind_ip must be a concrete interface address for ICE", + )); + } + let now = Instant::now(); + let mut rtc = RtcConfig::new().build(now); + let _ = rtc.add_local_candidate( + Candidate::host(local_addr, "udp") + .map_err(|error| WebRtcError::Rtc(error.to_string()))?, + ); + let mut change = rtc.sdp_api(); + let audio_mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None); + let channel_id = change.add_channel(DATA_CHANNEL_LABEL.into()); + let (offer, pending) = change.apply().ok_or(WebRtcError::Protocol( + "SDP changes did not produce an offer", + ))?; + let request = ProvisionRequest { + jsep: Jsep { + kind: "offer".into(), + sdp: offer.to_sdp_string(), + }, + channel_type: "local".into(), + voice_server_type: "webrtc".into(), + parcel_local_id: config.parcel_local_id, + }; + let mut response = tokio::time::timeout(config.timeout, signaling.provision(request)) + .await + .map_err(|_| WebRtcError::Timeout("voice provisioning"))??; + if response.answer_sdp.len() > MAX_SIGNALING_BYTES { + abandon_provisioned(&signaling, &mut response, config.timeout).await; + return Err(WebRtcError::Protocol("SDP answer exceeds 1 MiB")); + } + let answer = match SdpAnswer::from_sdp_string(&sanitize_remote_sdp(&response.answer_sdp)) { + Ok(answer) => answer, + Err(error) => { + abandon_provisioned(&signaling, &mut response, config.timeout).await; + return Err(WebRtcError::Rtc(error.to_string())); + } + }; + if let Err(error) = rtc.sdp_api().accept_answer(pending, answer) { + abandon_provisioned(&signaling, &mut response, config.timeout).await; + return Err(WebRtcError::Rtc(error.to_string())); + } + let complete = SignalingCompleteRequest { + voice_server_type: "webrtc".into(), + viewer_session: response.viewer_session.to_string(), + candidate: SignalingComplete { completed: true }, + }; + let complete_result = tokio::time::timeout(config.timeout, signaling.complete(complete)) + .await + .map_err(|_| WebRtcError::Timeout("ICE signaling completion")) + .and_then(|result| result); + if let Err(error) = complete_result { + abandon_provisioned(&signaling, &mut response, config.timeout).await; + return Err(error); + } + let (command_tx, command_rx) = mpsc::channel(64); + let (event_tx, event_rx) = mpsc::channel(256); + let (capture_tx, capture_rx) = mpsc::channel(32); + #[cfg(feature = "real-audio")] + let hardware_audio = match RealAudioBridge::open( + &config.input_device, + &config.output_device, + capture_tx, + &event_tx, + ) { + Ok(audio) => audio, + Err(error) => { + abandon_provisioned(&signaling, &mut response, config.timeout).await; + return Err(error); + } + }; + #[cfg(feature = "real-audio")] + let hardware_output = hardware_audio.output.clone(); + #[cfg(not(feature = "real-audio"))] + let hardware_output = (); + #[cfg(not(feature = "real-audio"))] + drop(capture_tx); + let snapshot = Arc::new(Mutex::new(SessionSnapshot { + selected_input: config.input_device, + selected_output: config.output_device, + active_tasks: 1, + ..SessionSnapshot::default() + })); + let run_snapshot = Arc::clone(&snapshot); + let task = tokio::spawn(async move { + Box::pin(run_client( + rtc, + socket, + audio_mid, + channel_id, + command_rx, + capture_rx, + event_tx, + run_snapshot, + hardware_output, + )) + .await; + }); + Ok(Self { + command: command_tx, + events: event_rx, + snapshot, + signaling, + viewer_session: response.viewer_session, + channel: response.channel, + credentials: response.credentials, + task: Some(task), + timeout: config.timeout, + #[cfg(feature = "real-audio")] + hardware_audio: Some(hardware_audio), + }) + } + + pub async fn next_event(&mut self) -> Option { + self.events.recv().await + } + + #[must_use] + pub fn snapshot(&self) -> SessionSnapshot { + lock(&self.snapshot).clone() + } + + pub async fn send_data(&self, message: impl Into) -> Result { + let message = message.into(); + if message.len() > MAX_DATA_BYTES { + return Err(WebRtcError::InvalidInput( + "data-channel message exceeds 64 KiB", + )); + } + let (reply_tx, reply_rx) = oneshot::channel(); + self.command + .send(Command::Data(message, reply_tx)) + .await + .map_err(|_| WebRtcError::Closed)?; + reply_rx.await.map_err(|_| WebRtcError::Closed)? + } + + pub async fn set_peer_mute(&self, peer: UUID, mute: bool) -> Result { + self.send_data(json!({"m": {peer.to_string(): mute}}).to_string()) + .await + } + + pub async fn set_peer_gain(&self, peer: UUID, gain: i32) -> Result { + self.send_data(json!({"ug": {peer.to_string(): gain.clamp(0, 220)}}).to_string()) + .await + } + + pub async fn send_position( + &self, + avatar: [f64; 3], + avatar_heading: [f64; 4], + listener: [f64; 3], + listener_heading: [f64; 4], + ) -> Result { + let scale3 = |v: [f64; 3]| json!({"x": (v[0] * 100.0).round() as i32, "y": (v[1] * 100.0).round() as i32, "z": (v[2] * 100.0).round() as i32}); + let scale4 = |v: [f64; 4]| json!({"x": (v[0] * 100.0).round() as i32, "y": (v[1] * 100.0).round() as i32, "z": (v[2] * 100.0).round() as i32, "w": (v[3] * 100.0).round() as i32}); + self.send_data( + json!({"sp": scale3(avatar), "sh": scale4(avatar_heading), "lp": scale3(listener), "lh": scale4(listener_heading)}).to_string(), + ) + .await + } + + pub async fn play_frames( + &self, + frames: Vec, + looping: bool, + ) -> Result<(), WebRtcError> { + if frames.is_empty() + || frames + .iter() + .any(|frame| frame.0.is_empty() || frame.0.len() > 4_000) + { + return Err(WebRtcError::InvalidInput("invalid Opus frame sequence")); + } + let (reply_tx, reply_rx) = oneshot::channel(); + self.command + .send(Command::Play(frames, looping, reply_tx)) + .await + .map_err(|_| WebRtcError::Closed)?; + reply_rx.await.map_err(|_| WebRtcError::Closed)? + } + + pub async fn play_wav(&self, path: &Path, looping: bool) -> Result<(), WebRtcError> { + self.play_frames(encode_wav(path)?, looping).await + } + + pub async fn stop_wav(&self) -> Result<(), WebRtcError> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.command + .send(Command::Stop(reply_tx)) + .await + .map_err(|_| WebRtcError::Closed)?; + reply_rx.await.map_err(|_| WebRtcError::Closed) + } + + /// Sends leave/logout, drains the peer run loop, and joins its only task. + pub async fn shutdown(&mut self) -> Result<(), WebRtcError> { + let _ = self.send_data("{\"l\":true}").await; + let close = CloseRequest { + logout: true, + voice_server_type: "webrtc".into(), + viewer_session: self.viewer_session.to_string(), + channel: self.channel.take(), + credentials: self.credentials.take(), + }; + let signal_result = + match tokio::time::timeout(self.timeout, self.signaling.close(close)).await { + Ok(result) => result, + Err(_) => Err(WebRtcError::Timeout("voice signaling teardown")), + }; + let (reply_tx, reply_rx) = oneshot::channel(); + let _ = self.command.send(Command::Shutdown(reply_tx)).await; + let _ = tokio::time::timeout(self.timeout, reply_rx).await; + let task_result = if let Some(mut task) = self.task.take() { + if let Ok(result) = tokio::time::timeout(self.timeout, &mut task).await { + result.map_err(|error| WebRtcError::Rtc(error.to_string())) + } else { + task.abort(); + let _ = task.await; + Err(WebRtcError::Timeout("WebRTC peer teardown")) + } + } else { + Ok(()) + }; + #[cfg(feature = "real-audio")] + self.hardware_audio.take(); + signal_result.and(task_result) + } +} + +async fn abandon_provisioned( + signaling: &Arc, + response: &mut ProvisionResponse, + timeout: Duration, +) { + let close = CloseRequest { + logout: true, + voice_server_type: "webrtc".into(), + viewer_session: response.viewer_session.to_string(), + channel: response.channel.take(), + credentials: response.credentials.take(), + }; + let _ = tokio::time::timeout(timeout, signaling.close(close)).await; +} + +impl Drop for WebRtcVoiceSession { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + let close = CloseRequest { + logout: true, + voice_server_type: "webrtc".into(), + viewer_session: self.viewer_session.to_string(), + channel: self.channel.take(), + credentials: self.credentials.take(), + }; + let signaling = Arc::clone(&self.signaling); + let command = self.command.clone(); + let timeout = self.timeout; + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let (reply_tx, reply_rx) = oneshot::channel(); + let _ = command.send(Command::Shutdown(reply_tx)).await; + let _ = tokio::time::timeout(timeout, reply_rx).await; + let _ = tokio::time::timeout(timeout, signaling.close(close)).await; + let mut task = task; + if tokio::time::timeout(timeout, &mut task).await.is_err() { + task.abort(); + let _ = task.await; + } + }); + } else { + task.abort(); + lock(&self.snapshot).active_tasks = 0; + } + } + } +} + +fn validate_device(id: &str, input: bool) -> Result<(), WebRtcError> { + let devices = audio_devices()?; + if devices + .iter() + .any(|device| device.id == id && device.input == input) + { + Ok(()) + } else { + Err(WebRtcError::InvalidInput( + "selected audio device is unavailable", + )) + } +} + +async fn run_client( + mut rtc: Rtc, + socket: UdpSocket, + audio_mid: Mid, + channel_id: ChannelId, + mut commands: mpsc::Receiver, + mut capture: mpsc::Receiver>, + events: mpsc::Sender, + snapshot: Arc>, + hardware_output: HardwareOutput, +) { + let mut buffer = vec![0_u8; 65_536]; + let mut playback: Option = None; + let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).ok(); + let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip).ok(); + let mut captured_elapsed = Duration::ZERO; + let mut running = true; + while running { + let deadline = match drain_client( + &mut rtc, + &socket, + channel_id, + &events, + &snapshot, + decoder.as_mut(), + &hardware_output, + ) + .await + { + Ok(value) => value, + Err(error) => { + let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await; + break; + } + }; + let audio_deadline = playback.as_ref().map(|value| value.next_frame); + let wake = audio_deadline.map_or(deadline, |value| value.min(deadline)); + tokio::select! { + command = commands.recv() => match command { + Some(Command::Data(message, reply)) => { + let result = rtc.channel(channel_id) + .ok_or(WebRtcError::Closed) + .and_then(|mut channel| channel.write(false, message.as_bytes()).map_err(|error| WebRtcError::Rtc(error.to_string()))); + let _ = reply.send(result); + } + Some(Command::Play(frames, looping, reply)) => { + playback = Some(Playback { frames, index: 0, looping, next_frame: Instant::now(), media_elapsed: Duration::ZERO }); + let _ = reply.send(Ok(())); + } + Some(Command::Stop(reply)) => { + playback = None; + let _ = reply.send(()); + } + Some(Command::Shutdown(reply)) => { + playback = None; + rtc.disconnect(); + let _ = drain_client(&mut rtc, &socket, channel_id, &events, &snapshot, decoder.as_mut(), &hardware_output).await; + let _ = reply.send(()); + running = false; + } + None => running = false, + }, + captured = capture.recv() => { + if let (Some(pcm), Some(codec)) = (captured, encoder.as_ref()) + && pcm.len() == FRAME_SAMPLES + { + let mut packet = vec![0_u8; 4_000]; + if let Ok(count) = codec.encode(&pcm, &mut packet) { + packet.truncate(count); + if write_opus(&mut rtc, audio_mid, &OpusFrame(packet), captured_elapsed).is_ok() { + lock(&snapshot).sent_audio_frames += 1; + } + captured_elapsed += FRAME_DURATION; + } + } + }, + receive = socket.recv_from(&mut buffer) => match receive { + Ok((count, source)) => { + let Ok(destination) = socket.local_addr() else { break }; + let Ok(contents) = buffer[..count].try_into() else { continue }; + let input = Input::Receive(Instant::now(), Receive { proto: Protocol::Udp, source, destination, contents }); + if let Err(error) = rtc.handle_input(input) { + let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await; + break; + } + } + Err(error) => { + let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await; + break; + } + }, + () = tokio::time::sleep_until(tokio::time::Instant::from_std(wake)) => { + let now = Instant::now(); + if audio_deadline.is_some_and(|value| value <= now) { + if let Some(active) = playback.as_mut() + && let Some(frame) = active.frames.get(active.index) + { + let result = write_opus(&mut rtc, audio_mid, frame, active.media_elapsed); + if result.is_ok() { + lock(&snapshot).sent_audio_frames += 1; + } + active.index += 1; + active.media_elapsed += FRAME_DURATION; + active.next_frame += FRAME_DURATION; + if active.index == active.frames.len() { + if active.looping { active.index = 0; } else { playback = None; } + } + } + } else if let Err(error) = rtc.handle_input(Input::Timeout(now)) { + let _ = events.send(VoiceEvent::Diagnostic(error.to_string())).await; + break; + } + } + } + } + { + let mut state = lock(&snapshot); + state.connected = false; + state.data_channel_ready = false; + state.active_tasks = 0; + } + let _ = events.send(VoiceEvent::Closed).await; +} + +fn write_opus( + rtc: &mut Rtc, + mid: Mid, + frame: &OpusFrame, + elapsed: Duration, +) -> Result<(), WebRtcError> { + let writer = rtc.writer(mid).ok_or(WebRtcError::Closed)?; + let pt = writer + .payload_params() + .find(|params| params.spec().codec == Codec::Opus) + .map(str0m::format::PayloadParams::pt) + .ok_or(WebRtcError::Protocol("Opus was not negotiated"))?; + writer + .audio_level(-30, true) + .write( + pt, + Instant::now(), + MediaTime::from(elapsed), + frame.0.clone(), + ) + .map_err(|error| WebRtcError::Rtc(error.to_string())) +} + +async fn drain_client( + rtc: &mut Rtc, + socket: &UdpSocket, + channel_id: ChannelId, + events: &mpsc::Sender, + snapshot: &Arc>, + decoder: Option<&mut Decoder>, + hardware_output: &HardwareOutput, +) -> Result { + #[cfg(not(feature = "real-audio"))] + let () = hardware_output; + let mut decoder = decoder; + loop { + match rtc + .poll_output() + .map_err(|error| WebRtcError::Rtc(error.to_string()))? + { + Output::Timeout(deadline) => return Ok(deadline), + Output::Transmit(transmit) => { + socket + .send_to(&transmit.contents, transmit.destination) + .await?; + } + Output::Event(event) => match event { + Event::Connected => { + lock(snapshot).connected = true; + let _ = events.send(VoiceEvent::Connected).await; + } + Event::IceConnectionStateChange(state) => { + lock(snapshot).connected = matches!( + state, + IceConnectionState::Connected | IceConnectionState::Completed + ); + let _ = events.send(VoiceEvent::IceState(state)).await; + } + Event::ChannelOpen(id, label) + if id == channel_id && label == DATA_CHANNEL_LABEL => + { + lock(snapshot).data_channel_ready = true; + if let Some(mut channel) = rtc.channel(id) { + channel + .write(false, b"{\"j\":{\"p\":true}}") + .map_err(|error| WebRtcError::Rtc(error.to_string()))?; + } + let _ = events.send(VoiceEvent::DataChannelReady).await; + } + Event::ChannelData(data) if !data.binary => { + if data.data.len() <= MAX_DATA_BYTES { + let text = std::str::from_utf8(&data.data).map_err(|_| { + WebRtcError::Protocol("data channel text was not UTF-8") + })?; + let replies = process_peer_message(text, snapshot, events).await?; + for reply in replies { + if let Some(mut channel) = rtc.channel(channel_id) { + let _ = channel.write(false, reply.as_bytes()); + } + } + } + } + Event::MediaData(media) if media.params.spec().codec == Codec::Opus => { + if let Some(codec) = decoder.as_deref_mut() { + let mut pcm = vec![0_i16; FRAME_SAMPLES * 6]; + if let Ok(count) = + codec.decode(Some(media.data.as_ref()), pcm.as_mut_slice(), false) + { + pcm.truncate(count); + let peak = i16::try_from( + pcm.iter() + .map(|value| value.unsigned_abs()) + .max() + .unwrap_or(0) + .min(i16::MAX as u16), + ) + .unwrap_or(i16::MAX); + { + let mut state = lock(snapshot); + state.received_audio_frames += 1; + state.decoded_samples += count as u64; + } + let _ = events + .send(VoiceEvent::AudioFrame { + samples: count, + peak, + }) + .await; + #[cfg(feature = "real-audio")] + if let Some(output) = hardware_output { + let mut output = lock(output); + let remaining = 96_000_usize.saturating_sub(output.samples.len()); + output.samples.extend(pcm.into_iter().take(remaining)); + } + } + } + } + Event::ChannelClose(id) if id == channel_id => { + lock(snapshot).data_channel_ready = false; + } + Event::Closed => return Err(WebRtcError::Closed), + _ => {} + }, + } + } +} + +async fn process_peer_message( + message: &str, + snapshot: &Arc>, + events: &mpsc::Sender, +) -> Result, WebRtcError> { + let root: Value = serde_json::from_str(message) + .map_err(|_| WebRtcError::Protocol("malformed SLData JSON"))?; + let object = root + .as_object() + .ok_or(WebRtcError::Protocol("SLData JSON must be an object"))?; + let mut replies = Vec::new(); + if object.get("ping").and_then(Value::as_bool) == Some(true) { + replies.push("{\"pong\":true}".into()); + } + if let Some(map) = object.get("m").and_then(Value::as_object) { + let values = parse_bool_map(map); + if !values.is_empty() { + let _ = events.send(VoiceEvent::MuteMap(values)).await; + } + } + if let Some(map) = object.get("ug").and_then(Value::as_object) { + let values = parse_int_map(map); + if !values.is_empty() { + let _ = events.send(VoiceEvent::GainMap(values)).await; + } + } + let all_peers = !object.is_empty() && object.keys().all(|key| parse_uuid(key).is_some()); + if all_peers { + for (key, value) in object { + let Some(peer) = parse_uuid(key) else { + continue; + }; + let Some(map) = value.as_object() else { + lock(snapshot).known_peers.remove(&peer); + continue; + }; + let state = PeerAudioState { + power: map.get("p").and_then(as_i32), + voice_active: map.get("V").or_else(|| map.get("v")).and_then(as_bool), + moderator_muted: map.get("m").and_then(as_bool), + joined_primary: map + .get("j") + .and_then(Value::as_object) + .and_then(|join| join.get("p")) + .and_then(as_bool), + left: map.get("l").and_then(as_bool).unwrap_or(false), + ssrc: map + .get("s") + .or_else(|| map.get("ssrc")) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + }; + if state.left { + lock(snapshot).known_peers.remove(&peer); + } else { + lock(snapshot).known_peers.insert(peer); + } + let _ = events.send(VoiceEvent::PeerAudio(peer, state)).await; + if let Some(position) = parse_position(map) { + let _ = events.send(VoiceEvent::PeerPosition(peer, position)).await; + } + } + } else if let Some(join) = object.get("j").and_then(Value::as_object) + && let Some(peer) = join.get("id").and_then(Value::as_str).and_then(parse_uuid) + { + lock(snapshot).known_peers.insert(peer); + } + Ok(replies) +} + +fn parse_uuid(value: &str) -> Option { + UUID::new_with_string(value.to_owned()).ok() +} + +fn parse_bool_map(map: &Map) -> HashMap { + map.iter() + .filter_map(|(key, value)| Some((parse_uuid(key)?, as_bool(value)?))) + .collect() +} + +fn parse_int_map(map: &Map) -> HashMap { + map.iter() + .filter_map(|(key, value)| Some((parse_uuid(key)?, as_i32(value)?))) + .collect() +} + +fn as_bool(value: &Value) -> Option { + value + .as_bool() + .or_else(|| value.as_i64().map(|number| number != 0)) + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) +} + +fn as_i32(value: &Value) -> Option { + value + .as_i64() + .and_then(|number| i32::try_from(number).ok()) + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) +} + +fn parse_position(map: &Map) -> Option { + let result = AvatarPosition { + sender_position: map.get("sp").and_then(parse_int3), + sender_heading: map.get("sh").and_then(parse_int4), + listener_position: map.get("lp").and_then(parse_int3), + listener_heading: map.get("lh").and_then(parse_int4), + }; + (result.sender_position.is_some() + || result.sender_heading.is_some() + || result.listener_position.is_some() + || result.listener_heading.is_some()) + .then_some(result) +} + +fn parse_int3(value: &Value) -> Option { + let map = value.as_object()?; + Some(Int3 { + x: as_i32(map.get("x")?)?, + y: as_i32(map.get("y")?)?, + z: as_i32(map.get("z")?)?, + }) +} + +fn parse_int4(value: &Value) -> Option { + let map = value.as_object()?; + Some(Int4 { + x: as_i32(map.get("x")?)?, + y: as_i32(map.get("y")?)?, + z: as_i32(map.get("z")?)?, + w: as_i32(map.get("w")?)?, + }) +} + +fn sanitize_remote_sdp(sdp: &str) -> String { + sdp.lines() + .filter(|line| { + if !line.starts_with("a=candidate:") { + return true; + } + let fields: Vec<_> = line.split_whitespace().collect(); + fields.get(5).is_none_or(|port| *port != "0") + }) + .collect::>() + .join("\r\n") + + "\r\n" +} + +fn lock(value: &Mutex) -> std::sync::MutexGuard<'_, T> { + value + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(feature = "real-audio")] +struct RealAudioBridge { + _input: Option, + _output: Option, + output: Option>>, +} + +#[cfg(feature = "real-audio")] +struct RealOutputState { + samples: VecDeque, + current: i16, + phase: u64, + output_rate: u32, + channels: usize, +} + +#[cfg(feature = "real-audio")] +type HardwareOutput = Option>>; +#[cfg(not(feature = "real-audio"))] +type HardwareOutput = (); + +#[cfg(feature = "real-audio")] +impl RealAudioBridge { + fn open( + input_id: &str, + output_id: &str, + capture: mpsc::Sender>, + events: &mpsc::Sender, + ) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + let host = cpal::default_host(); + let input = if input_id == "virtual:microphone" { + None + } else { + let id = input_id + .strip_prefix("cpal:input:") + .ok_or(WebRtcError::InvalidInput("invalid CPAL input device ID"))?; + let device = host + .input_devices() + .map_err(|error| WebRtcError::Audio(error.to_string()))? + .find(|device| { + device + .id() + .ok() + .as_ref() + .is_some_and(|value| value.to_string() == id) + }) + .ok_or(WebRtcError::InvalidInput( + "selected CPAL input device disappeared", + ))?; + let config = device + .default_input_config() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + let channels = usize::from(config.channels()); + let input_rate = config.sample_rate(); + let stream_config = config.config(); + let stream = match config.sample_format() { + cpal::SampleFormat::I16 => build_input::( + &device, + &stream_config, + channels, + input_rate, + capture, + events.clone(), + )?, + cpal::SampleFormat::F32 => build_input::( + &device, + &stream_config, + channels, + input_rate, + capture, + events.clone(), + )?, + cpal::SampleFormat::U16 => build_input::( + &device, + &stream_config, + channels, + input_rate, + capture, + events.clone(), + )?, + format => { + return Err(WebRtcError::Audio(format!( + "unsupported capture sample format {format}" + ))); + } + }; + stream + .play() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + Some(stream) + }; + + let (output, output_state) = if output_id == "virtual:speaker" { + (None, None) + } else { + let id = output_id + .strip_prefix("cpal:output:") + .ok_or(WebRtcError::InvalidInput("invalid CPAL output device ID"))?; + let device = host + .output_devices() + .map_err(|error| WebRtcError::Audio(error.to_string()))? + .find(|device| { + device + .id() + .ok() + .as_ref() + .is_some_and(|value| value.to_string() == id) + }) + .ok_or(WebRtcError::InvalidInput( + "selected CPAL output device disappeared", + ))?; + let config = device + .default_output_config() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + let state = Arc::new(Mutex::new(RealOutputState { + samples: VecDeque::with_capacity(96_000), + current: 0, + phase: 0, + output_rate: config.sample_rate(), + channels: usize::from(config.channels()), + })); + let stream_config = config.config(); + let stream = match config.sample_format() { + cpal::SampleFormat::I16 => build_output::( + &device, + &stream_config, + Arc::clone(&state), + events.clone(), + )?, + cpal::SampleFormat::F32 => build_output::( + &device, + &stream_config, + Arc::clone(&state), + events.clone(), + )?, + cpal::SampleFormat::U16 => build_output::( + &device, + &stream_config, + Arc::clone(&state), + events.clone(), + )?, + format => { + return Err(WebRtcError::Audio(format!( + "unsupported playback sample format {format}" + ))); + } + }; + stream + .play() + .map_err(|error| WebRtcError::Audio(error.to_string()))?; + (Some(stream), Some(state)) + }; + Ok(Self { + _input: input, + _output: output, + output: output_state, + }) + } +} + +#[cfg(feature = "real-audio")] +fn build_input( + device: &cpal::Device, + config: &cpal::StreamConfig, + channels: usize, + input_rate: u32, + sender: mpsc::Sender>, + events: mpsc::Sender, +) -> Result +where + T: cpal::SizedSample, + f32: cpal::FromSample, +{ + use cpal::Sample as _; + use cpal::traits::DeviceTrait; + let mut phase = 0_u64; + let mut frame = Vec::with_capacity(FRAME_SAMPLES); + device + .build_input_stream( + *config, + move |data: &[T], _| { + for input_frame in data.chunks(channels) { + let mono = input_frame + .iter() + .copied() + .map(f32::from_sample) + .sum::() + / input_frame.len().max(1) as f32; + phase += u64::from(SAMPLE_RATE); + while phase >= u64::from(input_rate) { + phase -= u64::from(input_rate); + frame.push(i16::from_sample(mono.clamp(-1.0, 1.0))); + if frame.len() == FRAME_SAMPLES { + let _ = sender.try_send(std::mem::replace( + &mut frame, + Vec::with_capacity(FRAME_SAMPLES), + )); + } + } + } + }, + move |error| { + let _ = events.try_send(VoiceEvent::Diagnostic(format!( + "capture stream failed: {error}" + ))); + }, + None, + ) + .map_err(|error| WebRtcError::Audio(error.to_string())) +} + +#[cfg(feature = "real-audio")] +fn build_output( + device: &cpal::Device, + config: &cpal::StreamConfig, + state: Arc>, + events: mpsc::Sender, +) -> Result +where + T: cpal::SizedSample + cpal::FromSample, +{ + use cpal::traits::DeviceTrait; + device + .build_output_stream( + *config, + move |data: &mut [T], _| { + let mut state = lock(&state); + for output_frame in data.chunks_mut(state.channels) { + let sample = T::from_sample(state.current); + for output in output_frame { + *output = sample; + } + state.phase += u64::from(SAMPLE_RATE); + while state.phase >= u64::from(state.output_rate) { + state.phase -= u64::from(state.output_rate); + state.current = state.samples.pop_front().unwrap_or(0); + } + } + }, + move |error| { + let _ = events.try_send(VoiceEvent::Diagnostic(format!( + "playback stream failed: {error}" + ))); + }, + None, + ) + .map_err(|error| WebRtcError::Audio(error.to_string())) +} + +/// Deterministic native WebRTC answerer used by CI and offline diagnostics. +pub struct LoopbackSignaling { + state: tokio::sync::Mutex, + active_tasks: Arc, + received_messages: Arc>>, + peer_id: UUID, +} + +struct LoopbackState { + shutdown: Option>, + task: Option>, + completed: bool, + closed: bool, +} + +impl LoopbackSignaling { + #[must_use] + pub fn new(peer_id: UUID) -> Arc { + Arc::new(Self { + state: tokio::sync::Mutex::new(LoopbackState { + shutdown: None, + task: None, + completed: false, + closed: false, + }), + active_tasks: Arc::new(AtomicUsize::new(0)), + received_messages: Arc::new(Mutex::new(Vec::new())), + peer_id, + }) + } + + #[must_use] + pub fn active_tasks(&self) -> usize { + self.active_tasks.load(Ordering::Acquire) + } + + #[must_use] + pub fn received_messages(&self) -> Vec { + lock(&self.received_messages).clone() + } + + pub async fn wait_closed(&self, timeout: Duration) -> Result<(), WebRtcError> { + tokio::time::timeout(timeout, async { + loop { + if self.active_tasks() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|_| WebRtcError::Timeout("fake signaling teardown")) + } +} + +impl VoiceSignaling for LoopbackSignaling { + fn provision(&self, request: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse> { + Box::pin(async move { + let mut state = self.state.lock().await; + if state.task.is_some() { + return Err(WebRtcError::Signaling("duplicate provision request".into())); + } + if request.jsep.kind != "offer" + || request.channel_type != "local" + || request.voice_server_type != "webrtc" + { + return Err(WebRtcError::Protocol("invalid WebRTC provision request")); + } + let offer = SdpOffer::from_sdp_string(&request.jsep.sdp) + .map_err(|error| WebRtcError::Rtc(error.to_string()))?; + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let local_addr = socket.local_addr()?; + let mut rtc = RtcConfig::new().build(Instant::now()); + let _ = rtc.add_local_candidate( + Candidate::host(local_addr, "udp") + .map_err(|error| WebRtcError::Rtc(error.to_string()))?, + ); + let answer = rtc + .sdp_api() + .accept_offer(offer) + .map_err(|error| WebRtcError::Rtc(error.to_string()))?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let active = Arc::clone(&self.active_tasks); + let messages = Arc::clone(&self.received_messages); + let peer_id = self.peer_id; + let viewer_session = UUID::random() + .map_err(|_| WebRtcError::Signaling("could not create session id".into()))?; + let credentials = VoiceSecret::new("offline-secret")?; + active.fetch_add(1, Ordering::AcqRel); + let task = tokio::spawn(async move { + Box::pin(run_loopback_peer( + rtc, + socket, + shutdown_rx, + messages, + peer_id, + active, + )) + .await; + }); + state.shutdown = Some(shutdown_tx); + state.task = Some(task); + Ok(ProvisionResponse { + answer_sdp: answer.to_sdp_string(), + viewer_session, + channel: Some("offline-local".into()), + credentials: Some(credentials), + }) + }) + } + + fn complete(&self, request: SignalingCompleteRequest) -> SignalFuture<'_, ()> { + Box::pin(async move { + if request.voice_server_type != "webrtc" + || !request.candidate.completed + || parse_uuid(&request.viewer_session).is_none() + { + return Err(WebRtcError::Protocol("invalid ICE completion request")); + } + self.state.lock().await.completed = true; + Ok(()) + }) + } + + fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()> { + Box::pin(async move { + if !request.logout + || request.voice_server_type != "webrtc" + || parse_uuid(&request.viewer_session).is_none() + { + return Err(WebRtcError::Protocol("invalid voice close request")); + } + let (shutdown, task) = { + let mut state = self.state.lock().await; + if !state.completed { + return Err(WebRtcError::Protocol( + "voice session closed before signaling completion", + )); + } + state.closed = true; + (state.shutdown.take(), state.task.take()) + }; + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); + } + if let Some(task) = task { + let _ = task.await; + } + Ok(()) + }) + } +} + +async fn run_loopback_peer( + mut rtc: Rtc, + socket: UdpSocket, + mut shutdown: oneshot::Receiver<()>, + messages: Arc>>, + peer_id: UUID, + active: Arc, +) { + let mut buffer = vec![0_u8; 65_536]; + let mut running = true; + let mut channel_id = None; + while running { + let deadline = loop { + match rtc.poll_output() { + Ok(Output::Timeout(deadline)) => break deadline, + Ok(Output::Transmit(transmit)) => { + let _ = socket + .send_to(&transmit.contents, transmit.destination) + .await; + } + Ok(Output::Event(Event::ChannelOpen(id, _))) => { + channel_id = Some(id); + if let Some(mut channel) = rtc.channel(id) { + let body = json!({ + peer_id.to_string(): { + "p": 42, "V": true, "m": false, "s": 777_u32, + "j": {"p": true}, + "sp": {"x": 100, "y": 200, "z": 300}, + "sh": {"x": 0, "y": 0, "z": 0, "w": 100} + } + }) + .to_string(); + let _ = channel.write(false, body.as_bytes()); + let _ = channel.write( + false, + json!({"m": {peer_id.to_string(): false}}) + .to_string() + .as_bytes(), + ); + let _ = channel.write( + false, + json!({"ug": {peer_id.to_string(): 180}}) + .to_string() + .as_bytes(), + ); + let _ = channel.write(false, b"{\"ping\":true}"); + } + } + Ok(Output::Event(Event::ChannelData(data))) if !data.binary => { + if let Ok(text) = String::from_utf8(data.data) { + lock(&messages).push(text); + } + } + Ok(Output::Event(Event::MediaData(media))) + if media.params.spec().codec == Codec::Opus => + { + let pt = rtc + .writer(media.mid) + .and_then(|writer| writer.match_params(media.params)); + if let Some(pt) = pt + && let Some(writer) = rtc.writer(media.mid) + { + let _ = + writer.write(pt, media.network_time, media.time, media.data.clone()); + } + } + Ok(Output::Event(Event::Closed)) | Err(_) => { + running = false; + break Instant::now(); + } + Ok(Output::Event(_)) => {} + } + }; + if !running { + break; + } + tokio::select! { + _ = &mut shutdown => { rtc.disconnect(); running = false; } + receive = socket.recv_from(&mut buffer) => if let Ok((count, source)) = receive + && let (Ok(destination), Ok(contents)) = (socket.local_addr(), buffer[..count].try_into()) + { + let _ = rtc.handle_input(Input::Receive(Instant::now(), Receive { proto: Protocol::Udp, source, destination, contents })); + }, + () = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { let _ = rtc.handle_input(Input::Timeout(Instant::now())); } + } + } + if let Some(id) = channel_id.and_then(|id| rtc.channel(id).map(|_| id)) { + let _ = id; + } + active.fetch_sub(1, Ordering::AcqRel); +} + +#[cfg(test)] +mod tests { + use super::*; + + struct InvalidAnswerSignaling { + close_calls: AtomicUsize, + } + + impl VoiceSignaling for InvalidAnswerSignaling { + fn provision(&self, _: ProvisionRequest) -> SignalFuture<'_, ProvisionResponse> { + Box::pin(async { + Ok(ProvisionResponse { + answer_sdp: "not an SDP answer".into(), + viewer_session: UUID::new_with_string( + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".into(), + ) + .unwrap(), + channel: Some("provisioned-channel".into()), + credentials: Some(VoiceSecret::new("provisioned-secret").unwrap()), + }) + }) + } + + fn complete(&self, _: SignalingCompleteRequest) -> SignalFuture<'_, ()> { + Box::pin(async { Err(WebRtcError::Protocol("completion must not run")) }) + } + + fn close(&self, request: CloseRequest) -> SignalFuture<'_, ()> { + Box::pin(async move { + assert!(request.logout); + assert!(request.credentials.is_some()); + self.close_calls.fetch_add(1, Ordering::AcqRel); + Ok(()) + }) + } + } + + fn wav_fixture() -> Vec { + let mut cursor = Cursor::new(Vec::new()); + { + let spec = hound::WavSpec { + channels: 2, + sample_rate: 24_000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::new(&mut cursor, spec).unwrap(); + for index in 0..2_400 { + let sample = (((index as f32 / 24_000.0) * 440.0 * std::f32::consts::TAU).sin() + * 8_000.0) as i16; + writer.write_sample(sample).unwrap(); + writer.write_sample(sample).unwrap(); + } + writer.finalize().unwrap(); + } + cursor.into_inner() + } + + #[test] + fn wav_is_resampled_and_encoded_as_valid_opus() { + let frames = encode_wav_bytes(&wav_fixture()).unwrap(); + assert_eq!(frames.len(), 5); + let mut decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono).unwrap(); + let mut samples = vec![0_i16; FRAME_SAMPLES * 6]; + assert_eq!( + decoder + .decode(Some(frames[0].0.as_slice()), samples.as_mut_slice(), false) + .unwrap(), + FRAME_SAMPLES + ); + } + + #[tokio::test] + async fn loopback_exercises_secure_peer_data_media_and_teardown() { + let peer = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap(); + let signaling = LoopbackSignaling::new(peer); + let mut session = WebRtcVoiceSession::connect( + signaling.clone(), + VoiceSessionConfig { + timeout: Duration::from_secs(10), + ..VoiceSessionConfig::default() + }, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while !session.snapshot().data_channel_ready { + assert!(session.next_event().await.is_some()); + } + }) + .await + .unwrap(); + let frames = encode_wav_bytes(&wav_fixture()).unwrap(); + session.play_frames(frames, false).await.unwrap(); + session.set_peer_mute(peer, true).await.unwrap(); + session.set_peer_gain(peer, 500).await.unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let state = session.snapshot(); + if state.data_channel_ready + && state.known_peers.contains(&peer) + && state.received_audio_frames > 0 + { + break; + } + let _ = session.next_event().await; + } + }) + .await + .unwrap(); + session.shutdown().await.unwrap(); + signaling.wait_closed(Duration::from_secs(2)).await.unwrap(); + assert_eq!(session.snapshot().active_tasks, 0); + assert!( + signaling + .received_messages() + .iter() + .any(|message| message.contains("\"ug\"") && message.contains("220")) + ); + } + + #[tokio::test] + async fn dropping_session_cancels_peer_and_signaling_tasks() { + let peer = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap(); + let signaling = LoopbackSignaling::new(peer); + let session = WebRtcVoiceSession::connect( + signaling.clone(), + VoiceSessionConfig { + timeout: Duration::from_secs(10), + ..VoiceSessionConfig::default() + }, + ) + .await + .unwrap(); + let snapshot = Arc::clone(&session.snapshot); + drop(session); + signaling.wait_closed(Duration::from_secs(2)).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while lock(&snapshot).active_tasks != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(signaling.active_tasks(), 0); + } + + #[tokio::test] + async fn failed_answer_closes_provisioned_server_session() { + let signaling = Arc::new(InvalidAnswerSignaling { + close_calls: AtomicUsize::new(0), + }); + let result = WebRtcVoiceSession::connect( + signaling.clone(), + VoiceSessionConfig { + timeout: Duration::from_secs(2), + ..VoiceSessionConfig::default() + }, + ) + .await; + assert!(matches!(result, Err(WebRtcError::Rtc(_)))); + assert_eq!(signaling.close_calls.load(Ordering::Acquire), 1); + } + + #[test] + fn secrets_and_protocol_shapes_are_safe() { + assert_eq!( + format!("{:?}", VoiceSecret::new("token").unwrap()), + "VoiceSecret()" + ); + let request = SignalingCompleteRequest { + voice_server_type: "webrtc".into(), + viewer_session: "id".into(), + candidate: SignalingComplete { completed: true }, + }; + assert_eq!( + serde_json::to_value(request).unwrap()["candidate"]["completed"], + true + ); + } +} diff --git a/docs/webrtc.md b/docs/webrtc.md new file mode 100644 index 0000000..4ee3687 --- /dev/null +++ b/docs/webrtc.md @@ -0,0 +1,80 @@ +# Native WebRTC voice validation + +The `libremetaverse-voice-webrtc` native API owns the complete WebRTC voice +path. `WebRtcVoiceSession::connect` binds a caller-selected interface, creates +an Opus send/receive m-line and ordered `SLData` channel, posts the grid's +provision body through a `VoiceSignaling` implementation, applies the SDP +answer, and drives ICE, DTLS, SRTP, RTP, and SCTP from a single async run loop. +There is no CLR, C# process, RPC bridge, proprietary SDK, or platform-specific +fallback. + +The grid wire shapes match the viewer protocol: + +- Initial provisioning uses `jsep.type=offer`, `channel_type=local`, and + `voice_server_type=webrtc`, with an optional top-level `parcel_local_id`. +- ICE completion uses the singular `candidate: { completed: true }` field and + the opaque viewer-session ID. +- Teardown sends `logout=true`, then disconnects and joins the local peer loop. + +Capability URLs, SDP, channel credentials, login secrets, and viewer-session +credentials are not printed. `VoiceSecret` always formats as ``. +Incoming SDP and `SLData` messages are bounded and validated before use. + +## Deterministic validation + +`webrtc-test --fake` is the CI path. `LoopbackSignaling` accepts the real offer, +creates a second native peer, and exchanges UDP ICE checks, DTLS, encrypted +Opus RTP, and SCTP `SLData` on loopback. It publishes peer audio/position and +mute/gain maps; the client replies with join, mute, clamped gain, position, +ping/pong, and leave. A generated virtual 48 kHz tone is encoded with libopus, +echoed by the remote peer, decoded into the virtual sink, and counted. Shutdown +awaits both peers and reports zero peer/audio tasks. + +WAV playback accepts bounded integer or float PCM, downmixes all channels, +linearly resamples to 48 kHz mono, encodes 20 ms Opus frames, and paces them on +the same peer loop. This keeps virtual-audio tests deterministic and prevents +orphan playback tasks. + +## Native prerequisites + +The default virtual path requires native libopus development files at build +time. Typical packages are: + +- Ubuntu/Debian: `libopus-dev` (and `pkg-config`). +- Fedora: `opus-devel`. +- Windows MSVC: `audiopus` supplies supported prebuilt Opus libraries; a custom + libopus can be selected with `OPUS_LIB_DIR`/`LIBOPUS_LIB_DIR`. +- macOS: install `opus` with the system package manager when it is not already + discoverable by `pkg-config`. + +Real hardware is separately opt-in. Build the program with +`--features real-audio`; CPAL uses ALSA on Linux, WASAPI on Windows, and +CoreAudio on macOS. Linux builders need ALSA development headers (for example +`libasound2-dev`). List stable endpoint IDs with `webrtc-test --list-devices`, +then pass `--input-device cpal:input:...` and/or +`--output-device cpal:output:...`. Selected capture and render streams are +opened only during the session and dropped after the peer loop is joined. + +## Live grid gate + +Credential-only capability validation: + +```sh +webrtc-test --allow-live-login --confirm-live-login LOGIN +``` + +The command reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and +`GRID_LOGIN_URL` from the process environment or the workspace `.env`, matching +the compatibility-test convention. Positional names/password and the legacy +`GRID_FIRST_NAME`/`GRID_LAST_NAME` pair remain supported. Merely having a +credential file never permits a login: both command-line confirmations above +are still mandatory. + +Creating a live voice session and sending/receiving media requires the separate +`--allow-session-audio` flag. `--bind-ip` can select the concrete interface +advertised in the host ICE candidate; otherwise the program discovers the +preferred route without sending a packet. `--wav FILE` starts looping microphone +playback. Interactive commands preserve the upstream tool's `peers`, `mute`, +`gain`, `playwav`, `stopwav`, and `quit` controls; `--commands FILE` supplies the +same commands non-interactively. Grid logout and voice teardown run even when +the validation body fails. diff --git a/programs/Cargo.toml b/programs/Cargo.toml index 380234a..47e3719 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -13,6 +13,7 @@ libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features = libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] } libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" } libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" } +libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" } regex = "1.12" roxmltree = "0.21.1" tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } @@ -20,6 +21,10 @@ tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "r [lints] workspace = true +[features] +default = [] +real-audio = ["libremetaverse-voice-webrtc/real-audio"] + [[bin]] name = "irc-gateway" path = "src/bin/irc_gateway.rs" diff --git a/programs/README.md b/programs/README.md index 22af26b..98f9bfb 100644 --- a/programs/README.md +++ b/programs/README.md @@ -15,7 +15,43 @@ here so a source entry is never mistaken for a completed port. | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | | `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 | | `vivox-test` | VivoxTest | Implemented with gated live validation and a scripted fake TCP/control service | -| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | +| `webrtc-test` | WebRtcTest | Implemented with gated live WebRTC and a deterministic secure loopback peer | + +## WebRtcTest + +`webrtc-test` uses the native `libremetaverse-voice-webrtc` adapter. Its +`str0m` peer performs ICE, DTLS, SRTP, ordered SCTP `SLData`, SDP offer/answer, +Opus RTP, peer mute/gain/position messages, WAV microphone playback, diagnostics, +and joined teardown. It does not invoke the CLR or upstream C# program. + +The hardware-independent gate is: + +```sh +webrtc-test --fake --timeout-seconds 10 +``` + +This creates two native peers on IPv4 loopback, exchanges an actual encrypted +WebRTC session, sends and decodes native-libopus audio, validates peer maps and +controls, and proves both peer/audio task counts return to zero. `--list-devices` +always reports the virtual endpoints. A build with `--features real-audio` +also reports CPAL endpoints and `--input-device`/`--output-device` opens the +selected hardware streams; capture and playback are converted to/from 48 kHz +mono without changing the WebRTC protocol. + +Live login requires `--allow-live-login --confirm-live-login LOGIN`. It only +checks the redacted voice capabilities unless `--allow-session-audio` is also +present. It reads `GRID_USER="First Last"`, `GRID_PASSWORD`, and +`GRID_LOGIN_URL` from the environment or workspace `.env`; positional values +and `GRID_FIRST_NAME`/`GRID_LAST_NAME` remain supported. See `docs/webrtc.md` +for native prerequisites and the full command/gate matrix. + +Run the isolated checks with: + +```sh +cargo test -p libremetaverse-voice-webrtc --lib native::tests +cargo test -p libremetaverse-programs --test webrtc_test_cli +cargo test --manifest-path tests/compat/Cargo.toml --test webrtc_protocol_semantics +``` ## VivoxTest diff --git a/programs/src/bin/webrtc_test.rs b/programs/src/bin/webrtc_test.rs index 9334aef..e523604 100644 --- a/programs/src/bin/webrtc_test.rs +++ b/programs/src/bin/webrtc_test.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("WebRtcTest") + libremetaverse_programs::webrtc_test::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index f135b26..332fe7b 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -9,5 +9,6 @@ pub mod prim_inspector; pub mod simple_bot; pub mod test_client; pub mod vivox_test; +pub mod webrtc_test; pub use libremetaverse::shim::pending_program; diff --git a/programs/src/webrtc_test.rs b/programs/src/webrtc_test.rs new file mode 100644 index 0000000..6841308 --- /dev/null +++ b/programs/src/webrtc_test.rs @@ -0,0 +1,801 @@ +//! Credential-safe native port of the `WebRtcTest` validation program. + +#![allow(clippy::cast_possible_truncation)] // The bounded test tone fits exactly in i16. +#![allow(clippy::cast_precision_loss)] // Tone synthesis intentionally uses f32 sample positions. +#![allow(clippy::struct_excessive_bools)] // Independent CLI safety gates are clearer as flags. + +use clap::Parser; +use libremetaverse::types::compat::{CancellationToken, Uri}; +use libremetaverse::{GridClient, NetworkManager, Simulator}; +use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; +use libremetaverse_voice_webrtc::{ + CloseRequest, LoopbackSignaling, ProvisionRequest, ProvisionResponse, SignalingCompleteRequest, + VoiceEvent, VoiceSecret, VoiceSessionConfig, VoiceSignaling, WebRtcError, WebRtcVoiceSession, + audio_devices, encode_pcm_48k_mono, +}; +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::ExitCode; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncBufReadExt as _; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_INPUT: u8 = 3; +pub const EXIT_SERVICE: u8 = 4; +const LIVE_CONFIRMATION: &str = "LOGIN"; +const MAX_CAPABILITY_BYTES: usize = 1024 * 1024; + +#[derive(Parser)] +#[command( + name = "webrtc-test", + version, + about = "Validate native LibreMetaverse WebRTC voice, Opus audio, and SLData behavior", + arg_required_else_help = true, + after_help = "CI/offline: --fake runs a real in-process ICE/DTLS/SRTP/SCTP peer with virtual 48 kHz audio. Live grid login requires --allow-live-login --confirm-live-login LOGIN; joining voice additionally requires --allow-session-audio. Live credentials use GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL from the environment or workspace .env. Real hardware enumeration requires building with --features real-audio and remains opt-in through --input-device/--output-device. Native libopus development files are required. Prefer GRID_PASSWORD over a positional password." +)] +struct Cli { + /// Avatar first name; `GRID_FIRST_NAME` is the fallback. + #[arg(value_name = "FIRSTNAME")] + first_name: Option, + /// Avatar last name; `GRID_LAST_NAME` is the fallback. + #[arg(value_name = "LASTNAME")] + last_name: Option, + /// Avatar password; prefer `GRID_PASSWORD` to avoid process-list exposure. + #[arg(value_name = "PASSWORD")] + password: Option, + + /// Run the full deterministic loopback signaling, peer, media, and data-channel flow. + #[arg(long)] + fake: bool, + /// List selectable virtual and compiled-in real audio endpoints. + #[arg(long)] + list_devices: bool, + /// Explicitly permit a live grid login. + #[arg(long)] + allow_live_login: bool, + /// Required literal confirmation for live login. + #[arg(long, value_name = "LOGIN")] + confirm_live_login: Option, + /// Join live parcel voice and transmit/receive media. + #[arg(long, requires = "allow_live_login")] + allow_session_audio: bool, + /// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback. + #[arg(long, value_name = "URL")] + login_uri: Option, + /// Concrete local interface address advertised as the host ICE candidate. + #[arg(long, value_name = "IP")] + bind_ip: Option, + /// Optional parcel local ID included in WebRTC provisioning. + #[arg(long, value_name = "ID")] + parcel_local_id: Option, + /// Selected capture endpoint ID. + #[arg(long, default_value = "virtual:microphone")] + input_device: String, + /// Selected playback endpoint ID. + #[arg(long, default_value = "virtual:speaker")] + output_device: String, + /// WAV file to resample, Opus-encode, and send as microphone input. + #[arg(long, value_name = "FILE")] + wav: Option, + /// Read interactive commands from a file instead of stdin. + #[arg(long, value_name = "FILE")] + commands: Option, + /// Maximum duration for login, capabilities, connection, and teardown. + #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] + timeout_seconds: u64, +} + +#[derive(Debug)] +enum ProgramError { + Usage(&'static str), + Input(String), + Grid(&'static str), + Voice(WebRtcError), + Io(io::Error), + Timeout(&'static str), +} + +impl ProgramError { + const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) => EXIT_USAGE, + Self::Input(_) | Self::Io(_) => EXIT_INPUT, + Self::Grid(_) | Self::Voice(_) | Self::Timeout(_) => EXIT_SERVICE, + } + } +} + +impl fmt::Display for ProgramError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) | Self::Grid(message) => formatter.write_str(message), + Self::Input(message) => write!(formatter, "invalid input: {message}"), + Self::Voice(error) => error.fmt(formatter), + Self::Io(error) => error.fmt(formatter), + Self::Timeout(action) => write!(formatter, "{action} timed out"), + } + } +} + +impl From for ProgramError { + fn from(error: WebRtcError) -> Self { + Self::Voice(error) + } +} + +impl From for ProgramError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Runs the `WebRtcTest` command. +#[must_use] +pub fn main_entry() -> ExitCode { + let cli = Cli::parse(); + let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + else { + eprintln!("webrtc-test: could not initialize the async runtime"); + return ExitCode::from(EXIT_SERVICE); + }; + match runtime.block_on(run(cli)) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("webrtc-test: {error}"); + ExitCode::from(error.exit_code()) + } + } +} + +async fn run(cli: Cli) -> Result<(), ProgramError> { + if cli.list_devices { + if cli.fake + || cli.first_name.is_some() + || cli.last_name.is_some() + || cli.password.is_some() + || cli.allow_live_login + { + return Err(ProgramError::Usage( + "--list-devices cannot be combined with fake or live mode", + )); + } + print_devices()?; + return Ok(()); + } + if cli.fake { + if cli.first_name.is_some() + || cli.last_name.is_some() + || cli.password.is_some() + || cli.allow_live_login + || cli.confirm_live_login.is_some() + || cli.allow_session_audio + || cli.login_uri.is_some() + || cli.commands.is_some() + { + return Err(ProgramError::Usage( + "credentials and live/interactive options cannot be combined with --fake", + )); + } + return run_fake(Duration::from_secs(cli.timeout_seconds)).await; + } + run_live(cli).await +} + +fn print_devices() -> Result<(), ProgramError> { + for device in audio_devices()? { + println!( + "{}\t{}\t{}{}", + if device.input { "input" } else { "output" }, + device.id, + device.name, + if device.is_default { " [default]" } else { "" } + ); + } + Ok(()) +} + +async fn run_fake(timeout: Duration) -> Result<(), ProgramError> { + let peer = + libremetaverse::types::UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()) + .map_err(|_| ProgramError::Grid("could not build deterministic peer ID"))?; + let signaling = LoopbackSignaling::new(peer); + let mut session = WebRtcVoiceSession::connect( + signaling.clone(), + VoiceSessionConfig { + timeout, + ..VoiceSessionConfig::default() + }, + ) + .await?; + let mut saw_connected = false; + let mut saw_channel = false; + let mut saw_audio_state = false; + let mut saw_position = false; + let mut saw_mute = false; + let mut saw_gain = false; + tokio::time::timeout(timeout, async { + while !(saw_connected + && saw_channel + && saw_audio_state + && saw_position + && saw_mute + && saw_gain) + { + match session.next_event().await { + Some(VoiceEvent::Connected) => saw_connected = true, + Some(VoiceEvent::DataChannelReady) => saw_channel = true, + Some(VoiceEvent::PeerAudio(id, _)) if id == peer => saw_audio_state = true, + Some(VoiceEvent::PeerPosition(id, _)) if id == peer => saw_position = true, + Some(VoiceEvent::MuteMap(map)) if map.contains_key(&peer) => saw_mute = true, + Some(VoiceEvent::GainMap(map)) if map.contains_key(&peer) => saw_gain = true, + Some(VoiceEvent::Diagnostic(message)) => { + return Err(ProgramError::Input(message)); + } + None | Some(VoiceEvent::Closed) => { + return Err(ProgramError::Grid("fake WebRTC peer closed prematurely")); + } + _ => {} + } + } + Ok(()) + }) + .await + .map_err(|_| ProgramError::Timeout("fake WebRTC connection"))??; + + session.set_peer_mute(peer, true).await?; + session.set_peer_gain(peer, 500).await?; + session + .send_position( + [1.0, 2.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + [4.0, 5.0, 6.0], + [0.0, 0.0, 0.0, 1.0], + ) + .await?; + let pcm: Vec = (0..4 * 960) + .map(|index| { + ((index as f32 / 48_000.0 * 440.0 * std::f32::consts::TAU).sin() * 8_000.0) as i16 + }) + .collect(); + session + .play_frames(encode_pcm_48k_mono(&pcm)?, false) + .await?; + tokio::time::timeout(timeout, async { + while session.snapshot().received_audio_frames < 4 { + if session.next_event().await.is_none() { + return Err(ProgramError::Grid("fake audio echo stopped prematurely")); + } + } + Ok(()) + }) + .await + .map_err(|_| ProgramError::Timeout("virtual audio echo"))??; + session.shutdown().await?; + signaling.wait_closed(timeout).await?; + let snapshot = session.snapshot(); + let sent = signaling.received_messages(); + if snapshot.active_tasks != 0 + || signaling.active_tasks() != 0 + || !sent.iter().any(|value| value.contains("\"m\"")) + || !sent + .iter() + .any(|value| value.contains("\"ug\"") && value.contains("220")) + || !sent.iter().any(|value| value.contains("\"sp\"")) + || !sent.iter().any(|value| value.contains("\"pong\"")) + { + return Err(ProgramError::Grid( + "fake peer did not observe every required SLData action or clean teardown", + )); + } + println!( + "Fake WebRTC validation complete; ice=connected dtls=connected srtp=opus sctp=SLData peer-events=4 sent-audio={} received-audio={} active-peer-tasks=0 active-audio-tasks=0", + snapshot.sent_audio_frames, snapshot.received_audio_frames + ); + Ok(()) +} + +async fn run_live(mut cli: Cli) -> Result<(), ProgramError> { + if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) { + return Err(ProgramError::Usage( + "live mode requires --allow-live-login --confirm-live-login LOGIN", + )); + } + let (first_name, last_name) = grid_names(cli.first_name.take(), cli.last_name.take())?; + let password = required(cli.password.take(), "GRID_PASSWORD", "PASSWORD is required")?; + let timeout = Duration::from_secs(cli.timeout_seconds); + let client = + GridClient::new().map_err(|_| ProgramError::Grid("could not construct GridClient"))?; + let network = client.network(); + let mut login = network + .default_login_params( + first_name, + last_name, + password, + "WebRtcTest".into(), + env!("CARGO_PKG_VERSION").into(), + ) + .map_err(|_| ProgramError::Grid("could not build grid login parameters"))?; + login.login_location = "WebRTC Voice 1/128/128/50".into(); + if let Some(uri) = cli + .login_uri + .take() + .or_else(|| credential("GRID_LOGIN_URL")) + { + login.uri = uri; + } + println!("Logging into the grid with redacted credentials..."); + let logged_in = tokio::time::timeout( + timeout, + network.login_with_login_params_cancellation_token(login, None), + ) + .await + .map_err(|_| ProgramError::Timeout("grid login"))? + .unwrap_or(false); + if !logged_in { + return Err(ProgramError::Grid("grid login was rejected")); + } + let operation = async { + let simulator = wait_for_event_queue(&network, timeout).await?; + let provision_uri = capability(&simulator, "ProvisionVoiceAccountRequest")?; + let signaling_uri = capability(&simulator, "VoiceSignalingRequest")?; + println!("WebRTC voice capabilities are present; capability URLs are redacted."); + if !cli.allow_session_audio { + println!("Live voice not joined; pass --allow-session-audio to create a peer and transmit/receive audio."); + return Ok(()); + } + let bind_ip = cli.bind_ip.unwrap_or_else(discover_bind_ip); + let signaling: Arc = Arc::new(GridCapabilitySignaling { + client: client.clone(), + provision_uri, + signaling_uri, + timeout, + }); + let mut session = WebRtcVoiceSession::connect( + signaling, + VoiceSessionConfig { + bind_ip, + parcel_local_id: cli.parcel_local_id, + timeout, + input_device: cli.input_device, + output_device: cli.output_device, + }, + ) + .await?; + let session_result = async { + wait_ready(&mut session, timeout).await?; + println!("WebRTC voice connected; ICE/DTLS/SRTP and ordered SLData are ready."); + if let Some(wav) = cli.wav.as_deref() { + session.play_wav(wav, true).await?; + println!("WAV microphone playback started (48 kHz mono Opus)."); + } + run_commands(&mut session, cli.commands.as_deref()).await + } + .await; + let shutdown_result = session.shutdown().await; + if session.snapshot().active_tasks != 0 { + return Err(ProgramError::Grid("peer/audio tasks remained after shutdown")); + } + session_result?; + shutdown_result?; + println!("WebRTC voice disconnected; peer, audio, and signaling resources closed."); + Ok(()) + } + .await; + let _ = network.logout_with_method(); + operation +} + +fn required( + value: Option, + environment: &str, + message: &'static str, +) -> Result { + value + .or_else(|| credential(environment)) + .ok_or(ProgramError::Usage(message)) +} + +fn credential(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"); + let contents = std::fs::read_to_string(dotenv).ok()?; + contents.lines().find_map(|line| { + let line = line.trim().strip_prefix("export ").unwrap_or(line.trim()); + let (key, value) = line.split_once('=')?; + (key.trim() == name) + .then(|| value.trim().trim_matches(['\'', '"']).to_owned()) + .filter(|value| !value.is_empty()) + }) + }) +} + +fn grid_names( + first_name: Option, + last_name: Option, +) -> Result<(String, String), ProgramError> { + let first_name = first_name.or_else(|| credential("GRID_FIRST_NAME")); + let last_name = last_name.or_else(|| credential("GRID_LAST_NAME")); + match (first_name, last_name) { + (Some(first), Some(last)) if !first.trim().is_empty() && !last.trim().is_empty() => { + Ok((first, last)) + } + (Some(_), None) | (None, Some(_)) => Err(ProgramError::Usage( + "FIRSTNAME and LASTNAME must be provided together", + )), + _ => split_grid_user(&credential("GRID_USER").ok_or(ProgramError::Usage( + "FIRSTNAME/LASTNAME or GRID_USER is required", + ))?), + } +} + +fn split_grid_user(value: &str) -> Result<(String, String), ProgramError> { + let mut names = value.split_whitespace(); + let (Some(first), Some(last), None) = (names.next(), names.next(), names.next()) else { + return Err(ProgramError::Usage( + "GRID_USER must contain exactly FIRSTNAME LASTNAME", + )); + }; + Ok((first.to_owned(), last.to_owned())) +} + +async fn wait_for_event_queue( + network: &NetworkManager, + timeout: Duration, +) -> Result { + tokio::time::timeout(timeout, async { + loop { + if let Some(simulator) = network.current_sim() + && simulator + .is_event_queue_running(Some(false)) + .unwrap_or(false) + { + return simulator; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .map_err(|_| ProgramError::Timeout("EventQueueRunning")) +} + +fn capability(simulator: &Simulator, name: &'static str) -> Result { + simulator + .native_capability_uri(name) + .map_err(|_| ProgramError::Grid("voice capability lookup failed"))? + .ok_or(ProgramError::Grid( + "required WebRTC voice capability is missing", + )) +} + +fn discover_bind_ip() -> IpAddr { + let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)); + if let Ok(socket) = socket + && socket + .connect(SocketAddr::from(([192, 0, 2, 1], 9))) + .is_ok() + && let Ok(address) = socket.local_addr() + && !address.ip().is_unspecified() + { + return address.ip(); + } + IpAddr::V4(Ipv4Addr::LOCALHOST) +} + +async fn wait_ready( + session: &mut WebRtcVoiceSession, + timeout: Duration, +) -> Result<(), ProgramError> { + tokio::time::timeout(timeout, async { + while !session.snapshot().data_channel_ready { + match session.next_event().await { + Some(VoiceEvent::Diagnostic(message)) => { + return Err(ProgramError::Input(message)); + } + Some(VoiceEvent::Closed) | None => { + return Err(ProgramError::Grid( + "WebRTC peer closed before SLData opened", + )); + } + Some(event) => print_event(&event), + } + } + Ok(()) + }) + .await + .map_err(|_| ProgramError::Timeout("WebRTC peer connection"))? +} + +fn print_event(event: &VoiceEvent) { + match event { + VoiceEvent::PeerAudio(id, state) => println!( + "[Voice] PeerAudio {id} Power={:?} VAD={:?} JoinedPrimary={:?} Left={}", + state.power, state.voice_active, state.joined_primary, state.left + ), + VoiceEvent::PeerPosition(id, position) => { + println!( + "[Voice] PeerPosition {id} sender={:?}", + position.sender_position + ); + } + VoiceEvent::MuteMap(map) => { + for (id, value) in map { + println!("[Voice] Mute {id} = {value}"); + } + } + VoiceEvent::GainMap(map) => { + for (id, value) in map { + println!("[Voice] Gain {id} = {value}"); + } + } + _ => {} + } +} + +async fn run_commands( + session: &mut WebRtcVoiceSession, + path: Option<&Path>, +) -> Result<(), ProgramError> { + if let Some(path) = path { + let file = io::BufReader::new(std::fs::File::open(path)?); + for line in file.lines().take(1_024) { + if !execute_command(session, &line?).await? { + break; + } + } + return Ok(()); + } + if !io::stdin().is_terminal() { + return Ok(()); + } + println!( + "Commands: peers, mute , gain <0-220>, playwav , stopwav, quit" + ); + let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines(); + loop { + print!("> "); + io::stdout().flush()?; + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal?; + println!("\nCancellation requested; closing WebRTC voice."); + break; + } + line = lines.next_line() => { + let Some(line) = line? else { break }; + if !execute_command(session, &line).await? { + break; + } + } + } + } + Ok(()) +} + +async fn execute_command( + session: &mut WebRtcVoiceSession, + line: &str, +) -> Result { + let fields: Vec<_> = line.split_whitespace().collect(); + let Some(command) = fields.first().map(|value| value.to_ascii_lowercase()) else { + return Ok(true); + }; + match command.as_str() { + "quit" | "exit" => return Ok(false), + "peers" | "list" => { + for peer in session.snapshot().known_peers { + println!("{peer}"); + } + } + "mute" if fields.len() == 3 => { + let peer = parse_peer(fields[1])?; + let mute = fields[2] + .parse() + .map_err(|_| ProgramError::Input("mute value must be true or false".into()))?; + session.set_peer_mute(peer, mute).await?; + } + "gain" if fields.len() == 3 => { + let peer = parse_peer(fields[1])?; + let gain = fields[2] + .parse() + .map_err(|_| ProgramError::Input("gain must be an integer".into()))?; + session.set_peer_gain(peer, gain).await?; + } + "playwav" if fields.len() == 2 => { + session.play_wav(Path::new(fields[1]), true).await?; + } + "stopwav" if fields.len() == 1 => session.stop_wav().await?, + _ => { + return Err(ProgramError::Input( + "unknown command or incorrect command arguments".into(), + )); + } + } + Ok(true) +} + +fn parse_peer(value: &str) -> Result { + libremetaverse::types::UUID::new_with_string(value.to_owned()) + .map_err(|_| ProgramError::Input("peer ID must be a UUID".into())) +} + +struct GridCapabilitySignaling { + client: GridClient, + provision_uri: Uri, + signaling_uri: Uri, + timeout: Duration, +} + +impl GridCapabilitySignaling { + async fn post(&self, uri: &Uri, body: OSD) -> Result { + let http = self.client.http_caps_client(); + let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress( + uri.clone(), + OSDFormat::Xml, + body, + CancellationToken::default(), + None, + ); + let (response, bytes) = tokio::time::timeout(self.timeout, request) + .await + .map_err(|_| WebRtcError::Timeout("grid voice capability"))? + .map_err(|_| WebRtcError::Signaling("capability request failed".into()))?; + if !response.is_success_status_code() || bytes.len() > MAX_CAPABILITY_BYTES { + return Err(WebRtcError::Signaling(format!( + "capability returned HTTP {}", + response.status_code + ))); + } + OSDParser::deserialize_with_bytes(bytes) + .map_err(|_| WebRtcError::Signaling("capability returned malformed LLSD".into())) + } +} + +impl VoiceSignaling for GridCapabilitySignaling { + fn provision( + &self, + request: ProvisionRequest, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let mut jsep = HashMap::new(); + jsep.insert("type".into(), OSD::String(request.jsep.kind)); + jsep.insert("sdp".into(), OSD::String(request.jsep.sdp)); + let mut body = HashMap::new(); + body.insert("jsep".into(), OSD::Map(jsep)); + body.insert("channel_type".into(), OSD::String(request.channel_type)); + body.insert( + "voice_server_type".into(), + OSD::String(request.voice_server_type), + ); + if let Some(id) = request.parcel_local_id { + body.insert("parcel_local_id".into(), OSD::Integer(id)); + } + let OSD::Map(response) = self.post(&self.provision_uri, OSD::Map(body)).await? else { + return Err(WebRtcError::Signaling( + "provision response was not a map".into(), + )); + }; + let answer_sdp = match response.get("jsep") { + Some(OSD::Map(jsep)) => jsep + .get("sdp") + .and_then(|value| value.as_string().ok()) + .filter(|value| !value.is_empty()), + _ => None, + } + .ok_or_else(|| WebRtcError::Signaling("provision response omitted SDP".into()))?; + let viewer_session = response + .get("viewer_session") + .and_then(|value| value.as_uuid().ok()) + .filter(|value| *value != libremetaverse::types::UUID::zero()) + .ok_or_else(|| { + WebRtcError::Signaling("provision response omitted session ID".into()) + })?; + let channel = response + .get("channel") + .and_then(|value| value.as_string().ok()) + .filter(|value| !value.is_empty()); + let credentials = response + .get("credentials") + .and_then(|value| value.as_string().ok()) + .filter(|value| !value.is_empty()) + .map(VoiceSecret::new) + .transpose()?; + Ok(ProvisionResponse { + answer_sdp, + viewer_session, + channel, + credentials, + }) + }) + } + + fn complete( + &self, + request: SignalingCompleteRequest, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let mut complete = HashMap::new(); + complete.insert( + "completed".into(), + OSD::Boolean(request.candidate.completed), + ); + let mut body = HashMap::new(); + body.insert( + "voice_server_type".into(), + OSD::String(request.voice_server_type), + ); + body.insert("viewer_session".into(), OSD::String(request.viewer_session)); + body.insert("candidate".into(), OSD::Map(complete)); + let _ = self.post(&self.signaling_uri, OSD::Map(body)).await?; + Ok(()) + }) + } + + fn close( + &self, + request: CloseRequest, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let mut body = HashMap::new(); + body.insert("logout".into(), OSD::Boolean(request.logout)); + body.insert( + "voice_server_type".into(), + OSD::String(request.voice_server_type), + ); + body.insert("viewer_session".into(), OSD::String(request.viewer_session)); + if let Some(channel) = request.channel { + body.insert("channel".into(), OSD::String(channel)); + } + if let Some(credentials) = request.credentials { + body.insert( + "credentials".into(), + OSD::String(credentials.expose().to_owned()), + ); + } + let _ = self.post(&self.provision_uri, OSD::Map(body)).await?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_mode_is_doubly_gated_and_does_not_echo_password() { + let cli = Cli::try_parse_from(["webrtc-test", "First", "Last", "secret"]).unwrap(); + let error = tokio::runtime::Runtime::new() + .unwrap() + .block_on(run(cli)) + .unwrap_err(); + assert!(matches!(error, ProgramError::Usage(_))); + assert!(!error.to_string().contains("secret")); + } + + #[test] + fn bind_discovery_returns_a_concrete_address() { + assert!(!discover_bind_ip().is_unspecified()); + } + + #[test] + fn grid_user_requires_the_repository_first_last_shape() { + assert_eq!( + split_grid_user("First Last").unwrap(), + ("First".to_owned(), "Last".to_owned()) + ); + assert!(split_grid_user("First").is_err()); + assert!(split_grid_user("First Middle Last").is_err()); + } +} diff --git a/programs/tests/webrtc_test_cli.rs b/programs/tests/webrtc_test_cli.rs new file mode 100644 index 0000000..1db279b --- /dev/null +++ b/programs/tests/webrtc_test_cli.rs @@ -0,0 +1,103 @@ +use std::process::{Command, Output, Stdio}; + +const EXIT_USAGE: i32 = 2; + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_webrtc-test")) + .args(args) + .env_remove("GRID_FIRST_NAME") + .env_remove("GRID_LAST_NAME") + .env_remove("GRID_PASSWORD") + .env_remove("GRID_LOGIN_URL") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run webrtc-test") +} + +fn text(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).expect("UTF-8 process output") +} + +#[test] +fn help_documents_native_prerequisites_fake_flow_and_live_gates() { + let output = run(&["--help"]); + assert!(output.status.success()); + let help = text(&output.stdout); + for marker in [ + "--fake", + "--list-devices", + "--allow-live-login", + "--confirm-live-login", + "--allow-session-audio", + "--input-device", + "--output-device", + "--wav", + "ICE/DTLS/SRTP/SCTP", + "Native libopus", + "--features real-audio", + ] { + assert!(help.contains(marker), "help omitted {marker}:\n{help}"); + } + assert_eq!(run(&[]).status.code(), Some(EXIT_USAGE)); +} + +#[test] +fn fake_peer_exercises_media_data_channel_and_clean_teardown() { + let output = run(&["--fake", "--timeout-seconds", "10"]); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + text(&output.stdout), + text(&output.stderr) + ); + let stdout = text(&output.stdout); + for marker in [ + "ice=connected", + "dtls=connected", + "srtp=opus", + "sctp=SLData", + "sent-audio=4", + "active-peer-tasks=0", + "active-audio-tasks=0", + ] { + assert!( + stdout.contains(marker), + "output omitted {marker}:\n{stdout}" + ); + } + assert!(output.stderr.is_empty(), "{}", text(&output.stderr)); +} + +#[test] +fn virtual_devices_are_available_without_physical_hardware() { + let output = run(&["--list-devices"]); + assert!(output.status.success(), "{}", text(&output.stderr)); + let stdout = text(&output.stdout); + assert!(stdout.contains("input\tvirtual:microphone")); + assert!(stdout.contains("output\tvirtual:speaker")); +} + +#[test] +fn live_inputs_are_rejected_before_network_without_both_opt_ins_or_secret_echo() { + let output = run(&["First", "Last", "do-not-echo"]); + assert_eq!(output.status.code(), Some(EXIT_USAGE)); + assert!(text(&output.stderr).contains("--allow-live-login")); + assert!(!text(&output.stderr).contains("do-not-echo")); + + let output = run(&[ + "First", + "Last", + "do-not-echo", + "--allow-live-login", + "--confirm-live-login", + "WRONG", + ]); + assert_eq!(output.status.code(), Some(EXIT_USAGE)); + assert!(!text(&output.stderr).contains("do-not-echo")); + + let output = run(&["First", "Last", "do-not-echo", "--fake"]); + assert_eq!(output.status.code(), Some(EXIT_USAGE)); + assert!(!text(&output.stderr).contains("do-not-echo")); +} diff --git a/tests/compat/Cargo.toml b/tests/compat/Cargo.toml index e8320d6..6cac242 100644 --- a/tests/compat/Cargo.toml +++ b/tests/compat/Cargo.toml @@ -20,6 +20,7 @@ libremetaverse-types = { path = "../../crates/libremetaverse-types" } libremetaverse-utilities = { path = "../../crates/libremetaverse-utilities" } libremetaverse-voice-vivox = { path = "../../crates/libremetaverse-voice-vivox" } libremetaverse-voice-webrtc = { path = "../../crates/libremetaverse-voice-webrtc" } +serde_json = "1" tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } [lints] diff --git a/tests/compat/tests/webrtc_protocol_semantics.rs b/tests/compat/tests/webrtc_protocol_semantics.rs new file mode 100644 index 0000000..d34a471 --- /dev/null +++ b/tests/compat/tests/webrtc_protocol_semantics.rs @@ -0,0 +1,57 @@ +use libremetaverse::types::UUID; +use libremetaverse_voice_webrtc::{ + CloseRequest, Jsep, ProvisionRequest, SignalingComplete, SignalingCompleteRequest, VoiceSecret, + encode_pcm_48k_mono, +}; + +#[test] +fn local_provision_matches_web_rtc_capability_wire_shape() { + let request = ProvisionRequest { + jsep: Jsep { + kind: "offer".into(), + sdp: "v=0\r\n".into(), + }, + channel_type: "local".into(), + voice_server_type: "webrtc".into(), + parcel_local_id: Some(42), + }; + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["jsep"]["type"], "offer"); + assert_eq!(value["parcel_local_id"], 42); + assert_eq!(value["channel_type"], "local"); + assert_eq!(value["voice_server_type"], "webrtc"); +} + +#[test] +fn completion_uses_singular_candidate_and_close_is_explicit() { + let session = UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()).unwrap(); + let completion = serde_json::to_value(SignalingCompleteRequest { + voice_server_type: "webrtc".into(), + viewer_session: session.to_string(), + candidate: SignalingComplete { completed: true }, + }) + .unwrap(); + assert_eq!(completion["candidate"]["completed"], true); + assert!(completion.get("candidates").is_none()); + + let close = serde_json::to_value(CloseRequest { + logout: true, + voice_server_type: "webrtc".into(), + viewer_session: session.to_string(), + channel: None, + credentials: None, + }) + .unwrap(); + assert_eq!(close["logout"], true); +} + +#[test] +fn native_opus_frames_are_nonempty_and_credentials_are_redacted() { + let frames = encode_pcm_48k_mono(&vec![0_i16; 1_920]).unwrap(); + assert_eq!(frames.len(), 2); + assert!(frames.iter().all(|frame| !frame.0.is_empty())); + assert_eq!( + format!("{:?}", VoiceSecret::new("capability-token").unwrap()), + "VoiceSecret()" + ); +}