Compare commits
5 Commits
android-re
...
ec437afbce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec437afbce | ||
|
|
137e7973c4 | ||
|
|
55d4004f86 | ||
|
|
09a18b086b | ||
|
|
f3c8e11995 |
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
||||
when token efficiency is requested.
|
||||
---
|
||||
|
||||
# Caveman Mode
|
||||
|
||||
## Core Rule
|
||||
|
||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
||||
|
||||
## Grammar
|
||||
|
||||
- Drop articles (a, an, the)
|
||||
- Drop filler (just, really, basically, actually, simply)
|
||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
||||
- No hedging (skip "it might be worth considering")
|
||||
- Fragments fine. No need full sentence
|
||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
||||
- Code blocks unchanged. Caveman speak around code, not in code
|
||||
- Error messages quoted exact. Caveman only for explanation
|
||||
|
||||
## Pattern
|
||||
|
||||
```
|
||||
[thing] [action] [reason]. [next step].
|
||||
```
|
||||
|
||||
Not:
|
||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
||||
|
||||
Yes:
|
||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||
|
||||
## Examples
|
||||
|
||||
**User:** Why is my React component re-rendering?
|
||||
|
||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||
|
||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
|
||||
---
|
||||
|
||||
**User:** How do I set up a PostgreSQL connection pool?
|
||||
|
||||
**Caveman:**
|
||||
```
|
||||
Use `pg` pool:
|
||||
```
|
||||
```js
|
||||
const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
```
|
||||
```
|
||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Code: write normal. Caveman English only
|
||||
- Git commits: normal
|
||||
- PR descriptions: normal
|
||||
- User say "stop caveman" or "normal mode": revert immediately
|
||||
@@ -1,5 +0,0 @@
|
||||
[target.aarch64-linux-android]
|
||||
linker = "aarch64-linux-android26-clang"
|
||||
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "armv7a-linux-androideabi26-clang"
|
||||
@@ -2,57 +2,187 @@ name: Build Release Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
targets:
|
||||
description: 'Targets to build (comma-separated: amd64,arm64,armv7,mac-arm64)'
|
||||
required: false
|
||||
default: 'amd64'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# Always builds on push tags. On manual dispatch, reads inputs.
|
||||
build-amd64:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'amd64')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
image: rust:1-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Init submodules
|
||||
run: |
|
||||
git config --global url."https://git.manko.yoga/".insteadOf "ssh://git@git.manko.yoga:222/"
|
||||
git submodule update --init --recursive
|
||||
- name: Install dependencies
|
||||
run: apt-get update && apt-get install -y cmake pkg-config libasound2-dev
|
||||
|
||||
- name: Install Rust + dependencies
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
apt-get update && apt-get install -y cmake pkg-config libasound2-dev ninja-build
|
||||
rustc --version
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-amd64-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-amd64-
|
||||
|
||||
- name: Build relay + tools
|
||||
- name: Build headless binaries
|
||||
run: cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Build audio client
|
||||
run: |
|
||||
source "$HOME/.cargo/env"
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
cargo build --release --bin wzp-client --features audio
|
||||
cp target/release/wzp-client target/release/wzp-client-audio
|
||||
cargo build --release --bin wzp-client
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
source "$HOME/.cargo/env"
|
||||
cargo test --workspace --lib
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
- name: Upload to rustypaste
|
||||
env:
|
||||
PASTE_AUTH: ${{ secrets.PASTE_AUTH }}
|
||||
PASTE_URL: ${{ secrets.PASTE_URL }}
|
||||
- name: Package
|
||||
run: |
|
||||
tar czf /tmp/wzp-linux-amd64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-web wzp-bench
|
||||
ls -lh /tmp/wzp-linux-amd64.tar.gz
|
||||
LINK=$(curl -sF "file=@/tmp/wzp-linux-amd64.tar.gz" \
|
||||
-H "Authorization: ${PASTE_AUTH}" \
|
||||
"https://${PASTE_URL}")
|
||||
echo "Download: ${LINK}"
|
||||
mkdir -p dist/wzp-linux-amd64
|
||||
cp target/release/wzp-relay dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-client dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-client-audio dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-web dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-bench dist/wzp-linux-amd64/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-amd64/
|
||||
cd dist && tar czf wzp-linux-amd64.tar.gz wzp-linux-amd64/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-amd64
|
||||
path: dist/wzp-linux-amd64.tar.gz
|
||||
|
||||
build-arm64:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'arm64')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
dpkg --add-architecture arm64
|
||||
apt-get update
|
||||
apt-get install -y cmake pkg-config gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-arm64-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-arm64-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu \
|
||||
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist/wzp-linux-arm64
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-client dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-web dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-bench dist/wzp-linux-arm64/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-arm64/
|
||||
cd dist && tar czf wzp-linux-arm64.tar.gz wzp-linux-arm64/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-arm64
|
||||
path: dist/wzp-linux-arm64.tar.gz
|
||||
|
||||
build-armv7:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'armv7')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
dpkg --add-architecture armhf
|
||||
apt-get update
|
||||
apt-get install -y cmake pkg-config gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-armv7-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-armv7-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
CC_armv7_unknown_linux_gnueabihf: arm-linux-gnueabihf-gcc
|
||||
run: |
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf \
|
||||
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist/wzp-linux-armv7
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-relay dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-client dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-web dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-bench dist/wzp-linux-armv7/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-armv7/
|
||||
cd dist && tar czf wzp-linux-armv7.tar.gz wzp-linux-armv7/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-armv7
|
||||
path: dist/wzp-linux-armv7.tar.gz
|
||||
|
||||
# Release job — creates a release with all artifacts when a tag is pushed
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/**/*.tar.gz
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
- 'feature/*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
git remote add github git@github.com:manawenuz/wzp.git
|
||||
|
||||
# Push the current branch
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||
echo "Pushing tag: ${TAG}"
|
||||
git push github "refs/tags/${TAG}" --force
|
||||
else
|
||||
echo "Pushing branch: ${BRANCH}"
|
||||
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||
fi
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,28 +4,3 @@
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dev-debug.log
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
# Environment variables
|
||||
.env
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# OS specific
|
||||
|
||||
# Taskmaster (local workflow tool)
|
||||
.taskmaster/
|
||||
.env.example
|
||||
|
||||
124
Cargo.lock
generated
124
Cargo.lock
generated
@@ -43,12 +43,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -847,27 +841,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1781,15 +1754,6 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1832,15 +1796,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
@@ -2119,12 +2074,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
@@ -2465,17 +2414,6 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -3387,17 +3325,6 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -3436,14 +3363,10 @@ version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
@@ -4256,32 +4179,6 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "wzp-android"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"cc",
|
||||
"jni",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-android",
|
||||
"tracing-subscriber",
|
||||
"wzp-codec",
|
||||
"wzp-crypto",
|
||||
"wzp-fec",
|
||||
"wzp-proto",
|
||||
"wzp-transport",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-client"
|
||||
version = "0.1.0"
|
||||
@@ -4370,8 +4267,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"prometheus",
|
||||
"quinn",
|
||||
@@ -4379,7 +4274,6 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower-http",
|
||||
@@ -4399,18 +4293,30 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"ed25519-dalek",
|
||||
"hkdf",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wzp-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"js-sys",
|
||||
"rand 0.8.5",
|
||||
"raptorq",
|
||||
"sha2",
|
||||
"wasm-bindgen",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-web"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
"crates/wzp-relay",
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-android",
|
||||
"crates/wzp-wasm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -40,7 +40,7 @@ codec2 = "0.3"
|
||||
|
||||
# Crypto
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
6
android/.gitignore
vendored
6
android/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
app/src/main/jniLibs/
|
||||
local.properties
|
||||
keystore/*.jks
|
||||
Binary file not shown.
@@ -1,85 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.wzp.phone"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.wzp.phone"
|
||||
minSdk = 26 // AAudio requires API 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("${project.rootDir}/keystore/wzp-release.jks")
|
||||
storePassword = "wzphone2024"
|
||||
keyAlias = "wzp-release"
|
||||
keyPassword = "wzphone2024"
|
||||
}
|
||||
getByName("debug") {
|
||||
storeFile = file("${project.rootDir}/keystore/wzp-debug.jks")
|
||||
storePassword = "android"
|
||||
keyAlias = "wzp-debug"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = true
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
||||
|
||||
ndkVersion = "26.1.10909125"
|
||||
}
|
||||
|
||||
// cargo-ndk integration: build the Rust native library for Android targets
|
||||
tasks.register<Exec>("cargoNdkBuild") {
|
||||
workingDir = file("${project.rootDir}/..")
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"-o", "${project.projectDir}/src/main/jniLibs",
|
||||
"build", "--release", "-p", "wzp-android"
|
||||
)
|
||||
}
|
||||
|
||||
// Skip cargo-ndk in CI/Docker — .so is pre-built into jniLibs
|
||||
// tasks.named("preBuild") { dependsOn("cargoNdkBuild") }
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
}
|
||||
9
android/app/proguard-rules.pro
vendored
9
android/app/proguard-rules.pro
vendored
@@ -1,9 +0,0 @@
|
||||
# WZPhone ProGuard rules
|
||||
|
||||
# Keep JNI native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep the WZP engine bridge class
|
||||
-keep class com.wzp.phone.engine.** { *; }
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name="com.wzp.WzpApplication"
|
||||
android:label="WZ Phone"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name="com.wzp.ui.call.CallActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.wzp.service.CallService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.wzp
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Application entry point for WarzonePhone.
|
||||
*
|
||||
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
|
||||
*/
|
||||
class WzpApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Active Call",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shown while a VoIP call is in progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "wzp_call_channel"
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
package com.wzp.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.wzp.engine.WzpEngine
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStreamWriter
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Audio pipeline that captures mic audio and plays received audio using
|
||||
* Android AudioRecord/AudioTrack APIs running on JVM threads.
|
||||
*
|
||||
* PCM samples are shuttled to/from the Rust engine via JNI ring buffers:
|
||||
* - Capture: AudioRecord → WzpEngine.writeAudio() → Rust encoder → network
|
||||
* - Playout: network → Rust decoder → WzpEngine.readAudio() → AudioTrack
|
||||
*
|
||||
* All audio is 48kHz, mono, 16-bit PCM (matching Opus codec requirements).
|
||||
*/
|
||||
class AudioPipeline(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AudioPipeline"
|
||||
private const val SAMPLE_RATE = 48000
|
||||
private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
|
||||
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
|
||||
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
||||
/** 20ms frame at 48kHz = 960 samples */
|
||||
private const val FRAME_SAMPLES = 960
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var running = false
|
||||
/** Playout (incoming voice) gain in dB. 0 = unity. */
|
||||
@Volatile
|
||||
var playoutGainDb: Float = 0f
|
||||
/** Capture (mic) gain in dB. 0 = unity. */
|
||||
@Volatile
|
||||
var captureGainDb: Float = 0f
|
||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||
var aecEnabled: Boolean = true
|
||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||
var debugRecording: Boolean = false
|
||||
private var captureThread: Thread? = null
|
||||
private var playoutThread: Thread? = null
|
||||
|
||||
// DirectByteBuffers for zero-copy JNI audio transfer.
|
||||
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
||||
// can null local variables when it replaces the stack frame mid-loop.
|
||||
// These survive OSR because they're on the heap.
|
||||
private val captureDirectBuf: ByteBuffer =
|
||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
private val playoutDirectBuf: ByteBuffer =
|
||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
/** Latch counted down by each audio thread after exiting its loop.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
private var drainLatch: CountDownLatch? = null
|
||||
|
||||
private val debugDir: File by lazy {
|
||||
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
fun start(engine: WzpEngine) {
|
||||
if (running) return
|
||||
running = true
|
||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
||||
|
||||
captureThread = Thread({
|
||||
runCapture(engine)
|
||||
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
|
||||
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
||||
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
||||
parkThread()
|
||||
}, "wzp-capture").apply {
|
||||
isDaemon = true
|
||||
priority = Thread.MAX_PRIORITY
|
||||
start()
|
||||
}
|
||||
|
||||
playoutThread = Thread({
|
||||
runPlayout(engine)
|
||||
drainLatch?.countDown() // signal: playout loop exited
|
||||
parkThread()
|
||||
}, "wzp-playout").apply {
|
||||
isDaemon = true
|
||||
priority = Thread.MAX_PRIORITY
|
||||
start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "audio pipeline started")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
// Don't join threads — they are parked as daemons to avoid native TLS crash.
|
||||
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
|
||||
Log.i(TAG, "audio pipeline stopped (running=false)")
|
||||
}
|
||||
|
||||
/** Block until both audio threads have exited their loops (max 200ms).
|
||||
* After this returns, no more JNI calls to the engine will be made. */
|
||||
fun awaitDrain(): Boolean {
|
||||
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
||||
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
|
||||
captureThread = null
|
||||
playoutThread = null
|
||||
drainLatch = null
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
||||
if (db == 0f) return
|
||||
val linear = 10f.pow(db / 20f)
|
||||
for (i in 0 until count) {
|
||||
pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeRms(pcm: ShortArray, count: Int): Int {
|
||||
var sumSq = 0.0
|
||||
for (i in 0 until count) {
|
||||
val s = pcm[i].toDouble()
|
||||
sumSq += s * s
|
||||
}
|
||||
return sqrt(sumSq / count).toInt()
|
||||
}
|
||||
|
||||
private fun parkThread() {
|
||||
try {
|
||||
Thread.sleep(Long.MAX_VALUE)
|
||||
} catch (_: InterruptedException) {
|
||||
// process exiting
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCapture(engine: WzpEngine) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.e(TAG, "RECORD_AUDIO permission not granted, capture disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING)
|
||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4) // at least 4 frames
|
||||
|
||||
val recorder = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_IN,
|
||||
ENCODING,
|
||||
bufSize
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "AudioRecord SecurityException: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord failed to initialize")
|
||||
recorder.release()
|
||||
return
|
||||
}
|
||||
|
||||
// Attach hardware AEC if available and enabled in settings
|
||||
var aec: AcousticEchoCanceler? = null
|
||||
var ns: NoiseSuppressor? = null
|
||||
if (aecEnabled) {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
try {
|
||||
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
|
||||
aec?.enabled = true
|
||||
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AEC init failed: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "AEC not available on this device")
|
||||
}
|
||||
|
||||
// Attach hardware noise suppressor if available
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
try {
|
||||
ns = NoiseSuppressor.create(recorder.audioSessionId)
|
||||
ns?.enabled = true
|
||||
Log.i(TAG, "NoiseSuppressor enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "AEC disabled by user setting")
|
||||
}
|
||||
|
||||
recorder.startRecording()
|
||||
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
applyGain(pcm, read, captureGainDb)
|
||||
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||
captureDirectBuf.clear()
|
||||
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||
engine.writeAudioDirect(captureDirectBuf, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
} else if (read < 0) {
|
||||
Log.e(TAG, "AudioRecord.read error: $read")
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
recorder.stop()
|
||||
aec?.release()
|
||||
ns?.release()
|
||||
recorder.release()
|
||||
Log.i(TAG, "capture stopped (frames=$frameIdx)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPlayout(engine: WzpEngine) {
|
||||
val minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
|
||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4)
|
||||
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(CHANNEL_OUT)
|
||||
.setEncoding(ENCODING)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(bufSize)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioTrack failed to initialize")
|
||||
track.release()
|
||||
return
|
||||
}
|
||||
|
||||
track.play()
|
||||
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
val silence = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV for playout
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug playout recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||
playoutDirectBuf.clear()
|
||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||
if (read >= FRAME_SAMPLES) {
|
||||
playoutDirectBuf.rewind()
|
||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||
applyGain(pcm, read, playoutGainDb)
|
||||
track.write(pcm, 0, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
} else {
|
||||
track.write(silence, 0, FRAME_SAMPLES)
|
||||
// Log silence frames to RMS as 0
|
||||
if (rmsCsv != null) {
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,0\n")
|
||||
}
|
||||
frameIdx++
|
||||
Thread.sleep(5)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
track.stop()
|
||||
track.release()
|
||||
Log.i(TAG, "playout stopped (frames=$frameIdx)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package com.wzp.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
|
||||
*
|
||||
* Wraps [AudioManager] operations and listens for device connection changes
|
||||
* via [AudioDeviceCallback] (API 23+).
|
||||
*
|
||||
* Usage:
|
||||
* 1. Call [register] when the call starts
|
||||
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
|
||||
* 3. Call [unregister] when the call ends
|
||||
*/
|
||||
class AudioRouteManager(context: Context) {
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** Listener for audio route changes. */
|
||||
var onRouteChanged: ((AudioRoute) -> Unit)? = null
|
||||
|
||||
/** Current active route. */
|
||||
var currentRoute: AudioRoute = AudioRoute.EARPIECE
|
||||
private set
|
||||
|
||||
// -- Device callback (API 23+) -------------------------------------------
|
||||
|
||||
private val deviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
for (device in addedDevices) {
|
||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||
// A Bluetooth headset was connected — optionally auto-switch
|
||||
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
for (device in removedDevices) {
|
||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||
// Bluetooth disconnected — fall back to earpiece or speaker
|
||||
val fallback = if (audioManager.isSpeakerphoneOn) {
|
||||
AudioRoute.SPEAKER
|
||||
} else {
|
||||
AudioRoute.EARPIECE
|
||||
}
|
||||
currentRoute = fallback
|
||||
onRouteChanged?.invoke(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/** Register the device callback. Call when a call starts. */
|
||||
fun register() {
|
||||
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
||||
}
|
||||
|
||||
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
|
||||
fun unregister() {
|
||||
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
||||
stopBluetoothSco()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the loudspeaker.
|
||||
*
|
||||
* When enabling speaker, Bluetooth SCO is disconnected.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun setSpeaker(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
stopBluetoothSco()
|
||||
}
|
||||
audioManager.isSpeakerphoneOn = enabled
|
||||
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||
onRouteChanged?.invoke(currentRoute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
|
||||
*
|
||||
* When enabling Bluetooth, the speaker is turned off.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun setBluetoothSco(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
audioManager.isSpeakerphoneOn = false
|
||||
audioManager.startBluetoothSco()
|
||||
audioManager.isBluetoothScoOn = true
|
||||
currentRoute = AudioRoute.BLUETOOTH
|
||||
} else {
|
||||
stopBluetoothSco()
|
||||
currentRoute = AudioRoute.EARPIECE
|
||||
}
|
||||
onRouteChanged?.invoke(currentRoute)
|
||||
}
|
||||
|
||||
/** Check whether a Bluetooth SCO device is currently connected. */
|
||||
fun isBluetoothAvailable(): Boolean {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
}
|
||||
|
||||
/** List available output audio routes. */
|
||||
fun availableRoutes(): List<AudioRoute> {
|
||||
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
|
||||
if (isBluetoothAvailable()) {
|
||||
routes.add(AudioRoute.BLUETOOTH)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// -- Internal -------------------------------------------------------------
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun stopBluetoothSco() {
|
||||
if (audioManager.isBluetoothScoOn) {
|
||||
audioManager.isBluetoothScoOn = false
|
||||
audioManager.stopBluetoothSco()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio output route. */
|
||||
enum class AudioRoute {
|
||||
/** Phone earpiece (default for calls). */
|
||||
EARPIECE,
|
||||
/** Built-in loudspeaker. */
|
||||
SPEAKER,
|
||||
/** Bluetooth SCO headset/headphones. */
|
||||
BLUETOOTH
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package com.wzp.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.wzp.ui.call.ServerEntry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Persists user settings via SharedPreferences.
|
||||
*
|
||||
* Stores: servers, default server index, room name, alias, gain values,
|
||||
* IPv6 preference, and the identity seed (hex-encoded 32 bytes).
|
||||
*/
|
||||
class SettingsRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVERS = "servers_json"
|
||||
private const val KEY_SELECTED_SERVER = "selected_server"
|
||||
private const val KEY_ROOM = "room_name"
|
||||
private const val KEY_ALIAS = "alias"
|
||||
private const val KEY_PLAYOUT_GAIN = "playout_gain_db"
|
||||
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
|
||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||
private const val TOFU_PREFIX = "tofu_"
|
||||
}
|
||||
|
||||
// --- Servers ---
|
||||
|
||||
fun saveServers(servers: List<ServerEntry>) {
|
||||
val arr = JSONArray()
|
||||
servers.forEach { entry ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("address", entry.address)
|
||||
put("label", entry.label)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(KEY_SERVERS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadServers(): List<ServerEntry>? {
|
||||
val json = prefs.getString(KEY_SERVERS, null) ?: return null
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
ServerEntry(obj.getString("address"), obj.getString("label"))
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveSelectedServer(index: Int) {
|
||||
prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply()
|
||||
}
|
||||
|
||||
fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0)
|
||||
|
||||
// --- Room ---
|
||||
|
||||
fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() }
|
||||
fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android"
|
||||
|
||||
// --- Alias ---
|
||||
|
||||
fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() }
|
||||
|
||||
/**
|
||||
* Load alias, generating a random name on first launch.
|
||||
*/
|
||||
fun getOrCreateAlias(): String {
|
||||
val existing = prefs.getString(KEY_ALIAS, null)
|
||||
if (!existing.isNullOrEmpty()) return existing
|
||||
val name = generateRandomName()
|
||||
prefs.edit().putString(KEY_ALIAS, name).apply()
|
||||
return name
|
||||
}
|
||||
|
||||
private fun generateRandomName(): String {
|
||||
val adjectives = listOf(
|
||||
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
|
||||
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
|
||||
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
|
||||
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
|
||||
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc"
|
||||
)
|
||||
val nouns = listOf(
|
||||
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
|
||||
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
|
||||
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
|
||||
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
|
||||
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus"
|
||||
)
|
||||
val adj = adjectives.random()
|
||||
val noun = nouns.random()
|
||||
return "$adj $noun"
|
||||
}
|
||||
|
||||
// --- Gain ---
|
||||
|
||||
fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() }
|
||||
fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f)
|
||||
|
||||
fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() }
|
||||
fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f)
|
||||
|
||||
// --- IPv6 ---
|
||||
|
||||
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
|
||||
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
|
||||
|
||||
// --- AEC ---
|
||||
|
||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||
|
||||
// --- Debug recording ---
|
||||
|
||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
||||
|
||||
// --- Codec choice ---
|
||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
||||
|
||||
// --- Identity seed ---
|
||||
|
||||
/**
|
||||
* Get or generate the identity seed. On first call, generates a random
|
||||
* 32-byte seed and persists it. Subsequent calls return the same seed.
|
||||
*/
|
||||
fun getOrCreateSeedHex(): String {
|
||||
val existing = prefs.getString(KEY_IDENTITY_SEED, null)
|
||||
if (!existing.isNullOrEmpty()) return existing
|
||||
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val hex = seed.joinToString("") { "%02x".format(it) }
|
||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||
return hex
|
||||
}
|
||||
|
||||
fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: ""
|
||||
|
||||
fun saveSeedHex(hex: String) {
|
||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||
}
|
||||
|
||||
// --- Recent rooms ---
|
||||
|
||||
data class RecentRoom(val relay: String, val room: String)
|
||||
|
||||
fun addRecentRoom(relay: String, room: String) {
|
||||
val rooms = loadRecentRooms().toMutableList()
|
||||
rooms.removeAll { it.relay == relay && it.room == room }
|
||||
rooms.add(0, RecentRoom(relay, room))
|
||||
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
||||
val arr = JSONArray()
|
||||
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
||||
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadRecentRooms(): List<RecentRoom> {
|
||||
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
RecentRoom(o.getString("relay"), o.getString("room"))
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun clearRecentRooms() {
|
||||
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
||||
}
|
||||
|
||||
// --- Server fingerprint TOFU ---
|
||||
|
||||
fun saveServerFingerprint(address: String, fingerprint: String) {
|
||||
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
||||
}
|
||||
|
||||
fun loadServerFingerprint(address: String): String? {
|
||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||
}
|
||||
|
||||
// --- Ping RTT cache ---
|
||||
|
||||
fun savePingRtt(address: String, rttMs: Int) {
|
||||
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
||||
}
|
||||
|
||||
fun loadPingRtt(address: String): Int {
|
||||
return prefs.getInt("ping_rtt_$address", -1)
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.wzp.debug
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Collects call debug data (audio recordings, logs, histograms, stats)
|
||||
* into a zip file for email sharing.
|
||||
*/
|
||||
class DebugReporter(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DebugReporter"
|
||||
private const val SAMPLE_RATE = 48000
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip with all debug data.
|
||||
* Returns the zip File on success, or null on failure.
|
||||
*/
|
||||
suspend fun collectZip(
|
||||
callDurationSecs: Double,
|
||||
finalStatsJson: String,
|
||||
aecEnabled: Boolean,
|
||||
alias: String,
|
||||
server: String,
|
||||
room: String
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
// 1. Call metadata
|
||||
val meta = buildString {
|
||||
appendLine("=== WZ Phone Debug Report ===")
|
||||
appendLine("Timestamp: $timestamp")
|
||||
appendLine("Alias: $alias")
|
||||
appendLine("Server: $server")
|
||||
appendLine("Room: $room")
|
||||
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
|
||||
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||
appendLine()
|
||||
appendLine("=== Final Stats ===")
|
||||
appendLine(finalStatsJson)
|
||||
}
|
||||
addTextEntry(zos, "meta.txt", meta)
|
||||
|
||||
// 2. Logcat — WZP-related tags
|
||||
val logcat = collectLogcat()
|
||||
addTextEntry(zos, "logcat.txt", logcat)
|
||||
|
||||
// 3. Capture audio (mic) → WAV
|
||||
val captureRaw = File(debugDir, "capture.pcm")
|
||||
if (captureRaw.exists() && captureRaw.length() > 0) {
|
||||
addWavEntry(zos, "capture.wav", captureRaw)
|
||||
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 4. Playout audio (speaker) → WAV
|
||||
val playoutRaw = File(debugDir, "playout.pcm")
|
||||
if (playoutRaw.exists() && playoutRaw.length() > 0) {
|
||||
addWavEntry(zos, "playout.wav", playoutRaw)
|
||||
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 5. RMS histogram CSV
|
||||
val captureHist = File(debugDir, "capture_rms.csv")
|
||||
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
|
||||
val playoutHist = File(debugDir, "playout_rms.csv")
|
||||
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
|
||||
}
|
||||
|
||||
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
|
||||
|
||||
// Clean up raw debug files (keep zip)
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
zipFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "debug report failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up any leftover debug files from a previous session. */
|
||||
fun prepareForCall() {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
if (debugDir.exists()) {
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
debugDir.mkdirs()
|
||||
// Also clean up old zip files
|
||||
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun collectLogcat(): String {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf(
|
||||
"logcat", "-d",
|
||||
"-t", "5000",
|
||||
"--format", "threadtime"
|
||||
)
|
||||
)
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output.lines()
|
||||
.filter { line ->
|
||||
line.contains("wzp", ignoreCase = true) ||
|
||||
line.contains("WzpEngine") ||
|
||||
line.contains("AudioPipeline") ||
|
||||
line.contains("WzpCall") ||
|
||||
line.contains("CallService") ||
|
||||
line.contains("AudioTrack") ||
|
||||
line.contains("AudioRecord") ||
|
||||
line.contains("AcousticEchoCanceler") ||
|
||||
line.contains("NoiseSuppressor") ||
|
||||
line.contains("FATAL") ||
|
||||
line.contains("ANR") ||
|
||||
line.contains("AudioFlinger") ||
|
||||
line.contains("DebugReporter") ||
|
||||
line.contains("QUIC") ||
|
||||
line.contains("quinn") ||
|
||||
line.contains("send task") ||
|
||||
line.contains("recv task") ||
|
||||
line.contains("send stats") ||
|
||||
line.contains("recv stats") ||
|
||||
line.contains("send_media") ||
|
||||
line.contains("FEC block") ||
|
||||
line.contains("recv gap") ||
|
||||
line.contains("frames_dropped") ||
|
||||
line.contains("opus")
|
||||
}
|
||||
.joinToString("\n")
|
||||
} catch (e: Exception) {
|
||||
"Failed to collect logcat: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
|
||||
val dataSize = pcmFile.length().toInt()
|
||||
val byteRate = SAMPLE_RATE * 1 * 16 / 8
|
||||
val blockAlign = 1 * 16 / 8
|
||||
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
|
||||
// Write WAV header (44 bytes)
|
||||
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
|
||||
header.put("RIFF".toByteArray())
|
||||
header.putInt(36 + dataSize)
|
||||
header.put("WAVE".toByteArray())
|
||||
header.put("fmt ".toByteArray())
|
||||
header.putInt(16)
|
||||
header.putShort(1) // PCM
|
||||
header.putShort(1) // mono
|
||||
header.putInt(SAMPLE_RATE)
|
||||
header.putInt(byteRate)
|
||||
header.putShort(blockAlign.toShort())
|
||||
header.putShort(16) // bits per sample
|
||||
header.put("data".toByteArray())
|
||||
header.putInt(dataSize)
|
||||
zos.write(header.array())
|
||||
|
||||
// Stream PCM data directly (avoids loading entire file into memory)
|
||||
FileInputStream(pcmFile).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
zos.write(content.toByteArray())
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
FileInputStream(file).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.wzp.engine
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
|
||||
*
|
||||
* Constructed from the JSON string returned by [WzpEngine.getStats].
|
||||
*/
|
||||
data class CallStats(
|
||||
/** Current call state ordinal (see [CallStateConstants]). */
|
||||
val state: Int = 0,
|
||||
/** Call duration in seconds. */
|
||||
val durationSecs: Double = 0.0,
|
||||
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
|
||||
val qualityTier: Int = 0,
|
||||
/** Observed packet loss percentage (0..100). */
|
||||
val lossPct: Float = 0f,
|
||||
/** Smoothed round-trip time in milliseconds. */
|
||||
val rttMs: Int = 0,
|
||||
/** Jitter in milliseconds. */
|
||||
val jitterMs: Int = 0,
|
||||
/** Current jitter buffer depth in packets. */
|
||||
val jitterBufferDepth: Int = 0,
|
||||
/** Total frames encoded since call start. */
|
||||
val framesEncoded: Long = 0,
|
||||
/** Total frames decoded since call start. */
|
||||
val framesDecoded: Long = 0,
|
||||
/** Number of playout underruns (buffer empty when audio was needed). */
|
||||
val underruns: Long = 0,
|
||||
/** Frames recovered by FEC. */
|
||||
val fecRecovered: Long = 0,
|
||||
/** Current mic audio level (RMS, 0-32767). */
|
||||
val audioLevel: Int = 0,
|
||||
/** Our current outgoing codec (e.g. "Opus24k"). */
|
||||
val currentCodec: String = "",
|
||||
/** Last seen incoming codec from peers. */
|
||||
val peerCodec: String = "",
|
||||
/** Whether auto quality mode is active. */
|
||||
val autoMode: Boolean = false,
|
||||
/** Number of participants in the room. */
|
||||
val roomParticipantCount: Int = 0,
|
||||
/** Participants in the room (fingerprint + optional alias). */
|
||||
val roomParticipants: List<RoomMember> = emptyList(),
|
||||
/** SAS verification code (4-digit, null if not in a call). */
|
||||
val sasCode: Int? = null,
|
||||
/** Incoming call ID (or "relay|room" for CallSetup). */
|
||||
val incomingCallId: String? = null,
|
||||
/** Incoming caller's fingerprint. */
|
||||
val incomingCallerFp: String? = null,
|
||||
/** Incoming caller's alias. */
|
||||
val incomingCallerAlias: String? = null,
|
||||
) {
|
||||
/** Human-readable quality label. */
|
||||
val qualityLabel: String
|
||||
get() = when (qualityTier) {
|
||||
0 -> "Good"
|
||||
1 -> "Degraded"
|
||||
2 -> "Catastrophic"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun parseParticipants(arr: JSONArray?): List<RoomMember> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
RoomMember(
|
||||
fingerprint = o.optString("fingerprint", ""),
|
||||
alias = if (o.isNull("alias")) null else o.optString("alias", null),
|
||||
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deserialise from the JSON string produced by the native engine. */
|
||||
fun fromJson(json: String): CallStats {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
CallStats(
|
||||
state = obj.optInt("state", 0),
|
||||
durationSecs = obj.optDouble("duration_secs", 0.0),
|
||||
qualityTier = obj.optInt("quality_tier", 0),
|
||||
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
|
||||
rttMs = obj.optInt("rtt_ms", 0),
|
||||
jitterMs = obj.optInt("jitter_ms", 0),
|
||||
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
|
||||
framesEncoded = obj.optLong("frames_encoded", 0),
|
||||
framesDecoded = obj.optLong("frames_decoded", 0),
|
||||
underruns = obj.optLong("underruns", 0),
|
||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||
audioLevel = obj.optInt("audio_level", 0),
|
||||
currentCodec = obj.optString("current_codec", ""),
|
||||
peerCodec = obj.optString("peer_codec", ""),
|
||||
autoMode = obj.optBoolean("auto_mode", false),
|
||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
||||
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
CallStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RoomMember(
|
||||
val fingerprint: String,
|
||||
val alias: String? = null,
|
||||
val relayLabel: String? = null
|
||||
) {
|
||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||
val displayName: String
|
||||
get() = alias?.takeIf { it.isNotBlank() }
|
||||
?: fingerprint.take(8).ifEmpty { "unknown" }
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.wzp.engine
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Persistent signal connection for direct 1:1 calls.
|
||||
* Separate from WzpEngine — survives across calls.
|
||||
*
|
||||
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
|
||||
*/
|
||||
class SignalManager {
|
||||
|
||||
private var handle: Long = 0L
|
||||
|
||||
val isConnected: Boolean get() = handle != 0L
|
||||
|
||||
/**
|
||||
* Connect to relay and register for direct calls.
|
||||
* MUST be called from a thread with sufficient stack (8MB).
|
||||
* Blocks briefly during QUIC connect + register, then returns.
|
||||
*/
|
||||
fun connect(relay: String, seedHex: String): Boolean {
|
||||
if (handle != 0L) return true // already connected
|
||||
handle = nativeSignalConnect(relay, seedHex)
|
||||
return handle != 0L
|
||||
}
|
||||
|
||||
/** Get current signal state as parsed object. Non-blocking. */
|
||||
fun getState(): SignalState {
|
||||
if (handle == 0L) return SignalState()
|
||||
val json = nativeSignalGetState(handle) ?: return SignalState()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
SignalState(
|
||||
status = obj.optString("status", "idle"),
|
||||
fingerprint = obj.optString("fingerprint", ""),
|
||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
|
||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
|
||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
|
||||
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
|
||||
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
|
||||
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
SignalState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a direct call to a target fingerprint. */
|
||||
fun placeCall(targetFp: String): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalPlaceCall(handle, targetFp)
|
||||
}
|
||||
|
||||
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
|
||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalAnswerCall(handle, callId, mode)
|
||||
}
|
||||
|
||||
/** Send hangup signal. */
|
||||
fun hangup() {
|
||||
if (handle != 0L) nativeSignalHangup(handle)
|
||||
}
|
||||
|
||||
/** Destroy the signal manager. */
|
||||
fun destroy() {
|
||||
if (handle != 0L) {
|
||||
nativeSignalDestroy(handle)
|
||||
handle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native methods
|
||||
private external fun nativeSignalConnect(relay: String, seed: String): Long
|
||||
private external fun nativeSignalGetState(handle: Long): String?
|
||||
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
|
||||
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||
private external fun nativeSignalHangup(handle: Long)
|
||||
private external fun nativeSignalDestroy(handle: Long)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("wzp_android") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Signal connection state. */
|
||||
data class SignalState(
|
||||
val status: String = "idle",
|
||||
val fingerprint: String = "",
|
||||
val incomingCallId: String? = null,
|
||||
val incomingCallerFp: String? = null,
|
||||
val incomingCallerAlias: String? = null,
|
||||
val callSetupRelay: String? = null,
|
||||
val callSetupRoom: String? = null,
|
||||
val callSetupId: String? = null,
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.wzp.engine
|
||||
|
||||
/**
|
||||
* Callback interface for VoIP engine events.
|
||||
*
|
||||
* All callbacks are invoked on the main/UI thread.
|
||||
*/
|
||||
interface WzpCallback {
|
||||
|
||||
/**
|
||||
* Called when the call state changes.
|
||||
*
|
||||
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
|
||||
* RECONNECTING(3), CLOSED(4)
|
||||
*/
|
||||
fun onCallStateChanged(state: Int)
|
||||
|
||||
/**
|
||||
* Called when the network quality tier changes.
|
||||
*
|
||||
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
|
||||
*/
|
||||
fun onQualityTierChanged(tier: Int)
|
||||
|
||||
/**
|
||||
* Called when an error occurs in the native engine.
|
||||
*
|
||||
* @param code numeric error code (negative)
|
||||
* @param message human-readable description
|
||||
*/
|
||||
fun onError(code: Int, message: String)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package com.wzp.engine
|
||||
|
||||
/**
|
||||
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Construct with a [WzpCallback]
|
||||
* 2. Call [init] to create the native engine
|
||||
* 3. Call [startCall] to begin a VoIP session
|
||||
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
|
||||
* 5. Call [stopCall] to end the session
|
||||
* 6. Call [destroy] when the engine is no longer needed
|
||||
*
|
||||
* Thread safety: all methods must be called from the same thread (typically main).
|
||||
*/
|
||||
class WzpEngine(private val callback: WzpCallback) {
|
||||
|
||||
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
|
||||
private var nativeHandle: Long = 0L
|
||||
|
||||
/** Whether the engine has been initialised. */
|
||||
val isInitialized: Boolean get() = nativeHandle != 0L
|
||||
|
||||
/** Create the native engine. Must be called before any other method. */
|
||||
fun init() {
|
||||
check(nativeHandle == 0L) { "Engine already initialized" }
|
||||
nativeHandle = nativeInit()
|
||||
check(nativeHandle != 0L) { "Native engine creation failed" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a call.
|
||||
*
|
||||
* @param relayAddr relay server address (host:port)
|
||||
* @param room room identifier (used as QUIC SNI)
|
||||
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
|
||||
* @param token authentication token (empty = no auth)
|
||||
* @param alias display name sent to relay for room participant list
|
||||
* @return 0 on success, negative error code on failure
|
||||
*/
|
||||
/**
|
||||
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
||||
*/
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
||||
if (result == 0) {
|
||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||
} else {
|
||||
callback.onError(result, "Failed to start call")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Stop the active call. Safe to call when no call is active. */
|
||||
@Synchronized
|
||||
fun stopCall() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeStopCall(nativeHandle)
|
||||
callback.onCallStateChanged(CallStateConstants.CLOSED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mute or unmute the microphone. */
|
||||
fun setMute(muted: Boolean) {
|
||||
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
|
||||
}
|
||||
|
||||
/** Enable or disable loudspeaker mode. */
|
||||
fun setSpeaker(speaker: Boolean) {
|
||||
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get current call statistics as a JSON string.
|
||||
*
|
||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getStats(): String {
|
||||
if (nativeHandle == 0L) return "{}"
|
||||
return try {
|
||||
nativeGetStats(nativeHandle) ?: "{}"
|
||||
} catch (_: Exception) {
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a quality profile, overriding adaptive selection.
|
||||
*
|
||||
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
|
||||
*/
|
||||
fun forceProfile(profile: Int) {
|
||||
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
||||
}
|
||||
|
||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||
@Synchronized
|
||||
fun destroy() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeDestroy(nativeHandle)
|
||||
nativeHandle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write captured PCM samples into the engine's capture ring buffer.
|
||||
* Called from the AudioRecord capture thread.
|
||||
*/
|
||||
fun writeAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeWriteAudio(nativeHandle, pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decoded PCM samples from the engine's playout ring buffer.
|
||||
* Called from the AudioTrack playout thread.
|
||||
*/
|
||||
fun readAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeReadAudio(nativeHandle, pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
|
||||
* Called from the AudioRecord capture thread.
|
||||
*/
|
||||
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order.
|
||||
* Called from the AudioTrack playout thread.
|
||||
*/
|
||||
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
|
||||
}
|
||||
|
||||
// -- JNI native methods --------------------------------------------------
|
||||
|
||||
private external fun nativeInit(): Long
|
||||
private external fun nativeStartCall(
|
||||
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
||||
): Int
|
||||
private external fun nativeStopCall(handle: Long)
|
||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
|
||||
private external fun nativeGetStats(handle: Long): String?
|
||||
private external fun nativeForceProfile(handle: Long, profile: Int)
|
||||
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("wzp_android") }
|
||||
|
||||
/** Get the identity fingerprint for a seed hex. No engine needed. */
|
||||
@JvmStatic
|
||||
private external fun nativeGetFingerprint(seedHex: String): String?
|
||||
|
||||
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
|
||||
@JvmStatic
|
||||
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
|
||||
}
|
||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
||||
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
||||
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
||||
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||
|
||||
/**
|
||||
* Ping a relay server. Requires engine to be initialized.
|
||||
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
||||
*/
|
||||
fun pingRelay(address: String): String? {
|
||||
if (nativeHandle == 0L) return null
|
||||
return nativePingRelay(nativeHandle, address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start persistent signaling connection for direct 1:1 calls.
|
||||
* The engine registers on the relay and listens for incoming calls.
|
||||
* Call state updates are available via [getStats].
|
||||
*
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a direct call to a peer by fingerprint.
|
||||
* Requires [startSignaling] to have been called first.
|
||||
*
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun placeCall(targetFingerprint: String): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativePlaceCall(nativeHandle, targetFingerprint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an incoming direct call.
|
||||
*
|
||||
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
|
||||
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||
object CallStateConstants {
|
||||
const val IDLE = 0
|
||||
const val CONNECTING = 1
|
||||
const val ACTIVE = 2
|
||||
const val RECONNECTING = 3
|
||||
const val CLOSED = 4
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.wzp.net
|
||||
|
||||
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
||||
// This file kept for the data class only.
|
||||
|
||||
object RelayPinger {
|
||||
data class PingResult(
|
||||
val rttMs: Int,
|
||||
val reachable: Boolean,
|
||||
val serverFingerprint: String = "",
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.wzp.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.wzp.WzpApplication
|
||||
import com.wzp.ui.call.CallActivity
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Shows a persistent notification during the call
|
||||
* - Acquires a partial wake lock so the CPU stays on
|
||||
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
|
||||
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
|
||||
* - Releases all resources when the call ends
|
||||
*/
|
||||
class CallService : Service() {
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
|
||||
|
||||
// -- Lifecycle ------------------------------------------------------------
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
acquireWakeLock()
|
||||
acquireWifiLock()
|
||||
setAudioMode()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
onStopFromNotification?.invoke()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
restoreAudioMode()
|
||||
releaseWifiLock()
|
||||
releaseWakeLock()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
// -- Notification ---------------------------------------------------------
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
// Tapping the notification returns to the call screen
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, CallActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// "End call" action button
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
|
||||
.setContentTitle("WZ Phone")
|
||||
.setContentText("Call in progress")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
// -- Wake lock ------------------------------------------------------------
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"wzp:call_wake_lock"
|
||||
).apply {
|
||||
acquire(MAX_CALL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
// -- Wi-Fi lock -----------------------------------------------------------
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWifiLock() {
|
||||
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
wifiLock = wm.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"wzp:call_wifi_lock"
|
||||
).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWifiLock() {
|
||||
wifiLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wifiLock = null
|
||||
}
|
||||
|
||||
// -- Audio mode -----------------------------------------------------------
|
||||
|
||||
private fun setAudioMode() {
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
previousAudioMode = am.mode
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
}
|
||||
|
||||
private fun restoreAudioMode() {
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = previousAudioMode
|
||||
}
|
||||
|
||||
// -- Static helpers -------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACTION_STOP = "com.wzp.service.STOP"
|
||||
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
/** Called when the user taps "End Call" in the notification. */
|
||||
var onStopFromNotification: (() -> Unit)? = null
|
||||
|
||||
/** Start the foreground call service. */
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, CallService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/** Stop the foreground call service. */
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, CallService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.wzp.ui.settings.SettingsScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main activity hosting the in-call Compose UI.
|
||||
*
|
||||
* Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
|
||||
* is managed by [com.wzp.service.CallService] foreground service.
|
||||
*/
|
||||
class CallActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CallActivity"
|
||||
}
|
||||
|
||||
private val viewModel: CallViewModel by viewModels()
|
||||
|
||||
private val audioPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (!granted) {
|
||||
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.setContext(this)
|
||||
|
||||
setContent {
|
||||
WzpTheme {
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { showSettings = false }
|
||||
)
|
||||
} else {
|
||||
InCallScreen(
|
||||
viewModel = viewModel,
|
||||
onHangUp = { viewModel.stopCall() },
|
||||
onOpenSettings = { showSettings = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Watch for debug zip ready → launch email intent
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.debugZipReady.collect { zipFile ->
|
||||
if (zipFile != null && zipFile.exists()) {
|
||||
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
|
||||
launchEmailIntent(zipFile)
|
||||
viewModel.onDebugReportSent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchEmailIntent(zipFile: java.io.File) {
|
||||
try {
|
||||
val authority = "${applicationContext.packageName}.fileprovider"
|
||||
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
|
||||
val uri = FileProvider.getUriForFile(this, authority, zipFile)
|
||||
Log.i(TAG, "FileProvider URI: $uri")
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "message/rfc822"
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
|
||||
)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Send debug report"))
|
||||
Log.i(TAG, "email intent launched")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "email intent failed", e)
|
||||
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isFinishing) {
|
||||
viewModel.stopCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WzpTheme(content: @Composable () -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,764 +0,0 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.audio.AudioRouteManager
|
||||
import com.wzp.data.SettingsRepository
|
||||
import com.wzp.debug.DebugReporter
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.service.CallService
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
|
||||
data class ServerEntry(val address: String, val label: String)
|
||||
|
||||
data class PingResult(
|
||||
val rttMs: Int,
|
||||
val serverFingerprint: String = "",
|
||||
val reachable: Boolean = rttMs > 0,
|
||||
)
|
||||
|
||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||
|
||||
class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private var engine: WzpEngine? = null
|
||||
private var engineInitialized = false
|
||||
private var audioPipeline: AudioPipeline? = null
|
||||
private var audioRouteManager: AudioRouteManager? = null
|
||||
private var audioStarted = false
|
||||
private var appContext: Context? = null
|
||||
private var settings: SettingsRepository? = null
|
||||
private var debugReporter: DebugReporter? = null
|
||||
private var lastStatsJson: String = "{}"
|
||||
private var lastCallDuration: Double = 0.0
|
||||
private var lastCallServer: String = ""
|
||||
|
||||
private val _callState = MutableStateFlow(0)
|
||||
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
||||
|
||||
private val _isMuted = MutableStateFlow(false)
|
||||
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
||||
|
||||
private val _isSpeaker = MutableStateFlow(false)
|
||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow(CallStats())
|
||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||
|
||||
private val _qualityTier = MutableStateFlow(0)
|
||||
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
|
||||
val roomName: StateFlow<String> = _roomName.asStateFlow()
|
||||
|
||||
private val _selectedServer = MutableStateFlow(0)
|
||||
val selectedServer: StateFlow<Int> = _selectedServer.asStateFlow()
|
||||
|
||||
private val _servers = MutableStateFlow(DEFAULT_SERVERS.toList())
|
||||
val servers: StateFlow<List<ServerEntry>> = _servers.asStateFlow()
|
||||
|
||||
private val _preferIPv6 = MutableStateFlow(false)
|
||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||
|
||||
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
||||
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
||||
|
||||
/** Ping results keyed by server address. */
|
||||
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
||||
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
||||
|
||||
/** Known server fingerprints (TOFU). */
|
||||
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
private val _playoutGainDb = MutableStateFlow(0f)
|
||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||
|
||||
private val _captureGainDb = MutableStateFlow(0f)
|
||||
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
|
||||
|
||||
private val _alias = MutableStateFlow("")
|
||||
val alias: StateFlow<String> = _alias.asStateFlow()
|
||||
|
||||
private val _seedHex = MutableStateFlow("")
|
||||
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
|
||||
|
||||
private val _aecEnabled = MutableStateFlow(true)
|
||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||
|
||||
private val _debugRecording = MutableStateFlow(false)
|
||||
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
||||
|
||||
// Quality profile index (matches JNI bridge profile_from_int)
|
||||
private val _codecChoice = MutableStateFlow(0)
|
||||
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
||||
|
||||
/** Key-change warning dialog state. */
|
||||
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
||||
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
||||
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
||||
|
||||
/** True when a call just ended and debug report can be sent. */
|
||||
private val _debugReportAvailable = MutableStateFlow(false)
|
||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||
|
||||
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
|
||||
private val _debugReportStatus = MutableStateFlow<String?>(null)
|
||||
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
|
||||
|
||||
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
|
||||
private val _debugZipReady = MutableStateFlow<File?>(null)
|
||||
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
// ── Direct calling state ──
|
||||
/** 0=room mode, 1=direct call mode */
|
||||
private val _callMode = MutableStateFlow(0)
|
||||
val callMode: StateFlow<Int> = _callMode.asStateFlow()
|
||||
|
||||
/** Target fingerprint for direct call */
|
||||
private val _targetFingerprint = MutableStateFlow("")
|
||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||
|
||||
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
|
||||
private val _signalState = MutableStateFlow("idle")
|
||||
val signalState: StateFlow<String> = _signalState.asStateFlow()
|
||||
|
||||
/** Incoming call info */
|
||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
|
||||
|
||||
private val _incomingCallerFp = MutableStateFlow<String?>(null)
|
||||
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
|
||||
|
||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||
|
||||
/** Separate signal manager (persistent, survives calls) */
|
||||
private var signalManager: com.wzp.engine.SignalManager? = null
|
||||
private var signalPollJob: Job? = null
|
||||
|
||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||
|
||||
/** Register on relay for direct calls */
|
||||
fun registerForCalls() {
|
||||
val serverIdx = _selectedServer.value
|
||||
val serverList = _servers.value
|
||||
if (serverIdx >= serverList.size) return
|
||||
|
||||
val relay = serverList[serverIdx].address
|
||||
var seed = _seedHex.value
|
||||
// Generate seed if empty (fresh install or cleared storage)
|
||||
if (seed.isEmpty()) {
|
||||
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
|
||||
seed = newSeed.joinToString("") { "%02x".format(it) }
|
||||
_seedHex.value = seed
|
||||
settings?.saveSeedHex(seed)
|
||||
Log.i(TAG, "generated new identity seed")
|
||||
}
|
||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||
|
||||
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack.
|
||||
// Dispatchers.IO threads overflow. Use explicit Java Thread.
|
||||
Thread(null, {
|
||||
try {
|
||||
val mgr = com.wzp.engine.SignalManager()
|
||||
val ok = mgr.connect(resolvedRelay, seed)
|
||||
viewModelScope.launch {
|
||||
if (ok) {
|
||||
signalManager = mgr
|
||||
startSignalPolling()
|
||||
} else {
|
||||
_errorMessage.value = "Failed to register on relay"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
viewModelScope.launch {
|
||||
_errorMessage.value = "Register error: ${e.message}"
|
||||
}
|
||||
}
|
||||
}, "wzp-signal-init", 8 * 1024 * 1024).start()
|
||||
}
|
||||
|
||||
/** Poll signal manager state every 500ms */
|
||||
private fun startSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val mgr = signalManager
|
||||
if (mgr != null && mgr.isConnected) {
|
||||
val state = mgr.getState()
|
||||
_signalState.value = state.status
|
||||
_incomingCallId.value = state.incomingCallId
|
||||
_incomingCallerFp.value = state.incomingCallerFp
|
||||
_incomingCallerAlias.value = state.incomingCallerAlias
|
||||
|
||||
// Auto-connect to media room when call is set up
|
||||
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
|
||||
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
|
||||
startCallInternal(state.callSetupRelay, state.callSetupRoom)
|
||||
}
|
||||
}
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = null
|
||||
}
|
||||
|
||||
/** Place a direct call to the target fingerprint */
|
||||
fun placeDirectCall() {
|
||||
val target = _targetFingerprint.value.trim()
|
||||
if (target.isEmpty()) {
|
||||
_errorMessage.value = "Enter a fingerprint to call"
|
||||
return
|
||||
}
|
||||
signalManager?.placeCall(target)
|
||||
}
|
||||
|
||||
/** Answer an incoming direct call */
|
||||
fun answerIncomingCall(mode: Int = 2) {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
signalManager?.answerCall(callId, mode)
|
||||
}
|
||||
|
||||
/** Reject an incoming direct call */
|
||||
fun rejectIncomingCall() {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
signalManager?.answerCall(callId, 0)
|
||||
}
|
||||
|
||||
/** Hang up direct call — media ends, signal stays alive */
|
||||
fun hangupDirectCall() {
|
||||
signalManager?.hangup()
|
||||
engine?.stopCall()
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WzpCall"
|
||||
val DEFAULT_SERVERS = listOf(
|
||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||
)
|
||||
const val DEFAULT_ROOM = "general"
|
||||
}
|
||||
|
||||
fun setContext(context: Context) {
|
||||
val appCtx = context.applicationContext
|
||||
appContext = appCtx
|
||||
if (audioPipeline == null) {
|
||||
audioPipeline = AudioPipeline(appCtx)
|
||||
}
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
}
|
||||
if (settings == null) {
|
||||
settings = SettingsRepository(appCtx)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val s = settings ?: return
|
||||
s.loadServers()?.let { saved ->
|
||||
if (saved.isNotEmpty()) _servers.value = saved
|
||||
}
|
||||
_selectedServer.value = s.loadSelectedServer().coerceIn(0, _servers.value.lastIndex)
|
||||
_roomName.value = s.loadRoom()
|
||||
_alias.value = s.getOrCreateAlias()
|
||||
_preferIPv6.value = s.loadPreferIPv6()
|
||||
_playoutGainDb.value = s.loadPlayoutGain()
|
||||
_captureGainDb.value = s.loadCaptureGain()
|
||||
_seedHex.value = s.getOrCreateSeedHex()
|
||||
_aecEnabled.value = s.loadAecEnabled()
|
||||
_debugRecording.value = s.loadDebugRecording()
|
||||
_codecChoice.value = s.loadCodecChoice()
|
||||
_recentRooms.value = s.loadRecentRooms()
|
||||
}
|
||||
|
||||
fun selectServer(index: Int) {
|
||||
if (index in _servers.value.indices) {
|
||||
_selectedServer.value = index
|
||||
settings?.saveSelectedServer(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreferIPv6(prefer: Boolean) {
|
||||
_preferIPv6.value = prefer
|
||||
settings?.savePreferIPv6(prefer)
|
||||
}
|
||||
|
||||
fun addServer(hostPort: String, label: String) {
|
||||
val current = _servers.value.toMutableList()
|
||||
current.add(ServerEntry(hostPort, label))
|
||||
_servers.value = current
|
||||
settings?.saveServers(current)
|
||||
}
|
||||
|
||||
fun removeServer(index: Int) {
|
||||
if (index < DEFAULT_SERVERS.size) return // don't remove built-in servers
|
||||
val current = _servers.value.toMutableList()
|
||||
if (index in current.indices) {
|
||||
current.removeAt(index)
|
||||
_servers.value = current
|
||||
if (_selectedServer.value >= current.size) {
|
||||
_selectedServer.value = 0
|
||||
}
|
||||
settings?.saveServers(current)
|
||||
settings?.saveSelectedServer(_selectedServer.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Batch-apply servers and selection from Settings draft state. */
|
||||
fun applyServers(servers: List<ServerEntry>, selected: Int) {
|
||||
_servers.value = servers
|
||||
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
|
||||
settings?.saveServers(servers)
|
||||
settings?.saveSelectedServer(_selectedServer.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping all servers via native QUIC. Requires engine to be initialized.
|
||||
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
|
||||
*/
|
||||
fun pingAllServers() {
|
||||
viewModelScope.launch {
|
||||
// Ensure engine exists
|
||||
if (engine == null || engine?.isInitialized != true) {
|
||||
try {
|
||||
engine = WzpEngine(this@CallViewModel).also { it.init() }
|
||||
engineInitialized = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "engine init for ping failed: $e")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
val eng = engine ?: return@launch
|
||||
|
||||
val results = mutableMapOf<String, PingResult>()
|
||||
val known = mutableMapOf<String, String>()
|
||||
_servers.value.forEach { server ->
|
||||
val json = withContext(Dispatchers.IO) {
|
||||
eng.pingRelay(server.address)
|
||||
}
|
||||
if (json != null) {
|
||||
try {
|
||||
val obj = JSONObject(json)
|
||||
val rtt = obj.getInt("rtt_ms")
|
||||
val fp = obj.optString("server_fingerprint", "")
|
||||
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
|
||||
// TOFU
|
||||
if (fp.isNotEmpty()) {
|
||||
val saved = settings?.loadServerFingerprint(server.address)
|
||||
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
|
||||
known[server.address] = saved ?: fp
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
_pingResults.value = results
|
||||
_knownFingerprints.value = known
|
||||
}
|
||||
}
|
||||
|
||||
/** Load saved TOFU fingerprints. */
|
||||
fun loadSavedFingerprints() {
|
||||
val known = mutableMapOf<String, String>()
|
||||
_servers.value.forEach { server ->
|
||||
settings?.loadServerFingerprint(server.address)?.let {
|
||||
known[server.address] = it
|
||||
}
|
||||
}
|
||||
_knownFingerprints.value = known
|
||||
}
|
||||
|
||||
/** Get lock status for a server. */
|
||||
fun lockStatus(address: String): LockStatus {
|
||||
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
||||
if (!pr.reachable) return LockStatus.OFFLINE
|
||||
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
||||
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||
}
|
||||
|
||||
fun setRoomName(name: String) {
|
||||
_roomName.value = name
|
||||
settings?.saveRoom(name)
|
||||
}
|
||||
|
||||
fun setPlayoutGainDb(db: Float) {
|
||||
_playoutGainDb.value = db
|
||||
audioPipeline?.playoutGainDb = db
|
||||
settings?.savePlayoutGain(db)
|
||||
}
|
||||
|
||||
fun setCaptureGainDb(db: Float) {
|
||||
_captureGainDb.value = db
|
||||
audioPipeline?.captureGainDb = db
|
||||
settings?.saveCaptureGain(db)
|
||||
}
|
||||
|
||||
fun setAlias(alias: String) {
|
||||
_alias.value = alias
|
||||
settings?.saveAlias(alias)
|
||||
}
|
||||
|
||||
fun restoreSeed(hex: String) {
|
||||
_seedHex.value = hex
|
||||
settings?.saveSeedHex(hex)
|
||||
}
|
||||
|
||||
fun setAecEnabled(enabled: Boolean) {
|
||||
_aecEnabled.value = enabled
|
||||
settings?.saveAecEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setDebugRecording(enabled: Boolean) {
|
||||
_debugRecording.value = enabled
|
||||
settings?.saveDebugRecording(enabled)
|
||||
}
|
||||
|
||||
fun setCodecChoice(choice: Int) {
|
||||
_codecChoice.value = choice
|
||||
settings?.saveCodecChoice(choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||
* since Rust's DNS resolution may not work on Android.
|
||||
* Returns "ip:port" string.
|
||||
*/
|
||||
private fun resolveToIp(hostPort: String): String {
|
||||
val parts = hostPort.split(":")
|
||||
if (parts.size != 2) return hostPort
|
||||
val host = parts[0]
|
||||
val port = parts[1]
|
||||
|
||||
// Already an IP address — return as-is
|
||||
if (host.matches(Regex("""\d+\.\d+\.\d+\.\d+"""))) return hostPort
|
||||
if (host.contains(":")) return hostPort // IPv6 literal
|
||||
|
||||
return try {
|
||||
val addresses = InetAddress.getAllByName(host)
|
||||
val preferV6 = _preferIPv6.value
|
||||
val picked = if (preferV6) {
|
||||
addresses.firstOrNull { it is Inet6Address } ?: addresses.firstOrNull { it is Inet4Address }
|
||||
} else {
|
||||
addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull { it is Inet6Address }
|
||||
}
|
||||
if (picked != null) {
|
||||
val ip = picked.hostAddress ?: host
|
||||
val formatted = if (picked is Inet6Address) "[$ip]:$port" else "$ip:$port"
|
||||
formatted
|
||||
} else {
|
||||
hostPort
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
hostPort // resolution failed — pass through and let Rust try
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
|
||||
private fun teardown(stopService: Boolean = true) {
|
||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||
val hadCall = audioStarted
|
||||
CallService.onStopFromNotification = null
|
||||
stopAudio() // sets running=false (non-blocking)
|
||||
stopStatsPolling()
|
||||
|
||||
// Wait for audio threads to exit their loops before destroying the engine.
|
||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
||||
val drained = audioPipeline?.awaitDrain() ?: true
|
||||
if (!drained) {
|
||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
||||
}
|
||||
audioPipeline = null
|
||||
|
||||
Log.i(TAG, "teardown: stopping engine")
|
||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
_callState.value = 0
|
||||
if (hadCall) {
|
||||
_debugReportAvailable.value = true
|
||||
}
|
||||
if (stopService) {
|
||||
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
/** Accept the new server key and proceed with the call. */
|
||||
fun acceptNewFingerprint() {
|
||||
val info = _keyWarning.value ?: return
|
||||
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
||||
it[info.address] = info.newFp
|
||||
}
|
||||
settings?.saveServerFingerprint(info.address, info.newFp)
|
||||
_keyWarning.value = null
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
fun dismissKeyWarning() {
|
||||
_keyWarning.value = null
|
||||
}
|
||||
|
||||
fun startCall() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
// Check for key change before connecting
|
||||
val ls = lockStatus(serverEntry.address)
|
||||
if (ls == LockStatus.CHANGED) {
|
||||
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
||||
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
||||
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
||||
return
|
||||
}
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
/** Start a call to a specific relay + room (used by direct call setup). */
|
||||
private fun startCallInternal(relay: String, room: String) {
|
||||
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
|
||||
try {
|
||||
// Don't teardown — keep the signal connection alive
|
||||
engine = WzpEngine(this)
|
||||
engine!!.init()
|
||||
engineInitialized = true
|
||||
_callState.value = 1
|
||||
_errorMessage.value = null
|
||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
||||
Log.w(TAG, "service start err: $e")
|
||||
}
|
||||
startStatsPolling()
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val seed = _seedHex.value
|
||||
val name = _alias.value
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||
CallService.onStopFromNotification = { stopCall() }
|
||||
if (result != 0) {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Failed to connect to call room (code $result)"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCallDirect error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCallDirect error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCallInternal() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
val room = _roomName.value
|
||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
lastCallServer = serverEntry.address
|
||||
settings?.addRecentRoom(serverEntry.address, room)
|
||||
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||
debugReporter?.prepareForCall()
|
||||
try {
|
||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||
teardown(stopService = false)
|
||||
|
||||
Log.i(TAG, "startCall: creating engine")
|
||||
engine = WzpEngine(this)
|
||||
engine!!.init()
|
||||
engineInitialized = true
|
||||
_callState.value = 1
|
||||
_errorMessage.value = null
|
||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
||||
Log.w(TAG, "service start err: $e")
|
||||
}
|
||||
startStatsPolling()
|
||||
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val relay = resolveToIp(serverEntry.address)
|
||||
val seed = _seedHex.value
|
||||
val name = _alias.value
|
||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||
Log.i(TAG, "startCall: engine returned $result")
|
||||
// Only wire up notification callback after engine is running
|
||||
CallService.onStopFromNotification = { stopCall() }
|
||||
if (result != 0) {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Failed to start call (code $result)"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCall IO error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCall error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCall() {
|
||||
Log.i(TAG, "stopCall")
|
||||
teardown()
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val newMuted = !_isMuted.value
|
||||
_isMuted.value = newMuted
|
||||
try { engine?.setMute(newMuted) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun toggleSpeaker() {
|
||||
val newSpeaker = !_isSpeaker.value
|
||||
_isSpeaker.value = newSpeaker
|
||||
audioRouteManager?.setSpeaker(newSpeaker)
|
||||
}
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun sendDebugReport() {
|
||||
val reporter = debugReporter ?: return
|
||||
_debugReportStatus.value = "Preparing debug report..."
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val zipFile = reporter.collectZip(
|
||||
callDurationSecs = lastCallDuration,
|
||||
finalStatsJson = lastStatsJson,
|
||||
aecEnabled = _aecEnabled.value,
|
||||
alias = _alias.value,
|
||||
server = lastCallServer,
|
||||
room = _roomName.value
|
||||
)
|
||||
if (zipFile != null) {
|
||||
_debugZipReady.value = zipFile
|
||||
_debugReportStatus.value = "ready"
|
||||
} else {
|
||||
_debugReportStatus.value = "Error: failed to create zip"
|
||||
}
|
||||
_debugReportAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by Activity after email intent is launched. */
|
||||
fun onDebugReportSent() {
|
||||
_debugZipReady.value = null
|
||||
_debugReportStatus.value = null
|
||||
}
|
||||
|
||||
fun dismissDebugReport() {
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
_debugZipReady.value = null
|
||||
}
|
||||
|
||||
// WzpCallback
|
||||
override fun onCallStateChanged(state: Int) { _callState.value = state }
|
||||
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
|
||||
override fun onError(code: Int, message: String) { _errorMessage.value = "Error $code: $message" }
|
||||
|
||||
private fun startAudio() {
|
||||
if (audioStarted) return
|
||||
val e = engine ?: return
|
||||
val ctx = appContext ?: return
|
||||
// Create a fresh pipeline each call to avoid stale threads
|
||||
audioPipeline = AudioPipeline(ctx).also {
|
||||
it.playoutGainDb = _playoutGainDb.value
|
||||
it.captureGainDb = _captureGainDb.value
|
||||
it.aecEnabled = _aecEnabled.value
|
||||
it.debugRecording = _debugRecording.value
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
audioStarted = false
|
||||
}
|
||||
|
||||
private fun startStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val json = engine?.getStats() ?: "{}"
|
||||
if (json.isNotEmpty()) {
|
||||
Log.d(TAG, "raw: $json")
|
||||
lastStatsJson = json
|
||||
val s = CallStats.fromJson(json)
|
||||
lastCallDuration = s.durationSecs
|
||||
_stats.value = s
|
||||
// Only update callState from media engine stats (not signal)
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
}
|
||||
if (s.state == 2 && !audioStarted) {
|
||||
startAudio()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.i(TAG, "onCleared")
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
||||
package com.wzp.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
||||
* from a hex fingerprint string. Identical algorithm to the desktop
|
||||
* TypeScript implementation in identicon.ts.
|
||||
*/
|
||||
@Composable
|
||||
fun Identicon(
|
||||
fingerprint: String,
|
||||
size: Dp = 36.dp,
|
||||
clickToCopy: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val bytes = hashBytes(fingerprint)
|
||||
val (bg, fg) = deriveColors(bytes)
|
||||
val grid = buildGrid(bytes)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(size * 0.12f))
|
||||
.then(
|
||||
if (clickToCopy && fingerprint.isNotEmpty()) {
|
||||
Modifier.clickable {
|
||||
clipboard.setText(AnnotatedString(fingerprint))
|
||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
val cellW = this.size.width / 5f
|
||||
val cellH = this.size.height / 5f
|
||||
|
||||
// Background
|
||||
drawRect(color = bg, size = this.size)
|
||||
|
||||
// Foreground cells
|
||||
for (y in 0 until 5) {
|
||||
for (x in 0 until 5) {
|
||||
if (grid[y][x]) {
|
||||
drawRect(
|
||||
color = fg,
|
||||
topLeft = Offset(x * cellW, y * cellH),
|
||||
size = Size(cellW, cellH),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint text that copies to clipboard on tap.
|
||||
*/
|
||||
@Composable
|
||||
fun CopyableFingerprint(
|
||||
fingerprint: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
color: Color = Color.Unspecified,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
androidx.compose.material3.Text(
|
||||
text = fingerprint,
|
||||
style = style,
|
||||
color = color,
|
||||
modifier = modifier.clickable {
|
||||
if (fingerprint.isNotEmpty()) {
|
||||
clipboard.setText(AnnotatedString(fingerprint))
|
||||
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helpers (matching desktop identicon.ts) ---
|
||||
|
||||
private fun hashBytes(hex: String): List<Int> {
|
||||
val clean = hex.filter { it.isLetterOrDigit() }
|
||||
val bytes = mutableListOf<Int>()
|
||||
var i = 0
|
||||
while (i + 1 < clean.length) {
|
||||
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
||||
bytes.add(b)
|
||||
i += 2
|
||||
}
|
||||
// Pad to at least 16 bytes
|
||||
while (bytes.size < 16) bytes.add(0)
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
||||
val hue1 = bytes[0] * 360f / 256f
|
||||
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
||||
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
||||
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
||||
return bg to fg
|
||||
}
|
||||
|
||||
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
||||
return (0 until 5).map { y ->
|
||||
val left = (0 until 3).map { x ->
|
||||
val idx = 2 + y * 3 + x
|
||||
bytes[idx % bytes.size] > 128
|
||||
}
|
||||
// Mirror: col3 = col1, col4 = col0
|
||||
listOf(left[0], left[1], left[2], left[1], left[0])
|
||||
}
|
||||
}
|
||||
|
||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||
val k = { n: Float -> (n + h / 30f) % 12f }
|
||||
val a = s * min(l, 1f - l)
|
||||
val f = { n: Float ->
|
||||
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
||||
}
|
||||
return Color(f(0f), f(8f), f(4f))
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
package com.wzp.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.wzp.ui.call.CallViewModel
|
||||
import com.wzp.ui.call.ServerEntry
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: CallViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Snapshot current values into local draft state
|
||||
val currentAlias by viewModel.alias.collectAsState()
|
||||
val currentSeedHex by viewModel.seedHex.collectAsState()
|
||||
val currentServers by viewModel.servers.collectAsState()
|
||||
val currentSelectedServer by viewModel.selectedServer.collectAsState()
|
||||
val currentRoomName by viewModel.roomName.collectAsState()
|
||||
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
|
||||
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
|
||||
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
|
||||
|
||||
// Draft state — initialized from current values
|
||||
var draftAlias by remember { mutableStateOf(currentAlias) }
|
||||
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
|
||||
val draftServers = remember { currentServers.toMutableStateList() }
|
||||
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
|
||||
var draftRoomName by remember { mutableStateOf(currentRoomName) }
|
||||
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
|
||||
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
|
||||
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
|
||||
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
|
||||
|
||||
// Track if anything changed
|
||||
val hasChanges = draftAlias != currentAlias ||
|
||||
draftSeedHex != currentSeedHex ||
|
||||
draftServers.toList() != currentServers ||
|
||||
draftSelectedServer != currentSelectedServer ||
|
||||
draftRoomName != currentRoomName ||
|
||||
draftPreferIPv6 != currentPreferIPv6 ||
|
||||
draftPlayoutGain != currentPlayoutGain ||
|
||||
draftCaptureGain != currentCaptureGain ||
|
||||
draftAecEnabled != currentAecEnabled
|
||||
|
||||
var showAddServerDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("< Back")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// Save button — only enabled when changes exist
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setAlias(draftAlias)
|
||||
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
|
||||
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
|
||||
viewModel.setRoomName(draftRoomName)
|
||||
viewModel.setPreferIPv6(draftPreferIPv6)
|
||||
viewModel.setPlayoutGainDb(draftPlayoutGain)
|
||||
viewModel.setCaptureGainDb(draftCaptureGain)
|
||||
viewModel.setAecEnabled(draftAecEnabled)
|
||||
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
},
|
||||
enabled = hasChanges
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// --- Identity ---
|
||||
SectionHeader("Identity")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftAlias,
|
||||
onValueChange = { draftAlias = it },
|
||||
label = { Text("Display Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Fingerprint display with identicon
|
||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||
Text(
|
||||
text = "Fingerprint",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
com.wzp.ui.components.Identicon(
|
||||
fingerprint = draftSeedHex,
|
||||
size = 40.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
com.wzp.ui.components.CopyableFingerprint(
|
||||
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Key backup/restore
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
|
||||
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text("Copy Key")
|
||||
}
|
||||
OutlinedButton(onClick = { showRestoreKeyDialog = true }) {
|
||||
Text("Restore Key")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Audio ---
|
||||
SectionHeader("Audio Defaults")
|
||||
|
||||
GainSlider(
|
||||
label = "Voice Volume",
|
||||
gainDb = draftPlayoutGain,
|
||||
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
GainSlider(
|
||||
label = "Mic Gain",
|
||||
gainDb = draftCaptureGain,
|
||||
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Echo Cancellation (AEC)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Disable if audio sounds distorted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = draftAecEnabled,
|
||||
onCheckedChange = { draftAecEnabled = it }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
||||
val qualityLabels = listOf(
|
||||
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
||||
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
||||
)
|
||||
// Map slider position to JNI profile int:
|
||||
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
||||
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
||||
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
||||
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
||||
val qualityColors = listOf(
|
||||
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
||||
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
||||
)
|
||||
val currentCodec by viewModel.codecChoice.collectAsState()
|
||||
val sliderPos = profileToSlider[currentCodec] ?: 3
|
||||
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = "Decode always accepts all codecs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = qualityLabels[sliderPos],
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = qualityColors[sliderPos]
|
||||
)
|
||||
Slider(
|
||||
value = sliderPos.toFloat(),
|
||||
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
||||
valueRange = 0f..7f,
|
||||
steps = 6,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
||||
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Servers ---
|
||||
SectionHeader("Servers")
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
draftServers.forEachIndexed { idx, entry ->
|
||||
val isSelected = draftSelectedServer == idx
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
FilledTonalIconButton(
|
||||
onClick = { draftSelectedServer = idx },
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp)
|
||||
.height(36.dp)
|
||||
.width(140.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = if (isSelected) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = entry.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
// Show remove button for non-default servers
|
||||
if (idx >= 2) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
draftServers.removeAt(idx)
|
||||
if (draftSelectedServer >= draftServers.size) {
|
||||
draftSelectedServer = 0
|
||||
}
|
||||
},
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("X", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showAddServerDialog = true },
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("+ Add Server")
|
||||
}
|
||||
|
||||
// Show selected server address
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Network ---
|
||||
SectionHeader("Network")
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Prefer IPv6",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = draftPreferIPv6,
|
||||
onCheckedChange = { draftPreferIPv6 = it }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Room ---
|
||||
SectionHeader("Room")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftRoomName,
|
||||
onValueChange = { draftRoomName = it },
|
||||
label = { Text("Default Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddServerDialog) {
|
||||
AddServerDialog(
|
||||
onDismiss = { showAddServerDialog = false },
|
||||
onAdd = { host, port, label ->
|
||||
draftServers.add(ServerEntry("$host:$port", label))
|
||||
showAddServerDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showRestoreKeyDialog) {
|
||||
RestoreKeyDialog(
|
||||
onDismiss = { showRestoreKeyDialog = false },
|
||||
onRestore = { hex ->
|
||||
draftSeedHex = hex
|
||||
showRestoreKeyDialog = false
|
||||
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val sign = if (gainDb >= 0) "+" else ""
|
||||
Text(
|
||||
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = gainDb,
|
||||
onValueChange = onGainChange,
|
||||
valueRange = -20f..20f,
|
||||
steps = 0,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddServerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (host: String, port: String, label: String) -> Unit
|
||||
) {
|
||||
var host by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("4433") }
|
||||
var label by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Server") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host (IP or domain)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
label = { Text("Label (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (host.isNotBlank()) {
|
||||
val displayLabel = label.ifBlank { host }
|
||||
onAdd(host.trim(), port.trim(), displayLabel)
|
||||
}
|
||||
}
|
||||
) { Text("Add") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreKeyDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onRestore: (hex: String) -> Unit
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Restore Identity Key") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Paste your 64-character hex key below. This will replace your current identity.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it.trim().lowercase()
|
||||
error = null
|
||||
},
|
||||
label = { Text("Identity Key (hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = error != null
|
||||
)
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val cleaned = keyInput.replace("\\s".toRegex(), "")
|
||||
if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) {
|
||||
error = "Key must be exactly 64 hex characters"
|
||||
} else {
|
||||
onRestore(cleaned)
|
||||
}
|
||||
}
|
||||
) { Text("Restore") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="debug" path="." />
|
||||
</paths>
|
||||
@@ -1,4 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,6 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
5
android/gradlew
vendored
5
android/gradlew
vendored
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Gradle wrapper script
|
||||
APP_HOME=$(cd "$(dirname "$0")" && pwd)
|
||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
@@ -1,18 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "WZPhone"
|
||||
include(":app")
|
||||
@@ -1,34 +0,0 @@
|
||||
[package]
|
||||
name = "wzp-android"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "WarzonePhone Android native VoIP engine — Oboe audio, JNI bridge, call pipeline"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wzp-proto = { workspace = true }
|
||||
wzp-codec = { workspace = true }
|
||||
wzp-fec = { workspace = true }
|
||||
wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = "1"
|
||||
libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
tracing-android = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
@@ -1,154 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
|
||||
if target.contains("android") {
|
||||
// Override broken static getauxval from compiler-rt that crashes
|
||||
// in shared libraries. Must be compiled first to take link priority.
|
||||
cc::Build::new()
|
||||
.file("cpp/getauxval_fix.c")
|
||||
.compile("getauxval_fix");
|
||||
|
||||
let oboe_dir = fetch_oboe();
|
||||
match oboe_dir {
|
||||
Some(oboe_path) => {
|
||||
println!("cargo:warning=Building with Oboe from {:?}", oboe_path);
|
||||
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
// Use shared libc++ — avoids pulling in static libc stubs
|
||||
// that crash in shared libraries (getauxval, pthread_create, etc.)
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.include("cpp")
|
||||
.include(oboe_path.join("include"))
|
||||
.include(oboe_path.join("src"))
|
||||
.define("WZP_HAS_OBOE", None)
|
||||
.file("cpp/oboe_bridge.cpp");
|
||||
|
||||
// Compile all Oboe source files
|
||||
let src_dir = oboe_path.join("src");
|
||||
add_cpp_files_recursive(&mut build, &src_dir);
|
||||
|
||||
build.compile("oboe_bridge");
|
||||
}
|
||||
None => {
|
||||
println!("cargo:warning=Oboe not found, building with stub");
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("oboe_bridge");
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic C++ runtime — libc++_shared.so must be in jniLibs alongside
|
||||
// libwzp_android.so. We copy it there from the NDK sysroot.
|
||||
//
|
||||
// WHY NOT STATIC: libc++_static.a + libc++abi.a transitively pull in
|
||||
// object files from libc.a (static libc) which contain broken stubs for
|
||||
// getauxval, __init_tcb, pthread_create, etc. These stubs only work in
|
||||
// statically-linked executables. In shared libraries loaded by dlopen(),
|
||||
// they SIGSEGV because the static libc init hasn't run.
|
||||
// Google's official recommendation: use libc++_shared.so for native libs.
|
||||
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME") {
|
||||
let arch = if target.contains("aarch64") {
|
||||
"aarch64-linux-android"
|
||||
} else if target.contains("armv7") {
|
||||
"arm-linux-androideabi"
|
||||
} else if target.contains("x86_64") {
|
||||
"x86_64-linux-android"
|
||||
} else {
|
||||
"aarch64-linux-android"
|
||||
};
|
||||
let lib_dir = format!(
|
||||
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||
|
||||
// Copy libc++_shared.so to the jniLibs directory
|
||||
let shared_so = format!("{lib_dir}/libc++_shared.so");
|
||||
if std::path::Path::new(&shared_so).exists() {
|
||||
let jni_abi = if target.contains("aarch64") {
|
||||
"arm64-v8a"
|
||||
} else if target.contains("armv7") {
|
||||
"armeabi-v7a"
|
||||
} else {
|
||||
"arm64-v8a"
|
||||
};
|
||||
// Try to copy to the Gradle jniLibs directory
|
||||
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let jni_dir = format!(
|
||||
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
|
||||
);
|
||||
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
||||
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
||||
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oboe needs liblog and libOpenSLES from Android
|
||||
println!("cargo:rustc-link-lib=log");
|
||||
println!("cargo:rustc-link-lib=OpenSLES");
|
||||
} else {
|
||||
// Non-Android: always use stub
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("oboe_bridge");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively add all .cpp files from a directory to a cc::Build.
|
||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
||||
if !dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_cpp_files_recursive(build, &path);
|
||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
||||
build.file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to find or fetch Oboe headers + source.
|
||||
fn fetch_oboe() -> Option<PathBuf> {
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let oboe_dir = out_dir.join("oboe");
|
||||
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
return Some(oboe_dir);
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"--branch=1.8.1",
|
||||
"https://github.com/google/oboe.git",
|
||||
oboe_dir.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
Some(oboe_dir)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Override the broken static getauxval from compiler-rt/CRT.
|
||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
||||
// This version calls the real bionic getauxval via dlsym.
|
||||
#ifdef __ANDROID__
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
||||
|
||||
unsigned long getauxval(unsigned long type) {
|
||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
||||
if (!real_getauxval) {
|
||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
||||
if (!real_getauxval) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return real_getauxval(type);
|
||||
}
|
||||
#endif
|
||||
@@ -1,278 +0,0 @@
|
||||
// Full Oboe implementation for Android
|
||||
// This file is compiled only when targeting Android
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <oboe/Oboe.h>
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
|
||||
#define LOG_TAG "wzp-oboe"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ring buffer helpers (SPSC, lock-free)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
||||
const wzp_atomic_int* read_idx,
|
||||
int32_t capacity) {
|
||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||
int32_t avail = w - r;
|
||||
if (avail < 0) avail += capacity;
|
||||
return avail;
|
||||
}
|
||||
|
||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
||||
const wzp_atomic_int* read_idx,
|
||||
int32_t capacity) {
|
||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
||||
}
|
||||
|
||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
||||
const int16_t* src, int32_t count) {
|
||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
||||
for (int32_t i = 0; i < count; i++) {
|
||||
buf[w] = src[i];
|
||||
w++;
|
||||
if (w >= capacity) w = 0;
|
||||
}
|
||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
||||
}
|
||||
|
||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
||||
int16_t* dst, int32_t count) {
|
||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||
for (int32_t i = 0; i < count; i++) {
|
||||
dst[i] = buf[r];
|
||||
r++;
|
||||
if (r >= capacity) r = 0;
|
||||
}
|
||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
||||
static const WzpOboeRings* g_rings = nullptr;
|
||||
static std::atomic<bool> g_running{false};
|
||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
||||
public:
|
||||
oboe::DataCallbackResult onAudioReady(
|
||||
oboe::AudioStream* stream,
|
||||
void* audioData,
|
||||
int32_t numFrames) override {
|
||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
||||
return oboe::DataCallbackResult::Stop;
|
||||
}
|
||||
|
||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
||||
int32_t avail = ring_available_write(g_rings->capture_write_idx,
|
||||
g_rings->capture_read_idx,
|
||||
g_rings->capture_capacity);
|
||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
||||
if (to_write > 0) {
|
||||
ring_write(g_rings->capture_buf, g_rings->capture_capacity,
|
||||
g_rings->capture_write_idx, g_rings->capture_read_idx,
|
||||
src, to_write);
|
||||
}
|
||||
|
||||
// Update latency estimate
|
||||
auto result = stream->calculateLatencyMillis();
|
||||
if (result) {
|
||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playout callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
||||
public:
|
||||
oboe::DataCallbackResult onAudioReady(
|
||||
oboe::AudioStream* stream,
|
||||
void* audioData,
|
||||
int32_t numFrames) override {
|
||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
||||
return oboe::DataCallbackResult::Stop;
|
||||
}
|
||||
|
||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
||||
int32_t avail = ring_available_read(g_rings->playout_write_idx,
|
||||
g_rings->playout_read_idx,
|
||||
g_rings->playout_capacity);
|
||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
||||
|
||||
if (to_read > 0) {
|
||||
ring_read(g_rings->playout_buf, g_rings->playout_capacity,
|
||||
g_rings->playout_write_idx, g_rings->playout_read_idx,
|
||||
dst, to_read);
|
||||
}
|
||||
// Fill remainder with silence on underrun
|
||||
if (to_read < numFrames) {
|
||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
||||
}
|
||||
|
||||
// Update latency estimate
|
||||
auto result = stream->calculateLatencyMillis();
|
||||
if (result) {
|
||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
};
|
||||
|
||||
static CaptureCallback g_capture_cb;
|
||||
static PlayoutCallback g_playout_cb;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public C API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
if (g_running.load(std::memory_order_relaxed)) {
|
||||
LOGW("wzp_oboe_start: already running");
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_rings = rings;
|
||||
|
||||
// Build capture stream
|
||||
oboe::AudioStreamBuilder captureBuilder;
|
||||
captureBuilder.setDirection(oboe::Direction::Input)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
||||
->setDataCallback(&g_capture_cb);
|
||||
|
||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Build playout stream
|
||||
oboe::AudioStreamBuilder playoutBuilder;
|
||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setUsage(oboe::Usage::VoiceCommunication)
|
||||
->setDataCallback(&g_playout_cb);
|
||||
|
||||
result = playoutBuilder.openStream(g_playout_stream);
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
||||
g_capture_stream->close();
|
||||
g_capture_stream.reset();
|
||||
return -3;
|
||||
}
|
||||
|
||||
g_running.store(true, std::memory_order_release);
|
||||
|
||||
// Start both streams
|
||||
result = g_capture_stream->requestStart();
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
||||
g_running.store(false, std::memory_order_release);
|
||||
g_capture_stream->close();
|
||||
g_playout_stream->close();
|
||||
g_capture_stream.reset();
|
||||
g_playout_stream.reset();
|
||||
return -4;
|
||||
}
|
||||
|
||||
result = g_playout_stream->requestStart();
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
||||
g_running.store(false, std::memory_order_release);
|
||||
g_capture_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_playout_stream->close();
|
||||
g_capture_stream.reset();
|
||||
g_playout_stream.reset();
|
||||
return -5;
|
||||
}
|
||||
|
||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
g_running.store(false, std::memory_order_release);
|
||||
|
||||
if (g_capture_stream) {
|
||||
g_capture_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_capture_stream.reset();
|
||||
}
|
||||
if (g_playout_stream) {
|
||||
g_playout_stream->requestStop();
|
||||
g_playout_stream->close();
|
||||
g_playout_stream.reset();
|
||||
}
|
||||
|
||||
g_rings = nullptr;
|
||||
LOGI("Oboe stopped");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
#else
|
||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
||||
// Provide empty implementations just in case.
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config; (void)rings;
|
||||
return -99;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {}
|
||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
||||
int wzp_oboe_is_running(void) { return 0; }
|
||||
|
||||
#endif // __ANDROID__
|
||||
@@ -1,43 +0,0 @@
|
||||
#ifndef WZP_OBOE_BRIDGE_H
|
||||
#define WZP_OBOE_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
||||
extern "C" {
|
||||
#else
|
||||
#include <stdatomic.h>
|
||||
typedef atomic_int wzp_atomic_int;
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int32_t sample_rate;
|
||||
int32_t frames_per_burst;
|
||||
int32_t channel_count;
|
||||
} WzpOboeConfig;
|
||||
|
||||
typedef struct {
|
||||
int16_t* capture_buf;
|
||||
int32_t capture_capacity;
|
||||
wzp_atomic_int* capture_write_idx;
|
||||
wzp_atomic_int* capture_read_idx;
|
||||
|
||||
int16_t* playout_buf;
|
||||
int32_t playout_capacity;
|
||||
wzp_atomic_int* playout_write_idx;
|
||||
wzp_atomic_int* playout_read_idx;
|
||||
} WzpOboeRings;
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
||||
void wzp_oboe_stop(void);
|
||||
float wzp_oboe_capture_latency_ms(void);
|
||||
float wzp_oboe_playout_latency_ms(void);
|
||||
int wzp_oboe_is_running(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // WZP_OBOE_BRIDGE_H
|
||||
@@ -1,27 +0,0 @@
|
||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
#include <stdio.h>
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config;
|
||||
(void)rings;
|
||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return 0;
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
//! Lock-free SPSC ring buffer audio backend for Android (Oboe).
|
||||
//!
|
||||
//! The ring buffers are shared between Rust and C++: the Oboe callbacks
|
||||
//! (running on a high-priority audio thread) read/write directly into
|
||||
//! the buffers via atomic indices, while the Rust codec thread on the
|
||||
//! other side does the same.
|
||||
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
use tracing::info;
|
||||
#[allow(unused_imports)]
|
||||
use tracing::warn;
|
||||
|
||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
/// Default ring buffer capacity: 8 frames = 160 ms at 48 kHz.
|
||||
const RING_CAPACITY: usize = 7680;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations matching oboe_bridge.h
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct WzpOboeConfig {
|
||||
sample_rate: i32,
|
||||
frames_per_burst: i32,
|
||||
channel_count: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct WzpOboeRings {
|
||||
capture_buf: *mut i16,
|
||||
capture_capacity: i32,
|
||||
capture_write_idx: *mut AtomicI32,
|
||||
capture_read_idx: *mut AtomicI32,
|
||||
|
||||
playout_buf: *mut i16,
|
||||
playout_capacity: i32,
|
||||
playout_write_idx: *mut AtomicI32,
|
||||
playout_read_idx: *mut AtomicI32,
|
||||
}
|
||||
|
||||
unsafe impl Send for WzpOboeRings {}
|
||||
unsafe impl Sync for WzpOboeRings {}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
||||
fn wzp_oboe_stop();
|
||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
||||
fn wzp_oboe_is_running() -> i32;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SPSC Ring Buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Single-producer single-consumer lock-free ring buffer.
|
||||
///
|
||||
/// The producer calls `write()` and the consumer calls `read()`.
|
||||
/// Atomics use acquire/release ordering to ensure correct visibility
|
||||
/// across the Oboe audio thread and the Rust codec thread.
|
||||
pub struct RingBuffer {
|
||||
buf: Vec<i16>,
|
||||
capacity: usize,
|
||||
write_idx: AtomicI32,
|
||||
read_idx: AtomicI32,
|
||||
}
|
||||
|
||||
impl RingBuffer {
|
||||
/// Create a new ring buffer with the given capacity (in samples).
|
||||
///
|
||||
/// The actual usable capacity is `capacity - 1` to distinguish
|
||||
/// full from empty.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buf: vec![0i16; capacity],
|
||||
capacity,
|
||||
write_idx: AtomicI32::new(0),
|
||||
read_idx: AtomicI32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read.
|
||||
pub fn available_read(&self) -> usize {
|
||||
let w = self.write_idx.load(Ordering::Acquire);
|
||||
let r = self.read_idx.load(Ordering::Relaxed);
|
||||
let avail = w - r;
|
||||
if avail < 0 {
|
||||
(avail + self.capacity as i32) as usize
|
||||
} else {
|
||||
avail as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples that can be written before the buffer is full.
|
||||
pub fn available_write(&self) -> usize {
|
||||
self.capacity - 1 - self.available_read()
|
||||
}
|
||||
|
||||
/// Write samples into the ring buffer (producer side).
|
||||
///
|
||||
/// Returns the number of samples actually written (may be less than
|
||||
/// `data.len()` if the buffer is nearly full).
|
||||
pub fn write(&self, data: &[i16]) -> usize {
|
||||
let avail = self.available_write();
|
||||
let count = data.len().min(avail);
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
||||
let cap = self.capacity;
|
||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
||||
|
||||
for i in 0..count {
|
||||
// SAFETY: w is always in [0, capacity) and we are the sole producer.
|
||||
unsafe {
|
||||
*buf_ptr.add(w) = data[i];
|
||||
}
|
||||
w += 1;
|
||||
if w >= cap {
|
||||
w = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.write_idx.store(w as i32, Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring buffer (consumer side).
|
||||
///
|
||||
/// Returns the number of samples actually read (may be less than
|
||||
/// `out.len()` if the buffer doesn't have enough data).
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let avail = self.available_read();
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
||||
let cap = self.capacity;
|
||||
let buf_ptr = self.buf.as_ptr();
|
||||
|
||||
for i in 0..count {
|
||||
// SAFETY: r is always in [0, capacity) and we are the sole consumer.
|
||||
unsafe {
|
||||
out[i] = *buf_ptr.add(r);
|
||||
}
|
||||
r += 1;
|
||||
if r >= cap {
|
||||
r = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.read_idx.store(r as i32, Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the buffer data (for FFI).
|
||||
fn buf_ptr(&self) -> *mut i16 {
|
||||
self.buf.as_ptr() as *mut i16
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the write index atomic (for FFI).
|
||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the read index atomic (for FFI).
|
||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: The ring buffer is designed for SPSC use where producer and consumer
|
||||
// are on different threads. The atomic indices provide the synchronization.
|
||||
unsafe impl Send for RingBuffer {}
|
||||
unsafe impl Sync for RingBuffer {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oboe Backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Oboe-based audio backend for Android.
|
||||
///
|
||||
/// Owns two SPSC ring buffers (capture and playout) that are shared with
|
||||
/// the C++ Oboe callbacks via raw pointers. The Oboe callbacks run on
|
||||
/// high-priority audio threads managed by the Android audio system.
|
||||
pub struct OboeBackend {
|
||||
capture_ring: RingBuffer,
|
||||
playout_ring: RingBuffer,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl OboeBackend {
|
||||
/// Create a new backend with default ring buffer sizes (160 ms each).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
capture_ring: RingBuffer::new(RING_CAPACITY),
|
||||
playout_ring: RingBuffer::new(RING_CAPACITY),
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start Oboe audio streams.
|
||||
///
|
||||
/// This sets up the ring buffer pointers and calls into the C++ layer
|
||||
/// to open and start the capture and playout Oboe streams.
|
||||
pub fn start(&mut self) -> Result<(), anyhow::Error> {
|
||||
if self.started {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = WzpOboeConfig {
|
||||
sample_rate: 48_000,
|
||||
frames_per_burst: FRAME_SAMPLES as i32,
|
||||
channel_count: 1,
|
||||
};
|
||||
|
||||
let rings = WzpOboeRings {
|
||||
capture_buf: self.capture_ring.buf_ptr(),
|
||||
capture_capacity: self.capture_ring.capacity as i32,
|
||||
capture_write_idx: self.capture_ring.write_idx_ptr(),
|
||||
capture_read_idx: self.capture_ring.read_idx_ptr(),
|
||||
|
||||
playout_buf: self.playout_ring.buf_ptr(),
|
||||
playout_capacity: self.playout_ring.capacity as i32,
|
||||
playout_write_idx: self.playout_ring.write_idx_ptr(),
|
||||
playout_read_idx: self.playout_ring.read_idx_ptr(),
|
||||
};
|
||||
|
||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
||||
if ret != 0 {
|
||||
return Err(anyhow::anyhow!("wzp_oboe_start failed with code {}", ret));
|
||||
}
|
||||
|
||||
self.started = true;
|
||||
info!("Oboe backend started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop Oboe audio streams.
|
||||
pub fn stop(&mut self) {
|
||||
if !self.started {
|
||||
return;
|
||||
}
|
||||
unsafe { wzp_oboe_stop() };
|
||||
self.started = false;
|
||||
info!("Oboe backend stopped");
|
||||
}
|
||||
|
||||
/// Read captured audio samples from the capture ring buffer.
|
||||
///
|
||||
/// Returns the number of samples actually read. The caller should
|
||||
/// provide a buffer of at least `FRAME_SAMPLES` (960) samples.
|
||||
pub fn read_capture(&self, out: &mut [i16]) -> usize {
|
||||
self.capture_ring.read(out)
|
||||
}
|
||||
|
||||
/// Write audio samples to the playout ring buffer.
|
||||
///
|
||||
/// Returns the number of samples actually written.
|
||||
pub fn write_playout(&self, samples: &[i16]) -> usize {
|
||||
self.playout_ring.write(samples)
|
||||
}
|
||||
|
||||
/// Get the current capture latency in milliseconds (from Oboe).
|
||||
#[allow(unused)]
|
||||
pub fn capture_latency_ms(&self) -> f32 {
|
||||
unsafe { wzp_oboe_capture_latency_ms() }
|
||||
}
|
||||
|
||||
/// Get the current playout latency in milliseconds (from Oboe).
|
||||
#[allow(unused)]
|
||||
pub fn playout_latency_ms(&self) -> f32 {
|
||||
unsafe { wzp_oboe_playout_latency_ms() }
|
||||
}
|
||||
|
||||
/// Check if the Oboe streams are currently running.
|
||||
#[allow(unused)]
|
||||
pub fn is_running(&self) -> bool {
|
||||
unsafe { wzp_oboe_is_running() != 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OboeBackend {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread affinity / priority helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pin the current thread to the highest-numbered CPU cores (big cores on
|
||||
/// ARM big.LITTLE architectures). Falls back silently on failure.
|
||||
#[allow(unused)]
|
||||
pub fn pin_to_big_core() {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
unsafe {
|
||||
let num_cpus = libc::sysconf(libc::_SC_NPROCESSORS_ONLN);
|
||||
if num_cpus <= 0 {
|
||||
warn!("pin_to_big_core: could not determine CPU count");
|
||||
return;
|
||||
}
|
||||
let num_cpus = num_cpus as usize;
|
||||
|
||||
// Target the upper half of CPUs (big cores on most big.LITTLE SoCs)
|
||||
let start = num_cpus / 2;
|
||||
let mut set: libc::cpu_set_t = std::mem::zeroed();
|
||||
libc::CPU_ZERO(&mut set);
|
||||
for cpu in start..num_cpus {
|
||||
libc::CPU_SET(cpu, &mut set);
|
||||
}
|
||||
|
||||
let ret = libc::sched_setaffinity(
|
||||
0, // current thread
|
||||
std::mem::size_of::<libc::cpu_set_t>(),
|
||||
&set,
|
||||
);
|
||||
if ret != 0 {
|
||||
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
||||
} else {
|
||||
info!(start, num_cpus, "pinned to big cores");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// No-op on non-Android
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set SCHED_FIFO real-time priority for the current thread.
|
||||
/// Falls back silently on failure (requires appropriate permissions on Android).
|
||||
#[allow(unused)]
|
||||
pub fn set_realtime_priority() {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
unsafe {
|
||||
let param = libc::sched_param {
|
||||
sched_priority: 2, // Low RT priority — enough for audio, safe
|
||||
};
|
||||
let ret = libc::sched_setscheduler(0, libc::SCHED_FIFO, ¶m);
|
||||
if ret != 0 {
|
||||
warn!(
|
||||
"sched_setscheduler(SCHED_FIFO) failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
} else {
|
||||
info!("set SCHED_FIFO priority 2");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// No-op on non-Android
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_write_read() {
|
||||
let ring = RingBuffer::new(16);
|
||||
let data = [1i16, 2, 3, 4, 5];
|
||||
assert_eq!(ring.write(&data), 5);
|
||||
assert_eq!(ring.available_read(), 5);
|
||||
|
||||
let mut out = [0i16; 5];
|
||||
assert_eq!(ring.read(&mut out), 5);
|
||||
assert_eq!(out, [1, 2, 3, 4, 5]);
|
||||
assert_eq!(ring.available_read(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_wraparound() {
|
||||
let ring = RingBuffer::new(8);
|
||||
let data = [10i16, 20, 30, 40, 50, 60]; // 6 samples, capacity 8 (usable 7)
|
||||
assert_eq!(ring.write(&data), 6);
|
||||
|
||||
let mut out = [0i16; 4];
|
||||
assert_eq!(ring.read(&mut out), 4);
|
||||
assert_eq!(out, [10, 20, 30, 40]);
|
||||
|
||||
// Now write more, which should wrap around
|
||||
let data2 = [70i16, 80, 90, 100];
|
||||
assert_eq!(ring.write(&data2), 4);
|
||||
|
||||
let mut out2 = [0i16; 6];
|
||||
assert_eq!(ring.read(&mut out2), 6);
|
||||
assert_eq!(out2, [50, 60, 70, 80, 90, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_full() {
|
||||
let ring = RingBuffer::new(4); // usable capacity = 3
|
||||
let data = [1i16, 2, 3, 4, 5];
|
||||
assert_eq!(ring.write(&data), 3); // Only 3 fit
|
||||
assert_eq!(ring.available_write(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oboe_backend_stub_start_stop() {
|
||||
let mut backend = OboeBackend::new();
|
||||
backend.start().expect("stub start should succeed");
|
||||
assert!(backend.started);
|
||||
backend.stop();
|
||||
assert!(!backend.started);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||
//!
|
||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||
//!
|
||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||
//! old buffer data. The reader detects the lap via `available() >
|
||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||
//!
|
||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
|
||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16]>,
|
||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||
write_pos: AtomicUsize,
|
||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||
read_pos: AtomicUsize,
|
||||
/// Incremented by reader when it detects it was lapped (overflow).
|
||||
overflow_count: AtomicU64,
|
||||
/// Incremented by reader when ring is empty (underrun).
|
||||
underrun_count: AtomicU64,
|
||||
}
|
||||
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
unsafe impl Send for AudioRing {}
|
||||
unsafe impl Sync for AudioRing {}
|
||||
|
||||
impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read (clamped to capacity).
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
}
|
||||
|
||||
/// Number of samples that can be written without overwriting unread data.
|
||||
pub fn free_space(&self) -> usize {
|
||||
RING_CAPACITY.saturating_sub(self.available())
|
||||
}
|
||||
|
||||
/// Write samples into the ring. Returns number of samples written.
|
||||
///
|
||||
/// If the ring is full, old data is silently overwritten. The reader
|
||||
/// will detect the lap and self-correct. The writer NEVER touches
|
||||
/// `read_pos` — this is the key invariant that prevents cursor desync.
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
|
||||
for i in 0..count {
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||
///
|
||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||
/// forward to the oldest valid data. This is safe because only the
|
||||
/// reader thread writes `read_pos`.
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
// Snap read_pos forward to oldest valid data in the buffer.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of overflow events (reader was lapped by writer).
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of underrun events (reader found empty buffer).
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//! Engine commands sent from the JNI/UI thread to the engine.
|
||||
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
/// Commands that can be sent to the running engine.
|
||||
pub enum EngineCommand {
|
||||
/// Mute or unmute the microphone.
|
||||
SetMute(bool),
|
||||
/// Enable or disable speaker (loudspeaker) mode.
|
||||
SetSpeaker(bool),
|
||||
/// Force a specific quality profile (overrides adaptive logic).
|
||||
ForceProfile(QualityProfile),
|
||||
/// Stop the call and shut down the engine.
|
||||
Stop,
|
||||
/// Place a direct call to a fingerprint (requires signal connection).
|
||||
PlaceCall { target_fingerprint: String },
|
||||
/// Answer an incoming direct call.
|
||||
AnswerCall {
|
||||
call_id: String,
|
||||
accept_mode: wzp_proto::CallAcceptMode,
|
||||
},
|
||||
/// Reject an incoming direct call.
|
||||
RejectCall { call_id: String },
|
||||
}
|
||||
@@ -1,897 +0,0 @@
|
||||
//! Engine orchestrator — manages the call lifecycle.
|
||||
//!
|
||||
//! IMPORTANT: On Android, pthread_create crashes in shared libraries due to
|
||||
//! static bionic stubs in the Rust std prebuilt rlibs. ALL work must happen
|
||||
//! on the JNI calling thread or via the tokio current_thread runtime.
|
||||
//! No std::thread::spawn or tokio multi_thread allowed.
|
||||
//!
|
||||
//! Audio capture and playout happen on Kotlin JVM threads via AudioRecord
|
||||
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::{
|
||||
AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||
MediaHeader, MediaPacket, MediaTransport, QualityController, QualityProfile, SignalMessage,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
use crate::commands::EngineCommand;
|
||||
use crate::stats::{CallState, CallStats};
|
||||
|
||||
/// Max frame size at 48kHz mono (40ms = 1920 samples, for Codec2/Opus6k).
|
||||
const MAX_FRAME_SAMPLES: usize = 1920;
|
||||
|
||||
/// Sentinel value: no profile change pending.
|
||||
const PROFILE_NO_CHANGE: u8 = 0xFF;
|
||||
|
||||
/// All quality profiles in index order, for AtomicU8-based signaling.
|
||||
const PROFILES: [QualityProfile; 6] = [
|
||||
QualityProfile::STUDIO_64K, // 0
|
||||
QualityProfile::STUDIO_48K, // 1
|
||||
QualityProfile::STUDIO_32K, // 2
|
||||
QualityProfile::GOOD, // 3
|
||||
QualityProfile::DEGRADED, // 4
|
||||
QualityProfile::CATASTROPHIC, // 5
|
||||
];
|
||||
|
||||
fn profile_to_index(p: &QualityProfile) -> u8 {
|
||||
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
|
||||
}
|
||||
|
||||
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
|
||||
PROFILES.get(idx as usize).copied()
|
||||
}
|
||||
|
||||
/// Compute frame samples at 48kHz for a given profile.
|
||||
fn frame_samples_for(profile: &QualityProfile) -> usize {
|
||||
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
||||
}
|
||||
|
||||
/// Configuration to start a call.
|
||||
pub struct CallStartConfig {
|
||||
pub profile: QualityProfile,
|
||||
/// When true, use the relay's chosen_profile from CallAnswer instead of local profile.
|
||||
pub auto_profile: bool,
|
||||
pub relay_addr: String,
|
||||
pub room: String,
|
||||
pub auth_token: Vec<u8>,
|
||||
pub identity_seed: [u8; 32],
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CallStartConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: QualityProfile::GOOD,
|
||||
auto_profile: false,
|
||||
relay_addr: String::new(),
|
||||
room: String::new(),
|
||||
auth_token: Vec::new(),
|
||||
identity_seed: [0u8; 32],
|
||||
alias: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EngineState {
|
||||
pub running: AtomicBool,
|
||||
pub muted: AtomicBool,
|
||||
pub stats: Mutex<CallStats>,
|
||||
pub command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
||||
pub command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
|
||||
/// Ring buffer: Kotlin AudioRecord → Rust encoder
|
||||
pub capture_ring: AudioRing,
|
||||
/// Ring buffer: Rust decoder → Kotlin AudioTrack
|
||||
pub playout_ring: AudioRing,
|
||||
/// Current audio level (RMS) for UI display, updated by capture path.
|
||||
pub audio_level_rms: AtomicU32,
|
||||
/// QUIC transport handle — stored so stop_call() can close it immediately,
|
||||
/// triggering relay-side leave + RoomUpdate broadcast.
|
||||
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
|
||||
}
|
||||
|
||||
pub struct WzpEngine {
|
||||
pub(crate) state: Arc<EngineState>,
|
||||
tokio_runtime: Option<tokio::runtime::Runtime>,
|
||||
call_start: Option<Instant>,
|
||||
}
|
||||
|
||||
impl WzpEngine {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let state = Arc::new(EngineState {
|
||||
running: AtomicBool::new(false),
|
||||
muted: AtomicBool::new(false),
|
||||
stats: Mutex::new(CallStats::default()),
|
||||
command_tx: tx,
|
||||
command_rx: Mutex::new(Some(rx)),
|
||||
capture_ring: AudioRing::new(),
|
||||
playout_ring: AudioRing::new(),
|
||||
audio_level_rms: AtomicU32::new(0),
|
||||
quic_transport: Mutex::new(None),
|
||||
});
|
||||
Self {
|
||||
state,
|
||||
tokio_runtime: None,
|
||||
call_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_call(&mut self, config: CallStartConfig) -> Result<(), anyhow::Error> {
|
||||
if self.state.running.load(Ordering::Acquire) {
|
||||
return Err(anyhow::anyhow!("call already active"));
|
||||
}
|
||||
|
||||
{
|
||||
let mut stats = self.state.stats.lock().unwrap();
|
||||
*stats = CallStats {
|
||||
state: CallState::Connecting,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
|
||||
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
|
||||
})?;
|
||||
|
||||
let room = config.room.clone();
|
||||
let identity_seed = config.identity_seed;
|
||||
let profile = config.profile;
|
||||
let auto_profile = config.auto_profile;
|
||||
let alias = config.alias.clone();
|
||||
let state = self.state.clone();
|
||||
|
||||
self.state.running.store(true, Ordering::Release);
|
||||
self.call_start = Some(Instant::now());
|
||||
|
||||
let state_clone = state.clone();
|
||||
runtime.block_on(async move {
|
||||
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await
|
||||
{
|
||||
error!("call failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
state.running.store(false, Ordering::Release);
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Closed;
|
||||
}
|
||||
|
||||
self.tokio_runtime = Some(runtime);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_call(&mut self) {
|
||||
info!("stop_call: setting running=false");
|
||||
self.state.running.store(false, Ordering::Release);
|
||||
// Close QUIC connection — this wakes up all blocked recv/send futures
|
||||
// inside block_on(run_call(...)) on the JNI thread. run_call will then
|
||||
// wait up to 500ms for the peer to acknowledge the close before returning.
|
||||
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
|
||||
info!("stop_call: closing QUIC connection");
|
||||
transport.close_now();
|
||||
}
|
||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
||||
// Note: the runtime is still blocked in block_on(run_call(...)) on the
|
||||
// start_call thread. Once run_call exits (triggered by running=false +
|
||||
// connection close above), block_on returns and stores the runtime in
|
||||
// self.tokio_runtime. We don't need to shut it down here.
|
||||
if let Some(rt) = self.tokio_runtime.take() {
|
||||
rt.shutdown_timeout(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.call_start = None;
|
||||
info!("stop_call: done");
|
||||
}
|
||||
|
||||
/// Ping a relay — same pattern as start_call (creates runtime on calling thread).
|
||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
||||
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
||||
let addr: SocketAddr = address.parse()?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let result = rt.block_on(async {
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind, None)?;
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let start = Instant::now();
|
||||
|
||||
let conn_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Always close endpoint to prevent resource leaks
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
|
||||
let conn = conn_result.map_err(|_| anyhow::anyhow!("timeout"))??;
|
||||
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||
let server_fp = conn
|
||||
.peer_identity()
|
||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||
.and_then(|certs| certs.first().map(|c| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
c.as_ref().hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
conn.close(0u32.into(), b"ping");
|
||||
|
||||
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
|
||||
});
|
||||
|
||||
// Shutdown runtime cleanly with timeout
|
||||
rt.shutdown_timeout(std::time::Duration::from_millis(500));
|
||||
result
|
||||
}
|
||||
|
||||
/// Start persistent signaling connection for direct calls.
|
||||
// Signal methods (start_signaling, place_call, answer_call) moved to signal_mgr.rs
|
||||
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
self.state.muted.store(muted, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_speaker(&self, _enabled: bool) {}
|
||||
|
||||
pub fn force_profile(&self, _profile: QualityProfile) {}
|
||||
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
if let Some(start) = self.call_start {
|
||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
||||
}
|
||||
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
|
||||
stats.playout_overflows = self.state.playout_ring.overflow_count();
|
||||
stats.playout_underruns = self.state.playout_ring.underrun_count();
|
||||
stats.capture_overflows = self.state.capture_ring.overflow_count();
|
||||
stats
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn write_audio(&self, samples: &[i16]) -> usize {
|
||||
if self.state.muted.load(Ordering::Relaxed) {
|
||||
return samples.len();
|
||||
}
|
||||
// Compute RMS for audio level display
|
||||
if !samples.is_empty() {
|
||||
let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||
let rms = (sum_sq / samples.len() as f64).sqrt() as u32;
|
||||
self.state.audio_level_rms.store(rms, Ordering::Relaxed);
|
||||
}
|
||||
self.state.capture_ring.write(samples)
|
||||
}
|
||||
|
||||
pub fn read_audio(&self, out: &mut [i16]) -> usize {
|
||||
self.state.playout_ring.read(out)
|
||||
}
|
||||
|
||||
pub fn destroy(mut self) {
|
||||
self.stop_call();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WzpEngine {
|
||||
fn drop(&mut self) {
|
||||
self.stop_call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full call lifecycle: connect, handshake, send/recv media with Opus + FEC.
|
||||
async fn run_call(
|
||||
relay_addr: SocketAddr,
|
||||
room: &str,
|
||||
identity_seed: &[u8; 32],
|
||||
profile: QualityProfile,
|
||||
auto_profile: bool,
|
||||
alias: Option<&str>,
|
||||
state: Arc<EngineState>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
|
||||
let sni = if room.is_empty() { "android" } else { room };
|
||||
info!(%relay_addr, sni, "connecting to relay...");
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&endpoint, relay_addr, sni, client_cfg).await?;
|
||||
info!("QUIC connected to relay");
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Store transport handle so stop_call() can close the connection immediately
|
||||
*state.quic_transport.lock().unwrap() = Some(transport.clone());
|
||||
|
||||
// Crypto handshake
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed);
|
||||
let ephemeral_pub = kx.generate_ephemeral();
|
||||
let identity_pub = kx.identity_public_key();
|
||||
|
||||
let mut sign_data = Vec::with_capacity(42);
|
||||
sign_data.extend_from_slice(&ephemeral_pub);
|
||||
sign_data.extend_from_slice(b"call-offer");
|
||||
let signature = kx.sign(&sign_data);
|
||||
|
||||
let offer = SignalMessage::CallOffer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
};
|
||||
transport.send_signal(&offer).await?;
|
||||
info!("CallOffer sent, waiting for CallAnswer...");
|
||||
|
||||
let answer = transport
|
||||
.recv_signal()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||
|
||||
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
||||
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Auto mode: use the relay's chosen profile instead of the local preference
|
||||
let profile = if auto_profile {
|
||||
info!(chosen = ?chosen_profile.codec, "auto mode: using relay's chosen profile");
|
||||
chosen_profile
|
||||
} else {
|
||||
profile
|
||||
};
|
||||
|
||||
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
||||
info!(codec = ?profile.codec, "handshake complete, call active");
|
||||
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// Initialize codec (Opus or Codec2 based on profile)
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = wzp_codec::create_decoder(profile);
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
let mut fec_dec = wzp_fec::create_decoder(&profile);
|
||||
|
||||
// AGC: normalize volume on both capture and playout paths
|
||||
let mut capture_agc = AutoGainControl::new();
|
||||
let mut playout_agc = AutoGainControl::new();
|
||||
|
||||
let mut frame_samples = frame_samples_for(&profile);
|
||||
info!(
|
||||
codec = ?profile.codec,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
frames_per_block = profile.frames_per_block,
|
||||
frame_ms = profile.frame_duration_ms,
|
||||
frame_samples,
|
||||
"codec + FEC + AGC initialized"
|
||||
);
|
||||
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.current_codec = format!("{:?}", profile.codec);
|
||||
stats.auto_mode = auto_profile;
|
||||
}
|
||||
|
||||
let seq = AtomicU16::new(0);
|
||||
let ts = AtomicU32::new(0);
|
||||
let transport_recv = transport.clone();
|
||||
|
||||
// Adaptive quality: shared AtomicU8 between recv task (writer) and send task (reader).
|
||||
// 0xFF = no change pending, 0-5 = index into PROFILES array.
|
||||
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
|
||||
let pending_profile_recv = pending_profile.clone();
|
||||
|
||||
// Pre-allocate buffers (sized for current profile)
|
||||
let mut capture_buf = vec![0i16; frame_samples];
|
||||
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
||||
let mut frame_in_block: u8 = 0;
|
||||
let mut block_id: u8 = 0;
|
||||
let mut current_profile = profile;
|
||||
|
||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||
//
|
||||
// IMPORTANT: send_media() uses quinn's send_datagram() which is
|
||||
// synchronous and returns Err(Blocked) when the congestion window
|
||||
// is full. We MUST NOT break on send errors — that would kill the
|
||||
// entire call. Instead we drop the packet and keep going.
|
||||
let send_task = async {
|
||||
info!("send task started (Opus + RaptorQ FEC)");
|
||||
let mut send_errors: u64 = 0;
|
||||
let mut last_send_error_log = Instant::now();
|
||||
let mut last_stats_log = Instant::now();
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut frames_dropped: u64 = 0;
|
||||
// Per-step timing accumulators (reset every stats log)
|
||||
let mut t_agc_us: u64 = 0;
|
||||
let mut t_opus_us: u64 = 0;
|
||||
let mut t_fec_us: u64 = 0;
|
||||
let mut t_send_us: u64 = 0;
|
||||
let mut t_frames: u64 = 0;
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for adaptive profile switch from recv task
|
||||
if auto_profile {
|
||||
let p = pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
||||
if p != PROFILE_NO_CHANGE {
|
||||
if let Some(new_profile) = index_to_profile(p) {
|
||||
info!(
|
||||
from = ?current_profile.codec,
|
||||
to = ?new_profile.codec,
|
||||
"auto: switching encoder profile"
|
||||
);
|
||||
if let Err(e) = encoder.set_profile(new_profile) {
|
||||
warn!("encoder set_profile failed: {e}");
|
||||
} else {
|
||||
fec_enc = wzp_fec::create_encoder(&new_profile);
|
||||
current_profile = new_profile;
|
||||
let new_frame_samples = frame_samples_for(&new_profile);
|
||||
if new_frame_samples != frame_samples {
|
||||
frame_samples = new_frame_samples;
|
||||
capture_buf.resize(frame_samples, 0);
|
||||
}
|
||||
encode_buf.resize(encoder.max_frame_bytes(), 0);
|
||||
// Reset FEC block state for clean switch
|
||||
frame_in_block = 0;
|
||||
block_id = block_id.wrapping_add(1);
|
||||
// Update stats with new codec
|
||||
if let Ok(mut stats) = state.stats.lock() {
|
||||
stats.current_codec = format!("{:?}", new_profile.codec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let avail = state.capture_ring.available();
|
||||
if avail < frame_samples {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let read = state.capture_ring.read(&mut capture_buf);
|
||||
if read < frame_samples {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mute: zero out the buffer so Opus encodes silence.
|
||||
// We still read from the ring to prevent it from filling up.
|
||||
if state.muted.load(Ordering::Relaxed) {
|
||||
capture_buf.fill(0);
|
||||
}
|
||||
|
||||
// AGC: normalize capture volume before encoding
|
||||
let t0 = Instant::now();
|
||||
capture_agc.process_frame(&mut capture_buf);
|
||||
t_agc_us += t0.elapsed().as_micros() as u64;
|
||||
|
||||
// Opus encode
|
||||
let t0 = Instant::now();
|
||||
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("opus encode error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
t_opus_us += t0.elapsed().as_micros() as u64;
|
||||
let encoded = &encode_buf[..encoded_len];
|
||||
|
||||
// Build source packet
|
||||
let s = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let t = ts.fetch_add(frame_samples as u32, Ordering::Relaxed);
|
||||
|
||||
let source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
||||
seq: s,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: frame_in_block,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::copy_from_slice(encoded),
|
||||
quality_report: None,
|
||||
};
|
||||
|
||||
// Send source packet — drop on error, never break
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Log first few errors, then throttle to once per second
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(
|
||||
seq = s,
|
||||
send_errors,
|
||||
frames_dropped,
|
||||
"send_media error (dropping packet): {e}"
|
||||
);
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
// Don't feed to FEC either — the source is lost
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
continue;
|
||||
}
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// Feed encoded frame to FEC encoder
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
|
||||
// When block is full, generate repair packets
|
||||
if frame_in_block >= current_profile.frames_per_block {
|
||||
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = current_profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
}
|
||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||
t_frames += 1;
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
let avg = |total: u64| if t_frames > 0 { total / t_frames } else { 0 };
|
||||
info!(
|
||||
seq = s,
|
||||
block_id,
|
||||
frames_sent,
|
||||
frames_dropped,
|
||||
send_errors,
|
||||
ring_avail = state.capture_ring.available(),
|
||||
capture_overflows = state.capture_ring.overflow_count(),
|
||||
avg_agc_us = avg(t_agc_us),
|
||||
avg_opus_us = avg(t_opus_us),
|
||||
avg_fec_us = avg(t_fec_us),
|
||||
avg_send_us = avg(t_send_us),
|
||||
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
|
||||
"send stats"
|
||||
);
|
||||
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||
};
|
||||
|
||||
// Pre-allocate decode buffer (max size to handle any incoming codec)
|
||||
let mut decode_buf = vec![0i16; MAX_FRAME_SAMPLES];
|
||||
|
||||
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
||||
let recv_task = async {
|
||||
let mut frames_decoded: u64 = 0;
|
||||
let mut fec_recovered: u64 = 0;
|
||||
let mut recv_errors: u64 = 0;
|
||||
let mut last_recv_instant = Instant::now();
|
||||
let mut max_recv_gap_ms: u64 = 0;
|
||||
let mut last_stats_log = Instant::now();
|
||||
let mut quality_ctrl = AdaptiveQualityController::new();
|
||||
let mut last_peer_codec: Option<CodecId> = None;
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match transport_recv.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
// Track recv gaps — large gaps indicate network or relay issues
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 500 {
|
||||
warn!(
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
is_repair = pkt.header.is_repair,
|
||||
"large recv gap — possible network stall"
|
||||
);
|
||||
}
|
||||
|
||||
// Adaptive quality: ingest quality reports from relay
|
||||
if auto_profile {
|
||||
if let Some(ref qr) = pkt.quality_report {
|
||||
if let Some(new_profile) = quality_ctrl.observe(qr) {
|
||||
let idx = profile_to_index(&new_profile);
|
||||
info!(
|
||||
loss = qr.loss_percent(),
|
||||
rtt = qr.rtt_ms(),
|
||||
tier = ?quality_ctrl.tier(),
|
||||
to = ?new_profile.codec,
|
||||
"auto: quality adapter recommends switch"
|
||||
);
|
||||
pending_profile_recv.store(idx, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
|
||||
// Feed every packet (source + repair) to FEC decoder
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
|
||||
// Source packets: decode directly
|
||||
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||
// Switch decoder to match incoming codec if different
|
||||
if pkt.header.codec_id != decoder.codec_id() {
|
||||
let switch_profile = match pkt.header.codec_id {
|
||||
CodecId::Opus24k => QualityProfile::GOOD,
|
||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||
CodecId::Codec2_3200 => QualityProfile {
|
||||
codec: CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||
};
|
||||
info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||
let _ = decoder.set_profile(switch_profile);
|
||||
}
|
||||
// Track peer codec for UI display
|
||||
if last_peer_codec != Some(pkt.header.codec_id) {
|
||||
last_peer_codec = Some(pkt.header.codec_id);
|
||||
if let Ok(mut stats) = state.stats.lock() {
|
||||
stats.peer_codec = format!("{:?}", pkt.header.codec_id);
|
||||
}
|
||||
}
|
||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||
Ok(samples) => {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
frames_decoded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("opus decode error: {e}");
|
||||
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try FEC recovery
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
}
|
||||
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_decoded = frames_decoded;
|
||||
stats.fec_recovered = fec_recovered;
|
||||
drop(stats);
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
playout_overflows = state.playout_ring.overflow_count(),
|
||||
playout_underruns = state.playout_ring.underrun_count(),
|
||||
"recv stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
recv_errors += 1;
|
||||
// Transient errors: log and keep going
|
||||
let msg = e.to_string();
|
||||
if msg.contains("closed") || msg.contains("reset") {
|
||||
error!(recv_errors, "recv fatal: {e}");
|
||||
break;
|
||||
}
|
||||
// Non-fatal: log throttled
|
||||
if recv_errors <= 3 || recv_errors % 50 == 0 {
|
||||
warn!(recv_errors, "recv error (continuing): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
|
||||
};
|
||||
|
||||
// Stats task — polls path quality + quinn RTT every 500ms
|
||||
let transport_stats = transport.clone();
|
||||
let stats_task = async {
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
// Feed quinn's QUIC-level RTT into our path monitor
|
||||
let quic_rtt_ms = transport_stats.connection().stats().path.rtt.as_millis() as u32;
|
||||
if quic_rtt_ms > 0 {
|
||||
transport_stats.feed_rtt(quic_rtt_ms);
|
||||
}
|
||||
let pq = transport_stats.path_quality();
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_encoded = seq.load(Ordering::Relaxed) as u64;
|
||||
stats.loss_pct = pq.loss_pct;
|
||||
stats.rtt_ms = quic_rtt_ms;
|
||||
stats.jitter_ms = pq.jitter_ms;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
};
|
||||
|
||||
// Signal recv task — listens for RoomUpdate and other signaling messages
|
||||
let transport_signal = transport.clone();
|
||||
let state_signal = state.clone();
|
||||
let signal_task = async {
|
||||
loop {
|
||||
match transport_signal.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => {
|
||||
info!(count, "RoomUpdate received");
|
||||
let members: Vec<crate::stats::RoomMember> = participants
|
||||
.iter()
|
||||
.map(|p| crate::stats::RoomMember {
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
alias: p.alias.clone(),
|
||||
relay_label: p.relay_label.clone(),
|
||||
})
|
||||
.collect();
|
||||
let mut stats = state_signal.stats.lock().unwrap();
|
||||
stats.room_participant_count = count;
|
||||
stats.room_participants = members;
|
||||
}
|
||||
Ok(Some(msg)) => {
|
||||
info!("signal received: {:?}", std::mem::discriminant(&msg));
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("signal stream closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => info!("send task ended"),
|
||||
_ = recv_task => info!("recv task ended"),
|
||||
_ = stats_task => info!("stats task ended"),
|
||||
_ = signal_task => info!("signal task ended"),
|
||||
}
|
||||
|
||||
// Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
|
||||
// This ensures the relay sees the close even if the first packet is lost.
|
||||
info!("closing QUIC connection...");
|
||||
transport.close_now();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
transport.connection().closed(),
|
||||
).await {
|
||||
Ok(_) => info!("QUIC connection closed cleanly"),
|
||||
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
|
||||
|
||||
use std::panic;
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use tracing::{error, info};
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
use crate::engine::{CallStartConfig, WzpEngine};
|
||||
|
||||
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
|
||||
struct EngineHandle {
|
||||
engine: WzpEngine,
|
||||
}
|
||||
|
||||
/// Recover the `EngineHandle` from a raw handle value.
|
||||
unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||
}
|
||||
|
||||
/// 7 = auto (use relay's chosen profile)
|
||||
const PROFILE_AUTO: jint = 7;
|
||||
|
||||
fn profile_from_int(value: jint) -> QualityProfile {
|
||||
match value {
|
||||
0 => QualityProfile::GOOD, // Opus 24k
|
||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
||||
3 => QualityProfile { // Codec2 3.2k
|
||||
codec: wzp_proto::CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
||||
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
||||
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
||||
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
||||
}
|
||||
}
|
||||
|
||||
static INIT_LOGGING: Once = Once::new();
|
||||
|
||||
/// Initialize tracing → Android logcat (tag "wzp_android").
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// Filter: INFO for our crates, WARN for everything else.
|
||||
// The jni crate emits VERBOSE logs for every method lookup
|
||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||
// and causes the system to kill the app.
|
||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(layer)
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jlong {
|
||||
let result = panic::catch_unwind(|| {
|
||||
init_logging();
|
||||
// Install rustls crypto provider ONCE on the main thread.
|
||||
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let handle = Box::new(EngineHandle {
|
||||
engine: WzpEngine::new(),
|
||||
});
|
||||
Box::into_raw(handle) as jlong
|
||||
});
|
||||
match result {
|
||||
Ok(h) => h,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
relay_addr_j: JString,
|
||||
room_j: JString,
|
||||
seed_hex_j: JString,
|
||||
token_j: JString,
|
||||
alias_j: JString,
|
||||
profile_j: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
|
||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
||||
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
|
||||
// Parse hex seed
|
||||
let mut identity_seed = [0u8; 32];
|
||||
if seed_hex.len() == 64 {
|
||||
for i in 0..32 {
|
||||
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
|
||||
identity_seed[i] = byte;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate random seed if not provided
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut identity_seed);
|
||||
}
|
||||
|
||||
let config = CallStartConfig {
|
||||
profile: profile_from_int(profile_j),
|
||||
auto_profile: profile_j == PROFILE_AUTO,
|
||||
relay_addr,
|
||||
room,
|
||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||
identity_seed,
|
||||
alias: if alias.is_empty() { None } else { Some(alias) },
|
||||
};
|
||||
|
||||
match h.engine.start_call(config) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
error!("start_call failed: {e}");
|
||||
-1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
match result {
|
||||
Ok(code) => code,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.stop_call();
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
muted: jboolean,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.set_mute(muted != 0);
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
speaker: jboolean,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.set_speaker(speaker != 0);
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let stats = h.engine.get_stats();
|
||||
serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string())
|
||||
}));
|
||||
|
||||
let json = match result {
|
||||
Ok(s) => s,
|
||||
Err(_) => "{}".to_string(),
|
||||
};
|
||||
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
profile: jint,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let qp = profile_from_int(profile);
|
||||
h.engine.force_profile(qp);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
|
||||
/// pcm is a Java short[] array.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
pcm: jni::objects::JShortArray,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut buf = vec![0i16; len];
|
||||
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
|
||||
return 0;
|
||||
}
|
||||
h.engine.write_audio(&buf) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read decoded PCM samples from the engine's playout ring for Kotlin AudioTrack.
|
||||
/// pcm is a Java short[] array to fill. Returns number of samples actually read.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
pcm: jni::objects::JShortArray,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut buf = vec![0i16; len];
|
||||
let read = h.engine.read_audio(&mut buf);
|
||||
if read > 0 {
|
||||
let _ = env.set_short_array_region(&pcm, 0, &buf[..read]);
|
||||
}
|
||||
read as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer must contain little-endian i16 samples.
|
||||
/// Called from the AudioRecord capture thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
sample_count: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || sample_count <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
|
||||
};
|
||||
h.engine.write_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer will be filled with little-endian i16 samples.
|
||||
/// Called from the AudioTrack playout thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
max_samples: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || max_samples <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
|
||||
};
|
||||
h.engine.read_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
|
||||
drop(h);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Ping a relay server — instance method, requires engine handle.
|
||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
relay_j: JString,
|
||||
) -> jstring {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||
match h.engine.ping_relay(&relay) {
|
||||
Ok(json) => Some(json),
|
||||
Err(_) => None,
|
||||
}
|
||||
}));
|
||||
|
||||
let json = match result {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return JObject::null().into_raw(),
|
||||
};
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
/// Get the identity fingerprint for a seed hex string.
|
||||
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
seed_hex_j: JString,
|
||||
) -> jstring {
|
||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||
let fp = if seed_hex.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
match wzp_crypto::Seed::from_hex(&seed_hex) {
|
||||
Ok(seed) => {
|
||||
let id = seed.derive_identity();
|
||||
id.public_identity().fingerprint.to_string()
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
};
|
||||
env.new_string(&fp)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
// ── Direct calling JNI functions ──
|
||||
|
||||
// ── SignalManager JNI functions ──
|
||||
|
||||
/// Opaque handle for SignalManager (separate from EngineHandle).
|
||||
struct SignalHandle {
|
||||
mgr: crate::signal_mgr::SignalManager,
|
||||
}
|
||||
|
||||
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
|
||||
unsafe { &*(handle as *const SignalHandle) }
|
||||
}
|
||||
|
||||
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
|
||||
/// Blocks up to 10s waiting for the internal signal thread to connect.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
relay_j: JString,
|
||||
seed_j: JString,
|
||||
) -> jlong {
|
||||
info!("nativeSignalConnect: entered");
|
||||
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
|
||||
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
|
||||
|
||||
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
|
||||
// Blocks up to 10s waiting for the connect+register to complete.
|
||||
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
|
||||
Ok(mgr) => {
|
||||
let handle = Box::new(SignalHandle { mgr });
|
||||
Box::into_raw(handle) as jlong
|
||||
}
|
||||
Err(e) => {
|
||||
error!("signal connect failed: {e}");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get signal state as JSON string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
if handle == 0 { return JObject::null().into_raw(); }
|
||||
let h = signal_ref(handle);
|
||||
let json = h.mgr.get_state_json();
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
/// Place a direct call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
target_j: JString,
|
||||
) -> jint {
|
||||
if handle == 0 { return -1; }
|
||||
let h = signal_ref(handle);
|
||||
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default();
|
||||
match h.mgr.place_call(&target) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => { error!("place_call: {e}"); -1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer an incoming call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
call_id_j: JString,
|
||||
mode: jint,
|
||||
) -> jint {
|
||||
if handle == 0 { return -1; }
|
||||
let h = signal_ref(handle);
|
||||
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
||||
let accept_mode = match mode {
|
||||
0 => wzp_proto::CallAcceptMode::Reject,
|
||||
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
||||
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||
};
|
||||
match h.mgr.answer_call(&call_id, accept_mode) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => { error!("answer_call: {e}"); -1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Send hangup signal.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle == 0 { return; }
|
||||
let h = signal_ref(handle);
|
||||
h.mgr.hangup();
|
||||
}
|
||||
|
||||
/// Destroy the signal manager and free resources.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle == 0 { return; }
|
||||
let h = signal_ref(handle);
|
||||
h.mgr.stop();
|
||||
// Reclaim the Box
|
||||
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! WarzonePhone Android native VoIP engine.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - Oboe audio backend with lock-free SPSC ring buffers
|
||||
//! - Engine orchestrator managing call lifecycle
|
||||
//! - Codec pipeline thread (encode/decode/FEC/jitter)
|
||||
//! - Call statistics and command interface
|
||||
//!
|
||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
||||
//! allowing `cargo check` and unit tests on the host.
|
||||
|
||||
pub mod audio_android;
|
||||
pub mod audio_ring;
|
||||
pub mod commands;
|
||||
pub mod engine;
|
||||
pub mod pipeline;
|
||||
pub mod signal_mgr;
|
||||
pub mod stats;
|
||||
pub mod jni_bridge;
|
||||
@@ -1,262 +0,0 @@
|
||||
//! Codec pipeline — encode/decode with FEC and jitter buffer.
|
||||
//!
|
||||
//! Runs on a dedicated thread, processing 20 ms frames at 48 kHz.
|
||||
//! The pipeline is NOT Send/Sync (Opus encoder state) — it is owned
|
||||
//! exclusively by the codec thread.
|
||||
|
||||
use tracing::{debug, warn};
|
||||
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::traits::QualityController;
|
||||
use wzp_proto::{MediaPacket, QualityProfile};
|
||||
|
||||
use crate::audio_android::FRAME_SAMPLES;
|
||||
|
||||
/// Maximum encoded frame size (Opus worst case at highest bitrate).
|
||||
const MAX_ENCODED_BYTES: usize = 1275;
|
||||
|
||||
/// Pipeline statistics snapshot.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PipelineStats {
|
||||
pub frames_encoded: u64,
|
||||
pub frames_decoded: u64,
|
||||
pub underruns: u64,
|
||||
pub jitter_depth: usize,
|
||||
pub quality_tier: u8,
|
||||
}
|
||||
|
||||
/// The codec pipeline: encode, FEC, jitter buffer, decode.
|
||||
///
|
||||
/// This struct is owned by the codec thread and not shared.
|
||||
pub struct Pipeline {
|
||||
encoder: AdaptiveEncoder,
|
||||
decoder: AdaptiveDecoder,
|
||||
fec_encoder: RaptorQFecEncoder,
|
||||
fec_decoder: RaptorQFecDecoder,
|
||||
jitter_buffer: JitterBuffer,
|
||||
quality_ctrl: AdaptiveQualityController,
|
||||
/// Acoustic echo canceller applied before encoding.
|
||||
aec: EchoCanceller,
|
||||
/// Automatic gain control applied before encoding.
|
||||
agc: AutoGainControl,
|
||||
/// Last decoded PCM frame, used as the AEC far-end reference.
|
||||
last_decoded_farend: Option<Vec<i16>>,
|
||||
// Pre-allocated scratch buffers
|
||||
capture_buf: Vec<i16>,
|
||||
#[allow(dead_code)]
|
||||
playout_buf: Vec<i16>,
|
||||
encode_out: Vec<u8>,
|
||||
// Stats counters
|
||||
frames_encoded: u64,
|
||||
frames_decoded: u64,
|
||||
underruns: u64,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Create a new pipeline configured for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
||||
let encoder = AdaptiveEncoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
||||
let decoder = AdaptiveDecoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
||||
let fec_encoder =
|
||||
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
||||
let fec_decoder =
|
||||
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
||||
let quality_ctrl = AdaptiveQualityController::new();
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
decoder,
|
||||
fec_encoder,
|
||||
fec_decoder,
|
||||
jitter_buffer,
|
||||
quality_ctrl,
|
||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||
agc: AutoGainControl::new(),
|
||||
last_decoded_farend: None,
|
||||
capture_buf: vec![0i16; FRAME_SAMPLES],
|
||||
playout_buf: vec![0i16; FRAME_SAMPLES],
|
||||
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
||||
frames_encoded: 0,
|
||||
frames_decoded: 0,
|
||||
underruns: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a PCM frame into a compressed packet.
|
||||
///
|
||||
/// If `muted` is true, a silence frame is encoded (all zeros).
|
||||
/// Returns the encoded bytes, or `None` on encoder error.
|
||||
pub fn encode_frame(&mut self, pcm: &[i16], muted: bool) -> Option<Vec<u8>> {
|
||||
let input = if muted {
|
||||
// Zero the capture buffer for silence
|
||||
for s in self.capture_buf.iter_mut() {
|
||||
*s = 0;
|
||||
}
|
||||
&self.capture_buf[..]
|
||||
} else {
|
||||
// Feed the last decoded playout as AEC far-end reference.
|
||||
if let Some(ref farend) = self.last_decoded_farend {
|
||||
self.aec.feed_farend(farend);
|
||||
}
|
||||
|
||||
// Apply AEC + AGC to the captured PCM.
|
||||
let len = pcm.len().min(self.capture_buf.len());
|
||||
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
|
||||
self.aec.process_frame(&mut self.capture_buf[..len]);
|
||||
self.agc.process_frame(&mut self.capture_buf[..len]);
|
||||
&self.capture_buf[..len]
|
||||
};
|
||||
|
||||
match self.encoder.encode(input, &mut self.encode_out) {
|
||||
Ok(n) => {
|
||||
self.frames_encoded += 1;
|
||||
let encoded = self.encode_out[..n].to_vec();
|
||||
|
||||
// Feed into FEC encoder
|
||||
if let Err(e) = self.fec_encoder.add_source_symbol(&encoded) {
|
||||
warn!("FEC encode error: {e}");
|
||||
}
|
||||
|
||||
Some(encoded)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("encode error: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a received media packet into the jitter buffer.
|
||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
||||
// Feed FEC symbols if present
|
||||
let header = &packet.header;
|
||||
if header.fec_block != 0 || header.fec_symbol != 0 {
|
||||
let is_repair = header.is_repair;
|
||||
if let Err(e) = self.fec_decoder.add_symbol(
|
||||
header.fec_block,
|
||||
header.fec_symbol,
|
||||
is_repair,
|
||||
&packet.payload,
|
||||
) {
|
||||
debug!("FEC symbol feed error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self.jitter_buffer.push(packet);
|
||||
}
|
||||
|
||||
/// Decode the next frame from the jitter buffer.
|
||||
///
|
||||
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
||||
/// Decoded PCM is also stored as the AEC far-end reference for the next
|
||||
/// encode cycle.
|
||||
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
||||
let result = match self.jitter_buffer.pop() {
|
||||
PlayoutResult::Packet(pkt) => {
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
||||
Ok(n) => {
|
||||
self.frames_decoded += 1;
|
||||
pcm.truncate(n);
|
||||
Some(pcm)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("decode error: {e}");
|
||||
// Attempt PLC
|
||||
self.generate_plc()
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayoutResult::Missing { seq } => {
|
||||
debug!(seq, "jitter buffer: missing packet, generating PLC");
|
||||
self.generate_plc()
|
||||
}
|
||||
PlayoutResult::NotReady => {
|
||||
self.underruns += 1;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Save decoded PCM as far-end reference for AEC.
|
||||
if let Some(ref pcm) = result {
|
||||
self.last_decoded_farend = Some(pcm.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Generate packet loss concealment output.
|
||||
fn generate_plc(&mut self) -> Option<Vec<i16>> {
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||
match self.decoder.decode_lost(&mut pcm) {
|
||||
Ok(n) => {
|
||||
self.frames_decoded += 1;
|
||||
pcm.truncate(n);
|
||||
Some(pcm)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("PLC error: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a quality report into the adaptive quality controller.
|
||||
///
|
||||
/// Returns a new profile if a tier transition occurred.
|
||||
#[allow(unused)]
|
||||
pub fn observe_quality(
|
||||
&mut self,
|
||||
report: &wzp_proto::QualityReport,
|
||||
) -> Option<QualityProfile> {
|
||||
let new_profile = self.quality_ctrl.observe(report);
|
||||
if let Some(ref profile) = new_profile {
|
||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
||||
warn!("encoder set_profile error: {e}");
|
||||
}
|
||||
if let Err(e) = self.decoder.set_profile(*profile) {
|
||||
warn!("decoder set_profile error: {e}");
|
||||
}
|
||||
}
|
||||
new_profile
|
||||
}
|
||||
|
||||
/// Force a specific quality profile.
|
||||
#[allow(unused)]
|
||||
pub fn force_profile(&mut self, profile: QualityProfile) {
|
||||
self.quality_ctrl.force_profile(profile);
|
||||
if let Err(e) = self.encoder.set_profile(profile) {
|
||||
warn!("encoder set_profile error: {e}");
|
||||
}
|
||||
if let Err(e) = self.decoder.set_profile(profile) {
|
||||
warn!("decoder set_profile error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current pipeline statistics.
|
||||
pub fn stats(&self) -> PipelineStats {
|
||||
PipelineStats {
|
||||
frames_encoded: self.frames_encoded,
|
||||
frames_decoded: self.frames_decoded,
|
||||
underruns: self.underruns,
|
||||
jitter_depth: self.jitter_buffer.stats().current_depth,
|
||||
quality_tier: self.quality_ctrl.tier() as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||
self.aec.set_enabled(enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||
self.agc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Persistent signal connection manager for direct 1:1 calls.
|
||||
//!
|
||||
//! Separate from the media engine — survives across calls.
|
||||
//! Connects to relay via `_signal` SNI, registers presence,
|
||||
//! and handles call signaling (offer/answer/setup/hangup).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
|
||||
/// Signal connection status.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct SignalState {
|
||||
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
|
||||
pub fingerprint: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_fp: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_alias: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_relay: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_room: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Manages a persistent `_signal` QUIC connection to a relay.
|
||||
pub struct SignalManager {
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
state: Arc<Mutex<SignalState>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl SignalManager {
|
||||
/// Create SignalManager and start connect+register+recv on a background thread.
|
||||
/// Returns immediately. The internal thread runs forever.
|
||||
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
|
||||
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
|
||||
let addr: SocketAddr = relay_addr.parse()?;
|
||||
let seed = if seed_hex.is_empty() {
|
||||
wzp_crypto::Seed::generate()
|
||||
} else {
|
||||
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
|
||||
};
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
let identity_pub = *pub_id.signing.as_bytes();
|
||||
let fp = pub_id.fingerprint.to_string();
|
||||
|
||||
let state = Arc::new(Mutex::new(SignalState {
|
||||
status: "connecting".into(),
|
||||
fingerprint: fp.clone(),
|
||||
..Default::default()
|
||||
}));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Channel to receive transport after connect succeeds
|
||||
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
|
||||
|
||||
let bg_state = Arc::clone(&state);
|
||||
let bg_running = Arc::clone(&running);
|
||||
let ret_state = Arc::clone(&state);
|
||||
let ret_running = Arc::clone(&running);
|
||||
|
||||
// ONE thread, ONE runtime, NEVER dropped.
|
||||
// Connect + register + recv loop all happen here.
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-signal".into())
|
||||
.stack_size(4 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
|
||||
rt.block_on(async move {
|
||||
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
|
||||
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!("signal endpoint: {e}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("signal connect: {e}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Register
|
||||
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||
identity_pub, signature: vec![], alias: None,
|
||||
}).await {
|
||||
error!("signal register: {e}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
||||
info!(fingerprint = %fp, "signal: registered");
|
||||
bg_state.lock().unwrap().status = "registered".into();
|
||||
// Send transport to caller
|
||||
let _ = transport_tx.send(transport.clone());
|
||||
}
|
||||
other => {
|
||||
error!("signal registration failed: {other:?}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recv loop — runs forever
|
||||
loop {
|
||||
if !running.load(Ordering::Relaxed) { break; }
|
||||
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
||||
info!(call_id = %call_id, "signal: ringing");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "ringing".into();
|
||||
}
|
||||
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "incoming".into();
|
||||
s.incoming_call_id = Some(call_id);
|
||||
s.incoming_caller_fp = Some(caller_fingerprint);
|
||||
s.incoming_caller_alias = caller_alias;
|
||||
}
|
||||
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
||||
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
|
||||
}
|
||||
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
||||
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "setup".into();
|
||||
s.call_setup_relay = Some(relay_addr);
|
||||
s.call_setup_room = Some(room);
|
||||
s.call_setup_id = Some(call_id);
|
||||
}
|
||||
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||
info!(reason = ?reason, "signal: hangup");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "registered".into();
|
||||
s.incoming_call_id = None;
|
||||
s.incoming_caller_fp = None;
|
||||
s.incoming_caller_alias = None;
|
||||
s.call_setup_relay = None;
|
||||
s.call_setup_room = None;
|
||||
s.call_setup_id = None;
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
info!("signal: connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
}); // block_on
|
||||
|
||||
// Runtime intentionally NOT dropped — lives until thread exits.
|
||||
// This prevents ring/libcrypto TLS cleanup conflict on Android.
|
||||
// The thread is parked here forever (block_on returned = connection lost).
|
||||
std::thread::park();
|
||||
})?; // thread spawn
|
||||
|
||||
// Wait for transport (up to 10s)
|
||||
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
|
||||
|
||||
Ok(Self { transport, state: ret_state, running: ret_running })
|
||||
}
|
||||
|
||||
/// Get current state (non-blocking).
|
||||
pub fn get_state(&self) -> SignalState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get state as JSON string.
|
||||
pub fn get_state_json(&self) -> String {
|
||||
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
|
||||
/// Place a direct call.
|
||||
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
|
||||
let fp = self.state.lock().unwrap().fingerprint.clone();
|
||||
let target = target_fp.to_string();
|
||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
let transport = self.transport.clone();
|
||||
|
||||
// Send on a small thread (async send needs a runtime)
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-call-send".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: fp,
|
||||
caller_alias: None,
|
||||
target_fingerprint: target,
|
||||
call_id,
|
||||
identity_pub: [0u8; 32],
|
||||
ephemeral_pub: [0u8; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
}).await;
|
||||
});
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer an incoming call.
|
||||
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
||||
let call_id = call_id.to_string();
|
||||
let transport = self.transport.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-answer-send".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode: mode,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
}).await;
|
||||
});
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send hangup.
|
||||
pub fn hangup(&self) {
|
||||
let transport = self.transport.clone();
|
||||
let state = self.state.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
});
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "registered".into();
|
||||
s.incoming_call_id = None;
|
||||
s.incoming_caller_fp = None;
|
||||
s.incoming_caller_alias = None;
|
||||
s.call_setup_relay = None;
|
||||
s.call_setup_room = None;
|
||||
s.call_setup_id = None;
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the signal connection.
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Release);
|
||||
self.transport.connection().close(0u32.into(), b"shutdown");
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Call statistics for the Android engine.
|
||||
|
||||
/// State of the call.
|
||||
/// Serializes as integer for easy parsing on the Kotlin side:
|
||||
/// 0=Idle, 1=Connecting, 2=Active, 3=Reconnecting, 4=Closed
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
#[default]
|
||||
Idle,
|
||||
Connecting,
|
||||
Active,
|
||||
Reconnecting,
|
||||
Closed,
|
||||
/// Connected to relay signal channel, registered for direct calls.
|
||||
Registered,
|
||||
/// Outgoing call ringing on callee's side.
|
||||
Ringing,
|
||||
/// Incoming call received, waiting for user to accept/reject.
|
||||
IncomingCall,
|
||||
}
|
||||
|
||||
impl serde::Serialize for CallState {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let n: u8 = match self {
|
||||
CallState::Idle => 0,
|
||||
CallState::Connecting => 1,
|
||||
CallState::Active => 2,
|
||||
CallState::Reconnecting => 3,
|
||||
CallState::Closed => 4,
|
||||
CallState::Registered => 5,
|
||||
CallState::Ringing => 6,
|
||||
CallState::IncomingCall => 7,
|
||||
};
|
||||
serializer.serialize_u8(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated call statistics, serializable for JNI bridge.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct CallStats {
|
||||
/// Current call state.
|
||||
pub state: CallState,
|
||||
/// Call duration in seconds.
|
||||
pub duration_secs: f64,
|
||||
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
|
||||
pub quality_tier: u8,
|
||||
/// Observed packet loss percentage.
|
||||
pub loss_pct: f32,
|
||||
/// Smoothed round-trip time in milliseconds.
|
||||
pub rtt_ms: u32,
|
||||
/// Jitter in milliseconds.
|
||||
pub jitter_ms: u32,
|
||||
/// Current jitter buffer depth in packets.
|
||||
pub jitter_buffer_depth: usize,
|
||||
/// Total frames encoded since call start.
|
||||
pub frames_encoded: u64,
|
||||
/// Total frames decoded since call start.
|
||||
pub frames_decoded: u64,
|
||||
/// Number of playout underruns (buffer empty when audio needed).
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by FEC.
|
||||
pub fec_recovered: u64,
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
pub playout_underruns: u64,
|
||||
/// Capture ring overflow count.
|
||||
pub capture_overflows: u64,
|
||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||
pub audio_level: u32,
|
||||
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
||||
pub current_codec: String,
|
||||
/// Last seen incoming codec from other participants.
|
||||
pub peer_codec: String,
|
||||
/// Whether auto quality mode is active.
|
||||
pub auto_mode: bool,
|
||||
/// Number of participants in the room (from last RoomUpdate).
|
||||
pub room_participant_count: u32,
|
||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||
pub room_participants: Vec<RoomMember>,
|
||||
/// SAS code for verbal verification (None if not in a call).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sas_code: Option<u32>,
|
||||
/// Incoming call info (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_call_id: Option<String>,
|
||||
/// Fingerprint of the caller (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_fp: Option<String>,
|
||||
/// Alias of the caller (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_alias: Option<String>,
|
||||
}
|
||||
|
||||
/// A room member entry, serialized into the stats JSON.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct RoomMember {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
@@ -207,10 +207,6 @@ pub struct CallEncoder {
|
||||
frame_in_block: u8,
|
||||
/// Timestamp counter (ms).
|
||||
timestamp_ms: u32,
|
||||
/// Acoustic echo canceller (removes speaker echo from mic signal).
|
||||
aec: EchoCanceller,
|
||||
/// Automatic gain control (normalises mic level).
|
||||
agc: AutoGainControl,
|
||||
/// Silence detector for suppression.
|
||||
silence_detector: SilenceDetector,
|
||||
/// Whether silence suppression is enabled.
|
||||
@@ -241,8 +237,6 @@ impl CallEncoder {
|
||||
block_id: 0,
|
||||
frame_in_block: 0,
|
||||
timestamp_ms: 0,
|
||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||
agc: AutoGainControl::new(),
|
||||
silence_detector: SilenceDetector::new(
|
||||
config.silence_threshold_rms,
|
||||
config.silence_hangover_frames,
|
||||
@@ -280,21 +274,15 @@ impl CallEncoder {
|
||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||
/// Output: one or more MediaPackets to send.
|
||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||
// Copy PCM into a mutable buffer for the processing pipeline.
|
||||
let mut pcm_buf = pcm.to_vec();
|
||||
|
||||
// Step 1: Echo cancellation (far-end reference must have been fed already).
|
||||
self.aec.process_frame(&mut pcm_buf);
|
||||
|
||||
// Step 2: Automatic gain control (normalise mic level).
|
||||
self.agc.process_frame(&mut pcm_buf);
|
||||
|
||||
// Step 3: Noise suppression (RNNoise).
|
||||
if self.denoiser.is_enabled() {
|
||||
self.denoiser.process(&mut pcm_buf);
|
||||
}
|
||||
|
||||
let pcm = &pcm_buf[..];
|
||||
// Noise suppression: denoise the PCM before silence detection and encoding.
|
||||
let pcm = if self.denoiser.is_enabled() {
|
||||
let mut buf = pcm.to_vec();
|
||||
self.denoiser.process(&mut buf);
|
||||
buf
|
||||
} else {
|
||||
pcm.to_vec()
|
||||
};
|
||||
let pcm = &pcm[..];
|
||||
|
||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||
@@ -412,24 +400,6 @@ impl CallEncoder {
|
||||
self.frame_in_block = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feed decoded playout audio as the echo reference signal.
|
||||
///
|
||||
/// Must be called with each decoded frame BEFORE the corresponding
|
||||
/// microphone frame is processed.
|
||||
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
||||
self.aec.feed_farend(farend);
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||
self.aec.set_enabled(enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||
self.agc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the recv/decode side of a call.
|
||||
|
||||
@@ -47,11 +47,6 @@ struct CliArgs {
|
||||
room: Option<String>,
|
||||
token: Option<String>,
|
||||
_metrics_file: Option<String>,
|
||||
version_check: bool,
|
||||
/// Connect to relay for persistent signaling (direct calls).
|
||||
signal: bool,
|
||||
/// Place a direct call to a fingerprint (requires --signal).
|
||||
call_target: Option<String>,
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
@@ -93,20 +88,12 @@ fn parse_args() -> CliArgs {
|
||||
let mut room = None;
|
||||
let mut token = None;
|
||||
let mut metrics_file = None;
|
||||
let mut version_check = false;
|
||||
let mut relay_str = None;
|
||||
let mut signal = false;
|
||||
let mut call_target = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--live" => live = true,
|
||||
"--signal" => signal = true,
|
||||
"--call" => {
|
||||
i += 1;
|
||||
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||
}
|
||||
"--send-tone" => {
|
||||
i += 1;
|
||||
send_tone_secs = Some(
|
||||
@@ -182,7 +169,6 @@ fn parse_args() -> CliArgs {
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--version-check" => { version_check = true; }
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
eprintln!();
|
||||
@@ -235,9 +221,6 @@ fn parse_args() -> CliArgs {
|
||||
room,
|
||||
token,
|
||||
_metrics_file: metrics_file,
|
||||
version_check,
|
||||
signal,
|
||||
call_target,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,32 +239,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --version-check: query relay version over QUIC and exit
|
||||
if cli.version_check {
|
||||
let client_config = wzp_transport::client_config();
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
|
||||
match conn.accept_uni().await {
|
||||
Ok(mut recv) => {
|
||||
let data = recv.read_to_end(256).await.unwrap_or_default();
|
||||
let version = String::from_utf8_lossy(&data);
|
||||
println!("{} {}", cli.relay_addr, version.trim());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
|
||||
}
|
||||
}
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --signal mode: persistent signaling for direct calls
|
||||
if cli.signal {
|
||||
let seed = cli.resolve_seed();
|
||||
return run_signal_mode(cli.relay_addr, seed, cli.token, cli.call_target).await;
|
||||
}
|
||||
|
||||
let seed = cli.resolve_seed();
|
||||
|
||||
info!(
|
||||
@@ -293,11 +250,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
"WarzonePhone client"
|
||||
);
|
||||
|
||||
// Use raw room name as SNI (consistent with Android + Desktop clients for federation)
|
||||
// Hash room name for SNI privacy (or "default" if none specified)
|
||||
let sni = match &cli.room {
|
||||
Some(name) => {
|
||||
info!(room = %name, "using room name as SNI");
|
||||
name.clone()
|
||||
let hashed = wzp_crypto::hash_room_name(name);
|
||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
||||
hashed
|
||||
}
|
||||
None => "default".to_string(),
|
||||
};
|
||||
@@ -316,26 +274,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
|
||||
// Register shutdown handler so SIGTERM/SIGINT always closes QUIC cleanly.
|
||||
// Without this, killed clients leave zombie connections on the relay for ~30s.
|
||||
{
|
||||
let shutdown_transport = transport.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to register SIGTERM handler");
|
||||
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||
.expect("failed to register SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
|
||||
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
|
||||
}
|
||||
// Close the QUIC connection immediately (APPLICATION_CLOSE frame).
|
||||
// Don't call process::exit — let the main task detect the closed
|
||||
// connection and perform clean shutdown (e.g., save recordings).
|
||||
shutdown_transport.connection().close(0u32.into(), b"shutdown");
|
||||
});
|
||||
}
|
||||
|
||||
// Send auth token if provided (relay with --auth-url expects this first)
|
||||
if let Some(ref token) = cli.token {
|
||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||
@@ -349,7 +287,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
None, // alias — desktop client doesn't set one yet
|
||||
).await?;
|
||||
info!("crypto handshake complete");
|
||||
|
||||
@@ -686,195 +623,3 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
||||
info!("done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persistent signaling mode for direct 1:1 calls.
|
||||
async fn run_signal_mode(
|
||||
relay_addr: SocketAddr,
|
||||
seed: wzp_crypto::Seed,
|
||||
token: Option<String>,
|
||||
call_target: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
use wzp_proto::SignalMessage;
|
||||
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
let fp = pub_id.fingerprint.to_string();
|
||||
let identity_pub = *pub_id.signing.as_bytes();
|
||||
info!(fingerprint = %fp, "signal mode");
|
||||
|
||||
// Connect to relay with SNI "_signal"
|
||||
let client_config = wzp_transport::client_config();
|
||||
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
|
||||
"[::]:0".parse()?
|
||||
} else {
|
||||
"0.0.0.0:0".parse()?
|
||||
};
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let conn = wzp_transport::connect(&endpoint, relay_addr, "_signal", client_config).await?;
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
info!("connected to relay (signal channel)");
|
||||
|
||||
// Auth if token provided
|
||||
if let Some(ref tok) = token {
|
||||
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
|
||||
}
|
||||
|
||||
// Register presence (signature not verified in Phase 1)
|
||||
transport.send_signal(&SignalMessage::RegisterPresence {
|
||||
identity_pub,
|
||||
signature: vec![], // Phase 1: not verified
|
||||
alias: None,
|
||||
}).await?;
|
||||
|
||||
// Wait for ack
|
||||
match transport.recv_signal().await? {
|
||||
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
|
||||
info!(fingerprint = %fp, "registered on relay — waiting for calls");
|
||||
}
|
||||
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => {
|
||||
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("unexpected response: {other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// If --call specified, place the call
|
||||
if let Some(ref target) = call_target {
|
||||
info!(target = %target, "placing direct call...");
|
||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
|
||||
transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: fp.clone(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: target.clone(),
|
||||
call_id: call_id.clone(),
|
||||
identity_pub,
|
||||
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
|
||||
signature: vec![],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
}).await?;
|
||||
}
|
||||
|
||||
// Signal recv loop — handle incoming signals
|
||||
let signal_transport = transport.clone();
|
||||
let relay = relay_addr;
|
||||
let my_fp = fp.clone();
|
||||
let my_seed = seed.0;
|
||||
|
||||
loop {
|
||||
match signal_transport.recv_signal().await {
|
||||
Ok(Some(msg)) => match msg {
|
||||
SignalMessage::CallRinging { call_id } => {
|
||||
info!(call_id = %call_id, "ringing...");
|
||||
}
|
||||
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
|
||||
info!(
|
||||
from = %caller_fingerprint,
|
||||
alias = ?caller_alias,
|
||||
call_id = %call_id,
|
||||
"incoming call — auto-accepting (generic)"
|
||||
);
|
||||
// Auto-accept for CLI testing
|
||||
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||
identity_pub: Some(identity_pub),
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
}).await;
|
||||
}
|
||||
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
||||
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
||||
}
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay } => {
|
||||
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
||||
|
||||
// Connect to the media room
|
||||
let media_relay: SocketAddr = setup_relay.parse().unwrap_or(relay);
|
||||
let media_cfg = wzp_transport::client_config();
|
||||
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
|
||||
Ok(media_conn) => {
|
||||
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn));
|
||||
|
||||
// Crypto handshake
|
||||
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await {
|
||||
Ok(_session) => {
|
||||
info!("media connected — sending tone (press Ctrl+C to hang up)");
|
||||
|
||||
// Simple tone sender for testing
|
||||
let mt = media_transport.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
let config = wzp_client::call::CallConfig::default();
|
||||
let mut encoder = wzp_client::call::CallEncoder::new(&config);
|
||||
let duration = tokio::time::Duration::from_millis(20);
|
||||
loop {
|
||||
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
||||
.map(|_| 0i16) // silence — could be tone
|
||||
.collect();
|
||||
if let Ok(pkts) = encoder.encode_frame(&pcm) {
|
||||
for pkt in &pkts {
|
||||
if mt.send_media(pkt).await.is_err() { return; }
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for hangup or ctrl+c
|
||||
loop {
|
||||
tokio::select! {
|
||||
sig = signal_transport.recv_signal() => {
|
||||
match sig {
|
||||
Ok(Some(SignalMessage::Hangup { .. })) => {
|
||||
info!("remote hung up");
|
||||
break;
|
||||
}
|
||||
Ok(None) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("hanging up...");
|
||||
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
media_transport.close().await.ok();
|
||||
info!("call ended");
|
||||
}
|
||||
Err(e) => error!("media handshake failed: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => error!("media connect failed: {e}"),
|
||||
}
|
||||
}
|
||||
SignalMessage::Hangup { reason } => {
|
||||
info!(reason = ?reason, "call ended by remote");
|
||||
}
|
||||
SignalMessage::Pong { .. } => {}
|
||||
other => {
|
||||
info!("signal: {:?}", std::mem::discriminant(&other));
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
info!("signal connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("signal error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transport.close().await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,23 +109,12 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::FederationHello { .. }
|
||||
| SignalMessage::GlobalRoomActive { .. }
|
||||
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
||||
SignalMessage::RegisterPresence { .. }
|
||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
#[test]
|
||||
fn payload_roundtrip() {
|
||||
@@ -134,7 +123,6 @@ mod tests {
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
|
||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||
@@ -152,7 +140,6 @@ mod tests {
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
alias: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||
pub async fn perform_handshake(
|
||||
transport: &dyn MediaTransport,
|
||||
seed: &[u8; 32],
|
||||
alias: Option<&str>,
|
||||
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||
// 1. Create key exchange from identity seed
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||
@@ -38,14 +37,10 @@ pub async fn perform_handshake(
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
};
|
||||
transport.send_signal(&offer).await?;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
|
||||
use crate::codec2_enc::Codec2Encoder;
|
||||
use crate::opus_dec::OpusDecoder;
|
||||
use crate::opus_enc::OpusEncoder;
|
||||
use crate::resample::{Downsampler48to8, Upsampler8to48};
|
||||
use crate::resample;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,7 +54,6 @@ pub struct AdaptiveEncoder {
|
||||
opus: OpusEncoder,
|
||||
codec2: Codec2Encoder,
|
||||
active: CodecId,
|
||||
downsampler: Downsampler48to8,
|
||||
}
|
||||
|
||||
impl AdaptiveEncoder {
|
||||
@@ -67,7 +66,6 @@ impl AdaptiveEncoder {
|
||||
opus,
|
||||
codec2,
|
||||
active: profile.codec,
|
||||
downsampler: Downsampler48to8::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -76,7 +74,7 @@ impl AudioEncoder for AdaptiveEncoder {
|
||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||
if is_codec2(self.active) {
|
||||
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||
let pcm_8k = self.downsampler.process(pcm);
|
||||
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
||||
self.codec2.encode(&pcm_8k, out)
|
||||
} else {
|
||||
self.opus.encode(pcm, out)
|
||||
@@ -128,7 +126,6 @@ pub struct AdaptiveDecoder {
|
||||
opus: OpusDecoder,
|
||||
codec2: Codec2Decoder,
|
||||
active: CodecId,
|
||||
upsampler: Upsampler8to48,
|
||||
}
|
||||
|
||||
impl AdaptiveDecoder {
|
||||
@@ -141,7 +138,6 @@ impl AdaptiveDecoder {
|
||||
opus,
|
||||
codec2,
|
||||
active: profile.codec,
|
||||
upsampler: Upsampler8to48::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -153,7 +149,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
||||
let c2_samples = self.codec2_frame_samples();
|
||||
let mut buf_8k = vec![0i16; c2_samples];
|
||||
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||
let out_len = pcm_48k.len().min(pcm.len());
|
||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||
Ok(out_len)
|
||||
@@ -167,7 +163,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
||||
let c2_samples = self.codec2_frame_samples();
|
||||
let mut buf_8k = vec![0i16; c2_samples];
|
||||
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||
let out_len = pcm_48k.len().min(pcm.len());
|
||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||
Ok(out_len)
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
||||
|
||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
||||
///
|
||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
||||
/// the estimated echo from the near-end in real time.
|
||||
pub struct EchoCanceller {
|
||||
filter_coeffs: Vec<f32>,
|
||||
filter_len: usize,
|
||||
far_end_buf: Vec<f32>,
|
||||
far_end_pos: usize,
|
||||
mu: f32,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl EchoCanceller {
|
||||
/// Create a new echo canceller.
|
||||
///
|
||||
/// * `sample_rate` — typically 48000
|
||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||
Self {
|
||||
filter_coeffs: vec![0.0f32; filter_len],
|
||||
filter_len,
|
||||
far_end_buf: vec![0.0f32; filter_len],
|
||||
far_end_pos: 0,
|
||||
mu: 0.01,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
||||
///
|
||||
/// Must be called with the audio that was played out through the speaker
|
||||
/// *before* the corresponding near-end frame is processed.
|
||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||
for &s in farend {
|
||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||
///
|
||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
||||
/// mean echo was reduced.
|
||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||
if !self.enabled {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let n = nearend.len();
|
||||
let fl = self.filter_len;
|
||||
|
||||
let mut sum_near_sq: f64 = 0.0;
|
||||
let mut sum_err_sq: f64 = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let near_f = nearend[i] as f32;
|
||||
|
||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
||||
// The far-end window for this sample starts at
|
||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
||||
// and goes back filter_len samples.
|
||||
let mut echo_est: f32 = 0.0;
|
||||
let mut power: f32 = 0.0;
|
||||
|
||||
// Position of the most-recent far-end sample for this near-end sample.
|
||||
// far_end_pos points to the *next write* position, so the most-recent
|
||||
// sample written is at far_end_pos - 1. We have already called
|
||||
// feed_farend for this block, so the relevant samples are the last
|
||||
// filter_len entries ending just before the current write position,
|
||||
// offset by how far we are into this near-end frame.
|
||||
//
|
||||
// For sample i of the near-end frame, the corresponding far-end
|
||||
// "now" is far_end_pos - n + i (wrapping).
|
||||
// far_end_pos points to next-write, so most recent sample is at
|
||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
||||
// repeatedly to avoid underflow on the usize subtraction.
|
||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
echo_est += self.filter_coeffs[k] * fe;
|
||||
power += fe * fe;
|
||||
}
|
||||
|
||||
let error = near_f - echo_est;
|
||||
|
||||
// --- NLMS coefficient update ---
|
||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
||||
let step = self.mu * error / norm;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
self.filter_coeffs[k] += step * fe;
|
||||
}
|
||||
|
||||
// Clamp output
|
||||
let out = error.max(-32768.0).min(32767.0);
|
||||
nearend[i] = out as i16;
|
||||
|
||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
||||
sum_err_sq += (out as f64) * (out as f64);
|
||||
}
|
||||
|
||||
// ERLE ratio
|
||||
if sum_err_sq < 1.0 {
|
||||
return 100.0; // near-perfect cancellation
|
||||
}
|
||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||
}
|
||||
|
||||
/// Enable or disable echo cancellation.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns whether echo cancellation is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Reset the adaptive filter to its initial state.
|
||||
///
|
||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
||||
pub fn reset(&mut self) {
|
||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||
self.far_end_pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aec_creates_with_correct_filter_len() {
|
||||
let aec = EchoCanceller::new(48000, 100);
|
||||
assert_eq!(aec.filter_len, 4800);
|
||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 100);
|
||||
aec.set_enabled(false);
|
||||
assert!(!aec.is_enabled());
|
||||
|
||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
||||
let mut frame = original.clone();
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert_eq!(erle, 1.0);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reset_zeroes_state() {
|
||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
aec.reset();
|
||||
|
||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
||||
assert_eq!(aec.far_end_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reduces_echo_of_known_signal() {
|
||||
// Use a small filter for speed. Feed a known far-end signal, then
|
||||
// present the *same* signal as near-end (perfect echo, no room).
|
||||
// After adaptation the output energy should drop.
|
||||
let filter_ms = 5; // 240 taps at 48 kHz
|
||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
||||
|
||||
// Generate a simple repeating pattern.
|
||||
let frame_len = 480usize;
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48000.0;
|
||||
(5000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Warm up the adaptive filter with several frames.
|
||||
let mut last_erle = 1.0f32;
|
||||
for frame_idx in 0..40 {
|
||||
let farend = make_frame(frame_idx * frame_len);
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
// Near-end = exact copy of far-end (pure echo).
|
||||
let mut nearend = farend.clone();
|
||||
last_erle = aec.process_frame(&mut nearend);
|
||||
}
|
||||
|
||||
// After 40 frames the ERLE should be meaningfully > 1.
|
||||
assert!(
|
||||
last_erle > 1.0,
|
||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_silence_passthrough() {
|
||||
let mut aec = EchoCanceller::new(48000, 10);
|
||||
// Feed silence far-end
|
||||
aec.feed_farend(&vec![0i16; 480]);
|
||||
// Near-end is silence too
|
||||
let mut frame = vec![0i16; 480];
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert!(erle >= 1.0);
|
||||
// Output should still be silence
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
//! Automatic Gain Control (AGC) with two-stage smoothing.
|
||||
//!
|
||||
//! Uses a fast attack / slow release envelope follower to keep the
|
||||
//! output signal near a configurable target RMS level. This prevents
|
||||
//! both clipping (when the speaker is too loud) and inaudibility (when
|
||||
//! the speaker is too quiet or far from the mic).
|
||||
|
||||
/// Two-stage automatic gain control.
|
||||
///
|
||||
/// The gain is adjusted per-frame based on the measured RMS energy,
|
||||
/// with a fast attack (gain decreases quickly when signal gets louder)
|
||||
/// and a slow release (gain increases gradually when signal gets quieter).
|
||||
pub struct AutoGainControl {
|
||||
target_rms: f64,
|
||||
current_gain: f64,
|
||||
min_gain: f64,
|
||||
max_gain: f64,
|
||||
attack_alpha: f64,
|
||||
release_alpha: f64,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl AutoGainControl {
|
||||
/// Create a new AGC with sensible VoIP defaults.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
||||
current_gain: 1.0,
|
||||
min_gain: 0.5,
|
||||
max_gain: 32.0,
|
||||
attack_alpha: 0.3, // fast attack
|
||||
release_alpha: 0.02, // slow release
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a frame of PCM audio in-place, applying gain adjustment.
|
||||
pub fn process_frame(&mut self, pcm: &mut [i16]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute RMS of the frame.
|
||||
let rms = Self::compute_rms(pcm);
|
||||
|
||||
// Don't amplify near-silence — it would just boost noise.
|
||||
if rms < 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Desired instantaneous gain.
|
||||
let desired_gain = (self.target_rms / rms).clamp(self.min_gain, self.max_gain);
|
||||
|
||||
// Smooth the gain transition.
|
||||
let alpha = if desired_gain < self.current_gain {
|
||||
// Signal is louder than target → reduce gain quickly (attack).
|
||||
self.attack_alpha
|
||||
} else {
|
||||
// Signal is quieter than target → raise gain slowly (release).
|
||||
self.release_alpha
|
||||
};
|
||||
|
||||
self.current_gain = self.current_gain * (1.0 - alpha) + desired_gain * alpha;
|
||||
|
||||
// Apply gain to each sample with hard limiting at ±31000 (~0.946 * i16::MAX).
|
||||
const LIMIT: f64 = 31000.0;
|
||||
let gain = self.current_gain;
|
||||
for sample in pcm.iter_mut() {
|
||||
let amplified = (*sample as f64) * gain;
|
||||
let clamped = amplified.clamp(-LIMIT, LIMIT);
|
||||
*sample = clamped as i16;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the AGC.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns whether the AGC is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Current gain expressed in dB.
|
||||
pub fn current_gain_db(&self) -> f64 {
|
||||
20.0 * self.current_gain.log10()
|
||||
}
|
||||
|
||||
/// Compute the RMS (root mean square) of a PCM buffer.
|
||||
fn compute_rms(pcm: &[i16]) -> f64 {
|
||||
if pcm.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||
(sum_sq / pcm.len() as f64).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoGainControl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn agc_creates_with_defaults() {
|
||||
let agc = AutoGainControl::new();
|
||||
assert!(agc.is_enabled());
|
||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_passthrough_when_disabled() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
agc.set_enabled(false);
|
||||
|
||||
let original: Vec<i16> = (0..960).map(|i| (i * 5) as i16).collect();
|
||||
let mut frame = original.clone();
|
||||
agc.process_frame(&mut frame);
|
||||
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_does_not_amplify_silence() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
let mut frame = vec![0i16; 960];
|
||||
agc.process_frame(&mut frame);
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
// Gain should remain at initial value.
|
||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_amplifies_quiet_signal() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
|
||||
// Very quiet signal (RMS ~ 50).
|
||||
let mut frame: Vec<i16> = (0..960)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(50.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process several frames to let the gain ramp up.
|
||||
for _ in 0..50 {
|
||||
let mut f = frame.clone();
|
||||
agc.process_frame(&mut f);
|
||||
frame = f;
|
||||
}
|
||||
|
||||
// Gain should have increased past 1.0.
|
||||
assert!(
|
||||
agc.current_gain > 1.05,
|
||||
"expected gain > 1.05 for quiet signal, got {}",
|
||||
agc.current_gain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_attenuates_loud_signal() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
|
||||
// Loud signal (RMS ~ 20000).
|
||||
let frame: Vec<i16> = (0..960)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(28000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process several frames.
|
||||
for _ in 0..20 {
|
||||
let mut f = frame.clone();
|
||||
agc.process_frame(&mut f);
|
||||
}
|
||||
|
||||
// Gain should have decreased below 1.0.
|
||||
assert!(
|
||||
agc.current_gain < 1.0,
|
||||
"expected gain < 1.0 for loud signal, got {}",
|
||||
agc.current_gain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_output_within_limits() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
// Force a high gain by processing many quiet frames first.
|
||||
for _ in 0..100 {
|
||||
let mut f: Vec<i16> = vec![100; 960];
|
||||
agc.process_frame(&mut f);
|
||||
}
|
||||
|
||||
// Now send a louder frame — output should still be within ±31000.
|
||||
let mut frame: Vec<i16> = vec![20000; 960];
|
||||
agc.process_frame(&mut frame);
|
||||
assert!(
|
||||
frame.iter().all(|&s| s.abs() <= 31000),
|
||||
"output samples must be within ±31000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_gain_db_at_unity() {
|
||||
let agc = AutoGainControl::new();
|
||||
let db = agc.current_gain_db();
|
||||
assert!(
|
||||
db.abs() < 0.01,
|
||||
"expected ~0 dB at unity gain, got {db}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@
|
||||
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||
|
||||
pub mod adaptive;
|
||||
pub mod aec;
|
||||
pub mod agc;
|
||||
pub mod codec2_dec;
|
||||
pub mod codec2_enc;
|
||||
pub mod denoise;
|
||||
@@ -21,8 +19,6 @@ pub mod resample;
|
||||
pub mod silence;
|
||||
|
||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||
pub use aec::EchoCanceller;
|
||||
pub use agc::AutoGainControl;
|
||||
pub use denoise::NoiseSupressor;
|
||||
pub use silence::{ComfortNoise, SilenceDetector};
|
||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||
|
||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
c if c.is_opus() => {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
Ok(())
|
||||
|
||||
@@ -40,11 +40,6 @@ impl OpusEncoder {
|
||||
.set_signal(Signal::Voice)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||
|
||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||
enc.inner
|
||||
.set_complexity(7)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -61,21 +56,6 @@ impl OpusEncoder {
|
||||
pub fn frame_samples(&self) -> usize {
|
||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||
}
|
||||
|
||||
/// Set the encoder complexity (0-10). Higher values produce better quality
|
||||
/// at the cost of more CPU. Default is 7.
|
||||
pub fn set_complexity(&mut self, complexity: i32) {
|
||||
let c = (complexity as u8).min(10);
|
||||
let _ = self.inner.set_complexity(c);
|
||||
}
|
||||
|
||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||
///
|
||||
/// Higher values cause the encoder to use more redundancy to survive
|
||||
/// packet loss, at the expense of slightly higher bitrate.
|
||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioEncoder for OpusEncoder {
|
||||
@@ -100,7 +80,7 @@ impl AudioEncoder for OpusEncoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
c if c.is_opus() => {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
self.apply_bitrate(profile.codec)?;
|
||||
|
||||
@@ -1,257 +1,54 @@
|
||||
//! Windowed-sinc FIR resampler for 48 kHz <-> 8 kHz conversion.
|
||||
//! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
|
||||
//!
|
||||
//! Provides both stateless free functions (backward-compatible) and stateful
|
||||
//! `Downsampler48to8` / `Upsampler8to48` structs that maintain overlap history
|
||||
//! between frames for glitch-free streaming.
|
||||
//! These are basic implementations suitable for voice. For higher quality,
|
||||
//! replace with the `rubato` crate later.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
// ─── FIR kernel parameters ─────────────────────────────────────────────────
|
||||
|
||||
/// Number of FIR taps in the anti-alias / interpolation filter.
|
||||
const FIR_TAPS: usize = 48;
|
||||
/// Kaiser window beta parameter — controls sidelobe attenuation.
|
||||
const KAISER_BETA: f64 = 8.0;
|
||||
/// Cutoff frequency in Hz for the low-pass filter (just below 4 kHz Nyquist of 8 kHz).
|
||||
const CUTOFF_HZ: f64 = 3800.0;
|
||||
/// Working sample rate in Hz.
|
||||
const SAMPLE_RATE: f64 = 48000.0;
|
||||
/// Decimation / interpolation ratio between 48 kHz and 8 kHz.
|
||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
|
||||
///
|
||||
/// Each output sample is the average of 6 consecutive input samples,
|
||||
/// providing basic anti-aliasing via a box filter.
|
||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||
const RATIO: usize = 6;
|
||||
|
||||
// ─── Kaiser window helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// Zeroth-order modified Bessel function of the first kind, I₀(x).
|
||||
///
|
||||
/// Computed via the well-known power-series expansion, converging rapidly
|
||||
/// for the moderate values of x used in Kaiser window design.
|
||||
fn bessel_i0(x: f64) -> f64 {
|
||||
let mut sum = 1.0f64;
|
||||
let mut term = 1.0f64;
|
||||
let half_x = x / 2.0;
|
||||
for k in 1..=25 {
|
||||
term *= (half_x / k as f64) * (half_x / k as f64);
|
||||
sum += term;
|
||||
if term < 1e-12 * sum {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// Build a windowed-sinc low-pass FIR kernel.
|
||||
///
|
||||
/// Returns `FIR_TAPS` coefficients normalised so that the DC gain is exactly 1.0.
|
||||
fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
||||
let mut kernel = [0.0f64; FIR_TAPS];
|
||||
let m = (FIR_TAPS - 1) as f64;
|
||||
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
||||
let beta_denom = bessel_i0(KAISER_BETA);
|
||||
|
||||
for i in 0..FIR_TAPS {
|
||||
// Sinc
|
||||
let n = i as f64 - m / 2.0;
|
||||
let sinc = if n.abs() < 1e-12 {
|
||||
2.0 * fc
|
||||
} else {
|
||||
(2.0 * PI * fc * n).sin() / (PI * n)
|
||||
};
|
||||
|
||||
// Kaiser window
|
||||
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
||||
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
|
||||
|
||||
kernel[i] = sinc * kaiser;
|
||||
}
|
||||
|
||||
// Normalise to unity DC gain.
|
||||
let sum: f64 = kernel.iter().sum();
|
||||
if sum.abs() > 1e-15 {
|
||||
for k in kernel.iter_mut() {
|
||||
*k /= sum;
|
||||
}
|
||||
}
|
||||
|
||||
kernel
|
||||
}
|
||||
|
||||
// ─── Stateful Downsampler 48→8 ─────────────────────────────────────────────
|
||||
|
||||
/// Stateful polyphase FIR downsampler from 48 kHz to 8 kHz.
|
||||
///
|
||||
/// Maintains `FIR_TAPS - 1` samples of history between successive calls to
|
||||
/// `process()` for seamless frame boundaries.
|
||||
pub struct Downsampler48to8 {
|
||||
kernel: [f64; FIR_TAPS],
|
||||
history: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Downsampler48to8 {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kernel: build_fir_kernel(),
|
||||
history: vec![0.0; FIR_TAPS - 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Downsample a block of 48 kHz samples to 8 kHz.
|
||||
///
|
||||
/// The input length should be a multiple of 6; any trailing samples that
|
||||
/// don't form a complete output sample are consumed into the history.
|
||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
||||
let total_len = hist_len + input.len();
|
||||
|
||||
// Build a working buffer: history ++ input (as f64).
|
||||
let mut work = Vec::with_capacity(total_len);
|
||||
work.extend_from_slice(&self.history);
|
||||
work.extend(input.iter().map(|&s| s as f64));
|
||||
|
||||
let out_len = input.len() / RATIO;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
for i in 0..out_len {
|
||||
// The centre of the filter for output sample i sits at
|
||||
// position hist_len + i*RATIO in the work buffer (aligning
|
||||
// with the first new input sample at decimation phase 0).
|
||||
let centre = hist_len + i * RATIO;
|
||||
let start = centre + 1 - FIR_TAPS; // may be 0 for the first few
|
||||
|
||||
let mut acc = 0.0f64;
|
||||
for k in 0..FIR_TAPS {
|
||||
let idx = start + k;
|
||||
if idx < work.len() {
|
||||
acc += work[idx] * self.kernel[k];
|
||||
}
|
||||
}
|
||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
||||
}
|
||||
|
||||
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
||||
if work.len() >= hist_len {
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
} else {
|
||||
// Input was shorter than history — shift.
|
||||
let shift = hist_len - work.len();
|
||||
self.history.copy_within(shift.., 0);
|
||||
for (i, &v) in work.iter().enumerate() {
|
||||
self.history[hist_len - work.len() + i] = v;
|
||||
}
|
||||
for chunk in input.chunks_exact(RATIO) {
|
||||
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
|
||||
output.push((sum / RATIO as i32) as i16);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Downsampler48to8 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stateful Upsampler 8→48 ───────────────────────────────────────────────
|
||||
|
||||
/// Stateful FIR upsampler from 8 kHz to 48 kHz.
|
||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
|
||||
///
|
||||
/// Inserts zeros between input samples (zero-stuffing), then applies the
|
||||
/// low-pass FIR to remove imaging, with gain compensation of `RATIO`.
|
||||
pub struct Upsampler8to48 {
|
||||
kernel: [f64; FIR_TAPS],
|
||||
history: Vec<f64>,
|
||||
/// Linearly interpolates between each pair of input samples to produce
|
||||
/// 6 output samples per input sample.
|
||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||
const RATIO: usize = 6;
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
impl Upsampler8to48 {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kernel: build_fir_kernel(),
|
||||
history: vec![0.0; FIR_TAPS - 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsample a block of 8 kHz samples to 48 kHz.
|
||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
||||
|
||||
// Zero-stuff: insert RATIO-1 zeros between each input sample.
|
||||
let stuffed_len = input.len() * RATIO;
|
||||
let total_len = hist_len + stuffed_len;
|
||||
|
||||
let mut work = Vec::with_capacity(total_len);
|
||||
work.extend_from_slice(&self.history);
|
||||
for &s in input {
|
||||
work.push(s as f64);
|
||||
for _ in 1..RATIO {
|
||||
work.push(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
let out_len = stuffed_len;
|
||||
let out_len = input.len() * RATIO;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
// The gain factor compensates for the zeros introduced by stuffing.
|
||||
let gain = RATIO as f64;
|
||||
|
||||
for i in 0..out_len {
|
||||
let centre = hist_len + i;
|
||||
let start = centre + 1 - FIR_TAPS;
|
||||
|
||||
let mut acc = 0.0f64;
|
||||
for k in 0..FIR_TAPS {
|
||||
let idx = start + k;
|
||||
if idx < work.len() {
|
||||
acc += work[idx] * self.kernel[k];
|
||||
}
|
||||
}
|
||||
acc *= gain;
|
||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
||||
}
|
||||
|
||||
// Update history.
|
||||
if work.len() >= hist_len {
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
for i in 0..input.len() {
|
||||
let current = input[i] as i32;
|
||||
let next = if i + 1 < input.len() {
|
||||
input[i + 1] as i32
|
||||
} else {
|
||||
let shift = hist_len - work.len();
|
||||
self.history.copy_within(shift.., 0);
|
||||
for (i, &v) in work.iter().enumerate() {
|
||||
self.history[hist_len - work.len() + i] = v;
|
||||
current // hold last sample
|
||||
};
|
||||
|
||||
for j in 0..RATIO {
|
||||
let interp = current + (next - current) * j as i32 / RATIO as i32;
|
||||
output.push(interp as i16);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Upsampler8to48 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Backward-compatible free functions ─────────────────────────────────────
|
||||
|
||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with FIR anti-alias filter).
|
||||
///
|
||||
/// This is a convenience wrapper that creates a temporary [`Downsampler48to8`].
|
||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||
/// frames.
|
||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||
let mut ds = Downsampler48to8::new();
|
||||
ds.process(input)
|
||||
}
|
||||
|
||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with FIR imaging filter).
|
||||
///
|
||||
/// This is a convenience wrapper that creates a temporary [`Upsampler8to48`].
|
||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||
/// frames.
|
||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||
let mut us = Upsampler8to48::new();
|
||||
us.process(input)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -269,28 +66,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dc_signal_preserved() {
|
||||
// A constant signal should survive resampling (approximately).
|
||||
// A constant signal should survive resampling
|
||||
let input = vec![1000i16; 960];
|
||||
let down = resample_48k_to_8k(&input);
|
||||
// Allow some edge transient — check that the middle samples are close.
|
||||
let mid_start = down.len() / 4;
|
||||
let mid_end = 3 * down.len() / 4;
|
||||
for &s in &down[mid_start..mid_end] {
|
||||
assert!(
|
||||
(s - 1000).abs() < 50,
|
||||
"DC downsampled sample {s} too far from 1000"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(down.iter().all(|&s| s == 1000));
|
||||
let up = resample_8k_to_48k(&down);
|
||||
let mid_start_up = up.len() / 4;
|
||||
let mid_end_up = 3 * up.len() / 4;
|
||||
for &s in &up[mid_start_up..mid_end_up] {
|
||||
assert!(
|
||||
(s - 1000).abs() < 100,
|
||||
"DC upsampled sample {s} too far from 1000"
|
||||
);
|
||||
}
|
||||
assert!(up.iter().all(|&s| s == 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -298,40 +79,4 @@ mod tests {
|
||||
assert!(resample_48k_to_8k(&[]).is_empty());
|
||||
assert!(resample_8k_to_48k(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_downsampler_produces_correct_length() {
|
||||
let mut ds = Downsampler48to8::new();
|
||||
let out = ds.process(&vec![0i16; 960]);
|
||||
assert_eq!(out.len(), 160);
|
||||
let out2 = ds.process(&vec![0i16; 960]);
|
||||
assert_eq!(out2.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_upsampler_produces_correct_length() {
|
||||
let mut us = Upsampler8to48::new();
|
||||
let out = us.process(&vec![0i16; 160]);
|
||||
assert_eq!(out.len(), 960);
|
||||
let out2 = us.process(&vec![0i16; 160]);
|
||||
assert_eq!(out2.len(), 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fir_kernel_has_unity_dc_gain() {
|
||||
let kernel = build_fir_kernel();
|
||||
let sum: f64 = kernel.iter().sum();
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 1e-10,
|
||||
"FIR kernel DC gain should be 1.0, got {sum}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bessel_i0_known_values() {
|
||||
// I₀(0) = 1
|
||||
assert!((bessel_i0(0.0) - 1.0).abs() < 1e-12);
|
||||
// I₀(1) ≈ 1.2660658
|
||||
assert!((bessel_i0(1.0) - 1.2660658).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +110,7 @@ impl KeyExchange for WarzoneKeyExchange {
|
||||
hk.expand(b"warzone-session-key", &mut session_key)
|
||||
.expect("HKDF expand for session key should not fail");
|
||||
|
||||
// Derive SAS (Short Authentication String) from shared secret only.
|
||||
// The shared secret is identical on both sides (X25519 DH property).
|
||||
// A MITM would produce a different shared secret → different SAS.
|
||||
// We use a dedicated HKDF label so SAS is independent of the session key.
|
||||
let mut sas_key = [0u8; 4];
|
||||
hk.expand(b"warzone-sas-code", &mut sas_key)
|
||||
.expect("HKDF expand for SAS should not fail");
|
||||
let sas_code = u32::from_be_bytes(sas_key) % 10000;
|
||||
|
||||
let mut session = ChaChaSession::new(session_key);
|
||||
session.set_sas(sas_code);
|
||||
Ok(Box::new(session))
|
||||
Ok(Box::new(ChaChaSession::new(session_key)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,47 +211,4 @@ mod tests {
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_codes_match_between_peers() {
|
||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||
|
||||
let alice_eph_pub = alice.generate_ephemeral();
|
||||
let bob_eph_pub = bob.generate_ephemeral();
|
||||
|
||||
let alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||
let bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||
|
||||
let alice_sas = alice_session.sas_code();
|
||||
let bob_sas = bob_session.sas_code();
|
||||
|
||||
assert!(alice_sas.is_some(), "Alice should have SAS");
|
||||
assert!(bob_sas.is_some(), "Bob should have SAS");
|
||||
assert_eq!(alice_sas, bob_sas, "SAS codes must match between peers");
|
||||
assert!(alice_sas.unwrap() < 10000, "SAS should be 4 digits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_differs_for_different_peers() {
|
||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||
let mut eve = WarzoneKeyExchange::from_identity_seed(&[0xEE; 32]);
|
||||
|
||||
let alice_eph = alice.generate_ephemeral();
|
||||
let bob_eph = bob.generate_ephemeral();
|
||||
let eve_eph = eve.generate_ephemeral();
|
||||
|
||||
let alice_bob_session = alice.derive_session(&bob_eph).unwrap();
|
||||
|
||||
// Eve does separate handshake with Bob (MITM scenario)
|
||||
let eve_bob_session = eve.derive_session(&bob_eph).unwrap();
|
||||
|
||||
// SAS codes should differ — Eve's session has different shared secret
|
||||
assert_ne!(
|
||||
alice_bob_session.sas_code(),
|
||||
eve_bob_session.sas_code(),
|
||||
"MITM session should produce different SAS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ pub struct ChaChaSession {
|
||||
rekey_mgr: RekeyManager,
|
||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||
pending_rekey_secret: Option<StaticSecret>,
|
||||
/// Short Authentication String (4-digit code for verbal verification).
|
||||
sas_code: Option<u32>,
|
||||
}
|
||||
|
||||
impl ChaChaSession {
|
||||
@@ -48,15 +46,9 @@ impl ChaChaSession {
|
||||
recv_seq: 0,
|
||||
rekey_mgr: RekeyManager::new(shared_secret),
|
||||
pending_rekey_secret: None,
|
||||
sas_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the SAS code (called by key exchange after derivation).
|
||||
pub fn set_sas(&mut self, code: u32) {
|
||||
self.sas_code = Some(code);
|
||||
}
|
||||
|
||||
/// Install a new key (after rekeying).
|
||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||
use sha2::Digest;
|
||||
@@ -144,10 +136,6 @@ impl CryptoSession for ChaChaSession {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sas_code(&self) -> Option<u32> {
|
||||
self.sas_code
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||
use wzp_proto::error::FecError;
|
||||
@@ -10,9 +9,6 @@ use wzp_proto::FecDecoder;
|
||||
/// Length prefix size (u16 little-endian), must match encoder.
|
||||
const LEN_PREFIX: usize = 2;
|
||||
|
||||
/// Decoded blocks older than this are eligible for reuse by a new sender.
|
||||
const BLOCK_STALE_SECS: u64 = 2;
|
||||
|
||||
/// State for one in-flight block being decoded.
|
||||
struct BlockState {
|
||||
/// Number of source symbols expected.
|
||||
@@ -25,8 +21,6 @@ struct BlockState {
|
||||
decoded: bool,
|
||||
/// Cached decoded result.
|
||||
result: Option<Vec<Vec<u8>>>,
|
||||
/// When this block was last decoded (for staleness check).
|
||||
decoded_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||
@@ -64,7 +58,6 @@ impl RaptorQFecDecoder {
|
||||
symbol_size: self.symbol_size,
|
||||
decoded: false,
|
||||
result: None,
|
||||
decoded_at: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,21 +74,9 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
let block = self.get_or_create_block(block_id);
|
||||
|
||||
if block.decoded {
|
||||
// If the block was decoded recently, skip (normal duplicate).
|
||||
// If it's stale (>2s), a new sender is reusing this block_id — reset it.
|
||||
if let Some(at) = block.decoded_at {
|
||||
if at.elapsed().as_secs() >= BLOCK_STALE_SECS {
|
||||
block.decoded = false;
|
||||
block.result = None;
|
||||
block.decoded_at = None;
|
||||
block.packets.clear();
|
||||
} else {
|
||||
// Already decoded, ignore additional symbols.
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||
// But if caller sends raw data, pad it.
|
||||
@@ -151,7 +132,6 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
|
||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||
block.decoded = true;
|
||||
block.decoded_at = Some(Instant::now());
|
||||
block.result = Some(frames.clone());
|
||||
Ok(Some(frames))
|
||||
}
|
||||
|
||||
@@ -18,12 +18,6 @@ pub enum CodecId {
|
||||
Codec2_1200 = 4,
|
||||
/// Comfort noise descriptor (silence suppression)
|
||||
ComfortNoise = 5,
|
||||
/// Opus at 32kbps (studio low)
|
||||
Opus32k = 6,
|
||||
/// Opus at 48kbps (studio)
|
||||
Opus48k = 7,
|
||||
/// Opus at 64kbps (studio high)
|
||||
Opus64k = 8,
|
||||
}
|
||||
|
||||
impl CodecId {
|
||||
@@ -33,9 +27,6 @@ impl CodecId {
|
||||
Self::Opus24k => 24_000,
|
||||
Self::Opus16k => 16_000,
|
||||
Self::Opus6k => 6_000,
|
||||
Self::Opus32k => 32_000,
|
||||
Self::Opus48k => 48_000,
|
||||
Self::Opus64k => 64_000,
|
||||
Self::Codec2_3200 => 3_200,
|
||||
Self::Codec2_1200 => 1_200,
|
||||
Self::ComfortNoise => 0,
|
||||
@@ -45,7 +36,8 @@ impl CodecId {
|
||||
/// Preferred frame duration in milliseconds.
|
||||
pub const fn frame_duration_ms(self) -> u8 {
|
||||
match self {
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||
Self::Opus24k => 20,
|
||||
Self::Opus16k => 20,
|
||||
Self::Opus6k => 40,
|
||||
Self::Codec2_3200 => 20,
|
||||
Self::Codec2_1200 => 40,
|
||||
@@ -56,8 +48,7 @@ impl CodecId {
|
||||
/// Sample rate expected by this codec.
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||
Self::ComfortNoise => 48_000,
|
||||
}
|
||||
@@ -72,9 +63,6 @@ impl CodecId {
|
||||
3 => Some(Self::Codec2_3200),
|
||||
4 => Some(Self::Codec2_1200),
|
||||
5 => Some(Self::ComfortNoise),
|
||||
6 => Some(Self::Opus32k),
|
||||
7 => Some(Self::Opus48k),
|
||||
8 => Some(Self::Opus64k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -83,12 +71,6 @@ impl CodecId {
|
||||
pub const fn to_wire(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Returns true if this is an Opus variant.
|
||||
pub const fn is_opus(self) -> bool {
|
||||
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the complete quality configuration for a call session.
|
||||
@@ -129,30 +111,6 @@ impl QualityProfile {
|
||||
frames_per_block: 8,
|
||||
};
|
||||
|
||||
/// Studio low: Opus 32kbps, minimal FEC.
|
||||
pub const STUDIO_32K: Self = Self {
|
||||
codec: CodecId::Opus32k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Studio: Opus 48kbps, minimal FEC.
|
||||
pub const STUDIO_48K: Self = Self {
|
||||
codec: CodecId::Opus48k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Studio high: Opus 64kbps, minimal FEC.
|
||||
pub const STUDIO_64K: Self = Self {
|
||||
codec: CodecId::Opus64k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::packet::MediaPacket;
|
||||
|
||||
@@ -21,29 +20,19 @@ pub struct AdaptivePlayoutDelay {
|
||||
max_delay: usize,
|
||||
/// Exponential moving average of inter-packet arrival jitter (ms).
|
||||
jitter_ema: f64,
|
||||
/// EMA smoothing factor for jitter increases (fast reaction).
|
||||
alpha_up: f64,
|
||||
/// EMA smoothing factor for jitter decreases (slow decay).
|
||||
alpha_down: f64,
|
||||
/// EMA smoothing factor (0.0-1.0, lower = smoother).
|
||||
alpha: f64,
|
||||
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
||||
last_arrival_ms: Option<u64>,
|
||||
/// Last packet expected timestamp.
|
||||
last_expected_ms: Option<u64>,
|
||||
/// Safety margin added to jitter-derived target (in packets).
|
||||
safety_margin: f64,
|
||||
/// Instant when a jitter spike was detected (handoff detection).
|
||||
spike_detected_at: Option<Instant>,
|
||||
/// Duration to hold max_delay after a spike is detected.
|
||||
spike_cooldown: Duration,
|
||||
/// Multiplier of jitter_ema that constitutes a spike.
|
||||
spike_threshold_multiplier: f64,
|
||||
}
|
||||
|
||||
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
||||
const FRAME_DURATION_MS: f64 = 20.0;
|
||||
/// Default safety margin in packets.
|
||||
const DEFAULT_SAFETY_MARGIN: f64 = 2.0;
|
||||
/// Default EMA smoothing factor (used for both up/down in non-mobile mode).
|
||||
/// Safety margin added to jitter-derived target (in packets).
|
||||
const SAFETY_MARGIN_PACKETS: f64 = 2.0;
|
||||
/// Default EMA smoothing factor.
|
||||
const DEFAULT_ALPHA: f64 = 0.05;
|
||||
|
||||
impl AdaptivePlayoutDelay {
|
||||
@@ -57,14 +46,9 @@ impl AdaptivePlayoutDelay {
|
||||
min_delay,
|
||||
max_delay,
|
||||
jitter_ema: 0.0,
|
||||
alpha_up: DEFAULT_ALPHA,
|
||||
alpha_down: DEFAULT_ALPHA,
|
||||
alpha: DEFAULT_ALPHA,
|
||||
last_arrival_ms: None,
|
||||
last_expected_ms: None,
|
||||
safety_margin: DEFAULT_SAFETY_MARGIN,
|
||||
spike_detected_at: None,
|
||||
spike_cooldown: Duration::from_secs(2),
|
||||
spike_threshold_multiplier: 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,39 +64,14 @@ impl AdaptivePlayoutDelay {
|
||||
let expected_delta = expected_ms as f64 - last_expected as f64;
|
||||
let jitter = (actual_delta - expected_delta).abs();
|
||||
|
||||
// Spike detection: check before EMA update
|
||||
if self.jitter_ema > 0.0
|
||||
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
||||
{
|
||||
self.spike_detected_at = Some(Instant::now());
|
||||
}
|
||||
// Update EMA
|
||||
self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
|
||||
|
||||
// Asymmetric EMA update
|
||||
let alpha = if jitter > self.jitter_ema {
|
||||
self.alpha_up
|
||||
} else {
|
||||
self.alpha_down
|
||||
};
|
||||
self.jitter_ema = alpha * jitter + (1.0 - alpha) * self.jitter_ema;
|
||||
|
||||
// Check if spike cooldown has expired
|
||||
if let Some(spike_time) = self.spike_detected_at {
|
||||
if spike_time.elapsed() >= self.spike_cooldown {
|
||||
self.spike_detected_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
// If within spike cooldown, return max_delay
|
||||
if self.spike_detected_at.is_some() {
|
||||
self.target_delay = self.max_delay;
|
||||
} else {
|
||||
// Convert jitter estimate to target delay in packets
|
||||
let raw_target =
|
||||
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
|
||||
self.target_delay =
|
||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_arrival_ms = Some(arrival_ms);
|
||||
self.last_expected_ms = Some(expected_ms);
|
||||
@@ -128,28 +87,6 @@ impl AdaptivePlayoutDelay {
|
||||
pub fn jitter_estimate_ms(&self) -> f64 {
|
||||
self.jitter_ema
|
||||
}
|
||||
|
||||
/// Enable or disable mobile mode, adjusting parameters for cellular networks.
|
||||
///
|
||||
/// Mobile mode uses:
|
||||
/// - Asymmetric alpha (fast up=0.3, slow down=0.02) for quicker spike detection
|
||||
/// - Higher safety margin (3.0 packets) to absorb handoff jitter
|
||||
/// - Spike detection with 2-second cooldown at 3x threshold
|
||||
pub fn set_mobile_mode(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.safety_margin = 3.0;
|
||||
self.alpha_up = 0.3;
|
||||
self.alpha_down = 0.02;
|
||||
self.spike_threshold_multiplier = 3.0;
|
||||
self.spike_cooldown = Duration::from_secs(2);
|
||||
} else {
|
||||
self.safety_margin = DEFAULT_SAFETY_MARGIN;
|
||||
self.alpha_up = DEFAULT_ALPHA;
|
||||
self.alpha_down = DEFAULT_ALPHA;
|
||||
self.spike_threshold_multiplier = 3.0;
|
||||
self.spike_cooldown = Duration::from_secs(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -273,22 +210,11 @@ impl JitterBuffer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if packet is too old (already played out).
|
||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
||||
// federation room — reset instead of dropping.
|
||||
// Check if packet is too old (already played out)
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
||||
if backward_distance > 100 {
|
||||
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
||||
self.buffer.clear();
|
||||
self.next_playout_seq = seq;
|
||||
self.stats.packets_late = 0;
|
||||
} else {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||
@@ -423,22 +349,11 @@ impl JitterBuffer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if packet is too old (already played out).
|
||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
||||
// federation room — reset instead of dropping.
|
||||
// Check if packet is too old (already played out)
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
||||
if backward_distance > 100 {
|
||||
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
||||
self.buffer.clear();
|
||||
self.next_playout_seq = seq;
|
||||
self.stats.packets_late = 0;
|
||||
} else {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||
@@ -476,11 +391,6 @@ impl JitterBuffer {
|
||||
self.adaptive.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the adaptive playout delay estimator.
|
||||
pub fn adaptive_delay_mut(&mut self) -> Option<&mut AdaptivePlayoutDelay> {
|
||||
self.adaptive.as_mut()
|
||||
}
|
||||
|
||||
/// Adjust target depth based on observed jitter.
|
||||
pub fn set_target_depth(&mut self, depth: usize) {
|
||||
self.target_depth = depth.min(self.max_depth);
|
||||
@@ -810,29 +720,4 @@ mod tests {
|
||||
let ad = jb.adaptive_delay().unwrap();
|
||||
assert_eq!(ad.target_delay(), 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mobile mode tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mobile_mode_increases_safety_margin() {
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
apd.set_mobile_mode(true);
|
||||
assert_eq!(apd.safety_margin, 3.0);
|
||||
assert_eq!(apd.alpha_up, 0.3);
|
||||
assert_eq!(apd.alpha_down, 0.02);
|
||||
|
||||
apd.set_mobile_mode(false);
|
||||
assert_eq!(apd.safety_margin, DEFAULT_SAFETY_MARGIN);
|
||||
assert_eq!(apd.alpha_up, DEFAULT_ALPHA);
|
||||
assert_eq!(apd.alpha_down, DEFAULT_ALPHA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_mode_accessible_via_jitter_buffer() {
|
||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||
jb.adaptive_delay_mut().unwrap().set_mobile_mode(true);
|
||||
assert_eq!(jb.adaptive_delay().unwrap().safety_margin, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,10 @@ pub mod traits;
|
||||
pub use codec_id::{CodecId, QualityProfile};
|
||||
pub use error::*;
|
||||
pub use packet::{
|
||||
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
||||
FRAME_TYPE_MINI,
|
||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||
pub use quality::{AdaptiveQualityController, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
|
||||
@@ -548,9 +548,6 @@ pub enum SignalMessage {
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
/// Optional display name set by the caller.
|
||||
#[serde(default)]
|
||||
alias: Option<String>,
|
||||
},
|
||||
|
||||
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||
@@ -648,133 +645,6 @@ pub enum SignalMessage {
|
||||
session_id: String,
|
||||
room_name: String,
|
||||
},
|
||||
|
||||
/// Room membership update — sent by relay to all participants when someone joins or leaves.
|
||||
RoomUpdate {
|
||||
/// Current participant count.
|
||||
count: u32,
|
||||
/// List of participants currently in the room.
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
// ── Federation signals (relay-to-relay) ──
|
||||
|
||||
/// Federation: initial handshake — the connecting relay identifies itself.
|
||||
FederationHello {
|
||||
/// TLS certificate fingerprint of the connecting relay.
|
||||
tls_fingerprint: String,
|
||||
},
|
||||
|
||||
/// Federation: this relay now has local participants in a global room.
|
||||
GlobalRoomActive {
|
||||
room: String,
|
||||
/// Participants on the announcing relay (for federated presence).
|
||||
#[serde(default)]
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
/// Federation: this relay's last local participant left a global room.
|
||||
GlobalRoomInactive {
|
||||
room: String,
|
||||
},
|
||||
|
||||
// ── Direct calling signals (client ↔ relay signaling) ──
|
||||
|
||||
/// Register on relay for direct calls. Sent on `_signal` connections
|
||||
/// after optional AuthToken.
|
||||
RegisterPresence {
|
||||
/// Client's Ed25519 identity public key.
|
||||
identity_pub: [u8; 32],
|
||||
/// Signature over ("register-presence" || identity_pub).
|
||||
signature: Vec<u8>,
|
||||
/// Optional display name.
|
||||
alias: Option<String>,
|
||||
},
|
||||
|
||||
/// Relay confirms presence registration.
|
||||
RegisterPresenceAck {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Direct call offer routed through the relay to a specific peer.
|
||||
DirectCallOffer {
|
||||
/// Caller's fingerprint.
|
||||
caller_fingerprint: String,
|
||||
/// Caller's display name.
|
||||
caller_alias: Option<String>,
|
||||
/// Target's fingerprint.
|
||||
target_fingerprint: String,
|
||||
/// Unique call session ID (UUID).
|
||||
call_id: String,
|
||||
/// Caller's Ed25519 identity pub.
|
||||
identity_pub: [u8; 32],
|
||||
/// Caller's ephemeral X25519 pub (for key exchange on media connect).
|
||||
ephemeral_pub: [u8; 32],
|
||||
/// Signature over (ephemeral_pub || target_fingerprint || call_id).
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
},
|
||||
|
||||
/// Callee's response to a direct call.
|
||||
DirectCallAnswer {
|
||||
call_id: String,
|
||||
/// How the callee accepts (or rejects).
|
||||
accept_mode: CallAcceptMode,
|
||||
/// Callee's identity pub (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
identity_pub: Option<[u8; 32]>,
|
||||
/// Callee's ephemeral pub (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ephemeral_pub: Option<[u8; 32]>,
|
||||
/// Signature (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<Vec<u8>>,
|
||||
/// Chosen quality profile (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chosen_profile: Option<crate::QualityProfile>,
|
||||
},
|
||||
|
||||
/// Relay tells both parties: media room is ready.
|
||||
CallSetup {
|
||||
call_id: String,
|
||||
/// Room name on the relay for the media session (e.g., "_call:a1b2c3d4").
|
||||
room: String,
|
||||
/// Relay address for the QUIC media connection.
|
||||
relay_addr: String,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
CallRinging {
|
||||
call_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the callee responds to a direct call.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CallAcceptMode {
|
||||
/// Reject the call.
|
||||
Reject,
|
||||
/// Accept with trust — in Phase 2, this enables P2P (reveals IP).
|
||||
/// In Phase 1, behaves the same as AcceptGeneric.
|
||||
AcceptTrusted,
|
||||
/// Accept with privacy — relay always mediates media.
|
||||
AcceptGeneric,
|
||||
}
|
||||
|
||||
/// A participant entry in a RoomUpdate message.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RoomParticipant {
|
||||
/// Identity fingerprint (hex string, stable across reconnects if seed is persisted).
|
||||
pub fingerprint: String,
|
||||
/// Optional display name set by the client.
|
||||
pub alias: Option<String>,
|
||||
/// Relay label — identifies which relay this participant is connected to.
|
||||
/// None for local participants, Some("Relay B") for federated.
|
||||
#[serde(default)]
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::packet::QualityReport;
|
||||
use crate::traits::QualityController;
|
||||
@@ -25,31 +24,11 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine which tier a quality report belongs to (default/WiFi thresholds).
|
||||
/// Determine which tier a quality report belongs to.
|
||||
pub fn classify(report: &QualityReport) -> Self {
|
||||
Self::classify_with_context(report, NetworkContext::Unknown)
|
||||
}
|
||||
|
||||
/// Classify with network-context-aware thresholds.
|
||||
pub fn classify_with_context(report: &QualityReport, context: NetworkContext) -> Self {
|
||||
let loss = report.loss_percent();
|
||||
let rtt = report.rtt_ms();
|
||||
|
||||
match context {
|
||||
NetworkContext::CellularLte
|
||||
| NetworkContext::Cellular5g
|
||||
| NetworkContext::Cellular3g => {
|
||||
// Tighter thresholds for cellular networks
|
||||
if loss > 25.0 || rtt > 500 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 8.0 || rtt > 300 {
|
||||
Self::Degraded
|
||||
} else {
|
||||
Self::Good
|
||||
}
|
||||
}
|
||||
NetworkContext::WiFi | NetworkContext::Unknown => {
|
||||
// Original thresholds
|
||||
if loss > 40.0 || rtt > 600 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 10.0 || rtt > 400 {
|
||||
@@ -59,37 +38,10 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the next lower (worse) tier, or None if already at the worst.
|
||||
pub fn downgrade(self) -> Option<Tier> {
|
||||
match self {
|
||||
Self::Good => Some(Self::Degraded),
|
||||
Self::Degraded => Some(Self::Catastrophic),
|
||||
Self::Catastrophic => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the network transport type for context-aware quality decisions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkContext {
|
||||
WiFi,
|
||||
CellularLte,
|
||||
Cellular5g,
|
||||
Cellular3g,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for NetworkContext {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||
///
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier
|
||||
/// - Upgrade: 10 consecutive reports in a better tier
|
||||
pub struct AdaptiveQualityController {
|
||||
current_tier: Tier,
|
||||
@@ -102,26 +54,14 @@ pub struct AdaptiveQualityController {
|
||||
history: VecDeque<QualityReport>,
|
||||
/// Whether the profile was manually forced (disables adaptive logic).
|
||||
forced: bool,
|
||||
/// Current network context for threshold selection.
|
||||
network_context: NetworkContext,
|
||||
/// FEC boost expiry time (set during network handoff).
|
||||
fec_boost_until: Option<Instant>,
|
||||
/// FEC boost amount to add during handoff recovery window.
|
||||
fec_boost_amount: f32,
|
||||
}
|
||||
|
||||
/// Threshold for downgrading (fast reaction to degradation).
|
||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||
/// Threshold for downgrading on cellular networks (even faster).
|
||||
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
||||
/// Threshold for upgrading (slow, cautious improvement).
|
||||
const UPGRADE_THRESHOLD: u32 = 10;
|
||||
/// Maximum history window size.
|
||||
const HISTORY_SIZE: usize = 20;
|
||||
/// Default FEC boost amount during handoff recovery.
|
||||
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
||||
/// Duration of FEC boost after a network handoff.
|
||||
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
||||
|
||||
impl AdaptiveQualityController {
|
||||
pub fn new() -> Self {
|
||||
@@ -132,9 +72,6 @@ impl AdaptiveQualityController {
|
||||
consecutive_down: 0,
|
||||
history: VecDeque::with_capacity(HISTORY_SIZE),
|
||||
forced: false,
|
||||
network_context: NetworkContext::default(),
|
||||
fec_boost_until: None,
|
||||
fec_boost_amount: DEFAULT_FEC_BOOST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,69 +80,6 @@ impl AdaptiveQualityController {
|
||||
self.current_tier
|
||||
}
|
||||
|
||||
/// Get the current network context.
|
||||
pub fn network_context(&self) -> NetworkContext {
|
||||
self.network_context
|
||||
}
|
||||
|
||||
/// Signal a network transport change (e.g., WiFi to cellular handoff).
|
||||
///
|
||||
/// When switching from WiFi to any cellular type, this preemptively
|
||||
/// downgrades one quality tier and activates a temporary FEC boost.
|
||||
pub fn signal_network_change(&mut self, new_context: NetworkContext) {
|
||||
let old = self.network_context;
|
||||
self.network_context = new_context;
|
||||
|
||||
let new_is_cellular = matches!(
|
||||
new_context,
|
||||
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
||||
);
|
||||
|
||||
// If switching from WiFi to cellular, preemptively downgrade one tier
|
||||
if old == NetworkContext::WiFi && new_is_cellular {
|
||||
if let Some(lower_tier) = self.current_tier.downgrade() {
|
||||
self.current_tier = lower_tier;
|
||||
self.current_profile = lower_tier.profile();
|
||||
}
|
||||
// Reset counters to avoid stale hysteresis state
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
// Un-force so adaptive logic resumes
|
||||
self.forced = false;
|
||||
}
|
||||
|
||||
// Activate FEC boost for any network change
|
||||
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
||||
}
|
||||
|
||||
/// Returns the FEC boost amount if within the handoff recovery window, 0.0 otherwise.
|
||||
///
|
||||
/// Callers should add this to their base FEC ratio during the boost window.
|
||||
pub fn fec_boost(&self) -> f32 {
|
||||
if let Some(until) = self.fec_boost_until {
|
||||
if Instant::now() < until {
|
||||
return self.fec_boost_amount;
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Reset the hysteresis counters.
|
||||
pub fn reset_counters(&mut self) {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
}
|
||||
|
||||
/// Get the effective downgrade threshold based on network context.
|
||||
fn downgrade_threshold(&self) -> u32 {
|
||||
match self.network_context {
|
||||
NetworkContext::CellularLte
|
||||
| NetworkContext::Cellular5g
|
||||
| NetworkContext::Cellular3g => CELLULAR_DOWNGRADE_THRESHOLD,
|
||||
_ => DOWNGRADE_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||
if observed_tier == self.current_tier {
|
||||
self.consecutive_up = 0;
|
||||
@@ -222,7 +96,7 @@ impl AdaptiveQualityController {
|
||||
if is_worse {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down += 1;
|
||||
if self.consecutive_down >= self.downgrade_threshold() {
|
||||
if self.consecutive_down >= DOWNGRADE_THRESHOLD {
|
||||
self.current_tier = observed_tier;
|
||||
self.current_profile = observed_tier.profile();
|
||||
self.consecutive_down = 0;
|
||||
@@ -268,7 +142,7 @@ impl QualityController for AdaptiveQualityController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let observed = Tier::classify_with_context(report, self.network_context);
|
||||
let observed = Tier::classify(report);
|
||||
self.try_transition(observed)
|
||||
}
|
||||
|
||||
@@ -372,110 +246,4 @@ mod tests {
|
||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Network context tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cellular_tighter_thresholds() {
|
||||
// 12% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(12.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 9% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(9.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
||||
let report = make_report(30.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
||||
Tier::Catastrophic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_rtt_thresholds() {
|
||||
// RTT 350ms: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_faster_downgrade() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
// Reset tier back to Good for testing downgrade threshold
|
||||
ctrl.current_tier = Tier::Good;
|
||||
ctrl.current_profile = Tier::Good.profile();
|
||||
|
||||
// On cellular, downgrade threshold is 2 instead of 3
|
||||
let bad = make_report(50.0, 200);
|
||||
assert!(ctrl.observe(&bad).is_none()); // 1st bad
|
||||
let result = ctrl.observe(&bad); // 2nd bad — should trigger on cellular
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_network_change_preemptive_downgrade() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
|
||||
// Switch from WiFi to cellular
|
||||
ctrl.network_context = NetworkContext::WiFi;
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
|
||||
// Should have downgraded one tier: Good -> Degraded
|
||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_network_change_fec_boost() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
assert_eq!(ctrl.fec_boost(), 0.0);
|
||||
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
|
||||
// FEC boost should be active
|
||||
assert!(ctrl.fec_boost() > 0.0);
|
||||
assert_eq!(ctrl.fec_boost(), DEFAULT_FEC_BOOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_downgrade() {
|
||||
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
||||
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
||||
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_context_default() {
|
||||
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,14 +132,6 @@ pub trait CryptoSession: Send + Sync {
|
||||
fn overhead(&self) -> usize {
|
||||
16 // ChaCha20-Poly1305 tag
|
||||
}
|
||||
|
||||
/// Short Authentication String (SAS) — 4-digit code for verbal verification.
|
||||
/// Both peers derive the same code from the shared secret + identity keys.
|
||||
/// If a MITM relay is intercepting, the codes will differ.
|
||||
/// Returns None if SAS was not computed (e.g., relay-side sessions).
|
||||
fn sas_code(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Key exchange using the Warzone identity model.
|
||||
|
||||
@@ -28,9 +28,6 @@ prometheus = "0.13"
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "6"
|
||||
sha2 = { workspace = true }
|
||||
chrono = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-relay"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Get git hash at build time
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output();
|
||||
|
||||
let hash = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
};
|
||||
|
||||
println!("cargo:rustc-env=WZP_BUILD_HASH={hash}");
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
//! Direct call state tracking.
|
||||
//!
|
||||
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
|
||||
//! Each call goes through: Pending → Ringing → Active → Ended.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// State of a direct call.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DirectCallState {
|
||||
/// Offer sent to callee, waiting for response.
|
||||
Pending,
|
||||
/// Callee acknowledged, ringing.
|
||||
Ringing,
|
||||
/// Call accepted, media room active.
|
||||
Active,
|
||||
/// Call ended (hangup, reject, timeout, or error).
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// A tracked direct call between two users.
|
||||
pub struct DirectCall {
|
||||
pub call_id: String,
|
||||
pub caller_fingerprint: String,
|
||||
pub callee_fingerprint: String,
|
||||
pub state: DirectCallState,
|
||||
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
|
||||
/// Private room name (set when accepted).
|
||||
pub room_name: Option<String>,
|
||||
pub created_at: Instant,
|
||||
pub answered_at: Option<Instant>,
|
||||
pub ended_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Registry of active direct calls.
|
||||
pub struct CallRegistry {
|
||||
calls: HashMap<String, DirectCall>,
|
||||
}
|
||||
|
||||
impl CallRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
calls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new pending call. Returns the call_id.
|
||||
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
|
||||
let call = DirectCall {
|
||||
call_id: call_id.clone(),
|
||||
caller_fingerprint: caller_fp,
|
||||
callee_fingerprint: callee_fp,
|
||||
state: DirectCallState::Pending,
|
||||
accept_mode: None,
|
||||
room_name: None,
|
||||
created_at: Instant::now(),
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
self.calls.insert(call_id.clone(), call);
|
||||
self.calls.get(&call_id).unwrap()
|
||||
}
|
||||
|
||||
/// Get a call by ID.
|
||||
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
||||
self.calls.get(call_id)
|
||||
}
|
||||
|
||||
/// Get a mutable call by ID.
|
||||
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
|
||||
self.calls.get_mut(call_id)
|
||||
}
|
||||
|
||||
/// Transition to Ringing state.
|
||||
pub fn set_ringing(&mut self, call_id: &str) -> bool {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
if call.state == DirectCallState::Pending {
|
||||
call.state = DirectCallState::Ringing;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Transition to Active state.
|
||||
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
|
||||
call.state = DirectCallState::Active;
|
||||
call.accept_mode = Some(mode);
|
||||
call.room_name = Some(room);
|
||||
call.answered_at = Some(Instant::now());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// End a call.
|
||||
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.state = DirectCallState::Ended;
|
||||
call.ended_at = Some(Instant::now());
|
||||
}
|
||||
self.calls.remove(call_id)
|
||||
}
|
||||
|
||||
/// Find active/pending calls involving a fingerprint.
|
||||
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
|
||||
self.calls.values()
|
||||
.filter(|c| {
|
||||
c.state != DirectCallState::Ended
|
||||
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find the peer's fingerprint in a call.
|
||||
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
|
||||
self.calls.get(call_id).map(|c| {
|
||||
if c.caller_fingerprint == my_fp {
|
||||
c.callee_fingerprint.as_str()
|
||||
} else {
|
||||
c.caller_fingerprint.as_str()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove calls that have been pending longer than the timeout.
|
||||
/// Returns call IDs of expired calls.
|
||||
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
|
||||
let now = Instant::now();
|
||||
let expired: Vec<String> = self.calls.iter()
|
||||
.filter(|(_, c)| {
|
||||
c.state == DirectCallState::Pending
|
||||
&& now.duration_since(c.created_at) > timeout
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
expired.into_iter()
|
||||
.filter_map(|id| self.calls.remove(&id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of active (non-ended) calls.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.calls.values()
|
||||
.filter(|c| c.state != DirectCallState::Ended)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn call_lifecycle() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
|
||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
|
||||
assert!(reg.set_ringing("c1"));
|
||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
|
||||
|
||||
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
|
||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
|
||||
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
|
||||
|
||||
let ended = reg.end_call("c1").unwrap();
|
||||
assert_eq!(ended.state, DirectCallState::Ended);
|
||||
assert_eq!(reg.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expire_stale_calls() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
|
||||
// Not expired yet
|
||||
let expired = reg.expire_stale(Duration::from_secs(30));
|
||||
assert!(expired.is_empty());
|
||||
|
||||
// Force expiry with 0 timeout
|
||||
let expired = reg.expire_stale(Duration::from_secs(0));
|
||||
assert_eq!(expired.len(), 1);
|
||||
assert_eq!(expired[0].call_id, "c1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_lookup() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
|
||||
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
|
||||
}
|
||||
}
|
||||
@@ -3,41 +3,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// A federated peer relay.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
/// Address of the peer relay (e.g., "193.180.213.68:4433").
|
||||
pub url: String,
|
||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||
pub fingerprint: String,
|
||||
/// Optional human-readable label.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// A trusted relay — accepts inbound federation without needing the peer's address.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TrustedConfig {
|
||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||
pub fingerprint: String,
|
||||
/// Optional human-readable label.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// A room declared global — bridged across all federated peers.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GlobalRoomConfig {
|
||||
/// Room name to bridge (e.g., "android").
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Configuration for the relay daemon.
|
||||
///
|
||||
/// All fields have defaults, so a minimal TOML file only needs the
|
||||
/// fields you want to override (e.g., just `[[peers]]`).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RelayConfig {
|
||||
/// Address to listen on for incoming connections (client-facing).
|
||||
pub listen_addr: SocketAddr,
|
||||
@@ -77,22 +44,6 @@ pub struct RelayConfig {
|
||||
pub ws_port: Option<u16>,
|
||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||
pub static_dir: Option<String>,
|
||||
/// Federation peer relays.
|
||||
#[serde(default)]
|
||||
pub peers: Vec<PeerConfig>,
|
||||
/// Global rooms bridged across federation.
|
||||
#[serde(default)]
|
||||
pub global_rooms: Vec<GlobalRoomConfig>,
|
||||
/// Trusted relay fingerprints — accept inbound federation from these relays.
|
||||
/// Unlike [[peers]], no url is needed — the peer connects to us.
|
||||
#[serde(default)]
|
||||
pub trusted: Vec<TrustedConfig>,
|
||||
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
|
||||
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
|
||||
pub debug_tap: Option<String>,
|
||||
/// JSONL event log path for protocol analysis (--event-log).
|
||||
#[serde(skip)]
|
||||
pub event_log: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RelayConfig {
|
||||
@@ -111,100 +62,6 @@ impl Default for RelayConfig {
|
||||
trunking_enabled: false,
|
||||
ws_port: None,
|
||||
static_dir: None,
|
||||
peers: Vec::new(),
|
||||
global_rooms: Vec::new(),
|
||||
trusted: Vec::new(),
|
||||
debug_tap: None,
|
||||
event_log: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load relay configuration from a TOML file.
|
||||
pub fn load_config(path: &str) -> Result<RelayConfig, anyhow::Error> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: RelayConfig = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Info about this relay instance, used to generate personalized example configs.
|
||||
pub struct RelayInfo {
|
||||
pub listen_addr: String,
|
||||
pub tls_fingerprint: String,
|
||||
pub public_ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Load config from path, or create a personalized example config if it doesn't exist.
|
||||
pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<RelayConfig, anyhow::Error> {
|
||||
let p = std::path::Path::new(path);
|
||||
if p.exists() {
|
||||
return load_config(path);
|
||||
}
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Generate personalized example config
|
||||
let example = generate_example_config(info);
|
||||
std::fs::write(p, &example)?;
|
||||
eprintln!("Created example config at {path} — edit it and restart.");
|
||||
let config: RelayConfig = toml::from_str(&example)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Generate an example TOML config, personalized with this relay's info if available.
|
||||
fn generate_example_config(info: Option<&RelayInfo>) -> String {
|
||||
let listen = info.map(|i| i.listen_addr.as_str()).unwrap_or("0.0.0.0:4433");
|
||||
let peer_example = if let Some(i) = info {
|
||||
let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip");
|
||||
format!(
|
||||
r#"# Other relays can peer with this relay using:
|
||||
# [[peers]]
|
||||
# url = "{ip}:{port}"
|
||||
# fingerprint = "{fp}"
|
||||
# label = "This Relay""#,
|
||||
port = listen.rsplit(':').next().unwrap_or("4433"),
|
||||
fp = i.tls_fingerprint,
|
||||
)
|
||||
} else {
|
||||
"# To peer with another relay, add its url + fingerprint:".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"# WarzonePhone Relay Configuration
|
||||
# See docs/ADMINISTRATION.md for full reference.
|
||||
|
||||
# Listen address for client connections
|
||||
listen_addr = "{listen}"
|
||||
|
||||
# Maximum concurrent sessions
|
||||
# max_sessions = 100
|
||||
|
||||
# Prometheus metrics endpoint (uncomment to enable)
|
||||
# metrics_port = 9090
|
||||
|
||||
# featherChat auth endpoint (uncomment to enable)
|
||||
# auth_url = "https://chat.example.com/v1/auth/validate"
|
||||
|
||||
{peer_example}
|
||||
|
||||
# Federation: peer relays we connect to (outbound)
|
||||
# [[peers]]
|
||||
# url = "other-relay.example.com:4433"
|
||||
# fingerprint = "aa:bb:cc:dd:..."
|
||||
# label = "Relay B"
|
||||
|
||||
# Federation: relays we trust inbound connections from
|
||||
# [[trusted]]
|
||||
# fingerprint = "ee:ff:00:11:..."
|
||||
# label = "Relay X"
|
||||
|
||||
# Global rooms bridged across all federated peers
|
||||
# [[global_rooms]]
|
||||
# name = "general"
|
||||
|
||||
# Debug: log packet headers for a room ("*" for all)
|
||||
# debug_tap = "*"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
//! JSONL event log for protocol analysis.
|
||||
//!
|
||||
//! When `--event-log <path>` is set, every media packet emits a structured
|
||||
//! event at each decision point (recv, forward, drop, deliver).
|
||||
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
/// A single protocol event for JSONL output.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Event {
|
||||
/// ISO 8601 timestamp with microseconds.
|
||||
pub ts: String,
|
||||
/// Event type.
|
||||
pub event: &'static str,
|
||||
/// Room name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub room: Option<String>,
|
||||
/// Source address or peer label.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub src: Option<String>,
|
||||
/// Packet sequence number.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<u16>,
|
||||
/// Codec identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
/// FEC block ID.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fec_block: Option<u8>,
|
||||
/// FEC symbol index.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fec_sym: Option<u8>,
|
||||
/// Is FEC repair packet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repair: Option<bool>,
|
||||
/// Payload length in bytes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub len: Option<usize>,
|
||||
/// Number of recipients.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to_count: Option<usize>,
|
||||
/// Peer label (for federation events).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub peer: Option<String>,
|
||||
/// Drop/error reason.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
/// Presence action (active/inactive).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
/// Participant count (presence events).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub participants: Option<usize>,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
||||
}
|
||||
|
||||
/// Create a minimal event with just type and timestamp.
|
||||
pub fn new(event: &'static str) -> Self {
|
||||
Self {
|
||||
ts: Self::now(),
|
||||
event,
|
||||
room: None,
|
||||
src: None,
|
||||
seq: None,
|
||||
codec: None,
|
||||
fec_block: None,
|
||||
fec_sym: None,
|
||||
repair: None,
|
||||
len: None,
|
||||
to_count: None,
|
||||
peer: None,
|
||||
reason: None,
|
||||
action: None,
|
||||
participants: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set room.
|
||||
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
|
||||
/// Set source.
|
||||
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
|
||||
/// Set packet header fields from a MediaPacket.
|
||||
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
|
||||
self.seq = Some(pkt.header.seq);
|
||||
self.codec = Some(format!("{:?}", pkt.header.codec_id));
|
||||
self.fec_block = Some(pkt.header.fec_block);
|
||||
self.fec_sym = Some(pkt.header.fec_symbol);
|
||||
self.repair = Some(pkt.header.is_repair);
|
||||
self.len = Some(pkt.payload.len());
|
||||
self
|
||||
}
|
||||
/// Set seq only (when full packet not available).
|
||||
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
|
||||
/// Set payload length.
|
||||
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
|
||||
/// Set recipient count.
|
||||
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
|
||||
/// Set peer label.
|
||||
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
|
||||
/// Set drop reason.
|
||||
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
|
||||
/// Set presence action.
|
||||
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
|
||||
/// Set participant count.
|
||||
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
|
||||
}
|
||||
|
||||
/// Handle for emitting events. Cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub struct EventLog {
|
||||
tx: mpsc::UnboundedSender<Event>,
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
/// Emit an event (non-blocking, drops if channel is full).
|
||||
pub fn emit(&self, event: Event) {
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op event log for when `--event-log` is not set.
|
||||
/// All methods are no-ops that compile to nothing.
|
||||
#[derive(Clone)]
|
||||
pub struct NoopEventLog;
|
||||
|
||||
/// Unified event log handle — either real or no-op.
|
||||
#[derive(Clone)]
|
||||
pub enum EventLogger {
|
||||
Active(EventLog),
|
||||
Noop,
|
||||
}
|
||||
|
||||
impl EventLogger {
|
||||
pub fn emit(&self, event: Event) {
|
||||
if let EventLogger::Active(log) = self {
|
||||
log.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, EventLogger::Active(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the event log writer. Returns an `EventLogger` handle.
|
||||
pub fn start_event_log(path: Option<PathBuf>) -> EventLogger {
|
||||
match path {
|
||||
Some(path) => {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(writer_task(path, rx));
|
||||
info!("event log enabled");
|
||||
EventLogger::Active(EventLog { tx })
|
||||
}
|
||||
None => EventLogger::Noop,
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task that writes events to a JSONL file.
|
||||
async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let file = match tokio::fs::File::create(&path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!("failed to create event log {}: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
let mut count: u64 = 0;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(json) => {
|
||||
if writer.write_all(json.as_bytes()).await.is_err() { break; }
|
||||
if writer.write_all(b"\n").await.is_err() { break; }
|
||||
count += 1;
|
||||
// Flush every 100 events
|
||||
if count % 100 == 0 {
|
||||
let _ = writer.flush().await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("event log serialize error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writer.flush().await;
|
||||
info!(events = count, "event log closed");
|
||||
}
|
||||
@@ -1,966 +0,0 @@
|
||||
//! Relay federation — global room routing between peer relays.
|
||||
//!
|
||||
//! Each relay maintains a forwarding table per global room. When a local participant
|
||||
//! sends media in a global room, it's forwarded to all peer relays that have the room
|
||||
//! active. Incoming federated media is delivered to local participants and optionally
|
||||
//! forwarded to other active peers (multi-hop).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use sha2::{Sha256, Digest};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::QuinnTransport;
|
||||
|
||||
use crate::config::{PeerConfig, TrustedConfig};
|
||||
use crate::event_log::{Event, EventLogger};
|
||||
use crate::room::{self, FederationMediaOut, RoomEvent, RoomManager};
|
||||
|
||||
/// Compute 8-byte room hash for federation datagram tagging.
|
||||
pub fn room_hash(room_name: &str) -> [u8; 8] {
|
||||
let h = Sha256::digest(room_name.as_bytes());
|
||||
let mut out = [0u8; 8];
|
||||
out.copy_from_slice(&h[..8]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Normalize a fingerprint string (remove colons, lowercase).
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.replace(':', "").to_lowercase()
|
||||
}
|
||||
|
||||
/// Time-based dedup filter for federation datagrams.
|
||||
/// Tracks recently seen packets and expires entries older than 2 seconds.
|
||||
/// This prevents duplicate delivery when the same packet arrives via
|
||||
/// multiple federation paths, while allowing new senders that happen to
|
||||
/// reuse the same seq numbers.
|
||||
struct Deduplicator {
|
||||
/// Recently seen packet keys with insertion time.
|
||||
entries: HashMap<u64, Instant>,
|
||||
/// Expiry duration.
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl Deduplicator {
|
||||
fn new(_capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::with_capacity(512),
|
||||
ttl: Duration::from_secs(2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this packet is a duplicate (already seen within TTL).
|
||||
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
|
||||
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
|
||||
let now = Instant::now();
|
||||
|
||||
// Periodic cleanup (every ~256 packets)
|
||||
if self.entries.len() > 256 {
|
||||
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
|
||||
}
|
||||
|
||||
if let Some(ts) = self.entries.get(&key) {
|
||||
if now.duration_since(*ts) < self.ttl {
|
||||
return true; // seen recently — duplicate
|
||||
}
|
||||
}
|
||||
self.entries.insert(key, now);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-room token bucket rate limiter for federation forwarding.
|
||||
struct RateLimiter {
|
||||
/// Max packets per second per room.
|
||||
max_pps: u32,
|
||||
/// Tokens remaining in current window.
|
||||
tokens: u32,
|
||||
/// When the current window started.
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(max_pps: u32) -> Self {
|
||||
Self {
|
||||
max_pps,
|
||||
tokens: max_pps,
|
||||
window_start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the packet should be allowed through.
|
||||
fn allow(&mut self) -> bool {
|
||||
let elapsed = self.window_start.elapsed();
|
||||
if elapsed >= Duration::from_secs(1) {
|
||||
self.tokens = self.max_pps;
|
||||
self.window_start = Instant::now();
|
||||
}
|
||||
if self.tokens > 0 {
|
||||
self.tokens -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active link to a peer relay.
|
||||
struct PeerLink {
|
||||
transport: Arc<QuinnTransport>,
|
||||
label: String,
|
||||
/// Global rooms that this peer has reported as active.
|
||||
active_rooms: HashSet<String>,
|
||||
/// Remote participants per room (for federated presence in RoomUpdate).
|
||||
remote_participants: HashMap<String, Vec<wzp_proto::packet::RoomParticipant>>,
|
||||
/// Last time we received any data (signal or media) from this peer.
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
/// Max federation packets per second per room (0 = unlimited).
|
||||
const FEDERATION_RATE_LIMIT_PPS: u32 = 500;
|
||||
/// Dedup window size (number of recent packets to remember).
|
||||
const DEDUP_WINDOW_SIZE: usize = 4096;
|
||||
/// Remote participants are considered stale after this duration with no updates.
|
||||
const REMOTE_PARTICIPANT_STALE_SECS: u64 = 15;
|
||||
|
||||
/// Manages federation connections and global room forwarding.
|
||||
pub struct FederationManager {
|
||||
peers: Vec<PeerConfig>,
|
||||
trusted: Vec<TrustedConfig>,
|
||||
global_rooms: HashSet<String>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
endpoint: quinn::Endpoint,
|
||||
local_tls_fp: String,
|
||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
||||
/// Active peer connections, keyed by normalized fingerprint.
|
||||
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
|
||||
/// Dedup filter for incoming federation datagrams.
|
||||
dedup: Mutex<Deduplicator>,
|
||||
/// Per-room seq counter for federation media delivered to local clients.
|
||||
/// Ensures clients see monotonically increasing seq regardless of federation sender.
|
||||
local_delivery_seq: std::sync::atomic::AtomicU16,
|
||||
/// JSONL event log for protocol analysis.
|
||||
event_log: EventLogger,
|
||||
/// Per-room rate limiters for inbound federation media.
|
||||
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
|
||||
}
|
||||
|
||||
impl FederationManager {
|
||||
pub fn new(
|
||||
peers: Vec<PeerConfig>,
|
||||
trusted: Vec<TrustedConfig>,
|
||||
global_rooms: HashSet<String>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
endpoint: quinn::Endpoint,
|
||||
local_tls_fp: String,
|
||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
||||
event_log: EventLogger,
|
||||
) -> Self {
|
||||
Self {
|
||||
peers,
|
||||
trusted,
|
||||
global_rooms,
|
||||
room_mgr,
|
||||
endpoint,
|
||||
local_tls_fp,
|
||||
metrics,
|
||||
peer_links: Arc::new(Mutex::new(HashMap::new())),
|
||||
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
|
||||
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
|
||||
event_log,
|
||||
rate_limiters: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a room name (which may be hashed) is a global room.
|
||||
pub fn is_global_room(&self, room: &str) -> bool {
|
||||
self.resolve_global_room(room).is_some()
|
||||
}
|
||||
|
||||
/// Resolve a room name (raw or hashed) to the canonical global room name.
|
||||
/// Returns the configured global room name if it matches.
|
||||
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
|
||||
// Direct match (raw room name, e.g. Android clients)
|
||||
if self.global_rooms.contains(room) {
|
||||
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
|
||||
}
|
||||
// Hashed match (desktop clients hash room names for SNI privacy)
|
||||
self.global_rooms.iter().find(|name| {
|
||||
wzp_crypto::hash_room_name(name) == room
|
||||
}).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Get the canonical federation room hash for a room.
|
||||
/// Always uses the configured global room name, not the client-provided name.
|
||||
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
|
||||
if let Some(canonical) = self.resolve_global_room(room) {
|
||||
room_hash(canonical)
|
||||
} else {
|
||||
room_hash(room)
|
||||
}
|
||||
}
|
||||
|
||||
/// Start federation — spawns connection loops + event dispatcher.
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
if self.peers.is_empty() && self.global_rooms.is_empty() {
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
peers = self.peers.len(),
|
||||
global_rooms = self.global_rooms.len(),
|
||||
"federation starting"
|
||||
);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Per-peer outbound connection loops
|
||||
for peer in &self.peers {
|
||||
let this = self.clone();
|
||||
let peer = peer.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
run_peer_loop(this, peer).await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Room event dispatcher
|
||||
let room_events = {
|
||||
let mgr = self.room_mgr.lock().await;
|
||||
mgr.subscribe_events()
|
||||
};
|
||||
let this = self.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
run_room_event_dispatcher(this, room_events).await;
|
||||
}));
|
||||
|
||||
// Stale presence sweeper — purges remote participants from dead peers
|
||||
let this = self.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
run_stale_presence_sweeper(this).await;
|
||||
}));
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound federation connection from a recognized peer.
|
||||
pub async fn handle_inbound(
|
||||
self: &Arc<Self>,
|
||||
transport: Arc<QuinnTransport>,
|
||||
peer_config: PeerConfig,
|
||||
) {
|
||||
let peer_fp = normalize_fp(&peer_config.fingerprint);
|
||||
let label = peer_config.label.unwrap_or_else(|| peer_config.url.clone());
|
||||
info!(peer = %label, "inbound federation link active");
|
||||
if let Err(e) = run_federation_link(self.clone(), transport, peer_fp, label.clone()).await {
|
||||
warn!(peer = %label, "inbound federation link ended: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all remote participants for a room from all peer links.
|
||||
/// Deduplicates by fingerprint (same participant may appear via multiple links).
|
||||
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
let canonical = self.resolve_global_room(room);
|
||||
let links = self.peer_links.lock().await;
|
||||
let mut result = Vec::new();
|
||||
for link in links.values() {
|
||||
// Check canonical name
|
||||
if let Some(c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
// Also check raw room name, but only if different from canonical
|
||||
if c != room {
|
||||
if let Some(remote) = link.remote_participants.get(room) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(remote) = link.remote_participants.get(room) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate by fingerprint
|
||||
let mut seen = HashSet::new();
|
||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
result
|
||||
}
|
||||
|
||||
/// Forward locally-generated media to all connected peers.
|
||||
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
|
||||
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
|
||||
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
||||
let links = self.peer_links.lock().await;
|
||||
if links.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (_fp, link) in links.iter() {
|
||||
let mut tagged = Vec::with_capacity(8 + media_data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(media_data);
|
||||
match link.transport.send_raw_datagram(&tagged) {
|
||||
Ok(()) => {
|
||||
self.metrics.federation_packets_forwarded
|
||||
.with_label_values(&[&link.label, "out"]).inc();
|
||||
}
|
||||
Err(e) => warn!(peer = %link.label, "federation send error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trust verification (kept from previous implementation) ──
|
||||
|
||||
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
|
||||
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
|
||||
}
|
||||
|
||||
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
|
||||
let addr_ip = addr.ip();
|
||||
self.peers.iter().find(|p| {
|
||||
p.url.parse::<SocketAddr>()
|
||||
.map(|sa| sa.ip() == addr_ip)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
|
||||
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
|
||||
}
|
||||
|
||||
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
|
||||
if let Some(peer) = self.find_peer_by_addr(addr) {
|
||||
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
|
||||
}
|
||||
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
|
||||
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outbound media egress task ──
|
||||
|
||||
/// Drains the federation media channel and forwards to active peers.
|
||||
pub async fn run_federation_media_egress(
|
||||
fm: Arc<FederationManager>,
|
||||
mut rx: tokio::sync::mpsc::Receiver<FederationMediaOut>,
|
||||
) {
|
||||
let mut count: u64 = 0;
|
||||
while let Some(out) = rx.recv().await {
|
||||
count += 1;
|
||||
if count == 1 || count % 250 == 0 {
|
||||
info!(room = %out.room_name, count, "federation egress: forwarding media");
|
||||
}
|
||||
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
|
||||
}
|
||||
info!(total = count, "federation egress task ended");
|
||||
}
|
||||
|
||||
// ── Room event dispatcher ──
|
||||
|
||||
/// Watches RoomManager events and sends GlobalRoomActive/Inactive to peers.
|
||||
async fn run_room_event_dispatcher(
|
||||
fm: Arc<FederationManager>,
|
||||
mut events: tokio::sync::broadcast::Receiver<RoomEvent>,
|
||||
) {
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(RoomEvent::LocalJoin { room }) => {
|
||||
if fm.is_global_room(&room) {
|
||||
let participants = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.local_participant_list(&room)
|
||||
};
|
||||
info!(room = %room, count = participants.len(), "global room now active, announcing to peers");
|
||||
let msg = SignalMessage::GlobalRoomActive { room, participants };
|
||||
let links = fm.peer_links.lock().await;
|
||||
for link in links.values() {
|
||||
let _ = link.transport.send_signal(&msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RoomEvent::LocalLeave { room }) => {
|
||||
if fm.is_global_room(&room) {
|
||||
info!(room = %room, "global room now inactive, announcing to peers");
|
||||
let msg = SignalMessage::GlobalRoomInactive { room };
|
||||
let links = fm.peer_links.lock().await;
|
||||
for link in links.values() {
|
||||
let _ = link.transport.send_signal(&msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(missed = n, "room event receiver lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stale presence sweeper ──
|
||||
|
||||
/// Periodically checks for stale remote participants and purges them.
|
||||
/// This handles the case where a peer link dies without sending GlobalRoomInactive
|
||||
/// (e.g., QUIC timeout, network partition, crash).
|
||||
async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let stale_threshold = Duration::from_secs(REMOTE_PARTICIPANT_STALE_SECS);
|
||||
|
||||
// Find peers with stale remote_participants whose link is also gone or idle
|
||||
let stale_rooms: Vec<(String, String)> = {
|
||||
let links = fm.peer_links.lock().await;
|
||||
let mut stale = Vec::new();
|
||||
for (fp, link) in links.iter() {
|
||||
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
|
||||
for room in link.remote_participants.keys() {
|
||||
stale.push((fp.clone(), room.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
stale
|
||||
};
|
||||
|
||||
if stale_rooms.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Purge stale entries and collect affected rooms
|
||||
let mut affected_rooms = HashSet::new();
|
||||
{
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
for (fp, room) in &stale_rooms {
|
||||
if let Some(link) = links.get_mut(fp.as_str()) {
|
||||
if link.last_seen.elapsed() > stale_threshold {
|
||||
info!(peer = %link.label, room = %room, "purging stale remote participants (no data for {}s)", link.last_seen.elapsed().as_secs());
|
||||
link.remote_participants.remove(room);
|
||||
link.active_rooms.remove(room);
|
||||
affected_rooms.insert(room.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast updated RoomUpdate for affected rooms
|
||||
for room in &affected_rooms {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
for local_room in mgr.active_rooms() {
|
||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(room) {
|
||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||
let remote = fm.get_remote_participants(&local_room).await;
|
||||
all_participants.extend(remote);
|
||||
let mut seen = HashSet::new();
|
||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
let update = SignalMessage::RoomUpdate {
|
||||
count: all_participants.len() as u32,
|
||||
participants: all_participants,
|
||||
};
|
||||
let senders = mgr.local_senders(&local_room);
|
||||
drop(mgr);
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
info!(room = %room, "swept stale presence — broadcast updated RoomUpdate");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer connection management ──
|
||||
|
||||
/// Persistent connection loop for one peer — reconnects with backoff.
|
||||
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
|
||||
let mut backoff = Duration::from_secs(5);
|
||||
loop {
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
|
||||
match connect_to_peer(&fm, &peer).await {
|
||||
Ok(transport) => {
|
||||
backoff = Duration::from_secs(5);
|
||||
let peer_fp = normalize_fp(&peer.fingerprint);
|
||||
let label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
|
||||
if let Err(e) = run_federation_link(fm.clone(), transport, peer_fp, label).await {
|
||||
warn!(peer_url = %peer.url, "federation link ended: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a peer relay and send hello.
|
||||
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
|
||||
let addr: SocketAddr = peer.url.parse()?;
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
||||
let transport = Arc::new(QuinnTransport::new(conn));
|
||||
|
||||
// Send hello with our TLS fingerprint
|
||||
let hello = SignalMessage::FederationHello {
|
||||
tls_fingerprint: fm.local_tls_fp.clone(),
|
||||
};
|
||||
transport.send_signal(&hello).await
|
||||
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
|
||||
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
// ── Federation link (runs on a single QUIC connection) ──
|
||||
|
||||
/// Run the federation link: exchange global room state and forward media.
|
||||
async fn run_federation_link(
|
||||
fm: Arc<FederationManager>,
|
||||
transport: Arc<QuinnTransport>,
|
||||
peer_fp: String,
|
||||
peer_label: String,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// Register peer link + metrics
|
||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
|
||||
{
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
links.insert(peer_fp.clone(), PeerLink {
|
||||
transport: transport.clone(),
|
||||
label: peer_label.clone(),
|
||||
active_rooms: HashSet::new(),
|
||||
remote_participants: HashMap::new(),
|
||||
last_seen: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Announce our currently active global rooms to this new peer
|
||||
// Collect all announcements first, then send (avoid holding locks across await)
|
||||
let announcements = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
let active = mgr.active_rooms();
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
// Local rooms
|
||||
for room_name in &active {
|
||||
if fm.is_global_room(room_name) {
|
||||
let participants = mgr.local_participant_list(room_name);
|
||||
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
|
||||
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
|
||||
}
|
||||
}
|
||||
|
||||
// Remote rooms from OTHER peers (for multi-hop propagation)
|
||||
let links = fm.peer_links.lock().await;
|
||||
for (fp, link) in links.iter() {
|
||||
if fp != &peer_fp {
|
||||
for (room, participants) in &link.remote_participants {
|
||||
if fm.is_global_room(room) {
|
||||
info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer");
|
||||
msgs.push(SignalMessage::GlobalRoomActive {
|
||||
room: room.clone(),
|
||||
participants: participants.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs
|
||||
};
|
||||
for msg in &announcements {
|
||||
let _ = transport.send_signal(msg).await;
|
||||
}
|
||||
|
||||
// Three concurrent tasks: signal recv + media recv + RTT monitor
|
||||
let signal_transport = transport.clone();
|
||||
let media_transport = transport.clone();
|
||||
let rtt_transport = transport.clone();
|
||||
let fm_signal = fm.clone();
|
||||
let fm_media = fm.clone();
|
||||
let fm_rtt = fm.clone();
|
||||
let peer_fp_signal = peer_fp.clone();
|
||||
let peer_fp_media = peer_fp.clone();
|
||||
let label_signal = peer_label.clone();
|
||||
let label_rtt = peer_label.clone();
|
||||
|
||||
let signal_task = async move {
|
||||
loop {
|
||||
match signal_transport.recv_signal().await {
|
||||
Ok(Some(msg)) => {
|
||||
handle_signal(&fm_signal, &peer_fp_signal, &label_signal, msg).await;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
error!(peer = %label_signal, "federation signal error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let peer_label_media = peer_label.clone();
|
||||
let media_task = async move {
|
||||
let mut media_count: u64 = 0;
|
||||
loop {
|
||||
match media_transport.connection().read_datagram().await {
|
||||
Ok(data) => {
|
||||
media_count += 1;
|
||||
if media_count == 1 || media_count % 250 == 0 {
|
||||
info!(peer = %peer_label_media, media_count, len = data.len(), "federation: received datagram");
|
||||
}
|
||||
handle_datagram(&fm_media, &peer_fp_media, data).await;
|
||||
}
|
||||
Err(e) => {
|
||||
info!(peer = %peer_label_media, "federation media task ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// RTT monitor: periodically sample QUIC RTT for this peer
|
||||
let rtt_task = async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = signal_task => {}
|
||||
_ = media_task => {}
|
||||
_ = rtt_task => {}
|
||||
}
|
||||
|
||||
// Cleanup: remove peer link + metrics
|
||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
|
||||
{
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
links.remove(&peer_fp);
|
||||
}
|
||||
info!(peer = %peer_label, "federation link ended");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an incoming federation signal.
|
||||
async fn handle_signal(
|
||||
fm: &Arc<FederationManager>,
|
||||
peer_fp: &str,
|
||||
peer_label: &str,
|
||||
msg: SignalMessage,
|
||||
) {
|
||||
// Update last_seen for this peer
|
||||
{
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
if let Some(link) = links.get_mut(peer_fp) {
|
||||
link.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
match msg {
|
||||
SignalMessage::GlobalRoomActive { room, participants } => {
|
||||
if fm.is_global_room(&room) {
|
||||
info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active");
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
if let Some(link) = links.get_mut(peer_fp) {
|
||||
link.active_rooms.insert(room.clone());
|
||||
}
|
||||
// Update active rooms metric
|
||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
||||
fm.metrics.federation_active_rooms.set(total as i64);
|
||||
if let Some(link) = links.get_mut(peer_fp) {
|
||||
// Tag remote participants with their relay label
|
||||
let tagged: Vec<_> = participants.iter().map(|p| {
|
||||
let mut tagged = p.clone();
|
||||
if tagged.relay_label.is_none() {
|
||||
tagged.relay_label = Some(link.label.clone());
|
||||
}
|
||||
tagged
|
||||
}).collect();
|
||||
link.remote_participants.insert(room.clone(), tagged);
|
||||
}
|
||||
// Propagate to other peers (with relay labels preserved)
|
||||
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
|
||||
let label = link.label.clone();
|
||||
participants.iter().map(|p| {
|
||||
let mut t = p.clone();
|
||||
if t.relay_label.is_none() {
|
||||
t.relay_label = Some(label.clone());
|
||||
}
|
||||
t
|
||||
}).collect::<Vec<_>>()
|
||||
} else {
|
||||
participants.clone()
|
||||
};
|
||||
for (fp, link) in links.iter() {
|
||||
if fp != peer_fp {
|
||||
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
|
||||
room: room.clone(),
|
||||
participants: tagged_for_propagation.clone(),
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
drop(links);
|
||||
|
||||
// Broadcast updated RoomUpdate to local clients in this room
|
||||
// Find the local room name (may be hashed or raw)
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
for local_room in mgr.active_rooms() {
|
||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||
// Build merged participant list: local + all remote (deduped)
|
||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||
let links = fm.peer_links.lock().await;
|
||||
for link in links.values() {
|
||||
if let Some(canonical) = fm.resolve_global_room(&local_room) {
|
||||
if let Some(remote) = link.remote_participants.get(canonical) {
|
||||
all_participants.extend(remote.iter().cloned());
|
||||
}
|
||||
// Also check raw room name, but only if different from canonical
|
||||
if canonical != local_room {
|
||||
if let Some(remote) = link.remote_participants.get(&local_room) {
|
||||
all_participants.extend(remote.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate by fingerprint
|
||||
let mut seen = HashSet::new();
|
||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
let update = SignalMessage::RoomUpdate {
|
||||
count: all_participants.len() as u32,
|
||||
participants: all_participants,
|
||||
};
|
||||
let senders = mgr.local_senders(&local_room);
|
||||
drop(links);
|
||||
drop(mgr);
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SignalMessage::GlobalRoomInactive { room } => {
|
||||
info!(peer = %peer_label, room = %room, "peer global room now inactive");
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
if let Some(link) = links.get_mut(peer_fp) {
|
||||
link.active_rooms.remove(&room);
|
||||
// Clear remote participants for this peer+room
|
||||
link.remote_participants.remove(&room);
|
||||
// Also try canonical name
|
||||
if let Some(canonical) = fm.resolve_global_room(&room) {
|
||||
link.remote_participants.remove(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active rooms metric
|
||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
||||
fm.metrics.federation_active_rooms.set(total as i64);
|
||||
|
||||
// Build remaining remote participants (from all peers except the one going inactive)
|
||||
let remaining_remote: Vec<wzp_proto::packet::RoomParticipant> = {
|
||||
let canonical = fm.resolve_global_room(&room);
|
||||
let mut result = Vec::new();
|
||||
for (fp, link) in links.iter() {
|
||||
if fp == peer_fp { continue; }
|
||||
if let Some(c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
result
|
||||
};
|
||||
|
||||
// Propagate to other peers: send updated GlobalRoomActive with revised list,
|
||||
// or GlobalRoomInactive if no participants remain anywhere
|
||||
let local_active = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room))
|
||||
};
|
||||
let has_remaining = !remaining_remote.is_empty() || local_active;
|
||||
|
||||
// Collect peer transports to send to (avoid holding lock across await)
|
||||
let peer_sends: Vec<_> = links.iter()
|
||||
.filter(|(fp, _)| *fp != peer_fp)
|
||||
.map(|(_, link)| link.transport.clone())
|
||||
.collect();
|
||||
drop(links);
|
||||
|
||||
if has_remaining {
|
||||
// Send updated participant list to other peers
|
||||
let mut updated_participants = remaining_remote.clone();
|
||||
if local_active {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
for local_room in mgr.active_rooms() {
|
||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||
updated_participants.extend(mgr.local_participant_list(&local_room));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let msg = SignalMessage::GlobalRoomActive {
|
||||
room: room.clone(),
|
||||
participants: updated_participants,
|
||||
};
|
||||
for transport in &peer_sends {
|
||||
let _ = transport.send_signal(&msg).await;
|
||||
}
|
||||
} else {
|
||||
// No participants left anywhere — propagate inactive
|
||||
let msg = SignalMessage::GlobalRoomInactive { room: room.clone() };
|
||||
for transport in &peer_sends {
|
||||
let _ = transport.send_signal(&msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast updated RoomUpdate to local clients (remote participant removed)
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
for local_room in mgr.active_rooms() {
|
||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||
all_participants.extend(remaining_remote.iter().cloned());
|
||||
// Deduplicate by fingerprint
|
||||
let mut seen = HashSet::new();
|
||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
let update = SignalMessage::RoomUpdate {
|
||||
count: all_participants.len() as u32,
|
||||
participants: all_participants,
|
||||
};
|
||||
let senders = mgr.local_senders(&local_room);
|
||||
drop(mgr);
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
info!(room = %room, "broadcast updated presence (remote participant removed)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // ignore other signals
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an incoming federation datagram (room-hash-tagged media).
|
||||
async fn handle_datagram(
|
||||
fm: &Arc<FederationManager>,
|
||||
source_peer_fp: &str,
|
||||
data: Bytes,
|
||||
) {
|
||||
if data.len() < 12 { return; } // 8-byte hash + min packet
|
||||
|
||||
let mut rh = [0u8; 8];
|
||||
rh.copy_from_slice(&data[..8]);
|
||||
let media_bytes = data.slice(8..);
|
||||
|
||||
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
|
||||
Some(pkt) => pkt,
|
||||
None => {
|
||||
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Event log: federation ingress
|
||||
let peer_label = {
|
||||
let links = fm.peer_links.lock().await;
|
||||
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
|
||||
};
|
||||
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
|
||||
|
||||
// Count inbound federation packet + update last_seen
|
||||
fm.metrics.federation_packets_forwarded
|
||||
.with_label_values(&[source_peer_fp, "in"]).inc();
|
||||
{
|
||||
let mut links = fm.peer_links.lock().await;
|
||||
if let Some(link) = links.get_mut(source_peer_fp) {
|
||||
link.last_seen = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: drop packets we've already seen (multi-path duplicates).
|
||||
// Key uses a hash of the actual payload bytes — unique per Opus frame,
|
||||
// so different senders with the same seq/timestamp never collide.
|
||||
let payload_hash = {
|
||||
let mut h = 0u64;
|
||||
for (i, &b) in media_bytes.iter().take(16).enumerate() {
|
||||
h ^= (b as u64) << ((i % 8) * 8);
|
||||
}
|
||||
h
|
||||
};
|
||||
{
|
||||
let mut dedup = fm.dedup.lock().await;
|
||||
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
|
||||
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find room by hash — check local rooms AND global room config
|
||||
let room_name = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
let active = mgr.active_rooms();
|
||||
// First: check local rooms (has participants)
|
||||
active.iter().find(|r| room_hash(r) == rh).cloned()
|
||||
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
|
||||
// Second: check global room config (hub relay may have no local participants)
|
||||
.or_else(|| {
|
||||
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
|
||||
})
|
||||
};
|
||||
|
||||
let room_name = match room_name {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Rate limit per room
|
||||
if FEDERATION_RATE_LIMIT_PPS > 0 {
|
||||
let mut limiters = fm.rate_limiters.lock().await;
|
||||
let limiter = limiters.entry(room_name.clone())
|
||||
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
|
||||
if !limiter.allow() {
|
||||
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver to all local participants — forward the raw bytes as-is.
|
||||
// The original sender's MediaPacket is preserved exactly (no re-serialization).
|
||||
let locals = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.local_senders(&room_name)
|
||||
};
|
||||
for sender in &locals {
|
||||
match sender {
|
||||
room::ParticipantSender::Quic(t) => {
|
||||
if let Err(e) = t.send_raw_datagram(&media_bytes) {
|
||||
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
|
||||
warn!("federation local delivery error: {e}");
|
||||
}
|
||||
}
|
||||
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
|
||||
}
|
||||
}
|
||||
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
|
||||
|
||||
// Multi-hop: forward to ALL other connected peers (not the source)
|
||||
// Don't filter by active_rooms — the receiving peer decides whether to deliver
|
||||
let links = fm.peer_links.lock().await;
|
||||
for (fp, link) in links.iter() {
|
||||
if fp != source_peer_fp {
|
||||
let mut tagged = Vec::with_capacity(8 + media_bytes.len());
|
||||
tagged.extend_from_slice(&rh);
|
||||
tagged.extend_from_slice(&media_bytes);
|
||||
let _ = link.transport.send_raw_datagram(&tagged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,27 +15,25 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||
/// 5. Derive shared ChaCha20-Poly1305 session
|
||||
/// 6. Send `CallAnswer` back
|
||||
///
|
||||
/// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint,
|
||||
/// and the caller's alias (if provided in CallOffer).
|
||||
/// Returns the derived `CryptoSession` and the chosen `QualityProfile`.
|
||||
pub async fn accept_handshake(
|
||||
transport: &dyn MediaTransport,
|
||||
seed: &[u8; 32],
|
||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
|
||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error> {
|
||||
// 1. Receive CallOffer
|
||||
let offer = transport
|
||||
.recv_signal()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
||||
|
||||
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) =
|
||||
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) =
|
||||
match offer {
|
||||
SignalMessage::CallOffer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles,
|
||||
alias,
|
||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
|
||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallOffer, got {:?}",
|
||||
@@ -78,26 +76,25 @@ pub async fn accept_handshake(
|
||||
};
|
||||
transport.send_signal(&answer).await?;
|
||||
|
||||
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
|
||||
// Must match the format used in signal registration and presence.
|
||||
let caller_fp = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(&caller_identity_pub);
|
||||
let fp = wzp_crypto::Fingerprint([
|
||||
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
||||
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
||||
]);
|
||||
fp.to_string()
|
||||
};
|
||||
|
||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||
Ok((session, chosen_profile))
|
||||
}
|
||||
|
||||
/// Select the best quality profile from those the caller supports.
|
||||
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested
|
||||
// for federation reliability (large packets may exceed path MTU).
|
||||
QualityProfile::GOOD
|
||||
// Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
|
||||
if supported.is_empty() {
|
||||
return QualityProfile::GOOD;
|
||||
}
|
||||
// Pick the profile with the highest bitrate.
|
||||
supported
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
a.total_bitrate_kbps()
|
||||
.partial_cmp(&b.total_bitrate_kbps())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(QualityProfile::GOOD)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
//! quality transitions.
|
||||
|
||||
pub mod auth;
|
||||
pub mod call_registry;
|
||||
pub mod config;
|
||||
pub mod event_log;
|
||||
pub mod federation;
|
||||
pub mod signal_hub;
|
||||
pub mod handshake;
|
||||
pub mod metrics;
|
||||
pub mod pipeline;
|
||||
|
||||
@@ -13,9 +13,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{error, info};
|
||||
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_proto::MediaTransport;
|
||||
use wzp_relay::config::RelayConfig;
|
||||
use wzp_relay::metrics::RelayMetrics;
|
||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||
@@ -23,54 +23,12 @@ use wzp_relay::presence::PresenceRegistry;
|
||||
use wzp_relay::room::{self, RoomManager};
|
||||
use wzp_relay::session_mgr::SessionManager;
|
||||
|
||||
/// Parsed CLI result — config + identity path.
|
||||
struct CliResult {
|
||||
config: RelayConfig,
|
||||
identity_path: Option<String>,
|
||||
config_file: Option<String>,
|
||||
config_needs_create: bool,
|
||||
}
|
||||
|
||||
fn parse_args() -> CliResult {
|
||||
fn parse_args() -> RelayConfig {
|
||||
let mut config = RelayConfig::default();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// First pass: extract --config and --identity
|
||||
let mut config_file = None;
|
||||
let mut identity_path = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--config" | "-c" => { i += 1; config_file = args.get(i).cloned(); }
|
||||
"--identity" | "-i" => { i += 1; identity_path = args.get(i).cloned(); }
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Track if we need to create the config after identity is known
|
||||
let config_needs_create = config_file.as_ref().map(|p| !std::path::Path::new(p).exists()).unwrap_or(false);
|
||||
|
||||
let mut config = if let Some(ref path) = config_file {
|
||||
if config_needs_create {
|
||||
// Will be re-created with personalized info after identity is loaded
|
||||
RelayConfig::default()
|
||||
} else {
|
||||
wzp_relay::config::load_config(path)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("failed to load config from {path}: {e}");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
} else {
|
||||
RelayConfig::default()
|
||||
};
|
||||
|
||||
// CLI flags override config file values
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--config" | "-c" => { i += 1; } // already handled
|
||||
"--identity" | "-i" => { i += 1; } // already handled
|
||||
"--listen" => {
|
||||
i += 1;
|
||||
config.listen_addr = args.get(i).expect("--listen requires an address")
|
||||
@@ -123,28 +81,6 @@ fn parse_args() -> CliResult {
|
||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
||||
);
|
||||
}
|
||||
"--global-room" => {
|
||||
i += 1;
|
||||
config.global_rooms.push(wzp_relay::config::GlobalRoomConfig {
|
||||
name: args.get(i).expect("--global-room requires a room name").to_string(),
|
||||
});
|
||||
}
|
||||
"--debug-tap" => {
|
||||
i += 1;
|
||||
config.debug_tap = Some(
|
||||
args.get(i).expect("--debug-tap requires a room name (or '*' for all)").to_string(),
|
||||
);
|
||||
}
|
||||
"--event-log" => {
|
||||
i += 1;
|
||||
config.event_log = Some(
|
||||
args.get(i).expect("--event-log requires a file path").to_string(),
|
||||
);
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
println!("wzp-relay {}", env!("WZP_BUILD_HASH"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--mesh-status" => {
|
||||
// Print mesh table from a fresh registry and exit.
|
||||
// In practice this is useful after the relay has been running;
|
||||
@@ -154,11 +90,9 @@ fn parse_args() -> CliResult {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||
eprintln!();
|
||||
eprintln!("Options:");
|
||||
eprintln!(" -c, --config <path> Load config from TOML file (creates example if missing)");
|
||||
eprintln!(" -i, --identity <path> Identity file path (creates if missing, uses OsRng)");
|
||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||
@@ -168,8 +102,6 @@ fn parse_args() -> CliResult {
|
||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||
eprintln!(" --global-room <name> Declare a room as global (bridged across federation). Repeatable.");
|
||||
eprintln!(" --debug-tap <room> Log packet headers for a room ('*' for all rooms).");
|
||||
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
||||
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||
eprintln!();
|
||||
@@ -184,7 +116,7 @@ fn parse_args() -> CliResult {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
CliResult { config, identity_path, config_file, config_needs_create }
|
||||
config
|
||||
}
|
||||
|
||||
struct RelayStats {
|
||||
@@ -252,29 +184,10 @@ async fn run_downstream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a non-loopback IP address from local interfaces.
|
||||
/// Prefers public IPs over private (10.x, 172.16-31.x, 192.168.x).
|
||||
fn detect_public_ip() -> Option<String> {
|
||||
use std::net::UdpSocket;
|
||||
// Connect to a public address to find our outbound IP (doesn't actually send anything)
|
||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
return Some(addr.ip().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build-time git hash, set by build.rs or env.
|
||||
const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args();
|
||||
let config = parse_args();
|
||||
tracing_subscriber::fmt().init();
|
||||
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("failed to install rustls crypto provider");
|
||||
@@ -294,88 +207,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||
}
|
||||
|
||||
// Load or generate relay identity
|
||||
let relay_seed = {
|
||||
let id_path = match identity_path {
|
||||
Some(ref p) => std::path::PathBuf::from(p),
|
||||
None => dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".wzp")
|
||||
.join("relay-identity"),
|
||||
};
|
||||
if id_path.exists() {
|
||||
if let Ok(hex) = std::fs::read_to_string(&id_path) {
|
||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||
info!("loaded relay identity from {}", id_path.display());
|
||||
s
|
||||
} else {
|
||||
warn!("corrupt identity file {}, generating new", id_path.display());
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&id_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&id_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
if let Some(parent) = id_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&id_path, &hex);
|
||||
info!("generated relay identity at {}", id_path.display());
|
||||
s
|
||||
}
|
||||
};
|
||||
// Generate ephemeral relay identity for crypto handshake
|
||||
let relay_seed = wzp_crypto::Seed::generate();
|
||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||
|
||||
let (server_config, cert_der) = wzp_transport::server_config_from_seed(&relay_seed.0);
|
||||
let tls_fp = wzp_transport::tls_fingerprint(&cert_der);
|
||||
info!(tls_fingerprint = %tls_fp, "TLS certificate (deterministic from relay identity)");
|
||||
|
||||
// Create personalized config file if it was missing
|
||||
let public_ip = detect_public_ip();
|
||||
if config_needs_create {
|
||||
if let Some(ref path) = config_file {
|
||||
let info = wzp_relay::config::RelayInfo {
|
||||
listen_addr: config.listen_addr.to_string(),
|
||||
tls_fingerprint: tls_fp.clone(),
|
||||
public_ip: public_ip.clone(),
|
||||
};
|
||||
if let Err(e) = wzp_relay::config::load_or_create_config(path, Some(&info)) {
|
||||
warn!("failed to create config: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print federation hint with our public IP + listen port + TLS fingerprint
|
||||
let listen_port = config.listen_addr.port();
|
||||
if let Some(ip) = &public_ip {
|
||||
info!("federation: to peer with this relay, add to relay.toml:");
|
||||
info!(" [[peers]]");
|
||||
info!(" url = \"{ip}:{listen_port}\"");
|
||||
info!(" fingerprint = \"{tls_fp}\"");
|
||||
}
|
||||
|
||||
// Log configured peers and trusted relays
|
||||
if !config.peers.is_empty() {
|
||||
info!(count = config.peers.len(), "federation peers configured");
|
||||
for p in &config.peers {
|
||||
info!(url = %p.url, label = ?p.label, " peer");
|
||||
}
|
||||
}
|
||||
if !config.trusted.is_empty() {
|
||||
info!(count = config.trusted.len(), "trusted relays configured");
|
||||
for t in &config.trusted {
|
||||
info!(fingerprint = %t.fingerprint, label = ?t.label, " trusted");
|
||||
}
|
||||
}
|
||||
let (server_config, _cert) = wzp_transport::server_config();
|
||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||
|
||||
// Forward mode
|
||||
@@ -393,41 +230,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Room manager (room mode only)
|
||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||
|
||||
// Event log for protocol analysis
|
||||
let event_log = wzp_relay::event_log::start_event_log(
|
||||
config.event_log.as_ref().map(std::path::PathBuf::from)
|
||||
);
|
||||
|
||||
// Federation manager
|
||||
let global_room_set: std::collections::HashSet<String> = config.global_rooms.iter()
|
||||
.map(|g| g.name.clone())
|
||||
.collect();
|
||||
|
||||
let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
|
||||
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
|
||||
config.peers.clone(),
|
||||
config.trusted.clone(),
|
||||
global_room_set.clone(),
|
||||
room_mgr.clone(),
|
||||
endpoint.clone(),
|
||||
tls_fp.clone(),
|
||||
metrics.clone(),
|
||||
event_log.clone(),
|
||||
));
|
||||
let fm_run = fm.clone();
|
||||
tokio::spawn(async move { fm_run.run().await });
|
||||
Some(fm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Session manager — enforces max concurrent sessions
|
||||
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
||||
|
||||
// Signal hub + call registry for direct 1:1 calls
|
||||
let signal_hub = Arc::new(Mutex::new(wzp_relay::signal_hub::SignalHub::new()));
|
||||
let call_registry = Arc::new(Mutex::new(wzp_relay::call_registry::CallRegistry::new()));
|
||||
|
||||
// Spawn inter-relay health probes via ProbeMesh coordinator
|
||||
if !config.probe_targets.is_empty() {
|
||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||
@@ -462,15 +267,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
} else {
|
||||
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
||||
}
|
||||
if !config.global_rooms.is_empty() {
|
||||
info!(count = config.global_rooms.len(), "global rooms configured");
|
||||
for g in &config.global_rooms {
|
||||
info!(name = %g.name, " global room");
|
||||
}
|
||||
}
|
||||
if let Some(ref tap) = config.debug_tap {
|
||||
info!(filter = %tap, "debug tap enabled — logging packet headers");
|
||||
}
|
||||
|
||||
info!("Listening for connections...");
|
||||
|
||||
@@ -487,13 +283,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
let relay_seed_bytes = relay_seed.0;
|
||||
let metrics = metrics.clone();
|
||||
let trunking_enabled = config.trunking_enabled;
|
||||
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap { room_filter: filter.clone() });
|
||||
let presence = presence.clone();
|
||||
let route_resolver = route_resolver.clone();
|
||||
let federation_mgr = federation_mgr.clone();
|
||||
let signal_hub = signal_hub.clone();
|
||||
let call_registry = call_registry.clone();
|
||||
let listen_addr_str = config.listen_addr.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = connection.remote_address();
|
||||
@@ -508,23 +299,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
|
||||
// Ping connections: client just measures QUIC connect RTT.
|
||||
if room_name == "ping" {
|
||||
info!(%addr, "ping connection (RTT probe)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Version query: respond with build hash over a uni stream.
|
||||
if room_name == "version" {
|
||||
if let Ok(mut send) = transport.connection().open_uni().await {
|
||||
let _ = send.write_all(BUILD_GIT_HASH.as_bytes()).await;
|
||||
let _ = send.finish();
|
||||
// Wait for client to read before closing
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe connections use SNI "_probe" to identify themselves.
|
||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||
if room_name == "_probe" {
|
||||
@@ -611,294 +385,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Federation connections use SNI "_federation"
|
||||
if room_name == "_federation" {
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
// Wait for FederationHello to identify the connecting relay
|
||||
let hello_fp = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
transport.recv_signal(),
|
||||
).await {
|
||||
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint,
|
||||
_ => {
|
||||
warn!(%addr, "federation: no hello received, closing");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(label) = fm.check_inbound_trust(addr, &hello_fp) {
|
||||
let peer_config = wzp_relay::config::PeerConfig {
|
||||
url: addr.to_string(),
|
||||
fingerprint: hello_fp,
|
||||
label: Some(label.clone()),
|
||||
};
|
||||
let fm = fm.clone();
|
||||
info!(%addr, label = %label, "inbound federation accepted (trusted)");
|
||||
fm.handle_inbound(transport, peer_config).await;
|
||||
} else {
|
||||
warn!(%addr, fp = %hello_fp, "unknown relay wants to federate");
|
||||
info!(" to accept, add to relay.toml:");
|
||||
info!(" [[trusted]]");
|
||||
info!(" fingerprint = \"{hello_fp}\"");
|
||||
info!(" label = \"Relay at {addr}\"");
|
||||
}
|
||||
} else {
|
||||
info!(%addr, "federation connection rejected (no federation configured)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct calling: persistent signaling connection
|
||||
if room_name == "_signal" {
|
||||
info!(%addr, "signal connection");
|
||||
|
||||
// Optional auth
|
||||
let auth_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::AuthToken { token })) => {
|
||||
match wzp_relay::auth::validate_token(url, &token).await {
|
||||
Ok(client) => Some(client.fingerprint),
|
||||
Err(e) => {
|
||||
error!(%addr, "signal auth failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => { warn!(%addr, "signal: expected AuthToken"); return; }
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wait for RegisterPresence
|
||||
let (client_fp, client_alias) = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
transport.recv_signal(),
|
||||
).await {
|
||||
Ok(Ok(Some(SignalMessage::RegisterPresence { identity_pub, signature: _, alias }))) => {
|
||||
// Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type
|
||||
let fp = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(&identity_pub);
|
||||
let fingerprint = wzp_crypto::Fingerprint([
|
||||
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
||||
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
||||
]);
|
||||
fingerprint.to_string()
|
||||
};
|
||||
let fp = auth_fp.unwrap_or(fp);
|
||||
(fp, alias)
|
||||
}
|
||||
_ => {
|
||||
warn!(%addr, "signal: no RegisterPresence received");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Register in signal hub + presence
|
||||
{
|
||||
let mut hub = signal_hub.lock().await;
|
||||
hub.register(client_fp.clone(), transport.clone(), client_alias.clone());
|
||||
}
|
||||
{
|
||||
let mut reg = presence.lock().await;
|
||||
reg.register_local(&client_fp, client_alias.clone(), None);
|
||||
}
|
||||
|
||||
// Send ack
|
||||
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
|
||||
success: true,
|
||||
error: None,
|
||||
}).await;
|
||||
|
||||
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
|
||||
|
||||
// Signal recv loop
|
||||
loop {
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(msg)) => {
|
||||
match msg {
|
||||
SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, ref caller_alias, .. } => {
|
||||
let target_fp = target_fingerprint.clone();
|
||||
let call_id = call_id.clone();
|
||||
|
||||
// Check if target is online
|
||||
let online = {
|
||||
let hub = signal_hub.lock().await;
|
||||
hub.is_online(&target_fp)
|
||||
};
|
||||
if !online {
|
||||
info!(%addr, target = %target_fp, "call target not online");
|
||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create call in registry
|
||||
{
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
|
||||
}
|
||||
|
||||
// Forward offer to callee
|
||||
info!(caller = %client_fp, callee = %target_fp, call_id = %call_id, "routing direct call offer");
|
||||
let hub = signal_hub.lock().await;
|
||||
if let Err(e) = hub.send_to(&target_fp, &msg).await {
|
||||
warn!("failed to forward call offer: {e}");
|
||||
}
|
||||
|
||||
// Send ringing to caller
|
||||
drop(hub);
|
||||
let _ = transport.send_signal(&SignalMessage::CallRinging {
|
||||
call_id: call_id.clone(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
SignalMessage::DirectCallAnswer { ref call_id, ref accept_mode, .. } => {
|
||||
let call_id = call_id.clone();
|
||||
let mode = *accept_mode;
|
||||
|
||||
let peer_fp = {
|
||||
let reg = call_registry.lock().await;
|
||||
reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let Some(peer_fp) = peer_fp else {
|
||||
warn!(call_id = %call_id, "answer for unknown call");
|
||||
continue;
|
||||
};
|
||||
|
||||
if mode == wzp_proto::CallAcceptMode::Reject {
|
||||
info!(call_id = %call_id, "call rejected");
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.end_call(&call_id);
|
||||
drop(reg);
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
} else {
|
||||
// Accept — create private room
|
||||
let room = format!("call-{call_id}");
|
||||
{
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.set_active(&call_id, mode, room.clone());
|
||||
}
|
||||
info!(call_id = %call_id, room = %room, mode = ?mode, "call accepted, creating room");
|
||||
|
||||
// Forward answer to caller
|
||||
{
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &msg).await;
|
||||
}
|
||||
|
||||
// Send CallSetup to both parties
|
||||
// Use the address the client connected to (their remote addr
|
||||
// is our perspective, but we need our listen addr).
|
||||
// Replace 0.0.0.0 with the client's destination IP.
|
||||
let relay_addr_for_setup = if listen_addr_str.starts_with("0.0.0.0:") {
|
||||
let port = &listen_addr_str[8..];
|
||||
// Use the local IP from the client's connection
|
||||
let local_ip = addr.ip();
|
||||
if local_ip.is_loopback() {
|
||||
format!("127.0.0.1:{port}")
|
||||
} else {
|
||||
format!("{local_ip}:{port}")
|
||||
}
|
||||
} else {
|
||||
listen_addr_str.clone()
|
||||
};
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
};
|
||||
{
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &setup).await;
|
||||
let _ = hub.send_to(&client_fp, &setup).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Hangup { .. } => {
|
||||
// Forward hangup to all active calls for this user
|
||||
let calls = {
|
||||
let reg = call_registry.lock().await;
|
||||
reg.calls_for_fingerprint(&client_fp)
|
||||
.iter()
|
||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||
c.callee_fingerprint.clone()
|
||||
} else {
|
||||
c.caller_fingerprint.clone()
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for (call_id, peer_fp) in &calls {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(peer_fp, &msg).await;
|
||||
drop(hub);
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.end_call(call_id);
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Ping { timestamp_ms } => {
|
||||
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
|
||||
}
|
||||
|
||||
other => {
|
||||
warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(%addr, "signal connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%addr, "signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: unregister + end active calls
|
||||
let active_calls = {
|
||||
let reg = call_registry.lock().await;
|
||||
reg.calls_for_fingerprint(&client_fp)
|
||||
.iter()
|
||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||
c.callee_fingerprint.clone()
|
||||
} else {
|
||||
c.caller_fingerprint.clone()
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for (call_id, peer_fp) in &active_calls {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
drop(hub);
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.end_call(call_id);
|
||||
}
|
||||
|
||||
{
|
||||
let mut hub = signal_hub.lock().await;
|
||||
hub.unregister(&client_fp);
|
||||
}
|
||||
{
|
||||
let mut reg = presence.lock().await;
|
||||
reg.unregister_local(&client_fp);
|
||||
}
|
||||
|
||||
transport.close().await.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth check: if --auth-url is set, expect first signal message to be a token
|
||||
// Auth: if --auth-url is set, expect AuthToken as first signal
|
||||
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||
@@ -945,7 +431,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Crypto handshake: verify client identity + negotiate quality profile
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake(
|
||||
let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake(
|
||||
&*transport,
|
||||
&relay_seed_bytes,
|
||||
).await {
|
||||
@@ -962,35 +448,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Use the caller's identity fingerprint from the handshake
|
||||
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
||||
|
||||
// ACL: call rooms (call-*) are restricted to the two authorized participants.
|
||||
// Only the relay's call orchestrator creates these rooms — random clients can't join.
|
||||
if room_name.starts_with("call-") {
|
||||
let call_id = &room_name[5..]; // strip "call-" prefix
|
||||
let authorized = {
|
||||
let reg = call_registry.lock().await;
|
||||
match reg.get(call_id) {
|
||||
Some(call) => {
|
||||
call.caller_fingerprint == participant_fp
|
||||
|| call.callee_fingerprint == participant_fp
|
||||
}
|
||||
None => false, // unknown call — reject
|
||||
}
|
||||
};
|
||||
if !authorized {
|
||||
warn!(%addr, room = %room_name, fp = %participant_fp, "rejected: not authorized for this call room");
|
||||
transport.close().await.ok();
|
||||
return;
|
||||
}
|
||||
info!(%addr, room = %room_name, fp = %participant_fp, "authorized for call room");
|
||||
}
|
||||
|
||||
// Register in presence registry
|
||||
{
|
||||
if let Some(ref fp) = authenticated_fp {
|
||||
let mut reg = presence.lock().await;
|
||||
reg.register_local(&participant_fp, None, Some(room_name.clone()));
|
||||
reg.register_local(fp, None, Some(room_name.clone()));
|
||||
}
|
||||
|
||||
info!(%addr, room = %room_name, "client joining");
|
||||
@@ -1039,55 +500,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
metrics.active_sessions.inc();
|
||||
|
||||
// Call rooms: enforce 2-participant limit
|
||||
if room_name.starts_with("call-") {
|
||||
let mgr = room_mgr.lock().await;
|
||||
if mgr.room_size(&room_name) >= 2 {
|
||||
drop(mgr);
|
||||
warn!(%addr, room = %room_name, "call room full (max 2 participants)");
|
||||
metrics.active_sessions.dec();
|
||||
let mut smgr = session_mgr.lock().await;
|
||||
smgr.remove_session(session_id);
|
||||
transport.close().await.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let participant_id = {
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
match mgr.join(
|
||||
&room_name,
|
||||
addr,
|
||||
room::ParticipantSender::Quic(transport.clone()),
|
||||
Some(&participant_fp),
|
||||
caller_alias.as_deref(),
|
||||
) {
|
||||
Ok((id, update, senders)) => {
|
||||
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
||||
Ok(id) => {
|
||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
drop(mgr); // release lock before async broadcast
|
||||
|
||||
// Merge federated participants into RoomUpdate if this is a global room
|
||||
let merged_update = if let Some(ref fm) = federation_mgr {
|
||||
if fm.is_global_room(&room_name) {
|
||||
if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts } = update {
|
||||
let remote = fm.get_remote_participants(&room_name).await;
|
||||
local_parts.extend(remote);
|
||||
// Deduplicate by fingerprint
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
local_parts.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||
SignalMessage::RoomUpdate {
|
||||
count: local_parts.len() as u32,
|
||||
participants: local_parts,
|
||||
}
|
||||
} else { update }
|
||||
} else { update }
|
||||
} else { update };
|
||||
|
||||
room::broadcast_signal(&senders, &merged_update).await;
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, room = %room_name, "room join denied: {e}");
|
||||
// Clean up the session we just created
|
||||
metrics.active_sessions.dec();
|
||||
let mut smgr = session_mgr.lock().await;
|
||||
smgr.remove_session(session_id);
|
||||
@@ -1101,25 +523,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
// Set up federation media channel if this is a global room
|
||||
let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr {
|
||||
let is_global = fm.is_global_room(&room_name);
|
||||
if is_global {
|
||||
let canonical_hash = fm.global_room_hash(&room_name);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||
let fm_clone = fm.clone();
|
||||
tokio::spawn(async move {
|
||||
wzp_relay::federation::run_federation_media_egress(fm_clone, rx).await;
|
||||
});
|
||||
info!(room = %room_name, canonical = ?fm.resolve_global_room(&room_name), "federation egress created (global room)");
|
||||
(Some(tx), Some(canonical_hash))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
room::run_participant(
|
||||
room_mgr.clone(),
|
||||
room_name,
|
||||
@@ -1128,9 +531,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
metrics.clone(),
|
||||
&session_id_str,
|
||||
trunking_enabled,
|
||||
debug_tap,
|
||||
federation_tx,
|
||||
federation_room_hash,
|
||||
).await;
|
||||
|
||||
// Participant disconnected — clean up presence + per-session metrics
|
||||
|
||||
@@ -16,13 +16,6 @@ pub struct RelayMetrics {
|
||||
pub bytes_forwarded: IntCounter,
|
||||
pub auth_attempts: IntCounterVec,
|
||||
pub handshake_duration: Histogram,
|
||||
// Federation metrics
|
||||
pub federation_peer_status: IntGaugeVec,
|
||||
pub federation_peer_rtt_ms: GaugeVec,
|
||||
pub federation_packets_forwarded: IntCounterVec,
|
||||
pub federation_packets_deduped: IntCounter,
|
||||
pub federation_packets_rate_limited: IntCounter,
|
||||
pub federation_active_rooms: IntGauge,
|
||||
// Per-session metrics
|
||||
pub session_buffer_depth: IntGaugeVec,
|
||||
pub session_loss_pct: GaugeVec,
|
||||
@@ -67,28 +60,6 @@ impl RelayMetrics {
|
||||
)
|
||||
.expect("metric");
|
||||
|
||||
let federation_peer_status = IntGaugeVec::new(
|
||||
Opts::new("wzp_federation_peer_status", "Peer connection status (0=disconnected, 1=connected)"),
|
||||
&["peer"],
|
||||
).expect("metric");
|
||||
let federation_peer_rtt_ms = GaugeVec::new(
|
||||
Opts::new("wzp_federation_peer_rtt_ms", "QUIC RTT to federated peer in milliseconds"),
|
||||
&["peer"],
|
||||
).expect("metric");
|
||||
let federation_packets_forwarded = IntCounterVec::new(
|
||||
Opts::new("wzp_federation_packets_forwarded_total", "Packets forwarded to/from federated peers"),
|
||||
&["peer", "direction"],
|
||||
).expect("metric");
|
||||
let federation_packets_deduped = IntCounter::with_opts(
|
||||
Opts::new("wzp_federation_packets_deduped_total", "Duplicate federation packets dropped"),
|
||||
).expect("metric");
|
||||
let federation_packets_rate_limited = IntCounter::with_opts(
|
||||
Opts::new("wzp_federation_packets_rate_limited_total", "Federation packets dropped by rate limiter"),
|
||||
).expect("metric");
|
||||
let federation_active_rooms = IntGauge::with_opts(
|
||||
Opts::new("wzp_federation_active_rooms", "Number of federated rooms currently active"),
|
||||
).expect("metric");
|
||||
|
||||
let session_buffer_depth = IntGaugeVec::new(
|
||||
Opts::new(
|
||||
"wzp_relay_session_jitter_buffer_depth",
|
||||
@@ -136,12 +107,6 @@ impl RelayMetrics {
|
||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
||||
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
||||
registry.register(Box::new(federation_peer_status.clone())).expect("register");
|
||||
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register");
|
||||
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register");
|
||||
registry.register(Box::new(federation_packets_deduped.clone())).expect("register");
|
||||
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register");
|
||||
registry.register(Box::new(federation_active_rooms.clone())).expect("register");
|
||||
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
||||
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||
@@ -155,12 +120,6 @@ impl RelayMetrics {
|
||||
bytes_forwarded,
|
||||
auth_attempts,
|
||||
handshake_duration,
|
||||
federation_peer_status,
|
||||
federation_peer_rtt_ms,
|
||||
federation_packets_forwarded,
|
||||
federation_packets_deduped,
|
||||
federation_packets_rate_limited,
|
||||
federation_active_rooms,
|
||||
session_buffer_depth,
|
||||
session_loss_pct,
|
||||
session_rtt_ms,
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::MediaTransport;
|
||||
@@ -18,38 +18,6 @@ use wzp_proto::MediaTransport;
|
||||
use crate::metrics::RelayMetrics;
|
||||
use crate::trunk::TrunkBatcher;
|
||||
|
||||
/// Debug tap: logs packet metadata for matching rooms.
|
||||
#[derive(Clone)]
|
||||
pub struct DebugTap {
|
||||
/// Room name filter ("*" = all rooms, or specific room name/hash).
|
||||
pub room_filter: String,
|
||||
}
|
||||
|
||||
impl DebugTap {
|
||||
pub fn matches(&self, room_name: &str) -> bool {
|
||||
self.room_filter == "*" || self.room_filter == room_name
|
||||
}
|
||||
|
||||
pub fn log_packet(&self, room: &str, dir: &str, addr: &std::net::SocketAddr, pkt: &wzp_proto::MediaPacket, fan_out: usize) {
|
||||
let h = &pkt.header;
|
||||
info!(
|
||||
target: "debug_tap",
|
||||
room = %room,
|
||||
dir = dir,
|
||||
addr = %addr,
|
||||
seq = h.seq,
|
||||
codec = ?h.codec_id,
|
||||
ts = h.timestamp,
|
||||
fec_block = h.fec_block,
|
||||
fec_sym = h.fec_symbol,
|
||||
repair = h.is_repair,
|
||||
len = pkt.payload.len(),
|
||||
fan_out,
|
||||
"TAP"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique participant ID within a room.
|
||||
pub type ParticipantId = u64;
|
||||
|
||||
@@ -59,22 +27,6 @@ fn next_id() -> ParticipantId {
|
||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Events emitted by RoomManager for federation to observe.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RoomEvent {
|
||||
/// First local participant joined this room.
|
||||
LocalJoin { room: String },
|
||||
/// Last local participant left this room.
|
||||
LocalLeave { room: String },
|
||||
}
|
||||
|
||||
/// Outbound federation media from a local participant.
|
||||
pub struct FederationMediaOut {
|
||||
pub room_name: String,
|
||||
pub room_hash: [u8; 8],
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||
#[derive(Clone)]
|
||||
pub enum ParticipantSender {
|
||||
@@ -115,24 +67,11 @@ impl ParticipantSender {
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a signal message to a list of participant senders.
|
||||
pub async fn broadcast_signal(senders: &[ParticipantSender], msg: &wzp_proto::SignalMessage) {
|
||||
for sender in senders {
|
||||
if let ParticipantSender::Quic(t) = sender {
|
||||
if let Err(e) = t.send_signal(msg).await {
|
||||
warn!("broadcast_signal error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A participant in a room.
|
||||
struct Participant {
|
||||
id: ParticipantId,
|
||||
_addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
}
|
||||
|
||||
/// A room holding multiple participants.
|
||||
@@ -147,16 +86,10 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
fn add(
|
||||
&mut self,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
) -> ParticipantId {
|
||||
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
||||
let id = next_id();
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias });
|
||||
self.participants.push(Participant { id, _addr: addr, sender });
|
||||
id
|
||||
}
|
||||
|
||||
@@ -173,23 +106,6 @@ impl Room {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a RoomUpdate participant list.
|
||||
fn participant_list(&self) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
self.participants
|
||||
.iter()
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
relay_label: None, // local participant
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all senders (for broadcasting to everyone including the joiner).
|
||||
fn all_senders(&self) -> Vec<ParticipantSender> {
|
||||
self.participants.iter().map(|p| p.sender.clone()).collect()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.participants.is_empty()
|
||||
}
|
||||
@@ -206,35 +122,24 @@ pub struct RoomManager {
|
||||
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
||||
/// fingerprints can join the corresponding room.
|
||||
acl: Option<HashMap<String, HashSet<String>>>,
|
||||
/// Channel for room lifecycle events (federation subscribes).
|
||||
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
pub fn new() -> Self {
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
acl: None,
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a room manager with ACL enforcement enabled.
|
||||
pub fn with_acl() -> Self {
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
acl: Some(HashMap::new()),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to room lifecycle events (for federation).
|
||||
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<RoomEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Grant a fingerprint access to a room.
|
||||
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
||||
if let Some(ref mut acl) = self.acl {
|
||||
@@ -260,32 +165,20 @@ impl RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a room. Returns (participant_id, room_update_msg, all_senders) for broadcasting.
|
||||
/// Join a room. Returns the participant ID or an error if unauthorized.
|
||||
pub fn join(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<&str>,
|
||||
alias: Option<&str>,
|
||||
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> {
|
||||
) -> Result<ParticipantId, String> {
|
||||
if !self.is_authorized(room_name, fingerprint) {
|
||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||
return Err("not authorized for this room".to_string());
|
||||
}
|
||||
let was_empty = !self.rooms.contains_key(room_name)
|
||||
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty());
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||
if was_empty {
|
||||
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
|
||||
}
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Ok((id, update, senders))
|
||||
Ok(room.add(addr, sender))
|
||||
}
|
||||
|
||||
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
||||
@@ -296,49 +189,17 @@ impl RoomManager {
|
||||
sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String> {
|
||||
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?;
|
||||
Ok(id)
|
||||
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
||||
}
|
||||
|
||||
/// Get list of active room names.
|
||||
pub fn active_rooms(&self) -> Vec<String> {
|
||||
self.rooms.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get participant list for a room (fingerprint + alias).
|
||||
pub fn local_participant_list(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participant_list())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all senders for participants in a room (for federation inbound media delivery).
|
||||
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participants.iter()
|
||||
.map(|p| p.sender.clone())
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
/// Leave a room. Removes the room if empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
room.remove(participant_id);
|
||||
if room.is_empty() {
|
||||
self.rooms.remove(room_name);
|
||||
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
||||
info!(room = room_name, "room closed (empty)");
|
||||
return None;
|
||||
}
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Some((update, senders))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,9 +298,6 @@ pub async fn run_participant(
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
trunking_enabled: bool,
|
||||
debug_tap: Option<DebugTap>,
|
||||
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||
federation_room_hash: Option<[u8; 8]>,
|
||||
) {
|
||||
if trunking_enabled {
|
||||
run_participant_trunked(
|
||||
@@ -448,7 +306,7 @@ pub async fn run_participant(
|
||||
.await;
|
||||
} else {
|
||||
run_participant_plain(
|
||||
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash,
|
||||
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -462,145 +320,58 @@ async fn run_participant_plain(
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
debug_tap: Option<DebugTap>,
|
||||
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||
federation_room_hash: Option<[u8; 8]>,
|
||||
) {
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (plain)"
|
||||
);
|
||||
|
||||
loop {
|
||||
let recv_start = std::time::Instant::now();
|
||||
let pkt = match transport.recv_media().await {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
||||
info!(%addr, participant = participant_id, "connection closed: {e}");
|
||||
} else {
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap"
|
||||
);
|
||||
}
|
||||
|
||||
// Update per-session quality metrics if a quality report is present
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
// Get current list of other participants
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock"
|
||||
);
|
||||
}
|
||||
|
||||
// Debug tap: log packet metadata
|
||||
if let Some(ref tap) = debug_tap {
|
||||
if tap.matches(&room_name) {
|
||||
tap.log_packet(&room_name, "in", &addr, &pkt, others.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to all others
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
if let Err(e) = t.send_media(&pkt).await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %t.connection().remote_address(),
|
||||
total_send_errors = send_errors,
|
||||
"send_media error: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = t.send_media(&pkt).await;
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients receive raw payload bytes
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Federation: forward to active peer relays via channel
|
||||
if let Some(ref fed_tx) = federation_tx {
|
||||
let data = pkt.to_bytes();
|
||||
let _ = fed_tx.try_send(FederationMediaOut {
|
||||
room_name: room_name.clone(),
|
||||
room_hash: federation_room_hash.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward"
|
||||
);
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
|
||||
// Periodic stats log every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
if packets_forwarded % 500 == 0 {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
@@ -610,24 +381,14 @@ async fn run_participant_plain(
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up — leave room and broadcast update to remaining participants
|
||||
// Clean up
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||
drop(mgr); // release lock before async broadcast
|
||||
broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
mgr.leave(&room_name, participant_id);
|
||||
}
|
||||
|
||||
/// Trunked forwarding loop — batches outgoing packets per peer.
|
||||
@@ -643,19 +404,6 @@ async fn run_participant_trunked(
|
||||
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (trunked)"
|
||||
);
|
||||
|
||||
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
|
||||
// transport (stable for the Arc's lifetime). We use the remote address
|
||||
@@ -677,50 +425,24 @@ async fn run_participant_trunked(
|
||||
let pkt = match result {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
@@ -730,44 +452,21 @@ async fn run_participant_trunked(
|
||||
.entry(peer_addr)
|
||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||
if let Err(e) = fwd.send(&pkt).await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %peer_addr,
|
||||
total_send_errors = send_errors,
|
||||
"trunked send error: {e}"
|
||||
);
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients bypass trunking — send raw payload directly
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
if packets_forwarded % 500 == 0 {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
@@ -777,30 +476,15 @@ async fn run_participant_trunked(
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats (trunked)"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
_ = flush_interval.tick() => {
|
||||
for fwd in forwarders.values_mut() {
|
||||
if let Err(e) = fwd.flush().await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
total_send_errors = send_errors,
|
||||
"trunk flush error: {e}"
|
||||
);
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,10 +497,7 @@ async fn run_participant_trunked(
|
||||
}
|
||||
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||
drop(mgr);
|
||||
broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
mgr.leave(&room_name, participant_id);
|
||||
}
|
||||
|
||||
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Persistent signaling connection manager.
|
||||
//!
|
||||
//! Tracks clients connected via `_signal` SNI. Routes call signals
|
||||
//! (DirectCallOffer, DirectCallAnswer, Hangup) between registered users.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use tracing::{info, warn};
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::QuinnTransport;
|
||||
|
||||
/// A client connected via `_signal` for direct calling.
|
||||
pub struct SignalClient {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
pub transport: Arc<QuinnTransport>,
|
||||
pub connected_at: Instant,
|
||||
}
|
||||
|
||||
/// Manages persistent signaling connections.
|
||||
pub struct SignalHub {
|
||||
clients: HashMap<String, SignalClient>,
|
||||
}
|
||||
|
||||
impl SignalHub {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new signaling client.
|
||||
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
|
||||
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
|
||||
self.clients.insert(fp.clone(), SignalClient {
|
||||
fingerprint: fp,
|
||||
alias,
|
||||
transport,
|
||||
connected_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Unregister a signaling client. Returns the client if found.
|
||||
pub fn unregister(&mut self, fp: &str) -> Option<SignalClient> {
|
||||
let client = self.clients.remove(fp);
|
||||
if client.is_some() {
|
||||
info!(fingerprint = %fp, "signal client unregistered");
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
/// Look up a client by fingerprint.
|
||||
pub fn get(&self, fp: &str) -> Option<&SignalClient> {
|
||||
self.clients.get(fp)
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is online.
|
||||
pub fn is_online(&self, fp: &str) -> bool {
|
||||
self.clients.contains_key(fp)
|
||||
}
|
||||
|
||||
/// Send a signal message to a client by fingerprint.
|
||||
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
|
||||
match self.clients.get(fp) {
|
||||
Some(client) => {
|
||||
client.transport.send_signal(msg).await
|
||||
.map_err(|e| format!("send to {fp}: {e}"))
|
||||
}
|
||||
None => Err(format!("{fp} not online")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of connected signaling clients.
|
||||
pub fn online_count(&self) -> usize {
|
||||
self.clients.len()
|
||||
}
|
||||
|
||||
/// List all online fingerprints.
|
||||
pub fn online_fingerprints(&self) -> Vec<&str> {
|
||||
self.clients.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Get alias for a fingerprint.
|
||||
pub fn alias(&self, fp: &str) -> Option<&str> {
|
||||
self.clients.get(fp).and_then(|c| c.alias.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn register_unregister() {
|
||||
let mut hub = SignalHub::new();
|
||||
assert_eq!(hub.online_count(), 0);
|
||||
assert!(!hub.is_online("alice"));
|
||||
|
||||
// Can't easily construct QuinnTransport in a unit test,
|
||||
// so we just test the HashMap logic conceptually.
|
||||
// Integration tests cover the full flow.
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ async-trait = { workspace = true }
|
||||
serde_json = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
rcgen = "0.13"
|
||||
ed25519-dalek = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -6,74 +6,20 @@ use std::time::Duration;
|
||||
use quinn::crypto::rustls::QuicClientConfig;
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
|
||||
/// Create a server configuration with a self-signed certificate (random keypair).
|
||||
/// Create a server configuration with a self-signed certificate (for testing).
|
||||
///
|
||||
/// The certificate changes on every call. Use `server_config_from_seed` for
|
||||
/// a deterministic certificate that survives relay restarts.
|
||||
/// Tunes QUIC transport parameters for lossy VoIP:
|
||||
/// - 30s idle timeout
|
||||
/// - 5s keep-alive interval
|
||||
/// - DATAGRAM extension enabled
|
||||
/// - Conservative flow control for bandwidth-constrained links
|
||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||
.expect("failed to generate self-signed cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||
let key_der =
|
||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||
build_server_config(cert_der, key_der)
|
||||
}
|
||||
|
||||
/// Create a server configuration with a deterministic self-signed certificate
|
||||
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
|
||||
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
// Derive Ed25519 key bytes from seed via HKDF
|
||||
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||
let mut ed_bytes = [0u8; 32];
|
||||
hk.expand(b"wzp-tls-ed25519", &mut ed_bytes)
|
||||
.expect("HKDF expand failed");
|
||||
|
||||
// Create Ed25519 signing key and export as PKCS8 DER
|
||||
let signing_key = SigningKey::from_bytes(&ed_bytes);
|
||||
let pkcs8_doc = signing_key.to_pkcs8_der()
|
||||
.expect("failed to encode Ed25519 key as PKCS8");
|
||||
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
|
||||
.expect("failed to wrap PKCS8 DER");
|
||||
|
||||
// Create rcgen KeyPair from DER
|
||||
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(
|
||||
&key_der_for_rcgen,
|
||||
&rcgen::PKCS_ED25519,
|
||||
)
|
||||
.expect("failed to create KeyPair from seed-derived Ed25519 key");
|
||||
|
||||
// Build self-signed cert with this deterministic keypair
|
||||
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
|
||||
.expect("failed to create CertificateParams");
|
||||
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
|
||||
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
|
||||
.expect("failed to serialize key DER");
|
||||
|
||||
build_server_config(cert_der, key_der)
|
||||
}
|
||||
|
||||
/// Compute a hex-formatted SHA-256 fingerprint of a DER-encoded certificate.
|
||||
///
|
||||
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
|
||||
pub fn tls_fingerprint(cert_der: &[u8]) -> String {
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(cert_der);
|
||||
hash.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
fn build_server_config(
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
) -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let mut server_crypto = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod path_monitor;
|
||||
pub mod quic;
|
||||
pub mod reliable;
|
||||
|
||||
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
||||
pub use config::{client_config, server_config};
|
||||
pub use connection::{accept, connect, create_endpoint};
|
||||
pub use path_monitor::PathMonitor;
|
||||
pub use quic::QuinnTransport;
|
||||
|
||||
@@ -136,11 +136,6 @@ impl PathMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get raw packet counts for debugging.
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
(self.total_sent, self.total_received)
|
||||
}
|
||||
|
||||
/// Estimate bandwidth in kbps from bytes received over time.
|
||||
fn estimate_bandwidth_kbps(&self) -> u32 {
|
||||
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
||||
@@ -154,27 +149,6 @@ impl PathMonitor {
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Detect whether a network handoff likely occurred.
|
||||
///
|
||||
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
|
||||
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
|
||||
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
|
||||
pub fn detect_handoff(&self) -> bool {
|
||||
// We need at least two RTT observations to have a meaningful jitter value,
|
||||
// and the EWMA must be non-zero to avoid division/multiplication by zero.
|
||||
if self.jitter_ewma <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
|
||||
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
|
||||
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
|
||||
instant_jitter > self.jitter_ewma * 3.0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PathMonitor {
|
||||
|
||||
@@ -33,29 +33,6 @@ impl QuinnTransport {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
||||
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||
self.connection
|
||||
.send_datagram(bytes::Bytes::copy_from_slice(data))
|
||||
.map_err(|e| TransportError::Internal(format!("datagram: {e}")))
|
||||
}
|
||||
|
||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||
/// The relay will detect the close and remove this participant from the room.
|
||||
pub fn close_now(&self) {
|
||||
self.connection.close(quinn::VarInt::from_u32(0), b"hangup");
|
||||
}
|
||||
|
||||
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
|
||||
pub fn feed_rtt(&self, rtt_ms: u32) {
|
||||
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);
|
||||
}
|
||||
|
||||
/// Get raw packet counts from path monitor (sent, received).
|
||||
pub fn monitor_counts(&self) -> (u64, u64) {
|
||||
self.path_monitor.lock().unwrap().counts()
|
||||
}
|
||||
|
||||
/// Get the maximum datagram payload size, if datagrams are supported.
|
||||
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||
datagram::max_datagram_payload(&self.connection)
|
||||
@@ -143,7 +120,7 @@ impl MediaTransport for QuinnTransport {
|
||||
}
|
||||
};
|
||||
|
||||
match datagram::deserialize_media(data.clone()) {
|
||||
match datagram::deserialize_media(data) {
|
||||
Some(packet) => {
|
||||
// Record receive observation
|
||||
{
|
||||
@@ -156,10 +133,8 @@ impl MediaTransport for QuinnTransport {
|
||||
Ok(Some(packet))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
|
||||
// Don't return Ok(None) — that signals connection closed.
|
||||
// Recurse to read the next datagram instead.
|
||||
Box::pin(self.recv_media()).await
|
||||
tracing::warn!("received malformed media datagram");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
crates/wzp-wasm/Cargo.toml
Normal file
25
crates/wzp-wasm/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "wzp-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
raptorq = "2"
|
||||
js-sys = "0.3"
|
||||
|
||||
# Crypto (ChaCha20-Poly1305 + X25519 key exchange)
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
rand = "0.8"
|
||||
getrandom = { version = "0.2", features = ["js"] } # CRITICAL for WASM randomness
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
692
crates/wzp-wasm/src/lib.rs
Normal file
692
crates/wzp-wasm/src/lib.rs
Normal file
@@ -0,0 +1,692 @@
|
||||
//! WarzonePhone WASM bindings.
|
||||
//!
|
||||
//! Exports two subsystems for browser-side usage:
|
||||
//!
|
||||
//! **FEC** — RaptorQ forward error correction (encode/decode).
|
||||
//! Audio frames are padded to a fixed symbol size (default 256 bytes) with a
|
||||
//! 2-byte little-endian length prefix, matching the native wzp-fec wire format.
|
||||
//!
|
||||
//! Wire format per symbol:
|
||||
//! [block_id:1][symbol_idx:1][is_repair:1][symbol_data:symbol_size]
|
||||
//!
|
||||
//! Encoder output: concatenated symbols in the above format when a block completes.
|
||||
//! Decoder input: individual symbols in the above format.
|
||||
//! Decoder output: concatenated original source data (length-prefix stripped).
|
||||
//!
|
||||
//! **Crypto** — X25519 key exchange + ChaCha20-Poly1305 AEAD encryption.
|
||||
//! Mirrors `wzp-crypto` nonce/session/handshake logic so WASM and native
|
||||
//! peers produce interoperable ciphertext.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use raptorq::{
|
||||
EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder,
|
||||
SourceBlockEncoder,
|
||||
};
|
||||
|
||||
/// Header size prepended to each symbol on the wire: block_id + symbol_idx + is_repair.
|
||||
const HEADER_SIZE: usize = 3;
|
||||
|
||||
/// Length prefix size inside each padded symbol (u16 LE), matching wzp-fec.
|
||||
const LEN_PREFIX: usize = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encoder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct WzpFecEncoder {
|
||||
block_id: u8,
|
||||
frames_per_block: usize,
|
||||
symbol_size: usize,
|
||||
source_symbols: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WzpFecEncoder {
|
||||
/// Create a new FEC encoder.
|
||||
///
|
||||
/// * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||
/// * `symbol_size` — padded byte size of each symbol (default 256).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||
Self {
|
||||
block_id: 0,
|
||||
frames_per_block: block_size,
|
||||
symbol_size,
|
||||
source_symbols: Vec::with_capacity(block_size),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a source symbol (audio frame).
|
||||
///
|
||||
/// Returns encoded packets (all source + repair) when the block is complete,
|
||||
/// or `undefined` if the block is still accumulating.
|
||||
///
|
||||
/// Each returned packet carries the 3-byte header:
|
||||
/// `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||
pub fn add_symbol(&mut self, data: &[u8]) -> Option<Vec<u8>> {
|
||||
self.source_symbols.push(data.to_vec());
|
||||
|
||||
if self.source_symbols.len() >= self.frames_per_block {
|
||||
Some(self.encode_block())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-flush the current (possibly partial) block.
|
||||
///
|
||||
/// Returns all source + repair symbols with headers, or empty vec if no
|
||||
/// symbols have been accumulated.
|
||||
pub fn flush(&mut self) -> Vec<u8> {
|
||||
if self.source_symbols.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.encode_block()
|
||||
}
|
||||
|
||||
/// Internal: encode accumulated source symbols into a block, generate repair,
|
||||
/// and return the concatenated wire-format output.
|
||||
fn encode_block(&mut self) -> Vec<u8> {
|
||||
let ss = self.symbol_size;
|
||||
let num_source = self.source_symbols.len();
|
||||
let block_id = self.block_id;
|
||||
|
||||
// Build length-prefixed, padded block data (matches wzp-fec format).
|
||||
let block_data = self.build_block_data();
|
||||
|
||||
let config =
|
||||
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, ss as u16);
|
||||
let encoder = SourceBlockEncoder::new(block_id, &config, &block_data);
|
||||
|
||||
// Generate source packets.
|
||||
let source_packets = encoder.source_packets();
|
||||
|
||||
// Generate repair packets — 50% overhead by default.
|
||||
let num_repair = ((num_source as f32) * 0.5).ceil() as u32;
|
||||
let repair_packets = encoder.repair_packets(0, num_repair);
|
||||
|
||||
// Allocate output buffer.
|
||||
let total_packets = source_packets.len() + repair_packets.len();
|
||||
let packet_wire_size = HEADER_SIZE + ss;
|
||||
let mut output = Vec::with_capacity(total_packets * packet_wire_size);
|
||||
|
||||
// Write source symbols.
|
||||
for (i, pkt) in source_packets.iter().enumerate() {
|
||||
output.push(block_id);
|
||||
output.push(i as u8);
|
||||
output.push(0); // is_repair = false
|
||||
let pkt_data = pkt.data();
|
||||
let copy_len = pkt_data.len().min(ss);
|
||||
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||
// Pad if shorter.
|
||||
if copy_len < ss {
|
||||
output.resize(output.len() + (ss - copy_len), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Write repair symbols.
|
||||
for (i, pkt) in repair_packets.iter().enumerate() {
|
||||
output.push(block_id);
|
||||
output.push((num_source + i) as u8);
|
||||
output.push(1); // is_repair = true
|
||||
let pkt_data = pkt.data();
|
||||
let copy_len = pkt_data.len().min(ss);
|
||||
output.extend_from_slice(&pkt_data[..copy_len]);
|
||||
if copy_len < ss {
|
||||
output.resize(output.len() + (ss - copy_len), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance block.
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.source_symbols.clear();
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Build the contiguous, length-prefixed block data buffer.
|
||||
fn build_block_data(&self) -> Vec<u8> {
|
||||
let ss = self.symbol_size;
|
||||
let mut data = vec![0u8; self.source_symbols.len() * ss];
|
||||
for (i, sym) in self.source_symbols.iter().enumerate() {
|
||||
let max_payload = ss - LEN_PREFIX;
|
||||
let payload_len = sym.len().min(max_payload);
|
||||
let offset = i * ss;
|
||||
data[offset..offset + LEN_PREFIX]
|
||||
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||
.copy_from_slice(&sym[..payload_len]);
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-block decoder state.
|
||||
struct BlockState {
|
||||
packets: Vec<EncodingPacket>,
|
||||
decoded: bool,
|
||||
result: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct WzpFecDecoder {
|
||||
frames_per_block: usize,
|
||||
symbol_size: usize,
|
||||
blocks: Vec<(u8, BlockState)>, // poor man's map (no std HashMap in tiny WASM)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WzpFecDecoder {
|
||||
/// Create a new FEC decoder.
|
||||
///
|
||||
/// * `block_size` — expected number of source symbols per block.
|
||||
/// * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(block_size: usize, symbol_size: usize) -> Self {
|
||||
Self {
|
||||
frames_per_block: block_size,
|
||||
symbol_size,
|
||||
blocks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a received symbol.
|
||||
///
|
||||
/// Returns the decoded block (concatenated original frames, unpadded) if
|
||||
/// enough symbols have been received to recover the block, or `undefined`.
|
||||
pub fn add_symbol(
|
||||
&mut self,
|
||||
block_id: u8,
|
||||
symbol_idx: u8,
|
||||
_is_repair: bool,
|
||||
data: &[u8],
|
||||
) -> Option<Vec<u8>> {
|
||||
let ss = self.symbol_size;
|
||||
|
||||
// Pad incoming data to symbol_size.
|
||||
let mut padded = vec![0u8; ss];
|
||||
let len = data.len().min(ss);
|
||||
padded[..len].copy_from_slice(&data[..len]);
|
||||
|
||||
let esi = symbol_idx as u32;
|
||||
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
|
||||
|
||||
// Find or create block state.
|
||||
let block = self.get_or_create_block(block_id);
|
||||
|
||||
if block.decoded {
|
||||
return block.result.clone();
|
||||
}
|
||||
|
||||
block.packets.push(packet);
|
||||
|
||||
// Attempt decode.
|
||||
self.try_decode(block_id)
|
||||
}
|
||||
|
||||
/// Try to decode a block; returns the original frames if successful.
|
||||
fn try_decode(&mut self, block_id: u8) -> Option<Vec<u8>> {
|
||||
let ss = self.symbol_size;
|
||||
let num_source = self.frames_per_block;
|
||||
let block_length = (num_source as u64) * (ss as u64);
|
||||
|
||||
let block = self.get_block_mut(block_id)?;
|
||||
if block.decoded {
|
||||
return block.result.clone();
|
||||
}
|
||||
|
||||
let config =
|
||||
ObjectTransmissionInformation::with_defaults(block_length, ss as u16);
|
||||
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
|
||||
|
||||
let decoded = decoder.decode(block.packets.clone());
|
||||
|
||||
match decoded {
|
||||
Some(data) => {
|
||||
// Extract original frames by stripping length prefixes.
|
||||
let mut output = Vec::new();
|
||||
for i in 0..num_source {
|
||||
let offset = i * ss;
|
||||
if offset + LEN_PREFIX > data.len() {
|
||||
break;
|
||||
}
|
||||
let payload_len = u16::from_le_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
]) as usize;
|
||||
let payload_start = offset + LEN_PREFIX;
|
||||
let payload_end = (payload_start + payload_len).min(data.len());
|
||||
output.extend_from_slice(&data[payload_start..payload_end]);
|
||||
}
|
||||
|
||||
let block = self.get_block_mut(block_id).unwrap();
|
||||
block.decoded = true;
|
||||
block.result = Some(output.clone());
|
||||
Some(output)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
|
||||
if let Some(pos) = self.blocks.iter().position(|(id, _)| *id == block_id) {
|
||||
return &mut self.blocks[pos].1;
|
||||
}
|
||||
self.blocks.push((
|
||||
block_id,
|
||||
BlockState {
|
||||
packets: Vec::new(),
|
||||
decoded: false,
|
||||
result: None,
|
||||
},
|
||||
));
|
||||
let last = self.blocks.len() - 1;
|
||||
&mut self.blocks[last].1
|
||||
}
|
||||
|
||||
fn get_block_mut(&mut self, block_id: u8) -> Option<&mut BlockState> {
|
||||
self.blocks
|
||||
.iter_mut()
|
||||
.find(|(id, _)| *id == block_id)
|
||||
.map(|(_, state)| state)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Crypto — X25519 key exchange
|
||||
// =========================================================================
|
||||
|
||||
/// X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||
///
|
||||
/// Usage from JS:
|
||||
/// ```js
|
||||
/// const kx = new WzpKeyExchange();
|
||||
/// const ourPub = kx.public_key(); // Uint8Array(32)
|
||||
/// // ... send ourPub to peer, receive peerPub ...
|
||||
/// const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||
/// const session = new WzpCryptoSession(secret);
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub struct WzpKeyExchange {
|
||||
secret: x25519_dalek::StaticSecret,
|
||||
public: x25519_dalek::PublicKey,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WzpKeyExchange {
|
||||
/// Generate a new random X25519 keypair.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
let secret = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let public = x25519_dalek::PublicKey::from(&secret);
|
||||
Self { secret, public }
|
||||
}
|
||||
|
||||
/// Our public key (32 bytes).
|
||||
pub fn public_key(&self) -> Vec<u8> {
|
||||
self.public.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Derive a 32-byte session key from the peer's public key.
|
||||
///
|
||||
/// Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||
/// matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||
pub fn derive_shared_secret(&self, peer_public: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
if peer_public.len() != 32 {
|
||||
return Err(JsValue::from_str("peer public key must be 32 bytes"));
|
||||
}
|
||||
let mut peer_bytes = [0u8; 32];
|
||||
peer_bytes.copy_from_slice(peer_public);
|
||||
let peer_pk = x25519_dalek::PublicKey::from(peer_bytes);
|
||||
|
||||
// Rebuild secret from bytes (StaticSecret doesn't impl Clone).
|
||||
let secret_bytes = self.secret.to_bytes();
|
||||
let secret_clone = x25519_dalek::StaticSecret::from(secret_bytes);
|
||||
let shared = secret_clone.diffie_hellman(&peer_pk);
|
||||
|
||||
// HKDF expand — same derivation as wzp-crypto handshake.rs
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
|
||||
let mut session_key = [0u8; 32];
|
||||
hk.expand(b"warzone-session-key", &mut session_key)
|
||||
.expect("HKDF expand should not fail for 32-byte output");
|
||||
|
||||
Ok(session_key.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Crypto — ChaCha20-Poly1305 AEAD session
|
||||
// =========================================================================
|
||||
|
||||
/// Build a 12-byte nonce (mirrors `wzp-crypto::nonce::build_nonce`).
|
||||
///
|
||||
/// Layout: `session_id[4] || seq(u32 BE) || direction(1) || pad(3 zero)`.
|
||||
fn build_nonce(session_id: &[u8; 4], seq: u32, direction: u8) -> [u8; 12] {
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[0..4].copy_from_slice(session_id);
|
||||
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||
nonce[8] = direction;
|
||||
nonce
|
||||
}
|
||||
|
||||
/// Symmetric encryption session using ChaCha20-Poly1305.
|
||||
///
|
||||
/// Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||
/// and key setup are identical so WASM and native peers interoperate.
|
||||
#[wasm_bindgen]
|
||||
pub struct WzpCryptoSession {
|
||||
cipher: chacha20poly1305::ChaCha20Poly1305,
|
||||
session_id: [u8; 4],
|
||||
send_seq: u32,
|
||||
recv_seq: u32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WzpCryptoSession {
|
||||
/// Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(shared_secret: &[u8]) -> Result<WzpCryptoSession, JsValue> {
|
||||
if shared_secret.len() != 32 {
|
||||
return Err(JsValue::from_str("shared secret must be 32 bytes"));
|
||||
}
|
||||
|
||||
use chacha20poly1305::KeyInit;
|
||||
use sha2::Digest;
|
||||
|
||||
let session_id_hash = sha2::Sha256::digest(shared_secret);
|
||||
let mut session_id = [0u8; 4];
|
||||
session_id.copy_from_slice(&session_id_hash[..4]);
|
||||
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(shared_secret)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid key: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
cipher,
|
||||
session_id,
|
||||
send_seq: 0,
|
||||
recv_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||
///
|
||||
/// Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||
pub fn encrypt(&mut self, header_aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
use chacha20poly1305::aead::{Aead, Payload};
|
||||
use chacha20poly1305::Nonce;
|
||||
|
||||
let nonce_bytes = build_nonce(&self.session_id, self.send_seq, 0); // 0 = Send
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let payload = Payload {
|
||||
msg: plaintext,
|
||||
aad: header_aad,
|
||||
};
|
||||
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, payload)
|
||||
.map_err(|_| JsValue::from_str("encryption failed"))?;
|
||||
|
||||
self.send_seq = self.send_seq.wrapping_add(1);
|
||||
Ok(ciphertext)
|
||||
}
|
||||
|
||||
/// Decrypt a media payload with AAD.
|
||||
///
|
||||
/// Returns plaintext on success, or throws on auth failure.
|
||||
pub fn decrypt(&mut self, header_aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
use chacha20poly1305::aead::{Aead, Payload};
|
||||
use chacha20poly1305::Nonce;
|
||||
|
||||
// direction=0 (Send) matches the sender's nonce — same as native code.
|
||||
let nonce_bytes = build_nonce(&self.session_id, self.recv_seq, 0);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let payload = Payload {
|
||||
msg: ciphertext,
|
||||
aad: header_aad,
|
||||
};
|
||||
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|_| JsValue::from_str("decryption failed — bad key or corrupted data"))?;
|
||||
|
||||
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Current send sequence number (for diagnostics / UI stats).
|
||||
pub fn send_seq(&self) -> u32 {
|
||||
self.send_seq
|
||||
}
|
||||
|
||||
/// Current receive sequence number (for diagnostics / UI stats).
|
||||
pub fn recv_seq(&self) -> u32 {
|
||||
self.recv_seq
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (native only — not compiled to WASM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_decode_roundtrip() {
|
||||
let block_size = 5;
|
||||
let symbol_size = 256;
|
||||
|
||||
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||
|
||||
// Create test frames of varying sizes.
|
||||
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 80 + i * 10])
|
||||
.collect();
|
||||
|
||||
// Feed frames to encoder; last one triggers block encoding.
|
||||
let mut wire_data = None;
|
||||
for frame in &frames {
|
||||
wire_data = encoder.add_symbol(frame);
|
||||
}
|
||||
let wire_data = wire_data.expect("block should be complete");
|
||||
|
||||
// Parse wire packets and feed to decoder.
|
||||
let packet_size = HEADER_SIZE + symbol_size;
|
||||
assert_eq!(wire_data.len() % packet_size, 0);
|
||||
|
||||
let mut result = None;
|
||||
for chunk in wire_data.chunks(packet_size) {
|
||||
let blk_id = chunk[0];
|
||||
let sym_idx = chunk[1];
|
||||
let is_repair = chunk[2] != 0;
|
||||
let sym_data = &chunk[HEADER_SIZE..];
|
||||
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||
result = Some(decoded);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let decoded_data = result.expect("should decode with all symbols");
|
||||
|
||||
// Verify: decoded data should be all original frames concatenated.
|
||||
let mut expected = Vec::new();
|
||||
for frame in &frames {
|
||||
expected.extend_from_slice(frame);
|
||||
}
|
||||
assert_eq!(decoded_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_with_packet_loss() {
|
||||
let block_size = 5;
|
||||
let symbol_size = 256;
|
||||
|
||||
let mut encoder = WzpFecEncoder::new(block_size, symbol_size);
|
||||
let mut decoder = WzpFecDecoder::new(block_size, symbol_size);
|
||||
|
||||
let frames: Vec<Vec<u8>> = (0..block_size)
|
||||
.map(|i| vec![(i as u8).wrapping_mul(37).wrapping_add(7); 100])
|
||||
.collect();
|
||||
|
||||
let mut wire_data = None;
|
||||
for frame in &frames {
|
||||
wire_data = encoder.add_symbol(frame);
|
||||
}
|
||||
let wire_data = wire_data.unwrap();
|
||||
|
||||
let packet_size = HEADER_SIZE + symbol_size;
|
||||
let packets: Vec<&[u8]> = wire_data.chunks(packet_size).collect();
|
||||
|
||||
// Drop 2 source packets (simulate 40% source loss).
|
||||
// We have 5 source + 3 repair = 8 packets. Drop packets at index 1 and 3.
|
||||
let mut result = None;
|
||||
for (i, chunk) in packets.iter().enumerate() {
|
||||
if i == 1 || i == 3 {
|
||||
continue; // simulate loss
|
||||
}
|
||||
let blk_id = chunk[0];
|
||||
let sym_idx = chunk[1];
|
||||
let is_repair = chunk[2] != 0;
|
||||
let sym_data = &chunk[HEADER_SIZE..];
|
||||
if let Some(decoded) = decoder.add_symbol(blk_id, sym_idx, is_repair, sym_data) {
|
||||
result = Some(decoded);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let decoded_data = result.expect("should recover with FEC despite 2 lost packets");
|
||||
|
||||
let mut expected = Vec::new();
|
||||
for frame in &frames {
|
||||
expected.extend_from_slice(frame);
|
||||
}
|
||||
assert_eq!(decoded_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_partial_block() {
|
||||
let mut encoder = WzpFecEncoder::new(5, 256);
|
||||
|
||||
// Add only 3 of 5 expected symbols, then flush.
|
||||
encoder.add_symbol(&[1; 50]);
|
||||
encoder.add_symbol(&[2; 60]);
|
||||
encoder.add_symbol(&[3; 70]);
|
||||
|
||||
let wire_data = encoder.flush();
|
||||
assert!(!wire_data.is_empty());
|
||||
|
||||
// Verify block_id advanced.
|
||||
assert_eq!(encoder.block_id, 1);
|
||||
}
|
||||
|
||||
// -- Crypto tests -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn crypto_encrypt_decrypt_roundtrip() {
|
||||
let key = [0x42u8; 32];
|
||||
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||
|
||||
let header = b"test-header";
|
||||
let plaintext = b"hello warzone from wasm";
|
||||
|
||||
let ciphertext = alice.encrypt(header, plaintext).unwrap();
|
||||
let decrypted = bob.decrypt(header, &ciphertext).unwrap();
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
}
|
||||
|
||||
// NOTE: crypto_wrong_aad_fails and crypto_wrong_key_fails return
|
||||
// Err(JsValue) which aborts on non-wasm32 (JsValue::from_str uses an
|
||||
// extern "C" shim that panics with "cannot unwind"). These tests are
|
||||
// gated to wasm32-only; on native the encrypt/decrypt roundtrip and
|
||||
// nonce-layout tests provide sufficient coverage.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[test]
|
||||
fn crypto_wrong_aad_fails() {
|
||||
let key = [0x42u8; 32];
|
||||
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||
|
||||
let ciphertext = alice.encrypt(b"correct", b"secret").unwrap();
|
||||
let result = bob.decrypt(b"wrong", &ciphertext);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[test]
|
||||
fn crypto_wrong_key_fails() {
|
||||
let mut alice = WzpCryptoSession::new(&[0xAA; 32]).unwrap();
|
||||
let mut eve = WzpCryptoSession::new(&[0xBB; 32]).unwrap();
|
||||
|
||||
let ciphertext = alice.encrypt(b"hdr", b"secret").unwrap();
|
||||
let result = eve.decrypt(b"hdr", &ciphertext);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_multiple_packets() {
|
||||
let key = [0x42u8; 32];
|
||||
let mut alice = WzpCryptoSession::new(&key).unwrap();
|
||||
let mut bob = WzpCryptoSession::new(&key).unwrap();
|
||||
|
||||
for i in 0..100u32 {
|
||||
let msg = format!("message {}", i);
|
||||
let ct = alice.encrypt(b"hdr", msg.as_bytes()).unwrap();
|
||||
let pt = bob.decrypt(b"hdr", &ct).unwrap();
|
||||
assert_eq!(pt, msg.as_bytes());
|
||||
}
|
||||
assert_eq!(alice.send_seq(), 100);
|
||||
assert_eq!(bob.recv_seq(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_exchange_roundtrip() {
|
||||
let alice_kx = WzpKeyExchange::new();
|
||||
let bob_kx = WzpKeyExchange::new();
|
||||
|
||||
let alice_secret = alice_kx
|
||||
.derive_shared_secret(&bob_kx.public_key())
|
||||
.unwrap();
|
||||
let bob_secret = bob_kx
|
||||
.derive_shared_secret(&alice_kx.public_key())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(alice_secret, bob_secret);
|
||||
assert_eq!(alice_secret.len(), 32);
|
||||
|
||||
// Verify the derived secret actually works for encrypt/decrypt.
|
||||
let mut alice_session = WzpCryptoSession::new(&alice_secret).unwrap();
|
||||
let mut bob_session = WzpCryptoSession::new(&bob_secret).unwrap();
|
||||
|
||||
let ct = alice_session.encrypt(b"hdr", b"hello").unwrap();
|
||||
let pt = bob_session.decrypt(b"hdr", &ct).unwrap();
|
||||
assert_eq!(&pt, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_layout_matches_native() {
|
||||
// Verify our build_nonce matches wzp-crypto::nonce::build_nonce layout.
|
||||
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let seq: u32 = 0x00000100;
|
||||
let nonce = build_nonce(&sid, seq, 1); // 1 = Recv direction
|
||||
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
|
||||
assert_eq!(nonce[8], 1);
|
||||
assert_eq!(&nonce[9..12], &[0, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||
// Crypto handshake with relay
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let bridge_seed = wzp_crypto::Seed::generate();
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await {
|
||||
Ok(_session) => {
|
||||
let elapsed = handshake_start.elapsed().as_secs_f64();
|
||||
state.metrics.handshake_latency.observe(elapsed);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
||||
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
||||
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
||||
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
||||
.room-input { margin-bottom: 1.5rem; }
|
||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||
@@ -31,15 +35,22 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WarzonePhone</h1>
|
||||
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||
|
||||
<div class="variant-selector">
|
||||
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
||||
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
||||
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
||||
</div>
|
||||
|
||||
<div class="room-input">
|
||||
<label for="room">Room</label>
|
||||
<input type="text" id="room" placeholder="enter room name" value="">
|
||||
</div>
|
||||
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||
<button id="callBtn">Connect</button>
|
||||
<div class="controls" id="controls" style="display:none;">
|
||||
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
||||
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
||||
</div>
|
||||
<button id="pttBtn">Hold to Talk</button>
|
||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||
@@ -47,302 +58,137 @@
|
||||
<div class="stats" id="stats"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/wzp-core.js"></script>
|
||||
<script>
|
||||
const SAMPLE_RATE = 48000;
|
||||
const FRAME_SIZE = 960;
|
||||
|
||||
let ws = null;
|
||||
let audioCtx = null;
|
||||
let mediaStream = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
let active = false;
|
||||
let transmitting = true; // in open-mic mode, always transmitting
|
||||
let pttMode = false;
|
||||
let framesSent = 0;
|
||||
let framesRecv = 0;
|
||||
let startTime = 0;
|
||||
let statsInterval = null;
|
||||
|
||||
// Use room from URL path or input field
|
||||
function getRoom() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') return path;
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) return hash;
|
||||
return document.getElementById('room').value.trim() || 'default';
|
||||
}
|
||||
|
||||
// Pre-fill room input from URL on page load
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load the selected variant script dynamically
|
||||
// ---------------------------------------------------------------------------
|
||||
(function() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') {
|
||||
document.getElementById('room').value = path;
|
||||
}
|
||||
var variant = WZPCore.detectVariant();
|
||||
var scriptMap = {
|
||||
pure: 'js/wzp-pure.js',
|
||||
hybrid: 'js/wzp-hybrid.js',
|
||||
full: 'js/wzp-full.js',
|
||||
};
|
||||
var src = scriptMap[variant] || scriptMap.pure;
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = function() { wzpBoot(); };
|
||||
s.onerror = function() {
|
||||
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
})();
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boot: wire UI to the loaded client variant
|
||||
// ---------------------------------------------------------------------------
|
||||
function wzpBoot() {
|
||||
var client = null;
|
||||
var capture = null;
|
||||
var playback = null;
|
||||
var transmitting = true;
|
||||
|
||||
function toggleCall() {
|
||||
if (active) stopCall();
|
||||
else startCall();
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
const btn = document.getElementById('callBtn');
|
||||
const room = getRoom();
|
||||
if (!room) { setStatus('Enter a room name'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
setStatus('Requesting microphone...');
|
||||
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
var ui = WZPCore.initUI({
|
||||
onConnect: function(room) {
|
||||
doConnect(room);
|
||||
},
|
||||
onDisconnect: function() {
|
||||
doDisconnect();
|
||||
},
|
||||
onTransmit: function(tx) {
|
||||
transmitting = tx;
|
||||
},
|
||||
});
|
||||
|
||||
async function doConnect(room) {
|
||||
WZPCore.updateStatus('Requesting microphone...');
|
||||
|
||||
var audioCtx;
|
||||
try {
|
||||
audioCtx = await WZPCore.startAudioContext();
|
||||
} catch (e) {
|
||||
setStatus('Mic access denied: ' + e.message);
|
||||
btn.disabled = false;
|
||||
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
// Build WebSocket URL
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||
|
||||
// Connect WebSocket with room name
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
||||
setStatus('Connecting to room: ' + room + '...');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = async () => {
|
||||
setStatus('Connected to room: ' + room);
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.classList.add('active');
|
||||
btn.disabled = false;
|
||||
active = true;
|
||||
framesSent = 0;
|
||||
framesRecv = 0;
|
||||
startTime = Date.now();
|
||||
showControls(true);
|
||||
await startAudioCapture();
|
||||
await startAudioPlayback();
|
||||
startStatsUpdate();
|
||||
// Create client based on selected variant
|
||||
var variant = WZPCore.detectVariant();
|
||||
var clientOpts = {
|
||||
wsUrl: wsUrl,
|
||||
room: room,
|
||||
onAudio: function(pcm) {
|
||||
if (playback) playback.play(pcm);
|
||||
},
|
||||
onStatus: function(msg) {
|
||||
WZPCore.updateStatus(msg);
|
||||
},
|
||||
onStats: function(stats) {
|
||||
WZPCore.updateStats(stats);
|
||||
},
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const pcmData = new Int16Array(event.data);
|
||||
framesRecv++;
|
||||
playAudio(pcmData);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (active) {
|
||||
setStatus('Disconnected — reconnecting to ' + room + '...');
|
||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||
if (variant === 'full' && typeof WZPFullClient !== 'undefined') {
|
||||
// Full variant: add WebTransport URL, falls back to WS if WT unavailable
|
||||
clientOpts.url = location.origin.replace('http', 'https');
|
||||
client = new WZPFullClient(clientOpts);
|
||||
} else if (variant === 'hybrid' && typeof WZPHybridClient !== 'undefined') {
|
||||
client = new WZPHybridClient(clientOpts);
|
||||
} else {
|
||||
setStatus('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (active) {
|
||||
setStatus('Error — reconnecting...');
|
||||
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||
}
|
||||
};
|
||||
client = new WZPPureClient(clientOpts);
|
||||
}
|
||||
|
||||
function stopCall() {
|
||||
active = false;
|
||||
const btn = document.getElementById('callBtn');
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('active');
|
||||
btn.disabled = false;
|
||||
showControls(false);
|
||||
cleanupAudio();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
setStatus('');
|
||||
setStats('');
|
||||
}
|
||||
|
||||
function cleanupAudio() {
|
||||
if (captureNode) { captureNode.disconnect(); captureNode = null; }
|
||||
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
|
||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
|
||||
}
|
||||
|
||||
let workletLoaded = false;
|
||||
|
||||
async function loadWorkletModule() {
|
||||
if (workletLoaded) return true;
|
||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
||||
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||
workletLoaded = true;
|
||||
return true;
|
||||
await client.connect();
|
||||
} catch (e) {
|
||||
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
|
||||
return false;
|
||||
}
|
||||
WZPCore.updateStatus('Connection failed: ' + e.message);
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function startAudioCapture() {
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
const hasWorklet = await loadWorkletModule();
|
||||
|
||||
if (hasWorklet) {
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
||||
captureNode.port.onmessage = (e) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||
ws.send(e.data);
|
||||
framesSent++;
|
||||
|
||||
// Level meter from the PCM data
|
||||
const pcm = new Int16Array(e.data);
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
|
||||
} else {
|
||||
// Fallback to ScriptProcessorNode (deprecated but widely supported)
|
||||
console.warn('Capture: using ScriptProcessorNode fallback');
|
||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
let acc = new Float32Array(0);
|
||||
captureNode.onaudioprocess = (ev) => {
|
||||
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
|
||||
const input = ev.inputBuffer.getChannelData(0);
|
||||
const n = new Float32Array(acc.length + input.length);
|
||||
n.set(acc); n.set(input, acc.length); acc = n;
|
||||
while (acc.length >= FRAME_SIZE) {
|
||||
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
|
||||
const pcm = new Int16Array(FRAME_SIZE);
|
||||
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
|
||||
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
|
||||
ws.send(pcm.buffer);
|
||||
framesSent++;
|
||||
}
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioPlayback() {
|
||||
const hasWorklet = await loadWorkletModule();
|
||||
|
||||
if (hasWorklet) {
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
||||
playbackNode.connect(audioCtx.destination);
|
||||
} else {
|
||||
console.warn('Playback: using scheduled BufferSource fallback');
|
||||
playbackNode = null; // will use createBufferSource fallback in playAudio()
|
||||
}
|
||||
}
|
||||
|
||||
let nextPlayTime = 0;
|
||||
|
||||
function playAudio(pcmInt16) {
|
||||
if (!audioCtx) return;
|
||||
|
||||
if (playbackNode && playbackNode.port) {
|
||||
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
|
||||
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
|
||||
} else {
|
||||
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
|
||||
const floatData = new Float32Array(pcmInt16.length);
|
||||
for (let i = 0; i < pcmInt16.length; i++) {
|
||||
floatData[i] = pcmInt16[i] / 32768.0;
|
||||
}
|
||||
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
|
||||
buffer.getChannelData(0).set(floatData);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioCtx.destination);
|
||||
const now = audioCtx.currentTime;
|
||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||
nextPlayTime = now + 0.02;
|
||||
}
|
||||
source.start(nextPlayTime);
|
||||
nextPlayTime += buffer.duration;
|
||||
}
|
||||
}
|
||||
|
||||
function startStatsUpdate() {
|
||||
statsInterval = setInterval(() => {
|
||||
if (!active) { clearInterval(statsInterval); return; }
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// --- Push-to-talk ---
|
||||
|
||||
function togglePTT() {
|
||||
pttMode = document.getElementById('pttMode').checked;
|
||||
const btn = document.getElementById('pttBtn');
|
||||
if (pttMode) {
|
||||
transmitting = false;
|
||||
btn.style.display = 'block';
|
||||
} else {
|
||||
transmitting = true;
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// PTT button — hold to talk (mouse + touch)
|
||||
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
|
||||
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
|
||||
|
||||
// Spacebar PTT
|
||||
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
|
||||
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
|
||||
|
||||
function startTransmit() {
|
||||
if (!pttMode || !active) return;
|
||||
transmitting = true;
|
||||
document.getElementById('pttBtn').classList.add('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Transmitting...';
|
||||
}
|
||||
|
||||
function stopTransmit() {
|
||||
if (!pttMode) return;
|
||||
transmitting = false;
|
||||
document.getElementById('pttBtn').classList.remove('transmitting');
|
||||
document.getElementById('pttBtn').textContent = 'Hold to Talk';
|
||||
}
|
||||
|
||||
// Show controls when connected
|
||||
function showControls(show) {
|
||||
document.getElementById('controls').style.display = show ? 'flex' : 'none';
|
||||
if (!show) {
|
||||
document.getElementById('pttBtn').style.display = 'none';
|
||||
pttMode = false;
|
||||
transmitting = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Set room from URL on load
|
||||
window.addEventListener('load', () => {
|
||||
const room = getRoom();
|
||||
if (room && room !== 'default') {
|
||||
document.getElementById('room').value = room;
|
||||
}
|
||||
// Start audio capture and playback
|
||||
try {
|
||||
capture = await WZPCore.connectCapture(audioCtx, function(pcmBuffer) {
|
||||
if (!transmitting) return;
|
||||
var pcm = new Int16Array(pcmBuffer);
|
||||
WZPCore.updateLevel(pcm);
|
||||
if (client) client.sendAudio(pcmBuffer);
|
||||
});
|
||||
|
||||
playback = await WZPCore.connectPlayback(audioCtx);
|
||||
} catch (e) {
|
||||
WZPCore.updateStatus('Audio error: ' + e.message);
|
||||
if (client) client.disconnect();
|
||||
client = null;
|
||||
ui.setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.setConnected(true);
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (capture) { capture.stop(); capture = null; }
|
||||
if (playback) { playback.stop(); playback = null; }
|
||||
if (client) { client.disconnect(); client = null; }
|
||||
|
||||
var audioCtx = WZPCore.getAudioContext();
|
||||
if (audioCtx && audioCtx.state !== 'closed') {
|
||||
audioCtx.close();
|
||||
}
|
||||
|
||||
WZPCore.updateStatus('');
|
||||
WZPCore.updateStats('');
|
||||
document.getElementById('levelBar').style.width = '0%';
|
||||
|
||||
ui.setConnected(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
378
crates/wzp-web/static/js/wzp-core.js
Normal file
378
crates/wzp-web/static/js/wzp-core.js
Normal file
@@ -0,0 +1,378 @@
|
||||
// WarzonePhone — Shared UI logic for all client variants.
|
||||
// Provides: audio context management, mic capture, playback, UI wiring.
|
||||
|
||||
'use strict';
|
||||
|
||||
const WZP_SAMPLE_RATE = 48000;
|
||||
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wzpDetectVariant() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const v = (params.get('variant') || 'pure').toLowerCase();
|
||||
if (v === 'hybrid' || v === 'full') return v;
|
||||
return 'pure';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wzpGetRoom() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') return path;
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) return hash;
|
||||
const el = document.getElementById('room');
|
||||
return (el && el.value.trim()) || 'default';
|
||||
}
|
||||
|
||||
function wzpPrefillRoom() {
|
||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (path && path !== 'index.html') {
|
||||
const el = document.getElementById('room');
|
||||
if (el) el.value = path;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status / stats helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wzpUpdateStatus(msg) {
|
||||
const el = document.getElementById('status');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function wzpUpdateStats(stats) {
|
||||
const el = document.getElementById('stats');
|
||||
if (!el) return;
|
||||
if (typeof stats === 'string') {
|
||||
el.textContent = stats;
|
||||
} else {
|
||||
const parts = [];
|
||||
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
|
||||
if (stats.sent != null) parts.push('sent: ' + stats.sent);
|
||||
if (stats.recv != null) parts.push('recv: ' + stats.recv);
|
||||
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
|
||||
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
|
||||
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
|
||||
el.textContent = parts.join(' | ');
|
||||
}
|
||||
}
|
||||
|
||||
function wzpUpdateLevel(pcmInt16) {
|
||||
const bar = document.getElementById('levelBar');
|
||||
if (!bar) return;
|
||||
let max = 0;
|
||||
for (let i = 0; i < pcmInt16.length; i += 16) {
|
||||
const v = Math.abs(pcmInt16[i]);
|
||||
if (v > max) max = v;
|
||||
}
|
||||
bar.style.width = (max / 32768 * 100) + '%';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio context + worklet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _wzpAudioCtx = null;
|
||||
let _wzpWorkletLoaded = false;
|
||||
|
||||
async function wzpStartAudioContext() {
|
||||
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
|
||||
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
|
||||
_wzpWorkletLoaded = false;
|
||||
return _wzpAudioCtx;
|
||||
}
|
||||
|
||||
function wzpGetAudioContext() {
|
||||
return _wzpAudioCtx;
|
||||
}
|
||||
|
||||
async function _wzpLoadWorklet(audioCtx) {
|
||||
if (_wzpWorkletLoaded) return true;
|
||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
||||
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
||||
_wzpWorkletLoaded = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[wzp-core] AudioWorklet load failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mic capture — returns { node, stop() }
|
||||
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function wzpConnectCapture(audioCtx, onFrame) {
|
||||
let mediaStream;
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: WZP_SAMPLE_RATE,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error('Mic access denied: ' + e.message);
|
||||
}
|
||||
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||
let captureNode;
|
||||
|
||||
if (hasWorklet) {
|
||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
||||
captureNode.port.onmessage = (e) => {
|
||||
onFrame(e.data); // ArrayBuffer of Int16 PCM
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination); // keep worklet alive
|
||||
} else {
|
||||
// ScriptProcessorNode fallback
|
||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
let acc = new Float32Array(0);
|
||||
captureNode.onaudioprocess = (ev) => {
|
||||
const input = ev.inputBuffer.getChannelData(0);
|
||||
const n = new Float32Array(acc.length + input.length);
|
||||
n.set(acc);
|
||||
n.set(input, acc.length);
|
||||
acc = n;
|
||||
while (acc.length >= WZP_FRAME_SIZE) {
|
||||
const frame = acc.slice(0, WZP_FRAME_SIZE);
|
||||
acc = acc.slice(WZP_FRAME_SIZE);
|
||||
const pcm = new Int16Array(WZP_FRAME_SIZE);
|
||||
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
}
|
||||
onFrame(pcm.buffer);
|
||||
}
|
||||
};
|
||||
source.connect(captureNode);
|
||||
captureNode.connect(audioCtx.destination);
|
||||
}
|
||||
|
||||
return {
|
||||
node: captureNode,
|
||||
stop() {
|
||||
captureNode.disconnect();
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback — returns { node, play(Int16Array), stop() }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function wzpConnectPlayback(audioCtx) {
|
||||
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
||||
let playbackNode;
|
||||
let nextPlayTime = 0;
|
||||
|
||||
if (hasWorklet) {
|
||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
||||
playbackNode.connect(audioCtx.destination);
|
||||
return {
|
||||
node: playbackNode,
|
||||
play(pcmInt16) {
|
||||
// Transfer Int16 buffer to worklet
|
||||
const buf = pcmInt16.buffer.slice(
|
||||
pcmInt16.byteOffset,
|
||||
pcmInt16.byteOffset + pcmInt16.byteLength
|
||||
);
|
||||
playbackNode.port.postMessage(buf, [buf]);
|
||||
},
|
||||
stop() {
|
||||
playbackNode.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: scheduled BufferSource
|
||||
return {
|
||||
node: null,
|
||||
play(pcmInt16) {
|
||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||
const floatData = new Float32Array(pcmInt16.length);
|
||||
for (let i = 0; i < pcmInt16.length; i++) {
|
||||
floatData[i] = pcmInt16[i] / 32768.0;
|
||||
}
|
||||
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
|
||||
buffer.getChannelData(0).set(floatData);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioCtx.destination);
|
||||
const now = audioCtx.currentTime;
|
||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
||||
nextPlayTime = now + 0.02;
|
||||
}
|
||||
source.start(nextPlayTime);
|
||||
nextPlayTime += buffer.duration;
|
||||
},
|
||||
stop() {
|
||||
// nothing to disconnect for fallback
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI wiring — call after DOM ready
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wzpInitUI(callbacks) {
|
||||
// callbacks: { onConnect(room), onDisconnect() }
|
||||
const btn = document.getElementById('callBtn');
|
||||
const pttBtn = document.getElementById('pttBtn');
|
||||
const pttCheckbox = document.getElementById('pttMode');
|
||||
let connected = false;
|
||||
let pttMode = false;
|
||||
|
||||
wzpPrefillRoom();
|
||||
|
||||
// Variant badge
|
||||
const variant = wzpDetectVariant();
|
||||
const badge = document.getElementById('variantBadge');
|
||||
if (badge) badge.textContent = variant.toUpperCase();
|
||||
|
||||
// Variant selector radio buttons
|
||||
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
|
||||
if (radio.value === variant) radio.checked = true;
|
||||
radio.addEventListener('change', () => {
|
||||
if (radio.checked) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('variant', radio.value);
|
||||
location.search = params.toString();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
btn.onclick = () => {
|
||||
if (connected) {
|
||||
connected = false;
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('active');
|
||||
_showControls(false);
|
||||
if (callbacks.onDisconnect) callbacks.onDisconnect();
|
||||
} else {
|
||||
const room = wzpGetRoom();
|
||||
if (!room) {
|
||||
wzpUpdateStatus('Enter a room name');
|
||||
return;
|
||||
}
|
||||
connected = true;
|
||||
btn.disabled = true;
|
||||
if (callbacks.onConnect) callbacks.onConnect(room);
|
||||
}
|
||||
};
|
||||
|
||||
// PTT toggle
|
||||
if (pttCheckbox) {
|
||||
pttCheckbox.onchange = () => {
|
||||
pttMode = pttCheckbox.checked;
|
||||
if (pttMode) {
|
||||
pttBtn.style.display = 'block';
|
||||
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||
} else {
|
||||
pttBtn.style.display = 'none';
|
||||
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// PTT button events
|
||||
function startTx() {
|
||||
if (!pttMode || !connected) return;
|
||||
pttBtn.classList.add('transmitting');
|
||||
pttBtn.textContent = 'Transmitting...';
|
||||
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
||||
}
|
||||
function stopTx() {
|
||||
if (!pttMode) return;
|
||||
pttBtn.classList.remove('transmitting');
|
||||
pttBtn.textContent = 'Hold to Talk';
|
||||
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
||||
}
|
||||
|
||||
if (pttBtn) {
|
||||
pttBtn.addEventListener('mousedown', startTx);
|
||||
pttBtn.addEventListener('mouseup', stopTx);
|
||||
pttBtn.addEventListener('mouseleave', stopTx);
|
||||
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
|
||||
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
|
||||
}
|
||||
|
||||
// Spacebar PTT
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
startTx();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (pttMode && connected && e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
stopTx();
|
||||
}
|
||||
});
|
||||
|
||||
function _showControls(show) {
|
||||
const controls = document.getElementById('controls');
|
||||
if (controls) controls.style.display = show ? 'flex' : 'none';
|
||||
if (!show && pttBtn) {
|
||||
pttBtn.style.display = 'none';
|
||||
pttMode = false;
|
||||
if (pttCheckbox) pttCheckbox.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setConnected(isConnected) {
|
||||
connected = isConnected;
|
||||
btn.disabled = false;
|
||||
if (isConnected) {
|
||||
btn.textContent = 'Disconnect';
|
||||
btn.classList.add('active');
|
||||
_showControls(true);
|
||||
} else {
|
||||
btn.textContent = 'Connect';
|
||||
btn.classList.remove('active');
|
||||
_showControls(false);
|
||||
}
|
||||
},
|
||||
isPTT() {
|
||||
return pttMode;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports (global)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPCore = {
|
||||
SAMPLE_RATE: WZP_SAMPLE_RATE,
|
||||
FRAME_SIZE: WZP_FRAME_SIZE,
|
||||
detectVariant: wzpDetectVariant,
|
||||
getRoom: wzpGetRoom,
|
||||
updateStatus: wzpUpdateStatus,
|
||||
updateStats: wzpUpdateStats,
|
||||
updateLevel: wzpUpdateLevel,
|
||||
startAudioContext: wzpStartAudioContext,
|
||||
getAudioContext: wzpGetAudioContext,
|
||||
connectCapture: wzpConnectCapture,
|
||||
connectPlayback: wzpConnectPlayback,
|
||||
initUI: wzpInitUI,
|
||||
};
|
||||
579
crates/wzp-web/static/js/wzp-full.js
Normal file
579
crates/wzp-web/static/js/wzp-full.js
Normal file
@@ -0,0 +1,579 @@
|
||||
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
|
||||
//
|
||||
// Architecture:
|
||||
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
|
||||
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
|
||||
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
|
||||
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
|
||||
//
|
||||
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
|
||||
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
|
||||
// architecture but will need relay-side HTTP/3 support to work end-to-end.
|
||||
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
|
||||
//
|
||||
// Relies on wzp-core.js for UI and audio helpers.
|
||||
|
||||
'use strict';
|
||||
|
||||
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
|
||||
|
||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||
const MEDIA_HEADER_SIZE = 12;
|
||||
|
||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||
const FEC_HEADER_SIZE = 3;
|
||||
|
||||
class WZPFullClient {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.url WebTransport URL (https://host:port)
|
||||
* @param {string} options.room Room name
|
||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||
* @param {Function} options.onStatus callback(string) for UI status
|
||||
* @param {Function} options.onStats callback(Object) for UI stats
|
||||
*/
|
||||
constructor(options) {
|
||||
this.url = options.url;
|
||||
this.wsUrl = options.wsUrl; // WS fallback URL
|
||||
this.room = options.room;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.wt = null; // WebTransport instance
|
||||
this.ws = null; // WebSocket fallback
|
||||
this.datagramWriter = null; // WritableStreamDefaultWriter
|
||||
this.datagramReader = null; // ReadableStreamDefaultReader
|
||||
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
||||
this.fecEncoder = null; // WzpFecEncoder (WASM)
|
||||
this.fecDecoder = null; // WzpFecDecoder (WASM)
|
||||
this.sequence = 0;
|
||||
this._wasmModule = null;
|
||||
this._connected = false;
|
||||
this._useWebTransport = false; // true if WT connected, false = WS fallback
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._recvLoopRunning = false;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect: load WASM, open WebTransport, perform key exchange,
|
||||
* initialise FEC, and start the receive loop.
|
||||
*/
|
||||
async connect() {
|
||||
if (this._connected) return;
|
||||
|
||||
this._status('Loading WASM module...');
|
||||
|
||||
// 1. Load WASM (FEC + crypto)
|
||||
this._wasmModule = await import(WZP_WASM_PATH);
|
||||
await this._wasmModule.default();
|
||||
|
||||
// 2. Try WebTransport first, fall back to WebSocket
|
||||
let wtSuccess = false;
|
||||
if (typeof WebTransport !== 'undefined' && this.url) {
|
||||
try {
|
||||
this._status('Trying WebTransport...');
|
||||
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
||||
this.wt = new WebTransport(wtUrl);
|
||||
await Promise.race([
|
||||
this.wt.ready,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
||||
]);
|
||||
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
||||
this.datagramReader = this.wt.datagrams.readable.getReader();
|
||||
this._status('Performing key exchange...');
|
||||
await this._performKeyExchange();
|
||||
wtSuccess = true;
|
||||
this._useWebTransport = true;
|
||||
} catch (e) {
|
||||
console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message);
|
||||
if (this.wt) { try { this.wt.close(); } catch (_) {} }
|
||||
this.wt = null;
|
||||
this.datagramWriter = null;
|
||||
this.datagramReader = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wtSuccess) {
|
||||
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
|
||||
this._useWebTransport = false;
|
||||
await this._connectWebSocket();
|
||||
}
|
||||
|
||||
// 3. Initialise FEC
|
||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||
|
||||
this._connected = true;
|
||||
this.sequence = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._startStatsTimer();
|
||||
|
||||
// 4. Start receive loop (WebTransport only — WS uses onmessage)
|
||||
if (this._useWebTransport) {
|
||||
this._recvLoop();
|
||||
this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)');
|
||||
} else {
|
||||
this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket fallback connection (used when WebTransport unavailable).
|
||||
*/
|
||||
async _connectWebSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._status('Connecting via WebSocket (fallback)...');
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._status('WebSocket connected to room: ' + this.room);
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
this.stats.recv++;
|
||||
if (this.onAudio) this.onAudio(pcm);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this._connected) {
|
||||
this._cleanup();
|
||||
this._status('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clean up all resources.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.wt) {
|
||||
try { this.wt.close(); } catch (_) { /* ignore */ }
|
||||
this.wt = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this._cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame.
|
||||
*
|
||||
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
|
||||
*
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected) return;
|
||||
|
||||
// WebSocket fallback: send raw PCM like pure/hybrid
|
||||
if (!this._useWebTransport) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(pcmBuffer);
|
||||
this.sequence++;
|
||||
this.stats.sent++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.datagramWriter || !this.cryptoSession) return;
|
||||
|
||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||
|
||||
// Build a minimal 12-byte MediaHeader for AAD.
|
||||
const header = this._buildMediaHeader(this.sequence);
|
||||
|
||||
// FEC encode: feed the frame; when a block completes we get wire packets.
|
||||
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
|
||||
|
||||
if (fecOutput) {
|
||||
// FEC block completed — send all packets (source + repair).
|
||||
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
|
||||
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
||||
const fecPacket = fecOutput.slice(offset, offset + packetSize);
|
||||
|
||||
// Encrypt: header bytes as AAD, FEC packet as plaintext.
|
||||
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
|
||||
this.stats.encrypted++;
|
||||
|
||||
// Build wire datagram: header (12) + ciphertext
|
||||
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
|
||||
datagram.set(header, 0);
|
||||
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
|
||||
|
||||
try {
|
||||
await this.datagramWriter.write(datagram);
|
||||
} catch (e) {
|
||||
// Datagram send can fail if the transport is closing.
|
||||
if (this._connected) {
|
||||
console.warn('[wzp-full] datagram write failed:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.stats.sent++;
|
||||
}
|
||||
}
|
||||
// If FEC block not yet complete, accumulate (no packets sent yet).
|
||||
|
||||
this.sequence = (this.sequence + 1) & 0xFFFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||
* Useful for verifying the WASM module works correctly in the browser.
|
||||
*
|
||||
* @returns {Object} test results
|
||||
*/
|
||||
testCryptoFec() {
|
||||
if (!this._wasmModule) {
|
||||
return { success: false, error: 'WASM module not loaded' };
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
const wasm = this._wasmModule;
|
||||
|
||||
// Key exchange
|
||||
const alice = new wasm.WzpKeyExchange();
|
||||
const bob = new wasm.WzpKeyExchange();
|
||||
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
||||
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
||||
|
||||
// Verify secrets match
|
||||
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||
if (secretsMatch) {
|
||||
for (let i = 0; i < aliceSecret.length; i++) {
|
||||
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt/decrypt
|
||||
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||
|
||||
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
const plaintext = new TextEncoder().encode('hello warzone from full variant');
|
||||
|
||||
const ciphertext = aliceSession.encrypt(header, plaintext);
|
||||
const decrypted = bobSession.decrypt(header, ciphertext);
|
||||
|
||||
let cryptoOk = decrypted.length === plaintext.length;
|
||||
if (cryptoOk) {
|
||||
for (let i = 0; i < plaintext.length; i++) {
|
||||
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// FEC test (same as hybrid testFec)
|
||||
const encoder = new wasm.WzpFecEncoder(5, 256);
|
||||
const decoder = new wasm.WzpFecDecoder(5, 256);
|
||||
|
||||
const frames = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const frame = new Uint8Array(100);
|
||||
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||
frames.push(frame);
|
||||
}
|
||||
|
||||
let wireData = null;
|
||||
for (const frame of frames) {
|
||||
const result = encoder.add_symbol(frame);
|
||||
if (result) wireData = result;
|
||||
}
|
||||
|
||||
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
|
||||
const packets = [];
|
||||
if (wireData) {
|
||||
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
|
||||
packets.push({
|
||||
blockId: wireData[off],
|
||||
symbolIdx: wireData[off + 1],
|
||||
isRepair: wireData[off + 2] !== 0,
|
||||
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Drop 2 packets, try to recover
|
||||
let fecDecoded = null;
|
||||
for (let i = 0; i < packets.length; i++) {
|
||||
if (i === 1 || i === 3) continue; // simulate loss
|
||||
const pkt = packets[i];
|
||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||
if (result) { fecDecoded = result; break; }
|
||||
}
|
||||
|
||||
let fecOk = false;
|
||||
if (fecDecoded) {
|
||||
const expected = new Uint8Array(5 * 100);
|
||||
let off = 0;
|
||||
for (const f of frames) { expected.set(f, off); off += f.length; }
|
||||
fecOk = fecDecoded.length === expected.length;
|
||||
if (fecOk) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup WASM objects
|
||||
alice.free();
|
||||
bob.free();
|
||||
aliceSession.free();
|
||||
bobSession.free();
|
||||
encoder.free();
|
||||
decoder.free();
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
|
||||
return {
|
||||
success: secretsMatch && cryptoOk && fecOk,
|
||||
secretsMatch,
|
||||
cryptoOk,
|
||||
fecOk,
|
||||
fecPacketsTotal: packets.length,
|
||||
fecDropped: 2,
|
||||
elapsed: elapsed.toFixed(2) + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Perform X25519 key exchange over a WebTransport bidirectional stream.
|
||||
*
|
||||
* Protocol (simplified DH, not the full SignalMessage handshake):
|
||||
* 1. Open a bidirectional stream.
|
||||
* 2. Send our 32-byte X25519 public key.
|
||||
* 3. Read the peer's 32-byte public key.
|
||||
* 4. Derive shared secret via HKDF.
|
||||
* 5. Create WzpCryptoSession from the shared secret.
|
||||
*
|
||||
* In production this would use the full SignalMessage protocol over the
|
||||
* bidirectional stream (offer/answer/encrypted-session). For now we do
|
||||
* a simple DH swap to prove the architecture.
|
||||
*/
|
||||
async _performKeyExchange() {
|
||||
const wasm = this._wasmModule;
|
||||
const kx = new wasm.WzpKeyExchange();
|
||||
const ourPub = kx.public_key(); // Uint8Array(32)
|
||||
|
||||
// Open a bidirectional stream for signaling.
|
||||
const stream = await this.wt.createBidirectionalStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
|
||||
// Send our public key.
|
||||
await writer.write(new Uint8Array(ourPub));
|
||||
|
||||
// Read peer's public key (exactly 32 bytes).
|
||||
// WebTransport streams are byte-oriented; we may get it in chunks.
|
||||
let peerPub = new Uint8Array(0);
|
||||
while (peerPub.length < 32) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
throw new Error('Key exchange stream closed before receiving peer public key');
|
||||
}
|
||||
const combined = new Uint8Array(peerPub.length + value.length);
|
||||
combined.set(peerPub, 0);
|
||||
combined.set(value, peerPub.length);
|
||||
peerPub = combined;
|
||||
}
|
||||
peerPub = peerPub.slice(0, 32);
|
||||
|
||||
// Derive shared secret and create crypto session.
|
||||
const secret = kx.derive_shared_secret(peerPub);
|
||||
this.cryptoSession = new wasm.WzpCryptoSession(secret);
|
||||
|
||||
// Close the signaling stream (key exchange complete).
|
||||
try {
|
||||
writer.releaseLock();
|
||||
reader.releaseLock();
|
||||
await stream.writable.close();
|
||||
} catch (_) {
|
||||
// Best-effort close.
|
||||
}
|
||||
|
||||
kx.free();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
|
||||
*
|
||||
* Runs until the transport closes or disconnect() is called.
|
||||
*/
|
||||
async _recvLoop() {
|
||||
if (this._recvLoopRunning) return;
|
||||
this._recvLoopRunning = true;
|
||||
|
||||
try {
|
||||
while (this._connected && this.datagramReader) {
|
||||
const { value, done } = await this.datagramReader.read();
|
||||
if (done) break;
|
||||
|
||||
this.stats.recv++;
|
||||
|
||||
// value is a Uint8Array datagram: header(12) + ciphertext
|
||||
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
|
||||
|
||||
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
|
||||
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
|
||||
|
||||
// Decrypt
|
||||
let fecPacket;
|
||||
try {
|
||||
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
|
||||
this.stats.decrypted++;
|
||||
} catch (e) {
|
||||
// Decryption failure — corrupted or out-of-order packet.
|
||||
// In a real implementation we'd handle sequence number gaps.
|
||||
console.warn('[wzp-full] decrypt failed:', e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// FEC decode: parse the FEC wire header and feed to decoder.
|
||||
if (fecPacket.length < FEC_HEADER_SIZE) continue;
|
||||
const blockId = fecPacket[0];
|
||||
const symbolIdx = fecPacket[1];
|
||||
const isRepair = fecPacket[2] !== 0;
|
||||
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
|
||||
|
||||
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
|
||||
if (decoded) {
|
||||
this.stats.fecRecovered++;
|
||||
// decoded is concatenated original PCM frames.
|
||||
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
|
||||
const FRAME_BYTES = 1920;
|
||||
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
|
||||
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
|
||||
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
|
||||
if (this.onAudio) {
|
||||
this.onAudio(pcm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (this._connected) {
|
||||
console.warn('[wzp-full] recv loop error:', e);
|
||||
}
|
||||
} finally {
|
||||
this._recvLoopRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal 12-byte MediaHeader for use as AAD.
|
||||
*
|
||||
* Wire layout (from wzp-proto::packet::MediaHeader):
|
||||
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
||||
* Byte 1: FecRatioLo(6)|unused(2)
|
||||
* Bytes 2-3: Sequence number (BE u16)
|
||||
* Bytes 4-7: Timestamp ms (BE u32)
|
||||
* Byte 8: FEC block ID
|
||||
* Byte 9: FEC symbol index
|
||||
* Byte 10: Reserved
|
||||
* Byte 11: CSRC count
|
||||
*
|
||||
* @param {number} seq Sequence number (u16)
|
||||
* @returns {Uint8Array} 12-byte header
|
||||
*/
|
||||
_buildMediaHeader(seq) {
|
||||
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
|
||||
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
|
||||
buf[0] = 0x00;
|
||||
// Byte 1: fec_ratio_lo=0
|
||||
buf[1] = 0x00;
|
||||
// Bytes 2-3: sequence (BE u16)
|
||||
buf[2] = (seq >> 8) & 0xFF;
|
||||
buf[3] = seq & 0xFF;
|
||||
// Bytes 4-7: timestamp (BE u32) — ms since session start
|
||||
const ts = Date.now() - this._startTime;
|
||||
buf[4] = (ts >> 24) & 0xFF;
|
||||
buf[5] = (ts >> 16) & 0xFF;
|
||||
buf[6] = (ts >> 8) & 0xFF;
|
||||
buf[7] = ts & 0xFF;
|
||||
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
|
||||
return buf;
|
||||
}
|
||||
|
||||
_startStatsTimer() {
|
||||
this._stopStatsTimer();
|
||||
this._statsInterval = setInterval(() => {
|
||||
if (!this._connected) {
|
||||
this._stopStatsTimer();
|
||||
return;
|
||||
}
|
||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||
const loss = this.stats.sent > 0
|
||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||
: 0;
|
||||
if (this.onStats) {
|
||||
this.onStats({
|
||||
sent: this.stats.sent,
|
||||
recv: this.stats.recv,
|
||||
loss,
|
||||
elapsed,
|
||||
encrypted: this.stats.encrypted,
|
||||
decrypted: this.stats.decrypted,
|
||||
fecRecovered: this.stats.fecRecovered,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_stopStatsTimer() {
|
||||
if (this._statsInterval) {
|
||||
clearInterval(this._statsInterval);
|
||||
this._statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_status(msg) {
|
||||
if (this.onStatus) this.onStatus(msg);
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._connected = false;
|
||||
this._stopStatsTimer();
|
||||
this.datagramWriter = null;
|
||||
this.datagramReader = null;
|
||||
if (this.cryptoSession) {
|
||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||
this.cryptoSession = null;
|
||||
}
|
||||
if (this.fecEncoder) {
|
||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecEncoder = null;
|
||||
}
|
||||
if (this.fecDecoder) {
|
||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecDecoder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPFullClient = WZPFullClient;
|
||||
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
345
crates/wzp-web/static/js/wzp-hybrid.js
Normal file
@@ -0,0 +1,345 @@
|
||||
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
|
||||
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
|
||||
// Relies on wzp-core.js for UI and audio helpers.
|
||||
//
|
||||
// The WASM FEC module is loaded and exposed but not used on the wire yet,
|
||||
// because WebSocket is TCP (no packet loss). FEC will activate when
|
||||
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
|
||||
// encode -> simulate loss -> decode in the browser.
|
||||
|
||||
'use strict';
|
||||
|
||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||
const WZP_WASM_PATH = '/wasm/wzp_wasm.js';
|
||||
|
||||
class WZPHybridClient {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||
* @param {string} options.room Room name
|
||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||
* @param {Function} options.onStatus callback(string) for UI status
|
||||
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
|
||||
*/
|
||||
constructor(options) {
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.room = options.room;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.sequence = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
|
||||
// WASM FEC instances (loaded in connect()).
|
||||
this._wasmModule = null;
|
||||
this.fecEncoder = null;
|
||||
this.fecDecoder = null;
|
||||
this._fecReady = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open WebSocket connection and load the WASM FEC module.
|
||||
* @returns {Promise<void>} resolves when connected
|
||||
*/
|
||||
async connect() {
|
||||
if (this._connected) return;
|
||||
|
||||
// Load WASM module in parallel with WebSocket connect.
|
||||
const wasmPromise = this._loadWasm();
|
||||
|
||||
const wsPromise = new Promise((resolve, reject) => {
|
||||
this._status('Connecting to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._connected = true;
|
||||
this.sequence = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._startStatsTimer();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this._handleMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const wasConnected = this._connected;
|
||||
this._cleanup();
|
||||
if (wasConnected) {
|
||||
this._status('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
} else {
|
||||
this._status('Connection error');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Wait for both WASM load and WS connect.
|
||||
await Promise.all([wasmPromise, wsPromise]);
|
||||
|
||||
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
|
||||
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket and clean up.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._stopStatsTimer();
|
||||
// Keep WASM module loaded (reusable).
|
||||
this.fecEncoder = null;
|
||||
this.fecDecoder = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame over the WebSocket.
|
||||
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
|
||||
* When WebTransport is added, this will FEC-encode before sending.
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Over WebSocket (TCP): send raw PCM, no FEC needed.
|
||||
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
|
||||
// and send the resulting FEC-protected packets.
|
||||
this.ws.send(pcmBuffer);
|
||||
this.sequence++;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test FEC encode -> simulate loss -> decode in the browser.
|
||||
* Demonstrates that the WASM RaptorQ module works correctly.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.blockSize=5] Source symbols per block
|
||||
* @param {number} [opts.symbolSize=256] Padded symbol size
|
||||
* @param {number} [opts.frameSize=100] Bytes per test frame
|
||||
* @param {number} [opts.dropCount=2] Number of packets to drop
|
||||
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
|
||||
*/
|
||||
testFec(opts) {
|
||||
if (!this._fecReady) {
|
||||
return { success: false, error: 'WASM FEC module not loaded' };
|
||||
}
|
||||
|
||||
const blockSize = (opts && opts.blockSize) || 5;
|
||||
const symbolSize = (opts && opts.symbolSize) || 256;
|
||||
const frameSize = (opts && opts.frameSize) || 100;
|
||||
const dropCount = (opts && opts.dropCount) || 2;
|
||||
|
||||
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
|
||||
const packetSize = HEADER_SIZE + symbolSize;
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// Create fresh encoder/decoder for the test.
|
||||
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
||||
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
||||
|
||||
// Generate test frames with known data.
|
||||
const frames = [];
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const frame = new Uint8Array(frameSize);
|
||||
for (let j = 0; j < frameSize; j++) {
|
||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
|
||||
// Encode: feed frames to encoder; last one triggers block output.
|
||||
let wireData = null;
|
||||
for (const frame of frames) {
|
||||
const result = encoder.add_symbol(frame);
|
||||
if (result) {
|
||||
wireData = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wireData) {
|
||||
// Flush if block didn't complete (shouldn't happen with exact blockSize).
|
||||
wireData = encoder.flush();
|
||||
}
|
||||
|
||||
// Parse wire packets.
|
||||
const packets = [];
|
||||
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
||||
packets.push({
|
||||
blockId: wireData[offset],
|
||||
symbolIdx: wireData[offset + 1],
|
||||
isRepair: wireData[offset + 2] !== 0,
|
||||
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
|
||||
});
|
||||
}
|
||||
|
||||
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
||||
const repairPackets = packets.filter(p => p.isRepair).length;
|
||||
|
||||
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
|
||||
const dropped = [];
|
||||
const surviving = [];
|
||||
for (let i = 0; i < packets.length; i++) {
|
||||
if (i < dropCount) {
|
||||
dropped.push(i);
|
||||
} else {
|
||||
surviving.push(packets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Decode from surviving packets.
|
||||
let decoded = null;
|
||||
for (const pkt of surviving) {
|
||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
||||
if (result) {
|
||||
decoded = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
|
||||
// Verify decoded data matches original frames.
|
||||
let success = false;
|
||||
if (decoded) {
|
||||
const expected = new Uint8Array(blockSize * frameSize);
|
||||
let off = 0;
|
||||
for (const frame of frames) {
|
||||
expected.set(frame, off);
|
||||
off += frame.length;
|
||||
}
|
||||
|
||||
success = decoded.length === expected.length;
|
||||
if (success) {
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
if (decoded[i] !== expected[i]) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Free WASM objects.
|
||||
encoder.free();
|
||||
decoder.free();
|
||||
|
||||
return {
|
||||
success,
|
||||
sourcePackets,
|
||||
repairPackets,
|
||||
totalPackets: packets.length,
|
||||
dropped: dropCount,
|
||||
recovered: success,
|
||||
decodedBytes: decoded ? decoded.length : 0,
|
||||
expectedBytes: blockSize * frameSize,
|
||||
elapsed: elapsed.toFixed(2) + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async _loadWasm() {
|
||||
try {
|
||||
// Dynamic import of the wasm-pack generated JS glue.
|
||||
this._wasmModule = await import(WZP_WASM_PATH);
|
||||
// Initialize the WASM module (calls __wbg_init).
|
||||
await this._wasmModule.default();
|
||||
|
||||
// Create FEC encoder/decoder instances.
|
||||
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
|
||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
||||
this._fecReady = true;
|
||||
|
||||
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
|
||||
} catch (e) {
|
||||
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
|
||||
this._fecReady = false;
|
||||
// Non-fatal: client still works without FEC (like pure variant).
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
this.stats.recv++;
|
||||
if (this.onAudio) {
|
||||
this.onAudio(pcm);
|
||||
}
|
||||
}
|
||||
|
||||
_startStatsTimer() {
|
||||
this._stopStatsTimer();
|
||||
this._statsInterval = setInterval(() => {
|
||||
if (!this._connected) {
|
||||
this._stopStatsTimer();
|
||||
return;
|
||||
}
|
||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||
const loss = this.stats.sent > 0
|
||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||
: 0;
|
||||
if (this.onStats) {
|
||||
this.onStats({
|
||||
sent: this.stats.sent,
|
||||
recv: this.stats.recv,
|
||||
loss: loss,
|
||||
elapsed: elapsed,
|
||||
fecRecovered: this.stats.fecRecovered,
|
||||
fecReady: this._fecReady,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_stopStatsTimer() {
|
||||
if (this._statsInterval) {
|
||||
clearInterval(this._statsInterval);
|
||||
this._statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_status(msg) {
|
||||
if (this.onStatus) this.onStatus(msg);
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._connected = false;
|
||||
this._stopStatsTimer();
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPHybridClient = WZPHybridClient;
|
||||
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
168
crates/wzp-web/static/js/wzp-pure.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// WarzonePhone — Pure JS client (Variant 1).
|
||||
// WebSocket transport, raw PCM, no WASM, no FEC.
|
||||
// Relies on wzp-core.js for UI and audio helpers.
|
||||
|
||||
'use strict';
|
||||
|
||||
class WZPPureClient {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||
* @param {string} options.room Room name
|
||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||
* @param {Function} options.onStatus callback(string) for UI status
|
||||
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
||||
*/
|
||||
constructor(options) {
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.room = options.room;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.sequence = 0;
|
||||
this.stats = { sent: 0, recv: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open WebSocket connection to the wzp-web bridge.
|
||||
* @returns {Promise<void>} resolves when connected
|
||||
*/
|
||||
async connect() {
|
||||
if (this._connected) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._status('Connecting to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._connected = true;
|
||||
this.sequence = 0;
|
||||
this.stats = { sent: 0, recv: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._status('Connected to room: ' + this.room);
|
||||
this._startStatsTimer();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this._handleMessage(event);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const wasConnected = this._connected;
|
||||
this._cleanup();
|
||||
if (wasConnected) {
|
||||
this._status('Disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
} else {
|
||||
this._status('Connection error');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket and clean up.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._stopStatsTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame over the WebSocket.
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pure JS variant: send raw PCM directly (no encryption, no header).
|
||||
// The wzp-web bridge handles QUIC-side encryption.
|
||||
this.ws.send(pcmBuffer);
|
||||
this.sequence++;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
this.stats.recv++;
|
||||
if (this.onAudio) {
|
||||
this.onAudio(pcm);
|
||||
}
|
||||
}
|
||||
|
||||
_startStatsTimer() {
|
||||
this._stopStatsTimer();
|
||||
this._statsInterval = setInterval(() => {
|
||||
if (!this._connected) {
|
||||
this._stopStatsTimer();
|
||||
return;
|
||||
}
|
||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||
// Simple loss estimate: if we sent frames, the other side should
|
||||
// receive roughly the same count. Since we only see our own recv,
|
||||
// we report raw counts and let the UI decide.
|
||||
const loss = this.stats.sent > 0
|
||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||
: 0;
|
||||
if (this.onStats) {
|
||||
this.onStats({
|
||||
sent: this.stats.sent,
|
||||
recv: this.stats.recv,
|
||||
loss: loss,
|
||||
elapsed: elapsed,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_stopStatsTimer() {
|
||||
if (this._statsInterval) {
|
||||
clearInterval(this._statsInterval);
|
||||
this._statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_status(msg) {
|
||||
if (this.onStatus) this.onStatus(msg);
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._connected = false;
|
||||
this._stopStatsTimer();
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPPureClient = WZPPureClient;
|
||||
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
2
crates/wzp-web/static/wasm/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
package.json
|
||||
*.d.ts
|
||||
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
556
crates/wzp-web/static/wasm/wzp_wasm.js
Normal file
@@ -0,0 +1,556 @@
|
||||
/* @ts-self-types="./wzp_wasm.d.ts" */
|
||||
|
||||
/**
|
||||
* Symmetric encryption session using ChaCha20-Poly1305.
|
||||
*
|
||||
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||
* and key setup are identical so WASM and native peers interoperate.
|
||||
*/
|
||||
export class WzpCryptoSession {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WzpCryptoSessionFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wzpcryptosession_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Decrypt a media payload with AAD.
|
||||
*
|
||||
* Returns plaintext on success, or throws on auth failure.
|
||||
* @param {Uint8Array} header_aad
|
||||
* @param {Uint8Array} ciphertext
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
decrypt(header_aad, ciphertext) {
|
||||
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpcryptosession_decrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||
if (ret[3]) {
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
return v3;
|
||||
}
|
||||
/**
|
||||
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||
*
|
||||
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||
* @param {Uint8Array} header_aad
|
||||
* @param {Uint8Array} plaintext
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
encrypt(header_aad, plaintext) {
|
||||
const ptr0 = passArray8ToWasm0(header_aad, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpcryptosession_encrypt(this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||
if (ret[3]) {
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
return v3;
|
||||
}
|
||||
/**
|
||||
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||
* @param {Uint8Array} shared_secret
|
||||
*/
|
||||
constructor(shared_secret) {
|
||||
const ptr0 = passArray8ToWasm0(shared_secret, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpcryptosession_new(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
this.__wbg_ptr = ret[0] >>> 0;
|
||||
WzpCryptoSessionFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Current receive sequence number (for diagnostics / UI stats).
|
||||
* @returns {number}
|
||||
*/
|
||||
recv_seq() {
|
||||
const ret = wasm.wzpcryptosession_recv_seq(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Current send sequence number (for diagnostics / UI stats).
|
||||
* @returns {number}
|
||||
*/
|
||||
send_seq() {
|
||||
const ret = wasm.wzpcryptosession_send_seq(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WzpCryptoSession.prototype[Symbol.dispose] = WzpCryptoSession.prototype.free;
|
||||
|
||||
export class WzpFecDecoder {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WzpFecDecoderFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wzpfecdecoder_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Feed a received symbol.
|
||||
*
|
||||
* Returns the decoded block (concatenated original frames, unpadded) if
|
||||
* enough symbols have been received to recover the block, or `undefined`.
|
||||
* @param {number} block_id
|
||||
* @param {number} symbol_idx
|
||||
* @param {boolean} _is_repair
|
||||
* @param {Uint8Array} data
|
||||
* @returns {Uint8Array | undefined}
|
||||
*/
|
||||
add_symbol(block_id, symbol_idx, _is_repair, data) {
|
||||
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpfecdecoder_add_symbol(this.__wbg_ptr, block_id, symbol_idx, _is_repair, ptr0, len0);
|
||||
let v2;
|
||||
if (ret[0] !== 0) {
|
||||
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
}
|
||||
return v2;
|
||||
}
|
||||
/**
|
||||
* Create a new FEC decoder.
|
||||
*
|
||||
* * `block_size` — expected number of source symbols per block.
|
||||
* * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||
* @param {number} block_size
|
||||
* @param {number} symbol_size
|
||||
*/
|
||||
constructor(block_size, symbol_size) {
|
||||
const ret = wasm.wzpfecdecoder_new(block_size, symbol_size);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WzpFecDecoderFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WzpFecDecoder.prototype[Symbol.dispose] = WzpFecDecoder.prototype.free;
|
||||
|
||||
export class WzpFecEncoder {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WzpFecEncoderFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wzpfecencoder_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Add a source symbol (audio frame).
|
||||
*
|
||||
* Returns encoded packets (all source + repair) when the block is complete,
|
||||
* or `undefined` if the block is still accumulating.
|
||||
*
|
||||
* Each returned packet carries the 3-byte header:
|
||||
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||
* @param {Uint8Array} data
|
||||
* @returns {Uint8Array | undefined}
|
||||
*/
|
||||
add_symbol(data) {
|
||||
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpfecencoder_add_symbol(this.__wbg_ptr, ptr0, len0);
|
||||
let v2;
|
||||
if (ret[0] !== 0) {
|
||||
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
}
|
||||
return v2;
|
||||
}
|
||||
/**
|
||||
* Force-flush the current (possibly partial) block.
|
||||
*
|
||||
* Returns all source + repair symbols with headers, or empty vec if no
|
||||
* symbols have been accumulated.
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
flush() {
|
||||
const ret = wasm.wzpfecencoder_flush(this.__wbg_ptr);
|
||||
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
return v1;
|
||||
}
|
||||
/**
|
||||
* Create a new FEC encoder.
|
||||
*
|
||||
* * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||
* * `symbol_size` — padded byte size of each symbol (default 256).
|
||||
* @param {number} block_size
|
||||
* @param {number} symbol_size
|
||||
*/
|
||||
constructor(block_size, symbol_size) {
|
||||
const ret = wasm.wzpfecencoder_new(block_size, symbol_size);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WzpFecEncoderFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WzpFecEncoder.prototype[Symbol.dispose] = WzpFecEncoder.prototype.free;
|
||||
|
||||
/**
|
||||
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||
*
|
||||
* Usage from JS:
|
||||
* ```js
|
||||
* const kx = new WzpKeyExchange();
|
||||
* const ourPub = kx.public_key(); // Uint8Array(32)
|
||||
* // ... send ourPub to peer, receive peerPub ...
|
||||
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||
* const session = new WzpCryptoSession(secret);
|
||||
* ```
|
||||
*/
|
||||
export class WzpKeyExchange {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WzpKeyExchangeFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wzpkeyexchange_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Derive a 32-byte session key from the peer's public key.
|
||||
*
|
||||
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||
* @param {Uint8Array} peer_public
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
derive_shared_secret(peer_public) {
|
||||
const ptr0 = passArray8ToWasm0(peer_public, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.wzpkeyexchange_derive_shared_secret(this.__wbg_ptr, ptr0, len0);
|
||||
if (ret[3]) {
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
return v2;
|
||||
}
|
||||
/**
|
||||
* Generate a new random X25519 keypair.
|
||||
*/
|
||||
constructor() {
|
||||
const ret = wasm.wzpkeyexchange_new();
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WzpKeyExchangeFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Our public key (32 bytes).
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
public_key() {
|
||||
const ret = wasm.wzpkeyexchange_public_key(this.__wbg_ptr);
|
||||
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
return v1;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WzpKeyExchange.prototype[Symbol.dispose] = WzpKeyExchange.prototype.free;
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg___wbindgen_is_function_3c846841762788c1: function(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_object_781bc9f159099513: function(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_string_7ef6b97b02428fae: function(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_is_undefined_52709e72fb9f179c: function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_call_2d781c1f4d5c0ef8: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_crypto_38df2bab126b63dc: function(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments); },
|
||||
__wbg_length_ea16607d7b61445b: function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
},
|
||||
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_with_length_825018a1616e9e55: function(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_node_84ea875411254db1: function(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
},
|
||||
__wbg_process_44c7a14e11e9f69e: function(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
},
|
||||
__wbg_prototypesetcall_d62e5099504357e6: function(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
},
|
||||
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments); },
|
||||
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_static_accessor_GLOBAL_8adb955bd33fac2f: function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_GLOBAL_THIS_ad356e0db91c7913: function() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_SELF_f207c857566db248: function() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_WINDOW_bb9f1ba69d61b386: function() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_subarray_a068d24e39478a8a: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_versions_276b2795b1c6a219: function(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./wzp_wasm_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
const WzpCryptoSessionFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wzpcryptosession_free(ptr >>> 0, 1));
|
||||
const WzpFecDecoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecdecoder_free(ptr >>> 0, 1));
|
||||
const WzpFecEncoderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wzpfecencoder_free(ptr >>> 0, 1));
|
||||
const WzpKeyExchangeFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wzpkeyexchange_free(ptr >>> 0, 1));
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('wzp_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
BIN
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm
Normal file
Binary file not shown.
@@ -1,115 +0,0 @@
|
||||
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** High — app crash (SIGBUS) mid-call
|
||||
**Status:** Root-caused, fix proposed
|
||||
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
|
||||
|
||||
## Summary
|
||||
|
||||
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
|
||||
|
||||
## Crash Details
|
||||
|
||||
### Crash 1: wzp-capture (18:42, after 476s of call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
|
||||
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 art::StackVisitor::WalkStack
|
||||
#01 art::Thread::VisitRoots
|
||||
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
|
||||
#03 art::Thread::EnsureFlipFunctionStarted
|
||||
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
|
||||
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
|
||||
#09 com.wzp.audio.AudioPipeline.runCapture
|
||||
```
|
||||
|
||||
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
|
||||
|
||||
### Crash 2: wzp-playout (19:17, mid-call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
|
||||
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
|
||||
#01 art_quick_osr_stub ← On-Stack Replacement
|
||||
#02 art::jit::Jit::MaybeDoOnStackReplacement
|
||||
#03-#04 art::interpreter::ExecuteSwitchImplCpp
|
||||
```
|
||||
|
||||
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
|
||||
|
||||
## Why This Happens
|
||||
|
||||
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
|
||||
|
||||
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
|
||||
|
||||
2. **Tight JNI loops** — `runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
|
||||
|
||||
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
|
||||
|
||||
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
|
||||
|
||||
## Evidence This Is Not Our Bug
|
||||
|
||||
| Component | Evidence |
|
||||
|-----------|---------|
|
||||
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
|
||||
| **Rust native code** | `libwzp_android.so` not in any crash frame |
|
||||
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
|
||||
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Option A: Disable concurrent GC compaction for audio threads (recommended)
|
||||
|
||||
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
|
||||
|
||||
**Not directly controllable from app code.** But we can reduce GC pressure:
|
||||
|
||||
### Option B: Reduce JNI transitions in audio threads
|
||||
|
||||
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
|
||||
|
||||
**Implementation:**
|
||||
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
|
||||
- Audio threads write/read directly to the buffer (no JNI call per frame)
|
||||
- Rust reads/writes from the same memory region
|
||||
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
|
||||
|
||||
### Option C: Use Android's Oboe (AAudio) natively from Rust
|
||||
|
||||
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
|
||||
|
||||
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
|
||||
|
||||
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
|
||||
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
|
||||
|
||||
### Option D: Pin audio thread objects to prevent GC movement
|
||||
|
||||
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
|
||||
|
||||
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
|
||||
|
||||
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
|
||||
|
||||
## Data Files
|
||||
|
||||
- Logcat from Nothing A059 (Android 16, API 36)
|
||||
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
|
||||
- Both SIGBUS/BUS_ADRERR, both in ART internal frames
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user