Compare commits
150 Commits
1d33f3ed4e
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c17317ea | ||
|
|
5799d18aee | ||
|
|
46c9ee1be3 | ||
|
|
b53eae9192 | ||
|
|
a3f54566d4 | ||
|
|
76e9fe5e43 | ||
|
|
b0a89d4f39 | ||
|
|
abc96e8887 | ||
|
|
3a6ae61f8d | ||
|
|
4c536d256b | ||
|
|
b0ec9ff4ab | ||
|
|
5855533a39 | ||
|
|
ed09c2e8cc | ||
|
|
f44306cc17 | ||
|
|
0b821585ab | ||
|
|
faec332a8c | ||
|
|
fe9ae276dc | ||
|
|
4fbf6770c4 | ||
|
|
30a893a73f | ||
|
|
d46f3b1deb | ||
|
|
0d3f0d4dcb | ||
|
|
c184d5e1f3 | ||
|
|
5d8e743cbf | ||
|
|
6694aebfd9 | ||
|
|
d27e85ecf2 | ||
|
|
39ac181d63 | ||
|
|
3351cb6473 | ||
|
|
54a4d91f3e | ||
|
|
3b962bd4cb | ||
|
|
1118eac752 | ||
|
|
f935bd69cd | ||
|
|
1c684f6b47 | ||
|
|
c92db7e9b7 | ||
|
|
c3bd657224 | ||
|
|
8b79cdc6fc | ||
|
|
2eab56beec | ||
|
|
7dadc1ddd6 | ||
|
|
be0441295a | ||
|
|
b9f4e7f102 | ||
|
|
28f4a0fb6f | ||
|
|
3d76acf528 | ||
|
|
f4b5996bdf | ||
|
|
fc721c4217 | ||
|
|
5c24adf1c1 | ||
|
|
8dbda3e052 | ||
|
|
c8a3aaacb6 | ||
|
|
54cb6c3b71 | ||
|
|
a3ebf5616f | ||
|
|
ff6d0444c0 | ||
|
|
8080713098 | ||
|
|
e813362395 | ||
|
|
d52b8befd6 | ||
|
|
0abecf7fd8 | ||
|
|
f4cc3b1a6b | ||
|
|
af4c89f5f0 | ||
|
|
406461d460 | ||
|
|
7064f484af | ||
|
|
1d2222a25a | ||
|
|
270e139f20 | ||
|
|
d9b2e0fd53 | ||
|
|
898c1ea32b | ||
|
|
b00db5dfdc | ||
|
|
bc8bb3d790 | ||
|
|
ea51d068e6 | ||
|
|
7271942c6a | ||
|
|
da84ed332c | ||
|
|
e50925e05a | ||
|
|
6be36e43c2 | ||
|
|
2f2720802d | ||
|
|
087bfd2335 | ||
|
|
0a05e62c7f | ||
|
|
b97f32ce46 | ||
|
|
d66d583583 | ||
|
|
d06cf66538 | ||
|
|
c8bcc5c974 | ||
|
|
760126b6ab | ||
|
|
53f8bf8fff | ||
|
|
b3cdad0c75 | ||
|
|
fa3c7f1cef | ||
|
|
68b56d9172 | ||
|
|
7973c8c6a3 | ||
|
|
3e9539e5da | ||
|
|
a1ccb3f390 | ||
|
|
7751439e2b | ||
|
|
20bc290c18 | ||
|
|
a8dc350a65 | ||
|
|
00fa109f07 | ||
|
|
1e40dec468 | ||
|
|
aecef0905d | ||
|
|
18f7faa279 | ||
|
|
eeb85aeac2 | ||
|
|
00b405aa87 | ||
|
|
d09e21965e | ||
|
|
97bcc79f9b | ||
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 | ||
|
|
5e93cb74f2 | ||
|
|
b56b4a759c | ||
|
|
6f99841cc7 | ||
|
|
3b0811ce2e | ||
|
|
9eed94850d | ||
|
|
5e9718aeb2 | ||
|
|
3093933602 | ||
|
|
4c6c909732 | ||
|
|
33fab9a049 | ||
|
|
31d2306915 | ||
|
|
4af7c5f94c | ||
|
|
6597b5bd86 | ||
|
|
ae9d8526dd | ||
|
|
4d54b6f9e4 | ||
|
|
2b3bdae440 | ||
|
|
20922455bd | ||
|
|
e6564bab57 | ||
|
|
aebf9156c0 | ||
|
|
9bbaec6b35 | ||
|
|
dc66b60d18 | ||
|
|
a9c4260b4e | ||
|
|
7eb136fcb3 | ||
|
|
550a124972 | ||
|
|
0835c36d0f | ||
|
|
6228ab32c1 | ||
|
|
bd258f432a | ||
|
|
8bf073aa80 | ||
|
|
72e834b45e | ||
|
|
673ffd498c | ||
|
|
2d4b8eebd5 | ||
|
|
a23d9f5e41 | ||
|
|
b3e56ecbd8 | ||
|
|
2fa07286c3 | ||
|
|
bf91cf25bd | ||
|
|
81c756c076 | ||
|
|
af85a49e86 | ||
|
|
bae03365da | ||
|
|
9d9ce4706d | ||
|
|
9098e28a1f | ||
|
|
f6d51fce61 | ||
|
|
a8dd0c2f57 | ||
|
|
64566e9acb | ||
|
|
10eb19cd24 | ||
|
|
778f4dd428 | ||
|
|
622fdee51f | ||
|
|
b204213a01 | ||
|
|
e751af7e38 | ||
|
|
8d5f6fe044 | ||
|
|
780309fede | ||
|
|
73ebcdd869 | ||
|
|
e7b1c3372a | ||
|
|
26e9c55f1f |
72
.agents/skills/caveman/SKILL.md
Normal file
72
.agents/skills/caveman/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[target.aarch64-linux-android]
|
||||||
|
linker = "aarch64-linux-android26-clang"
|
||||||
|
|
||||||
|
[target.armv7-linux-androideabi]
|
||||||
|
linker = "armv7a-linux-androideabi26-clang"
|
||||||
@@ -2,187 +2,57 @@ name: Build Release Binaries
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feat/*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '.gitea/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
targets:
|
|
||||||
description: 'Targets to build (comma-separated: amd64,arm64,armv7,mac-arm64)'
|
|
||||||
required: false
|
|
||||||
default: 'amd64'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Always builds on push tags. On manual dispatch, reads inputs.
|
|
||||||
build-amd64:
|
build-amd64:
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' ||
|
|
||||||
contains(github.event.inputs.targets, 'amd64')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: rust:1-bookworm
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Init submodules
|
||||||
run: apt-get update && apt-get install -y cmake pkg-config libasound2-dev
|
|
||||||
|
|
||||||
- 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 headless binaries
|
|
||||||
run: cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
|
||||||
|
|
||||||
- name: Build audio client
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --bin wzp-client --features audio
|
git config --global url."https://git.manko.yoga/".insteadOf "ssh://git@git.manko.yoga:222/"
|
||||||
cp target/release/wzp-client target/release/wzp-client-audio
|
git submodule update --init --recursive
|
||||||
cargo build --release --bin wzp-client
|
|
||||||
|
- 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: Build relay + tools
|
||||||
|
run: |
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --workspace --lib
|
|
||||||
|
|
||||||
- name: Package
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/wzp-linux-amd64
|
source "$HOME/.cargo/env"
|
||||||
cp target/release/wzp-relay dist/wzp-linux-amd64/
|
cargo test --workspace --lib
|
||||||
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
|
- name: Upload to rustypaste
|
||||||
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:
|
env:
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
PASTE_AUTH: ${{ secrets.PASTE_AUTH }}
|
||||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
PASTE_URL: ${{ secrets.PASTE_URL }}
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --target aarch64-unknown-linux-gnu \
|
tar czf /tmp/wzp-linux-amd64.tar.gz \
|
||||||
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
-C target/release wzp-relay wzp-client wzp-web wzp-bench
|
||||||
|
ls -lh /tmp/wzp-linux-amd64.tar.gz
|
||||||
- name: Package
|
LINK=$(curl -sF "file=@/tmp/wzp-linux-amd64.tar.gz" \
|
||||||
run: |
|
-H "Authorization: ${PASTE_AUTH}" \
|
||||||
mkdir -p dist/wzp-linux-arm64
|
"https://${PASTE_URL}")
|
||||||
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
|
echo "Download: ${LINK}"
|
||||||
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
|
|
||||||
|
|||||||
43
.gitea/workflows/mirror-github.yml
Normal file
43
.gitea/workflows/mirror-github.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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,3 +4,28 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.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,6 +43,12 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_log-sys"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -841,6 +847,27 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -1754,6 +1781,15 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1796,6 +1832,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -2074,6 +2119,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "os_str_bytes"
|
name = "os_str_bytes"
|
||||||
version = "6.6.1"
|
version = "6.6.1"
|
||||||
@@ -2414,6 +2465,17 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -3325,6 +3387,17 @@ dependencies = [
|
|||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
@@ -3363,10 +3436,14 @@ version = "0.3.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
@@ -4179,6 +4256,32 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wzp-android"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"cc",
|
||||||
|
"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]]
|
[[package]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4267,6 +4370,8 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -4274,6 +4379,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -4293,30 +4399,18 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"hkdf",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wzp-proto",
|
"wzp-proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wzp-wasm"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chacha20poly1305",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"hkdf",
|
|
||||||
"js-sys",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"raptorq",
|
|
||||||
"sha2",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"x25519-dalek",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wzp-web"
|
name = "wzp-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-wasm",
|
"crates/wzp-android",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -40,7 +40,7 @@ codec2 = "0.3"
|
|||||||
|
|
||||||
# Crypto
|
# Crypto
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
6
android/.gitignore
vendored
Normal file
6
android/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
app/src/main/jniLibs/
|
||||||
|
local.properties
|
||||||
|
keystore/*.jks
|
||||||
BIN
android/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so
Executable file
BIN
android/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so
Executable file
Binary file not shown.
85
android/app/build.gradle.kts
Normal file
85
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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
Normal file
9
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# WZPhone ProGuard rules
|
||||||
|
|
||||||
|
# Keep JNI native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the WZP engine bridge class
|
||||||
|
-keep class com.wzp.phone.engine.** { *; }
|
||||||
43
android/app/src/main/AndroidManifest.xml
Normal file
43
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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>
|
||||||
0
android/app/src/main/java/com/wzp/.gitkeep
Normal file
0
android/app/src/main/java/com/wzp/.gitkeep
Normal file
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
359
android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
Normal file
359
android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
203
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
203
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
android/app/src/main/java/com/wzp/debug/DebugReporter.kt
Normal file
198
android/app/src/main/java/com/wzp/debug/DebugReporter.kt
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
120
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
120
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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" }
|
||||||
|
}
|
||||||
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
232
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
232
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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 = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
172
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
172
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
149
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
764
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
764
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
1062
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
1062
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
567
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
567
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
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") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
4
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="debug" path="." />
|
||||||
|
</paths>
|
||||||
4
android/build.gradle.kts
Normal file
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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
Executable file
5
android/gradlew
vendored
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/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 "$@"
|
||||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "WZPhone"
|
||||||
|
include(":app")
|
||||||
34
crates/wzp-android/Cargo.toml
Normal file
34
crates/wzp-android/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[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"
|
||||||
154
crates/wzp-android/build.rs
Normal file
154
crates/wzp-android/build.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/wzp-android/cpp/getauxval_fix.c
Normal file
21
crates/wzp-android/cpp/getauxval_fix.c
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 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
|
||||||
278
crates/wzp-android/cpp/oboe_bridge.cpp
Normal file
278
crates/wzp-android/cpp/oboe_bridge.cpp
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// 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__
|
||||||
43
crates/wzp-android/cpp/oboe_bridge.h
Normal file
43
crates/wzp-android/cpp/oboe_bridge.h
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#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
|
||||||
27
crates/wzp-android/cpp/oboe_stub.cpp
Normal file
27
crates/wzp-android/cpp/oboe_stub.cpp
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
424
crates/wzp-android/src/audio_android.rs
Normal file
424
crates/wzp-android/src/audio_android.rs
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/wzp-android/src/audio_ring.rs
Normal file
128
crates/wzp-android/src/audio_ring.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/wzp-android/src/commands.rs
Normal file
24
crates/wzp-android/src/commands.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! 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 },
|
||||||
|
}
|
||||||
897
crates/wzp-android/src/engine.rs
Normal file
897
crates/wzp-android/src/engine.rs
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
511
crates/wzp-android/src/jni_bridge.rs
Normal file
511
crates/wzp-android/src/jni_bridge.rs
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
//! 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) };
|
||||||
|
}
|
||||||
19
crates/wzp-android/src/lib.rs
Normal file
19
crates/wzp-android/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! 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;
|
||||||
262
crates/wzp-android/src/pipeline.rs
Normal file
262
crates/wzp-android/src/pipeline.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
288
crates/wzp-android/src/signal_mgr.rs
Normal file
288
crates/wzp-android/src/signal_mgr.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/wzp-android/src/stats.rs
Normal file
101
crates/wzp-android/src/stats.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//! 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 bytes::Bytes;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||||
@@ -207,6 +207,10 @@ pub struct CallEncoder {
|
|||||||
frame_in_block: u8,
|
frame_in_block: u8,
|
||||||
/// Timestamp counter (ms).
|
/// Timestamp counter (ms).
|
||||||
timestamp_ms: u32,
|
timestamp_ms: u32,
|
||||||
|
/// Acoustic echo canceller (removes speaker echo from mic signal).
|
||||||
|
aec: EchoCanceller,
|
||||||
|
/// Automatic gain control (normalises mic level).
|
||||||
|
agc: AutoGainControl,
|
||||||
/// Silence detector for suppression.
|
/// Silence detector for suppression.
|
||||||
silence_detector: SilenceDetector,
|
silence_detector: SilenceDetector,
|
||||||
/// Whether silence suppression is enabled.
|
/// Whether silence suppression is enabled.
|
||||||
@@ -237,6 +241,8 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
|
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||||
|
agc: AutoGainControl::new(),
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
config.silence_hangover_frames,
|
config.silence_hangover_frames,
|
||||||
@@ -274,15 +280,21 @@ impl CallEncoder {
|
|||||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||||
/// Output: one or more MediaPackets to send.
|
/// Output: one or more MediaPackets to send.
|
||||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||||
// Noise suppression: denoise the PCM before silence detection and encoding.
|
// Copy PCM into a mutable buffer for the processing pipeline.
|
||||||
let pcm = if self.denoiser.is_enabled() {
|
let mut pcm_buf = pcm.to_vec();
|
||||||
let mut buf = pcm.to_vec();
|
|
||||||
self.denoiser.process(&mut buf);
|
// Step 1: Echo cancellation (far-end reference must have been fed already).
|
||||||
buf
|
self.aec.process_frame(&mut pcm_buf);
|
||||||
} else {
|
|
||||||
pcm.to_vec()
|
// Step 2: Automatic gain control (normalise mic level).
|
||||||
};
|
self.agc.process_frame(&mut pcm_buf);
|
||||||
let pcm = &pcm[..];
|
|
||||||
|
// Step 3: Noise suppression (RNNoise).
|
||||||
|
if self.denoiser.is_enabled() {
|
||||||
|
self.denoiser.process(&mut pcm_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pcm = &pcm_buf[..];
|
||||||
|
|
||||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||||
@@ -400,6 +412,24 @@ impl CallEncoder {
|
|||||||
self.frame_in_block = 0;
|
self.frame_in_block = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed decoded playout audio as the echo reference signal.
|
||||||
|
///
|
||||||
|
/// Must be called with each decoded frame BEFORE the corresponding
|
||||||
|
/// microphone frame is processed.
|
||||||
|
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
||||||
|
self.aec.feed_farend(farend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable acoustic echo cancellation.
|
||||||
|
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||||
|
self.aec.set_enabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable automatic gain control.
|
||||||
|
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||||
|
self.agc.set_enabled(enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages the recv/decode side of a call.
|
/// Manages the recv/decode side of a call.
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ struct CliArgs {
|
|||||||
room: Option<String>,
|
room: Option<String>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
_metrics_file: 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 {
|
impl CliArgs {
|
||||||
@@ -88,12 +93,20 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut room = None;
|
let mut room = None;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
let mut metrics_file = None;
|
let mut metrics_file = None;
|
||||||
|
let mut version_check = false;
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
|
let mut signal = false;
|
||||||
|
let mut call_target = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
"--live" => live = true,
|
"--live" => live = true,
|
||||||
|
"--signal" => signal = true,
|
||||||
|
"--call" => {
|
||||||
|
i += 1;
|
||||||
|
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||||
|
}
|
||||||
"--send-tone" => {
|
"--send-tone" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
send_tone_secs = Some(
|
send_tone_secs = Some(
|
||||||
@@ -169,6 +182,7 @@ fn parse_args() -> CliArgs {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
|
"--version-check" => { version_check = true; }
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -221,6 +235,9 @@ fn parse_args() -> CliArgs {
|
|||||||
room,
|
room,
|
||||||
token,
|
token,
|
||||||
_metrics_file: metrics_file,
|
_metrics_file: metrics_file,
|
||||||
|
version_check,
|
||||||
|
signal,
|
||||||
|
call_target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +256,32 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
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();
|
let seed = cli.resolve_seed();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -250,12 +293,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"WarzonePhone client"
|
"WarzonePhone client"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hash room name for SNI privacy (or "default" if none specified)
|
// Use raw room name as SNI (consistent with Android + Desktop clients for federation)
|
||||||
let sni = match &cli.room {
|
let sni = match &cli.room {
|
||||||
Some(name) => {
|
Some(name) => {
|
||||||
let hashed = wzp_crypto::hash_room_name(name);
|
info!(room = %name, "using room name as SNI");
|
||||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
name.clone()
|
||||||
hashed
|
|
||||||
}
|
}
|
||||||
None => "default".to_string(),
|
None => "default".to_string(),
|
||||||
};
|
};
|
||||||
@@ -274,6 +316,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
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)
|
// Send auth token if provided (relay with --auth-url expects this first)
|
||||||
if let Some(ref token) = cli.token {
|
if let Some(ref token) = cli.token {
|
||||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||||
@@ -287,6 +349,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||||
&*transport,
|
&*transport,
|
||||||
&seed.0,
|
&seed.0,
|
||||||
|
None, // alias — desktop client doesn't set one yet
|
||||||
).await?;
|
).await?;
|
||||||
info!("crypto handshake complete");
|
info!("crypto handshake complete");
|
||||||
|
|
||||||
@@ -623,3 +686,195 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
|||||||
info!("done");
|
info!("done");
|
||||||
Ok(())
|
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,12 +109,23 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_roundtrip() {
|
fn payload_roundtrip() {
|
||||||
@@ -123,6 +134,7 @@ mod tests {
|
|||||||
ephemeral_pub: [2u8; 32],
|
ephemeral_pub: [2u8; 32],
|
||||||
signature: vec![3u8; 64],
|
signature: vec![3u8; 64],
|
||||||
supported_profiles: vec![QualityProfile::GOOD],
|
supported_profiles: vec![QualityProfile::GOOD],
|
||||||
|
alias: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||||
@@ -140,6 +152,7 @@ mod tests {
|
|||||||
ephemeral_pub: [0; 32],
|
ephemeral_pub: [0; 32],
|
||||||
signature: vec![],
|
signature: vec![],
|
||||||
supported_profiles: vec![],
|
supported_profiles: vec![],
|
||||||
|
alias: None,
|
||||||
};
|
};
|
||||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
|||||||
pub async fn perform_handshake(
|
pub async fn perform_handshake(
|
||||||
transport: &dyn MediaTransport,
|
transport: &dyn MediaTransport,
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
|
alias: Option<&str>,
|
||||||
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||||
// 1. Create key exchange from identity seed
|
// 1. Create key exchange from identity seed
|
||||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||||
@@ -37,10 +38,14 @@ pub async fn perform_handshake(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
|
QualityProfile::STUDIO_64K,
|
||||||
|
QualityProfile::STUDIO_48K,
|
||||||
|
QualityProfile::STUDIO_32K,
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
],
|
],
|
||||||
|
alias: alias.map(|s| s.to_string()),
|
||||||
};
|
};
|
||||||
transport.send_signal(&offer).await?;
|
transport.send_signal(&offer).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
|
|||||||
use crate::codec2_enc::Codec2Encoder;
|
use crate::codec2_enc::Codec2Encoder;
|
||||||
use crate::opus_dec::OpusDecoder;
|
use crate::opus_dec::OpusDecoder;
|
||||||
use crate::opus_enc::OpusEncoder;
|
use crate::opus_enc::OpusEncoder;
|
||||||
use crate::resample;
|
use crate::resample::{Downsampler48to8, Upsampler8to48};
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ pub struct AdaptiveEncoder {
|
|||||||
opus: OpusEncoder,
|
opus: OpusEncoder,
|
||||||
codec2: Codec2Encoder,
|
codec2: Codec2Encoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
|
downsampler: Downsampler48to8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveEncoder {
|
impl AdaptiveEncoder {
|
||||||
@@ -66,6 +67,7 @@ impl AdaptiveEncoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
|
downsampler: Downsampler48to8::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ impl AudioEncoder for AdaptiveEncoder {
|
|||||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
if is_codec2(self.active) {
|
if is_codec2(self.active) {
|
||||||
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||||
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
let pcm_8k = self.downsampler.process(pcm);
|
||||||
self.codec2.encode(&pcm_8k, out)
|
self.codec2.encode(&pcm_8k, out)
|
||||||
} else {
|
} else {
|
||||||
self.opus.encode(pcm, out)
|
self.opus.encode(pcm, out)
|
||||||
@@ -126,6 +128,7 @@ pub struct AdaptiveDecoder {
|
|||||||
opus: OpusDecoder,
|
opus: OpusDecoder,
|
||||||
codec2: Codec2Decoder,
|
codec2: Codec2Decoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
|
upsampler: Upsampler8to48,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveDecoder {
|
impl AdaptiveDecoder {
|
||||||
@@ -138,6 +141,7 @@ impl AdaptiveDecoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
|
upsampler: Upsampler8to48::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +153,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
@@ -163,7 +167,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
|
|||||||
228
crates/wzp-codec/src/aec.rs
Normal file
228
crates/wzp-codec/src/aec.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//! 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
219
crates/wzp-codec/src/agc.rs
Normal file
219
crates/wzp-codec/src/agc.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! 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,6 +10,8 @@
|
|||||||
//! trait-object encoders/decoders that handle adaptive switching internally.
|
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||||
|
|
||||||
pub mod adaptive;
|
pub mod adaptive;
|
||||||
|
pub mod aec;
|
||||||
|
pub mod agc;
|
||||||
pub mod codec2_dec;
|
pub mod codec2_dec;
|
||||||
pub mod codec2_enc;
|
pub mod codec2_enc;
|
||||||
pub mod denoise;
|
pub mod denoise;
|
||||||
@@ -19,6 +21,8 @@ pub mod resample;
|
|||||||
pub mod silence;
|
pub mod silence;
|
||||||
|
|
||||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||||
|
pub use aec::EchoCanceller;
|
||||||
|
pub use agc::AutoGainControl;
|
||||||
pub use denoise::NoiseSupressor;
|
pub use denoise::NoiseSupressor;
|
||||||
pub use silence::{ComfortNoise, SilenceDetector};
|
pub use silence::{ComfortNoise, SilenceDetector};
|
||||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ impl OpusEncoder {
|
|||||||
.set_signal(Signal::Voice)
|
.set_signal(Signal::Voice)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
|
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||||
|
enc.inner
|
||||||
|
.set_complexity(7)
|
||||||
|
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||||
|
|
||||||
Ok(enc)
|
Ok(enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +61,21 @@ impl OpusEncoder {
|
|||||||
pub fn frame_samples(&self) -> usize {
|
pub fn frame_samples(&self) -> usize {
|
||||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the encoder complexity (0-10). Higher values produce better quality
|
||||||
|
/// at the cost of more CPU. Default is 7.
|
||||||
|
pub fn set_complexity(&mut self, complexity: i32) {
|
||||||
|
let c = (complexity as u8).min(10);
|
||||||
|
let _ = self.inner.set_complexity(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||||
|
///
|
||||||
|
/// Higher values cause the encoder to use more redundancy to survive
|
||||||
|
/// packet loss, at the expense of slightly higher bitrate.
|
||||||
|
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||||
|
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioEncoder for OpusEncoder {
|
impl AudioEncoder for OpusEncoder {
|
||||||
@@ -80,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
self.apply_bitrate(profile.codec)?;
|
self.apply_bitrate(profile.codec)?;
|
||||||
|
|||||||
@@ -1,55 +1,258 @@
|
|||||||
//! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
|
//! Windowed-sinc FIR resampler for 48 kHz <-> 8 kHz conversion.
|
||||||
//!
|
//!
|
||||||
//! These are basic implementations suitable for voice. For higher quality,
|
//! Provides both stateless free functions (backward-compatible) and stateful
|
||||||
//! replace with the `rubato` crate later.
|
//! `Downsampler48to8` / `Upsampler8to48` structs that maintain overlap history
|
||||||
|
//! between frames for glitch-free streaming.
|
||||||
|
|
||||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
|
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.
|
||||||
|
const RATIO: usize = 6;
|
||||||
|
|
||||||
|
// ─── Kaiser window helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Zeroth-order modified Bessel function of the first kind, I₀(x).
|
||||||
///
|
///
|
||||||
/// Each output sample is the average of 6 consecutive input samples,
|
/// Computed via the well-known power-series expansion, converging rapidly
|
||||||
/// providing basic anti-aliasing via a box filter.
|
/// for the moderate values of x used in Kaiser window design.
|
||||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
fn bessel_i0(x: f64) -> f64 {
|
||||||
const RATIO: usize = 6;
|
let mut sum = 1.0f64;
|
||||||
let out_len = input.len() / RATIO;
|
let mut term = 1.0f64;
|
||||||
let mut output = Vec::with_capacity(out_len);
|
let half_x = x / 2.0;
|
||||||
|
for k in 1..=25 {
|
||||||
for chunk in input.chunks_exact(RATIO) {
|
term *= (half_x / k as f64) * (half_x / k as f64);
|
||||||
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
|
sum += term;
|
||||||
output.push((sum / RATIO as i32) as i16);
|
if term < 1e-12 * sum {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
sum
|
||||||
output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
|
/// Build a windowed-sinc low-pass FIR kernel.
|
||||||
///
|
///
|
||||||
/// Linearly interpolates between each pair of input samples to produce
|
/// Returns `FIR_TAPS` coefficients normalised so that the DC gain is exactly 1.0.
|
||||||
/// 6 output samples per input sample.
|
fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
||||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
let mut kernel = [0.0f64; FIR_TAPS];
|
||||||
const RATIO: usize = 6;
|
let m = (FIR_TAPS - 1) as f64;
|
||||||
if input.is_empty() {
|
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
||||||
return Vec::new();
|
let beta_denom = bessel_i0(KAISER_BETA);
|
||||||
}
|
|
||||||
|
|
||||||
let out_len = input.len() * RATIO;
|
for i in 0..FIR_TAPS {
|
||||||
let mut output = Vec::with_capacity(out_len);
|
// Sinc
|
||||||
|
let n = i as f64 - m / 2.0;
|
||||||
for i in 0..input.len() {
|
let sinc = if n.abs() < 1e-12 {
|
||||||
let current = input[i] as i32;
|
2.0 * fc
|
||||||
let next = if i + 1 < input.len() {
|
|
||||||
input[i + 1] as i32
|
|
||||||
} else {
|
} else {
|
||||||
current // hold last sample
|
(2.0 * PI * fc * n).sin() / (PI * n)
|
||||||
};
|
};
|
||||||
|
|
||||||
for j in 0..RATIO {
|
// Kaiser window
|
||||||
let interp = current + (next - current) * j as i32 / RATIO as i32;
|
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
||||||
output.push(interp as i16);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Downsampler48to8 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stateful Upsampler 8→48 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Stateful FIR upsampler from 8 kHz to 48 kHz.
|
||||||
|
///
|
||||||
|
/// Inserts zeros between input samples (zero-stuffing), then applies the
|
||||||
|
/// low-pass FIR to remove imaging, with gain compensation of `RATIO`.
|
||||||
|
pub struct Upsampler8to48 {
|
||||||
|
kernel: [f64; FIR_TAPS],
|
||||||
|
history: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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..]);
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Upsampler8to48 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backward-compatible free functions ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with FIR anti-alias filter).
|
||||||
|
///
|
||||||
|
/// This is a convenience wrapper that creates a temporary [`Downsampler48to8`].
|
||||||
|
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||||
|
/// frames.
|
||||||
|
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||||
|
let mut ds = Downsampler48to8::new();
|
||||||
|
ds.process(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with FIR imaging filter).
|
||||||
|
///
|
||||||
|
/// This is a convenience wrapper that creates a temporary [`Upsampler8to48`].
|
||||||
|
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||||
|
/// frames.
|
||||||
|
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||||
|
let mut us = Upsampler8to48::new();
|
||||||
|
us.process(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -66,12 +269,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dc_signal_preserved() {
|
fn dc_signal_preserved() {
|
||||||
// A constant signal should survive resampling
|
// A constant signal should survive resampling (approximately).
|
||||||
let input = vec![1000i16; 960];
|
let input = vec![1000i16; 960];
|
||||||
let down = resample_48k_to_8k(&input);
|
let down = resample_48k_to_8k(&input);
|
||||||
assert!(down.iter().all(|&s| s == 1000));
|
// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let up = resample_8k_to_48k(&down);
|
let up = resample_8k_to_48k(&down);
|
||||||
assert!(up.iter().all(|&s| s == 1000));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -79,4 +298,40 @@ mod tests {
|
|||||||
assert!(resample_48k_to_8k(&[]).is_empty());
|
assert!(resample_48k_to_8k(&[]).is_empty());
|
||||||
assert!(resample_8k_to_48k(&[]).is_empty());
|
assert!(resample_8k_to_48k(&[]).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stateful_downsampler_produces_correct_length() {
|
||||||
|
let mut ds = Downsampler48to8::new();
|
||||||
|
let out = ds.process(&vec![0i16; 960]);
|
||||||
|
assert_eq!(out.len(), 160);
|
||||||
|
let out2 = ds.process(&vec![0i16; 960]);
|
||||||
|
assert_eq!(out2.len(), 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stateful_upsampler_produces_correct_length() {
|
||||||
|
let mut us = Upsampler8to48::new();
|
||||||
|
let out = us.process(&vec![0i16; 160]);
|
||||||
|
assert_eq!(out.len(), 960);
|
||||||
|
let out2 = us.process(&vec![0i16; 160]);
|
||||||
|
assert_eq!(out2.len(), 960);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fir_kernel_has_unity_dc_gain() {
|
||||||
|
let kernel = build_fir_kernel();
|
||||||
|
let sum: f64 = kernel.iter().sum();
|
||||||
|
assert!(
|
||||||
|
(sum - 1.0).abs() < 1e-10,
|
||||||
|
"FIR kernel DC gain should be 1.0, got {sum}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bessel_i0_known_values() {
|
||||||
|
// I₀(0) = 1
|
||||||
|
assert!((bessel_i0(0.0) - 1.0).abs() < 1e-12);
|
||||||
|
// I₀(1) ≈ 1.2660658
|
||||||
|
assert!((bessel_i0(1.0) - 1.2660658).abs() < 1e-5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,18 @@ impl KeyExchange for WarzoneKeyExchange {
|
|||||||
hk.expand(b"warzone-session-key", &mut session_key)
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
.expect("HKDF expand for session key should not fail");
|
.expect("HKDF expand for session key should not fail");
|
||||||
|
|
||||||
Ok(Box::new(ChaChaSession::new(session_key)))
|
// 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +222,47 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(&decrypted, plaintext);
|
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,6 +26,8 @@ pub struct ChaChaSession {
|
|||||||
rekey_mgr: RekeyManager,
|
rekey_mgr: RekeyManager,
|
||||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||||
pending_rekey_secret: Option<StaticSecret>,
|
pending_rekey_secret: Option<StaticSecret>,
|
||||||
|
/// Short Authentication String (4-digit code for verbal verification).
|
||||||
|
sas_code: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChaChaSession {
|
impl ChaChaSession {
|
||||||
@@ -46,9 +48,15 @@ impl ChaChaSession {
|
|||||||
recv_seq: 0,
|
recv_seq: 0,
|
||||||
rekey_mgr: RekeyManager::new(shared_secret),
|
rekey_mgr: RekeyManager::new(shared_secret),
|
||||||
pending_rekey_secret: None,
|
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).
|
/// Install a new key (after rekeying).
|
||||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
@@ -136,6 +144,10 @@ impl CryptoSession for ChaChaSession {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sas_code(&self) -> Option<u32> {
|
||||||
|
self.sas_code
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||||
use wzp_proto::error::FecError;
|
use wzp_proto::error::FecError;
|
||||||
@@ -9,6 +10,9 @@ use wzp_proto::FecDecoder;
|
|||||||
/// Length prefix size (u16 little-endian), must match encoder.
|
/// Length prefix size (u16 little-endian), must match encoder.
|
||||||
const LEN_PREFIX: usize = 2;
|
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.
|
/// State for one in-flight block being decoded.
|
||||||
struct BlockState {
|
struct BlockState {
|
||||||
/// Number of source symbols expected.
|
/// Number of source symbols expected.
|
||||||
@@ -21,6 +25,8 @@ struct BlockState {
|
|||||||
decoded: bool,
|
decoded: bool,
|
||||||
/// Cached decoded result.
|
/// Cached decoded result.
|
||||||
result: Option<Vec<Vec<u8>>>,
|
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.
|
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||||
@@ -58,6 +64,7 @@ impl RaptorQFecDecoder {
|
|||||||
symbol_size: self.symbol_size,
|
symbol_size: self.symbol_size,
|
||||||
decoded: false,
|
decoded: false,
|
||||||
result: None,
|
result: None,
|
||||||
|
decoded_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,8 +81,20 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
let block = self.get_or_create_block(block_id);
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
if block.decoded {
|
if block.decoded {
|
||||||
// Already decoded, ignore additional symbols.
|
// If the block was decoded recently, skip (normal duplicate).
|
||||||
return Ok(());
|
// 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 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||||
@@ -132,6 +151,7 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
|
|
||||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||||
block.decoded = true;
|
block.decoded = true;
|
||||||
|
block.decoded_at = Some(Instant::now());
|
||||||
block.result = Some(frames.clone());
|
block.result = Some(frames.clone());
|
||||||
Ok(Some(frames))
|
Ok(Some(frames))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub enum CodecId {
|
|||||||
Codec2_1200 = 4,
|
Codec2_1200 = 4,
|
||||||
/// Comfort noise descriptor (silence suppression)
|
/// Comfort noise descriptor (silence suppression)
|
||||||
ComfortNoise = 5,
|
ComfortNoise = 5,
|
||||||
|
/// Opus at 32kbps (studio low)
|
||||||
|
Opus32k = 6,
|
||||||
|
/// Opus at 48kbps (studio)
|
||||||
|
Opus48k = 7,
|
||||||
|
/// Opus at 64kbps (studio high)
|
||||||
|
Opus64k = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecId {
|
impl CodecId {
|
||||||
@@ -27,6 +33,9 @@ impl CodecId {
|
|||||||
Self::Opus24k => 24_000,
|
Self::Opus24k => 24_000,
|
||||||
Self::Opus16k => 16_000,
|
Self::Opus16k => 16_000,
|
||||||
Self::Opus6k => 6_000,
|
Self::Opus6k => 6_000,
|
||||||
|
Self::Opus32k => 32_000,
|
||||||
|
Self::Opus48k => 48_000,
|
||||||
|
Self::Opus64k => 64_000,
|
||||||
Self::Codec2_3200 => 3_200,
|
Self::Codec2_3200 => 3_200,
|
||||||
Self::Codec2_1200 => 1_200,
|
Self::Codec2_1200 => 1_200,
|
||||||
Self::ComfortNoise => 0,
|
Self::ComfortNoise => 0,
|
||||||
@@ -36,8 +45,7 @@ impl CodecId {
|
|||||||
/// Preferred frame duration in milliseconds.
|
/// Preferred frame duration in milliseconds.
|
||||||
pub const fn frame_duration_ms(self) -> u8 {
|
pub const fn frame_duration_ms(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k => 20,
|
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||||
Self::Opus16k => 20,
|
|
||||||
Self::Opus6k => 40,
|
Self::Opus6k => 40,
|
||||||
Self::Codec2_3200 => 20,
|
Self::Codec2_3200 => 20,
|
||||||
Self::Codec2_1200 => 40,
|
Self::Codec2_1200 => 40,
|
||||||
@@ -48,7 +56,8 @@ impl CodecId {
|
|||||||
/// Sample rate expected by this codec.
|
/// Sample rate expected by this codec.
|
||||||
pub const fn sample_rate_hz(self) -> u32 {
|
pub const fn sample_rate_hz(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
||||||
|
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
||||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||||
Self::ComfortNoise => 48_000,
|
Self::ComfortNoise => 48_000,
|
||||||
}
|
}
|
||||||
@@ -63,6 +72,9 @@ impl CodecId {
|
|||||||
3 => Some(Self::Codec2_3200),
|
3 => Some(Self::Codec2_3200),
|
||||||
4 => Some(Self::Codec2_1200),
|
4 => Some(Self::Codec2_1200),
|
||||||
5 => Some(Self::ComfortNoise),
|
5 => Some(Self::ComfortNoise),
|
||||||
|
6 => Some(Self::Opus32k),
|
||||||
|
7 => Some(Self::Opus48k),
|
||||||
|
8 => Some(Self::Opus64k),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +83,12 @@ impl CodecId {
|
|||||||
pub const fn to_wire(self) -> u8 {
|
pub const fn to_wire(self) -> u8 {
|
||||||
self as 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.
|
/// Describes the complete quality configuration for a call session.
|
||||||
@@ -111,6 +129,30 @@ impl QualityProfile {
|
|||||||
frames_per_block: 8,
|
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.
|
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::packet::MediaPacket;
|
use crate::packet::MediaPacket;
|
||||||
|
|
||||||
@@ -20,19 +21,29 @@ pub struct AdaptivePlayoutDelay {
|
|||||||
max_delay: usize,
|
max_delay: usize,
|
||||||
/// Exponential moving average of inter-packet arrival jitter (ms).
|
/// Exponential moving average of inter-packet arrival jitter (ms).
|
||||||
jitter_ema: f64,
|
jitter_ema: f64,
|
||||||
/// EMA smoothing factor (0.0-1.0, lower = smoother).
|
/// EMA smoothing factor for jitter increases (fast reaction).
|
||||||
alpha: f64,
|
alpha_up: f64,
|
||||||
|
/// EMA smoothing factor for jitter decreases (slow decay).
|
||||||
|
alpha_down: f64,
|
||||||
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
||||||
last_arrival_ms: Option<u64>,
|
last_arrival_ms: Option<u64>,
|
||||||
/// Last packet expected timestamp.
|
/// Last packet expected timestamp.
|
||||||
last_expected_ms: Option<u64>,
|
last_expected_ms: Option<u64>,
|
||||||
|
/// Safety margin added to jitter-derived target (in packets).
|
||||||
|
safety_margin: f64,
|
||||||
|
/// Instant when a jitter spike was detected (handoff detection).
|
||||||
|
spike_detected_at: Option<Instant>,
|
||||||
|
/// Duration to hold max_delay after a spike is detected.
|
||||||
|
spike_cooldown: Duration,
|
||||||
|
/// Multiplier of jitter_ema that constitutes a spike.
|
||||||
|
spike_threshold_multiplier: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
||||||
const FRAME_DURATION_MS: f64 = 20.0;
|
const FRAME_DURATION_MS: f64 = 20.0;
|
||||||
/// Safety margin added to jitter-derived target (in packets).
|
/// Default safety margin in packets.
|
||||||
const SAFETY_MARGIN_PACKETS: f64 = 2.0;
|
const DEFAULT_SAFETY_MARGIN: f64 = 2.0;
|
||||||
/// Default EMA smoothing factor.
|
/// Default EMA smoothing factor (used for both up/down in non-mobile mode).
|
||||||
const DEFAULT_ALPHA: f64 = 0.05;
|
const DEFAULT_ALPHA: f64 = 0.05;
|
||||||
|
|
||||||
impl AdaptivePlayoutDelay {
|
impl AdaptivePlayoutDelay {
|
||||||
@@ -46,9 +57,14 @@ impl AdaptivePlayoutDelay {
|
|||||||
min_delay,
|
min_delay,
|
||||||
max_delay,
|
max_delay,
|
||||||
jitter_ema: 0.0,
|
jitter_ema: 0.0,
|
||||||
alpha: DEFAULT_ALPHA,
|
alpha_up: DEFAULT_ALPHA,
|
||||||
|
alpha_down: DEFAULT_ALPHA,
|
||||||
last_arrival_ms: None,
|
last_arrival_ms: None,
|
||||||
last_expected_ms: None,
|
last_expected_ms: None,
|
||||||
|
safety_margin: DEFAULT_SAFETY_MARGIN,
|
||||||
|
spike_detected_at: None,
|
||||||
|
spike_cooldown: Duration::from_secs(2),
|
||||||
|
spike_threshold_multiplier: 3.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +80,38 @@ impl AdaptivePlayoutDelay {
|
|||||||
let expected_delta = expected_ms as f64 - last_expected as f64;
|
let expected_delta = expected_ms as f64 - last_expected as f64;
|
||||||
let jitter = (actual_delta - expected_delta).abs();
|
let jitter = (actual_delta - expected_delta).abs();
|
||||||
|
|
||||||
// Update EMA
|
// Spike detection: check before EMA update
|
||||||
self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
|
if self.jitter_ema > 0.0
|
||||||
|
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
||||||
|
{
|
||||||
|
self.spike_detected_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
// Convert jitter estimate to target delay in packets
|
// Asymmetric EMA update
|
||||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
|
let alpha = if jitter > self.jitter_ema {
|
||||||
self.target_delay =
|
self.alpha_up
|
||||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
} 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;
|
||||||
|
self.target_delay =
|
||||||
|
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last_arrival_ms = Some(arrival_ms);
|
self.last_arrival_ms = Some(arrival_ms);
|
||||||
@@ -87,6 +128,28 @@ impl AdaptivePlayoutDelay {
|
|||||||
pub fn jitter_estimate_ms(&self) -> f64 {
|
pub fn jitter_estimate_ms(&self) -> f64 {
|
||||||
self.jitter_ema
|
self.jitter_ema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable mobile mode, adjusting parameters for cellular networks.
|
||||||
|
///
|
||||||
|
/// Mobile mode uses:
|
||||||
|
/// - Asymmetric alpha (fast up=0.3, slow down=0.02) for quicker spike detection
|
||||||
|
/// - Higher safety margin (3.0 packets) to absorb handoff jitter
|
||||||
|
/// - Spike detection with 2-second cooldown at 3x threshold
|
||||||
|
pub fn set_mobile_mode(&mut self, enabled: bool) {
|
||||||
|
if enabled {
|
||||||
|
self.safety_margin = 3.0;
|
||||||
|
self.alpha_up = 0.3;
|
||||||
|
self.alpha_down = 0.02;
|
||||||
|
self.spike_threshold_multiplier = 3.0;
|
||||||
|
self.spike_cooldown = Duration::from_secs(2);
|
||||||
|
} else {
|
||||||
|
self.safety_margin = DEFAULT_SAFETY_MARGIN;
|
||||||
|
self.alpha_up = DEFAULT_ALPHA;
|
||||||
|
self.alpha_down = DEFAULT_ALPHA;
|
||||||
|
self.spike_threshold_multiplier = 3.0;
|
||||||
|
self.spike_cooldown = Duration::from_secs(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -210,10 +273,21 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out)
|
// 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.
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
self.stats.packets_late += 1;
|
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||||
return;
|
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 we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
@@ -349,10 +423,21 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out)
|
// 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.
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
self.stats.packets_late += 1;
|
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||||
return;
|
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 we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
@@ -391,6 +476,11 @@ impl JitterBuffer {
|
|||||||
self.adaptive.as_ref()
|
self.adaptive.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a mutable reference to the adaptive playout delay estimator.
|
||||||
|
pub fn adaptive_delay_mut(&mut self) -> Option<&mut AdaptivePlayoutDelay> {
|
||||||
|
self.adaptive.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
/// Adjust target depth based on observed jitter.
|
/// Adjust target depth based on observed jitter.
|
||||||
pub fn set_target_depth(&mut self, depth: usize) {
|
pub fn set_target_depth(&mut self, depth: usize) {
|
||||||
self.target_depth = depth.min(self.max_depth);
|
self.target_depth = depth.min(self.max_depth);
|
||||||
@@ -720,4 +810,29 @@ mod tests {
|
|||||||
let ad = jb.adaptive_delay().unwrap();
|
let ad = jb.adaptive_delay().unwrap();
|
||||||
assert_eq!(ad.target_delay(), 3);
|
assert_eq!(ad.target_delay(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Mobile mode tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_mode_increases_safety_margin() {
|
||||||
|
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||||
|
apd.set_mobile_mode(true);
|
||||||
|
assert_eq!(apd.safety_margin, 3.0);
|
||||||
|
assert_eq!(apd.alpha_up, 0.3);
|
||||||
|
assert_eq!(apd.alpha_down, 0.02);
|
||||||
|
|
||||||
|
apd.set_mobile_mode(false);
|
||||||
|
assert_eq!(apd.safety_margin, DEFAULT_SAFETY_MARGIN);
|
||||||
|
assert_eq!(apd.alpha_up, DEFAULT_ALPHA);
|
||||||
|
assert_eq!(apd.alpha_down, DEFAULT_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mobile_mode_accessible_via_jitter_buffer() {
|
||||||
|
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||||
|
jb.adaptive_delay_mut().unwrap().set_mobile_mode(true);
|
||||||
|
assert_eq!(jb.adaptive_delay().unwrap().safety_margin, 3.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ pub mod traits;
|
|||||||
pub use codec_id::{CodecId, QualityProfile};
|
pub use codec_id::{CodecId, QualityProfile};
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
||||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
||||||
|
FRAME_TYPE_MINI,
|
||||||
};
|
};
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, Tier};
|
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||||
pub use session::{Session, SessionEvent, SessionState};
|
pub use session::{Session, SessionEvent, SessionState};
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|||||||
@@ -548,6 +548,9 @@ pub enum SignalMessage {
|
|||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
/// Supported quality profiles.
|
/// Supported quality profiles.
|
||||||
supported_profiles: Vec<crate::QualityProfile>,
|
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).
|
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||||
@@ -645,6 +648,133 @@ pub enum SignalMessage {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
room_name: 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.
|
/// Reasons for ending a call.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::packet::QualityReport;
|
use crate::packet::QualityReport;
|
||||||
use crate::traits::QualityController;
|
use crate::traits::QualityController;
|
||||||
@@ -24,24 +25,71 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine which tier a quality report belongs to.
|
/// Determine which tier a quality report belongs to (default/WiFi thresholds).
|
||||||
pub fn classify(report: &QualityReport) -> Self {
|
pub fn classify(report: &QualityReport) -> Self {
|
||||||
|
Self::classify_with_context(report, NetworkContext::Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify with network-context-aware thresholds.
|
||||||
|
pub fn classify_with_context(report: &QualityReport, context: NetworkContext) -> Self {
|
||||||
let loss = report.loss_percent();
|
let loss = report.loss_percent();
|
||||||
let rtt = report.rtt_ms();
|
let rtt = report.rtt_ms();
|
||||||
|
|
||||||
if loss > 40.0 || rtt > 600 {
|
match context {
|
||||||
Self::Catastrophic
|
NetworkContext::CellularLte
|
||||||
} else if loss > 10.0 || rtt > 400 {
|
| NetworkContext::Cellular5g
|
||||||
Self::Degraded
|
| NetworkContext::Cellular3g => {
|
||||||
} else {
|
// Tighter thresholds for cellular networks
|
||||||
Self::Good
|
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 {
|
||||||
|
Self::Degraded
|
||||||
|
} else {
|
||||||
|
Self::Good
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the next lower (worse) tier, or None if already at the worst.
|
||||||
|
pub fn downgrade(self) -> Option<Tier> {
|
||||||
|
match self {
|
||||||
|
Self::Good => Some(Self::Degraded),
|
||||||
|
Self::Degraded => Some(Self::Catastrophic),
|
||||||
|
Self::Catastrophic => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes the network transport type for context-aware quality decisions.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum NetworkContext {
|
||||||
|
WiFi,
|
||||||
|
CellularLte,
|
||||||
|
Cellular5g,
|
||||||
|
Cellular3g,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NetworkContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Unknown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||||
///
|
///
|
||||||
/// - Downgrade: 3 consecutive reports in a worse tier
|
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||||
/// - Upgrade: 10 consecutive reports in a better tier
|
/// - Upgrade: 10 consecutive reports in a better tier
|
||||||
pub struct AdaptiveQualityController {
|
pub struct AdaptiveQualityController {
|
||||||
current_tier: Tier,
|
current_tier: Tier,
|
||||||
@@ -54,14 +102,26 @@ pub struct AdaptiveQualityController {
|
|||||||
history: VecDeque<QualityReport>,
|
history: VecDeque<QualityReport>,
|
||||||
/// Whether the profile was manually forced (disables adaptive logic).
|
/// Whether the profile was manually forced (disables adaptive logic).
|
||||||
forced: bool,
|
forced: bool,
|
||||||
|
/// Current network context for threshold selection.
|
||||||
|
network_context: NetworkContext,
|
||||||
|
/// FEC boost expiry time (set during network handoff).
|
||||||
|
fec_boost_until: Option<Instant>,
|
||||||
|
/// FEC boost amount to add during handoff recovery window.
|
||||||
|
fec_boost_amount: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Threshold for downgrading (fast reaction to degradation).
|
/// Threshold for downgrading (fast reaction to degradation).
|
||||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||||
|
/// Threshold for downgrading on cellular networks (even faster).
|
||||||
|
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
||||||
/// Threshold for upgrading (slow, cautious improvement).
|
/// Threshold for upgrading (slow, cautious improvement).
|
||||||
const UPGRADE_THRESHOLD: u32 = 10;
|
const UPGRADE_THRESHOLD: u32 = 10;
|
||||||
/// Maximum history window size.
|
/// Maximum history window size.
|
||||||
const HISTORY_SIZE: usize = 20;
|
const HISTORY_SIZE: usize = 20;
|
||||||
|
/// Default FEC boost amount during handoff recovery.
|
||||||
|
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
||||||
|
/// Duration of FEC boost after a network handoff.
|
||||||
|
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
||||||
|
|
||||||
impl AdaptiveQualityController {
|
impl AdaptiveQualityController {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -72,6 +132,9 @@ impl AdaptiveQualityController {
|
|||||||
consecutive_down: 0,
|
consecutive_down: 0,
|
||||||
history: VecDeque::with_capacity(HISTORY_SIZE),
|
history: VecDeque::with_capacity(HISTORY_SIZE),
|
||||||
forced: false,
|
forced: false,
|
||||||
|
network_context: NetworkContext::default(),
|
||||||
|
fec_boost_until: None,
|
||||||
|
fec_boost_amount: DEFAULT_FEC_BOOST,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +143,69 @@ impl AdaptiveQualityController {
|
|||||||
self.current_tier
|
self.current_tier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the current network context.
|
||||||
|
pub fn network_context(&self) -> NetworkContext {
|
||||||
|
self.network_context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal a network transport change (e.g., WiFi to cellular handoff).
|
||||||
|
///
|
||||||
|
/// When switching from WiFi to any cellular type, this preemptively
|
||||||
|
/// downgrades one quality tier and activates a temporary FEC boost.
|
||||||
|
pub fn signal_network_change(&mut self, new_context: NetworkContext) {
|
||||||
|
let old = self.network_context;
|
||||||
|
self.network_context = new_context;
|
||||||
|
|
||||||
|
let new_is_cellular = matches!(
|
||||||
|
new_context,
|
||||||
|
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
||||||
|
);
|
||||||
|
|
||||||
|
// If switching from WiFi to cellular, preemptively downgrade one tier
|
||||||
|
if old == NetworkContext::WiFi && new_is_cellular {
|
||||||
|
if let Some(lower_tier) = self.current_tier.downgrade() {
|
||||||
|
self.current_tier = lower_tier;
|
||||||
|
self.current_profile = lower_tier.profile();
|
||||||
|
}
|
||||||
|
// Reset counters to avoid stale hysteresis state
|
||||||
|
self.consecutive_up = 0;
|
||||||
|
self.consecutive_down = 0;
|
||||||
|
// Un-force so adaptive logic resumes
|
||||||
|
self.forced = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate FEC boost for any network change
|
||||||
|
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the FEC boost amount if within the handoff recovery window, 0.0 otherwise.
|
||||||
|
///
|
||||||
|
/// Callers should add this to their base FEC ratio during the boost window.
|
||||||
|
pub fn fec_boost(&self) -> f32 {
|
||||||
|
if let Some(until) = self.fec_boost_until {
|
||||||
|
if Instant::now() < until {
|
||||||
|
return self.fec_boost_amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the hysteresis counters.
|
||||||
|
pub fn reset_counters(&mut self) {
|
||||||
|
self.consecutive_up = 0;
|
||||||
|
self.consecutive_down = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective downgrade threshold based on network context.
|
||||||
|
fn downgrade_threshold(&self) -> u32 {
|
||||||
|
match self.network_context {
|
||||||
|
NetworkContext::CellularLte
|
||||||
|
| NetworkContext::Cellular5g
|
||||||
|
| NetworkContext::Cellular3g => CELLULAR_DOWNGRADE_THRESHOLD,
|
||||||
|
_ => DOWNGRADE_THRESHOLD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||||
if observed_tier == self.current_tier {
|
if observed_tier == self.current_tier {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
@@ -96,7 +222,7 @@ impl AdaptiveQualityController {
|
|||||||
if is_worse {
|
if is_worse {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
self.consecutive_down += 1;
|
self.consecutive_down += 1;
|
||||||
if self.consecutive_down >= DOWNGRADE_THRESHOLD {
|
if self.consecutive_down >= self.downgrade_threshold() {
|
||||||
self.current_tier = observed_tier;
|
self.current_tier = observed_tier;
|
||||||
self.current_profile = observed_tier.profile();
|
self.current_profile = observed_tier.profile();
|
||||||
self.consecutive_down = 0;
|
self.consecutive_down = 0;
|
||||||
@@ -142,7 +268,7 @@ impl QualityController for AdaptiveQualityController {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let observed = Tier::classify(report);
|
let observed = Tier::classify_with_context(report, self.network_context);
|
||||||
self.try_transition(observed)
|
self.try_transition(observed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,4 +372,110 @@ mod tests {
|
|||||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Network context tests
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cellular_tighter_thresholds() {
|
||||||
|
// 12% loss: Good on WiFi, Degraded on cellular
|
||||||
|
let report = make_report(12.0, 200);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9% loss: Good on WiFi, Degraded on cellular
|
||||||
|
let report = make_report(9.0, 200);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
|
Tier::Good
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
|
||||||
|
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
||||||
|
let report = make_report(30.0, 200);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
||||||
|
Tier::Catastrophic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cellular_rtt_thresholds() {
|
||||||
|
// RTT 350ms: Good on WiFi, Degraded on cellular
|
||||||
|
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
|
Tier::Good
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cellular_faster_downgrade() {
|
||||||
|
let mut ctrl = AdaptiveQualityController::new();
|
||||||
|
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||||
|
// Reset tier back to Good for testing downgrade threshold
|
||||||
|
ctrl.current_tier = Tier::Good;
|
||||||
|
ctrl.current_profile = Tier::Good.profile();
|
||||||
|
|
||||||
|
// On cellular, downgrade threshold is 2 instead of 3
|
||||||
|
let bad = make_report(50.0, 200);
|
||||||
|
assert!(ctrl.observe(&bad).is_none()); // 1st bad
|
||||||
|
let result = ctrl.observe(&bad); // 2nd bad — should trigger on cellular
|
||||||
|
assert!(result.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signal_network_change_preemptive_downgrade() {
|
||||||
|
let mut ctrl = AdaptiveQualityController::new();
|
||||||
|
assert_eq!(ctrl.tier(), Tier::Good);
|
||||||
|
|
||||||
|
// Switch from WiFi to cellular
|
||||||
|
ctrl.network_context = NetworkContext::WiFi;
|
||||||
|
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||||
|
|
||||||
|
// Should have downgraded one tier: Good -> Degraded
|
||||||
|
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signal_network_change_fec_boost() {
|
||||||
|
let mut ctrl = AdaptiveQualityController::new();
|
||||||
|
assert_eq!(ctrl.fec_boost(), 0.0);
|
||||||
|
|
||||||
|
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||||
|
|
||||||
|
// FEC boost should be active
|
||||||
|
assert!(ctrl.fec_boost() > 0.0);
|
||||||
|
assert_eq!(ctrl.fec_boost(), DEFAULT_FEC_BOOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_downgrade() {
|
||||||
|
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
||||||
|
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
||||||
|
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_context_default() {
|
||||||
|
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ pub trait CryptoSession: Send + Sync {
|
|||||||
fn overhead(&self) -> usize {
|
fn overhead(&self) -> usize {
|
||||||
16 // ChaCha20-Poly1305 tag
|
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.
|
/// Key exchange using the Warzone identity model.
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ prometheus = "0.13"
|
|||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
dirs = "6"
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
18
crates/wzp-relay/build.rs
Normal file
18
crates/wzp-relay/build.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
199
crates/wzp-relay/src/call_registry.rs
Normal file
199
crates/wzp-relay/src/call_registry.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//! 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,8 +3,41 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
/// Configuration for the relay daemon.
|
/// A federated peer relay.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[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 {
|
pub struct RelayConfig {
|
||||||
/// Address to listen on for incoming connections (client-facing).
|
/// Address to listen on for incoming connections (client-facing).
|
||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
@@ -44,6 +77,22 @@ pub struct RelayConfig {
|
|||||||
pub ws_port: Option<u16>,
|
pub ws_port: Option<u16>,
|
||||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||||
pub static_dir: Option<String>,
|
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 {
|
impl Default for RelayConfig {
|
||||||
@@ -62,6 +111,100 @@ impl Default for RelayConfig {
|
|||||||
trunking_enabled: false,
|
trunking_enabled: false,
|
||||||
ws_port: None,
|
ws_port: None,
|
||||||
static_dir: 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 = "*"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
201
crates/wzp-relay/src/event_log.rs
Normal file
201
crates/wzp-relay/src/event_log.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
966
crates/wzp-relay/src/federation.rs
Normal file
966
crates/wzp-relay/src/federation.rs
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
//! 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,25 +15,27 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
|||||||
/// 5. Derive shared ChaCha20-Poly1305 session
|
/// 5. Derive shared ChaCha20-Poly1305 session
|
||||||
/// 6. Send `CallAnswer` back
|
/// 6. Send `CallAnswer` back
|
||||||
///
|
///
|
||||||
/// Returns the derived `CryptoSession` and the chosen `QualityProfile`.
|
/// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint,
|
||||||
|
/// and the caller's alias (if provided in CallOffer).
|
||||||
pub async fn accept_handshake(
|
pub async fn accept_handshake(
|
||||||
transport: &dyn MediaTransport,
|
transport: &dyn MediaTransport,
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error> {
|
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
|
||||||
// 1. Receive CallOffer
|
// 1. Receive CallOffer
|
||||||
let offer = transport
|
let offer = transport
|
||||||
.recv_signal()
|
.recv_signal()
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
||||||
|
|
||||||
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) =
|
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) =
|
||||||
match offer {
|
match offer {
|
||||||
SignalMessage::CallOffer {
|
SignalMessage::CallOffer {
|
||||||
identity_pub,
|
identity_pub,
|
||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles,
|
supported_profiles,
|
||||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles),
|
alias,
|
||||||
|
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
|
||||||
other => {
|
other => {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"expected CallOffer, got {:?}",
|
"expected CallOffer, got {:?}",
|
||||||
@@ -76,25 +78,26 @@ pub async fn accept_handshake(
|
|||||||
};
|
};
|
||||||
transport.send_signal(&answer).await?;
|
transport.send_signal(&answer).await?;
|
||||||
|
|
||||||
Ok((session, chosen_profile))
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the best quality profile from those the caller supports.
|
/// Select the best quality profile from those the caller supports.
|
||||||
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||||
// Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
|
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested
|
||||||
if supported.is_empty() {
|
// for federation reliability (large packets may exceed path MTU).
|
||||||
return QualityProfile::GOOD;
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
//! quality transitions.
|
//! quality transitions.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod call_registry;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod event_log;
|
||||||
|
pub mod federation;
|
||||||
|
pub mod signal_hub;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
use wzp_relay::config::RelayConfig;
|
use wzp_relay::config::RelayConfig;
|
||||||
use wzp_relay::metrics::RelayMetrics;
|
use wzp_relay::metrics::RelayMetrics;
|
||||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||||
@@ -23,12 +23,54 @@ use wzp_relay::presence::PresenceRegistry;
|
|||||||
use wzp_relay::room::{self, RoomManager};
|
use wzp_relay::room::{self, RoomManager};
|
||||||
use wzp_relay::session_mgr::SessionManager;
|
use wzp_relay::session_mgr::SessionManager;
|
||||||
|
|
||||||
fn parse_args() -> RelayConfig {
|
/// Parsed CLI result — config + identity path.
|
||||||
let mut config = RelayConfig::default();
|
struct CliResult {
|
||||||
|
config: RelayConfig,
|
||||||
|
identity_path: Option<String>,
|
||||||
|
config_file: Option<String>,
|
||||||
|
config_needs_create: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> CliResult {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
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;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
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" => {
|
"--listen" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
config.listen_addr = args.get(i).expect("--listen requires an address")
|
config.listen_addr = args.get(i).expect("--listen requires an address")
|
||||||
@@ -81,6 +123,28 @@ fn parse_args() -> RelayConfig {
|
|||||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
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" => {
|
"--mesh-status" => {
|
||||||
// Print mesh table from a fresh registry and exit.
|
// Print mesh table from a fresh registry and exit.
|
||||||
// In practice this is useful after the relay has been running;
|
// In practice this is useful after the relay has been running;
|
||||||
@@ -90,9 +154,11 @@ fn parse_args() -> RelayConfig {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
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!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
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)");
|
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||||
@@ -102,6 +168,8 @@ fn parse_args() -> RelayConfig {
|
|||||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
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!(" --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!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -116,7 +184,7 @@ fn parse_args() -> RelayConfig {
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
config
|
CliResult { config, identity_path, config_file, config_needs_create }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RelayStats {
|
struct RelayStats {
|
||||||
@@ -184,10 +252,29 @@ 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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let config = parse_args();
|
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args();
|
||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
|
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
||||||
rustls::crypto::ring::default_provider()
|
rustls::crypto::ring::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.expect("failed to install rustls crypto provider");
|
.expect("failed to install rustls crypto provider");
|
||||||
@@ -207,12 +294,88 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ephemeral relay identity for crypto handshake
|
// Load or generate relay identity
|
||||||
let relay_seed = wzp_crypto::Seed::generate();
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||||
|
|
||||||
let (server_config, _cert) = wzp_transport::server_config();
|
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 endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||||
|
|
||||||
// Forward mode
|
// Forward mode
|
||||||
@@ -230,9 +393,41 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Room manager (room mode only)
|
// Room manager (room mode only)
|
||||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
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
|
// Session manager — enforces max concurrent sessions
|
||||||
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_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
|
// Spawn inter-relay health probes via ProbeMesh coordinator
|
||||||
if !config.probe_targets.is_empty() {
|
if !config.probe_targets.is_empty() {
|
||||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||||
@@ -267,6 +462,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
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...");
|
info!("Listening for connections...");
|
||||||
|
|
||||||
@@ -283,8 +487,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let relay_seed_bytes = relay_seed.0;
|
let relay_seed_bytes = relay_seed.0;
|
||||||
let metrics = metrics.clone();
|
let metrics = metrics.clone();
|
||||||
let trunking_enabled = config.trunking_enabled;
|
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 presence = presence.clone();
|
||||||
let route_resolver = route_resolver.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 {
|
tokio::spawn(async move {
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
@@ -299,6 +508,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
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.
|
// Probe connections use SNI "_probe" to identify themselves.
|
||||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||||
if room_name == "_probe" {
|
if room_name == "_probe" {
|
||||||
@@ -385,6 +611,294 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return;
|
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 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
|
// Auth: if --auth-url is set, expect AuthToken as first signal
|
||||||
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||||
@@ -431,7 +945,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Crypto handshake: verify client identity + negotiate quality profile
|
// Crypto handshake: verify client identity + negotiate quality profile
|
||||||
let handshake_start = std::time::Instant::now();
|
let handshake_start = std::time::Instant::now();
|
||||||
let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake(
|
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake(
|
||||||
&*transport,
|
&*transport,
|
||||||
&relay_seed_bytes,
|
&relay_seed_bytes,
|
||||||
).await {
|
).await {
|
||||||
@@ -448,10 +962,35 @@ 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
|
// Register in presence registry
|
||||||
if let Some(ref fp) = authenticated_fp {
|
{
|
||||||
let mut reg = presence.lock().await;
|
let mut reg = presence.lock().await;
|
||||||
reg.register_local(fp, None, Some(room_name.clone()));
|
reg.register_local(&participant_fp, None, Some(room_name.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(%addr, room = %room_name, "client joining");
|
info!(%addr, room = %room_name, "client joining");
|
||||||
@@ -500,16 +1039,55 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
metrics.active_sessions.inc();
|
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 participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
match mgr.join(
|
||||||
Ok(id) => {
|
&room_name,
|
||||||
|
addr,
|
||||||
|
room::ParticipantSender::Quic(transport.clone()),
|
||||||
|
Some(&participant_fp),
|
||||||
|
caller_alias.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok((id, update, senders)) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
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
|
id
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(%addr, room = %room_name, "room join denied: {e}");
|
error!(%addr, room = %room_name, "room join denied: {e}");
|
||||||
// Clean up the session we just created
|
|
||||||
metrics.active_sessions.dec();
|
metrics.active_sessions.dec();
|
||||||
let mut smgr = session_mgr.lock().await;
|
let mut smgr = session_mgr.lock().await;
|
||||||
smgr.remove_session(session_id);
|
smgr.remove_session(session_id);
|
||||||
@@ -523,6 +1101,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect();
|
.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::run_participant(
|
||||||
room_mgr.clone(),
|
room_mgr.clone(),
|
||||||
room_name,
|
room_name,
|
||||||
@@ -531,6 +1128,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
metrics.clone(),
|
metrics.clone(),
|
||||||
&session_id_str,
|
&session_id_str,
|
||||||
trunking_enabled,
|
trunking_enabled,
|
||||||
|
debug_tap,
|
||||||
|
federation_tx,
|
||||||
|
federation_room_hash,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
// Participant disconnected — clean up presence + per-session metrics
|
// Participant disconnected — clean up presence + per-session metrics
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ pub struct RelayMetrics {
|
|||||||
pub bytes_forwarded: IntCounter,
|
pub bytes_forwarded: IntCounter,
|
||||||
pub auth_attempts: IntCounterVec,
|
pub auth_attempts: IntCounterVec,
|
||||||
pub handshake_duration: Histogram,
|
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
|
// Per-session metrics
|
||||||
pub session_buffer_depth: IntGaugeVec,
|
pub session_buffer_depth: IntGaugeVec,
|
||||||
pub session_loss_pct: GaugeVec,
|
pub session_loss_pct: GaugeVec,
|
||||||
@@ -60,6 +67,28 @@ impl RelayMetrics {
|
|||||||
)
|
)
|
||||||
.expect("metric");
|
.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(
|
let session_buffer_depth = IntGaugeVec::new(
|
||||||
Opts::new(
|
Opts::new(
|
||||||
"wzp_relay_session_jitter_buffer_depth",
|
"wzp_relay_session_jitter_buffer_depth",
|
||||||
@@ -107,6 +136,12 @@ impl RelayMetrics {
|
|||||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||||
registry.register(Box::new(auth_attempts.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(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_buffer_depth.clone())).expect("register");
|
||||||
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
||||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||||
@@ -120,6 +155,12 @@ impl RelayMetrics {
|
|||||||
bytes_forwarded,
|
bytes_forwarded,
|
||||||
auth_attempts,
|
auth_attempts,
|
||||||
handshake_duration,
|
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_buffer_depth,
|
||||||
session_loss_pct,
|
session_loss_pct,
|
||||||
session_rtt_ms,
|
session_rtt_ms,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use wzp_proto::packet::TrunkFrame;
|
use wzp_proto::packet::TrunkFrame;
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::MediaTransport;
|
||||||
@@ -18,6 +18,38 @@ use wzp_proto::MediaTransport;
|
|||||||
use crate::metrics::RelayMetrics;
|
use crate::metrics::RelayMetrics;
|
||||||
use crate::trunk::TrunkBatcher;
|
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.
|
/// Unique participant ID within a room.
|
||||||
pub type ParticipantId = u64;
|
pub type ParticipantId = u64;
|
||||||
|
|
||||||
@@ -27,6 +59,22 @@ fn next_id() -> ParticipantId {
|
|||||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
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.
|
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ParticipantSender {
|
pub enum ParticipantSender {
|
||||||
@@ -67,11 +115,24 @@ 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.
|
/// A participant in a room.
|
||||||
struct Participant {
|
struct Participant {
|
||||||
id: ParticipantId,
|
id: ParticipantId,
|
||||||
_addr: std::net::SocketAddr,
|
_addr: std::net::SocketAddr,
|
||||||
sender: ParticipantSender,
|
sender: ParticipantSender,
|
||||||
|
fingerprint: Option<String>,
|
||||||
|
alias: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room holding multiple participants.
|
/// A room holding multiple participants.
|
||||||
@@ -86,10 +147,16 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
fn add(
|
||||||
|
&mut self,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
sender: ParticipantSender,
|
||||||
|
fingerprint: Option<String>,
|
||||||
|
alias: Option<String>,
|
||||||
|
) -> ParticipantId {
|
||||||
let id = next_id();
|
let id = next_id();
|
||||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||||
self.participants.push(Participant { id, _addr: addr, sender });
|
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias });
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +173,23 @@ impl Room {
|
|||||||
.collect()
|
.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 {
|
fn is_empty(&self) -> bool {
|
||||||
self.participants.is_empty()
|
self.participants.is_empty()
|
||||||
}
|
}
|
||||||
@@ -122,24 +206,35 @@ pub struct RoomManager {
|
|||||||
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
||||||
/// fingerprints can join the corresponding room.
|
/// fingerprints can join the corresponding room.
|
||||||
acl: Option<HashMap<String, HashSet<String>>>,
|
acl: Option<HashMap<String, HashSet<String>>>,
|
||||||
|
/// Channel for room lifecycle events (federation subscribes).
|
||||||
|
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomManager {
|
impl RoomManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: None,
|
acl: None,
|
||||||
|
event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a room manager with ACL enforcement enabled.
|
/// Create a room manager with ACL enforcement enabled.
|
||||||
pub fn with_acl() -> Self {
|
pub fn with_acl() -> Self {
|
||||||
|
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: Some(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.
|
/// Grant a fingerprint access to a room.
|
||||||
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
||||||
if let Some(ref mut acl) = self.acl {
|
if let Some(ref mut acl) = self.acl {
|
||||||
@@ -165,20 +260,32 @@ impl RoomManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Join a room. Returns the participant ID or an error if unauthorized.
|
/// Join a room. Returns (participant_id, room_update_msg, all_senders) for broadcasting.
|
||||||
pub fn join(
|
pub fn join(
|
||||||
&mut self,
|
&mut self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
sender: ParticipantSender,
|
sender: ParticipantSender,
|
||||||
fingerprint: Option<&str>,
|
fingerprint: Option<&str>,
|
||||||
) -> Result<ParticipantId, String> {
|
alias: Option<&str>,
|
||||||
|
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> {
|
||||||
if !self.is_authorized(room_name, fingerprint) {
|
if !self.is_authorized(room_name, fingerprint) {
|
||||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||||
return Err("not authorized for this room".to_string());
|
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 room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
Ok(room.add(addr, sender))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
||||||
@@ -189,17 +296,49 @@ impl RoomManager {
|
|||||||
sender: tokio::sync::mpsc::Sender<Bytes>,
|
sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||||
fingerprint: Option<&str>,
|
fingerprint: Option<&str>,
|
||||||
) -> Result<ParticipantId, String> {
|
) -> Result<ParticipantId, String> {
|
||||||
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?;
|
||||||
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leave a room. Removes the room if empty.
|
/// Get list of active room names.
|
||||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
|
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>)> {
|
||||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||||
room.remove(participant_id);
|
room.remove(participant_id);
|
||||||
if room.is_empty() {
|
if room.is_empty() {
|
||||||
self.rooms.remove(room_name);
|
self.rooms.remove(room_name);
|
||||||
|
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
||||||
info!(room = room_name, "room closed (empty)");
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +437,9 @@ pub async fn run_participant(
|
|||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
trunking_enabled: bool,
|
trunking_enabled: bool,
|
||||||
|
debug_tap: Option<DebugTap>,
|
||||||
|
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||||
|
federation_room_hash: Option<[u8; 8]>,
|
||||||
) {
|
) {
|
||||||
if trunking_enabled {
|
if trunking_enabled {
|
||||||
run_participant_trunked(
|
run_participant_trunked(
|
||||||
@@ -306,7 +448,7 @@ pub async fn run_participant(
|
|||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
run_participant_plain(
|
run_participant_plain(
|
||||||
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -320,58 +462,145 @@ async fn run_participant_plain(
|
|||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
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 addr = transport.connection().remote_address();
|
||||||
let mut packets_forwarded = 0u64;
|
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 {
|
loop {
|
||||||
|
let recv_start = std::time::Instant::now();
|
||||||
let pkt = match transport.recv_media().await {
|
let pkt = match transport.recv_media().await {
|
||||||
Ok(Some(pkt)) => pkt,
|
Ok(Some(pkt)) => pkt,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
info!(%addr, participant = participant_id, "disconnected");
|
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||||
info!(%addr, participant = participant_id, "connection closed: {e}");
|
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
||||||
} else {
|
} else {
|
||||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||||
}
|
}
|
||||||
break;
|
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
|
// Update per-session quality metrics if a quality report is present
|
||||||
if let Some(ref report) = pkt.quality_report {
|
if let Some(ref report) = pkt.quality_report {
|
||||||
metrics.update_session_quality(session_id, report);
|
metrics.update_session_quality(session_id, report);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current list of other participants
|
// Get current list of other participants
|
||||||
|
let lock_start = std::time::Instant::now();
|
||||||
let others = {
|
let others = {
|
||||||
let mgr = room_mgr.lock().await;
|
let mgr = room_mgr.lock().await;
|
||||||
mgr.others(&room_name, participant_id)
|
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
|
// Forward to all others
|
||||||
|
let fwd_start = std::time::Instant::now();
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
match other {
|
match other {
|
||||||
ParticipantSender::Quic(t) => {
|
ParticipantSender::Quic(t) => {
|
||||||
let _ = t.send_media(&pkt).await;
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ParticipantSender::WebSocket(_) => {
|
ParticipantSender::WebSocket(_) => {
|
||||||
// WS clients receive raw payload bytes
|
|
||||||
let _ = other.send_raw(&pkt.payload).await;
|
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;
|
let fan_out = others.len() as u64;
|
||||||
metrics.packets_forwarded.inc_by(fan_out);
|
metrics.packets_forwarded.inc_by(fan_out);
|
||||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||||
packets_forwarded += 1;
|
packets_forwarded += 1;
|
||||||
if packets_forwarded % 500 == 0 {
|
|
||||||
|
// Periodic stats log every 5 seconds
|
||||||
|
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||||
let room_size = {
|
let room_size = {
|
||||||
let mgr = room_mgr.lock().await;
|
let mgr = room_mgr.lock().await;
|
||||||
mgr.room_size(&room_name)
|
mgr.room_size(&room_name)
|
||||||
@@ -381,14 +610,24 @@ async fn run_participant_plain(
|
|||||||
participant = participant_id,
|
participant = participant_id,
|
||||||
forwarded = packets_forwarded,
|
forwarded = packets_forwarded,
|
||||||
room_size,
|
room_size,
|
||||||
|
fan_out,
|
||||||
|
max_recv_gap_ms,
|
||||||
|
max_forward_ms,
|
||||||
|
send_errors,
|
||||||
"participant stats"
|
"participant stats"
|
||||||
);
|
);
|
||||||
|
max_recv_gap_ms = 0;
|
||||||
|
max_forward_ms = 0;
|
||||||
|
last_log_instant = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up — leave room and broadcast update to remaining participants
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
mgr.leave(&room_name, participant_id);
|
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||||
|
drop(mgr); // release lock before async broadcast
|
||||||
|
broadcast_signal(&senders, &update).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trunked forwarding loop — batches outgoing packets per peer.
|
/// Trunked forwarding loop — batches outgoing packets per peer.
|
||||||
@@ -404,6 +643,19 @@ async fn run_participant_trunked(
|
|||||||
|
|
||||||
let addr = transport.connection().remote_address();
|
let addr = transport.connection().remote_address();
|
||||||
let mut packets_forwarded = 0u64;
|
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
|
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
|
||||||
// transport (stable for the Arc's lifetime). We use the remote address
|
// transport (stable for the Arc's lifetime). We use the remote address
|
||||||
@@ -425,24 +677,50 @@ async fn run_participant_trunked(
|
|||||||
let pkt = match result {
|
let pkt = match result {
|
||||||
Ok(Some(pkt)) => pkt,
|
Ok(Some(pkt)) => pkt,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
info!(%addr, participant = participant_id, "disconnected");
|
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||||
break;
|
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 {
|
if let Some(ref report) = pkt.quality_report {
|
||||||
metrics.update_session_quality(session_id, report);
|
metrics.update_session_quality(session_id, report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lock_start = std::time::Instant::now();
|
||||||
let others = {
|
let others = {
|
||||||
let mgr = room_mgr.lock().await;
|
let mgr = room_mgr.lock().await;
|
||||||
mgr.others(&room_name, participant_id)
|
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;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
for other in &others {
|
for other in &others {
|
||||||
match other {
|
match other {
|
||||||
@@ -452,21 +730,44 @@ async fn run_participant_trunked(
|
|||||||
.entry(peer_addr)
|
.entry(peer_addr)
|
||||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||||
if let Err(e) = fwd.send(&pkt).await {
|
if let Err(e) = fwd.send(&pkt).await {
|
||||||
let _ = e;
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ParticipantSender::WebSocket(_) => {
|
ParticipantSender::WebSocket(_) => {
|
||||||
// WS clients bypass trunking — send raw payload directly
|
|
||||||
let _ = other.send_raw(&pkt.payload).await;
|
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;
|
let fan_out = others.len() as u64;
|
||||||
metrics.packets_forwarded.inc_by(fan_out);
|
metrics.packets_forwarded.inc_by(fan_out);
|
||||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||||
packets_forwarded += 1;
|
packets_forwarded += 1;
|
||||||
if packets_forwarded % 500 == 0 {
|
|
||||||
|
// Periodic stats every 5 seconds
|
||||||
|
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||||
let room_size = {
|
let room_size = {
|
||||||
let mgr = room_mgr.lock().await;
|
let mgr = room_mgr.lock().await;
|
||||||
mgr.room_size(&room_name)
|
mgr.room_size(&room_name)
|
||||||
@@ -476,15 +777,30 @@ async fn run_participant_trunked(
|
|||||||
participant = participant_id,
|
participant = participant_id,
|
||||||
forwarded = packets_forwarded,
|
forwarded = packets_forwarded,
|
||||||
room_size,
|
room_size,
|
||||||
|
fan_out,
|
||||||
|
max_recv_gap_ms,
|
||||||
|
max_forward_ms,
|
||||||
|
send_errors,
|
||||||
"participant stats (trunked)"
|
"participant stats (trunked)"
|
||||||
);
|
);
|
||||||
|
max_recv_gap_ms = 0;
|
||||||
|
max_forward_ms = 0;
|
||||||
|
last_log_instant = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = flush_interval.tick() => {
|
_ = flush_interval.tick() => {
|
||||||
for fwd in forwarders.values_mut() {
|
for fwd in forwarders.values_mut() {
|
||||||
if let Err(e) = fwd.flush().await {
|
if let Err(e) = fwd.flush().await {
|
||||||
let _ = e;
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +813,10 @@ async fn run_participant_trunked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
mgr.leave(&room_name, participant_id);
|
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||||
|
drop(mgr);
|
||||||
|
broadcast_signal(&senders, &update).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.
|
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.
|
||||||
|
|||||||
105
crates/wzp-relay/src/signal_hub.rs
Normal file
105
crates/wzp-relay/src/signal_hub.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! 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,6 +16,9 @@ async-trait = { workspace = true }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
hkdf = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -6,20 +6,74 @@ use std::time::Duration;
|
|||||||
use quinn::crypto::rustls::QuicClientConfig;
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
use quinn::crypto::rustls::QuicServerConfig;
|
use quinn::crypto::rustls::QuicServerConfig;
|
||||||
|
|
||||||
/// Create a server configuration with a self-signed certificate (for testing).
|
/// Create a server configuration with a self-signed certificate (random keypair).
|
||||||
///
|
///
|
||||||
/// Tunes QUIC transport parameters for lossy VoIP:
|
/// The certificate changes on every call. Use `server_config_from_seed` for
|
||||||
/// - 30s idle timeout
|
/// a deterministic certificate that survives relay restarts.
|
||||||
/// - 5s keep-alive interval
|
|
||||||
/// - DATAGRAM extension enabled
|
|
||||||
/// - Conservative flow control for bandwidth-constrained links
|
|
||||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||||
.expect("failed to generate self-signed cert");
|
.expect("failed to generate self-signed cert");
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||||
let key_der =
|
let key_der =
|
||||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
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()
|
let mut server_crypto = rustls::ServerConfig::builder()
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub mod path_monitor;
|
|||||||
pub mod quic;
|
pub mod quic;
|
||||||
pub mod reliable;
|
pub mod reliable;
|
||||||
|
|
||||||
pub use config::{client_config, server_config};
|
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
||||||
pub use connection::{accept, connect, create_endpoint};
|
pub use connection::{accept, connect, create_endpoint};
|
||||||
pub use path_monitor::PathMonitor;
|
pub use path_monitor::PathMonitor;
|
||||||
pub use quic::QuinnTransport;
|
pub use quic::QuinnTransport;
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ 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.
|
/// Estimate bandwidth in kbps from bytes received over time.
|
||||||
fn estimate_bandwidth_kbps(&self) -> u32 {
|
fn estimate_bandwidth_kbps(&self) -> u32 {
|
||||||
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
||||||
@@ -149,6 +154,27 @@ impl PathMonitor {
|
|||||||
}
|
}
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect whether a network handoff likely occurred.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
|
||||||
|
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
|
||||||
|
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
|
||||||
|
pub fn detect_handoff(&self) -> bool {
|
||||||
|
// We need at least two RTT observations to have a meaningful jitter value,
|
||||||
|
// and the EWMA must be non-zero to avoid division/multiplication by zero.
|
||||||
|
if self.jitter_ewma <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
|
||||||
|
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
|
||||||
|
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
|
||||||
|
instant_jitter > self.jitter_ewma * 3.0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PathMonitor {
|
impl Default for PathMonitor {
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ impl QuinnTransport {
|
|||||||
&self.connection
|
&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.
|
/// Get the maximum datagram payload size, if datagrams are supported.
|
||||||
pub fn max_datagram_size(&self) -> Option<usize> {
|
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||||
datagram::max_datagram_payload(&self.connection)
|
datagram::max_datagram_payload(&self.connection)
|
||||||
@@ -120,7 +143,7 @@ impl MediaTransport for QuinnTransport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match datagram::deserialize_media(data) {
|
match datagram::deserialize_media(data.clone()) {
|
||||||
Some(packet) => {
|
Some(packet) => {
|
||||||
// Record receive observation
|
// Record receive observation
|
||||||
{
|
{
|
||||||
@@ -133,8 +156,10 @@ impl MediaTransport for QuinnTransport {
|
|||||||
Ok(Some(packet))
|
Ok(Some(packet))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("received malformed media datagram");
|
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
|
||||||
Ok(None)
|
// Don't return Ok(None) — that signals connection closed.
|
||||||
|
// Recurse to read the next datagram instead.
|
||||||
|
Box::pin(self.recv_media()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
//! 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
|
// Crypto handshake with relay
|
||||||
let handshake_start = std::time::Instant::now();
|
let handshake_start = std::time::Instant::now();
|
||||||
let bridge_seed = wzp_crypto::Seed::generate();
|
let bridge_seed = wzp_crypto::Seed::generate();
|
||||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await {
|
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
|
||||||
Ok(_session) => {
|
Ok(_session) => {
|
||||||
let elapsed = handshake_start.elapsed().as_secs_f64();
|
let elapsed = handshake_start.elapsed().as_secs_f64();
|
||||||
state.metrics.handshake_latency.observe(elapsed);
|
state.metrics.handshake_latency.observe(elapsed);
|
||||||
|
|||||||
@@ -10,10 +10,6 @@
|
|||||||
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
.container { text-align: center; max-width: 420px; padding: 2rem; }
|
||||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
|
||||||
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||||
.variant-badge { display: inline-block; background: #2a2a4a; border: 1px solid #444; color: #00d4ff; font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; font-family: monospace; letter-spacing: 0.05em; }
|
|
||||||
.variant-selector { margin-bottom: 1.2rem; display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; }
|
|
||||||
.variant-selector label { font-size: 0.75rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
|
|
||||||
.variant-selector input[type="radio"] { accent-color: #00d4ff; }
|
|
||||||
.room-input { margin-bottom: 1.5rem; }
|
.room-input { margin-bottom: 1.5rem; }
|
||||||
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
|
||||||
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
.room-input input:focus { outline: none; border-color: #00d4ff; }
|
||||||
@@ -35,22 +31,15 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>WarzonePhone <span class="variant-badge" id="variantBadge">PURE</span></h1>
|
<h1>WarzonePhone</h1>
|
||||||
<p class="subtitle">Lossy VoIP Protocol</p>
|
<p class="subtitle">Lossy VoIP Protocol</p>
|
||||||
|
|
||||||
<div class="variant-selector">
|
|
||||||
<label><input type="radio" name="variant" value="pure"> Pure JS</label>
|
|
||||||
<label><input type="radio" name="variant" value="hybrid"> Hybrid</label>
|
|
||||||
<label><input type="radio" name="variant" value="full"> Full WASM</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="room-input">
|
<div class="room-input">
|
||||||
<label for="room">Room</label>
|
<label for="room">Room</label>
|
||||||
<input type="text" id="room" placeholder="enter room name" value="">
|
<input type="text" id="room" placeholder="enter room name" value="">
|
||||||
</div>
|
</div>
|
||||||
<button id="callBtn">Connect</button>
|
<button id="callBtn" onclick="toggleCall()">Connect</button>
|
||||||
<div class="controls" id="controls" style="display:none;">
|
<div class="controls" id="controls" style="display:none;">
|
||||||
<label><input type="checkbox" id="pttMode"> Radio mode (push-to-talk)</label>
|
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
|
||||||
</div>
|
</div>
|
||||||
<button id="pttBtn">Hold to Talk</button>
|
<button id="pttBtn">Hold to Talk</button>
|
||||||
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
<div class="level"><div class="level-bar" id="levelBar"></div></div>
|
||||||
@@ -58,158 +47,302 @@
|
|||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/wzp-core.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// ---------------------------------------------------------------------------
|
const SAMPLE_RATE = 48000;
|
||||||
// Load the selected variant script dynamically
|
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
|
||||||
(function() {
|
(function() {
|
||||||
var variant = WZPCore.detectVariant();
|
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
||||||
var scriptMap = {
|
if (path && path !== 'index.html') {
|
||||||
pure: 'js/wzp-pure.js',
|
document.getElementById('room').value = path;
|
||||||
hybrid: 'js/wzp-hybrid.js',
|
}
|
||||||
full: 'js/wzp-full.js',
|
|
||||||
'ws': 'js/wzp-ws.js',
|
|
||||||
'ws-fec': 'js/wzp-ws-fec.js',
|
|
||||||
'ws-full': 'js/wzp-ws-full.js',
|
|
||||||
};
|
|
||||||
var src = scriptMap[variant] || scriptMap.pure;
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = src;
|
|
||||||
s.onload = function() { wzpBoot(); };
|
|
||||||
s.onerror = function() {
|
|
||||||
WZPCore.updateStatus('Failed to load variant: ' + variant);
|
|
||||||
};
|
|
||||||
document.body.appendChild(s);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
// Boot: wire UI to the loaded client variant
|
function setStats(msg) { document.getElementById('stats').textContent = msg; }
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function wzpBoot() {
|
|
||||||
var client = null;
|
|
||||||
var capture = null;
|
|
||||||
var playback = null;
|
|
||||||
var transmitting = true;
|
|
||||||
|
|
||||||
var ui = WZPCore.initUI({
|
function toggleCall() {
|
||||||
onConnect: function(room) {
|
if (active) stopCall();
|
||||||
doConnect(room);
|
else startCall();
|
||||||
},
|
}
|
||||||
onDisconnect: function() {
|
|
||||||
doDisconnect();
|
|
||||||
},
|
|
||||||
onTransmit: function(tx) {
|
|
||||||
transmitting = tx;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function doConnect(room) {
|
async function startCall() {
|
||||||
WZPCore.updateStatus('Requesting microphone...');
|
const btn = document.getElementById('callBtn');
|
||||||
|
const room = getRoom();
|
||||||
|
if (!room) { setStatus('Enter a room name'); return; }
|
||||||
|
|
||||||
var audioCtx;
|
btn.disabled = true;
|
||||||
try {
|
setStatus('Requesting microphone...');
|
||||||
audioCtx = await WZPCore.startAudioContext();
|
|
||||||
} catch (e) {
|
|
||||||
WZPCore.updateStatus('Audio init failed: ' + e.message);
|
|
||||||
ui.setConnected(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build WebSocket URL
|
try {
|
||||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
var wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
|
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||||
|
});
|
||||||
// Create client based on detected variant
|
} catch(e) {
|
||||||
var variant = WZPCore.detectVariant();
|
setStatus('Mic access denied: ' + e.message);
|
||||||
var ClientClass = {
|
btn.disabled = false;
|
||||||
pure: window.WZPPureClient,
|
return;
|
||||||
hybrid: window.WZPHybridClient,
|
|
||||||
full: window.WZPFullClient,
|
|
||||||
'ws': window.WZPWsClient,
|
|
||||||
'ws-fec': window.WZPWsFecClient,
|
|
||||||
'ws-full': window.WZPWsFullClient,
|
|
||||||
}[variant] || window.WZPPureClient;
|
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Full variant: add WebTransport URL for direct relay connection
|
|
||||||
if (variant === 'full') {
|
|
||||||
clientOpts.url = location.origin.replace('http', 'https');
|
|
||||||
}
|
|
||||||
|
|
||||||
client = new ClientClass(clientOpts);
|
|
||||||
|
|
||||||
// Load WASM for variants that need it
|
|
||||||
if (client.loadWasm) {
|
|
||||||
try {
|
|
||||||
WZPCore.updateStatus('Loading WASM module...');
|
|
||||||
await client.loadWasm();
|
|
||||||
} catch (e) {
|
|
||||||
WZPCore.updateStatus('WASM load failed: ' + e.message);
|
|
||||||
ui.setConnected(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
} catch (e) {
|
|
||||||
WZPCore.updateStatus('Connection failed: ' + e.message);
|
|
||||||
ui.setConnected(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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() {
|
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
if (capture) { capture.stop(); capture = null; }
|
|
||||||
if (playback) { playback.stop(); playback = null; }
|
|
||||||
if (client) { client.disconnect(); client = null; }
|
|
||||||
|
|
||||||
var audioCtx = WZPCore.getAudioContext();
|
// Connect WebSocket with room name
|
||||||
if (audioCtx && audioCtx.state !== 'closed') {
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
audioCtx.close();
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
setStatus('Disconnected');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
WZPCore.updateStatus('');
|
ws.onerror = () => {
|
||||||
WZPCore.updateStats('');
|
if (active) {
|
||||||
document.getElementById('levelBar').style.width = '0%';
|
setStatus('Error — reconnecting...');
|
||||||
|
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ui.setConnected(false);
|
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;
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
// WarzonePhone — Shared UI logic for all client variants.
|
|
||||||
// Provides: audio context management, mic capture, playback, UI wiring.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const WZP_SAMPLE_RATE = 48000;
|
|
||||||
const WZP_FRAME_SIZE = 960; // 20ms @ 48kHz
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Variant detection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function wzpDetectVariant() {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const v = (params.get('variant') || 'pure').toLowerCase();
|
|
||||||
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
|
|
||||||
if (valid.includes(v)) return v;
|
|
||||||
return 'pure';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Room helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function wzpGetRoom() {
|
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
||||||
if (path && path !== 'index.html') return path;
|
|
||||||
const hash = location.hash.replace('#', '');
|
|
||||||
if (hash) return hash;
|
|
||||||
const el = document.getElementById('room');
|
|
||||||
return (el && el.value.trim()) || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function wzpPrefillRoom() {
|
|
||||||
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
||||||
if (path && path !== 'index.html') {
|
|
||||||
const el = document.getElementById('room');
|
|
||||||
if (el) el.value = path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Status / stats helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function wzpUpdateStatus(msg) {
|
|
||||||
const el = document.getElementById('status');
|
|
||||||
if (el) el.textContent = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wzpUpdateStats(stats) {
|
|
||||||
const el = document.getElementById('stats');
|
|
||||||
if (!el) return;
|
|
||||||
if (typeof stats === 'string') {
|
|
||||||
el.textContent = stats;
|
|
||||||
} else {
|
|
||||||
const parts = [];
|
|
||||||
if (stats.elapsed != null) parts.push(stats.elapsed.toFixed(1) + 's');
|
|
||||||
if (stats.sent != null) parts.push('sent: ' + stats.sent);
|
|
||||||
if (stats.recv != null) parts.push('recv: ' + stats.recv);
|
|
||||||
if (stats.loss != null) parts.push('loss: ' + (stats.loss * 100).toFixed(1) + '%');
|
|
||||||
if (stats.fecRecovered != null && stats.fecRecovered > 0) parts.push('fec: ' + stats.fecRecovered);
|
|
||||||
if (stats.fecReady != null) parts.push(stats.fecReady ? 'FEC:on' : 'FEC:off');
|
|
||||||
el.textContent = parts.join(' | ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wzpUpdateLevel(pcmInt16) {
|
|
||||||
const bar = document.getElementById('levelBar');
|
|
||||||
if (!bar) return;
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < pcmInt16.length; i += 16) {
|
|
||||||
const v = Math.abs(pcmInt16[i]);
|
|
||||||
if (v > max) max = v;
|
|
||||||
}
|
|
||||||
bar.style.width = (max / 32768 * 100) + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Audio context + worklet
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let _wzpAudioCtx = null;
|
|
||||||
let _wzpWorkletLoaded = false;
|
|
||||||
|
|
||||||
async function wzpStartAudioContext() {
|
|
||||||
if (_wzpAudioCtx && _wzpAudioCtx.state !== 'closed') return _wzpAudioCtx;
|
|
||||||
_wzpAudioCtx = new AudioContext({ sampleRate: WZP_SAMPLE_RATE });
|
|
||||||
_wzpWorkletLoaded = false;
|
|
||||||
return _wzpAudioCtx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wzpGetAudioContext() {
|
|
||||||
return _wzpAudioCtx;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _wzpLoadWorklet(audioCtx) {
|
|
||||||
if (_wzpWorkletLoaded) return true;
|
|
||||||
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
|
|
||||||
console.warn('[wzp-core] AudioWorklet not supported, will use fallback');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await audioCtx.audioWorklet.addModule('audio-processor.js');
|
|
||||||
_wzpWorkletLoaded = true;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[wzp-core] AudioWorklet load failed:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mic capture — returns { node, stop() }
|
|
||||||
// onFrame(ArrayBuffer) called for each 960-sample Int16 PCM frame
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function wzpConnectCapture(audioCtx, onFrame) {
|
|
||||||
let mediaStream;
|
|
||||||
try {
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: {
|
|
||||||
sampleRate: WZP_SAMPLE_RATE,
|
|
||||||
channelCount: 1,
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('Mic access denied: ' + e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
|
||||||
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
|
||||||
let captureNode;
|
|
||||||
|
|
||||||
if (hasWorklet) {
|
|
||||||
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
|
|
||||||
captureNode.port.onmessage = (e) => {
|
|
||||||
onFrame(e.data); // ArrayBuffer of Int16 PCM
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination); // keep worklet alive
|
|
||||||
} else {
|
|
||||||
// ScriptProcessorNode fallback
|
|
||||||
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
||||||
let acc = new Float32Array(0);
|
|
||||||
captureNode.onaudioprocess = (ev) => {
|
|
||||||
const input = ev.inputBuffer.getChannelData(0);
|
|
||||||
const n = new Float32Array(acc.length + input.length);
|
|
||||||
n.set(acc);
|
|
||||||
n.set(input, acc.length);
|
|
||||||
acc = n;
|
|
||||||
while (acc.length >= WZP_FRAME_SIZE) {
|
|
||||||
const frame = acc.slice(0, WZP_FRAME_SIZE);
|
|
||||||
acc = acc.slice(WZP_FRAME_SIZE);
|
|
||||||
const pcm = new Int16Array(WZP_FRAME_SIZE);
|
|
||||||
for (let i = 0; i < WZP_FRAME_SIZE; i++) {
|
|
||||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
|
||||||
}
|
|
||||||
onFrame(pcm.buffer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
source.connect(captureNode);
|
|
||||||
captureNode.connect(audioCtx.destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
node: captureNode,
|
|
||||||
stop() {
|
|
||||||
captureNode.disconnect();
|
|
||||||
mediaStream.getTracks().forEach((t) => t.stop());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Playback — returns { node, play(Int16Array), stop() }
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function wzpConnectPlayback(audioCtx) {
|
|
||||||
const hasWorklet = await _wzpLoadWorklet(audioCtx);
|
|
||||||
let playbackNode;
|
|
||||||
let nextPlayTime = 0;
|
|
||||||
|
|
||||||
if (hasWorklet) {
|
|
||||||
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
|
|
||||||
playbackNode.connect(audioCtx.destination);
|
|
||||||
return {
|
|
||||||
node: playbackNode,
|
|
||||||
play(pcmInt16) {
|
|
||||||
// Transfer Int16 buffer to worklet
|
|
||||||
const buf = pcmInt16.buffer.slice(
|
|
||||||
pcmInt16.byteOffset,
|
|
||||||
pcmInt16.byteOffset + pcmInt16.byteLength
|
|
||||||
);
|
|
||||||
playbackNode.port.postMessage(buf, [buf]);
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
playbackNode.disconnect();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: scheduled BufferSource
|
|
||||||
return {
|
|
||||||
node: null,
|
|
||||||
play(pcmInt16) {
|
|
||||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
|
||||||
const floatData = new Float32Array(pcmInt16.length);
|
|
||||||
for (let i = 0; i < pcmInt16.length; i++) {
|
|
||||||
floatData[i] = pcmInt16[i] / 32768.0;
|
|
||||||
}
|
|
||||||
const buffer = audioCtx.createBuffer(1, floatData.length, WZP_SAMPLE_RATE);
|
|
||||||
buffer.getChannelData(0).set(floatData);
|
|
||||||
const source = audioCtx.createBufferSource();
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.connect(audioCtx.destination);
|
|
||||||
const now = audioCtx.currentTime;
|
|
||||||
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
|
|
||||||
nextPlayTime = now + 0.02;
|
|
||||||
}
|
|
||||||
source.start(nextPlayTime);
|
|
||||||
nextPlayTime += buffer.duration;
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
// nothing to disconnect for fallback
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UI wiring — call after DOM ready
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function wzpInitUI(callbacks) {
|
|
||||||
// callbacks: { onConnect(room), onDisconnect() }
|
|
||||||
const btn = document.getElementById('callBtn');
|
|
||||||
const pttBtn = document.getElementById('pttBtn');
|
|
||||||
const pttCheckbox = document.getElementById('pttMode');
|
|
||||||
let connected = false;
|
|
||||||
let pttMode = false;
|
|
||||||
|
|
||||||
wzpPrefillRoom();
|
|
||||||
|
|
||||||
// Variant badge
|
|
||||||
const variant = wzpDetectVariant();
|
|
||||||
const badge = document.getElementById('variantBadge');
|
|
||||||
if (badge) badge.textContent = variant.toUpperCase();
|
|
||||||
|
|
||||||
// Variant selector radio buttons
|
|
||||||
document.querySelectorAll('input[name="variant"]').forEach((radio) => {
|
|
||||||
if (radio.value === variant) radio.checked = true;
|
|
||||||
radio.addEventListener('change', () => {
|
|
||||||
if (radio.checked) {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
params.set('variant', radio.value);
|
|
||||||
location.search = params.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
btn.onclick = () => {
|
|
||||||
if (connected) {
|
|
||||||
connected = false;
|
|
||||||
btn.textContent = 'Connect';
|
|
||||||
btn.classList.remove('active');
|
|
||||||
_showControls(false);
|
|
||||||
if (callbacks.onDisconnect) callbacks.onDisconnect();
|
|
||||||
} else {
|
|
||||||
const room = wzpGetRoom();
|
|
||||||
if (!room) {
|
|
||||||
wzpUpdateStatus('Enter a room name');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
connected = true;
|
|
||||||
btn.disabled = true;
|
|
||||||
if (callbacks.onConnect) callbacks.onConnect(room);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// PTT toggle
|
|
||||||
if (pttCheckbox) {
|
|
||||||
pttCheckbox.onchange = () => {
|
|
||||||
pttMode = pttCheckbox.checked;
|
|
||||||
if (pttMode) {
|
|
||||||
pttBtn.style.display = 'block';
|
|
||||||
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
|
||||||
} else {
|
|
||||||
pttBtn.style.display = 'none';
|
|
||||||
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// PTT button events
|
|
||||||
function startTx() {
|
|
||||||
if (!pttMode || !connected) return;
|
|
||||||
pttBtn.classList.add('transmitting');
|
|
||||||
pttBtn.textContent = 'Transmitting...';
|
|
||||||
if (callbacks.onTransmit) callbacks.onTransmit(true);
|
|
||||||
}
|
|
||||||
function stopTx() {
|
|
||||||
if (!pttMode) return;
|
|
||||||
pttBtn.classList.remove('transmitting');
|
|
||||||
pttBtn.textContent = 'Hold to Talk';
|
|
||||||
if (callbacks.onTransmit) callbacks.onTransmit(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pttBtn) {
|
|
||||||
pttBtn.addEventListener('mousedown', startTx);
|
|
||||||
pttBtn.addEventListener('mouseup', stopTx);
|
|
||||||
pttBtn.addEventListener('mouseleave', stopTx);
|
|
||||||
pttBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startTx(); });
|
|
||||||
pttBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopTx(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spacebar PTT
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (pttMode && connected && e.code === 'Space' && !e.repeat) {
|
|
||||||
e.preventDefault();
|
|
||||||
startTx();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('keyup', (e) => {
|
|
||||||
if (pttMode && connected && e.code === 'Space') {
|
|
||||||
e.preventDefault();
|
|
||||||
stopTx();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function _showControls(show) {
|
|
||||||
const controls = document.getElementById('controls');
|
|
||||||
if (controls) controls.style.display = show ? 'flex' : 'none';
|
|
||||||
if (!show && pttBtn) {
|
|
||||||
pttBtn.style.display = 'none';
|
|
||||||
pttMode = false;
|
|
||||||
if (pttCheckbox) pttCheckbox.checked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
setConnected(isConnected) {
|
|
||||||
connected = isConnected;
|
|
||||||
btn.disabled = false;
|
|
||||||
if (isConnected) {
|
|
||||||
btn.textContent = 'Disconnect';
|
|
||||||
btn.classList.add('active');
|
|
||||||
_showControls(true);
|
|
||||||
} else {
|
|
||||||
btn.textContent = 'Connect';
|
|
||||||
btn.classList.remove('active');
|
|
||||||
_showControls(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isPTT() {
|
|
||||||
return pttMode;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Exports (global)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPCore = {
|
|
||||||
SAMPLE_RATE: WZP_SAMPLE_RATE,
|
|
||||||
FRAME_SIZE: WZP_FRAME_SIZE,
|
|
||||||
detectVariant: wzpDetectVariant,
|
|
||||||
getRoom: wzpGetRoom,
|
|
||||||
updateStatus: wzpUpdateStatus,
|
|
||||||
updateStats: wzpUpdateStats,
|
|
||||||
updateLevel: wzpUpdateLevel,
|
|
||||||
startAudioContext: wzpStartAudioContext,
|
|
||||||
getAudioContext: wzpGetAudioContext,
|
|
||||||
connectCapture: wzpConnectCapture,
|
|
||||||
connectPlayback: wzpConnectPlayback,
|
|
||||||
initUI: wzpInitUI,
|
|
||||||
};
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
// WarzonePhone — Full WASM + WebTransport client (Variant 3).
|
|
||||||
//
|
|
||||||
// Architecture:
|
|
||||||
// - WebTransport for unreliable datagrams (UDP-like, no head-of-line blocking)
|
|
||||||
// - ChaCha20-Poly1305 encryption via WASM (wzp-wasm WzpCryptoSession)
|
|
||||||
// - RaptorQ FEC via WASM (wzp-wasm WzpFecEncoder/WzpFecDecoder)
|
|
||||||
// - X25519 key exchange via WASM (wzp-wasm WzpKeyExchange)
|
|
||||||
//
|
|
||||||
// NOTE: WebTransport requires the relay to support HTTP/3 (h3-quinn).
|
|
||||||
// The current wzp-relay uses raw QUIC. This variant demonstrates the full
|
|
||||||
// architecture but will need relay-side HTTP/3 support to work end-to-end.
|
|
||||||
// For development / testing, use the hybrid variant (WebSocket + WASM FEC).
|
|
||||||
//
|
|
||||||
// Relies on wzp-core.js for UI and audio helpers.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
|
||||||
|
|
||||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
|
||||||
const MEDIA_HEADER_SIZE = 12;
|
|
||||||
|
|
||||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
|
||||||
const FEC_HEADER_SIZE = 3;
|
|
||||||
|
|
||||||
class WZPFullClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {string} options.url WebTransport URL (https://host:port)
|
|
||||||
* @param {string} options.room Room name
|
|
||||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
||||||
* @param {Function} options.onStatus callback(string) for UI status
|
|
||||||
* @param {Function} options.onStats callback(Object) for UI stats
|
|
||||||
*/
|
|
||||||
constructor(options) {
|
|
||||||
this.url = options.url;
|
|
||||||
this.wsUrl = options.wsUrl; // WS fallback URL
|
|
||||||
this.room = options.room;
|
|
||||||
this.onAudio = options.onAudio || null;
|
|
||||||
this.onStatus = options.onStatus || null;
|
|
||||||
this.onStats = options.onStats || null;
|
|
||||||
|
|
||||||
this.wt = null; // WebTransport instance
|
|
||||||
this.ws = null; // WebSocket fallback
|
|
||||||
this.datagramWriter = null; // WritableStreamDefaultWriter
|
|
||||||
this.datagramReader = null; // ReadableStreamDefaultReader
|
|
||||||
this.cryptoSession = null; // WzpCryptoSession (WASM)
|
|
||||||
this.fecEncoder = null; // WzpFecEncoder (WASM)
|
|
||||||
this.fecDecoder = null; // WzpFecDecoder (WASM)
|
|
||||||
this.sequence = 0;
|
|
||||||
this._wasmModule = null;
|
|
||||||
this._connected = false;
|
|
||||||
this._useWebTransport = false; // true if WT connected, false = WS fallback
|
|
||||||
this._startTime = 0;
|
|
||||||
this._statsInterval = null;
|
|
||||||
this._recvLoopRunning = false;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect: load WASM, open WebTransport, perform key exchange,
|
|
||||||
* initialise FEC, and start the receive loop.
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
if (this._connected) return;
|
|
||||||
|
|
||||||
this._status('Loading WASM module...');
|
|
||||||
|
|
||||||
// 1. Load WASM (FEC + crypto)
|
|
||||||
this._wasmModule = await import(WZP_WASM_PATH);
|
|
||||||
await this._wasmModule.default();
|
|
||||||
|
|
||||||
// 2. Try WebTransport first, fall back to WebSocket
|
|
||||||
let wtSuccess = false;
|
|
||||||
if (typeof WebTransport !== 'undefined' && this.url) {
|
|
||||||
try {
|
|
||||||
this._status('Trying WebTransport...');
|
|
||||||
const wtUrl = this.url + '/' + encodeURIComponent(this.room);
|
|
||||||
this.wt = new WebTransport(wtUrl);
|
|
||||||
await Promise.race([
|
|
||||||
this.wt.ready,
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
|
||||||
]);
|
|
||||||
this.datagramWriter = this.wt.datagrams.writable.getWriter();
|
|
||||||
this.datagramReader = this.wt.datagrams.readable.getReader();
|
|
||||||
this._status('Performing key exchange...');
|
|
||||||
await this._performKeyExchange();
|
|
||||||
wtSuccess = true;
|
|
||||||
this._useWebTransport = true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[wzp-full] WebTransport failed, falling back to WebSocket:', e.message);
|
|
||||||
if (this.wt) { try { this.wt.close(); } catch (_) {} }
|
|
||||||
this.wt = null;
|
|
||||||
this.datagramWriter = null;
|
|
||||||
this.datagramReader = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wtSuccess) {
|
|
||||||
// WebSocket fallback (same as hybrid — WASM loaded but uses WS transport)
|
|
||||||
this._useWebTransport = false;
|
|
||||||
await this._connectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Initialise FEC
|
|
||||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
|
||||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
|
||||||
|
|
||||||
this._connected = true;
|
|
||||||
this.sequence = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
|
||||||
this._startTime = Date.now();
|
|
||||||
this._startStatsTimer();
|
|
||||||
|
|
||||||
// 4. Start receive loop (WebTransport only — WS uses onmessage)
|
|
||||||
if (this._useWebTransport) {
|
|
||||||
this._recvLoop();
|
|
||||||
this._status('Connected to room: ' + this.room + ' (WebTransport, encrypted, FEC active)');
|
|
||||||
} else {
|
|
||||||
this._status('Connected to room: ' + this.room + ' (WebSocket fallback, WASM FEC loaded)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket fallback connection (used when WebTransport unavailable).
|
|
||||||
*/
|
|
||||||
async _connectWebSocket() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this._status('Connecting via WebSocket (fallback)...');
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._status('WebSocket connected to room: ' + this.room);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const pcm = new Int16Array(event.data);
|
|
||||||
this.stats.recv++;
|
|
||||||
if (this.onAudio) this.onAudio(pcm);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
if (this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
this._status('Disconnected');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect and clean up all resources.
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this._connected = false;
|
|
||||||
if (this.wt) {
|
|
||||||
try { this.wt.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.wt = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PCM audio frame.
|
|
||||||
*
|
|
||||||
* Pipeline: PCM -> FEC encode -> encrypt -> datagram send.
|
|
||||||
*
|
|
||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
||||||
*/
|
|
||||||
async sendAudio(pcmBuffer) {
|
|
||||||
if (!this._connected) return;
|
|
||||||
|
|
||||||
// WebSocket fallback: send raw PCM like pure/hybrid
|
|
||||||
if (!this._useWebTransport) {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(pcmBuffer);
|
|
||||||
this.sequence++;
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.datagramWriter || !this.cryptoSession) return;
|
|
||||||
|
|
||||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
|
||||||
|
|
||||||
// Build a minimal 12-byte MediaHeader for AAD.
|
|
||||||
const header = this._buildMediaHeader(this.sequence);
|
|
||||||
|
|
||||||
// FEC encode: feed the frame; when a block completes we get wire packets.
|
|
||||||
const fecOutput = this.fecEncoder.add_symbol(pcmBytes);
|
|
||||||
|
|
||||||
if (fecOutput) {
|
|
||||||
// FEC block completed — send all packets (source + repair).
|
|
||||||
const packetSize = FEC_HEADER_SIZE + 256; // header + symbol_size
|
|
||||||
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
|
||||||
const fecPacket = fecOutput.slice(offset, offset + packetSize);
|
|
||||||
|
|
||||||
// Encrypt: header bytes as AAD, FEC packet as plaintext.
|
|
||||||
const ciphertext = this.cryptoSession.encrypt(header, fecPacket);
|
|
||||||
this.stats.encrypted++;
|
|
||||||
|
|
||||||
// Build wire datagram: header (12) + ciphertext
|
|
||||||
const datagram = new Uint8Array(MEDIA_HEADER_SIZE + ciphertext.length);
|
|
||||||
datagram.set(header, 0);
|
|
||||||
datagram.set(ciphertext, MEDIA_HEADER_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.datagramWriter.write(datagram);
|
|
||||||
} catch (e) {
|
|
||||||
// Datagram send can fail if the transport is closing.
|
|
||||||
if (this._connected) {
|
|
||||||
console.warn('[wzp-full] datagram write failed:', e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If FEC block not yet complete, accumulate (no packets sent yet).
|
|
||||||
|
|
||||||
this.sequence = (this.sequence + 1) & 0xFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
|
||||||
* Useful for verifying the WASM module works correctly in the browser.
|
|
||||||
*
|
|
||||||
* @returns {Object} test results
|
|
||||||
*/
|
|
||||||
testCryptoFec() {
|
|
||||||
if (!this._wasmModule) {
|
|
||||||
return { success: false, error: 'WASM module not loaded' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const t0 = performance.now();
|
|
||||||
const wasm = this._wasmModule;
|
|
||||||
|
|
||||||
// Key exchange
|
|
||||||
const alice = new wasm.WzpKeyExchange();
|
|
||||||
const bob = new wasm.WzpKeyExchange();
|
|
||||||
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
|
||||||
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
|
||||||
|
|
||||||
// Verify secrets match
|
|
||||||
let secretsMatch = aliceSecret.length === bobSecret.length;
|
|
||||||
if (secretsMatch) {
|
|
||||||
for (let i = 0; i < aliceSecret.length; i++) {
|
|
||||||
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt/decrypt
|
|
||||||
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
|
||||||
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
|
||||||
|
|
||||||
const header = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
|
||||||
const plaintext = new TextEncoder().encode('hello warzone from full variant');
|
|
||||||
|
|
||||||
const ciphertext = aliceSession.encrypt(header, plaintext);
|
|
||||||
const decrypted = bobSession.decrypt(header, ciphertext);
|
|
||||||
|
|
||||||
let cryptoOk = decrypted.length === plaintext.length;
|
|
||||||
if (cryptoOk) {
|
|
||||||
for (let i = 0; i < plaintext.length; i++) {
|
|
||||||
if (decrypted[i] !== plaintext[i]) { cryptoOk = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FEC test (same as hybrid testFec)
|
|
||||||
const encoder = new wasm.WzpFecEncoder(5, 256);
|
|
||||||
const decoder = new wasm.WzpFecDecoder(5, 256);
|
|
||||||
|
|
||||||
const frames = [];
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const frame = new Uint8Array(100);
|
|
||||||
for (let j = 0; j < 100; j++) frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
|
||||||
frames.push(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
let wireData = null;
|
|
||||||
for (const frame of frames) {
|
|
||||||
const result = encoder.add_symbol(frame);
|
|
||||||
if (result) wireData = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PACKET_SIZE = FEC_HEADER_SIZE + 256;
|
|
||||||
const packets = [];
|
|
||||||
if (wireData) {
|
|
||||||
for (let off = 0; off + PACKET_SIZE <= wireData.length; off += PACKET_SIZE) {
|
|
||||||
packets.push({
|
|
||||||
blockId: wireData[off],
|
|
||||||
symbolIdx: wireData[off + 1],
|
|
||||||
isRepair: wireData[off + 2] !== 0,
|
|
||||||
data: wireData.slice(off + FEC_HEADER_SIZE, off + PACKET_SIZE),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop 2 packets, try to recover
|
|
||||||
let fecDecoded = null;
|
|
||||||
for (let i = 0; i < packets.length; i++) {
|
|
||||||
if (i === 1 || i === 3) continue; // simulate loss
|
|
||||||
const pkt = packets[i];
|
|
||||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
|
||||||
if (result) { fecDecoded = result; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let fecOk = false;
|
|
||||||
if (fecDecoded) {
|
|
||||||
const expected = new Uint8Array(5 * 100);
|
|
||||||
let off = 0;
|
|
||||||
for (const f of frames) { expected.set(f, off); off += f.length; }
|
|
||||||
fecOk = fecDecoded.length === expected.length;
|
|
||||||
if (fecOk) {
|
|
||||||
for (let i = 0; i < expected.length; i++) {
|
|
||||||
if (fecDecoded[i] !== expected[i]) { fecOk = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup WASM objects
|
|
||||||
alice.free();
|
|
||||||
bob.free();
|
|
||||||
aliceSession.free();
|
|
||||||
bobSession.free();
|
|
||||||
encoder.free();
|
|
||||||
decoder.free();
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: secretsMatch && cryptoOk && fecOk,
|
|
||||||
secretsMatch,
|
|
||||||
cryptoOk,
|
|
||||||
fecOk,
|
|
||||||
fecPacketsTotal: packets.length,
|
|
||||||
fecDropped: 2,
|
|
||||||
elapsed: elapsed.toFixed(2) + 'ms',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Internal
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform X25519 key exchange over a WebTransport bidirectional stream.
|
|
||||||
*
|
|
||||||
* Protocol (simplified DH, not the full SignalMessage handshake):
|
|
||||||
* 1. Open a bidirectional stream.
|
|
||||||
* 2. Send our 32-byte X25519 public key.
|
|
||||||
* 3. Read the peer's 32-byte public key.
|
|
||||||
* 4. Derive shared secret via HKDF.
|
|
||||||
* 5. Create WzpCryptoSession from the shared secret.
|
|
||||||
*
|
|
||||||
* In production this would use the full SignalMessage protocol over the
|
|
||||||
* bidirectional stream (offer/answer/encrypted-session). For now we do
|
|
||||||
* a simple DH swap to prove the architecture.
|
|
||||||
*/
|
|
||||||
async _performKeyExchange() {
|
|
||||||
const wasm = this._wasmModule;
|
|
||||||
const kx = new wasm.WzpKeyExchange();
|
|
||||||
const ourPub = kx.public_key(); // Uint8Array(32)
|
|
||||||
|
|
||||||
// Open a bidirectional stream for signaling.
|
|
||||||
const stream = await this.wt.createBidirectionalStream();
|
|
||||||
const writer = stream.writable.getWriter();
|
|
||||||
const reader = stream.readable.getReader();
|
|
||||||
|
|
||||||
// Send our public key.
|
|
||||||
await writer.write(new Uint8Array(ourPub));
|
|
||||||
|
|
||||||
// Read peer's public key (exactly 32 bytes).
|
|
||||||
// WebTransport streams are byte-oriented; we may get it in chunks.
|
|
||||||
let peerPub = new Uint8Array(0);
|
|
||||||
while (peerPub.length < 32) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
throw new Error('Key exchange stream closed before receiving peer public key');
|
|
||||||
}
|
|
||||||
const combined = new Uint8Array(peerPub.length + value.length);
|
|
||||||
combined.set(peerPub, 0);
|
|
||||||
combined.set(value, peerPub.length);
|
|
||||||
peerPub = combined;
|
|
||||||
}
|
|
||||||
peerPub = peerPub.slice(0, 32);
|
|
||||||
|
|
||||||
// Derive shared secret and create crypto session.
|
|
||||||
const secret = kx.derive_shared_secret(peerPub);
|
|
||||||
this.cryptoSession = new wasm.WzpCryptoSession(secret);
|
|
||||||
|
|
||||||
// Close the signaling stream (key exchange complete).
|
|
||||||
try {
|
|
||||||
writer.releaseLock();
|
|
||||||
reader.releaseLock();
|
|
||||||
await stream.writable.close();
|
|
||||||
} catch (_) {
|
|
||||||
// Best-effort close.
|
|
||||||
}
|
|
||||||
|
|
||||||
kx.free();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receive loop: read datagrams, decrypt, FEC decode, play audio.
|
|
||||||
*
|
|
||||||
* Runs until the transport closes or disconnect() is called.
|
|
||||||
*/
|
|
||||||
async _recvLoop() {
|
|
||||||
if (this._recvLoopRunning) return;
|
|
||||||
this._recvLoopRunning = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (this._connected && this.datagramReader) {
|
|
||||||
const { value, done } = await this.datagramReader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
this.stats.recv++;
|
|
||||||
|
|
||||||
// value is a Uint8Array datagram: header(12) + ciphertext
|
|
||||||
if (value.length <= MEDIA_HEADER_SIZE) continue; // too short
|
|
||||||
|
|
||||||
const headerAad = value.slice(0, MEDIA_HEADER_SIZE);
|
|
||||||
const ciphertext = value.slice(MEDIA_HEADER_SIZE);
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
let fecPacket;
|
|
||||||
try {
|
|
||||||
fecPacket = this.cryptoSession.decrypt(headerAad, ciphertext);
|
|
||||||
this.stats.decrypted++;
|
|
||||||
} catch (e) {
|
|
||||||
// Decryption failure — corrupted or out-of-order packet.
|
|
||||||
// In a real implementation we'd handle sequence number gaps.
|
|
||||||
console.warn('[wzp-full] decrypt failed:', e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FEC decode: parse the FEC wire header and feed to decoder.
|
|
||||||
if (fecPacket.length < FEC_HEADER_SIZE) continue;
|
|
||||||
const blockId = fecPacket[0];
|
|
||||||
const symbolIdx = fecPacket[1];
|
|
||||||
const isRepair = fecPacket[2] !== 0;
|
|
||||||
const symbolData = fecPacket.slice(FEC_HEADER_SIZE);
|
|
||||||
|
|
||||||
const decoded = this.fecDecoder.add_symbol(blockId, symbolIdx, isRepair, symbolData);
|
|
||||||
if (decoded) {
|
|
||||||
this.stats.fecRecovered++;
|
|
||||||
// decoded is concatenated original PCM frames.
|
|
||||||
// Each frame is 1920 bytes (960 Int16 samples @ 48kHz mono).
|
|
||||||
const FRAME_BYTES = 1920;
|
|
||||||
for (let off = 0; off + FRAME_BYTES <= decoded.length; off += FRAME_BYTES) {
|
|
||||||
const pcmSlice = decoded.slice(off, off + FRAME_BYTES);
|
|
||||||
const pcm = new Int16Array(pcmSlice.buffer, pcmSlice.byteOffset, pcmSlice.byteLength / 2);
|
|
||||||
if (this.onAudio) {
|
|
||||||
this.onAudio(pcm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (this._connected) {
|
|
||||||
console.warn('[wzp-full] recv loop error:', e);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this._recvLoopRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a minimal 12-byte MediaHeader for use as AAD.
|
|
||||||
*
|
|
||||||
* Wire layout (from wzp-proto::packet::MediaHeader):
|
|
||||||
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
|
||||||
* Byte 1: FecRatioLo(6)|unused(2)
|
|
||||||
* Bytes 2-3: Sequence number (BE u16)
|
|
||||||
* Bytes 4-7: Timestamp ms (BE u32)
|
|
||||||
* Byte 8: FEC block ID
|
|
||||||
* Byte 9: FEC symbol index
|
|
||||||
* Byte 10: Reserved
|
|
||||||
* Byte 11: CSRC count
|
|
||||||
*
|
|
||||||
* @param {number} seq Sequence number (u16)
|
|
||||||
* @returns {Uint8Array} 12-byte header
|
|
||||||
*/
|
|
||||||
_buildMediaHeader(seq) {
|
|
||||||
const buf = new Uint8Array(MEDIA_HEADER_SIZE);
|
|
||||||
// Byte 0: version=0, is_repair=0, codec=0 (Opus), quality_report=0, fec_ratio_hi=0
|
|
||||||
buf[0] = 0x00;
|
|
||||||
// Byte 1: fec_ratio_lo=0
|
|
||||||
buf[1] = 0x00;
|
|
||||||
// Bytes 2-3: sequence (BE u16)
|
|
||||||
buf[2] = (seq >> 8) & 0xFF;
|
|
||||||
buf[3] = seq & 0xFF;
|
|
||||||
// Bytes 4-7: timestamp (BE u32) — ms since session start
|
|
||||||
const ts = Date.now() - this._startTime;
|
|
||||||
buf[4] = (ts >> 24) & 0xFF;
|
|
||||||
buf[5] = (ts >> 16) & 0xFF;
|
|
||||||
buf[6] = (ts >> 8) & 0xFF;
|
|
||||||
buf[7] = ts & 0xFF;
|
|
||||||
// Bytes 8-11: FEC block/symbol/reserved/csrc — filled by FEC layer in production
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
_startStatsTimer() {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this._statsInterval = setInterval(() => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
||||||
const loss = this.stats.sent > 0
|
|
||||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
||||||
: 0;
|
|
||||||
if (this.onStats) {
|
|
||||||
this.onStats({
|
|
||||||
sent: this.stats.sent,
|
|
||||||
recv: this.stats.recv,
|
|
||||||
loss,
|
|
||||||
elapsed,
|
|
||||||
encrypted: this.stats.encrypted,
|
|
||||||
decrypted: this.stats.decrypted,
|
|
||||||
fecRecovered: this.stats.fecRecovered,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopStatsTimer() {
|
|
||||||
if (this._statsInterval) {
|
|
||||||
clearInterval(this._statsInterval);
|
|
||||||
this._statsInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_status(msg) {
|
|
||||||
if (this.onStatus) this.onStatus(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup() {
|
|
||||||
this._connected = false;
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this.datagramWriter = null;
|
|
||||||
this.datagramReader = null;
|
|
||||||
if (this.cryptoSession) {
|
|
||||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.cryptoSession = null;
|
|
||||||
}
|
|
||||||
if (this.fecEncoder) {
|
|
||||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecEncoder = null;
|
|
||||||
}
|
|
||||||
if (this.fecDecoder) {
|
|
||||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecDecoder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPFullClient = WZPFullClient;
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
// WarzonePhone — Hybrid JS + WASM client (Variant 2).
|
|
||||||
// WebSocket transport, raw PCM, WASM FEC (RaptorQ) ready for WebTransport.
|
|
||||||
// Relies on wzp-core.js for UI and audio helpers.
|
|
||||||
//
|
|
||||||
// The WASM FEC module is loaded and exposed but not used on the wire yet,
|
|
||||||
// because WebSocket is TCP (no packet loss). FEC will activate when
|
|
||||||
// WebTransport (UDP) is added. A testFec() method demonstrates FEC
|
|
||||||
// encode -> simulate loss -> decode in the browser.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
|
||||||
const WZP_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
|
||||||
|
|
||||||
class WZPHybridClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
|
||||||
* @param {string} options.room Room name
|
|
||||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
||||||
* @param {Function} options.onStatus callback(string) for UI status
|
|
||||||
* @param {Function} options.onStats callback({sent, recv, loss, elapsed, fecRecovered}) for UI
|
|
||||||
*/
|
|
||||||
constructor(options) {
|
|
||||||
this.wsUrl = options.wsUrl;
|
|
||||||
this.room = options.room;
|
|
||||||
this.onAudio = options.onAudio || null;
|
|
||||||
this.onStatus = options.onStatus || null;
|
|
||||||
this.onStats = options.onStats || null;
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.sequence = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
|
||||||
this._startTime = 0;
|
|
||||||
this._statsInterval = null;
|
|
||||||
this._connected = false;
|
|
||||||
|
|
||||||
// WASM FEC instances (loaded in connect()).
|
|
||||||
this._wasmModule = null;
|
|
||||||
this.fecEncoder = null;
|
|
||||||
this.fecDecoder = null;
|
|
||||||
this._fecReady = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection and load the WASM FEC module.
|
|
||||||
* @returns {Promise<void>} resolves when connected
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
if (this._connected) return;
|
|
||||||
|
|
||||||
// Load WASM module in parallel with WebSocket connect.
|
|
||||||
const wasmPromise = this._loadWasm();
|
|
||||||
|
|
||||||
const wsPromise = new Promise((resolve, reject) => {
|
|
||||||
this._status('Connecting to room: ' + this.room + '...');
|
|
||||||
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._connected = true;
|
|
||||||
this.sequence = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
|
||||||
this._startTime = Date.now();
|
|
||||||
this._startStatsTimer();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
this._handleMessage(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
const wasConnected = this._connected;
|
|
||||||
this._cleanup();
|
|
||||||
if (wasConnected) {
|
|
||||||
this._status('Disconnected');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
} else {
|
|
||||||
this._status('Connection error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for both WASM load and WS connect.
|
|
||||||
await Promise.all([wasmPromise, wsPromise]);
|
|
||||||
|
|
||||||
const fecStatus = this._fecReady ? 'FEC ready' : 'FEC unavailable';
|
|
||||||
this._status('Connected to room: ' + this.room + ' (' + fecStatus + ')');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket and clean up.
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this._connected = false;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._stopStatsTimer();
|
|
||||||
// Keep WASM module loaded (reusable).
|
|
||||||
this.fecEncoder = null;
|
|
||||||
this.fecDecoder = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PCM audio frame over the WebSocket.
|
|
||||||
* Currently sends raw PCM (same as pure client) since WebSocket is TCP.
|
|
||||||
* When WebTransport is added, this will FEC-encode before sending.
|
|
||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
||||||
*/
|
|
||||||
async sendAudio(pcmBuffer) {
|
|
||||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Over WebSocket (TCP): send raw PCM, no FEC needed.
|
|
||||||
// Over WebTransport (UDP, future): would call this.fecEncoder.add_symbol()
|
|
||||||
// and send the resulting FEC-protected packets.
|
|
||||||
this.ws.send(pcmBuffer);
|
|
||||||
this.sequence++;
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test FEC encode -> simulate loss -> decode in the browser.
|
|
||||||
* Demonstrates that the WASM RaptorQ module works correctly.
|
|
||||||
*
|
|
||||||
* @param {Object} [opts]
|
|
||||||
* @param {number} [opts.blockSize=5] Source symbols per block
|
|
||||||
* @param {number} [opts.symbolSize=256] Padded symbol size
|
|
||||||
* @param {number} [opts.frameSize=100] Bytes per test frame
|
|
||||||
* @param {number} [opts.dropCount=2] Number of packets to drop
|
|
||||||
* @returns {Object} { success, sourcePackets, repairPackets, dropped, recovered, elapsed }
|
|
||||||
*/
|
|
||||||
testFec(opts) {
|
|
||||||
if (!this._fecReady) {
|
|
||||||
return { success: false, error: 'WASM FEC module not loaded' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockSize = (opts && opts.blockSize) || 5;
|
|
||||||
const symbolSize = (opts && opts.symbolSize) || 256;
|
|
||||||
const frameSize = (opts && opts.frameSize) || 100;
|
|
||||||
const dropCount = (opts && opts.dropCount) || 2;
|
|
||||||
|
|
||||||
const HEADER_SIZE = 3; // block_id + symbol_idx + is_repair
|
|
||||||
const packetSize = HEADER_SIZE + symbolSize;
|
|
||||||
|
|
||||||
const t0 = performance.now();
|
|
||||||
|
|
||||||
// Create fresh encoder/decoder for the test.
|
|
||||||
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
|
||||||
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
|
||||||
|
|
||||||
// Generate test frames with known data.
|
|
||||||
const frames = [];
|
|
||||||
for (let i = 0; i < blockSize; i++) {
|
|
||||||
const frame = new Uint8Array(frameSize);
|
|
||||||
for (let j = 0; j < frameSize; j++) {
|
|
||||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
|
||||||
}
|
|
||||||
frames.push(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode: feed frames to encoder; last one triggers block output.
|
|
||||||
let wireData = null;
|
|
||||||
for (const frame of frames) {
|
|
||||||
const result = encoder.add_symbol(frame);
|
|
||||||
if (result) {
|
|
||||||
wireData = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wireData) {
|
|
||||||
// Flush if block didn't complete (shouldn't happen with exact blockSize).
|
|
||||||
wireData = encoder.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse wire packets.
|
|
||||||
const packets = [];
|
|
||||||
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
|
||||||
packets.push({
|
|
||||||
blockId: wireData[offset],
|
|
||||||
symbolIdx: wireData[offset + 1],
|
|
||||||
isRepair: wireData[offset + 2] !== 0,
|
|
||||||
data: wireData.slice(offset + HEADER_SIZE, offset + packetSize),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
|
||||||
const repairPackets = packets.filter(p => p.isRepair).length;
|
|
||||||
|
|
||||||
// Simulate packet loss: drop `dropCount` packets from the front (source symbols).
|
|
||||||
const dropped = [];
|
|
||||||
const surviving = [];
|
|
||||||
for (let i = 0; i < packets.length; i++) {
|
|
||||||
if (i < dropCount) {
|
|
||||||
dropped.push(i);
|
|
||||||
} else {
|
|
||||||
surviving.push(packets[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode from surviving packets.
|
|
||||||
let decoded = null;
|
|
||||||
for (const pkt of surviving) {
|
|
||||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
|
||||||
if (result) {
|
|
||||||
decoded = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
|
|
||||||
// Verify decoded data matches original frames.
|
|
||||||
let success = false;
|
|
||||||
if (decoded) {
|
|
||||||
const expected = new Uint8Array(blockSize * frameSize);
|
|
||||||
let off = 0;
|
|
||||||
for (const frame of frames) {
|
|
||||||
expected.set(frame, off);
|
|
||||||
off += frame.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
success = decoded.length === expected.length;
|
|
||||||
if (success) {
|
|
||||||
for (let i = 0; i < decoded.length; i++) {
|
|
||||||
if (decoded[i] !== expected[i]) {
|
|
||||||
success = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free WASM objects.
|
|
||||||
encoder.free();
|
|
||||||
decoder.free();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success,
|
|
||||||
sourcePackets,
|
|
||||||
repairPackets,
|
|
||||||
totalPackets: packets.length,
|
|
||||||
dropped: dropCount,
|
|
||||||
recovered: success,
|
|
||||||
decodedBytes: decoded ? decoded.length : 0,
|
|
||||||
expectedBytes: blockSize * frameSize,
|
|
||||||
elapsed: elapsed.toFixed(2) + 'ms',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
async _loadWasm() {
|
|
||||||
try {
|
|
||||||
// Dynamic import of the wasm-pack generated JS glue.
|
|
||||||
this._wasmModule = await import(WZP_WASM_PATH);
|
|
||||||
// Initialize the WASM module (calls __wbg_init).
|
|
||||||
await this._wasmModule.default();
|
|
||||||
|
|
||||||
// Create FEC encoder/decoder instances.
|
|
||||||
// 5 symbols per block, 256-byte symbols — matches native wzp-fec defaults.
|
|
||||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(5, 256);
|
|
||||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(5, 256);
|
|
||||||
this._fecReady = true;
|
|
||||||
|
|
||||||
console.log('[wzp-hybrid] WASM FEC module loaded successfully');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[wzp-hybrid] WASM FEC module failed to load:', e);
|
|
||||||
this._fecReady = false;
|
|
||||||
// Non-fatal: client still works without FEC (like pure variant).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleMessage(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const pcm = new Int16Array(event.data);
|
|
||||||
this.stats.recv++;
|
|
||||||
if (this.onAudio) {
|
|
||||||
this.onAudio(pcm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startStatsTimer() {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this._statsInterval = setInterval(() => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
||||||
const loss = this.stats.sent > 0
|
|
||||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
||||||
: 0;
|
|
||||||
if (this.onStats) {
|
|
||||||
this.onStats({
|
|
||||||
sent: this.stats.sent,
|
|
||||||
recv: this.stats.recv,
|
|
||||||
loss: loss,
|
|
||||||
elapsed: elapsed,
|
|
||||||
fecRecovered: this.stats.fecRecovered,
|
|
||||||
fecReady: this._fecReady,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopStatsTimer() {
|
|
||||||
if (this._statsInterval) {
|
|
||||||
clearInterval(this._statsInterval);
|
|
||||||
this._statsInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_status(msg) {
|
|
||||||
if (this.onStatus) this.onStatus(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup() {
|
|
||||||
this._connected = false;
|
|
||||||
this._stopStatsTimer();
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPHybridClient = WZPHybridClient;
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
// WarzonePhone — WZP-WS-FEC client (Variant 5).
|
|
||||||
// WebSocket transport, WZP wire protocol, WASM RaptorQ FEC.
|
|
||||||
// Application-layer redundancy even over TCP.
|
|
||||||
// Sends MediaPacket-formatted frames with FEC encoding.
|
|
||||||
// Ready for direct relay WS support (no bridge translation needed).
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
|
||||||
const WZP_WS_FEC_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
|
||||||
|
|
||||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
|
||||||
const WZP_WS_FEC_HEADER_SIZE = 12;
|
|
||||||
|
|
||||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
|
||||||
const WZP_WS_FEC_FEC_HEADER_SIZE = 3;
|
|
||||||
|
|
||||||
// FEC parameters.
|
|
||||||
// A 960-sample Int16 PCM frame = 1920 bytes. We use symbol_size = 2048
|
|
||||||
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
|
||||||
const WZP_WS_FEC_BLOCK_SIZE = 5;
|
|
||||||
const WZP_WS_FEC_SYMBOL_SIZE = 2048;
|
|
||||||
|
|
||||||
// Length prefix size within each FEC symbol.
|
|
||||||
const WZP_WS_FEC_LENGTH_PREFIX = 2;
|
|
||||||
|
|
||||||
class WZPWsFecClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
|
||||||
* @param {string} options.room Room name
|
|
||||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
||||||
* @param {Function} options.onStatus callback(string) for UI status
|
|
||||||
* @param {Function} options.onStats callback(Object) for UI stats
|
|
||||||
*/
|
|
||||||
constructor(options) {
|
|
||||||
this.wsUrl = options.wsUrl;
|
|
||||||
this.room = options.room;
|
|
||||||
this.authToken = options.authToken || null;
|
|
||||||
this.onAudio = options.onAudio || null;
|
|
||||||
this.onStatus = options.onStatus || null;
|
|
||||||
this.onStats = options.onStats || null;
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
|
||||||
this._startTime = 0;
|
|
||||||
this._statsInterval = null;
|
|
||||||
this._connected = false;
|
|
||||||
this._authenticated = false;
|
|
||||||
|
|
||||||
// WASM FEC instances (loaded in loadWasm() / connect()).
|
|
||||||
this._wasmModule = null;
|
|
||||||
this.fecEncoder = null;
|
|
||||||
this.fecDecoder = null;
|
|
||||||
this.wasmReady = false;
|
|
||||||
|
|
||||||
// Current FEC block counter for outgoing packets.
|
|
||||||
this._fecBlockId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the WASM FEC module.
|
|
||||||
* Called automatically by connect(), or can be called early.
|
|
||||||
*/
|
|
||||||
async loadWasm() {
|
|
||||||
if (this.wasmReady) return;
|
|
||||||
try {
|
|
||||||
this._wasmModule = await import(WZP_WS_FEC_WASM_PATH);
|
|
||||||
await this._wasmModule.default();
|
|
||||||
|
|
||||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
|
||||||
WZP_WS_FEC_BLOCK_SIZE,
|
|
||||||
WZP_WS_FEC_SYMBOL_SIZE
|
|
||||||
);
|
|
||||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
|
||||||
WZP_WS_FEC_BLOCK_SIZE,
|
|
||||||
WZP_WS_FEC_SYMBOL_SIZE
|
|
||||||
);
|
|
||||||
this.wasmReady = true;
|
|
||||||
console.log('[wzp-ws-fec] WASM FEC module loaded successfully');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[wzp-ws-fec] WASM FEC module failed to load:', e);
|
|
||||||
this.wasmReady = false;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a 12-byte WZP MediaHeader.
|
|
||||||
*
|
|
||||||
* @param {number} seq Sequence number (u16)
|
|
||||||
* @param {number} timestampMs Milliseconds since session start
|
|
||||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
|
||||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
|
||||||
* @param {number} fecBlock FEC block ID (u8)
|
|
||||||
* @param {number} fecSymbol FEC symbol index (u8)
|
|
||||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
|
||||||
* @param {boolean} hasQuality Whether a quality report is attached
|
|
||||||
* @returns {Uint8Array} 12-byte header
|
|
||||||
*/
|
|
||||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
|
||||||
const buf = new ArrayBuffer(WZP_WS_FEC_HEADER_SIZE);
|
|
||||||
const view = new DataView(buf);
|
|
||||||
|
|
||||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
|
||||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
|
||||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
|
||||||
| ((codecId & 0x0F) << 2) // CodecID
|
|
||||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
|
||||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
|
||||||
view.setUint8(0, byte0);
|
|
||||||
|
|
||||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
|
||||||
view.setUint8(1, byte1);
|
|
||||||
|
|
||||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
|
||||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
|
||||||
view.setUint8(8, fecBlock & 0xFF);
|
|
||||||
view.setUint8(9, fecSymbol & 0xFF);
|
|
||||||
view.setUint8(10, 0); // reserved
|
|
||||||
view.setUint8(11, 0); // csrc_count
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a 12-byte MediaHeader from received binary data.
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} data At least 12 bytes
|
|
||||||
* @returns {Object|null} Parsed header fields, or null if too short
|
|
||||||
*/
|
|
||||||
_parseHeader(data) {
|
|
||||||
if (data.byteLength < WZP_WS_FEC_HEADER_SIZE) return null;
|
|
||||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
|
||||||
const byte0 = view.getUint8(0);
|
|
||||||
const byte1 = view.getUint8(1);
|
|
||||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
|
||||||
return {
|
|
||||||
version: (byte0 >> 7) & 1,
|
|
||||||
isRepair: !!((byte0 >> 6) & 1),
|
|
||||||
codecId: (byte0 >> 2) & 0x0F,
|
|
||||||
hasQuality: !!((byte0 >> 1) & 1),
|
|
||||||
fecRatio: fecRatioEncoded / 63.5,
|
|
||||||
seq: view.getUint16(2),
|
|
||||||
timestamp: view.getUint32(4),
|
|
||||||
fecBlock: view.getUint8(8),
|
|
||||||
fecSymbol: view.getUint8(9),
|
|
||||||
reserved: view.getUint8(10),
|
|
||||||
csrcCount: view.getUint8(11),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
|
||||||
* Symbol layout: [len_hi, len_lo, ...pcm_bytes..., ...zero_padding...]
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
|
||||||
* @returns {Uint8Array} Padded symbol of WZP_WS_FEC_SYMBOL_SIZE bytes
|
|
||||||
*/
|
|
||||||
_padToSymbol(pcmBytes) {
|
|
||||||
const symbol = new Uint8Array(WZP_WS_FEC_SYMBOL_SIZE);
|
|
||||||
const len = pcmBytes.length;
|
|
||||||
symbol[0] = (len >> 8) & 0xFF;
|
|
||||||
symbol[1] = len & 0xFF;
|
|
||||||
symbol.set(pcmBytes, WZP_WS_FEC_LENGTH_PREFIX);
|
|
||||||
return symbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} symbol Symbol data (WZP_WS_FEC_SYMBOL_SIZE bytes)
|
|
||||||
* @returns {Uint8Array} Original PCM bytes
|
|
||||||
*/
|
|
||||||
_unpadSymbol(symbol) {
|
|
||||||
const len = (symbol[0] << 8) | symbol[1];
|
|
||||||
if (len > WZP_WS_FEC_SYMBOL_SIZE - WZP_WS_FEC_LENGTH_PREFIX) {
|
|
||||||
// Sanity check: if length is bogus, return empty.
|
|
||||||
return new Uint8Array(0);
|
|
||||||
}
|
|
||||||
return symbol.slice(WZP_WS_FEC_LENGTH_PREFIX, WZP_WS_FEC_LENGTH_PREFIX + len);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection and load the WASM FEC module.
|
|
||||||
* @returns {Promise<void>} resolves when connected
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
if (this._connected) return;
|
|
||||||
|
|
||||||
// Load WASM module in parallel with WebSocket connect.
|
|
||||||
const wasmPromise = this.loadWasm();
|
|
||||||
|
|
||||||
const wsPromise = new Promise((resolve, reject) => {
|
|
||||||
this._status('Connecting (WZP-WS-FEC) to room: ' + this.room + '...');
|
|
||||||
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
// Send auth if token provided.
|
|
||||||
if (this.authToken) {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
|
||||||
}
|
|
||||||
|
|
||||||
this._connected = true;
|
|
||||||
this._authenticated = !this.authToken;
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = Date.now();
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0 };
|
|
||||||
this._startTime = Date.now();
|
|
||||||
this._fecBlockId = 0;
|
|
||||||
this._startStatsTimer();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
// Handle text messages (auth responses).
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'auth_ok') {
|
|
||||||
this._authenticated = true;
|
|
||||||
this._status('Authenticated (WZP-WS-FEC) to room: ' + this.room);
|
|
||||||
}
|
|
||||||
if (msg.type === 'auth_error') {
|
|
||||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
} catch(e) { /* ignore non-JSON text */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._handleMessage(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
const was = this._connected;
|
|
||||||
this._cleanup();
|
|
||||||
if (was) this._status('Disconnected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
} else {
|
|
||||||
this._status('Connection error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all([wasmPromise, wsPromise]);
|
|
||||||
|
|
||||||
const fecStatus = this.wasmReady ? 'FEC ready' : 'FEC unavailable';
|
|
||||||
this._status('Connected (WZP-WS-FEC) to room: ' + this.room + ' (' + fecStatus + ')');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket and clean up.
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this._connected = false;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._stopStatsTimer();
|
|
||||||
// Keep WASM module loaded (reusable), but reset encoder/decoder.
|
|
||||||
if (this.fecEncoder) {
|
|
||||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecEncoder = null;
|
|
||||||
}
|
|
||||||
if (this.fecDecoder) {
|
|
||||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecDecoder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PCM audio frame with FEC encoding over the WebSocket.
|
|
||||||
*
|
|
||||||
* Each PCM frame is padded to a FEC symbol (2048 bytes with length prefix)
|
|
||||||
* and fed to the FEC encoder. When a block of 5 symbols completes, the
|
|
||||||
* encoder outputs source + repair symbols. Each is sent as an individual
|
|
||||||
* WZP MediaPacket with the appropriate fecBlock, fecSymbol, and isRepair
|
|
||||||
* fields in the 12-byte header.
|
|
||||||
*
|
|
||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
||||||
*/
|
|
||||||
async sendAudio(pcmBuffer) {
|
|
||||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
if (!this.wasmReady || !this.fecEncoder) return;
|
|
||||||
|
|
||||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
|
||||||
|
|
||||||
// Pad PCM frame to FEC symbol size with length prefix.
|
|
||||||
const symbol = this._padToSymbol(pcmBytes);
|
|
||||||
|
|
||||||
// Feed to FEC encoder. Returns wire data when block completes.
|
|
||||||
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
|
||||||
|
|
||||||
if (fecOutput) {
|
|
||||||
// Block completed — send all packets (source + repair).
|
|
||||||
const packetSize = WZP_WS_FEC_FEC_HEADER_SIZE + WZP_WS_FEC_SYMBOL_SIZE;
|
|
||||||
const timestampMs = Date.now() - this.startTimestamp;
|
|
||||||
|
|
||||||
for (let offset = 0; offset + packetSize <= fecOutput.length; offset += packetSize) {
|
|
||||||
const blockId = fecOutput[offset];
|
|
||||||
const symbolIdx = fecOutput[offset + 1];
|
|
||||||
const isRepair = fecOutput[offset + 2] !== 0;
|
|
||||||
const symbolData = fecOutput.slice(
|
|
||||||
offset + WZP_WS_FEC_FEC_HEADER_SIZE,
|
|
||||||
offset + packetSize
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build WZP MediaHeader for this FEC symbol.
|
|
||||||
// fecRatio ~0.5 for 50% repair overhead: encoded = round(0.5 * 63.5) = 32
|
|
||||||
const header = this._buildHeader(
|
|
||||||
this.seq,
|
|
||||||
timestampMs,
|
|
||||||
isRepair,
|
|
||||||
0, // codecId = RawPcm16
|
|
||||||
blockId,
|
|
||||||
symbolIdx,
|
|
||||||
0.5, // fecRatio
|
|
||||||
false // hasQuality
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wire frame: header(12) + symbol_data(2048)
|
|
||||||
const packet = new Uint8Array(WZP_WS_FEC_HEADER_SIZE + symbolData.length);
|
|
||||||
packet.set(header, 0);
|
|
||||||
packet.set(symbolData, WZP_WS_FEC_HEADER_SIZE);
|
|
||||||
|
|
||||||
this.ws.send(packet.buffer);
|
|
||||||
this.seq = (this.seq + 1) & 0xFFFF;
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._fecBlockId++;
|
|
||||||
}
|
|
||||||
// If block not yet complete, accumulate (no packets sent yet).
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test FEC encode -> simulate loss -> decode in the browser.
|
|
||||||
* Demonstrates that the WASM RaptorQ module works correctly
|
|
||||||
* with the WZP wire protocol symbol format.
|
|
||||||
*
|
|
||||||
* @param {Object} [opts]
|
|
||||||
* @param {number} [opts.blockSize=5] Source symbols per block
|
|
||||||
* @param {number} [opts.symbolSize=2048] Padded symbol size
|
|
||||||
* @param {number} [opts.frameSize=1920] PCM frame size in bytes
|
|
||||||
* @param {number} [opts.dropCount=2] Number of packets to drop (simulated 30%+ loss)
|
|
||||||
* @returns {Object} Test results
|
|
||||||
*/
|
|
||||||
testFec(opts) {
|
|
||||||
if (!this.wasmReady || !this._wasmModule) {
|
|
||||||
return { success: false, error: 'WASM FEC module not loaded' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockSize = (opts && opts.blockSize) || 5;
|
|
||||||
const symbolSize = (opts && opts.symbolSize) || WZP_WS_FEC_SYMBOL_SIZE;
|
|
||||||
const frameSize = (opts && opts.frameSize) || 1920;
|
|
||||||
const dropCount = (opts && opts.dropCount) || 2;
|
|
||||||
|
|
||||||
const FEC_HDR = 3; // block_id + symbol_idx + is_repair
|
|
||||||
const packetSize = FEC_HDR + symbolSize;
|
|
||||||
|
|
||||||
const t0 = performance.now();
|
|
||||||
|
|
||||||
// Create fresh encoder/decoder for the test.
|
|
||||||
const encoder = new this._wasmModule.WzpFecEncoder(blockSize, symbolSize);
|
|
||||||
const decoder = new this._wasmModule.WzpFecDecoder(blockSize, symbolSize);
|
|
||||||
|
|
||||||
// Generate test frames with known data, padded to symbol size with length prefix.
|
|
||||||
const originalFrames = [];
|
|
||||||
const paddedSymbols = [];
|
|
||||||
for (let i = 0; i < blockSize; i++) {
|
|
||||||
const frame = new Uint8Array(frameSize);
|
|
||||||
for (let j = 0; j < frameSize; j++) {
|
|
||||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
|
||||||
}
|
|
||||||
originalFrames.push(frame);
|
|
||||||
|
|
||||||
// Pad with length prefix (same as _padToSymbol).
|
|
||||||
const sym = new Uint8Array(symbolSize);
|
|
||||||
sym[0] = (frameSize >> 8) & 0xFF;
|
|
||||||
sym[1] = frameSize & 0xFF;
|
|
||||||
sym.set(frame, 2);
|
|
||||||
paddedSymbols.push(sym);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode: feed padded symbols to encoder.
|
|
||||||
let wireData = null;
|
|
||||||
for (const sym of paddedSymbols) {
|
|
||||||
const result = encoder.add_symbol(sym);
|
|
||||||
if (result) wireData = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wireData) {
|
|
||||||
wireData = encoder.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse wire packets.
|
|
||||||
const packets = [];
|
|
||||||
if (wireData) {
|
|
||||||
for (let offset = 0; offset + packetSize <= wireData.length; offset += packetSize) {
|
|
||||||
packets.push({
|
|
||||||
blockId: wireData[offset],
|
|
||||||
symbolIdx: wireData[offset + 1],
|
|
||||||
isRepair: wireData[offset + 2] !== 0,
|
|
||||||
data: wireData.slice(offset + FEC_HDR, offset + packetSize),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePackets = packets.filter(p => !p.isRepair).length;
|
|
||||||
const repairPackets = packets.filter(p => p.isRepair).length;
|
|
||||||
|
|
||||||
// Simulate packet loss: drop `dropCount` source packets from the front.
|
|
||||||
const dropped = [];
|
|
||||||
const surviving = [];
|
|
||||||
for (let i = 0; i < packets.length; i++) {
|
|
||||||
if (i < dropCount) {
|
|
||||||
dropped.push(i);
|
|
||||||
} else {
|
|
||||||
surviving.push(packets[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode from surviving packets.
|
|
||||||
let decoded = null;
|
|
||||||
for (const pkt of surviving) {
|
|
||||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, pkt.data);
|
|
||||||
if (result) {
|
|
||||||
decoded = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify decoded data: extract original frames from decoded symbols.
|
|
||||||
let success = false;
|
|
||||||
if (decoded) {
|
|
||||||
// decoded is the concatenated padded symbols. Extract original frames.
|
|
||||||
const recoveredFrames = [];
|
|
||||||
for (let i = 0; i < blockSize; i++) {
|
|
||||||
const symOffset = i * symbolSize;
|
|
||||||
if (symOffset + symbolSize <= decoded.length) {
|
|
||||||
const sym = decoded.slice(symOffset, symOffset + symbolSize);
|
|
||||||
const len = (sym[0] << 8) | sym[1];
|
|
||||||
recoveredFrames.push(sym.slice(2, 2 + len));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
success = recoveredFrames.length === blockSize;
|
|
||||||
if (success) {
|
|
||||||
for (let i = 0; i < blockSize && success; i++) {
|
|
||||||
if (recoveredFrames[i].length !== originalFrames[i].length) {
|
|
||||||
success = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for (let j = 0; j < originalFrames[i].length; j++) {
|
|
||||||
if (recoveredFrames[i][j] !== originalFrames[i][j]) {
|
|
||||||
success = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free WASM objects.
|
|
||||||
encoder.free();
|
|
||||||
decoder.free();
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success,
|
|
||||||
sourcePackets,
|
|
||||||
repairPackets,
|
|
||||||
totalPackets: packets.length,
|
|
||||||
dropped: dropCount,
|
|
||||||
recovered: !!decoded,
|
|
||||||
symbolSize: symbolSize,
|
|
||||||
frameSize: frameSize,
|
|
||||||
elapsed: elapsed.toFixed(2) + 'ms',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_handleMessage(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const data = new Uint8Array(event.data);
|
|
||||||
if (data.length < WZP_WS_FEC_HEADER_SIZE) return;
|
|
||||||
|
|
||||||
const header = this._parseHeader(data);
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
this.stats.recv++;
|
|
||||||
|
|
||||||
if (!this.wasmReady || !this.fecDecoder) {
|
|
||||||
// No FEC decoder — cannot process FEC-encoded data.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract symbol data (everything after 12-byte MediaHeader).
|
|
||||||
const symbolData = data.slice(WZP_WS_FEC_HEADER_SIZE);
|
|
||||||
|
|
||||||
// Feed symbol to FEC decoder using header fields.
|
|
||||||
const decoded = this.fecDecoder.add_symbol(
|
|
||||||
header.fecBlock,
|
|
||||||
header.fecSymbol,
|
|
||||||
header.isRepair,
|
|
||||||
symbolData
|
|
||||||
);
|
|
||||||
|
|
||||||
if (decoded) {
|
|
||||||
this.stats.fecRecovered++;
|
|
||||||
|
|
||||||
// decoded is concatenated padded symbols.
|
|
||||||
// Each symbol is WZP_WS_FEC_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
|
||||||
for (let off = 0; off + WZP_WS_FEC_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FEC_SYMBOL_SIZE) {
|
|
||||||
const symbol = decoded.slice(off, off + WZP_WS_FEC_SYMBOL_SIZE);
|
|
||||||
const pcmBytes = this._unpadSymbol(symbol);
|
|
||||||
|
|
||||||
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
|
||||||
const pcm = new Int16Array(
|
|
||||||
pcmBytes.buffer,
|
|
||||||
pcmBytes.byteOffset,
|
|
||||||
pcmBytes.byteLength / 2
|
|
||||||
);
|
|
||||||
if (this.onAudio) this.onAudio(pcm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startStatsTimer() {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this._statsInterval = setInterval(() => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
||||||
const loss = this.stats.sent > 0
|
|
||||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
||||||
: 0;
|
|
||||||
if (this.onStats) {
|
|
||||||
this.onStats({
|
|
||||||
sent: this.stats.sent,
|
|
||||||
recv: this.stats.recv,
|
|
||||||
loss: loss,
|
|
||||||
elapsed: elapsed,
|
|
||||||
fecRecovered: this.stats.fecRecovered,
|
|
||||||
fecReady: this.wasmReady,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopStatsTimer() {
|
|
||||||
if (this._statsInterval) {
|
|
||||||
clearInterval(this._statsInterval);
|
|
||||||
this._statsInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_status(msg) {
|
|
||||||
if (this.onStatus) this.onStatus(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup() {
|
|
||||||
this._connected = false;
|
|
||||||
this._stopStatsTimer();
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPWsFecClient = WZPWsFecClient;
|
|
||||||
@@ -1,749 +0,0 @@
|
|||||||
// WarzonePhone — WZP-WS-Full client (Variant 6).
|
|
||||||
// WebSocket transport, WZP wire protocol, WASM FEC + ChaCha20-Poly1305 E2E.
|
|
||||||
// Full encryption — relay sees only ciphertext.
|
|
||||||
// Sends MediaPacket-formatted frames with FEC + encryption.
|
|
||||||
// Ready for direct relay WS support (no bridge translation needed).
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
|
||||||
const WZP_WS_FULL_WASM_PATH = (window.__WZP_BASE_URL || '') + '/wasm/wzp_wasm.js';
|
|
||||||
|
|
||||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
|
||||||
const WZP_WS_FULL_HEADER_SIZE = 12;
|
|
||||||
|
|
||||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
|
||||||
const WZP_WS_FULL_FEC_HEADER_SIZE = 3;
|
|
||||||
|
|
||||||
// FEC parameters.
|
|
||||||
// A 960-sample Int16 PCM frame = 1920 bytes. Symbol size = 2048
|
|
||||||
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
|
||||||
const WZP_WS_FULL_BLOCK_SIZE = 5;
|
|
||||||
const WZP_WS_FULL_SYMBOL_SIZE = 2048;
|
|
||||||
|
|
||||||
// Length prefix size within each FEC symbol.
|
|
||||||
const WZP_WS_FULL_LENGTH_PREFIX = 2;
|
|
||||||
|
|
||||||
// ChaCha20-Poly1305 tag size (16 bytes).
|
|
||||||
const WZP_WS_FULL_TAG_SIZE = 16;
|
|
||||||
|
|
||||||
// X25519 public key size (32 bytes).
|
|
||||||
const WZP_WS_FULL_PUBKEY_SIZE = 32;
|
|
||||||
|
|
||||||
class WZPWsFullClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
|
||||||
* @param {string} options.room Room name
|
|
||||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
||||||
* @param {Function} options.onStatus callback(string) for UI status
|
|
||||||
* @param {Function} options.onStats callback(Object) for UI stats
|
|
||||||
*/
|
|
||||||
constructor(options) {
|
|
||||||
this.wsUrl = options.wsUrl;
|
|
||||||
this.room = options.room;
|
|
||||||
this.authToken = options.authToken || null;
|
|
||||||
this.onAudio = options.onAudio || null;
|
|
||||||
this.onStatus = options.onStatus || null;
|
|
||||||
this.onStats = options.onStats || null;
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
|
||||||
this._startTime = 0;
|
|
||||||
this._statsInterval = null;
|
|
||||||
this._connected = false;
|
|
||||||
this._authenticated = false;
|
|
||||||
|
|
||||||
// WASM instances.
|
|
||||||
this._wasmModule = null;
|
|
||||||
this.fecEncoder = null;
|
|
||||||
this.fecDecoder = null;
|
|
||||||
this.cryptoSession = null;
|
|
||||||
this._keyExchange = null;
|
|
||||||
this.wasmReady = false;
|
|
||||||
|
|
||||||
// Key exchange state.
|
|
||||||
this._keyExchangeComplete = false;
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
|
|
||||||
// Current FEC block counter for outgoing packets.
|
|
||||||
this._fecBlockId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the WASM module (FEC + Crypto).
|
|
||||||
* Called automatically by connect(), or can be called early.
|
|
||||||
*/
|
|
||||||
async loadWasm() {
|
|
||||||
if (this.wasmReady) return;
|
|
||||||
try {
|
|
||||||
this._wasmModule = await import(WZP_WS_FULL_WASM_PATH);
|
|
||||||
await this._wasmModule.default();
|
|
||||||
this.wasmReady = true;
|
|
||||||
console.log('[wzp-ws-full] WASM module loaded successfully');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[wzp-ws-full] WASM module failed to load:', e);
|
|
||||||
this.wasmReady = false;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a 12-byte WZP MediaHeader.
|
|
||||||
*
|
|
||||||
* @param {number} seq Sequence number (u16)
|
|
||||||
* @param {number} timestampMs Milliseconds since session start
|
|
||||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
|
||||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
|
||||||
* @param {number} fecBlock FEC block ID (u8)
|
|
||||||
* @param {number} fecSymbol FEC symbol index (u8)
|
|
||||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
|
||||||
* @param {boolean} hasQuality Whether a quality report is attached
|
|
||||||
* @returns {Uint8Array} 12-byte header
|
|
||||||
*/
|
|
||||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
|
||||||
const buf = new ArrayBuffer(WZP_WS_FULL_HEADER_SIZE);
|
|
||||||
const view = new DataView(buf);
|
|
||||||
|
|
||||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
|
||||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
|
||||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
|
||||||
| ((codecId & 0x0F) << 2) // CodecID
|
|
||||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
|
||||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
|
||||||
view.setUint8(0, byte0);
|
|
||||||
|
|
||||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
|
||||||
view.setUint8(1, byte1);
|
|
||||||
|
|
||||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
|
||||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
|
||||||
view.setUint8(8, fecBlock & 0xFF);
|
|
||||||
view.setUint8(9, fecSymbol & 0xFF);
|
|
||||||
view.setUint8(10, 0); // reserved
|
|
||||||
view.setUint8(11, 0); // csrc_count
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a 12-byte MediaHeader from received binary data.
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} data At least 12 bytes
|
|
||||||
* @returns {Object|null} Parsed header fields, or null if too short
|
|
||||||
*/
|
|
||||||
_parseHeader(data) {
|
|
||||||
if (data.byteLength < WZP_WS_FULL_HEADER_SIZE) return null;
|
|
||||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
|
||||||
const byte0 = view.getUint8(0);
|
|
||||||
const byte1 = view.getUint8(1);
|
|
||||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
|
||||||
return {
|
|
||||||
version: (byte0 >> 7) & 1,
|
|
||||||
isRepair: !!((byte0 >> 6) & 1),
|
|
||||||
codecId: (byte0 >> 2) & 0x0F,
|
|
||||||
hasQuality: !!((byte0 >> 1) & 1),
|
|
||||||
fecRatio: fecRatioEncoded / 63.5,
|
|
||||||
seq: view.getUint16(2),
|
|
||||||
timestamp: view.getUint32(4),
|
|
||||||
fecBlock: view.getUint8(8),
|
|
||||||
fecSymbol: view.getUint8(9),
|
|
||||||
reserved: view.getUint8(10),
|
|
||||||
csrcCount: view.getUint8(11),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
|
||||||
* @returns {Uint8Array} Padded symbol of WZP_WS_FULL_SYMBOL_SIZE bytes
|
|
||||||
*/
|
|
||||||
_padToSymbol(pcmBytes) {
|
|
||||||
const symbol = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
const len = pcmBytes.length;
|
|
||||||
symbol[0] = (len >> 8) & 0xFF;
|
|
||||||
symbol[1] = len & 0xFF;
|
|
||||||
symbol.set(pcmBytes, WZP_WS_FULL_LENGTH_PREFIX);
|
|
||||||
return symbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} symbol Symbol data
|
|
||||||
* @returns {Uint8Array} Original PCM bytes
|
|
||||||
*/
|
|
||||||
_unpadSymbol(symbol) {
|
|
||||||
const len = (symbol[0] << 8) | symbol[1];
|
|
||||||
if (len > WZP_WS_FULL_SYMBOL_SIZE - WZP_WS_FULL_LENGTH_PREFIX) {
|
|
||||||
return new Uint8Array(0);
|
|
||||||
}
|
|
||||||
return symbol.slice(WZP_WS_FULL_LENGTH_PREFIX, WZP_WS_FULL_LENGTH_PREFIX + len);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection, load WASM, and perform key exchange.
|
|
||||||
*
|
|
||||||
* Key exchange protocol over WebSocket:
|
|
||||||
* 1. After WS open, send our 32-byte X25519 public key as first binary message.
|
|
||||||
* 2. First received binary message of exactly 32 bytes = peer's public key.
|
|
||||||
* 3. Derive shared secret, create WzpCryptoSession.
|
|
||||||
* 4. All subsequent binary messages are encrypted MediaPackets.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>} resolves when connected and key exchange completes
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
if (this._connected) return;
|
|
||||||
|
|
||||||
// Load WASM first (needed for key exchange).
|
|
||||||
await this.loadWasm();
|
|
||||||
|
|
||||||
// Prepare key exchange.
|
|
||||||
this._keyExchange = new this._wasmModule.WzpKeyExchange();
|
|
||||||
this._keyExchangeComplete = false;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this._status('Connecting (WZP-WS-Full) to room: ' + this.room + '...');
|
|
||||||
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = Date.now();
|
|
||||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
|
||||||
this._startTime = Date.now();
|
|
||||||
this._fecBlockId = 0;
|
|
||||||
|
|
||||||
// Send auth if token provided.
|
|
||||||
if (this.authToken) {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
|
||||||
this._authenticated = false;
|
|
||||||
} else {
|
|
||||||
this._authenticated = true;
|
|
||||||
// No auth needed — proceed directly to key exchange.
|
|
||||||
this._status('Performing key exchange...');
|
|
||||||
const ourPub = this._keyExchange.public_key();
|
|
||||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store resolve/reject for key exchange completion.
|
|
||||||
this._keyExchangeResolve = resolve;
|
|
||||||
this._keyExchangeReject = reject;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
// Handle text messages (auth responses).
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'auth_ok') {
|
|
||||||
this._authenticated = true;
|
|
||||||
this._status('Authenticated, performing key exchange...');
|
|
||||||
// Auth succeeded — now send public key for key exchange.
|
|
||||||
const ourPub = this._keyExchange.public_key();
|
|
||||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
|
||||||
}
|
|
||||||
if (msg.type === 'auth_error') {
|
|
||||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
|
||||||
if (this._keyExchangeReject) {
|
|
||||||
this._keyExchangeReject(new Error('Auth failed: ' + (msg.reason || 'unknown')));
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
}
|
|
||||||
this._cleanup();
|
|
||||||
}
|
|
||||||
} catch(e) { /* ignore non-JSON text */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this._keyExchangeComplete) {
|
|
||||||
this._handleKeyExchange(event);
|
|
||||||
} else {
|
|
||||||
this._handleMessage(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
const was = this._connected;
|
|
||||||
this._cleanup();
|
|
||||||
if (was) {
|
|
||||||
this._status('Disconnected');
|
|
||||||
} else if (this._keyExchangeReject) {
|
|
||||||
this._keyExchangeReject(new Error('Connection closed during key exchange'));
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
if (this._keyExchangeReject) {
|
|
||||||
this._keyExchangeReject(new Error('WebSocket connection failed'));
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
} else {
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._status('Connection error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the key exchange: first binary message of 32 bytes = peer's public key.
|
|
||||||
*/
|
|
||||||
_handleKeyExchange(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const data = new Uint8Array(event.data);
|
|
||||||
|
|
||||||
if (data.length === WZP_WS_FULL_PUBKEY_SIZE) {
|
|
||||||
// Received peer's public key — derive shared secret.
|
|
||||||
try {
|
|
||||||
const peerPub = data;
|
|
||||||
const secret = this._keyExchange.derive_shared_secret(peerPub);
|
|
||||||
this.cryptoSession = new this._wasmModule.WzpCryptoSession(secret);
|
|
||||||
|
|
||||||
// Free key exchange object (no longer needed).
|
|
||||||
this._keyExchange.free();
|
|
||||||
this._keyExchange = null;
|
|
||||||
|
|
||||||
// Initialize FEC encoder/decoder.
|
|
||||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
|
||||||
WZP_WS_FULL_BLOCK_SIZE,
|
|
||||||
WZP_WS_FULL_SYMBOL_SIZE
|
|
||||||
);
|
|
||||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
|
||||||
WZP_WS_FULL_BLOCK_SIZE,
|
|
||||||
WZP_WS_FULL_SYMBOL_SIZE
|
|
||||||
);
|
|
||||||
|
|
||||||
this._keyExchangeComplete = true;
|
|
||||||
this._connected = true;
|
|
||||||
this._startStatsTimer();
|
|
||||||
this._status('Connected (WZP-WS-Full) to room: ' + this.room + ' (encrypted, FEC active)');
|
|
||||||
|
|
||||||
if (this._keyExchangeResolve) {
|
|
||||||
this._keyExchangeResolve();
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[wzp-ws-full] Key exchange failed:', e);
|
|
||||||
if (this._keyExchangeReject) {
|
|
||||||
this._keyExchangeReject(new Error('Key exchange failed: ' + e.message));
|
|
||||||
this._keyExchangeResolve = null;
|
|
||||||
this._keyExchangeReject = null;
|
|
||||||
}
|
|
||||||
this._cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ignore non-32-byte messages during key exchange.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket and clean up all resources.
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this._connected = false;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._stopStatsTimer();
|
|
||||||
if (this.cryptoSession) {
|
|
||||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.cryptoSession = null;
|
|
||||||
}
|
|
||||||
if (this.fecEncoder) {
|
|
||||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecEncoder = null;
|
|
||||||
}
|
|
||||||
if (this.fecDecoder) {
|
|
||||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecDecoder = null;
|
|
||||||
}
|
|
||||||
if (this._keyExchange) {
|
|
||||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
|
||||||
this._keyExchange = null;
|
|
||||||
}
|
|
||||||
this._keyExchangeComplete = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PCM audio frame with FEC encoding + encryption over the WebSocket.
|
|
||||||
*
|
|
||||||
* Pipeline: PCM -> pad to FEC symbol -> FEC encode -> encrypt -> WS send.
|
|
||||||
*
|
|
||||||
* Each FEC symbol is encrypted individually with ChaCha20-Poly1305. The
|
|
||||||
* 12-byte MediaHeader is used as AAD (authenticated but not encrypted),
|
|
||||||
* so the relay can inspect routing fields without decrypting the payload.
|
|
||||||
*
|
|
||||||
* Wire format per packet:
|
|
||||||
* header(12) + ciphertext(symbol_size) + tag(16)
|
|
||||||
*
|
|
||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
||||||
*/
|
|
||||||
async sendAudio(pcmBuffer) {
|
|
||||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
if (!this.cryptoSession || !this.fecEncoder) return;
|
|
||||||
|
|
||||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
|
||||||
|
|
||||||
// Pad PCM frame to FEC symbol size with length prefix.
|
|
||||||
const symbol = this._padToSymbol(pcmBytes);
|
|
||||||
|
|
||||||
// Feed to FEC encoder. Returns wire data when block completes.
|
|
||||||
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
|
||||||
|
|
||||||
if (fecOutput) {
|
|
||||||
// Block completed — encrypt and send all packets (source + repair).
|
|
||||||
const fecPacketSize = WZP_WS_FULL_FEC_HEADER_SIZE + WZP_WS_FULL_SYMBOL_SIZE;
|
|
||||||
const timestampMs = Date.now() - this.startTimestamp;
|
|
||||||
|
|
||||||
for (let offset = 0; offset + fecPacketSize <= fecOutput.length; offset += fecPacketSize) {
|
|
||||||
const blockId = fecOutput[offset];
|
|
||||||
const symbolIdx = fecOutput[offset + 1];
|
|
||||||
const isRepair = fecOutput[offset + 2] !== 0;
|
|
||||||
const symbolData = fecOutput.slice(
|
|
||||||
offset + WZP_WS_FULL_FEC_HEADER_SIZE,
|
|
||||||
offset + fecPacketSize
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build WZP MediaHeader (used as AAD for encryption).
|
|
||||||
// fecRatio ~0.5 for 50% repair overhead.
|
|
||||||
const header = this._buildHeader(
|
|
||||||
this.seq,
|
|
||||||
timestampMs,
|
|
||||||
isRepair,
|
|
||||||
0, // codecId = RawPcm16
|
|
||||||
blockId,
|
|
||||||
symbolIdx,
|
|
||||||
0.5, // fecRatio
|
|
||||||
false // hasQuality
|
|
||||||
);
|
|
||||||
|
|
||||||
// Encrypt: header as AAD, FEC symbol data as plaintext.
|
|
||||||
// Returns ciphertext + tag (symbol_size + 16 bytes).
|
|
||||||
const ciphertext = this.cryptoSession.encrypt(header, symbolData);
|
|
||||||
this.stats.encrypted++;
|
|
||||||
|
|
||||||
// Wire frame: header(12) + ciphertext_with_tag
|
|
||||||
const packet = new Uint8Array(WZP_WS_FULL_HEADER_SIZE + ciphertext.length);
|
|
||||||
packet.set(header, 0);
|
|
||||||
packet.set(ciphertext, WZP_WS_FULL_HEADER_SIZE);
|
|
||||||
|
|
||||||
this.ws.send(packet.buffer);
|
|
||||||
this.seq = (this.seq + 1) & 0xFFFF;
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._fecBlockId++;
|
|
||||||
}
|
|
||||||
// If block not yet complete, accumulate (no packets sent yet).
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
|
||||||
* Simulates: key exchange -> encrypt -> FEC encode -> simulate loss ->
|
|
||||||
* FEC decode -> decrypt -> verify.
|
|
||||||
*
|
|
||||||
* @returns {Object} Test results
|
|
||||||
*/
|
|
||||||
testCryptoFec() {
|
|
||||||
if (!this.wasmReady || !this._wasmModule) {
|
|
||||||
return { success: false, error: 'WASM module not loaded' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const t0 = performance.now();
|
|
||||||
const wasm = this._wasmModule;
|
|
||||||
|
|
||||||
// --- Key exchange ---
|
|
||||||
const alice = new wasm.WzpKeyExchange();
|
|
||||||
const bob = new wasm.WzpKeyExchange();
|
|
||||||
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
|
||||||
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
|
||||||
|
|
||||||
let secretsMatch = aliceSecret.length === bobSecret.length;
|
|
||||||
if (secretsMatch) {
|
|
||||||
for (let i = 0; i < aliceSecret.length; i++) {
|
|
||||||
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Crypto sessions ---
|
|
||||||
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
|
||||||
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
|
||||||
|
|
||||||
// --- Encrypt + FEC encode ---
|
|
||||||
const encoder = new wasm.WzpFecEncoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
const decoder = new wasm.WzpFecDecoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
|
|
||||||
// Generate test PCM frames (known data).
|
|
||||||
const originalFrames = [];
|
|
||||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE; i++) {
|
|
||||||
const frame = new Uint8Array(1920);
|
|
||||||
for (let j = 0; j < 1920; j++) {
|
|
||||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
|
||||||
}
|
|
||||||
originalFrames.push(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pad and FEC-encode.
|
|
||||||
const paddedSymbols = [];
|
|
||||||
let wireData = null;
|
|
||||||
for (const frame of originalFrames) {
|
|
||||||
const sym = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
sym[0] = (frame.length >> 8) & 0xFF;
|
|
||||||
sym[1] = frame.length & 0xFF;
|
|
||||||
sym.set(frame, 2);
|
|
||||||
paddedSymbols.push(sym);
|
|
||||||
|
|
||||||
const result = encoder.add_symbol(sym);
|
|
||||||
if (result) wireData = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wireData) wireData = encoder.flush();
|
|
||||||
|
|
||||||
// Parse FEC packets and encrypt each one.
|
|
||||||
const FEC_HDR = WZP_WS_FULL_FEC_HEADER_SIZE;
|
|
||||||
const fecPacketSize = FEC_HDR + WZP_WS_FULL_SYMBOL_SIZE;
|
|
||||||
const encryptedPackets = [];
|
|
||||||
|
|
||||||
if (wireData) {
|
|
||||||
for (let offset = 0; offset + fecPacketSize <= wireData.length; offset += fecPacketSize) {
|
|
||||||
const blockId = wireData[offset];
|
|
||||||
const symbolIdx = wireData[offset + 1];
|
|
||||||
const isRepair = wireData[offset + 2] !== 0;
|
|
||||||
const symbolData = wireData.slice(offset + FEC_HDR, offset + fecPacketSize);
|
|
||||||
|
|
||||||
// Build header for AAD (match wire protocol bit layout).
|
|
||||||
const header = new Uint8Array(WZP_WS_FULL_HEADER_SIZE);
|
|
||||||
const fecRatioEncoded = Math.min(127, Math.round(0.5 * 63.5)); // 50% FEC
|
|
||||||
header[0] = ((isRepair ? 1 : 0) << 6)
|
|
||||||
| ((0 & 0x0F) << 2) // codecId=0
|
|
||||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
|
||||||
header[1] = (fecRatioEncoded & 0x3F) << 2; // FecRatioLo
|
|
||||||
header[8] = blockId;
|
|
||||||
header[9] = symbolIdx;
|
|
||||||
|
|
||||||
// Encrypt with Alice's session.
|
|
||||||
const ciphertext = aliceSession.encrypt(header, symbolData);
|
|
||||||
|
|
||||||
encryptedPackets.push({
|
|
||||||
blockId, symbolIdx, isRepair, header, ciphertext,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePackets = encryptedPackets.filter(p => !p.isRepair).length;
|
|
||||||
const repairPackets = encryptedPackets.filter(p => p.isRepair).length;
|
|
||||||
|
|
||||||
// --- Simulate 30% loss (drop 2 of ~7 packets) ---
|
|
||||||
const dropIndices = new Set([1, 3]);
|
|
||||||
const surviving = encryptedPackets.filter((_, i) => !dropIndices.has(i));
|
|
||||||
|
|
||||||
// --- Decrypt + FEC decode on Bob's side ---
|
|
||||||
let fecDecoded = null;
|
|
||||||
let decryptOk = true;
|
|
||||||
|
|
||||||
for (const pkt of surviving) {
|
|
||||||
let symbolData;
|
|
||||||
try {
|
|
||||||
symbolData = bobSession.decrypt(pkt.header, pkt.ciphertext);
|
|
||||||
} catch (e) {
|
|
||||||
decryptOk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, symbolData);
|
|
||||||
if (result) {
|
|
||||||
fecDecoded = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Verify recovered frames ---
|
|
||||||
let fecOk = false;
|
|
||||||
if (fecDecoded) {
|
|
||||||
fecOk = true;
|
|
||||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE && fecOk; i++) {
|
|
||||||
const symOffset = i * WZP_WS_FULL_SYMBOL_SIZE;
|
|
||||||
if (symOffset + WZP_WS_FULL_SYMBOL_SIZE > fecDecoded.length) {
|
|
||||||
fecOk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const sym = fecDecoded.slice(symOffset, symOffset + WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
const len = (sym[0] << 8) | sym[1];
|
|
||||||
const recovered = sym.slice(2, 2 + len);
|
|
||||||
|
|
||||||
if (recovered.length !== originalFrames[i].length) {
|
|
||||||
fecOk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for (let j = 0; j < recovered.length; j++) {
|
|
||||||
if (recovered[j] !== originalFrames[i][j]) {
|
|
||||||
fecOk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup WASM objects.
|
|
||||||
alice.free();
|
|
||||||
bob.free();
|
|
||||||
aliceSession.free();
|
|
||||||
bobSession.free();
|
|
||||||
encoder.free();
|
|
||||||
decoder.free();
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: secretsMatch && decryptOk && fecOk,
|
|
||||||
secretsMatch,
|
|
||||||
decryptOk,
|
|
||||||
fecOk,
|
|
||||||
sourcePackets,
|
|
||||||
repairPackets,
|
|
||||||
totalPackets: encryptedPackets.length,
|
|
||||||
dropped: dropIndices.size,
|
|
||||||
surviving: surviving.length,
|
|
||||||
elapsed: elapsed.toFixed(2) + 'ms',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_handleMessage(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const data = new Uint8Array(event.data);
|
|
||||||
if (data.length < WZP_WS_FULL_HEADER_SIZE) return;
|
|
||||||
|
|
||||||
const header = this._parseHeader(data);
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
this.stats.recv++;
|
|
||||||
|
|
||||||
if (!this.cryptoSession || !this.fecDecoder) return;
|
|
||||||
|
|
||||||
// Extract header bytes (AAD) and ciphertext.
|
|
||||||
const headerBytes = data.slice(0, WZP_WS_FULL_HEADER_SIZE);
|
|
||||||
const ciphertext = data.slice(WZP_WS_FULL_HEADER_SIZE);
|
|
||||||
|
|
||||||
// Decrypt.
|
|
||||||
let symbolData;
|
|
||||||
try {
|
|
||||||
symbolData = this.cryptoSession.decrypt(headerBytes, ciphertext);
|
|
||||||
this.stats.decrypted++;
|
|
||||||
} catch (e) {
|
|
||||||
// Decryption failure — corrupted or replayed packet.
|
|
||||||
console.warn('[wzp-ws-full] decrypt failed:', e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feed decrypted symbol to FEC decoder.
|
|
||||||
const decoded = this.fecDecoder.add_symbol(
|
|
||||||
header.fecBlock,
|
|
||||||
header.fecSymbol,
|
|
||||||
header.isRepair,
|
|
||||||
symbolData
|
|
||||||
);
|
|
||||||
|
|
||||||
if (decoded) {
|
|
||||||
this.stats.fecRecovered++;
|
|
||||||
|
|
||||||
// decoded is concatenated padded symbols.
|
|
||||||
// Each symbol is WZP_WS_FULL_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
|
||||||
for (let off = 0; off + WZP_WS_FULL_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FULL_SYMBOL_SIZE) {
|
|
||||||
const symbol = decoded.slice(off, off + WZP_WS_FULL_SYMBOL_SIZE);
|
|
||||||
const pcmBytes = this._unpadSymbol(symbol);
|
|
||||||
|
|
||||||
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
|
||||||
const pcm = new Int16Array(
|
|
||||||
pcmBytes.buffer,
|
|
||||||
pcmBytes.byteOffset,
|
|
||||||
pcmBytes.byteLength / 2
|
|
||||||
);
|
|
||||||
if (this.onAudio) this.onAudio(pcm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_startStatsTimer() {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this._statsInterval = setInterval(() => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
||||||
const loss = this.stats.sent > 0
|
|
||||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
||||||
: 0;
|
|
||||||
if (this.onStats) {
|
|
||||||
this.onStats({
|
|
||||||
sent: this.stats.sent,
|
|
||||||
recv: this.stats.recv,
|
|
||||||
loss: loss,
|
|
||||||
elapsed: elapsed,
|
|
||||||
encrypted: this.stats.encrypted,
|
|
||||||
decrypted: this.stats.decrypted,
|
|
||||||
fecRecovered: this.stats.fecRecovered,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopStatsTimer() {
|
|
||||||
if (this._statsInterval) {
|
|
||||||
clearInterval(this._statsInterval);
|
|
||||||
this._statsInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_status(msg) {
|
|
||||||
if (this.onStatus) this.onStatus(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup() {
|
|
||||||
this._connected = false;
|
|
||||||
this._keyExchangeComplete = false;
|
|
||||||
this._stopStatsTimer();
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
if (this.cryptoSession) {
|
|
||||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.cryptoSession = null;
|
|
||||||
}
|
|
||||||
if (this.fecEncoder) {
|
|
||||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecEncoder = null;
|
|
||||||
}
|
|
||||||
if (this.fecDecoder) {
|
|
||||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
|
||||||
this.fecDecoder = null;
|
|
||||||
}
|
|
||||||
if (this._keyExchange) {
|
|
||||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
|
||||||
this._keyExchange = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPWsFullClient = WZPWsFullClient;
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
// WarzonePhone — WZP-WS client (Variant 4).
|
|
||||||
// WebSocket transport, WZP wire protocol, no WASM.
|
|
||||||
// Sends MediaPacket-formatted frames instead of raw PCM.
|
|
||||||
// Ready for direct relay WS support (no bridge translation needed).
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
|
||||||
const WZP_WS_HEADER_SIZE = 12;
|
|
||||||
|
|
||||||
class WZPWsClient {
|
|
||||||
/**
|
|
||||||
* @param {Object} options
|
|
||||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
|
||||||
* @param {string} options.room Room name
|
|
||||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
|
||||||
* @param {Function} options.onStatus callback(string) for UI status
|
|
||||||
* @param {Function} options.onStats callback({sent, recv, loss, elapsed}) for UI
|
|
||||||
*/
|
|
||||||
constructor(options) {
|
|
||||||
this.wsUrl = options.wsUrl;
|
|
||||||
this.room = options.room;
|
|
||||||
this.authToken = options.authToken || null;
|
|
||||||
this.onAudio = options.onAudio || null;
|
|
||||||
this.onStatus = options.onStatus || null;
|
|
||||||
this.onStats = options.onStats || null;
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = 0;
|
|
||||||
this.stats = { sent: 0, recv: 0 };
|
|
||||||
this._startTime = 0;
|
|
||||||
this._statsInterval = null;
|
|
||||||
this._connected = false;
|
|
||||||
this._authenticated = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a 12-byte WZP MediaHeader.
|
|
||||||
*
|
|
||||||
* Wire layout (from wzp-proto::packet::MediaHeader):
|
|
||||||
* Byte 0: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
|
|
||||||
* Byte 1: FecRatioLo(6)|Reserved(2)
|
|
||||||
* Bytes 2-3: Sequence number (BE u16)
|
|
||||||
* Bytes 4-7: Timestamp ms (BE u32)
|
|
||||||
* Byte 8: FEC block ID
|
|
||||||
* Byte 9: FEC symbol index
|
|
||||||
* Byte 10: Reserved
|
|
||||||
* Byte 11: CSRC count
|
|
||||||
*
|
|
||||||
* @param {number} seq Sequence number (u16)
|
|
||||||
* @param {number} timestampMs Milliseconds since session start
|
|
||||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
|
||||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
|
||||||
* @param {number} fecBlock FEC block ID (u8)
|
|
||||||
* @param {number} fecSymbol FEC symbol index (u8)
|
|
||||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
|
||||||
* @param {boolean} hasQuality Whether a quality report is attached
|
|
||||||
* @returns {Uint8Array} 12-byte header
|
|
||||||
*/
|
|
||||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
|
||||||
const buf = new ArrayBuffer(WZP_WS_HEADER_SIZE);
|
|
||||||
const view = new DataView(buf);
|
|
||||||
|
|
||||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
|
||||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
|
||||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
|
||||||
| ((codecId & 0x0F) << 2) // CodecID
|
|
||||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
|
||||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
|
||||||
view.setUint8(0, byte0);
|
|
||||||
|
|
||||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
|
||||||
view.setUint8(1, byte1);
|
|
||||||
|
|
||||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
|
||||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
|
||||||
view.setUint8(8, fecBlock & 0xFF);
|
|
||||||
view.setUint8(9, fecSymbol & 0xFF);
|
|
||||||
view.setUint8(10, 0); // reserved
|
|
||||||
view.setUint8(11, 0); // csrc_count
|
|
||||||
return new Uint8Array(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a 12-byte MediaHeader from received binary data.
|
|
||||||
*
|
|
||||||
* @param {Uint8Array} data At least 12 bytes
|
|
||||||
* @returns {Object|null} Parsed header fields, or null if too short
|
|
||||||
*/
|
|
||||||
_parseHeader(data) {
|
|
||||||
if (data.byteLength < WZP_WS_HEADER_SIZE) return null;
|
|
||||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
|
||||||
const byte0 = view.getUint8(0);
|
|
||||||
const byte1 = view.getUint8(1);
|
|
||||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
|
||||||
return {
|
|
||||||
version: (byte0 >> 7) & 1,
|
|
||||||
isRepair: !!((byte0 >> 6) & 1),
|
|
||||||
codecId: (byte0 >> 2) & 0x0F,
|
|
||||||
hasQuality: !!((byte0 >> 1) & 1),
|
|
||||||
fecRatio: fecRatioEncoded / 63.5,
|
|
||||||
seq: view.getUint16(2),
|
|
||||||
timestamp: view.getUint32(4),
|
|
||||||
fecBlock: view.getUint8(8),
|
|
||||||
fecSymbol: view.getUint8(9),
|
|
||||||
reserved: view.getUint8(10),
|
|
||||||
csrcCount: view.getUint8(11),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open WebSocket connection to the wzp-web bridge.
|
|
||||||
* @returns {Promise<void>} resolves when connected
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
if (this._connected) return;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this._status('Connecting (WZP-WS) to room: ' + this.room + '...');
|
|
||||||
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
this.ws.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
// Send auth if token provided.
|
|
||||||
if (this.authToken) {
|
|
||||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
|
||||||
}
|
|
||||||
|
|
||||||
this._connected = true;
|
|
||||||
this._authenticated = !this.authToken; // authenticated immediately if no token needed
|
|
||||||
this.seq = 0;
|
|
||||||
this.startTimestamp = Date.now();
|
|
||||||
this.stats = { sent: 0, recv: 0 };
|
|
||||||
this._startTime = Date.now();
|
|
||||||
this._status('Connected (WZP-WS) to room: ' + this.room);
|
|
||||||
this._startStatsTimer();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
// Handle text messages (auth responses).
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.type === 'auth_ok') {
|
|
||||||
this._authenticated = true;
|
|
||||||
this._status('Authenticated (WZP-WS) to room: ' + this.room);
|
|
||||||
}
|
|
||||||
if (msg.type === 'auth_error') {
|
|
||||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
} catch(e) { /* ignore non-JSON text */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._handleMessage(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
const was = this._connected;
|
|
||||||
this._cleanup();
|
|
||||||
if (was) this._status('Disconnected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._cleanup();
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
} else {
|
|
||||||
this._status('Connection error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close WebSocket and clean up.
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
this._connected = false;
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._stopStatsTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a PCM audio frame wrapped in a WZP MediaPacket over the WebSocket.
|
|
||||||
*
|
|
||||||
* Wire format: 12-byte MediaHeader + raw PCM payload.
|
|
||||||
* The relay can parse this natively without bridge translation.
|
|
||||||
*
|
|
||||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
|
||||||
*/
|
|
||||||
async sendAudio(pcmBuffer) {
|
|
||||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
const header = this._buildHeader(
|
|
||||||
this.seq,
|
|
||||||
Date.now() - this.startTimestamp,
|
|
||||||
false, 0, 0, 0, 0, false
|
|
||||||
);
|
|
||||||
|
|
||||||
// Combine header + payload into single binary frame.
|
|
||||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
|
||||||
const packet = new Uint8Array(WZP_WS_HEADER_SIZE + pcmBytes.length);
|
|
||||||
packet.set(header, 0);
|
|
||||||
packet.set(pcmBytes, WZP_WS_HEADER_SIZE);
|
|
||||||
|
|
||||||
this.ws.send(packet.buffer);
|
|
||||||
this.seq = (this.seq + 1) & 0xFFFF;
|
|
||||||
this.stats.sent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Internal
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_handleMessage(event) {
|
|
||||||
if (!(event.data instanceof ArrayBuffer)) return;
|
|
||||||
const data = new Uint8Array(event.data);
|
|
||||||
if (data.length < WZP_WS_HEADER_SIZE) return; // too small for header
|
|
||||||
|
|
||||||
const header = this._parseHeader(data);
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
// Extract payload (everything after 12-byte header).
|
|
||||||
// Payload is raw PCM Int16 samples.
|
|
||||||
const payloadBytes = data.slice(WZP_WS_HEADER_SIZE);
|
|
||||||
const pcm = new Int16Array(
|
|
||||||
payloadBytes.buffer,
|
|
||||||
payloadBytes.byteOffset,
|
|
||||||
payloadBytes.byteLength / 2
|
|
||||||
);
|
|
||||||
this.stats.recv++;
|
|
||||||
if (this.onAudio) this.onAudio(pcm);
|
|
||||||
}
|
|
||||||
|
|
||||||
_startStatsTimer() {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
this._statsInterval = setInterval(() => {
|
|
||||||
if (!this._connected) {
|
|
||||||
this._stopStatsTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
|
||||||
const loss = this.stats.sent > 0
|
|
||||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
|
||||||
: 0;
|
|
||||||
if (this.onStats) {
|
|
||||||
this.onStats({
|
|
||||||
sent: this.stats.sent,
|
|
||||||
recv: this.stats.recv,
|
|
||||||
loss: loss,
|
|
||||||
elapsed: elapsed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopStatsTimer() {
|
|
||||||
if (this._statsInterval) {
|
|
||||||
clearInterval(this._statsInterval);
|
|
||||||
this._statsInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_status(msg) {
|
|
||||||
if (this.onStatus) this.onStatus(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
_cleanup() {
|
|
||||||
this._connected = false;
|
|
||||||
this._stopStatsTimer();
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
window.WZPWsClient = WZPWsClient;
|
|
||||||
2
crates/wzp-web/static/wasm/.gitignore
vendored
2
crates/wzp-web/static/wasm/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
package.json
|
|
||||||
*.d.ts
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user