14 Commits

Author SHA1 Message Date
Siavash Sameni
d36feb2b59 ci: skip build on CI-only file changes
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:12:31 +04:00
Siavash Sameni
baf82d935b ci: add GitHub mirror workflow
Automatically pushes branches and tags to github.com:manawenuz/wzp.git
on every push to Forgejo. Uses GH_SSH_KEY secret for authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:50:39 +04:00
Siavash Sameni
6eb10327c1 fix: use jq instead of python3 for JSON parsing in CI
ubuntu:24.04 doesn't have python3 installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:47:04 +04:00
Siavash Sameni
50339542fa feat: upload build artifacts as Forgejo releases via API
JS-based upload-artifact action doesn't work with act runner.
Use curl to create a pre-release and attach the tarball instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:36:28 +04:00
Siavash Sameni
c67fa18f14 fix: add missing QualityProfile import in featherchat test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:26:54 +04:00
Siavash Sameni
6c5c4cb671 fix: add libssl-dev for openssl-sys build in CI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:16:39 +04:00
Siavash Sameni
8816f13df8 fix: use stable Rust toolchain — time crate requires rustc >= 1.88
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +04:00
Siavash Sameni
3804b0bf46 fix: use plain HTTPS for featherChat submodule (now public)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:56:42 +04:00
Siavash Sameni
234f3c4bfe fix: use HTTPS + token for featherChat submodule clone in CI
SSH has no keys in the container. Use exact URL remap to
https://<token>@git.tbs.amn.gg/manawenuz/featherChat.git

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:50:24 +04:00
Siavash Sameni
e97f278390 fix: remap submodule to Forgejo SSH URL for CI clone
Use ssh://git@git.tbs.amn.gg:2222/ instead of HTTPS token auth
which gets 403 on cross-repo access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:08 +04:00
Siavash Sameni
f6a77da948 fix: init submodules in CI — remap SSH URLs to Forgejo HTTPS with token
wzp-crypto depends on deps/featherchat (git submodule). Remap the
origin SSH URL to the Forgejo HTTPS mirror with github.token auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:45:25 +04:00
Siavash Sameni
82015a78af fix: authenticate git clone with GITHUB_TOKEN for private repo
The act runner can't clone a private repo over HTTPS without credentials.
Inject the auto-provided github.token into the clone URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:34:04 +04:00
Siavash Sameni
cb13af8abd fix: remove all JS-based actions for Forgejo act runner compatibility
act runner uses bare ubuntu:24.04 without Node.js — actions/checkout,
actions/upload-artifact, etc. all fail. Replace with plain git clone
and shell commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:31:43 +04:00
Siavash Sameni
0b8276b9c7 fix: CI workflow for Forgejo act runner — drop container, install Rust via rustup
The act runner doesn't have Node.js in the rust:1-bookworm container,
breaking JS-based actions (checkout, cache, upload-artifact).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:29:31 +04:00
134 changed files with 882 additions and 23810 deletions

View File

@@ -1,72 +0,0 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
when token efficiency is requested.
---
# Caveman Mode
## Core Rule
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
## Grammar
- Drop articles (a, an, the)
- Drop filler (just, really, basically, actually, simply)
- Drop pleasantries (sure, certainly, of course, happy to)
- Short synonyms (big not extensive, fix not "implement a solution for")
- No hedging (skip "it might be worth considering")
- Fragments fine. No need full sentence
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
- Code blocks unchanged. Caveman speak around code, not in code
- Error messages quoted exact. Caveman only for explanation
## Pattern
```
[thing] [action] [reason]. [next step].
```
Not:
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
Yes:
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
## Examples
**User:** Why is my React component re-rendering?
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
---
**User:** How do I set up a PostgreSQL connection pool?
**Caveman:**
```
Use `pg` pool:
```
```js
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
```
```
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
```
## Boundaries
- Code: write normal. Caveman English only
- Git commits: normal
- PR descriptions: normal
- User say "stop caveman" or "normal mode": revert immediately

View File

@@ -1,5 +0,0 @@
[target.aarch64-linux-android]
linker = "aarch64-linux-android26-clang"
[target.armv7-linux-androideabi]
linker = "armv7a-linux-androideabi26-clang"

View File

@@ -2,57 +2,207 @@ name: Build Release Binaries
on: on:
push: push:
branches:
- main
- 'feat/*'
tags: tags:
- 'v*' - 'v*'
paths-ignore: paths-ignore:
- '.gitea/**' - '.gitea/**'
workflow_dispatch: workflow_dispatch:
inputs:
targets:
description: 'Targets to build (comma-separated: amd64,arm64,armv7)'
required: false
default: 'amd64'
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
jobs: jobs:
build-amd64: build-amd64:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'amd64')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- name: Init submodules
run: | run: |
git config --global url."https://git.manko.yoga/".insteadOf "ssh://git@git.manko.yoga:222/" apt-get update && apt-get install -y git curl jq
git submodule update --init --recursive AUTH_URL="${{ github.server_url }}/${{ github.repository }}.git"
AUTH_URL=$(echo "$AUTH_URL" | sed "s|://|://${{ github.token }}@|")
git clone --depth 1 --branch ${{ github.ref_name }} "$AUTH_URL" .
git config --global url."https://git.tbs.amn.gg/manawenuz/featherChat.git".insteadOf "ssh://git@git.manko.yoga:222/manawenuz/featherChat.git"
git submodule update --init --depth 1
- name: Install Rust + dependencies - name: Install Rust and dependencies
run: | run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y apt-get install -y cmake pkg-config libasound2-dev libssl-dev build-essential
source "$HOME/.cargo/env" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
apt-get update && apt-get install -y cmake pkg-config libasound2-dev ninja-build echo "$HOME/.cargo/bin" >> $GITHUB_PATH
rustc --version
- name: Build relay + tools - name: Build headless binaries
run: | run: |
source "$HOME/.cargo/env" export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
- name: Build audio client
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --bin wzp-client --features audio
cp target/release/wzp-client target/release/wzp-client-audio
cargo build --release --bin wzp-client
- name: Run tests - name: Run tests
run: | run: |
source "$HOME/.cargo/env" export PATH="$HOME/.cargo/bin:$PATH"
cargo test --workspace --lib cargo test --workspace --lib
- name: Upload to rustypaste - name: Package
env:
PASTE_AUTH: ${{ secrets.PASTE_AUTH }}
PASTE_URL: ${{ secrets.PASTE_URL }}
run: | run: |
tar czf /tmp/wzp-linux-amd64.tar.gz \ mkdir -p dist/wzp-linux-amd64
-C target/release wzp-relay wzp-client wzp-web wzp-bench cp target/release/wzp-relay dist/wzp-linux-amd64/
ls -lh /tmp/wzp-linux-amd64.tar.gz cp target/release/wzp-client dist/wzp-linux-amd64/
LINK=$(curl -sF "file=@/tmp/wzp-linux-amd64.tar.gz" \ cp target/release/wzp-client-audio dist/wzp-linux-amd64/
-H "Authorization: ${PASTE_AUTH}" \ cp target/release/wzp-web dist/wzp-linux-amd64/
"https://${PASTE_URL}") cp target/release/wzp-bench dist/wzp-linux-amd64/
echo "Download: ${LINK}" cp -r crates/wzp-web/static dist/wzp-linux-amd64/
cd dist && tar czf wzp-linux-amd64.tar.gz wzp-linux-amd64/
- name: Upload to release
run: |
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
TOKEN="${{ github.token }}"
TAG="build-$(date +%Y%m%d-%H%M%S)"
SHA=$(git rev-parse --short HEAD)
# Create release
RELEASE=$(curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Build $SHA (amd64)\",\"body\":\"Automated build from ${{ github.ref_name }} at $SHA\",\"draft\":false,\"prerelease\":true}")
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id')
# Upload artifact
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=wzp-linux-amd64.tar.gz" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @dist/wzp-linux-amd64.tar.gz
echo "Release created: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
build-arm64:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'arm64')
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
apt-get update && apt-get install -y git curl jq
AUTH_URL="${{ github.server_url }}/${{ github.repository }}.git"
AUTH_URL=$(echo "$AUTH_URL" | sed "s|://|://${{ github.token }}@|")
git clone --depth 1 --branch ${{ github.ref_name }} "$AUTH_URL" .
git config --global url."https://git.tbs.amn.gg/manawenuz/featherChat.git".insteadOf "ssh://git@git.manko.yoga:222/manawenuz/featherChat.git"
git submodule update --init --depth 1
- name: Install Rust and 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 libssl-dev build-essential
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add aarch64-unknown-linux-gnu
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Build
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target aarch64-unknown-linux-gnu \
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
- name: Package
run: |
mkdir -p dist/wzp-linux-arm64
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-client dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-web dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-bench dist/wzp-linux-arm64/
cp -r crates/wzp-web/static dist/wzp-linux-arm64/
cd dist && tar czf wzp-linux-arm64.tar.gz wzp-linux-arm64/
- name: Upload to release
run: |
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
TOKEN="${{ github.token }}"
TAG="build-arm64-$(date +%Y%m%d-%H%M%S)"
SHA=$(git rev-parse --short HEAD)
RELEASE=$(curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Build $SHA (arm64)\",\"body\":\"Automated build from ${{ github.ref_name }} at $SHA\",\"draft\":false,\"prerelease\":true}")
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id')
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=wzp-linux-arm64.tar.gz" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @dist/wzp-linux-arm64.tar.gz
echo "Release created: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
build-armv7:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'armv7')
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
apt-get update && apt-get install -y git curl jq
AUTH_URL="${{ github.server_url }}/${{ github.repository }}.git"
AUTH_URL=$(echo "$AUTH_URL" | sed "s|://|://${{ github.token }}@|")
git clone --depth 1 --branch ${{ github.ref_name }} "$AUTH_URL" .
git config --global url."https://git.tbs.amn.gg/manawenuz/featherChat.git".insteadOf "ssh://git@git.manko.yoga:222/manawenuz/featherChat.git"
git submodule update --init --depth 1
- name: Install Rust and 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 libssl-dev build-essential
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add armv7-unknown-linux-gnueabihf
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Build
env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
CC_armv7_unknown_linux_gnueabihf: arm-linux-gnueabihf-gcc
run: |
export PATH="$HOME/.cargo/bin:$PATH"
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 to release
run: |
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
TOKEN="${{ github.token }}"
TAG="build-armv7-$(date +%Y%m%d-%H%M%S)"
SHA=$(git rev-parse --short HEAD)
RELEASE=$(curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Build $SHA (armv7)\",\"body\":\"Automated build from ${{ github.ref_name }} at $SHA\",\"draft\":false,\"prerelease\":true}")
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id')
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=wzp-linux-armv7.tar.gz" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @dist/wzp-linux-armv7.tar.gz
echo "Release created: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"

25
.gitignore vendored
View File

@@ -4,28 +4,3 @@
*.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

157
Cargo.lock generated
View File

@@ -43,12 +43,6 @@ 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"
@@ -119,6 +113,26 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "audiopus"
version = "0.3.0-rc.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab55eb0e56d7c6de3d59f544e5db122d7725ec33be6a276ee8241f3be6473955"
dependencies = [
"audiopus_sys",
]
[[package]]
name = "audiopus_sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
dependencies = [
"cmake",
"log",
"pkg-config",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -369,12 +383,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@@ -833,27 +841,6 @@ 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"
@@ -1767,15 +1754,6 @@ 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"
@@ -1818,15 +1796,6 @@ 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"
@@ -2105,30 +2074,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "opusic-c"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9486eb5a1a735bf56430b5b44e21157be30ac9fcc17999ba309981b8bd90d2ff"
dependencies = [
"opusic-sys",
]
[[package]]
name = "opusic-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3280fe5b6f97ac1a35a0ac003e2fb0b92f8e4bdf2b2057e1bf9b87acca5696"
dependencies = [
"cmake",
]
[[package]] [[package]]
name = "os_str_bytes" name = "os_str_bytes"
version = "6.6.1" version = "6.6.1"
@@ -2469,17 +2414,6 @@ 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"
@@ -3391,17 +3325,6 @@ 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"
@@ -3440,14 +3363,10 @@ 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",
] ]
@@ -4260,32 +4179,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wzp-android"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"bytes",
"cc",
"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"
@@ -4313,11 +4206,9 @@ dependencies = [
name = "wzp-codec" name = "wzp-codec"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytemuck", "audiopus",
"codec2", "codec2",
"nnnoiseless", "nnnoiseless",
"opusic-c",
"opusic-sys",
"rand 0.8.5", "rand 0.8.5",
"tracing", "tracing",
"wzp-proto", "wzp-proto",
@@ -4376,8 +4267,6 @@ 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",
@@ -4385,7 +4274,6 @@ dependencies = [
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tokio", "tokio",
"toml", "toml",
"tower-http", "tower-http",
@@ -4405,13 +4293,10 @@ 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",

View File

@@ -9,7 +9,6 @@ members = [
"crates/wzp-relay", "crates/wzp-relay",
"crates/wzp-client", "crates/wzp-client",
"crates/wzp-web", "crates/wzp-web",
"crates/wzp-android",
] ]
[workspace.package] [workspace.package]
@@ -35,19 +34,12 @@ quinn = "0.11"
raptorq = "2" raptorq = "2"
# Codec # Codec
# opusic-c: high-level safe bindings over libopus 1.5.2 (encoder side). audiopus = "0.3.0-rc.0"
# opusic-sys: raw FFI for the decoder side — we build our own DecoderHandle
# because opusic-c::Decoder.inner is pub(crate) and cannot be reached for the
# Phase 3 DRED reconstruction path. See docs/PRD-dred-integration.md.
# Pinned exactly (no caret) for reproducible libopus 1.5.2 across the fleet.
opusic-c = { version = "=1.5.5", default-features = false, features = ["bundled", "dred"] }
opusic-sys = { version = "=0.6.0", default-features = false, features = ["bundled"] }
bytemuck = "1"
codec2 = "0.3" 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", "pkcs8"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
hkdf = "0.12" hkdf = "0.12"
sha2 = "0.10" sha2 = "0.10"

6
android/.gitignore vendored
View File

@@ -1,6 +0,0 @@
.gradle/
build/
app/build/
app/src/main/jniLibs/
local.properties
keystore/*.jks

View File

@@ -1,85 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.wzp.phone"
compileSdk = 34
defaultConfig {
applicationId = "com.wzp.phone"
minSdk = 26 // AAudio requires API 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
ndk { abiFilters += listOf("arm64-v8a") }
}
signingConfigs {
create("release") {
storeFile = file("${project.rootDir}/keystore/wzp-release.jks")
storePassword = "wzphone2024"
keyAlias = "wzp-release"
keyPassword = "wzphone2024"
}
getByName("debug") {
storeFile = file("${project.rootDir}/keystore/wzp-debug.jks")
storePassword = "android"
keyAlias = "wzp-debug"
keyPassword = "android"
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
ndkVersion = "26.1.10909125"
}
// cargo-ndk integration: build the Rust native library for Android targets
tasks.register<Exec>("cargoNdkBuild") {
workingDir = file("${project.rootDir}/..")
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"-o", "${project.projectDir}/src/main/jniLibs",
"build", "--release", "-p", "wzp-android"
)
}
// Skip cargo-ndk in CI/Docker — .so is pre-built into jniLibs
// tasks.named("preBuild") { dependsOn("cargoNdkBuild") }
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
}

View File

@@ -1,9 +0,0 @@
# WZPhone ProGuard rules
# Keep JNI native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep the WZP engine bridge class
-keep class com.wzp.phone.engine.** { *; }

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name="com.wzp.WzpApplication"
android:label="WZ Phone"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name="com.wzp.ui.call.CallActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="com.wzp.service.CallService"
android:foregroundServiceType="microphone"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -1,38 +0,0 @@
package com.wzp
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
/**
* Application entry point for WarzonePhone.
*
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
*/
class WzpApplication : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Active Call",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shown while a VoIP call is in progress"
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
companion object {
const val CHANNEL_ID = "wzp_call_channel"
}
}

View File

@@ -1,359 +0,0 @@
package com.wzp.audio
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlin.math.sqrt
/**
* Audio pipeline that captures mic audio and plays received audio using
* Android AudioRecord/AudioTrack APIs running on JVM threads.
*
* PCM samples are shuttled to/from the Rust engine via JNI ring buffers:
* - Capture: AudioRecord → WzpEngine.writeAudio() → Rust encoder → network
* - Playout: network → Rust decoder → WzpEngine.readAudio() → AudioTrack
*
* All audio is 48kHz, mono, 16-bit PCM (matching Opus codec requirements).
*/
class AudioPipeline(private val context: Context) {
companion object {
private const val TAG = "AudioPipeline"
private const val SAMPLE_RATE = 48000
private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
/** 20ms frame at 48kHz = 960 samples */
private const val FRAME_SAMPLES = 960
}
@Volatile
private var running = false
/** Playout (incoming voice) gain in dB. 0 = unity. */
@Volatile
var playoutGainDb: Float = 0f
/** Capture (mic) gain in dB. 0 = unity. */
@Volatile
var captureGainDb: Float = 0f
/** Whether to attach hardware AEC. Must be set before start(). */
var aecEnabled: Boolean = true
/** Enable debug recording of PCM + RMS histogram to cache dir. */
var debugRecording: Boolean = false
private var captureThread: Thread? = null
private var playoutThread: Thread? = null
// DirectByteBuffers for zero-copy JNI audio transfer.
// Allocated as class fields (NOT locals) because ART's JIT OSR
// can null local variables when it replaces the stack frame mid-loop.
// These survive OSR because they're on the heap.
private val captureDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
private val playoutDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
/** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null
private val debugDir: File by lazy {
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
}
fun start(engine: WzpEngine) {
if (running) return
running = true
drainLatch = CountDownLatch(2) // one for capture, one for playout
captureThread = Thread({
runCapture(engine)
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
// Park thread forever — exiting triggers a libcrypto TLS destructor
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
parkThread()
}, "wzp-capture").apply {
isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
playoutThread = Thread({
runPlayout(engine)
drainLatch?.countDown() // signal: playout loop exited
parkThread()
}, "wzp-playout").apply {
isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
Log.i(TAG, "audio pipeline started")
}
fun stop() {
running = false
// Don't join threads — they are parked as daemons to avoid native TLS crash.
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
Log.i(TAG, "audio pipeline stopped (running=false)")
}
/** Block until both audio threads have exited their loops (max 200ms).
* After this returns, no more JNI calls to the engine will be made. */
fun awaitDrain(): Boolean {
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
captureThread = null
playoutThread = null
drainLatch = null
return ok
}
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
if (db == 0f) return
val linear = 10f.pow(db / 20f)
for (i in 0 until count) {
pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
}
}
private fun computeRms(pcm: ShortArray, count: Int): Int {
var sumSq = 0.0
for (i in 0 until count) {
val s = pcm[i].toDouble()
sumSq += s * s
}
return sqrt(sumSq / count).toInt()
}
private fun parkThread() {
try {
Thread.sleep(Long.MAX_VALUE)
} catch (_: InterruptedException) {
// process exiting
}
}
private fun runCapture(engine: WzpEngine) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
Log.e(TAG, "RECORD_AUDIO permission not granted, capture disabled")
return
}
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING)
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4) // at least 4 frames
val recorder = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
CHANNEL_IN,
ENCODING,
bufSize
)
} catch (e: SecurityException) {
Log.e(TAG, "AudioRecord SecurityException: ${e.message}")
return
}
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord failed to initialize")
recorder.release()
return
}
// Attach hardware AEC if available and enabled in settings
var aec: AcousticEchoCanceler? = null
var ns: NoiseSuppressor? = null
if (aecEnabled) {
if (AcousticEchoCanceler.isAvailable()) {
try {
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
aec?.enabled = true
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
} catch (e: Exception) {
Log.w(TAG, "AEC init failed: ${e.message}")
}
} else {
Log.w(TAG, "AEC not available on this device")
}
// Attach hardware noise suppressor if available
if (NoiseSuppressor.isAvailable()) {
try {
ns = NoiseSuppressor.create(recorder.audioSessionId)
ns?.enabled = true
Log.i(TAG, "NoiseSuppressor enabled")
} catch (e: Exception) {
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
}
}
} else {
Log.i(TAG, "AEC disabled by user setting")
}
recorder.startRecording()
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
val pcm = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug recording init failed: ${e.message}")
}
}
try {
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
applyGain(pcm, read, captureGainDb)
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
captureDirectBuf.clear()
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
engine.writeAudioDirect(captureDirectBuf, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read")
break
}
}
} finally {
pcmOut?.close()
rmsCsv?.close()
recorder.stop()
aec?.release()
ns?.release()
recorder.release()
Log.i(TAG, "capture stopped (frames=$frameIdx)")
}
}
private fun runPlayout(engine: WzpEngine) {
val minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4)
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(CHANNEL_OUT)
.setEncoding(ENCODING)
.build()
)
.setBufferSizeInBytes(bufSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
if (track.state != AudioTrack.STATE_INITIALIZED) {
Log.e(TAG, "AudioTrack failed to initialize")
track.release()
return
}
track.play()
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
val pcm = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV for playout
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug playout recording init failed: ${e.message}")
}
}
try {
while (running) {
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
playoutDirectBuf.clear()
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
if (read >= FRAME_SAMPLES) {
playoutDirectBuf.rewind()
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else {
track.write(silence, 0, FRAME_SAMPLES)
// Log silence frames to RMS as 0
if (rmsCsv != null) {
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,0\n")
}
frameIdx++
Thread.sleep(5)
}
}
} finally {
pcmOut?.close()
rmsCsv?.close()
track.stop()
track.release()
Log.i(TAG, "playout stopped (frames=$frameIdx)")
}
}
}

View File

@@ -1,142 +0,0 @@
package com.wzp.audio
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
/**
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
*
* Wraps [AudioManager] operations and listens for device connection changes
* via [AudioDeviceCallback] (API 23+).
*
* Usage:
* 1. Call [register] when the call starts
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
* 3. Call [unregister] when the call ends
*/
class AudioRouteManager(context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val mainHandler = Handler(Looper.getMainLooper())
/** Listener for audio route changes. */
var onRouteChanged: ((AudioRoute) -> Unit)? = null
/** Current active route. */
var currentRoute: AudioRoute = AudioRoute.EARPIECE
private set
// -- Device callback (API 23+) -------------------------------------------
private val deviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
for (device in addedDevices) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
// A Bluetooth headset was connected — optionally auto-switch
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
}
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
for (device in removedDevices) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
// Bluetooth disconnected — fall back to earpiece or speaker
val fallback = if (audioManager.isSpeakerphoneOn) {
AudioRoute.SPEAKER
} else {
AudioRoute.EARPIECE
}
currentRoute = fallback
onRouteChanged?.invoke(fallback)
}
}
}
}
// -- Public API -----------------------------------------------------------
/** Register the device callback. Call when a call starts. */
fun register() {
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
}
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
fun unregister() {
audioManager.unregisterAudioDeviceCallback(deviceCallback)
stopBluetoothSco()
}
/**
* Enable or disable the loudspeaker.
*
* When enabling speaker, Bluetooth SCO is disconnected.
*/
@Suppress("DEPRECATION")
fun setSpeaker(enabled: Boolean) {
if (enabled) {
stopBluetoothSco()
}
audioManager.isSpeakerphoneOn = enabled
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
onRouteChanged?.invoke(currentRoute)
}
/**
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
*
* When enabling Bluetooth, the speaker is turned off.
*/
@Suppress("DEPRECATION")
fun setBluetoothSco(enabled: Boolean) {
if (enabled) {
audioManager.isSpeakerphoneOn = false
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
currentRoute = AudioRoute.BLUETOOTH
} else {
stopBluetoothSco()
currentRoute = AudioRoute.EARPIECE
}
onRouteChanged?.invoke(currentRoute)
}
/** Check whether a Bluetooth SCO device is currently connected. */
fun isBluetoothAvailable(): Boolean {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
}
/** List available output audio routes. */
fun availableRoutes(): List<AudioRoute> {
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
if (isBluetoothAvailable()) {
routes.add(AudioRoute.BLUETOOTH)
}
return routes
}
// -- Internal -------------------------------------------------------------
@Suppress("DEPRECATION")
private fun stopBluetoothSco() {
if (audioManager.isBluetoothScoOn) {
audioManager.isBluetoothScoOn = false
audioManager.stopBluetoothSco()
}
}
}
/** Audio output route. */
enum class AudioRoute {
/** Phone earpiece (default for calls). */
EARPIECE,
/** Built-in loudspeaker. */
SPEAKER,
/** Bluetooth SCO headset/headphones. */
BLUETOOTH
}

View File

@@ -1,203 +0,0 @@
package com.wzp.data
import android.content.Context
import android.content.SharedPreferences
import com.wzp.ui.call.ServerEntry
import org.json.JSONArray
import org.json.JSONObject
import java.security.SecureRandom
/**
* Persists user settings via SharedPreferences.
*
* Stores: servers, default server index, room name, alias, gain values,
* IPv6 preference, and the identity seed (hex-encoded 32 bytes).
*/
class SettingsRepository(context: Context) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE)
companion object {
private const val KEY_SERVERS = "servers_json"
private const val KEY_SELECTED_SERVER = "selected_server"
private const val KEY_ROOM = "room_name"
private const val KEY_ALIAS = "alias"
private const val KEY_PLAYOUT_GAIN = "playout_gain_db"
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
private const val KEY_AEC_ENABLED = "aec_enabled"
private const val KEY_DEBUG_RECORDING = "debug_recording"
private const val KEY_RECENT_ROOMS = "recent_rooms"
private const val TOFU_PREFIX = "tofu_"
}
// --- Servers ---
fun saveServers(servers: List<ServerEntry>) {
val arr = JSONArray()
servers.forEach { entry ->
arr.put(JSONObject().apply {
put("address", entry.address)
put("label", entry.label)
})
}
prefs.edit().putString(KEY_SERVERS, arr.toString()).apply()
}
fun loadServers(): List<ServerEntry>? {
val json = prefs.getString(KEY_SERVERS, null) ?: return null
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
ServerEntry(obj.getString("address"), obj.getString("label"))
}
} catch (_: Exception) { null }
}
fun saveSelectedServer(index: Int) {
prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply()
}
fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0)
// --- Room ---
fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() }
fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android"
// --- Alias ---
fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() }
/**
* Load alias, generating a random name on first launch.
*/
fun getOrCreateAlias(): String {
val existing = prefs.getString(KEY_ALIAS, null)
if (!existing.isNullOrEmpty()) return existing
val name = generateRandomName()
prefs.edit().putString(KEY_ALIAS, name).apply()
return name
}
private fun generateRandomName(): String {
val adjectives = listOf(
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc"
)
val nouns = listOf(
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus"
)
val adj = adjectives.random()
val noun = nouns.random()
return "$adj $noun"
}
// --- Gain ---
fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() }
fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f)
fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() }
fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f)
// --- IPv6 ---
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
// --- AEC ---
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
// --- Debug recording ---
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
// --- Codec choice ---
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
// --- Identity seed ---
/**
* Get or generate the identity seed. On first call, generates a random
* 32-byte seed and persists it. Subsequent calls return the same seed.
*/
fun getOrCreateSeedHex(): String {
val existing = prefs.getString(KEY_IDENTITY_SEED, null)
if (!existing.isNullOrEmpty()) return existing
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
val hex = seed.joinToString("") { "%02x".format(it) }
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
return hex
}
fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: ""
fun saveSeedHex(hex: String) {
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
}
// --- Recent rooms ---
data class RecentRoom(val relay: String, val room: String)
fun addRecentRoom(relay: String, room: String) {
val rooms = loadRecentRooms().toMutableList()
rooms.removeAll { it.relay == relay && it.room == room }
rooms.add(0, RecentRoom(relay, room))
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
val arr = JSONArray()
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
}
fun loadRecentRooms(): List<RecentRoom> {
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentRoom(o.getString("relay"), o.getString("room"))
}
} catch (_: Exception) { emptyList() }
}
fun clearRecentRooms() {
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
}
// --- Server fingerprint TOFU ---
fun saveServerFingerprint(address: String, fingerprint: String) {
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
}
fun loadServerFingerprint(address: String): String? {
return prefs.getString("$TOFU_PREFIX$address", null)
}
// --- Ping RTT cache ---
fun savePingRtt(address: String, rttMs: Int) {
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
}
fun loadPingRtt(address: String): Int {
return prefs.getInt("ping_rtt_$address", -1)
}
}

View File

@@ -1,242 +0,0 @@
package com.wzp.debug
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Collects call debug data (audio recordings, logs, histograms, stats)
* into a zip file for email sharing.
*/
class DebugReporter(private val context: Context) {
companion object {
private const val TAG = "DebugReporter"
private const val SAMPLE_RATE = 48000
}
/**
* Build a zip with all debug data.
* Returns the zip File on success, or null on failure.
*/
suspend fun collectZip(
callDurationSecs: Double,
finalStatsJson: String,
aecEnabled: Boolean,
alias: String,
server: String,
room: String
): File? = withContext(Dispatchers.IO) {
try {
val debugDir = File(context.cacheDir, "wzp_debug")
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// Phase 4: extract DRED / classical PLC counters from the
// stats JSON so they're visible in the meta preamble at a
// glance, not buried in the trailing JSON dump.
val dredReconstructions = extractLongField(finalStatsJson, "dred_reconstructions")
val classicalPlc = extractLongField(finalStatsJson, "classical_plc_invocations")
val framesDecoded = extractLongField(finalStatsJson, "frames_decoded")
val fecRecovered = extractLongField(finalStatsJson, "fec_recovered")
// 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("=== Loss Recovery ===")
appendLine("Frames decoded: $framesDecoded")
appendLine("DRED reconstructions: $dredReconstructions (Opus neural recovery)")
appendLine("Classical PLC: $classicalPlc (fallback)")
appendLine("RaptorQ FEC recovered: $fecRecovered (Codec2 only)")
if (framesDecoded > 0) {
val dredPct = 100.0 * dredReconstructions / framesDecoded
val plcPct = 100.0 * classicalPlc / framesDecoded
appendLine("DRED rate: ${"%.2f".format(dredPct)}%")
appendLine("Classical PLC rate: ${"%.2f".format(plcPct)}%")
}
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()
}
/**
* Tiny JSON field extractor — pulls an integer value for a top-level
* field like `"dred_reconstructions":42`. We don't want to pull in a
* full JSON parser just for the debug preamble, and the CallStats
* output is a flat record with well-known field names.
*
* Returns 0 if the field is missing or unparseable.
*/
private fun extractLongField(json: String, field: String): Long {
val key = "\"$field\":"
val idx = json.indexOf(key)
if (idx < 0) return 0
var i = idx + key.length
// Skip whitespace
while (i < json.length && json[i].isWhitespace()) i++
val start = i
while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
return try {
json.substring(start, i).toLong()
} catch (_: NumberFormatException) {
0
}
}
}

View File

@@ -1,120 +0,0 @@
package com.wzp.engine
import org.json.JSONArray
import org.json.JSONObject
/**
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
*
* Constructed from the JSON string returned by [WzpEngine.getStats].
*/
data class CallStats(
/** Current call state ordinal (see [CallStateConstants]). */
val state: Int = 0,
/** Call duration in seconds. */
val durationSecs: Double = 0.0,
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
val qualityTier: Int = 0,
/** Observed packet loss percentage (0..100). */
val lossPct: Float = 0f,
/** Smoothed round-trip time in milliseconds. */
val rttMs: Int = 0,
/** Jitter in milliseconds. */
val jitterMs: Int = 0,
/** Current jitter buffer depth in packets. */
val jitterBufferDepth: Int = 0,
/** Total frames encoded since call start. */
val framesEncoded: Long = 0,
/** Total frames decoded since call start. */
val framesDecoded: Long = 0,
/** Number of playout underruns (buffer empty when audio was needed). */
val underruns: Long = 0,
/** Frames recovered by FEC. */
val fecRecovered: Long = 0,
/** Current mic audio level (RMS, 0-32767). */
val audioLevel: Int = 0,
/** Our current outgoing codec (e.g. "Opus24k"). */
val currentCodec: String = "",
/** Last seen incoming codec from peers. */
val peerCodec: String = "",
/** Whether auto quality mode is active. */
val autoMode: Boolean = false,
/** Number of participants in the room. */
val roomParticipantCount: Int = 0,
/** Participants in the room (fingerprint + optional alias). */
val roomParticipants: List<RoomMember> = emptyList(),
/** SAS verification code (4-digit, null if not in a call). */
val sasCode: Int? = null,
/** Incoming call ID (or "relay|room" for CallSetup). */
val incomingCallId: String? = null,
/** Incoming caller's fingerprint. */
val incomingCallerFp: String? = null,
/** Incoming caller's alias. */
val incomingCallerAlias: String? = null,
) {
/** Human-readable quality label. */
val qualityLabel: String
get() = when (qualityTier) {
0 -> "Good"
1 -> "Degraded"
2 -> "Catastrophic"
else -> "Unknown"
}
companion object {
private fun parseParticipants(arr: JSONArray?): List<RoomMember> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RoomMember(
fingerprint = o.optString("fingerprint", ""),
alias = if (o.isNull("alias")) null else o.optString("alias", null),
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
)
}
}
/** Deserialise from the JSON string produced by the native engine. */
fun fromJson(json: String): CallStats {
return try {
val obj = JSONObject(json)
CallStats(
state = obj.optInt("state", 0),
durationSecs = obj.optDouble("duration_secs", 0.0),
qualityTier = obj.optInt("quality_tier", 0),
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
rttMs = obj.optInt("rtt_ms", 0),
jitterMs = obj.optInt("jitter_ms", 0),
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
framesEncoded = obj.optLong("frames_encoded", 0),
framesDecoded = obj.optLong("frames_decoded", 0),
underruns = obj.optLong("underruns", 0),
fecRecovered = obj.optLong("fec_recovered", 0),
audioLevel = obj.optInt("audio_level", 0),
currentCodec = obj.optString("current_codec", ""),
peerCodec = obj.optString("peer_codec", ""),
autoMode = obj.optBoolean("auto_mode", false),
roomParticipantCount = obj.optInt("room_participant_count", 0),
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
)
} catch (e: Exception) {
CallStats()
}
}
}
}
data class RoomMember(
val fingerprint: String,
val alias: String? = null,
val relayLabel: String? = null
) {
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
val displayName: String
get() = alias?.takeIf { it.isNotBlank() }
?: fingerprint.take(8).ifEmpty { "unknown" }
}

View File

@@ -1,97 +0,0 @@
package com.wzp.engine
import org.json.JSONObject
/**
* Persistent signal connection for direct 1:1 calls.
* Separate from WzpEngine — survives across calls.
*
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
*/
class SignalManager {
private var handle: Long = 0L
val isConnected: Boolean get() = handle != 0L
/**
* Connect to relay and register for direct calls.
* MUST be called from a thread with sufficient stack (8MB).
* Blocks briefly during QUIC connect + register, then returns.
*/
fun connect(relay: String, seedHex: String): Boolean {
if (handle != 0L) return true // already connected
handle = nativeSignalConnect(relay, seedHex)
return handle != 0L
}
/** Get current signal state as parsed object. Non-blocking. */
fun getState(): SignalState {
if (handle == 0L) return SignalState()
val json = nativeSignalGetState(handle) ?: return SignalState()
return try {
val obj = JSONObject(json)
SignalState(
status = obj.optString("status", "idle"),
fingerprint = obj.optString("fingerprint", ""),
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
)
} catch (e: Exception) {
SignalState()
}
}
/** Place a direct call to a target fingerprint. */
fun placeCall(targetFp: String): Int {
if (handle == 0L) return -1
return nativeSignalPlaceCall(handle, targetFp)
}
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
fun answerCall(callId: String, mode: Int = 2): Int {
if (handle == 0L) return -1
return nativeSignalAnswerCall(handle, callId, mode)
}
/** Send hangup signal. */
fun hangup() {
if (handle != 0L) nativeSignalHangup(handle)
}
/** Destroy the signal manager. */
fun destroy() {
if (handle != 0L) {
nativeSignalDestroy(handle)
handle = 0L
}
}
// JNI native methods
private external fun nativeSignalConnect(relay: String, seed: String): Long
private external fun nativeSignalGetState(handle: Long): String?
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
private external fun nativeSignalHangup(handle: Long)
private external fun nativeSignalDestroy(handle: Long)
companion object {
init { System.loadLibrary("wzp_android") }
}
}
/** Signal connection state. */
data class SignalState(
val status: String = "idle",
val fingerprint: String = "",
val incomingCallId: String? = null,
val incomingCallerFp: String? = null,
val incomingCallerAlias: String? = null,
val callSetupRelay: String? = null,
val callSetupRoom: String? = null,
val callSetupId: String? = null,
)

View File

@@ -1,32 +0,0 @@
package com.wzp.engine
/**
* Callback interface for VoIP engine events.
*
* All callbacks are invoked on the main/UI thread.
*/
interface WzpCallback {
/**
* Called when the call state changes.
*
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
* RECONNECTING(3), CLOSED(4)
*/
fun onCallStateChanged(state: Int)
/**
* Called when the network quality tier changes.
*
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
*/
fun onQualityTierChanged(tier: Int)
/**
* Called when an error occurs in the native engine.
*
* @param code numeric error code (negative)
* @param message human-readable description
*/
fun onError(code: Int, message: String)
}

View File

@@ -1,232 +0,0 @@
package com.wzp.engine
/**
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
*
* Lifecycle:
* 1. Construct with a [WzpCallback]
* 2. Call [init] to create the native engine
* 3. Call [startCall] to begin a VoIP session
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
* 5. Call [stopCall] to end the session
* 6. Call [destroy] when the engine is no longer needed
*
* Thread safety: all methods must be called from the same thread (typically main).
*/
class WzpEngine(private val callback: WzpCallback) {
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
private var nativeHandle: Long = 0L
/** Whether the engine has been initialised. */
val isInitialized: Boolean get() = nativeHandle != 0L
/** Create the native engine. Must be called before any other method. */
fun init() {
check(nativeHandle == 0L) { "Engine already initialized" }
nativeHandle = nativeInit()
check(nativeHandle != 0L) { "Native engine creation failed" }
}
/**
* Start a call.
*
* @param relayAddr relay server address (host:port)
* @param room room identifier (used as QUIC SNI)
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
* @param token authentication token (empty = no auth)
* @param alias display name sent to relay for room participant list
* @return 0 on success, negative error code on failure
*/
/**
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
*/
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
check(nativeHandle != 0L) { "Engine not initialized" }
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
if (result == 0) {
callback.onCallStateChanged(CallStateConstants.CONNECTING)
} else {
callback.onError(result, "Failed to start call")
}
return result
}
/** Stop the active call. Safe to call when no call is active. */
@Synchronized
fun stopCall() {
if (nativeHandle != 0L) {
nativeStopCall(nativeHandle)
callback.onCallStateChanged(CallStateConstants.CLOSED)
}
}
/** Mute or unmute the microphone. */
fun setMute(muted: Boolean) {
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
}
/** Enable or disable loudspeaker mode. */
fun setSpeaker(speaker: Boolean) {
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
}
/**
* Get current call statistics as a JSON string.
*
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
*/
@Synchronized
fun getStats(): String {
if (nativeHandle == 0L) return "{}"
return try {
nativeGetStats(nativeHandle) ?: "{}"
} catch (_: Exception) {
"{}"
}
}
/**
* Force a quality profile, overriding adaptive selection.
*
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
*/
fun forceProfile(profile: Int) {
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
}
/** Destroy the native engine and free all resources. The instance must not be reused. */
@Synchronized
fun destroy() {
if (nativeHandle != 0L) {
nativeDestroy(nativeHandle)
nativeHandle = 0L
}
}
/**
* Write captured PCM samples into the engine's capture ring buffer.
* Called from the AudioRecord capture thread.
*/
fun writeAudio(pcm: ShortArray): Int {
if (nativeHandle == 0L) return 0
return nativeWriteAudio(nativeHandle, pcm)
}
/**
* Read decoded PCM samples from the engine's playout ring buffer.
* Called from the AudioTrack playout thread.
*/
fun readAudio(pcm: ShortArray): Int {
if (nativeHandle == 0L) return 0
return nativeReadAudio(nativeHandle, pcm)
}
/**
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
* Called from the AudioRecord capture thread.
*/
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
if (nativeHandle == 0L) return 0
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
}
/**
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order.
* Called from the AudioTrack playout thread.
*/
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
if (nativeHandle == 0L) return 0
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
}
// -- JNI native methods --------------------------------------------------
private external fun nativeInit(): Long
private external fun nativeStartCall(
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
): Int
private external fun nativeStopCall(handle: Long)
private external fun nativeSetMute(handle: Long, muted: Boolean)
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
private external fun nativeGetStats(handle: Long): String?
private external fun nativeForceProfile(handle: Long, profile: Int)
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
private external fun nativeDestroy(handle: Long)
companion object {
init { System.loadLibrary("wzp_android") }
/** Get the identity fingerprint for a seed hex. No engine needed. */
@JvmStatic
private external fun nativeGetFingerprint(seedHex: String): String?
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
@JvmStatic
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
}
private external fun nativePingRelay(handle: Long, relay: String): String?
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
/**
* Ping a relay server. Requires engine to be initialized.
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
*/
fun pingRelay(address: String): String? {
if (nativeHandle == 0L) return null
return nativePingRelay(nativeHandle, address)
}
/**
* Start persistent signaling connection for direct 1:1 calls.
* The engine registers on the relay and listens for incoming calls.
* Call state updates are available via [getStats].
*
* @return 0 on success, -1 on error
*/
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
check(nativeHandle != 0L) { "Engine not initialized" }
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
}
/**
* Place a direct call to a peer by fingerprint.
* Requires [startSignaling] to have been called first.
*
* @return 0 on success, -1 on error
*/
fun placeCall(targetFingerprint: String): Int {
check(nativeHandle != 0L) { "Engine not initialized" }
return nativePlaceCall(nativeHandle, targetFingerprint)
}
/**
* Answer an incoming direct call.
*
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
* @return 0 on success, -1 on error
*/
fun answerCall(callId: String, mode: Int = 2): Int {
check(nativeHandle != 0L) { "Engine not initialized" }
return nativeAnswerCall(nativeHandle, callId, mode)
}
}
/** Integer constants matching the Rust [CallState] enum ordinals. */
object CallStateConstants {
const val IDLE = 0
const val CONNECTING = 1
const val ACTIVE = 2
const val RECONNECTING = 3
const val CLOSED = 4
}

View File

@@ -1,12 +0,0 @@
package com.wzp.net
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
// This file kept for the data class only.
object RelayPinger {
data class PingResult(
val rttMs: Int,
val reachable: Boolean,
val serverFingerprint: String = "",
)
}

View File

@@ -1,172 +0,0 @@
package com.wzp.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.wzp.WzpApplication
import com.wzp.ui.call.CallActivity
/**
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
*
* Responsibilities:
* - Shows a persistent notification during the call
* - Acquires a partial wake lock so the CPU stays on
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
* - Releases all resources when the call ends
*/
class CallService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
// -- Lifecycle ------------------------------------------------------------
override fun onCreate() {
super.onCreate()
acquireWakeLock()
acquireWifiLock()
setAudioMode()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
onStopFromNotification?.invoke()
stopSelf()
return START_NOT_STICKY
}
}
startForeground(NOTIFICATION_ID, buildNotification())
return START_STICKY
}
override fun onDestroy() {
restoreAudioMode()
releaseWifiLock()
releaseWakeLock()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
// -- Notification ---------------------------------------------------------
private fun buildNotification(): Notification {
// Tapping the notification returns to the call screen
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// "End call" action button
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
.setContentTitle("WZ Phone")
.setContentText("Call in progress")
.setSmallIcon(android.R.drawable.ic_menu_call)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
// -- Wake lock ------------------------------------------------------------
private fun acquireWakeLock() {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"wzp:call_wake_lock"
).apply {
acquire(MAX_CALL_DURATION_MS)
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
// -- Wi-Fi lock -----------------------------------------------------------
@Suppress("DEPRECATION")
private fun acquireWifiLock() {
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"wzp:call_wifi_lock"
).apply {
acquire()
}
}
private fun releaseWifiLock() {
wifiLock?.let {
if (it.isHeld) it.release()
}
wifiLock = null
}
// -- Audio mode -----------------------------------------------------------
private fun setAudioMode() {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
previousAudioMode = am.mode
am.mode = AudioManager.MODE_IN_COMMUNICATION
}
private fun restoreAudioMode() {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.mode = previousAudioMode
}
// -- Static helpers -------------------------------------------------------
companion object {
private const val NOTIFICATION_ID = 1001
private const val ACTION_STOP = "com.wzp.service.STOP"
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
/** Called when the user taps "End Call" in the notification. */
var onStopFromNotification: (() -> Unit)? = null
/** Start the foreground call service. */
fun start(context: Context) {
val intent = Intent(context, CallService::class.java)
context.startForegroundService(intent)
}
/** Stop the foreground call service. */
fun stop(context: Context) {
val intent = Intent(context, CallService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}

View File

@@ -1,149 +0,0 @@
package com.wzp.ui.call
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.wzp.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
/**
* Main activity hosting the in-call Compose UI.
*
* Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
* is managed by [com.wzp.service.CallService] foreground service.
*/
class CallActivity : ComponentActivity() {
companion object {
private const val TAG = "CallActivity"
}
private val viewModel: CallViewModel by viewModels()
private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted) {
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.setContext(this)
setContent {
WzpTheme {
var showSettings by remember { mutableStateOf(false) }
if (showSettings) {
SettingsScreen(
viewModel = viewModel,
onBack = { showSettings = false }
)
} else {
InCallScreen(
viewModel = viewModel,
onHangUp = { viewModel.stopCall() },
onOpenSettings = { showSettings = true }
)
}
}
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
// Watch for debug zip ready → launch email intent
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.debugZipReady.collect { zipFile ->
if (zipFile != null && zipFile.exists()) {
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
launchEmailIntent(zipFile)
viewModel.onDebugReportSent()
}
}
}
}
}
private fun launchEmailIntent(zipFile: java.io.File) {
try {
val authority = "${applicationContext.packageName}.fileprovider"
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
val uri = FileProvider.getUriForFile(this, authority, zipFile)
Log.i(TAG, "FileProvider URI: $uri")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "message/rfc822"
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
putExtra(
Intent.EXTRA_TEXT,
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Send debug report"))
Log.i(TAG, "email intent launched")
} catch (e: Exception) {
Log.e(TAG, "email intent failed", e)
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
}
}
override fun onDestroy() {
super.onDestroy()
if (isFinishing) {
viewModel.stopCall()
}
}
}
@Composable
fun WzpTheme(content: @Composable () -> Unit) {
val darkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -1,764 +0,0 @@
package com.wzp.ui.call
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter
import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class ServerEntry(val address: String, val label: String)
data class PingResult(
val rttMs: Int,
val serverFingerprint: String = "",
val reachable: Boolean = rttMs > 0,
)
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null
private var audioStarted = false
private var appContext: Context? = null
private var settings: SettingsRepository? = null
private var debugReporter: DebugReporter? = null
private var lastStatsJson: String = "{}"
private var lastCallDuration: Double = 0.0
private var lastCallServer: String = ""
private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow()
private val _isMuted = MutableStateFlow(false)
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow()
private val _qualityTier = MutableStateFlow(0)
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
val roomName: StateFlow<String> = _roomName.asStateFlow()
private val _selectedServer = MutableStateFlow(0)
val selectedServer: StateFlow<Int> = _selectedServer.asStateFlow()
private val _servers = MutableStateFlow(DEFAULT_SERVERS.toList())
val servers: StateFlow<List<ServerEntry>> = _servers.asStateFlow()
private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
/** Ping results keyed by server address. */
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
/** Known server fingerprints (TOFU). */
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
private val _playoutGainDb = MutableStateFlow(0f)
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
private val _captureGainDb = MutableStateFlow(0f)
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
private val _alias = MutableStateFlow("")
val alias: StateFlow<String> = _alias.asStateFlow()
private val _seedHex = MutableStateFlow("")
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
private val _aecEnabled = MutableStateFlow(true)
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
private val _debugRecording = MutableStateFlow(false)
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
// Quality profile index (matches JNI bridge profile_from_int)
private val _codecChoice = MutableStateFlow(0)
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
/** Key-change warning dialog state. */
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
/** True when a call just ended and debug report can be sent. */
private val _debugReportAvailable = MutableStateFlow(false)
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
private val _debugReportStatus = MutableStateFlow<String?>(null)
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
private val _debugZipReady = MutableStateFlow<File?>(null)
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
private var statsJob: Job? = null
// ── Direct calling state ──
/** 0=room mode, 1=direct call mode */
private val _callMode = MutableStateFlow(0)
val callMode: StateFlow<Int> = _callMode.asStateFlow()
/** Target fingerprint for direct call */
private val _targetFingerprint = MutableStateFlow("")
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
private val _signalState = MutableStateFlow("idle")
val signalState: StateFlow<String> = _signalState.asStateFlow()
/** Incoming call info */
private val _incomingCallId = MutableStateFlow<String?>(null)
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
private val _incomingCallerFp = MutableStateFlow<String?>(null)
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
/** Separate signal manager (persistent, survives calls) */
private var signalManager: com.wzp.engine.SignalManager? = null
private var signalPollJob: Job? = null
fun setCallMode(mode: Int) { _callMode.value = mode }
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
/** Register on relay for direct calls */
fun registerForCalls() {
val serverIdx = _selectedServer.value
val serverList = _servers.value
if (serverIdx >= serverList.size) return
val relay = serverList[serverIdx].address
var seed = _seedHex.value
// Generate seed if empty (fresh install or cleared storage)
if (seed.isEmpty()) {
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
seed = newSeed.joinToString("") { "%02x".format(it) }
_seedHex.value = seed
settings?.saveSeedHex(seed)
Log.i(TAG, "generated new identity seed")
}
val resolvedRelay = resolveToIp(relay) ?: relay
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack.
// Dispatchers.IO threads overflow. Use explicit Java Thread.
Thread(null, {
try {
val mgr = com.wzp.engine.SignalManager()
val ok = mgr.connect(resolvedRelay, seed)
viewModelScope.launch {
if (ok) {
signalManager = mgr
startSignalPolling()
} else {
_errorMessage.value = "Failed to register on relay"
}
}
} catch (e: Exception) {
viewModelScope.launch {
_errorMessage.value = "Register error: ${e.message}"
}
}
}, "wzp-signal-init", 8 * 1024 * 1024).start()
}
/** Poll signal manager state every 500ms */
private fun startSignalPolling() {
signalPollJob?.cancel()
signalPollJob = viewModelScope.launch {
while (isActive) {
val mgr = signalManager
if (mgr != null && mgr.isConnected) {
val state = mgr.getState()
_signalState.value = state.status
_incomingCallId.value = state.incomingCallId
_incomingCallerFp.value = state.incomingCallerFp
_incomingCallerAlias.value = state.incomingCallerAlias
// Auto-connect to media room when call is set up
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
startCallInternal(state.callSetupRelay, state.callSetupRoom)
}
}
delay(500L)
}
}
}
private fun stopSignalPolling() {
signalPollJob?.cancel()
signalPollJob = null
}
/** Place a direct call to the target fingerprint */
fun placeDirectCall() {
val target = _targetFingerprint.value.trim()
if (target.isEmpty()) {
_errorMessage.value = "Enter a fingerprint to call"
return
}
signalManager?.placeCall(target)
}
/** Answer an incoming direct call */
fun answerIncomingCall(mode: Int = 2) {
val callId = _incomingCallId.value ?: return
signalManager?.answerCall(callId, mode)
}
/** Reject an incoming direct call */
fun rejectIncomingCall() {
val callId = _incomingCallId.value ?: return
signalManager?.answerCall(callId, 0)
}
/** Hang up direct call — media ends, signal stays alive */
fun hangupDirectCall() {
signalManager?.hangup()
engine?.stopCall()
engine?.destroy()
engine = null
engineInitialized = false
}
companion object {
private const val TAG = "WzpCall"
val DEFAULT_SERVERS = listOf(
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
)
const val DEFAULT_ROOM = "general"
}
fun setContext(context: Context) {
val appCtx = context.applicationContext
appContext = appCtx
if (audioPipeline == null) {
audioPipeline = AudioPipeline(appCtx)
}
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
}
if (debugReporter == null) {
debugReporter = DebugReporter(appCtx)
}
if (settings == null) {
settings = SettingsRepository(appCtx)
loadSettings()
}
}
private fun loadSettings() {
val s = settings ?: return
s.loadServers()?.let { saved ->
if (saved.isNotEmpty()) _servers.value = saved
}
_selectedServer.value = s.loadSelectedServer().coerceIn(0, _servers.value.lastIndex)
_roomName.value = s.loadRoom()
_alias.value = s.getOrCreateAlias()
_preferIPv6.value = s.loadPreferIPv6()
_playoutGainDb.value = s.loadPlayoutGain()
_captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex()
_aecEnabled.value = s.loadAecEnabled()
_debugRecording.value = s.loadDebugRecording()
_codecChoice.value = s.loadCodecChoice()
_recentRooms.value = s.loadRecentRooms()
}
fun selectServer(index: Int) {
if (index in _servers.value.indices) {
_selectedServer.value = index
settings?.saveSelectedServer(index)
}
}
fun setPreferIPv6(prefer: Boolean) {
_preferIPv6.value = prefer
settings?.savePreferIPv6(prefer)
}
fun addServer(hostPort: String, label: String) {
val current = _servers.value.toMutableList()
current.add(ServerEntry(hostPort, label))
_servers.value = current
settings?.saveServers(current)
}
fun removeServer(index: Int) {
if (index < DEFAULT_SERVERS.size) return // don't remove built-in servers
val current = _servers.value.toMutableList()
if (index in current.indices) {
current.removeAt(index)
_servers.value = current
if (_selectedServer.value >= current.size) {
_selectedServer.value = 0
}
settings?.saveServers(current)
settings?.saveSelectedServer(_selectedServer.value)
}
}
/** Batch-apply servers and selection from Settings draft state. */
fun applyServers(servers: List<ServerEntry>, selected: Int) {
_servers.value = servers
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
settings?.saveServers(servers)
settings?.saveSelectedServer(_selectedServer.value)
}
/**
* Ping all servers via native QUIC. Requires engine to be initialized.
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
*/
fun pingAllServers() {
viewModelScope.launch {
// Ensure engine exists
if (engine == null || engine?.isInitialized != true) {
try {
engine = WzpEngine(this@CallViewModel).also { it.init() }
engineInitialized = true
} catch (e: Exception) {
Log.w(TAG, "engine init for ping failed: $e")
return@launch
}
}
val eng = engine ?: return@launch
val results = mutableMapOf<String, PingResult>()
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
val json = withContext(Dispatchers.IO) {
eng.pingRelay(server.address)
}
if (json != null) {
try {
val obj = JSONObject(json)
val rtt = obj.getInt("rtt_ms")
val fp = obj.optString("server_fingerprint", "")
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
// TOFU
if (fp.isNotEmpty()) {
val saved = settings?.loadServerFingerprint(server.address)
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
known[server.address] = saved ?: fp
}
} catch (_: Exception) {}
}
}
_pingResults.value = results
_knownFingerprints.value = known
}
}
/** Load saved TOFU fingerprints. */
fun loadSavedFingerprints() {
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
settings?.loadServerFingerprint(server.address)?.let {
known[server.address] = it
}
}
_knownFingerprints.value = known
}
/** Get lock status for a server. */
fun lockStatus(address: String): LockStatus {
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
if (!pr.reachable) return LockStatus.OFFLINE
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
}
fun setRoomName(name: String) {
_roomName.value = name
settings?.saveRoom(name)
}
fun setPlayoutGainDb(db: Float) {
_playoutGainDb.value = db
audioPipeline?.playoutGainDb = db
settings?.savePlayoutGain(db)
}
fun setCaptureGainDb(db: Float) {
_captureGainDb.value = db
audioPipeline?.captureGainDb = db
settings?.saveCaptureGain(db)
}
fun setAlias(alias: String) {
_alias.value = alias
settings?.saveAlias(alias)
}
fun restoreSeed(hex: String) {
_seedHex.value = hex
settings?.saveSeedHex(hex)
}
fun setAecEnabled(enabled: Boolean) {
_aecEnabled.value = enabled
settings?.saveAecEnabled(enabled)
}
fun setDebugRecording(enabled: Boolean) {
_debugRecording.value = enabled
settings?.saveDebugRecording(enabled)
}
fun setCodecChoice(choice: Int) {
_codecChoice.value = choice
settings?.saveCodecChoice(choice)
}
/**
* Resolve DNS hostname to IP address on the Kotlin/Android side,
* since Rust's DNS resolution may not work on Android.
* Returns "ip:port" string.
*/
private fun resolveToIp(hostPort: String): String {
val parts = hostPort.split(":")
if (parts.size != 2) return hostPort
val host = parts[0]
val port = parts[1]
// Already an IP address — return as-is
if (host.matches(Regex("""\d+\.\d+\.\d+\.\d+"""))) return hostPort
if (host.contains(":")) return hostPort // IPv6 literal
return try {
val addresses = InetAddress.getAllByName(host)
val preferV6 = _preferIPv6.value
val picked = if (preferV6) {
addresses.firstOrNull { it is Inet6Address } ?: addresses.firstOrNull { it is Inet4Address }
} else {
addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull { it is Inet6Address }
}
if (picked != null) {
val ip = picked.hostAddress ?: host
val formatted = if (picked is Inet6Address) "[$ip]:$port" else "$ip:$port"
formatted
} else {
hostPort
}
} catch (_: Exception) {
hostPort // resolution failed — pass through and let Rust try
}
}
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null
stopAudio() // sets running=false (non-blocking)
stopStatsPolling()
// Wait for audio threads to exit their loops before destroying the engine.
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
val drained = audioPipeline?.awaitDrain() ?: true
if (!drained) {
Log.w(TAG, "teardown: audio threads did not drain in time")
}
audioPipeline = null
Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
engine = null
engineInitialized = false
_callState.value = 0
if (hadCall) {
_debugReportAvailable.value = true
}
if (stopService) {
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
}
Log.i(TAG, "teardown: done")
}
/** Accept the new server key and proceed with the call. */
fun acceptNewFingerprint() {
val info = _keyWarning.value ?: return
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
it[info.address] = info.newFp
}
settings?.saveServerFingerprint(info.address, info.newFp)
_keyWarning.value = null
startCallInternal()
}
fun dismissKeyWarning() {
_keyWarning.value = null
}
fun startCall() {
val serverEntry = _servers.value[_selectedServer.value]
// Check for key change before connecting
val ls = lockStatus(serverEntry.address)
if (ls == LockStatus.CHANGED) {
val known = _knownFingerprints.value[serverEntry.address] ?: ""
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
return
}
startCallInternal()
}
/** Start a call to a specific relay + room (used by direct call setup). */
private fun startCallInternal(relay: String, room: String) {
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
try {
// Don't teardown — keep the signal connection alive
engine = WzpEngine(this)
engine!!.init()
engineInitialized = true
_callState.value = 1
_errorMessage.value = null
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
Log.w(TAG, "service start err: $e")
}
startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val seed = _seedHex.value
val name = _alias.value
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
CallService.onStopFromNotification = { stopCall() }
if (result != 0) {
_callState.value = 0
_errorMessage.value = "Failed to connect to call room (code $result)"
appContext?.let { CallService.stop(it) }
}
} catch (e: Exception) {
Log.e(TAG, "startCallDirect error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
appContext?.let { CallService.stop(it) }
}
}
} catch (e: Exception) {
Log.e(TAG, "startCallDirect error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
}
}
private fun startCallInternal() {
val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
_debugReportAvailable.value = false
_debugReportStatus.value = null
lastCallServer = serverEntry.address
settings?.addRecentRoom(serverEntry.address, room)
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
debugReporter?.prepareForCall()
try {
// Teardown previous call but don't stop the service (we're about to restart it)
teardown(stopService = false)
Log.i(TAG, "startCall: creating engine")
engine = WzpEngine(this)
engine!!.init()
engineInitialized = true
_callState.value = 1
_errorMessage.value = null
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
Log.w(TAG, "service start err: $e")
}
startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val relay = resolveToIp(serverEntry.address)
val seed = _seedHex.value
val name = _alias.value
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
Log.i(TAG, "startCall: engine returned $result")
// Only wire up notification callback after engine is running
CallService.onStopFromNotification = { stopCall() }
if (result != 0) {
_callState.value = 0
_errorMessage.value = "Failed to start call (code $result)"
appContext?.let { CallService.stop(it) }
}
} catch (e: Exception) {
Log.e(TAG, "startCall IO error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
appContext?.let { CallService.stop(it) }
}
}
} catch (e: Exception) {
Log.e(TAG, "startCall error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
appContext?.let { CallService.stop(it) }
}
}
fun stopCall() {
Log.i(TAG, "stopCall")
teardown()
}
fun toggleMute() {
val newMuted = !_isMuted.value
_isMuted.value = newMuted
try { engine?.setMute(newMuted) } catch (_: Exception) {}
}
fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker
audioRouteManager?.setSpeaker(newSpeaker)
}
fun clearError() { _errorMessage.value = null }
fun sendDebugReport() {
val reporter = debugReporter ?: return
_debugReportStatus.value = "Preparing debug report..."
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val zipFile = reporter.collectZip(
callDurationSecs = lastCallDuration,
finalStatsJson = lastStatsJson,
aecEnabled = _aecEnabled.value,
alias = _alias.value,
server = lastCallServer,
room = _roomName.value
)
if (zipFile != null) {
_debugZipReady.value = zipFile
_debugReportStatus.value = "ready"
} else {
_debugReportStatus.value = "Error: failed to create zip"
}
_debugReportAvailable.value = false
}
}
/** Called by Activity after email intent is launched. */
fun onDebugReportSent() {
_debugZipReady.value = null
_debugReportStatus.value = null
}
fun dismissDebugReport() {
_debugReportAvailable.value = false
_debugReportStatus.value = null
_debugZipReady.value = null
}
// WzpCallback
override fun onCallStateChanged(state: Int) { _callState.value = state }
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
override fun onError(code: Int, message: String) { _errorMessage.value = "Error $code: $message" }
private fun startAudio() {
if (audioStarted) return
val e = engine ?: return
val ctx = appContext ?: return
// Create a fresh pipeline each call to avoid stale threads
audioPipeline = AudioPipeline(ctx).also {
it.playoutGainDb = _playoutGainDb.value
it.captureGainDb = _captureGainDb.value
it.aecEnabled = _aecEnabled.value
it.debugRecording = _debugRecording.value
it.start(e)
}
audioRouteManager?.register()
audioStarted = true
}
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
audioStarted = false
}
private fun startStatsPolling() {
statsJob?.cancel()
statsJob = viewModelScope.launch {
while (isActive) {
try {
val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) {
Log.d(TAG, "raw: $json")
lastStatsJson = json
val s = CallStats.fromJson(json)
lastCallDuration = s.durationSecs
_stats.value = s
// Only update callState from media engine stats (not signal)
if (s.state != 0) {
_callState.value = s.state
}
if (s.state == 2 && !audioStarted) {
startAudio()
}
}
} catch (_: Exception) {}
delay(500L)
}
}
}
private fun stopStatsPolling() {
statsJob?.cancel()
statsJob = null
}
override fun onCleared() {
super.onCleared()
Log.i(TAG, "onCleared")
teardown()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
package com.wzp.ui.components
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* Deterministic identicon — generates a unique 5x5 symmetric pattern
* from a hex fingerprint string. Identical algorithm to the desktop
* TypeScript implementation in identicon.ts.
*/
@Composable
fun Identicon(
fingerprint: String,
size: Dp = 36.dp,
clickToCopy: Boolean = true,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
val bytes = hashBytes(fingerprint)
val (bg, fg) = deriveColors(bytes)
val grid = buildGrid(bytes)
Canvas(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size * 0.12f))
.then(
if (clickToCopy && fingerprint.isNotEmpty()) {
Modifier.clickable {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
} else Modifier
)
) {
val cellW = this.size.width / 5f
val cellH = this.size.height / 5f
// Background
drawRect(color = bg, size = this.size)
// Foreground cells
for (y in 0 until 5) {
for (x in 0 until 5) {
if (grid[y][x]) {
drawRect(
color = fg,
topLeft = Offset(x * cellW, y * cellH),
size = Size(cellW, cellH),
)
}
}
}
}
}
/**
* Fingerprint text that copies to clipboard on tap.
*/
@Composable
fun CopyableFingerprint(
fingerprint: String,
modifier: Modifier = Modifier,
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color: Color = Color.Unspecified,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
androidx.compose.material3.Text(
text = fingerprint,
style = style,
color = color,
modifier = modifier.clickable {
if (fingerprint.isNotEmpty()) {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
}
}
)
}
// --- Internal helpers (matching desktop identicon.ts) ---
private fun hashBytes(hex: String): List<Int> {
val clean = hex.filter { it.isLetterOrDigit() }
val bytes = mutableListOf<Int>()
var i = 0
while (i + 1 < clean.length) {
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
bytes.add(b)
i += 2
}
// Pad to at least 16 bytes
while (bytes.size < 16) bytes.add(0)
return bytes
}
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
val hue1 = bytes[0] * 360f / 256f
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
val bg = hslToColor(hue1, 0.65f, 0.35f)
val fg = hslToColor(hue2, 0.70f, 0.55f)
return bg to fg
}
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
return (0 until 5).map { y ->
val left = (0 until 3).map { x ->
val idx = 2 + y * 3 + x
bytes[idx % bytes.size] > 128
}
// Mirror: col3 = col1, col4 = col0
listOf(left[0], left[1], left[2], left[1], left[0])
}
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val k = { n: Float -> (n + h / 30f) % 12f }
val a = s * min(l, 1f - l)
val f = { n: Float ->
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
}
return Color(f(0f), f(8f), f(4f))
}

View File

@@ -1,567 +0,0 @@
package com.wzp.ui.settings
import androidx.compose.foundation.clickable
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.RadioButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.wzp.ui.call.CallViewModel
import com.wzp.ui.call.ServerEntry
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
viewModel: CallViewModel,
onBack: () -> Unit
) {
val context = LocalContext.current
// Snapshot current values into local draft state
val currentAlias by viewModel.alias.collectAsState()
val currentSeedHex by viewModel.seedHex.collectAsState()
val currentServers by viewModel.servers.collectAsState()
val currentSelectedServer by viewModel.selectedServer.collectAsState()
val currentRoomName by viewModel.roomName.collectAsState()
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
// Draft state — initialized from current values
var draftAlias by remember { mutableStateOf(currentAlias) }
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
val draftServers = remember { currentServers.toMutableStateList() }
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
var draftRoomName by remember { mutableStateOf(currentRoomName) }
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
// Track if anything changed
val hasChanges = draftAlias != currentAlias ||
draftSeedHex != currentSeedHex ||
draftServers.toList() != currentServers ||
draftSelectedServer != currentSelectedServer ||
draftRoomName != currentRoomName ||
draftPreferIPv6 != currentPreferIPv6 ||
draftPlayoutGain != currentPlayoutGain ||
draftCaptureGain != currentCaptureGain ||
draftAecEnabled != currentAecEnabled
var showAddServerDialog by remember { mutableStateOf(false) }
var showRestoreKeyDialog by remember { mutableStateOf(false) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onBack) {
Text("< Back")
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Settings",
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.weight(1f))
// Save button — only enabled when changes exist
Button(
onClick = {
viewModel.setAlias(draftAlias)
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
viewModel.setRoomName(draftRoomName)
viewModel.setPreferIPv6(draftPreferIPv6)
viewModel.setPlayoutGainDb(draftPlayoutGain)
viewModel.setCaptureGainDb(draftCaptureGain)
viewModel.setAecEnabled(draftAecEnabled)
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
onBack()
},
enabled = hasChanges
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(24.dp))
// --- Identity ---
SectionHeader("Identity")
OutlinedTextField(
value = draftAlias,
onValueChange = { draftAlias = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display with identicon
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text(
text = "Fingerprint",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
com.wzp.ui.components.Identicon(
fingerprint = draftSeedHex,
size = 40.dp,
)
Spacer(modifier = Modifier.width(12.dp))
com.wzp.ui.components.CopyableFingerprint(
fingerprint = fingerprint.chunked(4).joinToString(" "),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.height(12.dp))
// Key backup/restore
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
}) {
Text("Copy Key")
}
OutlinedButton(onClick = { showRestoreKeyDialog = true }) {
Text("Restore Key")
}
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Audio ---
SectionHeader("Audio Defaults")
GainSlider(
label = "Voice Volume",
gainDb = draftPlayoutGain,
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(4.dp))
GainSlider(
label = "Mic Gain",
gainDb = draftCaptureGain,
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Echo Cancellation (AEC)",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Disable if audio sounds distorted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = draftAecEnabled,
onCheckedChange = { draftAecEnabled = it }
)
}
Spacer(modifier = Modifier.height(12.dp))
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
val qualityLabels = listOf(
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
)
// Map slider position to JNI profile int:
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
val qualityColors = listOf(
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
)
val currentCodec by viewModel.codecChoice.collectAsState()
val sliderPos = profileToSlider[currentCodec] ?: 3
Text("Quality", style = MaterialTheme.typography.bodyMedium)
Text(
text = "Decode always accepts all codecs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = qualityLabels[sliderPos],
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = qualityColors[sliderPos]
)
Slider(
value = sliderPos.toFloat(),
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
valueRange = 0f..7f,
steps = 6,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Servers ---
SectionHeader("Servers")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
draftServers.forEachIndexed { idx, entry ->
val isSelected = draftSelectedServer == idx
Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalIconButton(
onClick = { draftSelectedServer = idx },
modifier = Modifier
.padding(end = 2.dp)
.height(36.dp)
.width(140.dp),
shape = RoundedCornerShape(8.dp),
colors = if (isSelected) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = entry.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
// Show remove button for non-default servers
if (idx >= 2) {
TextButton(
onClick = {
draftServers.removeAt(idx)
if (draftSelectedServer >= draftServers.size) {
draftSelectedServer = 0
}
},
modifier = Modifier.height(36.dp)
) {
Text("X", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showAddServerDialog = true },
shape = RoundedCornerShape(8.dp)
) {
Text("+ Add Server")
}
// Show selected server address
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Network ---
SectionHeader("Network")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Prefer IPv6",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(
checked = draftPreferIPv6,
onCheckedChange = { draftPreferIPv6 = it }
)
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// --- Room ---
SectionHeader("Room")
OutlinedTextField(
value = draftRoomName,
onValueChange = { draftRoomName = it },
label = { Text("Default Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
}
}
if (showAddServerDialog) {
AddServerDialog(
onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label ->
draftServers.add(ServerEntry("$host:$port", label))
showAddServerDialog = false
}
)
}
if (showRestoreKeyDialog) {
RestoreKeyDialog(
onDismiss = { showRestoreKeyDialog = false },
onRestore = { hex ->
draftSeedHex = hex
showRestoreKeyDialog = false
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
}
)
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
}
@Composable
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val sign = if (gainDb >= 0) "+" else ""
Text(
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = gainDb,
onValueChange = onGainChange,
valueRange = -20f..20f,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun AddServerDialog(
onDismiss: () -> Unit,
onAdd: (host: String, port: String, label: String) -> Unit
) {
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("4433") }
var label by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Server") },
text = {
Column {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host (IP or domain)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
if (host.isNotBlank()) {
val displayLabel = label.ifBlank { host }
onAdd(host.trim(), port.trim(), displayLabel)
}
}
) { Text("Add") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
private fun RestoreKeyDialog(
onDismiss: () -> Unit,
onRestore: (hex: String) -> Unit
) {
var keyInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Restore Identity Key") },
text = {
Column {
Text(
text = "Paste your 64-character hex key below. This will replace your current identity.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = keyInput,
onValueChange = {
keyInput = it.trim().lowercase()
error = null
},
label = { Text("Identity Key (hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = error != null
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val cleaned = keyInput.replace("\\s".toRegex(), "")
if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) {
error = "Key must be exactly 64 hex characters"
} else {
onRestore(cleaned)
}
}
) { Text("Restore") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="debug" path="." />
</paths>

View File

@@ -1,4 +0,0 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

View File

@@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

5
android/gradlew vendored
View File

@@ -1,5 +0,0 @@
#!/bin/sh
# Gradle wrapper script
APP_HOME=$(cd "$(dirname "$0")" && pwd)
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

View File

@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WZPhone"
include(":app")

View File

@@ -1,34 +0,0 @@
[package]
name = "wzp-android"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "WarzonePhone Android native VoIP engine — Oboe audio, JNI bridge, call pipeline"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wzp-proto = { workspace = true }
wzp-codec = { workspace = true }
wzp-fec = { workspace = true }
wzp-crypto = { workspace = true }
wzp-transport = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
bytes = { workspace = true }
serde = { workspace = true }
serde_json = "1"
thiserror = { workspace = true }
async-trait = { workspace = true }
anyhow = "1"
libc = "0.2"
jni = { version = "0.21", default-features = false }
rand = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
tracing-android = "0.2"
[build-dependencies]
cc = "1"

View File

@@ -1,154 +0,0 @@
use std::path::PathBuf;
fn main() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("android") {
// Override broken static getauxval from compiler-rt that crashes
// in shared libraries. Must be compiled first to take link priority.
cc::Build::new()
.file("cpp/getauxval_fix.c")
.compile("getauxval_fix");
let oboe_dir = fetch_oboe();
match oboe_dir {
Some(oboe_path) => {
println!("cargo:warning=Building with Oboe from {:?}", oboe_path);
let mut build = cc::Build::new();
build
.cpp(true)
.std("c++17")
// Use shared libc++ — avoids pulling in static libc stubs
// that crash in shared libraries (getauxval, pthread_create, etc.)
.cpp_link_stdlib(Some("c++_shared"))
.include("cpp")
.include(oboe_path.join("include"))
.include(oboe_path.join("src"))
.define("WZP_HAS_OBOE", None)
.file("cpp/oboe_bridge.cpp");
// Compile all Oboe source files
let src_dir = oboe_path.join("src");
add_cpp_files_recursive(&mut build, &src_dir);
build.compile("oboe_bridge");
}
None => {
println!("cargo:warning=Oboe not found, building with stub");
cc::Build::new()
.cpp(true)
.std("c++17")
.cpp_link_stdlib(Some("c++_shared"))
.file("cpp/oboe_stub.cpp")
.include("cpp")
.compile("oboe_bridge");
}
}
// Dynamic C++ runtime — libc++_shared.so must be in jniLibs alongside
// libwzp_android.so. We copy it there from the NDK sysroot.
//
// WHY NOT STATIC: libc++_static.a + libc++abi.a transitively pull in
// object files from libc.a (static libc) which contain broken stubs for
// getauxval, __init_tcb, pthread_create, etc. These stubs only work in
// statically-linked executables. In shared libraries loaded by dlopen(),
// they SIGSEGV because the static libc init hasn't run.
// Google's official recommendation: use libc++_shared.so for native libs.
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME") {
let arch = if target.contains("aarch64") {
"aarch64-linux-android"
} else if target.contains("armv7") {
"arm-linux-androideabi"
} else if target.contains("x86_64") {
"x86_64-linux-android"
} else {
"aarch64-linux-android"
};
let lib_dir = format!(
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
);
println!("cargo:rustc-link-search=native={lib_dir}");
// Copy libc++_shared.so to the jniLibs directory
let shared_so = format!("{lib_dir}/libc++_shared.so");
if std::path::Path::new(&shared_so).exists() {
let jni_abi = if target.contains("aarch64") {
"arm64-v8a"
} else if target.contains("armv7") {
"armeabi-v7a"
} else {
"arm64-v8a"
};
// Try to copy to the Gradle jniLibs directory
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let jni_dir = format!(
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
);
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
}
}
}
// Oboe needs liblog and libOpenSLES from Android
println!("cargo:rustc-link-lib=log");
println!("cargo:rustc-link-lib=OpenSLES");
} else {
// Non-Android: always use stub
cc::Build::new()
.cpp(true)
.std("c++17")
.file("cpp/oboe_stub.cpp")
.include("cpp")
.compile("oboe_bridge");
}
}
/// Recursively add all .cpp files from a directory to a cc::Build.
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
if !dir.is_dir() {
return;
}
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
add_cpp_files_recursive(build, &path);
} else if path.extension().map_or(false, |e| e == "cpp") {
build.file(&path);
}
}
}
/// Try to find or fetch Oboe headers + source.
fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
return Some(oboe_dir);
}
let status = std::process::Command::new("git")
.args([
"clone",
"--depth=1",
"--branch=1.8.1",
"https://github.com/google/oboe.git",
oboe_dir.to_str().unwrap(),
])
.status();
match status {
Ok(s) if s.success() => {
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
Some(oboe_dir)
} else {
None
}
}
_ => None,
}
}

View File

@@ -1,21 +0,0 @@
// Override the broken static getauxval from compiler-rt/CRT.
// The static version reads from __libc_auxv which is NULL in shared libs
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
// This version calls the real bionic getauxval via dlsym.
#ifdef __ANDROID__
#include <dlfcn.h>
#include <stdint.h>
typedef unsigned long (*getauxval_fn)(unsigned long);
unsigned long getauxval(unsigned long type) {
static getauxval_fn real_getauxval = (getauxval_fn)0;
if (!real_getauxval) {
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
if (!real_getauxval) {
return 0;
}
}
return real_getauxval(type);
}
#endif

View File

@@ -1,278 +0,0 @@
// Full Oboe implementation for Android
// This file is compiled only when targeting Android
#include "oboe_bridge.h"
#ifdef __ANDROID__
#include <oboe/Oboe.h>
#include <android/log.h>
#include <cstring>
#include <atomic>
#define LOG_TAG "wzp-oboe"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
// ---------------------------------------------------------------------------
// Ring buffer helpers (SPSC, lock-free)
// ---------------------------------------------------------------------------
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
const wzp_atomic_int* read_idx,
int32_t capacity) {
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
int32_t avail = w - r;
if (avail < 0) avail += capacity;
return avail;
}
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
const wzp_atomic_int* read_idx,
int32_t capacity) {
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
}
static inline void ring_write(int16_t* buf, int32_t capacity,
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
const int16_t* src, int32_t count) {
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
for (int32_t i = 0; i < count; i++) {
buf[w] = src[i];
w++;
if (w >= capacity) w = 0;
}
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
}
static inline void ring_read(int16_t* buf, int32_t capacity,
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
int16_t* dst, int32_t count) {
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
for (int32_t i = 0; i < count; i++) {
dst[i] = buf[r];
r++;
if (r >= capacity) r = 0;
}
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
static const WzpOboeRings* g_rings = nullptr;
static std::atomic<bool> g_running{false};
static std::atomic<float> g_capture_latency_ms{0.0f};
static std::atomic<float> g_playout_latency_ms{0.0f};
// ---------------------------------------------------------------------------
// Capture callback
// ---------------------------------------------------------------------------
class CaptureCallback : public oboe::AudioStreamDataCallback {
public:
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override {
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
return oboe::DataCallbackResult::Stop;
}
const int16_t* src = static_cast<const int16_t*>(audioData);
int32_t avail = ring_available_write(g_rings->capture_write_idx,
g_rings->capture_read_idx,
g_rings->capture_capacity);
int32_t to_write = (numFrames < avail) ? numFrames : avail;
if (to_write > 0) {
ring_write(g_rings->capture_buf, g_rings->capture_capacity,
g_rings->capture_write_idx, g_rings->capture_read_idx,
src, to_write);
}
// Update latency estimate
auto result = stream->calculateLatencyMillis();
if (result) {
g_capture_latency_ms.store(static_cast<float>(result.value()),
std::memory_order_relaxed);
}
return oboe::DataCallbackResult::Continue;
}
};
// ---------------------------------------------------------------------------
// Playout callback
// ---------------------------------------------------------------------------
class PlayoutCallback : public oboe::AudioStreamDataCallback {
public:
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override {
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
memset(audioData, 0, numFrames * sizeof(int16_t));
return oboe::DataCallbackResult::Stop;
}
int16_t* dst = static_cast<int16_t*>(audioData);
int32_t avail = ring_available_read(g_rings->playout_write_idx,
g_rings->playout_read_idx,
g_rings->playout_capacity);
int32_t to_read = (numFrames < avail) ? numFrames : avail;
if (to_read > 0) {
ring_read(g_rings->playout_buf, g_rings->playout_capacity,
g_rings->playout_write_idx, g_rings->playout_read_idx,
dst, to_read);
}
// Fill remainder with silence on underrun
if (to_read < numFrames) {
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
}
// Update latency estimate
auto result = stream->calculateLatencyMillis();
if (result) {
g_playout_latency_ms.store(static_cast<float>(result.value()),
std::memory_order_relaxed);
}
return oboe::DataCallbackResult::Continue;
}
};
static CaptureCallback g_capture_cb;
static PlayoutCallback g_playout_cb;
// ---------------------------------------------------------------------------
// Public C API
// ---------------------------------------------------------------------------
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
if (g_running.load(std::memory_order_relaxed)) {
LOGW("wzp_oboe_start: already running");
return -1;
}
g_rings = rings;
// Build capture stream
oboe::AudioStreamBuilder captureBuilder;
captureBuilder.setDirection(oboe::Direction::Input)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication)
->setDataCallback(&g_capture_cb);
oboe::Result result = captureBuilder.openStream(g_capture_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
return -2;
}
// Build playout stream
oboe::AudioStreamBuilder playoutBuilder;
playoutBuilder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication)
->setDataCallback(&g_playout_cb);
result = playoutBuilder.openStream(g_playout_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
g_capture_stream->close();
g_capture_stream.reset();
return -3;
}
g_running.store(true, std::memory_order_release);
// Start both streams
result = g_capture_stream->requestStart();
if (result != oboe::Result::OK) {
LOGE("Failed to start capture: %s", oboe::convertToText(result));
g_running.store(false, std::memory_order_release);
g_capture_stream->close();
g_playout_stream->close();
g_capture_stream.reset();
g_playout_stream.reset();
return -4;
}
result = g_playout_stream->requestStart();
if (result != oboe::Result::OK) {
LOGE("Failed to start playout: %s", oboe::convertToText(result));
g_running.store(false, std::memory_order_release);
g_capture_stream->requestStop();
g_capture_stream->close();
g_playout_stream->close();
g_capture_stream.reset();
g_playout_stream.reset();
return -5;
}
LOGI("Oboe started: sr=%d burst=%d ch=%d",
config->sample_rate, config->frames_per_burst, config->channel_count);
return 0;
}
void wzp_oboe_stop(void) {
g_running.store(false, std::memory_order_release);
if (g_capture_stream) {
g_capture_stream->requestStop();
g_capture_stream->close();
g_capture_stream.reset();
}
if (g_playout_stream) {
g_playout_stream->requestStop();
g_playout_stream->close();
g_playout_stream.reset();
}
g_rings = nullptr;
LOGI("Oboe stopped");
}
float wzp_oboe_capture_latency_ms(void) {
return g_capture_latency_ms.load(std::memory_order_relaxed);
}
float wzp_oboe_playout_latency_ms(void) {
return g_playout_latency_ms.load(std::memory_order_relaxed);
}
int wzp_oboe_is_running(void) {
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
}
#else
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
// Provide empty implementations just in case.
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(void)config; (void)rings;
return -99;
}
void wzp_oboe_stop(void) {}
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
int wzp_oboe_is_running(void) { return 0; }
#endif // __ANDROID__

View File

@@ -1,43 +0,0 @@
#ifndef WZP_OBOE_BRIDGE_H
#define WZP_OBOE_BRIDGE_H
#include <stdint.h>
#ifdef __cplusplus
#include <atomic>
typedef std::atomic<int32_t> wzp_atomic_int;
extern "C" {
#else
#include <stdatomic.h>
typedef atomic_int wzp_atomic_int;
#endif
typedef struct {
int32_t sample_rate;
int32_t frames_per_burst;
int32_t channel_count;
} WzpOboeConfig;
typedef struct {
int16_t* capture_buf;
int32_t capture_capacity;
wzp_atomic_int* capture_write_idx;
wzp_atomic_int* capture_read_idx;
int16_t* playout_buf;
int32_t playout_capacity;
wzp_atomic_int* playout_write_idx;
wzp_atomic_int* playout_read_idx;
} WzpOboeRings;
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
void wzp_oboe_stop(void);
float wzp_oboe_capture_latency_ms(void);
float wzp_oboe_playout_latency_ms(void);
int wzp_oboe_is_running(void);
#ifdef __cplusplus
}
#endif
#endif // WZP_OBOE_BRIDGE_H

View File

@@ -1,27 +0,0 @@
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
#include "oboe_bridge.h"
#include <stdio.h>
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(void)config;
(void)rings;
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
return 0;
}
void wzp_oboe_stop(void) {
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
}
float wzp_oboe_capture_latency_ms(void) {
return 0.0f;
}
float wzp_oboe_playout_latency_ms(void) {
return 0.0f;
}
int wzp_oboe_is_running(void) {
return 0;
}

View File

@@ -1,424 +0,0 @@
//! Lock-free SPSC ring buffer audio backend for Android (Oboe).
//!
//! The ring buffers are shared between Rust and C++: the Oboe callbacks
//! (running on a high-priority audio thread) read/write directly into
//! the buffers via atomic indices, while the Rust codec thread on the
//! other side does the same.
use std::sync::atomic::{AtomicI32, Ordering};
use tracing::info;
#[allow(unused_imports)]
use tracing::warn;
/// Number of samples per 20 ms frame at 48 kHz mono.
pub const FRAME_SAMPLES: usize = 960;
/// Default ring buffer capacity: 8 frames = 160 ms at 48 kHz.
const RING_CAPACITY: usize = 7680;
// ---------------------------------------------------------------------------
// FFI declarations matching oboe_bridge.h
// ---------------------------------------------------------------------------
#[repr(C)]
#[allow(non_snake_case)]
struct WzpOboeConfig {
sample_rate: i32,
frames_per_burst: i32,
channel_count: i32,
}
#[repr(C)]
#[allow(non_snake_case)]
struct WzpOboeRings {
capture_buf: *mut i16,
capture_capacity: i32,
capture_write_idx: *mut AtomicI32,
capture_read_idx: *mut AtomicI32,
playout_buf: *mut i16,
playout_capacity: i32,
playout_write_idx: *mut AtomicI32,
playout_read_idx: *mut AtomicI32,
}
unsafe impl Send for WzpOboeRings {}
unsafe impl Sync for WzpOboeRings {}
unsafe extern "C" {
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
fn wzp_oboe_stop();
fn wzp_oboe_capture_latency_ms() -> f32;
fn wzp_oboe_playout_latency_ms() -> f32;
fn wzp_oboe_is_running() -> i32;
}
// ---------------------------------------------------------------------------
// SPSC Ring Buffer
// ---------------------------------------------------------------------------
/// Single-producer single-consumer lock-free ring buffer.
///
/// The producer calls `write()` and the consumer calls `read()`.
/// Atomics use acquire/release ordering to ensure correct visibility
/// across the Oboe audio thread and the Rust codec thread.
pub struct RingBuffer {
buf: Vec<i16>,
capacity: usize,
write_idx: AtomicI32,
read_idx: AtomicI32,
}
impl RingBuffer {
/// Create a new ring buffer with the given capacity (in samples).
///
/// The actual usable capacity is `capacity - 1` to distinguish
/// full from empty.
pub fn new(capacity: usize) -> Self {
Self {
buf: vec![0i16; capacity],
capacity,
write_idx: AtomicI32::new(0),
read_idx: AtomicI32::new(0),
}
}
/// Number of samples available to read.
pub fn available_read(&self) -> usize {
let w = self.write_idx.load(Ordering::Acquire);
let r = self.read_idx.load(Ordering::Relaxed);
let avail = w - r;
if avail < 0 {
(avail + self.capacity as i32) as usize
} else {
avail as usize
}
}
/// Number of samples that can be written before the buffer is full.
pub fn available_write(&self) -> usize {
self.capacity - 1 - self.available_read()
}
/// Write samples into the ring buffer (producer side).
///
/// Returns the number of samples actually written (may be less than
/// `data.len()` if the buffer is nearly full).
pub fn write(&self, data: &[i16]) -> usize {
let avail = self.available_write();
let count = data.len().min(avail);
if count == 0 {
return 0;
}
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr() as *mut i16;
for i in 0..count {
// SAFETY: w is always in [0, capacity) and we are the sole producer.
unsafe {
*buf_ptr.add(w) = data[i];
}
w += 1;
if w >= cap {
w = 0;
}
}
self.write_idx.store(w as i32, Ordering::Release);
count
}
/// Read samples from the ring buffer (consumer side).
///
/// Returns the number of samples actually read (may be less than
/// `out.len()` if the buffer doesn't have enough data).
pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available_read();
let count = out.len().min(avail);
if count == 0 {
return 0;
}
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr();
for i in 0..count {
// SAFETY: r is always in [0, capacity) and we are the sole consumer.
unsafe {
out[i] = *buf_ptr.add(r);
}
r += 1;
if r >= cap {
r = 0;
}
}
self.read_idx.store(r as i32, Ordering::Release);
count
}
/// Get a raw pointer to the buffer data (for FFI).
fn buf_ptr(&self) -> *mut i16 {
self.buf.as_ptr() as *mut i16
}
/// Get a raw pointer to the write index atomic (for FFI).
fn write_idx_ptr(&self) -> *mut AtomicI32 {
&self.write_idx as *const AtomicI32 as *mut AtomicI32
}
/// Get a raw pointer to the read index atomic (for FFI).
fn read_idx_ptr(&self) -> *mut AtomicI32 {
&self.read_idx as *const AtomicI32 as *mut AtomicI32
}
}
// SAFETY: The ring buffer is designed for SPSC use where producer and consumer
// are on different threads. The atomic indices provide the synchronization.
unsafe impl Send for RingBuffer {}
unsafe impl Sync for RingBuffer {}
// ---------------------------------------------------------------------------
// Oboe Backend
// ---------------------------------------------------------------------------
/// Oboe-based audio backend for Android.
///
/// Owns two SPSC ring buffers (capture and playout) that are shared with
/// the C++ Oboe callbacks via raw pointers. The Oboe callbacks run on
/// high-priority audio threads managed by the Android audio system.
pub struct OboeBackend {
capture_ring: RingBuffer,
playout_ring: RingBuffer,
started: bool,
}
impl OboeBackend {
/// Create a new backend with default ring buffer sizes (160 ms each).
pub fn new() -> Self {
Self {
capture_ring: RingBuffer::new(RING_CAPACITY),
playout_ring: RingBuffer::new(RING_CAPACITY),
started: false,
}
}
/// Start Oboe audio streams.
///
/// This sets up the ring buffer pointers and calls into the C++ layer
/// to open and start the capture and playout Oboe streams.
pub fn start(&mut self) -> Result<(), anyhow::Error> {
if self.started {
return Ok(());
}
let config = WzpOboeConfig {
sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1,
};
let rings = WzpOboeRings {
capture_buf: self.capture_ring.buf_ptr(),
capture_capacity: self.capture_ring.capacity as i32,
capture_write_idx: self.capture_ring.write_idx_ptr(),
capture_read_idx: self.capture_ring.read_idx_ptr(),
playout_buf: self.playout_ring.buf_ptr(),
playout_capacity: self.playout_ring.capacity as i32,
playout_write_idx: self.playout_ring.write_idx_ptr(),
playout_read_idx: self.playout_ring.read_idx_ptr(),
};
let ret = unsafe { wzp_oboe_start(&config, &rings) };
if ret != 0 {
return Err(anyhow::anyhow!("wzp_oboe_start failed with code {}", ret));
}
self.started = true;
info!("Oboe backend started");
Ok(())
}
/// Stop Oboe audio streams.
pub fn stop(&mut self) {
if !self.started {
return;
}
unsafe { wzp_oboe_stop() };
self.started = false;
info!("Oboe backend stopped");
}
/// Read captured audio samples from the capture ring buffer.
///
/// Returns the number of samples actually read. The caller should
/// provide a buffer of at least `FRAME_SAMPLES` (960) samples.
pub fn read_capture(&self, out: &mut [i16]) -> usize {
self.capture_ring.read(out)
}
/// Write audio samples to the playout ring buffer.
///
/// Returns the number of samples actually written.
pub fn write_playout(&self, samples: &[i16]) -> usize {
self.playout_ring.write(samples)
}
/// Get the current capture latency in milliseconds (from Oboe).
#[allow(unused)]
pub fn capture_latency_ms(&self) -> f32 {
unsafe { wzp_oboe_capture_latency_ms() }
}
/// Get the current playout latency in milliseconds (from Oboe).
#[allow(unused)]
pub fn playout_latency_ms(&self) -> f32 {
unsafe { wzp_oboe_playout_latency_ms() }
}
/// Check if the Oboe streams are currently running.
#[allow(unused)]
pub fn is_running(&self) -> bool {
unsafe { wzp_oboe_is_running() != 0 }
}
}
impl Drop for OboeBackend {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Thread affinity / priority helpers
// ---------------------------------------------------------------------------
/// Pin the current thread to the highest-numbered CPU cores (big cores on
/// ARM big.LITTLE architectures). Falls back silently on failure.
#[allow(unused)]
pub fn pin_to_big_core() {
#[cfg(target_os = "android")]
{
unsafe {
let num_cpus = libc::sysconf(libc::_SC_NPROCESSORS_ONLN);
if num_cpus <= 0 {
warn!("pin_to_big_core: could not determine CPU count");
return;
}
let num_cpus = num_cpus as usize;
// Target the upper half of CPUs (big cores on most big.LITTLE SoCs)
let start = num_cpus / 2;
let mut set: libc::cpu_set_t = std::mem::zeroed();
libc::CPU_ZERO(&mut set);
for cpu in start..num_cpus {
libc::CPU_SET(cpu, &mut set);
}
let ret = libc::sched_setaffinity(
0, // current thread
std::mem::size_of::<libc::cpu_set_t>(),
&set,
);
if ret != 0 {
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
} else {
info!(start, num_cpus, "pinned to big cores");
}
}
}
#[cfg(not(target_os = "android"))]
{
// No-op on non-Android
}
}
/// Attempt to set SCHED_FIFO real-time priority for the current thread.
/// Falls back silently on failure (requires appropriate permissions on Android).
#[allow(unused)]
pub fn set_realtime_priority() {
#[cfg(target_os = "android")]
{
unsafe {
let param = libc::sched_param {
sched_priority: 2, // Low RT priority — enough for audio, safe
};
let ret = libc::sched_setscheduler(0, libc::SCHED_FIFO, &param);
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);
}
}

View File

@@ -1,128 +0,0 @@
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
//!
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
//!
//! On overflow (writer laps the reader), the writer simply overwrites
//! old buffer data. The reader detects the lap via `available() >
//! RING_CAPACITY` and snaps its own `read_pos` forward.
//!
//! Capacity is a power of 2 for bitmask indexing (no modulo).
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
/// Ring buffer capacity — power of 2 for bitmask indexing.
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
/// than the previous 9600 (200ms) for surviving Android GC pauses.
const RING_CAPACITY: usize = 16384; // 2^14
const RING_MASK: usize = RING_CAPACITY - 1;
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
pub struct AudioRing {
buf: Box<[i16]>,
/// Monotonically increasing write cursor. ONLY written by producer.
write_pos: AtomicUsize,
/// Monotonically increasing read cursor. ONLY written by consumer.
read_pos: AtomicUsize,
/// Incremented by reader when it detects it was lapped (overflow).
overflow_count: AtomicU64,
/// Incremented by reader when ring is empty (underrun).
underrun_count: AtomicU64,
}
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
// The producer only writes write_pos. The consumer only writes read_pos.
// Neither thread writes the other's cursor. Buffer indices are derived from
// the owning thread's cursor, ensuring no concurrent access to the same index.
unsafe impl Send for AudioRing {}
unsafe impl Sync for AudioRing {}
impl AudioRing {
pub fn new() -> Self {
debug_assert!(RING_CAPACITY.is_power_of_two());
Self {
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
}
}
/// Number of samples available to read (clamped to capacity).
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Relaxed);
w.wrapping_sub(r).min(RING_CAPACITY)
}
/// Number of samples that can be written without overwriting unread data.
pub fn free_space(&self) -> usize {
RING_CAPACITY.saturating_sub(self.available())
}
/// Write samples into the ring. Returns number of samples written.
///
/// If the ring is full, old data is silently overwritten. The reader
/// will detect the lap and self-correct. The writer NEVER touches
/// `read_pos` — this is the key invariant that prevents cursor desync.
pub fn write(&self, samples: &[i16]) -> usize {
let count = samples.len().min(RING_CAPACITY);
let w = self.write_pos.load(Ordering::Relaxed);
for i in 0..count {
unsafe {
let ptr = self.buf.as_ptr() as *mut i16;
*ptr.add((w + i) & RING_MASK) = samples[i];
}
}
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
count
}
/// Read samples from the ring into `out`. Returns number of samples read.
///
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
/// forward to the oldest valid data. This is safe because only the
/// reader thread writes `read_pos`.
pub fn read(&self, out: &mut [i16]) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let mut r = self.read_pos.load(Ordering::Relaxed);
let mut avail = w.wrapping_sub(r);
// Lap detection: writer has overwritten our unread data.
// Snap read_pos forward to oldest valid data in the buffer.
if avail > RING_CAPACITY {
r = w.wrapping_sub(RING_CAPACITY);
avail = RING_CAPACITY;
self.overflow_count.fetch_add(1, Ordering::Relaxed);
}
let count = out.len().min(avail);
if count == 0 {
if w == r {
self.underrun_count.fetch_add(1, Ordering::Relaxed);
}
return 0;
}
for i in 0..count {
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
}
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count
}
/// Number of overflow events (reader was lapped by writer).
pub fn overflow_count(&self) -> u64 {
self.overflow_count.load(Ordering::Relaxed)
}
/// Number of underrun events (reader found empty buffer).
pub fn underrun_count(&self) -> u64 {
self.underrun_count.load(Ordering::Relaxed)
}
}

View File

@@ -1,24 +0,0 @@
//! Engine commands sent from the JNI/UI thread to the engine.
use wzp_proto::QualityProfile;
/// Commands that can be sent to the running engine.
pub enum EngineCommand {
/// Mute or unmute the microphone.
SetMute(bool),
/// Enable or disable speaker (loudspeaker) mode.
SetSpeaker(bool),
/// Force a specific quality profile (overrides adaptive logic).
ForceProfile(QualityProfile),
/// Stop the call and shut down the engine.
Stop,
/// Place a direct call to a fingerprint (requires signal connection).
PlaceCall { target_fingerprint: String },
/// Answer an incoming direct call.
AnswerCall {
call_id: String,
accept_mode: wzp_proto::CallAcceptMode,
},
/// Reject an incoming direct call.
RejectCall { call_id: String },
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,511 +0,0 @@
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
use std::panic;
use std::sync::Once;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use tracing::{error, info};
use wzp_proto::QualityProfile;
use crate::engine::{CallStartConfig, WzpEngine};
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
struct EngineHandle {
engine: WzpEngine,
}
/// Recover the `EngineHandle` from a raw handle value.
unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
unsafe { &mut *(handle as *mut EngineHandle) }
}
/// 7 = auto (use relay's chosen profile)
const PROFILE_AUTO: jint = 7;
fn profile_from_int(value: jint) -> QualityProfile {
match value {
0 => QualityProfile::GOOD, // Opus 24k
1 => QualityProfile::DEGRADED, // Opus 6k
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
3 => QualityProfile { // Codec2 3.2k
codec: wzp_proto::CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
4 => QualityProfile::STUDIO_32K, // Opus 32k
5 => QualityProfile::STUDIO_48K, // Opus 48k
6 => QualityProfile::STUDIO_64K, // Opus 64k
_ => QualityProfile::GOOD, // auto falls back to GOOD
}
}
static INIT_LOGGING: Once = Once::new();
/// Initialize tracing → Android logcat (tag "wzp_android").
/// Safe to call multiple times — only the first call takes effect.
fn init_logging() {
INIT_LOGGING.call_once(|| {
// Wrap in catch_unwind — sharded_slab allocation inside
// tracing_subscriber::registry() can crash on some Android
// devices if scudo malloc fails during early initialization.
let _ = std::panic::catch_unwind(|| {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if let Ok(layer) = tracing_android::layer("wzp_android") {
// Filter: INFO for our crates, WARN for everything else.
// The jni crate emits VERBOSE logs for every method lookup
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
// and causes the system to kill the app.
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
let _ = tracing_subscriber::registry()
.with(layer)
.with(filter)
.try_init();
}
});
});
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
_env: JNIEnv,
_class: JClass,
) -> jlong {
let result = panic::catch_unwind(|| {
init_logging();
// Install rustls crypto provider ONCE on the main thread.
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
let _ = rustls::crypto::ring::default_provider().install_default();
let handle = Box::new(EngineHandle {
engine: WzpEngine::new(),
});
Box::into_raw(handle) as jlong
});
match result {
Ok(h) => h,
Err(_) => 0,
}
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
relay_addr_j: JString,
room_j: JString,
seed_hex_j: JString,
token_j: JString,
alias_j: JString,
profile_j: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
let h = unsafe { handle_ref(handle) };
// Parse hex seed
let mut identity_seed = [0u8; 32];
if seed_hex.len() == 64 {
for i in 0..32 {
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
identity_seed[i] = byte;
}
}
} else {
// Generate random seed if not provided
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut identity_seed);
}
let config = CallStartConfig {
profile: profile_from_int(profile_j),
auto_profile: profile_j == PROFILE_AUTO,
relay_addr,
room,
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
identity_seed,
alias: if alias.is_empty() { None } else { Some(alias) },
};
match h.engine.start_call(config) {
Ok(()) => 0,
Err(e) => {
error!("start_call failed: {e}");
-1
}
}
}));
match result {
Ok(code) => code,
Err(_) => -1,
}
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.stop_call();
}));
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
_env: JNIEnv,
_class: JClass,
handle: jlong,
muted: jboolean,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.set_mute(muted != 0);
}));
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
_env: JNIEnv,
_class: JClass,
handle: jlong,
speaker: jboolean,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.set_speaker(speaker != 0);
}));
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let stats = h.engine.get_stats();
serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string())
}));
let json = match result {
Ok(s) => s,
Err(_) => "{}".to_string(),
};
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
_env: JNIEnv,
_class: JClass,
handle: jlong,
profile: jint,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let qp = profile_from_int(profile);
h.engine.force_profile(qp);
}));
}
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
/// pcm is a Java short[] array.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
env: JNIEnv,
_class: JClass,
handle: jlong,
pcm: jni::objects::JShortArray,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
if len == 0 {
return 0;
}
let mut buf = vec![0i16; len];
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
return 0;
}
h.engine.write_audio(&buf) as jint
}));
result.unwrap_or(0)
}
/// Read decoded PCM samples from the engine's playout ring for Kotlin AudioTrack.
/// pcm is a Java short[] array to fill. Returns number of samples actually read.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
env: JNIEnv,
_class: JClass,
handle: jlong,
pcm: jni::objects::JShortArray,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
if len == 0 {
return 0;
}
let mut buf = vec![0i16; len];
let read = h.engine.read_audio(&mut buf);
if read > 0 {
let _ = env.set_short_array_region(&pcm, 0, &buf[..read]);
}
read as jint
}));
result.unwrap_or(0)
}
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer must contain little-endian i16 samples.
/// Called from the AudioRecord capture thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
sample_count: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || sample_count <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
};
h.engine.write_audio(samples) as jint
}));
result.unwrap_or(0)
}
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer will be filled with little-endian i16 samples.
/// Called from the AudioTrack playout thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
max_samples: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || max_samples <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
};
h.engine.read_audio(samples) as jint
}));
result.unwrap_or(0)
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
drop(h);
}));
}
/// Ping a relay server — instance method, requires engine handle.
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
relay_j: JString,
) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
match h.engine.ping_relay(&relay) {
Ok(json) => Some(json),
Err(_) => None,
}
}));
let json = match result {
Ok(Some(s)) => s,
_ => return JObject::null().into_raw(),
};
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
/// Get the identity fingerprint for a seed hex string.
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
seed_hex_j: JString,
) -> jstring {
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let fp = if seed_hex.is_empty() {
String::new()
} else {
match wzp_crypto::Seed::from_hex(&seed_hex) {
Ok(seed) => {
let id = seed.derive_identity();
id.public_identity().fingerprint.to_string()
}
Err(_) => String::new(),
}
};
env.new_string(&fp)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
// ── Direct calling JNI functions ──
// ── SignalManager JNI functions ──
/// Opaque handle for SignalManager (separate from EngineHandle).
struct SignalHandle {
mgr: crate::signal_mgr::SignalManager,
}
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
unsafe { &*(handle as *const SignalHandle) }
}
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
/// Blocks up to 10s waiting for the internal signal thread to connect.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
relay_j: JString,
seed_j: JString,
) -> jlong {
info!("nativeSignalConnect: entered");
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
// Blocks up to 10s waiting for the connect+register to complete.
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
Ok(mgr) => {
let handle = Box::new(SignalHandle { mgr });
Box::into_raw(handle) as jlong
}
Err(e) => {
error!("signal connect failed: {e}");
0
}
}
}
/// Get signal state as JSON string.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
) -> jstring {
if handle == 0 { return JObject::null().into_raw(); }
let h = signal_ref(handle);
let json = h.mgr.get_state_json();
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
/// Place a direct call.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
target_j: JString,
) -> jint {
if handle == 0 { return -1; }
let h = signal_ref(handle);
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default();
match h.mgr.place_call(&target) {
Ok(()) => 0,
Err(e) => { error!("place_call: {e}"); -1 }
}
}
/// Answer an incoming call.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
call_id_j: JString,
mode: jint,
) -> jint {
if handle == 0 { return -1; }
let h = signal_ref(handle);
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
let accept_mode = match mode {
0 => wzp_proto::CallAcceptMode::Reject,
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
};
match h.mgr.answer_call(&call_id, accept_mode) {
Ok(()) => 0,
Err(e) => { error!("answer_call: {e}"); -1 }
}
}
/// Send hangup signal.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 { return; }
let h = signal_ref(handle);
h.mgr.hangup();
}
/// Destroy the signal manager and free resources.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 { return; }
let h = signal_ref(handle);
h.mgr.stop();
// Reclaim the Box
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
}

View File

@@ -1,19 +0,0 @@
//! WarzonePhone Android native VoIP engine.
//!
//! Provides:
//! - Oboe audio backend with lock-free SPSC ring buffers
//! - Engine orchestrator managing call lifecycle
//! - Codec pipeline thread (encode/decode/FEC/jitter)
//! - Call statistics and command interface
//!
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
//! allowing `cargo check` and unit tests on the host.
pub mod audio_android;
pub mod audio_ring;
pub mod commands;
pub mod engine;
pub mod pipeline;
pub mod signal_mgr;
pub mod stats;
pub mod jni_bridge;

View File

@@ -1,262 +0,0 @@
//! Codec pipeline — encode/decode with FEC and jitter buffer.
//!
//! Runs on a dedicated thread, processing 20 ms frames at 48 kHz.
//! The pipeline is NOT Send/Sync (Opus encoder state) — it is owned
//! exclusively by the codec thread.
use tracing::{debug, warn};
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::traits::QualityController;
use wzp_proto::{MediaPacket, QualityProfile};
use crate::audio_android::FRAME_SAMPLES;
/// Maximum encoded frame size (Opus worst case at highest bitrate).
const MAX_ENCODED_BYTES: usize = 1275;
/// Pipeline statistics snapshot.
#[derive(Clone, Debug, Default)]
pub struct PipelineStats {
pub frames_encoded: u64,
pub frames_decoded: u64,
pub underruns: u64,
pub jitter_depth: usize,
pub quality_tier: u8,
}
/// The codec pipeline: encode, FEC, jitter buffer, decode.
///
/// This struct is owned by the codec thread and not shared.
pub struct Pipeline {
encoder: AdaptiveEncoder,
decoder: AdaptiveDecoder,
fec_encoder: RaptorQFecEncoder,
fec_decoder: RaptorQFecDecoder,
jitter_buffer: JitterBuffer,
quality_ctrl: AdaptiveQualityController,
/// Acoustic echo canceller applied before encoding.
aec: EchoCanceller,
/// Automatic gain control applied before encoding.
agc: AutoGainControl,
/// Last decoded PCM frame, used as the AEC far-end reference.
last_decoded_farend: Option<Vec<i16>>,
// Pre-allocated scratch buffers
capture_buf: Vec<i16>,
#[allow(dead_code)]
playout_buf: Vec<i16>,
encode_out: Vec<u8>,
// Stats counters
frames_encoded: u64,
frames_decoded: u64,
underruns: u64,
}
impl Pipeline {
/// Create a new pipeline configured for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
let encoder = AdaptiveEncoder::new(profile)
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
let decoder = AdaptiveDecoder::new(profile)
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
let fec_encoder =
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
let fec_decoder =
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
let jitter_buffer = JitterBuffer::new(10, 250, 3);
let quality_ctrl = AdaptiveQualityController::new();
Ok(Self {
encoder,
decoder,
fec_encoder,
fec_decoder,
jitter_buffer,
quality_ctrl,
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
agc: AutoGainControl::new(),
last_decoded_farend: None,
capture_buf: vec![0i16; FRAME_SAMPLES],
playout_buf: vec![0i16; FRAME_SAMPLES],
encode_out: vec![0u8; MAX_ENCODED_BYTES],
frames_encoded: 0,
frames_decoded: 0,
underruns: 0,
})
}
/// Encode a PCM frame into a compressed packet.
///
/// If `muted` is true, a silence frame is encoded (all zeros).
/// Returns the encoded bytes, or `None` on encoder error.
pub fn encode_frame(&mut self, pcm: &[i16], muted: bool) -> Option<Vec<u8>> {
let input = if muted {
// Zero the capture buffer for silence
for s in self.capture_buf.iter_mut() {
*s = 0;
}
&self.capture_buf[..]
} else {
// Feed the last decoded playout as AEC far-end reference.
if let Some(ref farend) = self.last_decoded_farend {
self.aec.feed_farend(farend);
}
// Apply AEC + AGC to the captured PCM.
let len = pcm.len().min(self.capture_buf.len());
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
self.aec.process_frame(&mut self.capture_buf[..len]);
self.agc.process_frame(&mut self.capture_buf[..len]);
&self.capture_buf[..len]
};
match self.encoder.encode(input, &mut self.encode_out) {
Ok(n) => {
self.frames_encoded += 1;
let encoded = self.encode_out[..n].to_vec();
// Feed into FEC encoder
if let Err(e) = self.fec_encoder.add_source_symbol(&encoded) {
warn!("FEC encode error: {e}");
}
Some(encoded)
}
Err(e) => {
warn!("encode error: {e}");
None
}
}
}
/// Feed a received media packet into the jitter buffer.
pub fn feed_packet(&mut self, packet: MediaPacket) {
// Feed FEC symbols if present
let header = &packet.header;
if header.fec_block != 0 || header.fec_symbol != 0 {
let is_repair = header.is_repair;
if let Err(e) = self.fec_decoder.add_symbol(
header.fec_block,
header.fec_symbol,
is_repair,
&packet.payload,
) {
debug!("FEC symbol feed error: {e}");
}
}
self.jitter_buffer.push(packet);
}
/// Decode the next frame from the jitter buffer.
///
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
/// Decoded PCM is also stored as the AEC far-end reference for the next
/// encode cycle.
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
let result = match self.jitter_buffer.pop() {
PlayoutResult::Packet(pkt) => {
let mut pcm = vec![0i16; FRAME_SAMPLES];
match self.decoder.decode(&pkt.payload, &mut pcm) {
Ok(n) => {
self.frames_decoded += 1;
pcm.truncate(n);
Some(pcm)
}
Err(e) => {
warn!("decode error: {e}");
// Attempt PLC
self.generate_plc()
}
}
}
PlayoutResult::Missing { seq } => {
debug!(seq, "jitter buffer: missing packet, generating PLC");
self.generate_plc()
}
PlayoutResult::NotReady => {
self.underruns += 1;
None
}
};
// Save decoded PCM as far-end reference for AEC.
if let Some(ref pcm) = result {
self.last_decoded_farend = Some(pcm.clone());
}
result
}
/// Generate packet loss concealment output.
fn generate_plc(&mut self) -> Option<Vec<i16>> {
let mut pcm = vec![0i16; FRAME_SAMPLES];
match self.decoder.decode_lost(&mut pcm) {
Ok(n) => {
self.frames_decoded += 1;
pcm.truncate(n);
Some(pcm)
}
Err(e) => {
warn!("PLC error: {e}");
None
}
}
}
/// Feed a quality report into the adaptive quality controller.
///
/// Returns a new profile if a tier transition occurred.
#[allow(unused)]
pub fn observe_quality(
&mut self,
report: &wzp_proto::QualityReport,
) -> Option<QualityProfile> {
let new_profile = self.quality_ctrl.observe(report);
if let Some(ref profile) = new_profile {
if let Err(e) = self.encoder.set_profile(*profile) {
warn!("encoder set_profile error: {e}");
}
if let Err(e) = self.decoder.set_profile(*profile) {
warn!("decoder set_profile error: {e}");
}
}
new_profile
}
/// Force a specific quality profile.
#[allow(unused)]
pub fn force_profile(&mut self, profile: QualityProfile) {
self.quality_ctrl.force_profile(profile);
if let Err(e) = self.encoder.set_profile(profile) {
warn!("encoder set_profile error: {e}");
}
if let Err(e) = self.decoder.set_profile(profile) {
warn!("decoder set_profile error: {e}");
}
}
/// Get current pipeline statistics.
pub fn stats(&self) -> PipelineStats {
PipelineStats {
frames_encoded: self.frames_encoded,
frames_decoded: self.frames_decoded,
underruns: self.underruns,
jitter_depth: self.jitter_buffer.stats().current_depth,
quality_tier: self.quality_ctrl.tier() as u8,
}
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
}
/// Enable or disable automatic gain control.
pub fn set_agc_enabled(&mut self, enabled: bool) {
self.agc.set_enabled(enabled);
}
}

View File

@@ -1,288 +0,0 @@
//! Persistent signal connection manager for direct 1:1 calls.
//!
//! Separate from the media engine — survives across calls.
//! Connects to relay via `_signal` SNI, registers presence,
//! and handles call signaling (offer/answer/setup/hangup).
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{error, info, warn};
use wzp_proto::{MediaTransport, SignalMessage};
/// Signal connection status.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct SignalState {
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
pub fingerprint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_fp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_alias: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_relay: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_room: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_id: Option<String>,
}
/// Manages a persistent `_signal` QUIC connection to a relay.
pub struct SignalManager {
transport: Arc<wzp_transport::QuinnTransport>,
state: Arc<Mutex<SignalState>>,
running: Arc<AtomicBool>,
}
impl SignalManager {
/// Create SignalManager and start connect+register+recv on a background thread.
/// Returns immediately. The internal thread runs forever.
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
let addr: SocketAddr = relay_addr.parse()?;
let seed = if seed_hex.is_empty() {
wzp_crypto::Seed::generate()
} else {
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
};
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
let identity_pub = *pub_id.signing.as_bytes();
let fp = pub_id.fingerprint.to_string();
let state = Arc::new(Mutex::new(SignalState {
status: "connecting".into(),
fingerprint: fp.clone(),
..Default::default()
}));
let running = Arc::new(AtomicBool::new(true));
// Channel to receive transport after connect succeeds
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
let bg_state = Arc::clone(&state);
let bg_running = Arc::clone(&running);
let ret_state = Arc::clone(&state);
let ret_running = Arc::clone(&running);
// ONE thread, ONE runtime, NEVER dropped.
// Connect + register + recv loop all happen here.
std::thread::Builder::new()
.name("wzp-signal".into())
.stack_size(4 * 1024 * 1024)
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(async move {
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind, None) {
Ok(e) => e,
Err(e) => {
error!("signal endpoint: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
};
let client_cfg = wzp_transport::client_config();
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
Ok(c) => c,
Err(e) => {
error!("signal connect: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
};
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Register
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None,
}).await {
error!("signal register: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
match transport.recv_signal().await {
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
info!(fingerprint = %fp, "signal: registered");
bg_state.lock().unwrap().status = "registered".into();
// Send transport to caller
let _ = transport_tx.send(transport.clone());
}
other => {
error!("signal registration failed: {other:?}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
}
// Recv loop — runs forever
loop {
if !running.load(Ordering::Relaxed) { break; }
match transport.recv_signal().await {
Ok(Some(SignalMessage::CallRinging { call_id })) => {
info!(call_id = %call_id, "signal: ringing");
let mut s = state.lock().unwrap();
s.status = "ringing".into();
}
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
let mut s = state.lock().unwrap();
s.status = "incoming".into();
s.incoming_call_id = Some(call_id);
s.incoming_caller_fp = Some(caller_fingerprint);
s.incoming_caller_alias = caller_alias;
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
let mut s = state.lock().unwrap();
s.status = "setup".into();
s.call_setup_relay = Some(relay_addr);
s.call_setup_room = Some(room);
s.call_setup_id = Some(call_id);
}
Ok(Some(SignalMessage::Hangup { reason })) => {
info!(reason = ?reason, "signal: hangup");
let mut s = state.lock().unwrap();
s.status = "registered".into();
s.incoming_call_id = None;
s.incoming_caller_fp = None;
s.incoming_caller_alias = None;
s.call_setup_relay = None;
s.call_setup_room = None;
s.call_setup_id = None;
}
Ok(Some(_)) => {}
Ok(None) => {
info!("signal: connection closed");
break;
}
Err(e) => {
error!("signal recv error: {e}");
break;
}
}
}
bg_state.lock().unwrap().status = "idle".into();
}); // block_on
// Runtime intentionally NOT dropped — lives until thread exits.
// This prevents ring/libcrypto TLS cleanup conflict on Android.
// The thread is parked here forever (block_on returned = connection lost).
std::thread::park();
})?; // thread spawn
// Wait for transport (up to 10s)
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
Ok(Self { transport, state: ret_state, running: ret_running })
}
/// Get current state (non-blocking).
pub fn get_state(&self) -> SignalState {
self.state.lock().unwrap().clone()
}
/// Get state as JSON string.
pub fn get_state_json(&self) -> String {
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
}
/// Place a direct call.
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
let fp = self.state.lock().unwrap().fingerprint.clone();
let target = target_fp.to_string();
let call_id = format!("{:016x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
let transport = self.transport.clone();
// Send on a small thread (async send needs a runtime)
std::thread::Builder::new()
.name("wzp-call-send".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: fp,
caller_alias: None,
target_fingerprint: target,
call_id,
identity_pub: [0u8; 32],
ephemeral_pub: [0u8; 32],
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
}).await;
});
})?;
Ok(())
}
/// Answer an incoming call.
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
let call_id = call_id.to_string();
let transport = self.transport.clone();
std::thread::Builder::new()
.name("wzp-answer-send".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
call_id,
accept_mode: mode,
identity_pub: None,
ephemeral_pub: None,
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
}).await;
});
})?;
Ok(())
}
/// Send hangup.
pub fn hangup(&self) {
let transport = self.transport.clone();
let state = self.state.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
}).await;
});
let mut s = state.lock().unwrap();
s.status = "registered".into();
s.incoming_call_id = None;
s.incoming_caller_fp = None;
s.incoming_caller_alias = None;
s.call_setup_relay = None;
s.call_setup_room = None;
s.call_setup_id = None;
});
}
/// Stop the signal connection.
pub fn stop(&self) {
self.running.store(false, Ordering::Release);
self.transport.connection().close(0u32.into(), b"shutdown");
}
}

View File

@@ -1,109 +0,0 @@
//! Call statistics for the Android engine.
/// State of the call.
/// Serializes as integer for easy parsing on the Kotlin side:
/// 0=Idle, 1=Connecting, 2=Active, 3=Reconnecting, 4=Closed
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum CallState {
#[default]
Idle,
Connecting,
Active,
Reconnecting,
Closed,
/// Connected to relay signal channel, registered for direct calls.
Registered,
/// Outgoing call ringing on callee's side.
Ringing,
/// Incoming call received, waiting for user to accept/reject.
IncomingCall,
}
impl serde::Serialize for CallState {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let n: u8 = match self {
CallState::Idle => 0,
CallState::Connecting => 1,
CallState::Active => 2,
CallState::Reconnecting => 3,
CallState::Closed => 4,
CallState::Registered => 5,
CallState::Ringing => 6,
CallState::IncomingCall => 7,
};
serializer.serialize_u8(n)
}
}
/// Aggregated call statistics, serializable for JNI bridge.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct CallStats {
/// Current call state.
pub state: CallState,
/// Call duration in seconds.
pub duration_secs: f64,
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
pub quality_tier: u8,
/// Observed packet loss percentage.
pub loss_pct: f32,
/// Smoothed round-trip time in milliseconds.
pub rtt_ms: u32,
/// Jitter in milliseconds.
pub jitter_ms: u32,
/// Current jitter buffer depth in packets.
pub jitter_buffer_depth: usize,
/// Total frames encoded since call start.
pub frames_encoded: u64,
/// Total frames decoded since call start.
pub frames_decoded: u64,
/// Number of playout underruns (buffer empty when audio needed).
pub underruns: u64,
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
/// RaptorQ per Phase 2).
pub fec_recovered: u64,
/// Phase 3c: Opus frames reconstructed via DRED side-channel data.
/// Only increments on the Opus tiers; always zero for Codec2.
pub dred_reconstructions: u64,
/// Phase 3c: Opus frames filled via classical Opus PLC because no DRED
/// state covered the gap, plus any decode-error fallbacks. Codec2 loss
/// also increments this counter via the Codec2 PLC path.
pub classical_plc_invocations: 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>,
}

View File

@@ -7,15 +7,14 @@ use std::time::{Duration, Instant};
use bytes::Bytes; use bytes::Bytes;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState}; use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
use wzp_codec::{
AdaptiveDecoder, 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};
use wzp_proto::quality::AdaptiveQualityController; use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder}; use wzp_proto::traits::{
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
};
use wzp_proto::packet::QualityReport; use wzp_proto::packet::QualityReport;
use wzp_proto::{CodecId, QualityProfile}; use wzp_proto::{CodecId, QualityProfile};
@@ -208,10 +207,6 @@ pub struct CallEncoder {
frame_in_block: u8, frame_in_block: u8,
/// Timestamp counter (ms). /// Timestamp counter (ms).
timestamp_ms: u32, timestamp_ms: u32,
/// Acoustic echo canceller (removes speaker echo from mic signal).
aec: EchoCanceller,
/// Automatic gain control (normalises mic level).
agc: AutoGainControl,
/// Silence detector for suppression. /// Silence detector for suppression.
silence_detector: SilenceDetector, silence_detector: SilenceDetector,
/// Whether silence suppression is enabled. /// Whether silence suppression is enabled.
@@ -242,8 +237,6 @@ impl CallEncoder {
block_id: 0, block_id: 0,
frame_in_block: 0, frame_in_block: 0,
timestamp_ms: 0, timestamp_ms: 0,
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
agc: AutoGainControl::new(),
silence_detector: SilenceDetector::new( silence_detector: SilenceDetector::new(
config.silence_threshold_rms, config.silence_threshold_rms,
config.silence_hangover_frames, config.silence_hangover_frames,
@@ -281,21 +274,15 @@ impl CallEncoder {
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms). /// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
/// Output: one or more MediaPackets to send. /// Output: one or more MediaPackets to send.
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> { pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
// Copy PCM into a mutable buffer for the processing pipeline. // Noise suppression: denoise the PCM before silence detection and encoding.
let mut pcm_buf = pcm.to_vec(); let pcm = if self.denoiser.is_enabled() {
let mut buf = pcm.to_vec();
// Step 1: Echo cancellation (far-end reference must have been fed already). self.denoiser.process(&mut buf);
self.aec.process_frame(&mut pcm_buf); buf
} else {
// Step 2: Automatic gain control (normalise mic level). pcm.to_vec()
self.agc.process_frame(&mut pcm_buf); };
let pcm = &pcm[..];
// Step 3: Noise suppression (RNNoise).
if self.denoiser.is_enabled() {
self.denoiser.process(&mut pcm_buf);
}
let pcm = &pcm_buf[..];
// Silence suppression: skip encoding silent frames, periodically send CN. // Silence suppression: skip encoding silent frames, periodically send CN.
if self.suppression_enabled && self.silence_detector.is_silent(pcm) { if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
@@ -341,22 +328,6 @@ impl CallEncoder {
let enc_len = self.audio_enc.encode(pcm, &mut encoded)?; let enc_len = self.audio_enc.encode(pcm, &mut encoded)?;
encoded.truncate(enc_len); encoded.truncate(enc_len);
// Phase 2: Opus tiers bypass RaptorQ entirely (DRED handles loss
// recovery at the codec layer). Codec2 tiers keep RaptorQ unchanged.
// On Opus packets, zero the FEC header fields so old receivers
// can cleanly identify "no RaptorQ block to assemble" and new
// receivers can short-circuit their FEC ingest path.
let is_opus = self.profile.codec.is_opus();
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
(0u8, 0u8, 0u8)
} else {
(
self.block_id,
self.frame_in_block,
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
)
};
// Build source media packet // Build source media packet
let source_pkt = MediaPacket { let source_pkt = MediaPacket {
header: MediaHeader { header: MediaHeader {
@@ -364,11 +335,11 @@ impl CallEncoder {
is_repair: false, is_repair: false,
codec_id: self.profile.codec, codec_id: self.profile.codec,
has_quality_report: false, has_quality_report: false,
fec_ratio_encoded, fec_ratio_encoded: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
seq: self.seq, seq: self.seq,
timestamp: self.timestamp_ms, timestamp: self.timestamp_ms,
fec_block, fec_block: self.block_id,
fec_symbol, fec_symbol: self.frame_in_block,
reserved: 0, reserved: 0,
csrc_count: 0, csrc_count: 0,
}, },
@@ -383,42 +354,39 @@ impl CallEncoder {
let mut output = vec![source_pkt]; let mut output = vec![source_pkt];
// Codec2-only: feed RaptorQ and generate repair packets when the // Add to FEC encoder
// block is full. Opus tiers skip this entire block — DRED (active self.fec_enc.add_source_symbol(&encoded)?;
// in Phase 1) provides codec-layer loss recovery. self.frame_in_block += 1;
if !is_opus {
self.fec_enc.add_source_symbol(&encoded)?;
self.frame_in_block += 1;
if self.frame_in_block >= self.profile.frames_per_block { // If block is full, generate repair and finalize
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) { if self.frame_in_block >= self.profile.frames_per_block {
for (sym_idx, repair_data) in repairs { if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
output.push(MediaPacket { for (sym_idx, repair_data) in repairs {
header: MediaHeader { output.push(MediaPacket {
version: 0, header: MediaHeader {
is_repair: true, version: 0,
codec_id: self.profile.codec, is_repair: true,
has_quality_report: false, codec_id: self.profile.codec,
fec_ratio_encoded: MediaHeader::encode_fec_ratio( has_quality_report: false,
self.profile.fec_ratio, fec_ratio_encoded: MediaHeader::encode_fec_ratio(
), self.profile.fec_ratio,
seq: self.seq, ),
timestamp: self.timestamp_ms, seq: self.seq,
fec_block: self.block_id, timestamp: self.timestamp_ms,
fec_symbol: sym_idx, fec_block: self.block_id,
reserved: 0, fec_symbol: sym_idx,
csrc_count: 0, reserved: 0,
}, csrc_count: 0,
payload: Bytes::from(repair_data), },
quality_report: None, payload: Bytes::from(repair_data),
}); quality_report: None,
self.seq = self.seq.wrapping_add(1); });
} self.seq = self.seq.wrapping_add(1);
} }
let _ = self.fec_enc.finalize_block();
self.block_id = self.block_id.wrapping_add(1);
self.frame_in_block = 0;
} }
let _ = self.fec_enc.finalize_block();
self.block_id = self.block_id.wrapping_add(1);
self.frame_in_block = 0;
} }
Ok(output) Ok(output)
@@ -432,34 +400,13 @@ 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.
pub struct CallDecoder { pub struct CallDecoder {
/// Audio decoder. Concrete `AdaptiveDecoder` (not `Box<dyn AudioDecoder>`) /// Audio decoder.
/// because Phase 3b calls the inherent `reconstruct_from_dred` method, audio_dec: Box<dyn AudioDecoder>,
/// which cannot live on the `AudioDecoder` trait without dragging libopus /// FEC decoder.
/// types into `wzp-proto`.
audio_dec: AdaptiveDecoder,
/// FEC decoder (Codec2 tiers only; Opus bypasses RaptorQ per Phase 2).
fec_dec: RaptorQFecDecoder, fec_dec: RaptorQFecDecoder,
/// Jitter buffer. /// Jitter buffer.
jitter: JitterBuffer, jitter: JitterBuffer,
@@ -473,24 +420,6 @@ pub struct CallDecoder {
last_was_cn: bool, last_was_cn: bool,
/// Mini-frame decompression context (tracks last full header baseline). /// Mini-frame decompression context (tracks last full header baseline).
mini_context: MiniFrameContext, mini_context: MiniFrameContext,
// ─── Phase 3b: DRED reconstruction state ──────────────────────────────
/// DRED side-channel parser (a separate libopus object from the decoder).
dred_decoder: DredDecoderHandle,
/// Scratch buffer used by `dred_decoder.parse_into` on every arriving
/// Opus packet. Reused across calls to avoid 10 KB alloc churn per packet.
dred_parse_scratch: DredState,
/// Cached "most recently parsed valid" DRED state, swapped with
/// `dred_parse_scratch` on successful parse. Used by `decode_next` when
/// the jitter buffer reports a gap.
last_good_dred: DredState,
/// Sequence number of the packet that produced `last_good_dred`. `None`
/// if no packet has yielded DRED state yet (cold start or legacy sender).
last_good_dred_seq: Option<u16>,
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
pub dred_reconstructions: u64,
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
/// (because no DRED state covered the gap, or the active codec is Codec2).
pub classical_plc_invocations: u64,
} }
impl CallDecoder { impl CallDecoder {
@@ -500,19 +429,8 @@ impl CallDecoder {
} else { } else {
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min) JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
}; };
// Phase 3b: build the DRED parser + state buffers. These allocate
// libopus state (~10 KB each) once per call, not per packet — the
// scratch and last-good buffers are reused via std::mem::swap on
// every successful parse.
let dred_decoder =
DredDecoderHandle::new().expect("opus_dred_decoder_create failed at call setup");
let dred_parse_scratch =
DredState::new().expect("opus_dred_alloc failed at call setup (scratch)");
let last_good_dred =
DredState::new().expect("opus_dred_alloc failed at call setup (good state)");
Self { Self {
audio_dec: AdaptiveDecoder::new(config.profile) audio_dec: wzp_codec::create_decoder(config.profile),
.expect("failed to create adaptive decoder"),
fec_dec: wzp_fec::create_decoder(&config.profile), fec_dec: wzp_fec::create_decoder(&config.profile),
jitter, jitter,
quality: AdaptiveQualityController::new(), quality: AdaptiveQualityController::new(),
@@ -520,12 +438,6 @@ impl CallDecoder {
comfort_noise: ComfortNoise::new(50), comfort_noise: ComfortNoise::new(50),
last_was_cn: false, last_was_cn: false,
mini_context: MiniFrameContext::default(), mini_context: MiniFrameContext::default(),
dred_decoder,
dred_parse_scratch,
last_good_dred,
last_good_dred_seq: None,
dred_reconstructions: 0,
classical_plc_invocations: 0,
} }
} }
@@ -540,54 +452,15 @@ impl CallDecoder {
/// Feed a received media packet into the decode pipeline. /// Feed a received media packet into the decode pipeline.
pub fn ingest(&mut self, packet: MediaPacket) { pub fn ingest(&mut self, packet: MediaPacket) {
// Phase 2: Opus packets bypass RaptorQ. Codec2 packets still feed // Feed to FEC decoder
// the FEC decoder for recovery. This also cleanly drops any stray let _ = self.fec_dec.add_symbol(
// Opus repair packets from an old sender (we don't push repair packet.header.fec_block,
// packets to the jitter buffer either, so they're effectively packet.header.fec_symbol,
// ignored — a graceful mixed-version degradation). packet.header.is_repair,
if !packet.header.codec_id.is_opus() { &packet.payload,
let _ = self.fec_dec.add_symbol( );
packet.header.fec_block,
packet.header.fec_symbol,
packet.header.is_repair,
&packet.payload,
);
}
// Phase 3b: Opus source packets carry DRED side-channel data in // If not a repair packet, also feed directly to jitter buffer
// libopus 1.5. Parse it into the scratch state and, on success,
// swap with the cached `last_good_dred` so later gap reconstruction
// has fresh neural redundancy to draw from. Parsing happens before
// the jitter push because the jitter buffer consumes the packet.
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
match self
.dred_decoder
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
{
Ok(available) if available > 0 => {
// Swap the freshly parsed state into `last_good_dred`.
// The old good state (now in scratch) is about to be
// overwritten on the next parse — its contents are
// not needed after this swap.
std::mem::swap(&mut self.dred_parse_scratch, &mut self.last_good_dred);
self.last_good_dred_seq = Some(packet.header.seq);
}
Ok(_) => {
// Packet had no DRED data (return 0). Leave the cached
// state untouched — it may still cover upcoming gaps
// from a warm-up period where the encoder was producing
// DRED bytes. The scratch buffer was potentially written
// but its `samples_available` is 0 so it's harmless.
}
Err(e) => {
debug!("DRED parse error (ignored): {e}");
}
}
}
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
// Repair packets never reach the jitter buffer; for Codec2 they're
// used by the FEC decoder above, for Opus they're dropped here.
if !packet.header.is_repair { if !packet.header.is_repair {
self.jitter.push(packet); self.jitter.push(packet);
} }
@@ -621,72 +494,19 @@ impl CallDecoder {
result result
} }
PlayoutResult::Missing { seq } => { PlayoutResult::Missing { seq } => {
// Only attempt recovery if there are still packets buffered ahead. // Only generate PLC if there are still packets buffered ahead.
// Otherwise we've drained everything — return None to stop. // Otherwise we've drained everything — return None to stop.
if self.jitter.depth() == 0 { if self.jitter.depth() > 0 {
self.jitter.record_underrun(); debug!(seq, "packet loss, generating PLC");
return None; let result = self.audio_dec.decode_lost(pcm).ok();
} if result.is_some() {
self.jitter.record_decode();
// Phase 3b: try DRED reconstruction first. If we have a
// recent DRED state from a packet whose seq > missing seq,
// and the seq delta (in samples) fits within the state's
// available window, libopus can synthesize a plausible
// replacement for the lost frame. Fall back to classical
// PLC when no state covers the gap, when the active codec
// is Codec2, or when the reconstruction itself errors.
if self.profile.codec.is_opus() {
if let Some(last_seq) = self.last_good_dred_seq {
// How many frames ahead of the missing seq is the
// last-good packet? Use wrapping arithmetic for the
// u16 seq space.
let seq_delta = last_seq.wrapping_sub(seq);
// Reject stale or backward state. u16 wraparound
// would make a "seq went backward" delta very large;
// cap at a sane forward-looking window.
const MAX_SEQ_DELTA: u16 = 128;
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
let frame_samples =
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
let offset_samples = seq_delta as i32 * frame_samples;
let available = self.last_good_dred.samples_available();
if offset_samples > 0 && offset_samples <= available {
match self.audio_dec.reconstruct_from_dred(
&self.last_good_dred,
offset_samples,
pcm,
) {
Ok(n) => {
self.dred_reconstructions += 1;
self.jitter.record_decode();
debug!(
seq,
last_seq,
offset_samples,
available,
"DRED reconstruction for gap"
);
return Some(n);
}
Err(e) => {
// Reconstruction failed — fall
// through to classical PLC below.
debug!(seq, "DRED reconstruct error: {e}");
}
}
}
}
} }
result
} else {
self.jitter.record_underrun();
None
} }
// Classical PLC fallback (also the Codec2 path).
debug!(seq, "packet loss, generating classical PLC");
self.classical_plc_invocations += 1;
let result = self.audio_dec.decode_lost(pcm).ok();
if result.is_some() {
self.jitter.record_decode();
}
result
} }
PlayoutResult::NotReady => { PlayoutResult::NotReady => {
self.jitter.record_underrun(); self.jitter.record_underrun();
@@ -709,19 +529,6 @@ impl CallDecoder {
pub fn reset_stats(&mut self) { pub fn reset_stats(&mut self) {
self.jitter.reset_stats(); self.jitter.reset_stats();
} }
/// Phase 3b introspection: sequence number of the most recently parsed
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
/// yet. Used by tests to debug reconstruction eligibility.
pub fn last_good_dred_seq(&self) -> Option<u16> {
self.last_good_dred_seq
}
/// Phase 3b introspection: samples of audio history currently available
/// in the cached DRED state.
pub fn last_good_dred_samples_available(&self) -> i32 {
self.last_good_dred.samples_available()
}
} }
/// Periodic telemetry logger for jitter buffer statistics. /// Periodic telemetry logger for jitter buffer statistics.
@@ -783,83 +590,18 @@ mod tests {
assert!(!packets[0].header.is_repair); assert!(!packets[0].header.is_repair);
} }
/// Phase 2: Opus packets have zero FEC header fields — no block, no
/// symbol index, no repair ratio. The RaptorQ layer is bypassed
/// entirely on the Opus tiers.
#[test] #[test]
fn opus_source_packets_have_zero_fec_header_fields() { fn encoder_generates_repair_on_full_block() {
let config = CallConfig { let config = CallConfig {
profile: QualityProfile::GOOD, // Opus 24k profile: QualityProfile::GOOD, // 5 frames/block
suppression_enabled: false, // skip silence gate for this test
..Default::default() ..Default::default()
}; };
let mut enc = CallEncoder::new(&config); let mut enc = CallEncoder::new(&config);
// Non-silent sine wave so silence detection doesn't suppress us let pcm = vec![0i16; 960];
// even with suppression_enabled=false (belt and braces).
let pcm: Vec<i16> = (0..960)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
let packets = enc.encode_frame(&pcm).unwrap();
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
let hdr = &packets[0].header;
assert!(hdr.codec_id.is_opus());
assert!(!hdr.is_repair);
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
}
/// Phase 2: Opus never emits repair packets, regardless of how many let mut total_packets = 0;
/// source frames are fed in. DRED (Phase 1) provides loss recovery at let mut repair_count = 0;
/// the codec layer; RaptorQ is disabled on Opus tiers. for _ in 0..5 {
#[test]
fn opus_encoder_never_emits_repair_packets() {
let config = CallConfig {
profile: QualityProfile::GOOD, // 5 frames/block in the Codec2 sense
suppression_enabled: false,
..Default::default()
};
let mut enc = CallEncoder::new(&config);
let pcm: Vec<i16> = (0..960)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
// Encode well beyond a block boundary to prove no repair ever comes out.
let mut total_packets = 0usize;
let mut repair_count = 0usize;
for _ in 0..20 {
let packets = enc.encode_frame(&pcm).unwrap();
total_packets += packets.len();
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
}
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
assert_eq!(
total_packets, 20,
"20 source frames → 20 source packets (1:1, no RaptorQ expansion)"
);
}
/// Phase 2: Codec2 still emits repair packets with RaptorQ ratio unchanged.
/// DRED is libopus-only and does not apply here, so RaptorQ is still the
/// primary loss-recovery mechanism on Codec2 tiers.
#[test]
fn codec2_encoder_generates_repair_on_full_block() {
let config = CallConfig {
profile: QualityProfile::CATASTROPHIC, // Codec2 1200, 8 frames/block, ratio 1.0
suppression_enabled: false,
..Default::default()
};
let mut enc = CallEncoder::new(&config);
// Codec2 takes 48 kHz samples and downsamples internally.
// CATASTROPHIC uses 40 ms frames → 1920 samples.
let pcm: Vec<i16> = (0..1920)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
let mut total_packets = 0usize;
let mut repair_count = 0usize;
// Run long enough to cross the 8-frame block boundary and see repairs.
for _ in 0..16 {
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
for p in &packets { for p in &packets {
if p.header.is_repair { if p.header.is_repair {
@@ -868,10 +610,8 @@ mod tests {
} }
total_packets += packets.len(); total_packets += packets.len();
} }
assert!( assert!(repair_count > 0, "should have repair packets after full block");
repair_count > 0, assert!(total_packets > 5, "total {total_packets} should exceed 5 source");
"Codec2 must still emit repair packets (got {repair_count} repairs, {total_packets} total)"
);
} }
#[test] #[test]
@@ -902,219 +642,6 @@ mod tests {
assert!(dec.decode_next(&mut pcm).is_none()); assert!(dec.decode_next(&mut pcm).is_none());
} }
// ─── Phase 3b — DRED reconstruction on packet loss ────────────────────
/// Helper: create a CallEncoder/CallDecoder pair with the given profile
/// and silence suppression disabled so silence-detection doesn't drop
/// our synthetic test frames.
fn encoder_decoder_pair(profile: QualityProfile) -> (CallEncoder, CallDecoder) {
let config = CallConfig {
profile,
suppression_enabled: false,
// Small jitter buffer so decode_next drains quickly in tests.
jitter_min: 2,
jitter_target: 3,
jitter_max: 20,
adaptive_jitter: false,
..Default::default()
};
(CallEncoder::new(&config), CallDecoder::new(&config))
}
/// Helper: generate a non-silent 20 ms frame of 300 Hz sine at the
/// given sample offset so consecutive frames form a continuous tone.
fn voice_frame_20ms(sample_offset: usize) -> Vec<i16> {
(0..960)
.map(|i| {
let t = (sample_offset + i) as f64 / 48_000.0;
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
})
.collect()
}
/// Phase 3b probe: sweep packet_loss_perc values to find the minimum
/// that produces a samples_available ≥ 960 (enough to reconstruct a
/// single 20 ms Opus frame). This guides the production loss floor.
#[test]
#[ignore] // diagnostic only — run with `cargo test ... -- --ignored --nocapture`
fn probe_dred_samples_available_by_loss_floor() {
use wzp_codec::opus_enc::OpusEncoder;
use wzp_proto::traits::AudioEncoder;
for loss_pct in [5u8, 10, 15, 20, 25, 40, 60, 80].iter().copied() {
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
enc.set_expected_loss(loss_pct);
let (_drop_enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
for i in 0..60u16 {
let pcm = voice_frame_20ms(i as usize * 960);
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm, &mut encoded).unwrap();
encoded.truncate(n);
let pkt = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq: i,
timestamp: (i as u32) * 20,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from(encoded),
quality_report: None,
};
dec.ingest(pkt);
}
eprintln!(
"[phase3b probe] loss_pct={loss_pct} samples_available={}",
dec.last_good_dred_samples_available()
);
}
}
/// Phase 3b: simulated single-packet loss on an Opus call triggers a
/// DRED reconstruction rather than a classical PLC fill. Runs the full
/// encode → ingest → decode_next pipeline.
#[test]
fn opus_single_packet_loss_is_recovered_via_dred() {
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
// Warm-up: encode and ingest 60 frames (1.2 s) so the DRED emitter
// has had time to fill its 200 ms window and at least one
// successful DRED parse has happened on the decoder side.
let warmup_frames = 60;
for i in 0..warmup_frames {
let pcm = voice_frame_20ms(i * 960);
let packets = enc.encode_frame(&pcm).unwrap();
for pkt in packets {
dec.ingest(pkt);
}
}
// Drain the warm-up frames through the decoder to advance the
// jitter buffer cursor past them.
let mut out = vec![0i16; 960];
while dec.decode_next(&mut out).is_some() {}
// Encode the next three frames but skip ingesting the middle one.
let base_offset = warmup_frames * 960;
let pcm_a = voice_frame_20ms(base_offset);
let pcm_b = voice_frame_20ms(base_offset + 960);
let pcm_c = voice_frame_20ms(base_offset + 1920);
let pkts_a = enc.encode_frame(&pcm_a).unwrap();
let pkts_b = enc.encode_frame(&pcm_b).unwrap(); // DROP THIS ONE
let pkts_c = enc.encode_frame(&pcm_c).unwrap();
for pkt in pkts_a {
dec.ingest(pkt);
}
// Skip pkts_b entirely — this is the "packet loss".
drop(pkts_b);
for pkt in pkts_c {
dec.ingest(pkt);
}
// Drain again. Somewhere in here decode_next will hit Missing()
// for the dropped packet and attempt DRED reconstruction.
let baseline_dred = dec.dred_reconstructions;
let baseline_plc = dec.classical_plc_invocations;
eprintln!(
"[phase3b probe] pre-drain: last_good_seq={:?} samples_available={}",
dec.last_good_dred_seq(),
dec.last_good_dred_samples_available()
);
while dec.decode_next(&mut out).is_some() {}
let dred_delta = dec.dred_reconstructions - baseline_dred;
let plc_delta = dec.classical_plc_invocations - baseline_plc;
eprintln!(
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
);
assert!(
dred_delta >= 1,
"expected ≥1 DRED reconstruction on single-packet loss, \
got dred_delta={dred_delta} plc_delta={plc_delta}"
);
}
/// Phase 3b: lossless stream never triggers DRED reconstruction or PLC.
/// Baseline behavior — verifies the Missing() branch is not spuriously taken.
#[test]
fn opus_lossless_ingest_never_triggers_dred_or_plc() {
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
// Encode + ingest 40 frames with no drops.
for i in 0..40 {
let pcm = voice_frame_20ms(i * 960);
let packets = enc.encode_frame(&pcm).unwrap();
for pkt in packets {
dec.ingest(pkt);
}
}
let mut out = vec![0i16; 960];
while dec.decode_next(&mut out).is_some() {}
assert_eq!(
dec.dred_reconstructions, 0,
"lossless stream should not reconstruct"
);
assert_eq!(
dec.classical_plc_invocations, 0,
"lossless stream should not PLC"
);
}
/// Phase 3b: Codec2 calls fall through to classical PLC on loss.
/// DRED is libopus-only, so even if the decoder's DRED state were
/// populated (it won't be — Codec2 packets don't carry DRED bytes),
/// `reconstruct_from_dred` rejects Codec2 at the AdaptiveDecoder
/// level. This test guards the Codec2 side of the protection split.
#[test]
fn codec2_loss_falls_through_to_classical_plc() {
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::CATASTROPHIC);
// Codec2 1200 uses 40 ms frames → 1920 samples at 48 kHz (before
// the downsample inside the codec). Encode 20 frames (~0.8 s).
let make_frame = |offset: usize| -> Vec<i16> {
(0..1920)
.map(|i| {
let t = (offset + i) as f64 / 48_000.0;
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
})
.collect()
};
for i in 0..20 {
let pcm = make_frame(i * 1920);
let packets = enc.encode_frame(&pcm).unwrap();
for pkt in packets {
// Drop every 5th source packet to simulate loss.
if !pkt.header.is_repair && i % 5 == 3 {
continue;
}
dec.ingest(pkt);
}
}
let mut out = vec![0i16; 1920];
while dec.decode_next(&mut out).is_some() {}
assert_eq!(
dec.dred_reconstructions, 0,
"Codec2 must never reconstruct via DRED"
);
// classical_plc_invocations may or may not trigger depending on
// whether the jitter buffer sees Missing before draining — the key
// assertion is that DRED is not used. PLC count is advisory.
}
// ---- QualityAdapter tests ---- // ---- QualityAdapter tests ----
/// Helper: build a QualityReport from human-readable loss% and RTT ms. /// Helper: build a QualityReport from human-readable loss% and RTT ms.

View File

@@ -47,11 +47,6 @@ 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 {
@@ -93,20 +88,12 @@ 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(
@@ -182,7 +169,6 @@ 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!();
@@ -235,9 +221,6 @@ fn parse_args() -> CliArgs {
room, room,
token, token,
_metrics_file: metrics_file, _metrics_file: metrics_file,
version_check,
signal,
call_target,
} }
} }
@@ -256,32 +239,6 @@ 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!(
@@ -293,11 +250,12 @@ async fn main() -> anyhow::Result<()> {
"WarzonePhone client" "WarzonePhone client"
); );
// Use raw room name as SNI (consistent with Android + Desktop clients for federation) // Hash room name for SNI privacy (or "default" if none specified)
let sni = match &cli.room { let sni = match &cli.room {
Some(name) => { Some(name) => {
info!(room = %name, "using room name as SNI"); let hashed = wzp_crypto::hash_room_name(name);
name.clone() info!(room = %name, hashed = %hashed, "room name hashed for SNI");
hashed
} }
None => "default".to_string(), None => "default".to_string(),
}; };
@@ -316,26 +274,6 @@ 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 {
@@ -349,7 +287,6 @@ 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");
@@ -686,195 +623,3 @@ 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(())
}

View File

@@ -96,7 +96,6 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::Hangup { .. } => CallSignalType::Hangup, SignalMessage::Hangup { .. } => CallSignalType::Hangup,
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer, SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
SignalMessage::AuthToken { .. } => CallSignalType::Offer, SignalMessage::AuthToken { .. } => CallSignalType::Offer,
SignalMessage::Hold => CallSignalType::Hold, SignalMessage::Hold => CallSignalType::Hold,
@@ -110,16 +109,6 @@ 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
} }
} }
@@ -135,7 +124,6 @@ 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"));
@@ -153,7 +141,6 @@ 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));

View File

@@ -17,7 +17,6 @@ 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);
@@ -38,14 +37,10 @@ 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?;

View File

@@ -10,17 +10,8 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
wzp-proto = { workspace = true } wzp-proto = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
# Opus bindings — libopus 1.5.2. # Opus bindings
# opusic-c for the encoder (set_dred_duration lives here in Phase 1). audiopus = { workspace = true }
# opusic-sys for the decoder — we wrap the raw *mut OpusDecoder ourselves
# because opusic-c::Decoder.inner is pub(crate), blocking the unified
# decoder + DRED path we need in Phase 3.
opusic-c = { workspace = true }
opusic-sys = { workspace = true }
# Zero-cost slice reinterpretation for the i16 ↔ u16 boundary between
# our PCM buffers and opusic-c's encode API.
bytemuck = { workspace = true }
# Pure-Rust Codec2 implementation # Pure-Rust Codec2 implementation
codec2 = { workspace = true } codec2 = { workspace = true }

View File

@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
use crate::codec2_enc::Codec2Encoder; use crate::codec2_enc::Codec2Encoder;
use crate::opus_dec::OpusDecoder; use crate::opus_dec::OpusDecoder;
use crate::opus_enc::OpusEncoder; use crate::opus_enc::OpusEncoder;
use crate::resample::{Downsampler48to8, Upsampler8to48}; use crate::resample;
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -54,7 +54,6 @@ pub struct AdaptiveEncoder {
opus: OpusEncoder, opus: OpusEncoder,
codec2: Codec2Encoder, codec2: Codec2Encoder,
active: CodecId, active: CodecId,
downsampler: Downsampler48to8,
} }
impl AdaptiveEncoder { impl AdaptiveEncoder {
@@ -67,7 +66,6 @@ impl AdaptiveEncoder {
opus, opus,
codec2, codec2,
active: profile.codec, active: profile.codec,
downsampler: Downsampler48to8::new(),
}) })
} }
} }
@@ -76,7 +74,7 @@ impl AudioEncoder for AdaptiveEncoder {
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> { fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
if is_codec2(self.active) { if is_codec2(self.active) {
// Downsample 48 kHz → 8 kHz then encode via Codec2. // Downsample 48 kHz → 8 kHz then encode via Codec2.
let pcm_8k = self.downsampler.process(pcm); let pcm_8k = resample::resample_48k_to_8k(pcm);
self.codec2.encode(&pcm_8k, out) self.codec2.encode(&pcm_8k, out)
} else { } else {
self.opus.encode(pcm, out) self.opus.encode(pcm, out)
@@ -128,7 +126,6 @@ pub struct AdaptiveDecoder {
opus: OpusDecoder, opus: OpusDecoder,
codec2: Codec2Decoder, codec2: Codec2Decoder,
active: CodecId, active: CodecId,
upsampler: Upsampler8to48,
} }
impl AdaptiveDecoder { impl AdaptiveDecoder {
@@ -141,7 +138,6 @@ impl AdaptiveDecoder {
opus, opus,
codec2, codec2,
active: profile.codec, active: profile.codec,
upsampler: Upsampler8to48::new(),
}) })
} }
} }
@@ -153,7 +149,7 @@ impl AudioDecoder for AdaptiveDecoder {
let c2_samples = self.codec2_frame_samples(); let c2_samples = self.codec2_frame_samples();
let mut buf_8k = vec![0i16; c2_samples]; let mut buf_8k = vec![0i16; c2_samples];
let n = self.codec2.decode(encoded, &mut buf_8k)?; let n = self.codec2.decode(encoded, &mut buf_8k)?;
let pcm_48k = self.upsampler.process(&buf_8k[..n]); let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
let out_len = pcm_48k.len().min(pcm.len()); let out_len = pcm_48k.len().min(pcm.len());
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]); pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
Ok(out_len) Ok(out_len)
@@ -167,7 +163,7 @@ impl AudioDecoder for AdaptiveDecoder {
let c2_samples = self.codec2_frame_samples(); let c2_samples = self.codec2_frame_samples();
let mut buf_8k = vec![0i16; c2_samples]; let mut buf_8k = vec![0i16; c2_samples];
let n = self.codec2.decode_lost(&mut buf_8k)?; let n = self.codec2.decode_lost(&mut buf_8k)?;
let pcm_48k = self.upsampler.process(&buf_8k[..n]); let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
let out_len = pcm_48k.len().min(pcm.len()); let out_len = pcm_48k.len().min(pcm.len());
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]); pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
Ok(out_len) Ok(out_len)
@@ -199,27 +195,6 @@ impl AdaptiveDecoder {
fn codec2_frame_samples(&self) -> usize { fn codec2_frame_samples(&self) -> usize {
self.codec2.frame_samples() self.codec2.frame_samples()
} }
/// Reconstruct a lost frame from a previously parsed DRED state.
///
/// Phase 3b entry point for gap reconstruction. Dispatches to the
/// inner Opus decoder when active. Returns an error if the active
/// codec is Codec2 — DRED is libopus-only and has no Codec2 equivalent,
/// so callers must fall back to classical PLC on Codec2 tiers.
pub fn reconstruct_from_dred(
&mut self,
state: &crate::dred_ffi::DredState,
offset_samples: i32,
output: &mut [i16],
) -> Result<usize, CodecError> {
if is_codec2(self.active) {
return Err(CodecError::DecodeFailed(
"DRED reconstruction is Opus-only; Codec2 must use classical PLC".into(),
));
}
self.opus
.reconstruct_from_dred(state, offset_samples, output)
}
} }
// ─── Tests ─────────────────────────────────────────────────────────────────── // ─── Tests ───────────────────────────────────────────────────────────────────

View File

@@ -1,228 +0,0 @@
//! Acoustic Echo Cancellation using NLMS adaptive filter.
//! Processes 480-sample (10ms) sub-frames at 48kHz.
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
///
/// Removes acoustic echo by modelling the echo path between the far-end
/// (speaker) signal and the near-end (microphone) signal, then subtracting
/// the estimated echo from the near-end in real time.
pub struct EchoCanceller {
filter_coeffs: Vec<f32>,
filter_len: usize,
far_end_buf: Vec<f32>,
far_end_pos: usize,
mu: f32,
enabled: bool,
}
impl EchoCanceller {
/// Create a new echo canceller.
///
/// * `sample_rate` — typically 48000
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
Self {
filter_coeffs: vec![0.0f32; filter_len],
filter_len,
far_end_buf: vec![0.0f32; filter_len],
far_end_pos: 0,
mu: 0.01,
enabled: true,
}
}
/// Feed far-end (speaker/playback) samples into the circular buffer.
///
/// Must be called with the audio that was played out through the speaker
/// *before* the corresponding near-end frame is processed.
pub fn feed_farend(&mut self, farend: &[i16]) {
for &s in farend {
self.far_end_buf[self.far_end_pos] = s as f32;
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
}
}
/// Process a near-end (microphone) frame, removing the estimated echo.
///
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
/// the original near-end divided by the RMS of the residual. Values > 1.0
/// mean echo was reduced.
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
if !self.enabled {
return 1.0;
}
let n = nearend.len();
let fl = self.filter_len;
let mut sum_near_sq: f64 = 0.0;
let mut sum_err_sq: f64 = 0.0;
for i in 0..n {
let near_f = nearend[i] as f32;
// --- estimate echo as dot(coeffs, farend_window) ---
// The far-end window for this sample starts at
// (far_end_pos - 1 - i) mod filter_len (most recent)
// and goes back filter_len samples.
let mut echo_est: f32 = 0.0;
let mut power: f32 = 0.0;
// Position of the most-recent far-end sample for this near-end sample.
// far_end_pos points to the *next write* position, so the most-recent
// sample written is at far_end_pos - 1. We have already called
// feed_farend for this block, so the relevant samples are the last
// filter_len entries ending just before the current write position,
// offset by how far we are into this near-end frame.
//
// For sample i of the near-end frame, the corresponding far-end
// "now" is far_end_pos - n + i (wrapping).
// far_end_pos points to next-write, so most recent sample is at
// far_end_pos - 1. For the i-th near-end sample we want the
// far-end "now" to be at (far_end_pos - n + i). We add fl
// repeatedly to avoid underflow on the usize subtraction.
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
for k in 0..fl {
let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx];
echo_est += self.filter_coeffs[k] * fe;
power += fe * fe;
}
let error = near_f - echo_est;
// --- NLMS coefficient update ---
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
let step = self.mu * error / norm;
for k in 0..fl {
let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx];
self.filter_coeffs[k] += step * fe;
}
// Clamp output
let out = error.max(-32768.0).min(32767.0);
nearend[i] = out as i16;
sum_near_sq += (near_f as f64) * (near_f as f64);
sum_err_sq += (out as f64) * (out as f64);
}
// ERLE ratio
if sum_err_sq < 1.0 {
return 100.0; // near-perfect cancellation
}
(sum_near_sq / sum_err_sq).sqrt() as f32
}
/// Enable or disable echo cancellation.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Returns whether echo cancellation is currently enabled.
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Reset the adaptive filter to its initial state.
///
/// Zeroes out all filter coefficients and the far-end circular buffer.
pub fn reset(&mut self) {
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
self.far_end_pos = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aec_creates_with_correct_filter_len() {
let aec = EchoCanceller::new(48000, 100);
assert_eq!(aec.filter_len, 4800);
assert_eq!(aec.filter_coeffs.len(), 4800);
assert_eq!(aec.far_end_buf.len(), 4800);
}
#[test]
fn aec_passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 100);
aec.set_enabled(false);
assert!(!aec.is_enabled());
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
let mut frame = original.clone();
let erle = aec.process_frame(&mut frame);
assert_eq!(erle, 1.0);
assert_eq!(frame, original);
}
#[test]
fn aec_reset_zeroes_state() {
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
aec.feed_farend(&farend);
aec.reset();
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
assert_eq!(aec.far_end_pos, 0);
}
#[test]
fn aec_reduces_echo_of_known_signal() {
// Use a small filter for speed. Feed a known far-end signal, then
// present the *same* signal as near-end (perfect echo, no room).
// After adaptation the output energy should drop.
let filter_ms = 5; // 240 taps at 48 kHz
let mut aec = EchoCanceller::new(48000, filter_ms);
// Generate a simple repeating pattern.
let frame_len = 480usize;
let make_frame = |offset: usize| -> Vec<i16> {
(0..frame_len)
.map(|i| {
let t = (offset + i) as f64 / 48000.0;
(5000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
})
.collect()
};
// Warm up the adaptive filter with several frames.
let mut last_erle = 1.0f32;
for frame_idx in 0..40 {
let farend = make_frame(frame_idx * frame_len);
aec.feed_farend(&farend);
// Near-end = exact copy of far-end (pure echo).
let mut nearend = farend.clone();
last_erle = aec.process_frame(&mut nearend);
}
// After 40 frames the ERLE should be meaningfully > 1.
assert!(
last_erle > 1.0,
"expected ERLE > 1.0 after adaptation, got {last_erle}"
);
}
#[test]
fn aec_silence_passthrough() {
let mut aec = EchoCanceller::new(48000, 10);
// Feed silence far-end
aec.feed_farend(&vec![0i16; 480]);
// Near-end is silence too
let mut frame = vec![0i16; 480];
let erle = aec.process_frame(&mut frame);
assert!(erle >= 1.0);
// Output should still be silence
assert!(frame.iter().all(|&s| s == 0));
}
}

View File

@@ -1,219 +0,0 @@
//! Automatic Gain Control (AGC) with two-stage smoothing.
//!
//! Uses a fast attack / slow release envelope follower to keep the
//! output signal near a configurable target RMS level. This prevents
//! both clipping (when the speaker is too loud) and inaudibility (when
//! the speaker is too quiet or far from the mic).
/// Two-stage automatic gain control.
///
/// The gain is adjusted per-frame based on the measured RMS energy,
/// with a fast attack (gain decreases quickly when signal gets louder)
/// and a slow release (gain increases gradually when signal gets quieter).
pub struct AutoGainControl {
target_rms: f64,
current_gain: f64,
min_gain: f64,
max_gain: f64,
attack_alpha: f64,
release_alpha: f64,
enabled: bool,
}
impl AutoGainControl {
/// Create a new AGC with sensible VoIP defaults.
pub fn new() -> Self {
Self {
target_rms: 3000.0, // ~-20 dBFS for i16
current_gain: 1.0,
min_gain: 0.5,
max_gain: 32.0,
attack_alpha: 0.3, // fast attack
release_alpha: 0.02, // slow release
enabled: true,
}
}
/// Process a frame of PCM audio in-place, applying gain adjustment.
pub fn process_frame(&mut self, pcm: &mut [i16]) {
if !self.enabled {
return;
}
// Compute RMS of the frame.
let rms = Self::compute_rms(pcm);
// Don't amplify near-silence — it would just boost noise.
if rms < 10.0 {
return;
}
// Desired instantaneous gain.
let desired_gain = (self.target_rms / rms).clamp(self.min_gain, self.max_gain);
// Smooth the gain transition.
let alpha = if desired_gain < self.current_gain {
// Signal is louder than target → reduce gain quickly (attack).
self.attack_alpha
} else {
// Signal is quieter than target → raise gain slowly (release).
self.release_alpha
};
self.current_gain = self.current_gain * (1.0 - alpha) + desired_gain * alpha;
// Apply gain to each sample with hard limiting at ±31000 (~0.946 * i16::MAX).
const LIMIT: f64 = 31000.0;
let gain = self.current_gain;
for sample in pcm.iter_mut() {
let amplified = (*sample as f64) * gain;
let clamped = amplified.clamp(-LIMIT, LIMIT);
*sample = clamped as i16;
}
}
/// Enable or disable the AGC.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Returns whether the AGC is currently enabled.
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Current gain expressed in dB.
pub fn current_gain_db(&self) -> f64 {
20.0 * self.current_gain.log10()
}
/// Compute the RMS (root mean square) of a PCM buffer.
fn compute_rms(pcm: &[i16]) -> f64 {
if pcm.is_empty() {
return 0.0;
}
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
(sum_sq / pcm.len() as f64).sqrt()
}
}
impl Default for AutoGainControl {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agc_creates_with_defaults() {
let agc = AutoGainControl::new();
assert!(agc.is_enabled());
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
}
#[test]
fn agc_passthrough_when_disabled() {
let mut agc = AutoGainControl::new();
agc.set_enabled(false);
let original: Vec<i16> = (0..960).map(|i| (i * 5) as i16).collect();
let mut frame = original.clone();
agc.process_frame(&mut frame);
assert_eq!(frame, original);
}
#[test]
fn agc_does_not_amplify_silence() {
let mut agc = AutoGainControl::new();
let mut frame = vec![0i16; 960];
agc.process_frame(&mut frame);
assert!(frame.iter().all(|&s| s == 0));
// Gain should remain at initial value.
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
}
#[test]
fn agc_amplifies_quiet_signal() {
let mut agc = AutoGainControl::new();
// Very quiet signal (RMS ~ 50).
let mut frame: Vec<i16> = (0..960)
.map(|i| {
let t = i as f64 / 48000.0;
(50.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
})
.collect();
// Process several frames to let the gain ramp up.
for _ in 0..50 {
let mut f = frame.clone();
agc.process_frame(&mut f);
frame = f;
}
// Gain should have increased past 1.0.
assert!(
agc.current_gain > 1.05,
"expected gain > 1.05 for quiet signal, got {}",
agc.current_gain
);
}
#[test]
fn agc_attenuates_loud_signal() {
let mut agc = AutoGainControl::new();
// Loud signal (RMS ~ 20000).
let frame: Vec<i16> = (0..960)
.map(|i| {
let t = i as f64 / 48000.0;
(28000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
})
.collect();
// Process several frames.
for _ in 0..20 {
let mut f = frame.clone();
agc.process_frame(&mut f);
}
// Gain should have decreased below 1.0.
assert!(
agc.current_gain < 1.0,
"expected gain < 1.0 for loud signal, got {}",
agc.current_gain
);
}
#[test]
fn agc_output_within_limits() {
let mut agc = AutoGainControl::new();
// Force a high gain by processing many quiet frames first.
for _ in 0..100 {
let mut f: Vec<i16> = vec![100; 960];
agc.process_frame(&mut f);
}
// Now send a louder frame — output should still be within ±31000.
let mut frame: Vec<i16> = vec![20000; 960];
agc.process_frame(&mut frame);
assert!(
frame.iter().all(|&s| s.abs() <= 31000),
"output samples must be within ±31000"
);
}
#[test]
fn agc_gain_db_at_unity() {
let agc = AutoGainControl::new();
let db = agc.current_gain_db();
assert!(
db.abs() < 0.01,
"expected ~0 dB at unity gain, got {db}"
);
}
}

View File

@@ -1,585 +0,0 @@
//! Raw opusic-sys FFI wrappers for libopus 1.5.2 decoder + DRED reconstruction.
//!
//! # Why this module exists
//!
//! We cannot use `opusic_c::Decoder` because its inner `*mut OpusDecoder`
//! pointer is `pub(crate)` — not reachable from outside the opusic-c crate.
//! Phase 3 of the DRED integration needs to hand that same pointer to
//! `opus_decoder_dred_decode`, and running two parallel decoders (one from
//! opusic-c for normal audio, another from opusic-sys for DRED) would cause
//! the DRED-only decoder's internal state to drift out of sync with the
//! audio stream because it would not see normal decode calls.
//!
//! The fix is to own the raw decoder ourselves and use the same handle for
//! both normal decode AND DRED reconstruction. This module is the single
//! owner of `*mut OpusDecoder`, `*mut OpusDREDDecoder`, and `*mut OpusDRED`
//! in the WZP workspace.
//!
//! # Phase 3a scope
//!
//! Phase 0 added `DecoderHandle` (normal decode). Phase 3a adds:
//! - [`DredDecoderHandle`] — wraps `*mut OpusDREDDecoder` for parsing DRED
//! side-channel data out of arriving Opus packets.
//! - [`DredState`] — wraps `*mut OpusDRED` (a fixed 10,592-byte buffer
//! allocated by libopus) that holds parsed DRED state between the parse
//! and reconstruct steps.
//! - [`DredDecoderHandle::parse_into`] — wraps `opus_dred_parse`.
//! - [`DecoderHandle::reconstruct_from_dred`] — wraps `opus_decoder_dred_decode`.
//!
//! The pattern is: on every arriving Opus packet, the receiver calls
//! `parse_into` with a reusable `DredState`, then stores (seq, state_clone)
//! in a ring. On detected loss, the receiver computes the offset from the
//! freshest reachable DRED state and calls `reconstruct_from_dred` to
//! synthesize the missing audio.
use std::ptr::NonNull;
use opusic_sys::{
OPUS_OK, OpusDRED, OpusDREDDecoder, OpusDecoder as RawOpusDecoder, opus_decode,
opus_decoder_create, opus_decoder_destroy, opus_decoder_dred_decode, opus_dred_alloc,
opus_dred_decoder_create, opus_dred_decoder_destroy, opus_dred_free, opus_dred_parse,
};
use wzp_proto::CodecError;
/// libopus operates at 48 kHz for all Opus variants we use.
const SAMPLE_RATE_HZ: i32 = 48_000;
/// Mono.
const CHANNELS: i32 = 1;
/// Safe owner of a `*mut OpusDecoder` allocated via `opus_decoder_create`.
///
/// Releases the decoder in `Drop`. All FFI access goes through `&mut self`
/// methods, so there is no aliasing or race. The raw pointer is exposed via
/// [`Self::as_raw_ptr`] at a crate-internal visibility for the future Phase 3
/// DRED reconstruction path — external crates cannot reach it.
pub struct DecoderHandle {
inner: NonNull<RawOpusDecoder>,
}
impl DecoderHandle {
/// Allocate a new Opus decoder at 48 kHz mono.
pub fn new() -> Result<Self, CodecError> {
let mut error: i32 = OPUS_OK;
// SAFETY: opus_decoder_create writes to `error` and returns either a
// valid heap pointer or null. We check both before constructing the
// NonNull wrapper.
let ptr = unsafe { opus_decoder_create(SAMPLE_RATE_HZ, CHANNELS, &mut error) };
if error != OPUS_OK {
// Even if ptr is non-null on error, libopus contracts guarantee
// it is unusable — do not attempt to free it.
return Err(CodecError::DecodeFailed(format!(
"opus_decoder_create failed: err={error}"
)));
}
let inner = NonNull::new(ptr).ok_or_else(|| {
CodecError::DecodeFailed("opus_decoder_create returned null".into())
})?;
Ok(Self { inner })
}
/// Decode an Opus packet into PCM samples.
///
/// `pcm` must have enough capacity for the frame (960 for 20 ms, 1920
/// for 40 ms at 48 kHz mono). Returns the number of decoded samples
/// per channel — for mono streams this equals the total sample count.
pub fn decode(&mut self, packet: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
if packet.is_empty() {
return Err(CodecError::DecodeFailed("empty packet".into()));
}
if pcm.is_empty() {
return Err(CodecError::DecodeFailed("empty output buffer".into()));
}
// SAFETY: self.inner is a valid *mut OpusDecoder owned by this struct.
// `data` / `pcm` are live Rust slices, so their pointers and lengths
// are valid for the duration of the call. libopus reads len bytes
// from data and writes up to frame_size samples (per channel) to pcm.
let n = unsafe {
opus_decode(
self.inner.as_ptr(),
packet.as_ptr(),
packet.len() as i32,
pcm.as_mut_ptr(),
pcm.len() as i32,
/* decode_fec = */ 0,
)
};
if n < 0 {
return Err(CodecError::DecodeFailed(format!(
"opus_decode failed: err={n}"
)));
}
Ok(n as usize)
}
/// Generate packet-loss concealment audio for a missing frame.
///
/// Implemented via `opus_decode` with a null data pointer, per the
/// libopus API contract. `pcm` should be sized for the expected frame.
pub fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
if pcm.is_empty() {
return Err(CodecError::DecodeFailed("empty output buffer".into()));
}
// SAFETY: same invariants as decode(). libopus documents that passing
// a null data pointer with len=0 triggers PLC synthesis into pcm.
let n = unsafe {
opus_decode(
self.inner.as_ptr(),
std::ptr::null(),
0,
pcm.as_mut_ptr(),
pcm.len() as i32,
/* decode_fec = */ 0,
)
};
if n < 0 {
return Err(CodecError::DecodeFailed(format!(
"opus_decode PLC failed: err={n}"
)));
}
Ok(n as usize)
}
/// Reconstruct audio from a `DredState` into the `output` buffer.
///
/// `offset_samples` is the sample position (positive, measured backward
/// from the packet anchor that produced `state`) where reconstruction
/// begins. `output.len()` must match the number of samples to synthesize.
///
/// The libopus API: `opus_decoder_dred_decode(st, dred, dred_offset, pcm,
/// frame_size)` where `dred_offset` is "position of the redundancy to
/// decode, in samples before the beginning of the real audio data in the
/// packet." Valid values: `0 < offset_samples < state.samples_available()`.
///
/// Returns the number of samples actually written (should equal
/// `output.len()` on success).
pub fn reconstruct_from_dred(
&mut self,
state: &DredState,
offset_samples: i32,
output: &mut [i16],
) -> Result<usize, CodecError> {
if output.is_empty() {
return Err(CodecError::DecodeFailed(
"empty reconstruction output buffer".into(),
));
}
if offset_samples <= 0 {
return Err(CodecError::DecodeFailed(format!(
"DRED offset must be positive (got {offset_samples})"
)));
}
if offset_samples > state.samples_available() {
return Err(CodecError::DecodeFailed(format!(
"DRED offset {offset_samples} exceeds available samples {}",
state.samples_available()
)));
}
// SAFETY: self.inner is a valid *mut OpusDecoder, state.inner is a
// valid *const OpusDRED populated by a prior parse_into call, and
// output is a live mutable slice. libopus reads from dred and writes
// exactly frame_size samples (the output.len()) to pcm.
let n = unsafe {
opus_decoder_dred_decode(
self.inner.as_ptr(),
state.inner.as_ptr(),
offset_samples,
output.as_mut_ptr(),
output.len() as i32,
)
};
if n < 0 {
return Err(CodecError::DecodeFailed(format!(
"opus_decoder_dred_decode failed: err={n}"
)));
}
Ok(n as usize)
}
}
impl Drop for DecoderHandle {
fn drop(&mut self) {
// SAFETY: we own the pointer and no further access happens after
// this call because Drop consumes self.
unsafe { opus_decoder_destroy(self.inner.as_ptr()) };
}
}
// SAFETY: The underlying OpusDecoder is a plain heap allocation with no
// thread-local or lock-free state. It is safe to move between threads
// (Send), and all method access is gated by &mut self so Rust's borrow
// checker prevents simultaneous access from multiple threads (Sync).
unsafe impl Send for DecoderHandle {}
unsafe impl Sync for DecoderHandle {}
// ─── DRED decoder (parser) ──────────────────────────────────────────────────
/// Safe owner of a `*mut OpusDREDDecoder` allocated via
/// `opus_dred_decoder_create`.
///
/// The DRED decoder is a **separate** libopus object from the regular
/// `OpusDecoder`. It's used exclusively for parsing DRED side-channel data
/// out of arriving Opus packets via [`Self::parse_into`]. Actual audio
/// reconstruction from the parsed state uses the regular `DecoderHandle`
/// via [`DecoderHandle::reconstruct_from_dred`].
pub struct DredDecoderHandle {
inner: NonNull<OpusDREDDecoder>,
}
impl DredDecoderHandle {
/// Allocate a new DRED decoder.
pub fn new() -> Result<Self, CodecError> {
let mut error: i32 = OPUS_OK;
// SAFETY: opus_dred_decoder_create writes to `error` and returns
// either a valid heap pointer or null. Both are checked.
let ptr = unsafe { opus_dred_decoder_create(&mut error) };
if error != OPUS_OK {
return Err(CodecError::DecodeFailed(format!(
"opus_dred_decoder_create failed: err={error}"
)));
}
let inner = NonNull::new(ptr).ok_or_else(|| {
CodecError::DecodeFailed("opus_dred_decoder_create returned null".into())
})?;
Ok(Self { inner })
}
/// Parse DRED side-channel data from an Opus packet into `state`.
///
/// Returns the number of samples of audio history available for
/// reconstruction, or 0 if the packet carries no DRED data. Subsequent
/// `DecoderHandle::reconstruct_from_dred` calls using this `state` can
/// reconstruct any sample position in `(0, samples_available]`.
///
/// libopus API: `opus_dred_parse(dred_dec, dred, data, len,
/// max_dred_samples, sampling_rate, dred_end, defer_processing)`. We
/// pass `max_dred_samples = 48000` (1 s at 48 kHz, the DRED maximum),
/// `sampling_rate = 48000`, `defer_processing = 0` (process immediately).
/// The `dred_end` output is the silence gap at the tail of the DRED
/// window; we subtract it from the total offset to give callers the
/// truly usable sample count.
pub fn parse_into(
&mut self,
state: &mut DredState,
packet: &[u8],
) -> Result<i32, CodecError> {
if packet.is_empty() {
state.samples_available = 0;
return Ok(0);
}
let mut dred_end: i32 = 0;
// SAFETY: self.inner is a valid *mut OpusDREDDecoder; state.inner is
// a valid *mut OpusDRED allocated via opus_dred_alloc; packet is a
// live slice; dred_end is a stack int. libopus reads packet bytes
// and writes parsed DRED state into *state.inner.
let ret = unsafe {
opus_dred_parse(
self.inner.as_ptr(),
state.inner.as_ptr(),
packet.as_ptr(),
packet.len() as i32,
/* max_dred_samples = */ 48_000, // 1s max per libopus 1.5
/* sampling_rate = */ 48_000,
&mut dred_end,
/* defer_processing = */ 0,
)
};
if ret < 0 {
state.samples_available = 0;
return Err(CodecError::DecodeFailed(format!(
"opus_dred_parse failed: err={ret}"
)));
}
// ret is the positive offset of the first decodable DRED sample,
// or 0 if no DRED is present. dred_end is the silence gap at the
// tail. The usable sample range is (dred_end, ret], so the count
// of usable samples is ret - dred_end. We store `ret` as the max
// usable offset — callers should pass dred_offset values in the
// range (dred_end, ret] to reconstruct_from_dred. For simplicity
// we expose just samples_available = ret and let callers treat
// the full window as valid (the silence gap is small and libopus
// handles minor boundary cases gracefully).
state.samples_available = ret;
Ok(ret)
}
}
impl Drop for DredDecoderHandle {
fn drop(&mut self) {
// SAFETY: we own the pointer and no further access happens after
// this call because Drop consumes self.
unsafe { opus_dred_decoder_destroy(self.inner.as_ptr()) };
}
}
// SAFETY: same reasoning as DecoderHandle — heap allocation with no
// thread-local state, &mut self access discipline prevents races.
unsafe impl Send for DredDecoderHandle {}
unsafe impl Sync for DredDecoderHandle {}
// ─── DRED state buffer ──────────────────────────────────────────────────────
/// Safe owner of a `*mut OpusDRED` allocated via `opus_dred_alloc`.
///
/// Holds a fixed-size (10,592-byte per libopus 1.5) buffer that
/// `DredDecoderHandle::parse_into` populates from an Opus packet. The state
/// is reusable — the caller can call `parse_into` again on the same
/// `DredState` to overwrite it with a fresh packet's data.
///
/// `samples_available` tracks the last-parsed result so reconstruction
/// callers don't need to thread the return value separately. A fresh
/// state (before any `parse_into`) has `samples_available == 0`.
pub struct DredState {
inner: NonNull<OpusDRED>,
samples_available: i32,
}
impl DredState {
/// Allocate a new DRED state buffer.
pub fn new() -> Result<Self, CodecError> {
let mut error: i32 = OPUS_OK;
// SAFETY: opus_dred_alloc writes to `error` and returns either a
// valid heap pointer or null.
let ptr = unsafe { opus_dred_alloc(&mut error) };
if error != OPUS_OK {
return Err(CodecError::DecodeFailed(format!(
"opus_dred_alloc failed: err={error}"
)));
}
let inner = NonNull::new(ptr)
.ok_or_else(|| CodecError::DecodeFailed("opus_dred_alloc returned null".into()))?;
Ok(Self {
inner,
samples_available: 0,
})
}
/// How many samples of audio history this state currently covers.
///
/// Returns 0 if the state is fresh or the last parse found no DRED
/// data. Otherwise returns the positive offset set by the most recent
/// `DredDecoderHandle::parse_into` call — the maximum valid
/// `offset_samples` value for `DecoderHandle::reconstruct_from_dred`.
pub fn samples_available(&self) -> i32 {
self.samples_available
}
/// Reset the state to "fresh" without freeing the underlying buffer.
/// The next `parse_into` will overwrite the contents.
pub fn reset(&mut self) {
self.samples_available = 0;
}
}
impl Drop for DredState {
fn drop(&mut self) {
// SAFETY: we own the pointer and no further access happens after
// this call because Drop consumes self.
unsafe { opus_dred_free(self.inner.as_ptr()) };
}
}
// SAFETY: same reasoning as DecoderHandle.
unsafe impl Send for DredState {}
unsafe impl Sync for DredState {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decoder_handle_creates_and_drops() {
let handle = DecoderHandle::new().expect("decoder create");
// Dropping the handle must not panic or leak — validated by miri
// and the absence of sanitizer complaints in CI.
drop(handle);
}
#[test]
fn decode_lost_produces_full_frame_of_silence_on_cold_start() {
let mut handle = DecoderHandle::new().unwrap();
// 20 ms @ 48 kHz mono.
let mut pcm = vec![0i16; 960];
let n = handle.decode_lost(&mut pcm).unwrap();
assert_eq!(n, 960);
// On a fresh decoder, PLC output is silence (no past audio to extend).
assert!(pcm.iter().all(|&s| s == 0));
}
#[test]
fn decode_empty_packet_errors() {
let mut handle = DecoderHandle::new().unwrap();
let mut pcm = vec![0i16; 960];
let err = handle.decode(&[], &mut pcm);
assert!(err.is_err());
}
// ─── Phase 3a — DRED decoder + state ────────────────────────────────────
#[test]
fn dred_decoder_handle_creates_and_drops() {
let h = DredDecoderHandle::new().expect("dred decoder create");
drop(h);
}
#[test]
fn dred_state_creates_and_drops() {
let s = DredState::new().expect("dred state alloc");
assert_eq!(s.samples_available(), 0);
drop(s);
}
#[test]
fn dred_state_reset_zeroes_counter() {
let mut s = DredState::new().unwrap();
s.samples_available = 480; // pretend a parse populated it
assert_eq!(s.samples_available(), 480);
s.reset();
assert_eq!(s.samples_available(), 0);
}
/// Phase 3a end-to-end: encode a DRED-enabled stream, parse state out
/// of packets, and reconstruct audio at a past offset. Validates the
/// full parse → reconstruct pipeline against a real libopus 1.5.2
/// encoder so we catch FFI-layer bugs early.
#[test]
fn dred_parse_and_reconstruct_roundtrip() {
use crate::opus_enc::OpusEncoder;
use wzp_proto::{AudioEncoder, QualityProfile};
// Encoder with DRED at Opus 24k / 200 ms duration (Phase 1 default
// for GOOD profile). The loss floor is 5% per Phase 1.
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
// Decode-side handles.
let mut dec = DecoderHandle::new().unwrap();
let mut dred_dec = DredDecoderHandle::new().unwrap();
let mut state = DredState::new().unwrap();
// Generate 60 frames (1.2 s) of a voice-like 300 Hz sine wave so
// the encoder's DRED emitter has real content to encode rather
// than compressing silence.
let frame_len = 960usize; // 20 ms @ 48 kHz
let make_frame = |offset: usize| -> Vec<i16> {
(0..frame_len)
.map(|i| {
let t = (offset + i) as f64 / 48_000.0;
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
})
.collect()
};
// Track the freshest packet that carried non-zero DRED state.
let mut best_samples_available = 0;
let mut best_packet: Option<Vec<u8>> = None;
for frame_idx in 0..60 {
let pcm = make_frame(frame_idx * frame_len);
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm, &mut encoded).unwrap();
encoded.truncate(n);
// Run the packet through the normal decode path so dec's
// internal state mirrors the full stream — this is necessary
// for DRED reconstruction to produce meaningful output.
let mut decoded = vec![0i16; frame_len];
dec.decode(&encoded, &mut decoded).unwrap();
// Parse DRED state out of the same packet. Early packets may
// have samples_available == 0 while the DRED encoder warms up;
// later packets should carry the full window.
match dred_dec.parse_into(&mut state, &encoded) {
Ok(available) => {
if available > best_samples_available {
best_samples_available = available;
best_packet = Some(encoded.clone());
}
}
Err(e) => panic!("parse_into errored unexpectedly: {e:?}"),
}
}
// By the time we're 60 frames in, DRED should have emitted data.
assert!(
best_samples_available > 0,
"DRED emitted zero samples across 60 frames — the encoder isn't \
producing DRED bytes (check set_dred_duration and packet_loss floor)"
);
// Parse the best packet into a fresh state and reconstruct some
// audio from somewhere inside its DRED window. We use frame_len/2
// as the offset to pick a point squarely inside the reconstructable
// range rather than at an edge.
let packet = best_packet.expect("at least one packet had DRED state");
let mut fresh_state = DredState::new().unwrap();
let available = dred_dec.parse_into(&mut fresh_state, &packet).unwrap();
assert!(available > 0, "re-parse of known-good packet returned 0");
// Need a decoder that's in the right state to reconstruct — rewind
// by creating a fresh one and feeding it the same stream up to the
// point of the best packet. Simpler: just use a fresh decoder and
// accept that the reconstructed samples may not be phase-matched.
// The test here only asserts *non-silent energy*, not signal fidelity.
let mut recon_dec = DecoderHandle::new().unwrap();
// Warm up the decoder with one frame so its internal state is valid.
let warmup_pcm = vec![0i16; frame_len];
let warmup_encoded = {
let mut warmup_enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
let mut buf = vec![0u8; 512];
let n = warmup_enc.encode(&warmup_pcm, &mut buf).unwrap();
buf.truncate(n);
buf
};
let mut throwaway = vec![0i16; frame_len];
let _ = recon_dec.decode(&warmup_encoded, &mut throwaway);
// Reconstruct 20 ms from some position inside the DRED window.
let offset = (available / 2).max(480).min(available);
let mut recon_pcm = vec![0i16; frame_len];
let n = recon_dec
.reconstruct_from_dred(&fresh_state, offset, &mut recon_pcm)
.expect("reconstruct_from_dred failed");
assert_eq!(n, frame_len);
// Energy check: reconstructed audio should not be all zeros. A
// loose threshold — the DRED reconstruction won't be phase-matched
// to our sine wave because we fed a cold decoder only one warmup
// frame, but it should still produce non-silent speech-like output
// since the DRED state was parsed from real speech content.
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum();
assert!(
energy > 0,
"reconstructed audio has zero total energy — DRED reconstruction produced silence"
);
}
/// A second roundtrip variant: offset too large errors cleanly rather
/// than crashing the FFI.
#[test]
fn reconstruct_with_out_of_range_offset_errors() {
let mut dec = DecoderHandle::new().unwrap();
let state = DredState::new().unwrap();
// state has samples_available == 0 (fresh), so any positive offset
// should be out of range.
let mut out = vec![0i16; 960];
let err = dec.reconstruct_from_dred(&state, 480, &mut out);
assert!(err.is_err());
}
#[test]
fn reconstruct_with_zero_offset_errors() {
let mut dec = DecoderHandle::new().unwrap();
let state = DredState::new().unwrap();
let mut out = vec![0i16; 960];
let err = dec.reconstruct_from_dred(&state, 0, &mut out);
assert!(err.is_err());
}
#[test]
fn dred_parse_empty_packet_returns_zero() {
let mut dred_dec = DredDecoderHandle::new().unwrap();
let mut state = DredState::new().unwrap();
let result = dred_dec.parse_into(&mut state, &[]).unwrap();
assert_eq!(result, 0);
assert_eq!(state.samples_available(), 0);
}
}

View File

@@ -10,20 +10,15 @@
//! 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;
pub mod dred_ffi;
pub mod opus_dec; pub mod opus_dec;
pub mod opus_enc; pub mod opus_enc;
pub mod resample; 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};

View File

@@ -1,32 +1,30 @@
//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`. //! Opus decoder wrapping the `audiopus` crate.
//!
//! Phase 0 of the DRED integration: we went straight to a custom
//! `DecoderHandle` instead of `opusic_c::Decoder` because the latter's
//! inner pointer is `pub(crate)` and we need to reach it in Phase 3 for
//! `opus_decoder_dred_decode`. See `dred_ffi.rs` for the rationale and
//! `docs/PRD-dred-integration.md` for the full plan.
use crate::dred_ffi::{DecoderHandle, DredState}; use audiopus::coder::Decoder;
use audiopus::{Channels, MutSignals, SampleRate};
use audiopus::packet::Packet;
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile}; use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
/// Opus decoder implementing [`AudioDecoder`]. /// Opus decoder implementing `AudioDecoder`.
/// ///
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via /// Operates at 48 kHz mono output.
/// the active `QualityProfile`. Behavior is intentionally identical to
/// the pre-swap audiopus-based decoder at this phase — DRED reconstruction
/// lands in Phase 3.
pub struct OpusDecoder { pub struct OpusDecoder {
inner: DecoderHandle, inner: Decoder,
codec_id: CodecId, codec_id: CodecId,
frame_duration_ms: u8, frame_duration_ms: u8,
} }
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
unsafe impl Sync for OpusDecoder {}
impl OpusDecoder { impl OpusDecoder {
/// Create a new Opus decoder for the given quality profile. /// Create a new Opus decoder for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> { pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
let inner = DecoderHandle::new()?; let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
Ok(Self { Ok(Self {
inner, inner: decoder,
codec_id: profile.codec, codec_id: profile.codec,
frame_duration_ms: profile.frame_duration_ms, frame_duration_ms: profile.frame_duration_ms,
}) })
@@ -36,24 +34,6 @@ impl OpusDecoder {
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
} }
/// Reconstruct a lost frame from a previously parsed `DredState`.
///
/// Phase 3b entry point: callers (CallDecoder / engine.rs) use this to
/// synthesize audio for gaps detected by the jitter buffer when DRED
/// side-channel state from a later-arriving packet covers the gap's
/// sample offset. `offset_samples` is measured backward from the anchor
/// packet that produced `state`. See `DecoderHandle::reconstruct_from_dred`
/// for the full semantics.
pub fn reconstruct_from_dred(
&mut self,
state: &DredState,
offset_samples: i32,
output: &mut [i16],
) -> Result<usize, CodecError> {
self.inner
.reconstruct_from_dred(state, offset_samples, output)
}
} }
impl AudioDecoder for OpusDecoder { impl AudioDecoder for OpusDecoder {
@@ -65,7 +45,15 @@ impl AudioDecoder for OpusDecoder {
pcm.len() pcm.len()
))); )));
} }
self.inner.decode(encoded, pcm) let packet = Packet::try_from(encoded)
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
let signals = MutSignals::try_from(pcm)
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
let n = self
.inner
.decode(Some(packet), signals, false)
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
Ok(n)
} }
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> { fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
@@ -76,7 +64,13 @@ impl AudioDecoder for OpusDecoder {
pcm.len() pcm.len()
))); )));
} }
self.inner.decode_lost(pcm) let signals = MutSignals::try_from(pcm)
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
let n = self
.inner
.decode(None, signals, false)
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
Ok(n)
} }
fn codec_id(&self) -> CodecId { fn codec_id(&self) -> CodecId {
@@ -85,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 {
c if c.is_opus() => { CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
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(())

View File

@@ -1,199 +1,53 @@
//! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2). //! Opus encoder wrapping the `audiopus` crate.
//!
//! Phase 1 of the DRED integration: encoder-side DRED is enabled on every
//! Opus profile with a tiered duration (studio 100 ms / normal 200 ms /
//! degraded 500 ms), and Opus inband FEC (LBRR) is disabled because DRED
//! is the stronger mechanism for the same failure mode. The legacy behavior
//! is preserved behind the `AUDIO_USE_LEGACY_FEC` environment variable as a
//! runtime escape hatch for rollout. See `docs/PRD-dred-integration.md`.
//!
//! # DRED duration policy
//!
//! Rationale from the PRD:
//! - Studio tiers (Opus 32k/48k/64k): 100 ms — loss is rare on high-quality
//! networks; short window keeps decoder CPU modest.
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
//! VoIP loss patterns (20150 ms bursts from wifi roam, transient congestion).
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a
//! bad link; longer DRED buys maximum burst resilience where it matters.
//!
//! # Why the 15% packet loss floor
//!
//! libopus 1.5's DRED emitter is gated on `OPUS_SET_PACKET_LOSS_PERC` and
//! scales the emitted window proportionally to the assumed loss:
//!
//! ```text
//! loss_pct samples_available effective_ms
//! 5% 720 15
//! 10% 2640 55
//! 15% 4560 95
//! 20% 6480 135
//! 25%+ 8400 (capped) 175 (≈ 87% of the 200ms configured max)
//! ```
//!
//! Measured empirically against libopus 1.5.2 on Opus 24k / 200 ms DRED
//! duration during Phase 3b. At 5% loss the window is only 15 ms — too
//! small to even reconstruct a single 20 ms Opus frame. 15% gives 95 ms
//! (enough for single-frame recovery plus modest burst margin) while
//! keeping the bitrate overhead modest compared to 25%. Real measurements
//! from the quality adapter override upward when loss exceeds the floor.
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal}; use audiopus::coder::Encoder;
use tracing::{debug, warn}; use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
use tracing::debug;
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile}; use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus
/// scales the DRED emission window with the assumed loss percentage:
/// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15%
/// gives 95 ms, and 25%+ saturates the configured max (~175 ms at 200 ms
/// duration). 15% is the minimum value that produces a DRED window larger
/// than a single 20 ms frame, making it the minimum floor that actually
/// gives DRED something useful to reconstruct. Real loss measurements from
/// the quality adapter override this upward.
const DRED_LOSS_FLOOR_PCT: u8 = 15;
/// Environment variable that reverts Phase 1 behavior to Phase 0 (inband FEC
/// on, DRED off, no loss floor). Read once per encoder construction.
const LEGACY_FEC_ENV: &str = "AUDIO_USE_LEGACY_FEC";
/// Returns the DRED duration in 10 ms frame units for a given Opus codec.
///
/// Unit: each frame is 10 ms, so the max value of 104 corresponds to 1040 ms
/// of reconstructable history. Returns 0 for non-Opus codecs (DRED is not
/// emitted by the libopus encoder in that case anyway, but we avoid a
/// pointless FFI call).
///
/// See the DRED duration policy in the module docs for per-tier rationale.
pub fn dred_duration_for(codec: CodecId) -> u8 {
match codec {
// Studio tiers — loss is rare, short window.
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
// Normal tiers — balanced baseline.
CodecId::Opus16k | CodecId::Opus24k => 20,
// Degraded tier — maximum burst resilience.
CodecId::Opus6k => 50,
// Non-Opus (Codec2 / CN): DRED is N/A.
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
}
}
/// Returns whether the legacy-FEC escape hatch is active.
///
/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy
/// mode; unset or empty leaves DRED enabled.
fn read_legacy_fec_env() -> bool {
match std::env::var(LEGACY_FEC_ENV) {
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
Err(_) => false,
}
}
/// Opus encoder implementing `AudioEncoder`. /// Opus encoder implementing `AudioEncoder`.
/// ///
/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active /// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
/// `QualityProfile`. /// and 40 ms (1920 samples).
pub struct OpusEncoder { pub struct OpusEncoder {
inner: Encoder, inner: Encoder,
codec_id: CodecId, codec_id: CodecId,
frame_duration_ms: u8, frame_duration_ms: u8,
/// When `true`, revert to the Phase 0 behavior: inband FEC Mode1, DRED
/// disabled, no loss floor. Captured at construction time and not
/// re-read mid-call.
legacy_fec_mode: bool,
} }
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner // SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
// opusic-c Encoder wraps a non-null pointer that is !Sync by default, // audiopus Encoder contains a raw pointer that is !Sync, but we never
// but we never share it across threads without exclusive access. // share it across threads without exclusive access.
unsafe impl Sync for OpusEncoder {} unsafe impl Sync for OpusEncoder {}
impl OpusEncoder { impl OpusEncoder {
/// Create a new Opus encoder for the given quality profile. /// Create a new Opus encoder for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> { pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
// opusic-c argument order: (Channels, SampleRate, Application) let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
// — different from audiopus's (SampleRate, Channels, Application). .map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
let encoder = Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip)
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e:?}")))?;
let legacy_fec_mode = read_legacy_fec_env();
if legacy_fec_mode {
warn!(
"AUDIO_USE_LEGACY_FEC active — reverting Opus encoder to Phase 0 \
behavior (inband FEC Mode1, no DRED)"
);
}
let mut enc = Self { let mut enc = Self {
inner: encoder, inner: encoder,
codec_id: profile.codec, codec_id: profile.codec,
frame_duration_ms: profile.frame_duration_ms, frame_duration_ms: profile.frame_duration_ms,
legacy_fec_mode,
}; };
// Common setup — bitrate, DTX, signal hint, complexity. These are
// identical regardless of the protection mode below.
enc.apply_bitrate(profile.codec)?; enc.apply_bitrate(profile.codec)?;
enc.set_inband_fec(true);
enc.set_dtx(true); enc.set_dtx(true);
// Voice signal type hint for better compression
enc.inner enc.inner
.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}")))?;
enc.inner
.set_complexity(7)
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?;
// Protection mode: DRED (Phase 1 default) or legacy inband FEC.
enc.apply_protection_mode(profile.codec)?;
Ok(enc) Ok(enc)
} }
/// Configure the protection mode for the active codec.
///
/// In DRED mode (default): disable inband FEC, set DRED duration for the
/// codec tier, clamp packet_loss to the 5% floor so DRED stays active.
///
/// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave
/// DRED and packet_loss at libopus defaults.
fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> {
if self.legacy_fec_mode {
self.inner
.set_inband_fec(InbandFec::Mode1)
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?;
// Leave DRED at 0 and packet_loss at default — matches Phase 0.
return Ok(());
}
// DRED path: disable the overlapping inband FEC, enable DRED with
// per-profile duration, floor packet_loss so DRED emits.
self.inner
.set_inband_fec(InbandFec::Off)
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?;
let dred_frames = dred_duration_for(codec);
self.inner
.set_dred_duration(dred_frames)
.map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?;
self.inner
.set_packet_loss(DRED_LOSS_FLOOR_PCT)
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
debug!(
codec = ?codec,
dred_frames,
dred_ms = dred_frames as u32 * 10,
loss_floor_pct = DRED_LOSS_FLOOR_PCT,
"opus encoder: DRED enabled"
);
Ok(())
}
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> { fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
let bps = codec.bitrate_bps(); let bps = codec.bitrate_bps() as i32;
self.inner self.inner
.set_bitrate(Bitrate::Value(bps)) .set_bitrate(Bitrate::BitsPerSecond(bps))
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?; .map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
debug!(bitrate_bps = bps, "opus encoder bitrate set"); debug!(bitrate_bps = bps, "opus encoder bitrate set");
Ok(()) Ok(())
} }
@@ -202,47 +56,6 @@ impl OpusEncoder {
pub fn frame_samples(&self) -> usize { pub fn frame_samples(&self) -> usize {
(48_000 * self.frame_duration_ms as usize) / 1000 (48_000 * self.frame_duration_ms as usize) / 1000
} }
/// Set the encoder complexity (0-10). Higher values produce better quality
/// at the cost of more CPU. Default is 7.
pub fn set_complexity(&mut self, complexity: i32) {
let c = (complexity as u8).min(10);
let _ = self.inner.set_complexity(c);
}
/// Hint the encoder about expected packet loss percentage (0-100).
///
/// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the
/// encoder never drops DRED emission even on a perfect network. Real
/// loss measurements from the quality adapter override upward.
///
/// In legacy mode, the value is passed through unchanged (min 0, max 100).
pub fn set_expected_loss(&mut self, loss_pct: u8) {
let clamped = if self.legacy_fec_mode {
loss_pct.min(100)
} else {
loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100)
};
let _ = self.inner.set_packet_loss(clamped);
}
/// Set the DRED duration in 10 ms frame units (0 disables, max 104).
///
/// No-op in legacy mode. Normally driven automatically by the active
/// quality profile via `apply_protection_mode`; this setter exists for
/// tests and for the rare case where a caller needs to override the
/// per-profile default.
pub fn set_dred_duration(&mut self, frames: u8) {
if self.legacy_fec_mode {
return;
}
let _ = self.inner.set_dred_duration(frames.min(104));
}
/// Test/introspection accessor: whether legacy FEC mode is active.
pub fn is_legacy_fec_mode(&self) -> bool {
self.legacy_fec_mode
}
} }
impl AudioEncoder for OpusEncoder { impl AudioEncoder for OpusEncoder {
@@ -254,14 +67,10 @@ impl AudioEncoder for OpusEncoder {
pcm.len() pcm.len()
))); )));
} }
// opusic-c takes &[u16] for the sample input. Bit pattern is
// identical to i16 — the cast is zero-cost and the encoder
// interprets the bytes the same way as libopus internally.
let pcm_u16: &[u16] = bytemuck::cast_slice(pcm);
let n = self let n = self
.inner .inner
.encode_to_slice(pcm_u16, out) .encode(pcm, out)
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?; .map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
Ok(n) Ok(n)
} }
@@ -271,13 +80,10 @@ 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 {
c if c.is_opus() => { CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
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)?;
// Refresh DRED duration for the new tier. apply_protection_mode
// is idempotent and handles the legacy-vs-DRED branch correctly.
self.apply_protection_mode(profile.codec)?;
Ok(()) Ok(())
} }
other => Err(CodecError::UnsupportedTransition { other => Err(CodecError::UnsupportedTransition {
@@ -294,190 +100,10 @@ impl AudioEncoder for OpusEncoder {
} }
fn set_inband_fec(&mut self, enabled: bool) { fn set_inband_fec(&mut self, enabled: bool) {
// In DRED mode, ignore external requests to re-enable inband FEC — let _ = self.inner.set_inband_fec(enabled);
// running both mechanisms wastes bitrate on overlapping protection
// and opusic-c's own docs recommend disabling inband FEC when DRED
// is on. Trait callers that genuinely want classical FEC should set
// `AUDIO_USE_LEGACY_FEC=1` and re-create the encoder.
if !self.legacy_fec_mode {
debug!(
enabled,
"set_inband_fec ignored: DRED mode is active (set AUDIO_USE_LEGACY_FEC to revert)"
);
return;
}
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off };
let _ = self.inner.set_inband_fec(mode);
} }
fn set_dtx(&mut self, enabled: bool) { fn set_dtx(&mut self, enabled: bool) {
let _ = self.inner.set_dtx(enabled); let _ = self.inner.set_dtx(enabled);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use wzp_proto::AudioDecoder;
/// Phase 0 acceptance gate: fail loudly if the linked libopus is not 1.5.x.
/// DRED (Phase 1+) only exists in libopus ≥ 1.5, so running against an
/// older version would silently regress the entire DRED integration.
#[test]
fn linked_libopus_is_1_5() {
let version = opusic_c::version();
assert!(
version.contains("1.5"),
"expected libopus 1.5.x, got: {version}"
);
}
#[test]
fn encoder_creates_at_good_profile() {
let enc = OpusEncoder::new(QualityProfile::GOOD).expect("opus encoder init");
assert_eq!(enc.codec_id, CodecId::Opus24k);
assert_eq!(enc.frame_samples(), 960); // 20 ms @ 48 kHz
}
#[test]
fn encoder_roundtrip_silence() {
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
let pcm_in = vec![0i16; 960]; // 20 ms silence
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
assert!(n > 0);
let mut pcm_out = vec![0i16; 960];
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
assert_eq!(samples, 960);
}
// ─── Phase 1 — DRED duration policy ─────────────────────────────────────
#[test]
fn dred_duration_for_studio_tiers_is_100ms() {
assert_eq!(dred_duration_for(CodecId::Opus32k), 10);
assert_eq!(dred_duration_for(CodecId::Opus48k), 10);
assert_eq!(dred_duration_for(CodecId::Opus64k), 10);
}
#[test]
fn dred_duration_for_normal_tiers_is_200ms() {
assert_eq!(dred_duration_for(CodecId::Opus16k), 20);
assert_eq!(dred_duration_for(CodecId::Opus24k), 20);
}
#[test]
fn dred_duration_for_degraded_tier_is_500ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 50);
}
#[test]
fn dred_duration_for_codec2_is_zero() {
assert_eq!(dred_duration_for(CodecId::Codec2_3200), 0);
assert_eq!(dred_duration_for(CodecId::Codec2_1200), 0);
assert_eq!(dred_duration_for(CodecId::ComfortNoise), 0);
}
// ─── Phase 1 — Legacy escape hatch ──────────────────────────────────────
/// By default (env var unset), legacy mode is off.
///
/// This test does NOT manipulate the environment to avoid flakiness
/// when the full suite runs in parallel. It only asserts on a freshly
/// created encoder in the ambient environment.
#[test]
fn default_mode_is_dred_not_legacy() {
// SAFETY: only run if the ambient env hasn't set the var externally.
if std::env::var(LEGACY_FEC_ENV).is_ok() {
return; // don't assert — someone set the env for a reason.
}
let enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
assert!(!enc.is_legacy_fec_mode());
}
// ─── Phase 1 — Behavioral regression: roundtrip still works ─────────────
#[test]
fn dred_mode_roundtrip_voice_pattern() {
// Use a realistic voice-like input (sine wave at speech frequencies)
// so the encoder emits meaningful DRED data rather than trivially
// compressible silence.
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
let mut total_encoded_bytes = 0usize;
// Run 50 frames (1 second) so DRED fills up and starts emitting.
for frame_idx in 0..50 {
let pcm_in: Vec<i16> = (0..960)
.map(|i| {
let t = (frame_idx * 960 + i) as f64 / 48_000.0;
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
})
.collect();
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
assert!(n > 0);
total_encoded_bytes += n;
let mut pcm_out = vec![0i16; 960];
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
assert_eq!(samples, 960);
}
// Effective bitrate after 1 second of encoding.
// Opus 24k base + ~1 kbps DRED ≈ 25 kbps ≈ 3125 bytes/sec.
// Allow generous headroom (2000 lower bound, 8000 upper bound) —
// this is a behavioral regression check, not a tight bitrate assertion.
// The exact value is printed with --nocapture for diagnostic use.
eprintln!(
"[phase1 bitrate probe] legacy_fec_mode={} total_encoded={} bytes/sec",
enc.is_legacy_fec_mode(),
total_encoded_bytes
);
assert!(
total_encoded_bytes > 2000,
"encoder output too small: {total_encoded_bytes} bytes/sec (DRED likely not emitting)"
);
assert!(
total_encoded_bytes < 8000,
"encoder output too large: {total_encoded_bytes} bytes/sec"
);
}
// ─── Phase 1 — set_profile updates DRED duration on tier switch ─────────
#[test]
fn profile_switch_refreshes_dred_duration() {
// Start on GOOD (Opus 24k, DRED 20 frames), switch to DEGRADED
// (Opus 6k, DRED 50 frames). The encoder should accept both profile
// changes without error. We can't directly observe the DRED duration
// inside libopus, but apply_protection_mode returns Ok for both.
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
assert_eq!(enc.codec_id, CodecId::Opus24k);
enc.set_profile(QualityProfile::DEGRADED).unwrap();
assert_eq!(enc.codec_id, CodecId::Opus6k);
enc.set_profile(QualityProfile::STUDIO_64K).unwrap();
assert_eq!(enc.codec_id, CodecId::Opus64k);
}
// ─── Phase 1 — Trait set_inband_fec is a no-op in DRED mode ─────────────
#[test]
fn set_inband_fec_noop_in_dred_mode() {
if std::env::var(LEGACY_FEC_ENV).is_ok() {
return;
}
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
// Should not error, should not re-enable inband FEC internally.
enc.set_inband_fec(true);
// We can't directly query libopus's inband FEC state through opusic-c,
// but the call must not panic and the encoder must still work.
let pcm_in = vec![0i16; 960];
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
assert!(n > 0);
}
}

View File

@@ -1,258 +1,55 @@
//! Windowed-sinc FIR resampler for 48 kHz <-> 8 kHz conversion. //! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
//! //!
//! Provides both stateless free functions (backward-compatible) and stateful //! These are basic implementations suitable for voice. For higher quality,
//! `Downsampler48to8` / `Upsampler8to48` structs that maintain overlap history //! replace with the `rubato` crate later.
//! between frames for glitch-free streaming.
use std::f64::consts::PI; /// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
// ─── FIR kernel parameters ─────────────────────────────────────────────────
/// 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).
/// ///
/// Computed via the well-known power-series expansion, converging rapidly /// Each output sample is the average of 6 consecutive input samples,
/// for the moderate values of x used in Kaiser window design. /// providing basic anti-aliasing via a box filter.
fn bessel_i0(x: f64) -> f64 { pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
let mut sum = 1.0f64; const RATIO: usize = 6;
let mut term = 1.0f64; let out_len = input.len() / RATIO;
let half_x = x / 2.0; let mut output = Vec::with_capacity(out_len);
for k in 1..=25 {
term *= (half_x / k as f64) * (half_x / k as f64); for chunk in input.chunks_exact(RATIO) {
sum += term; let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
if term < 1e-12 * sum { output.push((sum / RATIO as i32) as i16);
break;
}
} }
sum
output
} }
/// Build a windowed-sinc low-pass FIR kernel. /// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
/// ///
/// Returns `FIR_TAPS` coefficients normalised so that the DC gain is exactly 1.0. /// Linearly interpolates between each pair of input samples to produce
fn build_fir_kernel() -> [f64; FIR_TAPS] { /// 6 output samples per input sample.
let mut kernel = [0.0f64; FIR_TAPS]; pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
let m = (FIR_TAPS - 1) as f64; const RATIO: usize = 6;
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5) if input.is_empty() {
let beta_denom = bessel_i0(KAISER_BETA); return Vec::new();
}
for i in 0..FIR_TAPS { let out_len = input.len() * RATIO;
// Sinc let mut output = Vec::with_capacity(out_len);
let n = i as f64 - m / 2.0;
let sinc = if n.abs() < 1e-12 { for i in 0..input.len() {
2.0 * fc let current = input[i] as i32;
let next = if i + 1 < input.len() {
input[i + 1] as i32
} else { } else {
(2.0 * PI * fc * n).sin() / (PI * n) current // hold last sample
}; };
// Kaiser window for j in 0..RATIO {
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1] let interp = current + (next - current) * j as i32 / RATIO as i32;
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom; output.push(interp as i16);
kernel[i] = sinc * kaiser;
}
// Normalise to unity DC gain.
let sum: f64 = kernel.iter().sum();
if sum.abs() > 1e-15 {
for k in kernel.iter_mut() {
*k /= sum;
} }
} }
kernel output
} }
// ─── 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::*;
@@ -269,28 +66,12 @@ mod tests {
#[test] #[test]
fn dc_signal_preserved() { fn dc_signal_preserved() {
// A constant signal should survive resampling (approximately). // A constant signal should survive resampling
let input = vec![1000i16; 960]; let input = vec![1000i16; 960];
let down = resample_48k_to_8k(&input); let down = resample_48k_to_8k(&input);
// Allow some edge transient — check that the middle samples are close. assert!(down.iter().all(|&s| s == 1000));
let mid_start = down.len() / 4;
let mid_end = 3 * down.len() / 4;
for &s in &down[mid_start..mid_end] {
assert!(
(s - 1000).abs() < 50,
"DC downsampled sample {s} too far from 1000"
);
}
let up = resample_8k_to_48k(&down); let up = resample_8k_to_48k(&down);
let mid_start_up = up.len() / 4; assert!(up.iter().all(|&s| s == 1000));
let mid_end_up = 3 * up.len() / 4;
for &s in &up[mid_start_up..mid_end_up] {
assert!(
(s - 1000).abs() < 100,
"DC upsampled sample {s} too far from 1000"
);
}
} }
#[test] #[test]
@@ -298,40 +79,4 @@ mod tests {
assert!(resample_48k_to_8k(&[]).is_empty()); assert!(resample_48k_to_8k(&[]).is_empty());
assert!(resample_8k_to_48k(&[]).is_empty()); assert!(resample_8k_to_48k(&[]).is_empty());
} }
#[test]
fn stateful_downsampler_produces_correct_length() {
let mut ds = Downsampler48to8::new();
let out = ds.process(&vec![0i16; 960]);
assert_eq!(out.len(), 160);
let out2 = ds.process(&vec![0i16; 960]);
assert_eq!(out2.len(), 160);
}
#[test]
fn stateful_upsampler_produces_correct_length() {
let mut us = Upsampler8to48::new();
let out = us.process(&vec![0i16; 160]);
assert_eq!(out.len(), 960);
let out2 = us.process(&vec![0i16; 160]);
assert_eq!(out2.len(), 960);
}
#[test]
fn fir_kernel_has_unity_dc_gain() {
let kernel = build_fir_kernel();
let sum: f64 = kernel.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-10,
"FIR kernel DC gain should be 1.0, got {sum}"
);
}
#[test]
fn bessel_i0_known_values() {
// I₀(0) = 1
assert!((bessel_i0(0.0) - 1.0).abs() < 1e-12);
// I₀(1) ≈ 1.2660658
assert!((bessel_i0(1.0) - 1.2660658).abs() < 1e-5);
}
} }

View File

@@ -110,18 +110,7 @@ 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");
// Derive SAS (Short Authentication String) from shared secret only. Ok(Box::new(ChaChaSession::new(session_key)))
// 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))
} }
} }
@@ -222,47 +211,4 @@ 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"
);
}
} }

View File

@@ -26,8 +26,6 @@ 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 {
@@ -48,15 +46,9 @@ 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;
@@ -144,10 +136,6 @@ impl CryptoSession for ChaChaSession {
Ok(()) Ok(())
} }
fn sas_code(&self) -> Option<u32> {
self.sas_code
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,7 +1,6 @@
//! 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;
@@ -10,9 +9,6 @@ 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.
@@ -25,8 +21,6 @@ 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.
@@ -64,7 +58,6 @@ impl RaptorQFecDecoder {
symbol_size: self.symbol_size, symbol_size: self.symbol_size,
decoded: false, decoded: false,
result: None, result: None,
decoded_at: None,
}) })
} }
} }
@@ -81,20 +74,8 @@ 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 {
// If the block was decoded recently, skip (normal duplicate). // Already decoded, ignore additional symbols.
// If it's stale (>2s), a new sender is reusing this block_id — reset it. return Ok(());
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).
@@ -151,7 +132,6 @@ 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))
} }

View File

@@ -18,12 +18,6 @@ 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 {
@@ -33,9 +27,6 @@ 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,
@@ -45,7 +36,8 @@ 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 | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20, Self::Opus24k => 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,
@@ -56,8 +48,7 @@ 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 Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
| 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,
} }
@@ -72,9 +63,6 @@ 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,
} }
} }
@@ -83,12 +71,6 @@ 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.
@@ -129,30 +111,6 @@ 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;

View File

@@ -1,5 +1,4 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use crate::packet::MediaPacket; use crate::packet::MediaPacket;
@@ -21,29 +20,19 @@ pub struct AdaptivePlayoutDelay {
max_delay: usize, max_delay: usize,
/// Exponential moving average of inter-packet arrival jitter (ms). /// Exponential moving average of inter-packet arrival jitter (ms).
jitter_ema: f64, jitter_ema: f64,
/// EMA smoothing factor for jitter increases (fast reaction). /// EMA smoothing factor (0.0-1.0, lower = smoother).
alpha_up: f64, alpha: f64,
/// EMA smoothing factor for jitter decreases (slow decay).
alpha_down: f64,
/// Last packet arrival timestamp (for computing inter-arrival jitter). /// Last packet arrival timestamp (for computing inter-arrival jitter).
last_arrival_ms: Option<u64>, last_arrival_ms: Option<u64>,
/// Last packet expected timestamp. /// Last packet expected timestamp.
last_expected_ms: Option<u64>, last_expected_ms: Option<u64>,
/// Safety margin added to jitter-derived target (in packets).
safety_margin: f64,
/// Instant when a jitter spike was detected (handoff detection).
spike_detected_at: Option<Instant>,
/// Duration to hold max_delay after a spike is detected.
spike_cooldown: Duration,
/// Multiplier of jitter_ema that constitutes a spike.
spike_threshold_multiplier: f64,
} }
/// Frame duration in milliseconds (20ms Opus/Codec2 frames). /// Frame duration in milliseconds (20ms Opus/Codec2 frames).
const FRAME_DURATION_MS: f64 = 20.0; const FRAME_DURATION_MS: f64 = 20.0;
/// Default safety margin in packets. /// Safety margin added to jitter-derived target (in packets).
const DEFAULT_SAFETY_MARGIN: f64 = 2.0; const SAFETY_MARGIN_PACKETS: f64 = 2.0;
/// Default EMA smoothing factor (used for both up/down in non-mobile mode). /// Default EMA smoothing factor.
const DEFAULT_ALPHA: f64 = 0.05; const DEFAULT_ALPHA: f64 = 0.05;
impl AdaptivePlayoutDelay { impl AdaptivePlayoutDelay {
@@ -57,14 +46,9 @@ impl AdaptivePlayoutDelay {
min_delay, min_delay,
max_delay, max_delay,
jitter_ema: 0.0, jitter_ema: 0.0,
alpha_up: DEFAULT_ALPHA, alpha: DEFAULT_ALPHA,
alpha_down: DEFAULT_ALPHA,
last_arrival_ms: None, last_arrival_ms: None,
last_expected_ms: None, last_expected_ms: None,
safety_margin: DEFAULT_SAFETY_MARGIN,
spike_detected_at: None,
spike_cooldown: Duration::from_secs(2),
spike_threshold_multiplier: 3.0,
} }
} }
@@ -80,38 +64,13 @@ impl AdaptivePlayoutDelay {
let expected_delta = expected_ms as f64 - last_expected as f64; let expected_delta = expected_ms as f64 - last_expected as f64;
let jitter = (actual_delta - expected_delta).abs(); let jitter = (actual_delta - expected_delta).abs();
// Spike detection: check before EMA update // Update EMA
if self.jitter_ema > 0.0 self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
{
self.spike_detected_at = Some(Instant::now());
}
// Asymmetric EMA update // Convert jitter estimate to target delay in packets
let alpha = if jitter > self.jitter_ema { let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
self.alpha_up self.target_delay =
} else { (raw_target as usize).clamp(self.min_delay, self.max_delay);
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);
@@ -128,28 +87,6 @@ impl AdaptivePlayoutDelay {
pub fn jitter_estimate_ms(&self) -> f64 { pub fn jitter_estimate_ms(&self) -> f64 {
self.jitter_ema self.jitter_ema
} }
/// Enable or disable mobile mode, adjusting parameters for cellular networks.
///
/// Mobile mode uses:
/// - Asymmetric alpha (fast up=0.3, slow down=0.02) for quicker spike detection
/// - Higher safety margin (3.0 packets) to absorb handoff jitter
/// - Spike detection with 2-second cooldown at 3x threshold
pub fn set_mobile_mode(&mut self, enabled: bool) {
if enabled {
self.safety_margin = 3.0;
self.alpha_up = 0.3;
self.alpha_down = 0.02;
self.spike_threshold_multiplier = 3.0;
self.spike_cooldown = Duration::from_secs(2);
} else {
self.safety_margin = DEFAULT_SAFETY_MARGIN;
self.alpha_up = DEFAULT_ALPHA;
self.alpha_down = DEFAULT_ALPHA;
self.spike_threshold_multiplier = 3.0;
self.spike_cooldown = Duration::from_secs(2);
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -273,21 +210,10 @@ 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) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq); self.stats.packets_late += 1;
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected"); return;
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
@@ -423,21 +349,10 @@ 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) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq); self.stats.packets_late += 1;
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected"); return;
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
@@ -476,11 +391,6 @@ impl JitterBuffer {
self.adaptive.as_ref() self.adaptive.as_ref()
} }
/// Get a mutable reference to the adaptive playout delay estimator.
pub fn adaptive_delay_mut(&mut self) -> Option<&mut AdaptivePlayoutDelay> {
self.adaptive.as_mut()
}
/// Adjust target depth based on observed jitter. /// Adjust target depth based on observed jitter.
pub fn set_target_depth(&mut self, depth: usize) { pub fn set_target_depth(&mut self, depth: usize) {
self.target_depth = depth.min(self.max_depth); self.target_depth = depth.min(self.max_depth);
@@ -810,29 +720,4 @@ mod tests {
let ad = jb.adaptive_delay().unwrap(); let ad = jb.adaptive_delay().unwrap();
assert_eq!(ad.target_delay(), 3); assert_eq!(ad.target_delay(), 3);
} }
// ---------------------------------------------------------------
// Mobile mode tests
// ---------------------------------------------------------------
#[test]
fn mobile_mode_increases_safety_margin() {
let mut apd = AdaptivePlayoutDelay::new(3, 50);
apd.set_mobile_mode(true);
assert_eq!(apd.safety_margin, 3.0);
assert_eq!(apd.alpha_up, 0.3);
assert_eq!(apd.alpha_down, 0.02);
apd.set_mobile_mode(false);
assert_eq!(apd.safety_margin, DEFAULT_SAFETY_MARGIN);
assert_eq!(apd.alpha_up, DEFAULT_ALPHA);
assert_eq!(apd.alpha_down, DEFAULT_ALPHA);
}
#[test]
fn mobile_mode_accessible_via_jitter_buffer() {
let mut jb = JitterBuffer::new_adaptive(3, 50);
jb.adaptive_delay_mut().unwrap().set_mobile_mode(true);
assert_eq!(jb.adaptive_delay().unwrap().safety_margin, 3.0);
}
} }

View File

@@ -25,11 +25,10 @@ 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::{
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
FRAME_TYPE_MINI,
}; };
pub use bandwidth::{BandwidthEstimator, CongestionState}; pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use quality::{AdaptiveQualityController, Tier};
pub use session::{Session, SessionEvent, SessionState}; pub use session::{Session, SessionEvent, SessionState};
pub use traits::*; pub use traits::*;

View File

@@ -548,9 +548,6 @@ 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).
@@ -584,26 +581,6 @@ pub enum SignalMessage {
recommended_profile: crate::QualityProfile, recommended_profile: crate::QualityProfile,
}, },
/// Phase 4 telemetry: loss-recovery counts for the current session.
/// Sent periodically from receivers to the relay so Prometheus metrics
/// can distinguish DRED reconstructions from classical PLC invocations.
/// Fields default to 0 on old receivers (`#[serde(default)]`), so
/// introducing this variant is backward-compatible with pre-Phase-4
/// relays — they'll just log "unknown signal variant" on receipt.
LossRecoveryUpdate {
/// Total frames reconstructed via DRED since call start (monotonic).
#[serde(default)]
dred_reconstructions: u64,
/// Total frames filled via classical Opus/Codec2 PLC since call
/// start (monotonic).
#[serde(default)]
classical_plc_invocations: u64,
/// Total frames decoded since call start. Used by the relay to
/// compute recovery rates as a fraction of total frames.
#[serde(default)]
frames_decoded: u64,
},
/// Connection keepalive / RTT measurement. /// Connection keepalive / RTT measurement.
Ping { timestamp_ms: u64 }, Ping { timestamp_ms: u64 },
Pong { timestamp_ms: u64 }, Pong { timestamp_ms: u64 },
@@ -668,133 +645,6 @@ 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.

View File

@@ -1,5 +1,4 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::packet::QualityReport; use crate::packet::QualityReport;
use crate::traits::QualityController; use crate::traits::QualityController;
@@ -25,71 +24,24 @@ impl Tier {
} }
} }
/// Determine which tier a quality report belongs to (default/WiFi thresholds). /// Determine which tier a quality report belongs to.
pub fn classify(report: &QualityReport) -> Self { pub fn classify(report: &QualityReport) -> Self {
Self::classify_with_context(report, NetworkContext::Unknown)
}
/// Classify with network-context-aware thresholds.
pub fn classify_with_context(report: &QualityReport, context: NetworkContext) -> Self {
let loss = report.loss_percent(); let loss = report.loss_percent();
let rtt = report.rtt_ms(); let rtt = report.rtt_ms();
match context { if loss > 40.0 || rtt > 600 {
NetworkContext::CellularLte Self::Catastrophic
| NetworkContext::Cellular5g } else if loss > 10.0 || rtt > 400 {
| NetworkContext::Cellular3g => { Self::Degraded
// Tighter thresholds for cellular networks } else {
if loss > 25.0 || rtt > 500 { Self::Good
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 (2 on cellular) /// - Downgrade: 3 consecutive reports in a worse tier
/// - Upgrade: 10 consecutive reports in a better tier /// - Upgrade: 10 consecutive reports in a better tier
pub struct AdaptiveQualityController { pub struct AdaptiveQualityController {
current_tier: Tier, current_tier: Tier,
@@ -102,26 +54,14 @@ pub struct AdaptiveQualityController {
history: VecDeque<QualityReport>, history: VecDeque<QualityReport>,
/// Whether the profile was manually forced (disables adaptive logic). /// Whether the profile was manually forced (disables adaptive logic).
forced: bool, forced: bool,
/// Current network context for threshold selection.
network_context: NetworkContext,
/// FEC boost expiry time (set during network handoff).
fec_boost_until: Option<Instant>,
/// FEC boost amount to add during handoff recovery window.
fec_boost_amount: f32,
} }
/// Threshold for downgrading (fast reaction to degradation). /// Threshold for downgrading (fast reaction to degradation).
const DOWNGRADE_THRESHOLD: u32 = 3; const DOWNGRADE_THRESHOLD: u32 = 3;
/// Threshold for downgrading on cellular networks (even faster).
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
/// Threshold for upgrading (slow, cautious improvement). /// Threshold for upgrading (slow, cautious improvement).
const UPGRADE_THRESHOLD: u32 = 10; const UPGRADE_THRESHOLD: u32 = 10;
/// Maximum history window size. /// Maximum history window size.
const HISTORY_SIZE: usize = 20; const HISTORY_SIZE: usize = 20;
/// Default FEC boost amount during handoff recovery.
const DEFAULT_FEC_BOOST: f32 = 0.2;
/// Duration of FEC boost after a network handoff.
const FEC_BOOST_DURATION_SECS: u64 = 10;
impl AdaptiveQualityController { impl AdaptiveQualityController {
pub fn new() -> Self { pub fn new() -> Self {
@@ -132,9 +72,6 @@ impl AdaptiveQualityController {
consecutive_down: 0, consecutive_down: 0,
history: VecDeque::with_capacity(HISTORY_SIZE), history: VecDeque::with_capacity(HISTORY_SIZE),
forced: false, forced: false,
network_context: NetworkContext::default(),
fec_boost_until: None,
fec_boost_amount: DEFAULT_FEC_BOOST,
} }
} }
@@ -143,69 +80,6 @@ impl AdaptiveQualityController {
self.current_tier self.current_tier
} }
/// Get the current network context.
pub fn network_context(&self) -> NetworkContext {
self.network_context
}
/// Signal a network transport change (e.g., WiFi to cellular handoff).
///
/// When switching from WiFi to any cellular type, this preemptively
/// downgrades one quality tier and activates a temporary FEC boost.
pub fn signal_network_change(&mut self, new_context: NetworkContext) {
let old = self.network_context;
self.network_context = new_context;
let new_is_cellular = matches!(
new_context,
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
);
// If switching from WiFi to cellular, preemptively downgrade one tier
if old == NetworkContext::WiFi && new_is_cellular {
if let Some(lower_tier) = self.current_tier.downgrade() {
self.current_tier = lower_tier;
self.current_profile = lower_tier.profile();
}
// Reset counters to avoid stale hysteresis state
self.consecutive_up = 0;
self.consecutive_down = 0;
// Un-force so adaptive logic resumes
self.forced = false;
}
// Activate FEC boost for any network change
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
}
/// Returns the FEC boost amount if within the handoff recovery window, 0.0 otherwise.
///
/// Callers should add this to their base FEC ratio during the boost window.
pub fn fec_boost(&self) -> f32 {
if let Some(until) = self.fec_boost_until {
if Instant::now() < until {
return self.fec_boost_amount;
}
}
0.0
}
/// Reset the hysteresis counters.
pub fn reset_counters(&mut self) {
self.consecutive_up = 0;
self.consecutive_down = 0;
}
/// Get the effective downgrade threshold based on network context.
fn downgrade_threshold(&self) -> u32 {
match self.network_context {
NetworkContext::CellularLte
| NetworkContext::Cellular5g
| NetworkContext::Cellular3g => CELLULAR_DOWNGRADE_THRESHOLD,
_ => DOWNGRADE_THRESHOLD,
}
}
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> { fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
if observed_tier == self.current_tier { if observed_tier == self.current_tier {
self.consecutive_up = 0; self.consecutive_up = 0;
@@ -222,7 +96,7 @@ impl AdaptiveQualityController {
if is_worse { if is_worse {
self.consecutive_up = 0; self.consecutive_up = 0;
self.consecutive_down += 1; self.consecutive_down += 1;
if self.consecutive_down >= self.downgrade_threshold() { if self.consecutive_down >= DOWNGRADE_THRESHOLD {
self.current_tier = observed_tier; self.current_tier = observed_tier;
self.current_profile = observed_tier.profile(); self.current_profile = observed_tier.profile();
self.consecutive_down = 0; self.consecutive_down = 0;
@@ -268,7 +142,7 @@ impl QualityController for AdaptiveQualityController {
return None; return None;
} }
let observed = Tier::classify_with_context(report, self.network_context); let observed = Tier::classify(report);
self.try_transition(observed) self.try_transition(observed)
} }
@@ -372,110 +246,4 @@ mod tests {
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic); assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic); assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
} }
// ---------------------------------------------------------------
// Network context tests
// ---------------------------------------------------------------
#[test]
fn cellular_tighter_thresholds() {
// 12% loss: Good on WiFi, Degraded on cellular
let report = make_report(12.0, 200);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::WiFi),
Tier::Degraded
);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::CellularLte),
Tier::Degraded
);
// 9% loss: Good on WiFi, Degraded on cellular
let report = make_report(9.0, 200);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::WiFi),
Tier::Good
);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::CellularLte),
Tier::Degraded
);
// 30% loss: Degraded on WiFi, Catastrophic on cellular
let report = make_report(30.0, 200);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::WiFi),
Tier::Degraded
);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
Tier::Catastrophic
);
}
#[test]
fn cellular_rtt_thresholds() {
// RTT 350ms: Good on WiFi, Degraded on cellular
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::WiFi),
Tier::Good
);
assert_eq!(
Tier::classify_with_context(&report, NetworkContext::CellularLte),
Tier::Degraded
);
}
#[test]
fn cellular_faster_downgrade() {
let mut ctrl = AdaptiveQualityController::new();
ctrl.signal_network_change(NetworkContext::CellularLte);
// Reset tier back to Good for testing downgrade threshold
ctrl.current_tier = Tier::Good;
ctrl.current_profile = Tier::Good.profile();
// On cellular, downgrade threshold is 2 instead of 3
let bad = make_report(50.0, 200);
assert!(ctrl.observe(&bad).is_none()); // 1st bad
let result = ctrl.observe(&bad); // 2nd bad — should trigger on cellular
assert!(result.is_some());
}
#[test]
fn signal_network_change_preemptive_downgrade() {
let mut ctrl = AdaptiveQualityController::new();
assert_eq!(ctrl.tier(), Tier::Good);
// Switch from WiFi to cellular
ctrl.network_context = NetworkContext::WiFi;
ctrl.signal_network_change(NetworkContext::CellularLte);
// Should have downgraded one tier: Good -> Degraded
assert_eq!(ctrl.tier(), Tier::Degraded);
}
#[test]
fn signal_network_change_fec_boost() {
let mut ctrl = AdaptiveQualityController::new();
assert_eq!(ctrl.fec_boost(), 0.0);
ctrl.signal_network_change(NetworkContext::CellularLte);
// FEC boost should be active
assert!(ctrl.fec_boost() > 0.0);
assert_eq!(ctrl.fec_boost(), DEFAULT_FEC_BOOST);
}
#[test]
fn tier_downgrade() {
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
assert_eq!(Tier::Catastrophic.downgrade(), None);
}
#[test]
fn network_context_default() {
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
}
} }

View File

@@ -132,14 +132,6 @@ 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.

View File

@@ -28,9 +28,6 @@ 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"

View File

@@ -1,18 +0,0 @@
use std::process::Command;
fn main() {
// Get git hash at build time
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output();
let hash = match output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => "unknown".to_string(),
};
println!("cargo:rustc-env=WZP_BUILD_HASH={hash}");
println!("cargo:rerun-if-changed=.git/HEAD");
}

View File

@@ -1,199 +0,0 @@
//! Direct call state tracking.
//!
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
//! Each call goes through: Pending → Ringing → Active → Ended.
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// State of a direct call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectCallState {
/// Offer sent to callee, waiting for response.
Pending,
/// Callee acknowledged, ringing.
Ringing,
/// Call accepted, media room active.
Active,
/// Call ended (hangup, reject, timeout, or error).
Ended,
}
/// A tracked direct call between two users.
pub struct DirectCall {
pub call_id: String,
pub caller_fingerprint: String,
pub callee_fingerprint: String,
pub state: DirectCallState,
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
/// Private room name (set when accepted).
pub room_name: Option<String>,
pub created_at: Instant,
pub answered_at: Option<Instant>,
pub ended_at: Option<Instant>,
}
/// Registry of active direct calls.
pub struct CallRegistry {
calls: HashMap<String, DirectCall>,
}
impl CallRegistry {
pub fn new() -> Self {
Self {
calls: HashMap::new(),
}
}
/// Create a new pending call. Returns the call_id.
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
let call = DirectCall {
call_id: call_id.clone(),
caller_fingerprint: caller_fp,
callee_fingerprint: callee_fp,
state: DirectCallState::Pending,
accept_mode: None,
room_name: None,
created_at: Instant::now(),
answered_at: None,
ended_at: None,
};
self.calls.insert(call_id.clone(), call);
self.calls.get(&call_id).unwrap()
}
/// Get a call by ID.
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
self.calls.get(call_id)
}
/// Get a mutable call by ID.
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
self.calls.get_mut(call_id)
}
/// Transition to Ringing state.
pub fn set_ringing(&mut self, call_id: &str) -> bool {
if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending {
call.state = DirectCallState::Ringing;
return true;
}
}
false
}
/// Transition to Active state.
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
call.state = DirectCallState::Active;
call.accept_mode = Some(mode);
call.room_name = Some(room);
call.answered_at = Some(Instant::now());
return true;
}
}
false
}
/// End a call.
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
if let Some(call) = self.calls.get_mut(call_id) {
call.state = DirectCallState::Ended;
call.ended_at = Some(Instant::now());
}
self.calls.remove(call_id)
}
/// Find active/pending calls involving a fingerprint.
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
self.calls.values()
.filter(|c| {
c.state != DirectCallState::Ended
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
})
.collect()
}
/// Find the peer's fingerprint in a call.
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
self.calls.get(call_id).map(|c| {
if c.caller_fingerprint == my_fp {
c.callee_fingerprint.as_str()
} else {
c.caller_fingerprint.as_str()
}
})
}
/// Remove calls that have been pending longer than the timeout.
/// Returns call IDs of expired calls.
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
let now = Instant::now();
let expired: Vec<String> = self.calls.iter()
.filter(|(_, c)| {
c.state == DirectCallState::Pending
&& now.duration_since(c.created_at) > timeout
})
.map(|(id, _)| id.clone())
.collect();
expired.into_iter()
.filter_map(|id| self.calls.remove(&id))
.collect()
}
/// Number of active (non-ended) calls.
pub fn active_count(&self) -> usize {
self.calls.values()
.filter(|c| c.state != DirectCallState::Ended)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_lifecycle() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
assert!(reg.set_ringing("c1"));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
let ended = reg.end_call("c1").unwrap();
assert_eq!(ended.state, DirectCallState::Ended);
assert_eq!(reg.active_count(), 0);
}
#[test]
fn expire_stale_calls() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
// Not expired yet
let expired = reg.expire_stale(Duration::from_secs(30));
assert!(expired.is_empty());
// Force expiry with 0 timeout
let expired = reg.expire_stale(Duration::from_secs(0));
assert_eq!(expired.len(), 1);
assert_eq!(expired[0].call_id, "c1");
}
#[test]
fn peer_lookup() {
let mut reg = CallRegistry::new();
reg.create_call("c1".into(), "alice".into(), "bob".into());
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
}
}

View File

@@ -3,41 +3,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::SocketAddr; use std::net::SocketAddr;
/// A federated peer relay.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeerConfig {
/// Address of the peer relay (e.g., "193.180.213.68:4433").
pub url: String,
/// Expected TLS certificate fingerprint (hex, with colons).
pub fingerprint: String,
/// Optional human-readable label.
#[serde(default)]
pub label: Option<String>,
}
/// A trusted relay — accepts inbound federation without needing the peer's address.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrustedConfig {
/// Expected TLS certificate fingerprint (hex, with colons).
pub fingerprint: String,
/// Optional human-readable label.
#[serde(default)]
pub label: Option<String>,
}
/// A room declared global — bridged across all federated peers.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GlobalRoomConfig {
/// Room name to bridge (e.g., "android").
pub name: String,
}
/// Configuration for the relay daemon. /// 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)] #[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,
@@ -77,22 +44,6 @@ 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 {
@@ -111,100 +62,6 @@ 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 = "*"
"#
)
}

View File

@@ -1,201 +0,0 @@
//! JSONL event log for protocol analysis.
//!
//! When `--event-log <path>` is set, every media packet emits a structured
//! event at each decision point (recv, forward, drop, deliver).
//! Use `wzp-analyzer` to correlate events across multiple relays.
use std::path::PathBuf;
use std::sync::Arc;
use serde::Serialize;
use tokio::sync::mpsc;
use tracing::{error, info};
/// A single protocol event for JSONL output.
#[derive(Debug, Serialize)]
pub struct Event {
/// ISO 8601 timestamp with microseconds.
pub ts: String,
/// Event type.
pub event: &'static str,
/// Room name.
#[serde(skip_serializing_if = "Option::is_none")]
pub room: Option<String>,
/// Source address or peer label.
#[serde(skip_serializing_if = "Option::is_none")]
pub src: Option<String>,
/// Packet sequence number.
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<u16>,
/// Codec identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
/// FEC block ID.
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_block: Option<u8>,
/// FEC symbol index.
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_sym: Option<u8>,
/// Is FEC repair packet.
#[serde(skip_serializing_if = "Option::is_none")]
pub repair: Option<bool>,
/// Payload length in bytes.
#[serde(skip_serializing_if = "Option::is_none")]
pub len: Option<usize>,
/// Number of recipients.
#[serde(skip_serializing_if = "Option::is_none")]
pub to_count: Option<usize>,
/// Peer label (for federation events).
#[serde(skip_serializing_if = "Option::is_none")]
pub peer: Option<String>,
/// Drop/error reason.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Presence action (active/inactive).
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
/// Participant count (presence events).
#[serde(skip_serializing_if = "Option::is_none")]
pub participants: Option<usize>,
}
impl Event {
fn now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
}
/// Create a minimal event with just type and timestamp.
pub fn new(event: &'static str) -> Self {
Self {
ts: Self::now(),
event,
room: None,
src: None,
seq: None,
codec: None,
fec_block: None,
fec_sym: None,
repair: None,
len: None,
to_count: None,
peer: None,
reason: None,
action: None,
participants: None,
}
}
/// Set room.
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
/// Set source.
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
/// Set packet header fields from a MediaPacket.
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
self.seq = Some(pkt.header.seq);
self.codec = Some(format!("{:?}", pkt.header.codec_id));
self.fec_block = Some(pkt.header.fec_block);
self.fec_sym = Some(pkt.header.fec_symbol);
self.repair = Some(pkt.header.is_repair);
self.len = Some(pkt.payload.len());
self
}
/// Set seq only (when full packet not available).
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
/// Set payload length.
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
/// Set recipient count.
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
/// Set peer label.
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
/// Set drop reason.
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
/// Set presence action.
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
/// Set participant count.
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
}
/// Handle for emitting events. Cheap to clone.
#[derive(Clone)]
pub struct EventLog {
tx: mpsc::UnboundedSender<Event>,
}
impl EventLog {
/// Emit an event (non-blocking, drops if channel is full).
pub fn emit(&self, event: Event) {
let _ = self.tx.send(event);
}
}
/// No-op event log for when `--event-log` is not set.
/// All methods are no-ops that compile to nothing.
#[derive(Clone)]
pub struct NoopEventLog;
/// Unified event log handle — either real or no-op.
#[derive(Clone)]
pub enum EventLogger {
Active(EventLog),
Noop,
}
impl EventLogger {
pub fn emit(&self, event: Event) {
if let EventLogger::Active(log) = self {
log.emit(event);
}
}
pub fn is_active(&self) -> bool {
matches!(self, EventLogger::Active(_))
}
}
/// Start the event log writer. Returns an `EventLogger` handle.
pub fn start_event_log(path: Option<PathBuf>) -> EventLogger {
match path {
Some(path) => {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(writer_task(path, rx));
info!("event log enabled");
EventLogger::Active(EventLog { tx })
}
None => EventLogger::Noop,
}
}
/// Background task that writes events to a JSONL file.
async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
use tokio::io::AsyncWriteExt;
let file = match tokio::fs::File::create(&path).await {
Ok(f) => f,
Err(e) => {
error!("failed to create event log {}: {e}", path.display());
return;
}
};
let mut writer = tokio::io::BufWriter::new(file);
let mut count: u64 = 0;
while let Some(event) = rx.recv().await {
match serde_json::to_string(&event) {
Ok(json) => {
if writer.write_all(json.as_bytes()).await.is_err() { break; }
if writer.write_all(b"\n").await.is_err() { break; }
count += 1;
// Flush every 100 events
if count % 100 == 0 {
let _ = writer.flush().await;
}
}
Err(e) => {
error!("event log serialize error: {e}");
}
}
}
let _ = writer.flush().await;
info!(events = count, "event log closed");
}

View File

@@ -1,966 +0,0 @@
//! Relay federation — global room routing between peer relays.
//!
//! Each relay maintains a forwarding table per global room. When a local participant
//! sends media in a global room, it's forwarded to all peer relays that have the room
//! active. Incoming federated media is delivered to local participants and optionally
//! forwarded to other active peers (multi-hop).
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use sha2::{Sha256, Digest};
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::QuinnTransport;
use crate::config::{PeerConfig, TrustedConfig};
use crate::event_log::{Event, EventLogger};
use crate::room::{self, FederationMediaOut, RoomEvent, RoomManager};
/// Compute 8-byte room hash for federation datagram tagging.
pub fn room_hash(room_name: &str) -> [u8; 8] {
let h = Sha256::digest(room_name.as_bytes());
let mut out = [0u8; 8];
out.copy_from_slice(&h[..8]);
out
}
/// Normalize a fingerprint string (remove colons, lowercase).
fn normalize_fp(fp: &str) -> String {
fp.replace(':', "").to_lowercase()
}
/// Time-based dedup filter for federation datagrams.
/// Tracks recently seen packets and expires entries older than 2 seconds.
/// This prevents duplicate delivery when the same packet arrives via
/// multiple federation paths, while allowing new senders that happen to
/// reuse the same seq numbers.
struct Deduplicator {
/// Recently seen packet keys with insertion time.
entries: HashMap<u64, Instant>,
/// Expiry duration.
ttl: Duration,
}
impl Deduplicator {
fn new(_capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(512),
ttl: Duration::from_secs(2),
}
}
/// Returns true if this packet is a duplicate (already seen within TTL).
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
let now = Instant::now();
// Periodic cleanup (every ~256 packets)
if self.entries.len() > 256 {
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
}
if let Some(ts) = self.entries.get(&key) {
if now.duration_since(*ts) < self.ttl {
return true; // seen recently — duplicate
}
}
self.entries.insert(key, now);
false
}
}
/// Per-room token bucket rate limiter for federation forwarding.
struct RateLimiter {
/// Max packets per second per room.
max_pps: u32,
/// Tokens remaining in current window.
tokens: u32,
/// When the current window started.
window_start: Instant,
}
impl RateLimiter {
fn new(max_pps: u32) -> Self {
Self {
max_pps,
tokens: max_pps,
window_start: Instant::now(),
}
}
/// Returns true if the packet should be allowed through.
fn allow(&mut self) -> bool {
let elapsed = self.window_start.elapsed();
if elapsed >= Duration::from_secs(1) {
self.tokens = self.max_pps;
self.window_start = Instant::now();
}
if self.tokens > 0 {
self.tokens -= 1;
true
} else {
false
}
}
}
/// Active link to a peer relay.
struct PeerLink {
transport: Arc<QuinnTransport>,
label: String,
/// Global rooms that this peer has reported as active.
active_rooms: HashSet<String>,
/// Remote participants per room (for federated presence in RoomUpdate).
remote_participants: HashMap<String, Vec<wzp_proto::packet::RoomParticipant>>,
/// Last time we received any data (signal or media) from this peer.
last_seen: Instant,
}
/// Max federation packets per second per room (0 = unlimited).
const FEDERATION_RATE_LIMIT_PPS: u32 = 500;
/// Dedup window size (number of recent packets to remember).
const DEDUP_WINDOW_SIZE: usize = 4096;
/// Remote participants are considered stale after this duration with no updates.
const REMOTE_PARTICIPANT_STALE_SECS: u64 = 15;
/// Manages federation connections and global room forwarding.
pub struct FederationManager {
peers: Vec<PeerConfig>,
trusted: Vec<TrustedConfig>,
global_rooms: HashSet<String>,
room_mgr: Arc<Mutex<RoomManager>>,
endpoint: quinn::Endpoint,
local_tls_fp: String,
metrics: Arc<crate::metrics::RelayMetrics>,
/// Active peer connections, keyed by normalized fingerprint.
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
/// Dedup filter for incoming federation datagrams.
dedup: Mutex<Deduplicator>,
/// Per-room seq counter for federation media delivered to local clients.
/// Ensures clients see monotonically increasing seq regardless of federation sender.
local_delivery_seq: std::sync::atomic::AtomicU16,
/// JSONL event log for protocol analysis.
event_log: EventLogger,
/// Per-room rate limiters for inbound federation media.
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
}
impl FederationManager {
pub fn new(
peers: Vec<PeerConfig>,
trusted: Vec<TrustedConfig>,
global_rooms: HashSet<String>,
room_mgr: Arc<Mutex<RoomManager>>,
endpoint: quinn::Endpoint,
local_tls_fp: String,
metrics: Arc<crate::metrics::RelayMetrics>,
event_log: EventLogger,
) -> Self {
Self {
peers,
trusted,
global_rooms,
room_mgr,
endpoint,
local_tls_fp,
metrics,
peer_links: Arc::new(Mutex::new(HashMap::new())),
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
event_log,
rate_limiters: Mutex::new(HashMap::new()),
}
}
/// Check if a room name (which may be hashed) is a global room.
pub fn is_global_room(&self, room: &str) -> bool {
self.resolve_global_room(room).is_some()
}
/// Resolve a room name (raw or hashed) to the canonical global room name.
/// Returns the configured global room name if it matches.
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
// Direct match (raw room name, e.g. Android clients)
if self.global_rooms.contains(room) {
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
}
// Hashed match (desktop clients hash room names for SNI privacy)
self.global_rooms.iter().find(|name| {
wzp_crypto::hash_room_name(name) == room
}).map(|s| s.as_str())
}
/// Get the canonical federation room hash for a room.
/// Always uses the configured global room name, not the client-provided name.
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
if let Some(canonical) = self.resolve_global_room(room) {
room_hash(canonical)
} else {
room_hash(room)
}
}
/// Start federation — spawns connection loops + event dispatcher.
pub async fn run(self: Arc<Self>) {
if self.peers.is_empty() && self.global_rooms.is_empty() {
return;
}
info!(
peers = self.peers.len(),
global_rooms = self.global_rooms.len(),
"federation starting"
);
let mut handles = Vec::new();
// Per-peer outbound connection loops
for peer in &self.peers {
let this = self.clone();
let peer = peer.clone();
handles.push(tokio::spawn(async move {
run_peer_loop(this, peer).await;
}));
}
// Room event dispatcher
let room_events = {
let mgr = self.room_mgr.lock().await;
mgr.subscribe_events()
};
let this = self.clone();
handles.push(tokio::spawn(async move {
run_room_event_dispatcher(this, room_events).await;
}));
// Stale presence sweeper — purges remote participants from dead peers
let this = self.clone();
handles.push(tokio::spawn(async move {
run_stale_presence_sweeper(this).await;
}));
for h in handles {
let _ = h.await;
}
}
/// Handle an inbound federation connection from a recognized peer.
pub async fn handle_inbound(
self: &Arc<Self>,
transport: Arc<QuinnTransport>,
peer_config: PeerConfig,
) {
let peer_fp = normalize_fp(&peer_config.fingerprint);
let label = peer_config.label.unwrap_or_else(|| peer_config.url.clone());
info!(peer = %label, "inbound federation link active");
if let Err(e) = run_federation_link(self.clone(), transport, peer_fp, label.clone()).await {
warn!(peer = %label, "inbound federation link ended: {e}");
}
}
/// Get all remote participants for a room from all peer links.
/// Deduplicates by fingerprint (same participant may appear via multiple links).
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
let canonical = self.resolve_global_room(room);
let links = self.peer_links.lock().await;
let mut result = Vec::new();
for link in links.values() {
// Check canonical name
if let Some(c) = canonical {
if let Some(remote) = link.remote_participants.get(c) {
result.extend(remote.iter().cloned());
}
// Also check raw room name, but only if different from canonical
if c != room {
if let Some(remote) = link.remote_participants.get(room) {
result.extend(remote.iter().cloned());
}
}
} else {
if let Some(remote) = link.remote_participants.get(room) {
result.extend(remote.iter().cloned());
}
}
}
// Deduplicate by fingerprint
let mut seen = HashSet::new();
result.retain(|p| seen.insert(p.fingerprint.clone()));
result
}
/// Forward locally-generated media to all connected peers.
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
let links = self.peer_links.lock().await;
if links.is_empty() {
return;
}
for (_fp, link) in links.iter() {
let mut tagged = Vec::with_capacity(8 + media_data.len());
tagged.extend_from_slice(room_hash);
tagged.extend_from_slice(media_data);
match link.transport.send_raw_datagram(&tagged) {
Ok(()) => {
self.metrics.federation_packets_forwarded
.with_label_values(&[&link.label, "out"]).inc();
}
Err(e) => warn!(peer = %link.label, "federation send error: {e}"),
}
}
}
// ── Trust verification (kept from previous implementation) ──
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
}
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
let addr_ip = addr.ip();
self.peers.iter().find(|p| {
p.url.parse::<SocketAddr>()
.map(|sa| sa.ip() == addr_ip)
.unwrap_or(false)
})
}
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
}
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
if let Some(peer) = self.find_peer_by_addr(addr) {
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
}
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
}
None
}
}
// ── Outbound media egress task ──
/// Drains the federation media channel and forwards to active peers.
pub async fn run_federation_media_egress(
fm: Arc<FederationManager>,
mut rx: tokio::sync::mpsc::Receiver<FederationMediaOut>,
) {
let mut count: u64 = 0;
while let Some(out) = rx.recv().await {
count += 1;
if count == 1 || count % 250 == 0 {
info!(room = %out.room_name, count, "federation egress: forwarding media");
}
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
}
info!(total = count, "federation egress task ended");
}
// ── Room event dispatcher ──
/// Watches RoomManager events and sends GlobalRoomActive/Inactive to peers.
async fn run_room_event_dispatcher(
fm: Arc<FederationManager>,
mut events: tokio::sync::broadcast::Receiver<RoomEvent>,
) {
loop {
match events.recv().await {
Ok(RoomEvent::LocalJoin { room }) => {
if fm.is_global_room(&room) {
let participants = {
let mgr = fm.room_mgr.lock().await;
mgr.local_participant_list(&room)
};
info!(room = %room, count = participants.len(), "global room now active, announcing to peers");
let msg = SignalMessage::GlobalRoomActive { room, participants };
let links = fm.peer_links.lock().await;
for link in links.values() {
let _ = link.transport.send_signal(&msg).await;
}
}
}
Ok(RoomEvent::LocalLeave { room }) => {
if fm.is_global_room(&room) {
info!(room = %room, "global room now inactive, announcing to peers");
let msg = SignalMessage::GlobalRoomInactive { room };
let links = fm.peer_links.lock().await;
for link in links.values() {
let _ = link.transport.send_signal(&msg).await;
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(missed = n, "room event receiver lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
// ── Stale presence sweeper ──
/// Periodically checks for stale remote participants and purges them.
/// This handles the case where a peer link dies without sending GlobalRoomInactive
/// (e.g., QUIC timeout, network partition, crash).
async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
let stale_threshold = Duration::from_secs(REMOTE_PARTICIPANT_STALE_SECS);
// Find peers with stale remote_participants whose link is also gone or idle
let stale_rooms: Vec<(String, String)> = {
let links = fm.peer_links.lock().await;
let mut stale = Vec::new();
for (fp, link) in links.iter() {
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
for room in link.remote_participants.keys() {
stale.push((fp.clone(), room.clone()));
}
}
}
stale
};
if stale_rooms.is_empty() {
continue;
}
// Purge stale entries and collect affected rooms
let mut affected_rooms = HashSet::new();
{
let mut links = fm.peer_links.lock().await;
for (fp, room) in &stale_rooms {
if let Some(link) = links.get_mut(fp.as_str()) {
if link.last_seen.elapsed() > stale_threshold {
info!(peer = %link.label, room = %room, "purging stale remote participants (no data for {}s)", link.last_seen.elapsed().as_secs());
link.remote_participants.remove(room);
link.active_rooms.remove(room);
affected_rooms.insert(room.clone());
}
}
}
}
// Broadcast updated RoomUpdate for affected rooms
for room in &affected_rooms {
let mgr = fm.room_mgr.lock().await;
for local_room in mgr.active_rooms() {
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(room) {
let mut all_participants = mgr.local_participant_list(&local_room);
let remote = fm.get_remote_participants(&local_room).await;
all_participants.extend(remote);
let mut seen = HashSet::new();
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
let update = SignalMessage::RoomUpdate {
count: all_participants.len() as u32,
participants: all_participants,
};
let senders = mgr.local_senders(&local_room);
drop(mgr);
room::broadcast_signal(&senders, &update).await;
info!(room = %room, "swept stale presence — broadcast updated RoomUpdate");
break;
}
}
}
}
}
// ── Peer connection management ──
/// Persistent connection loop for one peer — reconnects with backoff.
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
let mut backoff = Duration::from_secs(5);
loop {
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
match connect_to_peer(&fm, &peer).await {
Ok(transport) => {
backoff = Duration::from_secs(5);
let peer_fp = normalize_fp(&peer.fingerprint);
let label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
if let Err(e) = run_federation_link(fm.clone(), transport, peer_fp, label).await {
warn!(peer_url = %peer.url, "federation link ended: {e}");
}
}
Err(e) => {
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(300));
}
}
/// Connect to a peer relay and send hello.
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
let addr: SocketAddr = peer.url.parse()?;
let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
let transport = Arc::new(QuinnTransport::new(conn));
// Send hello with our TLS fingerprint
let hello = SignalMessage::FederationHello {
tls_fingerprint: fm.local_tls_fp.clone(),
};
transport.send_signal(&hello).await
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
Ok(transport)
}
// ── Federation link (runs on a single QUIC connection) ──
/// Run the federation link: exchange global room state and forward media.
async fn run_federation_link(
fm: Arc<FederationManager>,
transport: Arc<QuinnTransport>,
peer_fp: String,
peer_label: String,
) -> Result<(), anyhow::Error> {
// Register peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
{
let mut links = fm.peer_links.lock().await;
links.insert(peer_fp.clone(), PeerLink {
transport: transport.clone(),
label: peer_label.clone(),
active_rooms: HashSet::new(),
remote_participants: HashMap::new(),
last_seen: Instant::now(),
});
}
// Announce our currently active global rooms to this new peer
// Collect all announcements first, then send (avoid holding locks across await)
let announcements = {
let mgr = fm.room_mgr.lock().await;
let active = mgr.active_rooms();
let mut msgs = Vec::new();
// Local rooms
for room_name in &active {
if fm.is_global_room(room_name) {
let participants = mgr.local_participant_list(room_name);
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
}
}
// Remote rooms from OTHER peers (for multi-hop propagation)
let links = fm.peer_links.lock().await;
for (fp, link) in links.iter() {
if fp != &peer_fp {
for (room, participants) in &link.remote_participants {
if fm.is_global_room(room) {
info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer");
msgs.push(SignalMessage::GlobalRoomActive {
room: room.clone(),
participants: participants.clone(),
});
}
}
}
}
msgs
};
for msg in &announcements {
let _ = transport.send_signal(msg).await;
}
// Three concurrent tasks: signal recv + media recv + RTT monitor
let signal_transport = transport.clone();
let media_transport = transport.clone();
let rtt_transport = transport.clone();
let fm_signal = fm.clone();
let fm_media = fm.clone();
let fm_rtt = fm.clone();
let peer_fp_signal = peer_fp.clone();
let peer_fp_media = peer_fp.clone();
let label_signal = peer_label.clone();
let label_rtt = peer_label.clone();
let signal_task = async move {
loop {
match signal_transport.recv_signal().await {
Ok(Some(msg)) => {
handle_signal(&fm_signal, &peer_fp_signal, &label_signal, msg).await;
}
Ok(None) => break,
Err(e) => {
error!(peer = %label_signal, "federation signal error: {e}");
break;
}
}
}
};
let peer_label_media = peer_label.clone();
let media_task = async move {
let mut media_count: u64 = 0;
loop {
match media_transport.connection().read_datagram().await {
Ok(data) => {
media_count += 1;
if media_count == 1 || media_count % 250 == 0 {
info!(peer = %peer_label_media, media_count, len = data.len(), "federation: received datagram");
}
handle_datagram(&fm_media, &peer_fp_media, data).await;
}
Err(e) => {
info!(peer = %peer_label_media, "federation media task ended: {e}");
break;
}
}
}
};
// RTT monitor: periodically sample QUIC RTT for this peer
let rtt_task = async move {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
}
};
tokio::select! {
_ = signal_task => {}
_ = media_task => {}
_ = rtt_task => {}
}
// Cleanup: remove peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
{
let mut links = fm.peer_links.lock().await;
links.remove(&peer_fp);
}
info!(peer = %peer_label, "federation link ended");
Ok(())
}
/// Handle an incoming federation signal.
async fn handle_signal(
fm: &Arc<FederationManager>,
peer_fp: &str,
peer_label: &str,
msg: SignalMessage,
) {
// Update last_seen for this peer
{
let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(peer_fp) {
link.last_seen = Instant::now();
}
}
match msg {
SignalMessage::GlobalRoomActive { room, participants } => {
if fm.is_global_room(&room) {
info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active");
let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(peer_fp) {
link.active_rooms.insert(room.clone());
}
// Update active rooms metric
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
fm.metrics.federation_active_rooms.set(total as i64);
if let Some(link) = links.get_mut(peer_fp) {
// Tag remote participants with their relay label
let tagged: Vec<_> = participants.iter().map(|p| {
let mut tagged = p.clone();
if tagged.relay_label.is_none() {
tagged.relay_label = Some(link.label.clone());
}
tagged
}).collect();
link.remote_participants.insert(room.clone(), tagged);
}
// Propagate to other peers (with relay labels preserved)
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
let label = link.label.clone();
participants.iter().map(|p| {
let mut t = p.clone();
if t.relay_label.is_none() {
t.relay_label = Some(label.clone());
}
t
}).collect::<Vec<_>>()
} else {
participants.clone()
};
for (fp, link) in links.iter() {
if fp != peer_fp {
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
room: room.clone(),
participants: tagged_for_propagation.clone(),
}).await;
}
}
drop(links);
// Broadcast updated RoomUpdate to local clients in this room
// Find the local room name (may be hashed or raw)
let mgr = fm.room_mgr.lock().await;
for local_room in mgr.active_rooms() {
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
// Build merged participant list: local + all remote (deduped)
let mut all_participants = mgr.local_participant_list(&local_room);
let links = fm.peer_links.lock().await;
for link in links.values() {
if let Some(canonical) = fm.resolve_global_room(&local_room) {
if let Some(remote) = link.remote_participants.get(canonical) {
all_participants.extend(remote.iter().cloned());
}
// Also check raw room name, but only if different from canonical
if canonical != local_room {
if let Some(remote) = link.remote_participants.get(&local_room) {
all_participants.extend(remote.iter().cloned());
}
}
}
}
// Deduplicate by fingerprint
let mut seen = HashSet::new();
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
let update = SignalMessage::RoomUpdate {
count: all_participants.len() as u32,
participants: all_participants,
};
let senders = mgr.local_senders(&local_room);
drop(links);
drop(mgr);
room::broadcast_signal(&senders, &update).await;
break;
}
}
}
}
SignalMessage::GlobalRoomInactive { room } => {
info!(peer = %peer_label, room = %room, "peer global room now inactive");
let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(peer_fp) {
link.active_rooms.remove(&room);
// Clear remote participants for this peer+room
link.remote_participants.remove(&room);
// Also try canonical name
if let Some(canonical) = fm.resolve_global_room(&room) {
link.remote_participants.remove(canonical);
}
}
// Update active rooms metric
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
fm.metrics.federation_active_rooms.set(total as i64);
// Build remaining remote participants (from all peers except the one going inactive)
let remaining_remote: Vec<wzp_proto::packet::RoomParticipant> = {
let canonical = fm.resolve_global_room(&room);
let mut result = Vec::new();
for (fp, link) in links.iter() {
if fp == peer_fp { continue; }
if let Some(c) = canonical {
if let Some(remote) = link.remote_participants.get(c) {
result.extend(remote.iter().cloned());
}
}
}
let mut seen = HashSet::new();
result.retain(|p| seen.insert(p.fingerprint.clone()));
result
};
// Propagate to other peers: send updated GlobalRoomActive with revised list,
// or GlobalRoomInactive if no participants remain anywhere
let local_active = {
let mgr = fm.room_mgr.lock().await;
mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room))
};
let has_remaining = !remaining_remote.is_empty() || local_active;
// Collect peer transports to send to (avoid holding lock across await)
let peer_sends: Vec<_> = links.iter()
.filter(|(fp, _)| *fp != peer_fp)
.map(|(_, link)| link.transport.clone())
.collect();
drop(links);
if has_remaining {
// Send updated participant list to other peers
let mut updated_participants = remaining_remote.clone();
if local_active {
let mgr = fm.room_mgr.lock().await;
for local_room in mgr.active_rooms() {
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
updated_participants.extend(mgr.local_participant_list(&local_room));
break;
}
}
}
let msg = SignalMessage::GlobalRoomActive {
room: room.clone(),
participants: updated_participants,
};
for transport in &peer_sends {
let _ = transport.send_signal(&msg).await;
}
} else {
// No participants left anywhere — propagate inactive
let msg = SignalMessage::GlobalRoomInactive { room: room.clone() };
for transport in &peer_sends {
let _ = transport.send_signal(&msg).await;
}
}
// Broadcast updated RoomUpdate to local clients (remote participant removed)
let mgr = fm.room_mgr.lock().await;
for local_room in mgr.active_rooms() {
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
let mut all_participants = mgr.local_participant_list(&local_room);
all_participants.extend(remaining_remote.iter().cloned());
// Deduplicate by fingerprint
let mut seen = HashSet::new();
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
let update = SignalMessage::RoomUpdate {
count: all_participants.len() as u32,
participants: all_participants,
};
let senders = mgr.local_senders(&local_room);
drop(mgr);
room::broadcast_signal(&senders, &update).await;
info!(room = %room, "broadcast updated presence (remote participant removed)");
break;
}
}
}
_ => {} // ignore other signals
}
}
/// Handle an incoming federation datagram (room-hash-tagged media).
async fn handle_datagram(
fm: &Arc<FederationManager>,
source_peer_fp: &str,
data: Bytes,
) {
if data.len() < 12 { return; } // 8-byte hash + min packet
let mut rh = [0u8; 8];
rh.copy_from_slice(&data[..8]);
let media_bytes = data.slice(8..);
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
Some(pkt) => pkt,
None => {
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
return;
}
};
// Event log: federation ingress
let peer_label = {
let links = fm.peer_links.lock().await;
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
};
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
// Count inbound federation packet + update last_seen
fm.metrics.federation_packets_forwarded
.with_label_values(&[source_peer_fp, "in"]).inc();
{
let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(source_peer_fp) {
link.last_seen = Instant::now();
}
}
// Dedup: drop packets we've already seen (multi-path duplicates).
// Key uses a hash of the actual payload bytes — unique per Opus frame,
// so different senders with the same seq/timestamp never collide.
let payload_hash = {
let mut h = 0u64;
for (i, &b) in media_bytes.iter().take(16).enumerate() {
h ^= (b as u64) << ((i % 8) * 8);
}
h
};
{
let mut dedup = fm.dedup.lock().await;
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
return;
}
}
// Find room by hash — check local rooms AND global room config
let room_name = {
let mgr = fm.room_mgr.lock().await;
let active = mgr.active_rooms();
// First: check local rooms (has participants)
active.iter().find(|r| room_hash(r) == rh).cloned()
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
// Second: check global room config (hub relay may have no local participants)
.or_else(|| {
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
})
};
let room_name = match room_name {
Some(r) => r,
None => {
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
return;
}
};
// Rate limit per room
if FEDERATION_RATE_LIMIT_PPS > 0 {
let mut limiters = fm.rate_limiters.lock().await;
let limiter = limiters.entry(room_name.clone())
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
if !limiter.allow() {
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
return;
}
}
// Deliver to all local participants — forward the raw bytes as-is.
// The original sender's MediaPacket is preserved exactly (no re-serialization).
let locals = {
let mgr = fm.room_mgr.lock().await;
mgr.local_senders(&room_name)
};
for sender in &locals {
match sender {
room::ParticipantSender::Quic(t) => {
if let Err(e) = t.send_raw_datagram(&media_bytes) {
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
warn!("federation local delivery error: {e}");
}
}
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
}
}
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
// Multi-hop: forward to ALL other connected peers (not the source)
// Don't filter by active_rooms — the receiving peer decides whether to deliver
let links = fm.peer_links.lock().await;
for (fp, link) in links.iter() {
if fp != source_peer_fp {
let mut tagged = Vec::with_capacity(8 + media_bytes.len());
tagged.extend_from_slice(&rh);
tagged.extend_from_slice(&media_bytes);
let _ = link.transport.send_raw_datagram(&tagged);
}
}
}

View File

@@ -15,27 +15,25 @@ 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`, the chosen `QualityProfile`, the caller's fingerprint, /// Returns the derived `CryptoSession` and the chosen `QualityProfile`.
/// 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, String, Option<String>), anyhow::Error> { ) -> Result<(Box<dyn CryptoSession>, QualityProfile), 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, caller_alias) = let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) =
match offer { match offer {
SignalMessage::CallOffer { SignalMessage::CallOffer {
identity_pub, identity_pub,
ephemeral_pub, ephemeral_pub,
signature, signature,
supported_profiles, supported_profiles,
alias, } => (identity_pub, ephemeral_pub, signature, supported_profiles),
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
other => { other => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"expected CallOffer, got {:?}", "expected CallOffer, got {:?}",
@@ -78,26 +76,25 @@ pub async fn accept_handshake(
}; };
transport.send_signal(&answer).await?; transport.send_signal(&answer).await?;
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:... Ok((session, chosen_profile))
// 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 {
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested // Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
// for federation reliability (large packets may exceed path MTU). if supported.is_empty() {
QualityProfile::GOOD return QualityProfile::GOOD;
}
// Pick the profile with the highest bitrate.
supported
.iter()
.max_by(|a, b| {
a.total_bitrate_kbps()
.partial_cmp(&b.total_bitrate_kbps())
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
.unwrap_or(QualityProfile::GOOD)
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -8,11 +8,7 @@
//! 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;

View File

@@ -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, warn}; use tracing::{error, info};
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::MediaTransport;
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,54 +23,12 @@ 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;
/// Parsed CLI result — config + identity path. fn parse_args() -> RelayConfig {
struct CliResult { let mut config = RelayConfig::default();
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")
@@ -123,28 +81,6 @@ fn parse_args() -> CliResult {
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;
@@ -154,11 +90,9 @@ fn parse_args() -> CliResult {
std::process::exit(0); std::process::exit(0);
} }
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]"); eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
eprintln!(); eprintln!();
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)");
@@ -168,8 +102,6 @@ fn parse_args() -> CliResult {
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!();
@@ -184,7 +116,7 @@ fn parse_args() -> CliResult {
} }
i += 1; i += 1;
} }
CliResult { config, identity_path, config_file, config_needs_create } config
} }
struct RelayStats { struct RelayStats {
@@ -252,29 +184,10 @@ async fn run_downstream(
} }
} }
/// Detect a non-loopback IP address from local interfaces.
/// Prefers public IPs over private (10.x, 172.16-31.x, 192.168.x).
fn detect_public_ip() -> Option<String> {
use std::net::UdpSocket;
// Connect to a public address to find our outbound IP (doesn't actually send anything)
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
if socket.connect("8.8.8.8:80").is_ok() {
if let Ok(addr) = socket.local_addr() {
return Some(addr.ip().to_string());
}
}
}
None
}
/// Build-time git hash, set by build.rs or env.
const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args(); let config = parse_args();
tracing_subscriber::fmt().init(); 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");
@@ -294,88 +207,12 @@ 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));
} }
// Load or generate relay identity // Generate ephemeral relay identity for crypto handshake
let relay_seed = { let relay_seed = wzp_crypto::Seed::generate();
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_der) = wzp_transport::server_config_from_seed(&relay_seed.0); let (server_config, _cert) = wzp_transport::server_config();
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
@@ -393,41 +230,9 @@ 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(
@@ -462,15 +267,6 @@ 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...");
@@ -487,13 +283,8 @@ 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();
@@ -508,23 +299,6 @@ 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" {
@@ -611,294 +385,6 @@ 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 {
@@ -945,7 +431,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, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake( let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake(
&*transport, &*transport,
&relay_seed_bytes, &relay_seed_bytes,
).await { ).await {
@@ -962,35 +448,10 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
// Use the caller's identity fingerprint from the handshake
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
// ACL: call rooms (call-*) are restricted to the two authorized participants.
// Only the relay's call orchestrator creates these rooms — random clients can't join.
if room_name.starts_with("call-") {
let call_id = &room_name[5..]; // strip "call-" prefix
let authorized = {
let reg = call_registry.lock().await;
match reg.get(call_id) {
Some(call) => {
call.caller_fingerprint == participant_fp
|| call.callee_fingerprint == participant_fp
}
None => false, // unknown call — reject
}
};
if !authorized {
warn!(%addr, room = %room_name, fp = %participant_fp, "rejected: not authorized for this call room");
transport.close().await.ok();
return;
}
info!(%addr, room = %room_name, fp = %participant_fp, "authorized for call room");
}
// Register in presence registry // 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(&participant_fp, None, Some(room_name.clone())); reg.register_local(fp, None, Some(room_name.clone()));
} }
info!(%addr, room = %room_name, "client joining"); info!(%addr, room = %room_name, "client joining");
@@ -1039,55 +500,16 @@ 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( match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
&room_name, Ok(id) => {
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);
@@ -1101,25 +523,6 @@ 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,
@@ -1128,9 +531,6 @@ 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

View File

@@ -16,22 +16,12 @@ 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,
pub session_rtt_ms: GaugeVec, pub session_rtt_ms: GaugeVec,
pub session_underruns: IntCounterVec, pub session_underruns: IntCounterVec,
pub session_overruns: IntCounterVec, pub session_overruns: IntCounterVec,
// Phase 4: loss-recovery breakdown per session.
pub session_dred_reconstructions: IntCounterVec,
pub session_classical_plc: IntCounterVec,
registry: Registry, registry: Registry,
} }
@@ -70,28 +60,6 @@ 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",
@@ -133,42 +101,17 @@ impl RelayMetrics {
) )
.expect("metric"); .expect("metric");
let session_dred_reconstructions = IntCounterVec::new(
Opts::new(
"wzp_relay_session_dred_reconstructions_total",
"Frames reconstructed via DRED (Deep REDundancy) per session",
),
&["session_id"],
)
.expect("metric");
let session_classical_plc = IntCounterVec::new(
Opts::new(
"wzp_relay_session_classical_plc_total",
"Frames filled via classical Opus/Codec2 PLC per session",
),
&["session_id"],
)
.expect("metric");
registry.register(Box::new(active_sessions.clone())).expect("register"); registry.register(Box::new(active_sessions.clone())).expect("register");
registry.register(Box::new(active_rooms.clone())).expect("register"); registry.register(Box::new(active_rooms.clone())).expect("register");
registry.register(Box::new(packets_forwarded.clone())).expect("register"); registry.register(Box::new(packets_forwarded.clone())).expect("register");
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");
registry.register(Box::new(session_underruns.clone())).expect("register"); registry.register(Box::new(session_underruns.clone())).expect("register");
registry.register(Box::new(session_overruns.clone())).expect("register"); registry.register(Box::new(session_overruns.clone())).expect("register");
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register");
registry.register(Box::new(session_classical_plc.clone())).expect("register");
Self { Self {
active_sessions, active_sessions,
@@ -177,19 +120,11 @@ 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,
session_underruns, session_underruns,
session_overruns, session_overruns,
session_dred_reconstructions,
session_classical_plc,
registry, registry,
} }
} }
@@ -241,39 +176,6 @@ impl RelayMetrics {
} }
} }
/// Phase 4: update per-session loss-recovery counters from a client's
/// `LossRecoveryUpdate` signal message. The client sends monotonic
/// totals (frames reconstructed since call start); we compute the
/// delta against the current Prometheus counter and increment by it.
/// IntCounterVec only increases, so a client restart that resets the
/// counter to 0 simply produces no delta until the new totals exceed
/// the Prometheus state.
pub fn update_session_loss_recovery(
&self,
session_id: &str,
dred_reconstructions: u64,
classical_plc: u64,
) {
let cur_dred = self
.session_dred_reconstructions
.with_label_values(&[session_id])
.get();
if dred_reconstructions > cur_dred {
self.session_dred_reconstructions
.with_label_values(&[session_id])
.inc_by(dred_reconstructions - cur_dred);
}
let cur_plc = self
.session_classical_plc
.with_label_values(&[session_id])
.get();
if classical_plc > cur_plc {
self.session_classical_plc
.with_label_values(&[session_id])
.inc_by(classical_plc - cur_plc);
}
}
/// Remove all per-session label values for a disconnected session. /// Remove all per-session label values for a disconnected session.
pub fn remove_session_metrics(&self, session_id: &str) { pub fn remove_session_metrics(&self, session_id: &str) {
let _ = self.session_buffer_depth.remove_label_values(&[session_id]); let _ = self.session_buffer_depth.remove_label_values(&[session_id]);
@@ -281,10 +183,6 @@ impl RelayMetrics {
let _ = self.session_rtt_ms.remove_label_values(&[session_id]); let _ = self.session_rtt_ms.remove_label_values(&[session_id]);
let _ = self.session_underruns.remove_label_values(&[session_id]); let _ = self.session_underruns.remove_label_values(&[session_id]);
let _ = self.session_overruns.remove_label_values(&[session_id]); let _ = self.session_overruns.remove_label_values(&[session_id]);
let _ = self
.session_dred_reconstructions
.remove_label_values(&[session_id]);
let _ = self.session_classical_plc.remove_label_values(&[session_id]);
} }
/// Get a reference to the underlying Prometheus registry. /// Get a reference to the underlying Prometheus registry.
@@ -479,13 +377,10 @@ mod tests {
}; };
m.update_session_quality("sess-cleanup", &report); m.update_session_quality("sess-cleanup", &report);
m.update_session_buffer("sess-cleanup", 42, 3, 1); m.update_session_buffer("sess-cleanup", 42, 3, 1);
m.update_session_loss_recovery("sess-cleanup", 17, 4);
// Verify they appear // Verify they appear
let output = m.metrics_handler(); let output = m.metrics_handler();
assert!(output.contains("sess-cleanup")); assert!(output.contains("sess-cleanup"));
assert!(output.contains("wzp_relay_session_dred_reconstructions_total"));
assert!(output.contains("wzp_relay_session_classical_plc_total"));
// Remove and verify they are gone // Remove and verify they are gone
m.remove_session_metrics("sess-cleanup"); m.remove_session_metrics("sess-cleanup");
@@ -493,55 +388,6 @@ mod tests {
assert!(!output.contains("sess-cleanup")); assert!(!output.contains("sess-cleanup"));
} }
/// Phase 4: LossRecoveryUpdate → per-session counters, monotonic delta
/// application.
#[test]
fn session_loss_recovery_monotonic_delta() {
let m = RelayMetrics::new();
let sess = "sess-dred";
// First update: 10 DRED, 2 PLC
m.update_session_loss_recovery(sess, 10, 2);
let dred1 = m
.session_dred_reconstructions
.with_label_values(&[sess])
.get();
let plc1 = m.session_classical_plc.with_label_values(&[sess]).get();
assert_eq!(dred1, 10);
assert_eq!(plc1, 2);
// Second update: 25 DRED, 5 PLC — counter advances by (15, 3)
m.update_session_loss_recovery(sess, 25, 5);
let dred2 = m
.session_dred_reconstructions
.with_label_values(&[sess])
.get();
let plc2 = m.session_classical_plc.with_label_values(&[sess]).get();
assert_eq!(dred2, 25);
assert_eq!(plc2, 5);
// Third update with LOWER values (e.g., client reset) — counters
// hold steady, no decrement.
m.update_session_loss_recovery(sess, 5, 1);
let dred3 = m
.session_dred_reconstructions
.with_label_values(&[sess])
.get();
let plc3 = m.session_classical_plc.with_label_values(&[sess]).get();
assert_eq!(dred3, 25, "counter must not decrease");
assert_eq!(plc3, 5, "counter must not decrease");
// Fourth update: client caught up and exceeded the old max.
m.update_session_loss_recovery(sess, 30, 8);
let dred4 = m
.session_dred_reconstructions
.with_label_values(&[sess])
.get();
let plc4 = m.session_classical_plc.with_label_values(&[sess]).get();
assert_eq!(dred4, 30);
assert_eq!(plc4, 8);
}
#[test] #[test]
fn metrics_increment() { fn metrics_increment() {
let m = RelayMetrics::new(); let m = RelayMetrics::new();

View File

@@ -10,7 +10,7 @@ use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, info, trace, warn}; use tracing::{error, info, warn};
use wzp_proto::packet::TrunkFrame; use wzp_proto::packet::TrunkFrame;
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
@@ -18,38 +18,6 @@ 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;
@@ -59,22 +27,6 @@ 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 {
@@ -115,24 +67,11 @@ impl ParticipantSender {
} }
} }
/// Broadcast a signal message to a list of participant senders.
pub async fn broadcast_signal(senders: &[ParticipantSender], msg: &wzp_proto::SignalMessage) {
for sender in senders {
if let ParticipantSender::Quic(t) = sender {
if let Err(e) = t.send_signal(msg).await {
warn!("broadcast_signal error: {e}");
}
}
}
}
/// A participant in a room. /// 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.
@@ -147,16 +86,10 @@ impl Room {
} }
} }
fn add( fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
&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, fingerprint, alias }); self.participants.push(Participant { id, _addr: addr, sender });
id id
} }
@@ -173,23 +106,6 @@ 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()
} }
@@ -206,35 +122,24 @@ 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 {
@@ -260,32 +165,20 @@ impl RoomManager {
} }
} }
/// Join a room. Returns (participant_id, room_update_msg, all_senders) for broadcasting. /// Join a room. Returns the participant ID or an error if unauthorized.
pub fn join( 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>,
alias: Option<&str>, ) -> Result<ParticipantId, String> {
) -> 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);
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string())); Ok(room.add(addr, sender))
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()`.
@@ -296,49 +189,17 @@ 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> {
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?; self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
Ok(id)
} }
/// Get list of active room names. /// Leave a room. Removes the room if empty.
pub fn active_rooms(&self) -> Vec<String> { pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
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
} }
} }
@@ -437,9 +298,6 @@ 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(
@@ -448,7 +306,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, debug_tap, federation_tx, federation_room_hash, room_mgr, room_name, participant_id, transport, metrics, session_id,
) )
.await; .await;
} }
@@ -462,145 +320,58 @@ 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, forwarded = packets_forwarded, "disconnected (stream ended)"); info!(%addr, participant = participant_id, "disconnected");
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, forwarded = packets_forwarded, "connection closed: {e}"); info!(%addr, participant = participant_id, "connection closed: {e}");
} else { } else {
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}"); error!(%addr, participant = participant_id, "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) => {
if let Err(e) = t.send_media(&pkt).await { let _ = 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)
@@ -610,24 +381,14 @@ 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 — leave room and broadcast update to remaining participants // Clean up
let mut mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) { 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.
@@ -643,19 +404,6 @@ 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
@@ -677,50 +425,24 @@ 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, forwarded = packets_forwarded, "disconnected (stream ended)"); info!(%addr, participant = participant_id, "disconnected");
break; break;
} }
Err(e) => { Err(e) => {
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}"); error!(%addr, participant = participant_id, "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 {
@@ -730,44 +452,21 @@ 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 {
send_errors += 1; let _ = e;
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)
@@ -777,30 +476,15 @@ 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 {
send_errors += 1; let _ = e;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
total_send_errors = send_errors,
"trunk flush error: {e}"
);
}
} }
} }
} }
@@ -813,10 +497,7 @@ async fn run_participant_trunked(
} }
let mut mgr = room_mgr.lock().await; let mut mgr = room_mgr.lock().await;
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) { 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]`.

View File

@@ -1,105 +0,0 @@
//! Persistent signaling connection manager.
//!
//! Tracks clients connected via `_signal` SNI. Routes call signals
//! (DirectCallOffer, DirectCallAnswer, Hangup) between registered users.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tracing::{info, warn};
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::QuinnTransport;
/// A client connected via `_signal` for direct calling.
pub struct SignalClient {
pub fingerprint: String,
pub alias: Option<String>,
pub transport: Arc<QuinnTransport>,
pub connected_at: Instant,
}
/// Manages persistent signaling connections.
pub struct SignalHub {
clients: HashMap<String, SignalClient>,
}
impl SignalHub {
pub fn new() -> Self {
Self {
clients: HashMap::new(),
}
}
/// Register a new signaling client.
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
self.clients.insert(fp.clone(), SignalClient {
fingerprint: fp,
alias,
transport,
connected_at: Instant::now(),
});
}
/// Unregister a signaling client. Returns the client if found.
pub fn unregister(&mut self, fp: &str) -> Option<SignalClient> {
let client = self.clients.remove(fp);
if client.is_some() {
info!(fingerprint = %fp, "signal client unregistered");
}
client
}
/// Look up a client by fingerprint.
pub fn get(&self, fp: &str) -> Option<&SignalClient> {
self.clients.get(fp)
}
/// Check if a fingerprint is online.
pub fn is_online(&self, fp: &str) -> bool {
self.clients.contains_key(fp)
}
/// Send a signal message to a client by fingerprint.
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
match self.clients.get(fp) {
Some(client) => {
client.transport.send_signal(msg).await
.map_err(|e| format!("send to {fp}: {e}"))
}
None => Err(format!("{fp} not online")),
}
}
/// Number of connected signaling clients.
pub fn online_count(&self) -> usize {
self.clients.len()
}
/// List all online fingerprints.
pub fn online_fingerprints(&self) -> Vec<&str> {
self.clients.keys().map(|s| s.as_str()).collect()
}
/// Get alias for a fingerprint.
pub fn alias(&self, fp: &str) -> Option<&str> {
self.clients.get(fp).and_then(|c| c.alias.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_unregister() {
let mut hub = SignalHub::new();
assert_eq!(hub.online_count(), 0);
assert!(!hub.is_online("alice"));
// Can't easily construct QuinnTransport in a unit test,
// so we just test the HashMap logic conceptually.
// Integration tests cover the full flow.
}
}

View File

@@ -16,9 +16,6 @@ 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"] }

View File

@@ -6,74 +6,20 @@ 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 (random keypair). /// Create a server configuration with a self-signed certificate (for testing).
/// ///
/// The certificate changes on every call. Use `server_config_from_seed` for /// Tunes QUIC transport parameters for lossy VoIP:
/// a deterministic certificate that survives relay restarts. /// - 30s idle timeout
/// - 5s keep-alive interval
/// - DATAGRAM extension enabled
/// - Conservative flow control for bandwidth-constrained links
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) { 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)

View File

@@ -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, server_config_from_seed, tls_fingerprint}; pub use config::{client_config, server_config};
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;

View File

@@ -136,11 +136,6 @@ impl PathMonitor {
} }
} }
/// Get raw packet counts for debugging.
pub fn counts(&self) -> (u64, u64) {
(self.total_sent, self.total_received)
}
/// Estimate bandwidth in kbps from bytes received over time. /// 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) {
@@ -154,27 +149,6 @@ impl PathMonitor {
} }
0 0
} }
/// Detect whether a network handoff likely occurred.
///
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
pub fn detect_handoff(&self) -> bool {
// We need at least two RTT observations to have a meaningful jitter value,
// and the EWMA must be non-zero to avoid division/multiplication by zero.
if self.jitter_ewma <= 0.0 {
return false;
}
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
instant_jitter > self.jitter_ewma * 3.0
} else {
false
}
}
} }
impl Default for PathMonitor { impl Default for PathMonitor {

View File

@@ -33,29 +33,6 @@ 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)
@@ -143,7 +120,7 @@ impl MediaTransport for QuinnTransport {
} }
}; };
match datagram::deserialize_media(data.clone()) { match datagram::deserialize_media(data) {
Some(packet) => { Some(packet) => {
// Record receive observation // Record receive observation
{ {
@@ -156,10 +133,8 @@ impl MediaTransport for QuinnTransport {
Ok(Some(packet)) Ok(Some(packet))
} }
None => { None => {
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing"); tracing::warn!("received malformed media datagram");
// Don't return Ok(None) — that signals connection closed. Ok(None)
// Recurse to read the next datagram instead.
Box::pin(self.recv_media()).await
} }
} }
} }

View File

@@ -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, None).await { match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).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);

View File

@@ -1,115 +0,0 @@
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
**Date:** 2026-04-06
**Severity:** High — app crash (SIGBUS) mid-call
**Status:** Root-caused, fix proposed
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
## Summary
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
## Crash Details
### Crash 1: wzp-capture (18:42, after 476s of call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
```
**Backtrace:**
```
#00 art::StackVisitor::WalkStack
#01 art::Thread::VisitRoots
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
#03 art::Thread::EnsureFlipFunctionStarted
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
#09 com.wzp.audio.AudioPipeline.runCapture
```
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
### Crash 2: wzp-playout (19:17, mid-call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
```
**Backtrace:**
```
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
#01 art_quick_osr_stub ← On-Stack Replacement
#02 art::jit::Jit::MaybeDoOnStackReplacement
#03-#04 art::interpreter::ExecuteSwitchImplCpp
```
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
## Why This Happens
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
2. **Tight JNI loops**`runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
## Evidence This Is Not Our Bug
| Component | Evidence |
|-----------|---------|
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
| **Rust native code** | `libwzp_android.so` not in any crash frame |
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
## Proposed Fix
### Option A: Disable concurrent GC compaction for audio threads (recommended)
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
**Not directly controllable from app code.** But we can reduce GC pressure:
### Option B: Reduce JNI transitions in audio threads
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
**Implementation:**
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
- Audio threads write/read directly to the buffer (no JNI call per frame)
- Rust reads/writes from the same memory region
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
### Option C: Use Android's Oboe (AAudio) natively from Rust
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
### Option D: Pin audio thread objects to prevent GC movement
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
### Recommendation
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
## Data Files
- Logcat from Nothing A059 (Android 16, API 36)
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
- Both SIGBUS/BUS_ADRERR, both in ART internal frames

View File

@@ -1,175 +0,0 @@
# Incident Report: Native Crash in Capture Thread — Use-After-Free on Engine Handle
**Date:** 2026-04-06
**Severity:** Critical — app crash (SIGSEGV) on call hangup
**Status:** Root-caused, fix pending
**Affects:** Android client only
## Summary
The app crashes with a native SIGSEGV during or shortly after call hangup. The crash occurs in JIT-compiled code inside `AudioPipeline.runCapture()`. The root cause is a use-after-free: the capture thread calls `engine.writeAudio()` via JNI after the engine's native handle has been freed by `teardown()` on the ViewModel thread.
## Crash Stacktrace
```
04-06 13:05:42.707 F DEBUG: #09 pc 000000000250696c /memfd:jit-cache (deleted) (com.wzp.audio.AudioPipeline.runCapture+3228)
04-06 13:05:42.707 F DEBUG: #14 pc 0000000000005270 <anonymous:730900d000> (com.wzp.audio.AudioPipeline.start$lambda$0+0)
04-06 13:05:42.708 F DEBUG: #19 pc 00000000000044cc <anonymous:730900d000> (com.wzp.audio.AudioPipeline.$r8$lambda$0rYcivupwvyN4SgBXhsroKmTlo8+0)
04-06 13:05:42.708 F DEBUG: #24 pc 00000000000042e4 <anonymous:730900d000> (com.wzp.audio.AudioPipeline$$ExternalSyntheticLambda0.run+0)
```
This is a tombstone (signal crash), not a Java exception. The `F DEBUG` tag indicates a native crash handler (debuggerd) captured the signal.
## Root Cause
### The Race Condition
Two threads operate on the engine concurrently without synchronization:
**Thread 1: `wzp-capture` (AudioRecord thread, MAX_PRIORITY)**
```kotlin
// AudioPipeline.runCapture() — runs in a tight loop
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
engine.writeAudio(pcm) // <-- JNI call to native engine
}
}
```
**Thread 2: ViewModel/UI thread (normal priority)**
```kotlin
// CallViewModel.teardown()
stopAudio() // sets AudioPipeline.running = false
engine?.stopCall() // tells Rust to stop
engine?.destroy() // frees native memory, sets nativeHandle = 0L
engine = null
```
### The Kotlin Guard is Insufficient
`WzpEngine.writeAudio()` has a guard:
```kotlin
fun writeAudio(pcm: ShortArray): Int {
if (nativeHandle == 0L) return 0 // check
return nativeWriteAudio(nativeHandle, pcm) // use
}
```
This is a **TOCTOU (time-of-check/time-of-use) race**:
1. Capture thread checks `nativeHandle != 0L` → true
2. ViewModel thread calls `destroy()`, which calls `nativeDestroy(handle)` then sets `nativeHandle = 0L`
3. Capture thread calls `nativeWriteAudio(handle, pcm)` with the now-freed handle
4. The JNI function dereferences `handle` as a pointer → **SIGSEGV**
The same race exists for `readAudio()` on the `wzp-playout` thread.
### Why `stopAudio()` Doesn't Prevent This
`AudioPipeline.stop()` sets `running = false` but does **NOT join or wait** for the threads:
```kotlin
fun stop() {
running = false
// Don't join — threads are parked as daemons to avoid native TLS crash
captureThread = null
playoutThread = null
}
```
The threads are intentionally not joined because of a separate bug: exiting a JNI-calling thread triggers a `SIGSEGV in OPENSSL_free` due to libcrypto TLS destructors on Android. The threads instead "park" with `Thread.sleep(Long.MAX_VALUE)` after the loop exits.
But the problem is the **window between `running = false` and the thread actually checking it**. The capture thread may be blocked in `recorder.read()` (which blocks for 20ms per frame) or in the middle of `engine.writeAudio()` when `destroy()` is called.
### Timeline of the Crash
```
T=0ms ViewModel: stopAudio() → sets running=false
T=0ms ViewModel: stopStatsPolling()
T=0ms ViewModel: engine.stopCall() — Rust stops internal tasks
T=1ms ViewModel: engine.destroy() — frees native memory
↑ nativeHandle = 0L
T=0-20ms Capture thread: still in recorder.read() or writeAudio()
→ if in writeAudio(), the nativeHandle check passed BEFORE destroy()
→ JNI dereferences freed pointer → SIGSEGV
```
## Affected Code
### Files with the race
| File | Line(s) | Issue |
|------|---------|-------|
| `android/.../WzpEngine.kt` | 107-108, 116-117 | TOCTOU on `nativeHandle` in `writeAudio()` / `readAudio()` |
| `android/.../CallViewModel.kt` | 257-262 | `stopAudio()` + `destroy()` without waiting for audio threads to quiesce |
| `android/.../AudioPipeline.kt` | 80-82 | `stop()` doesn't synchronize with running threads |
### Files with the thread parking workaround
| File | Line(s) | Context |
|------|---------|---------|
| `android/.../AudioPipeline.kt` | 57-58, 69-70 | Threads parked after loop exit to avoid libcrypto TLS crash |
| `android/.../AudioPipeline.kt` | 96-101 | `parkThread()``Thread.sleep(Long.MAX_VALUE)` |
## Constraints for the Fix
1. **Cannot join audio threads** — joining triggers a separate SIGSEGV in `OPENSSL_free` when the thread's TLS destructors fire (documented in `AudioPipeline.kt` comments). The parking workaround must be preserved.
2. **Must guarantee no JNI calls after `destroy()`** — the native handle is a raw pointer; any dereference after free is undefined behavior.
3. **Must not add blocking waits on the UI thread**`teardown()` runs on the ViewModel thread which must remain responsive.
4. **The `@Volatile running` flag is necessary but not sufficient** — it prevents new loop iterations but doesn't help with in-flight JNI calls.
5. **Both `writeAudio` and `readAudio` have the same race** — the fix must cover both the capture and playout paths.
## Reproduction
The crash is timing-dependent. It's most likely to occur when:
- The capture thread is in the middle of a `writeAudio()` JNI call when `destroy()` is called
- More likely on slower devices or under CPU pressure (GC, thermal throttling)
- Can happen on every hangup, but only crashes ~10-30% of the time due to the timing window
## Analysis of Possible Fix Approaches
### Approach A: Add a synchronization gate in the JNI bridge
Use a `ReentrantReadWriteLock` or `AtomicBoolean` in `WzpEngine.kt`:
- Audio threads acquire a read lock / check the flag before JNI calls
- `destroy()` acquires a write lock / sets the flag and waits for in-flight calls to drain
**Pro:** Clean, solves the race directly.
**Con:** Adding a lock to the audio hot path (every 20ms). `ReentrantReadWriteLock` is not lock-free. However, the read-lock path is uncontended 99.99% of the time (write-lock only during destroy), so contention is negligible.
### Approach B: Defer `destroy()` until audio threads have stopped
Instead of calling `destroy()` in `teardown()`, set a flag and have the audio threads call `destroy()` after they exit the loop (before parking).
**Pro:** No locks on hot path.
**Con:** Complex lifecycle — which thread calls destroy? What if both threads race to destroy? Need a `CountDownLatch` or similar.
### Approach C: Make the JNI handle atomically invalidated
Use `AtomicLong` for `nativeHandle` and use `compareAndExchange` in `destroy()` + `getAndCheck` pattern in audio calls.
**Pro:** Lock-free.
**Con:** Still has a TOCTOU window — the thread can load the handle, then it gets CAS'd to 0, then the thread uses the stale handle. Doesn't fully solve the race without combining with a reference count or epoch.
### Approach D: Introduce a destroy latch
Add a `CountDownLatch(1)` that audio threads wait on before parking. `teardown()` sets `running=false`, then `await`s the latch (with timeout), then calls `destroy()`. Each audio thread counts down the latch after exiting the loop.
Actually this needs a `CountDownLatch(2)` — one for each thread (capture + playout).
**Pro:** Guarantees no in-flight JNI calls at destroy time. No locks on hot path.
**Con:** `teardown()` blocks for up to one frame duration (~20ms) waiting for threads to exit their loops. Acceptable for a hangup path.
### Recommendation
**Approach D (destroy latch)** is the cleanest. The 20ms worst-case wait is imperceptible on the hangup path, and it provides a hard guarantee that no JNI calls are in flight when `destroy()` runs. Combined with the existing `running` volatile flag, the audio threads exit their loops within one frame and count down the latch.
If the latch times out (e.g., AudioRecord.read() is stuck), `destroy()` proceeds anyway — the `panic::catch_unwind` in the JNI bridge will catch the invalid access as a panic rather than a SIGSEGV (though this is best-effort; a true SIGSEGV from freed memory is not catchable).
## Data Files
The crash was captured from the Nothing A059 device at 13:05:42 on 2026-04-06. The tombstone is in the device's `/data/tombstones/` directory. The logcat output shows the crash frames.

View File

@@ -1,166 +0,0 @@
# Incident Report: Playout Ring Buffer Cursor Desync — Bidirectional Audio Loss
**Date:** 2026-04-06
**Severity:** Critical — causes 10-16 seconds of complete bidirectional silence mid-call
**Status:** Root-caused, fix pending
**Affects:** All clients using `AudioRing` (Android, potentially desktop)
## Summary
Both participants in a call experience simultaneous, prolonged audio silence (10-16 seconds) despite the QUIC transport, relay, and Rust codec pipeline all functioning normally. The root cause is a cursor desynchronization in the lock-free SPSC ring buffer (`AudioRing`) that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
## How We Know It's the Ring Buffer
### Evidence that eliminates other components
| Component | Evidence it's healthy | Source |
|-----------|----------------------|--------|
| **QUIC send path** | `frames_dropped=0, send_errors=0` on both clients | Engine send stats log |
| **QUIC recv path** | `max_recv_gap_ms=82, recv_errors=0` — no gaps >82ms | Engine recv stats log |
| **Relay forwarding** | `max_forward_ms=0, send_errors=0` in previous relay-instrumented test | Relay debug logging |
| **Opus codec** | `frames_decoded=2442` over 51.9s = 47 frames/sec (correct for 20ms) | Final stats JSON |
| **FEC** | `fec_recovered=4870` — FEC working normally | Final stats JSON |
| **Audio capture** | Pixel 6 capture has 0% silence; Nothing has gaps but those are expected mic pauses | capture_rms.csv |
### Evidence pointing to the ring buffer
1. **Both clients go silent at the exact same wall-clock moment (26.66s into call)** — rules out per-device issues; the common factor is the relay, but the relay was proven healthy in prior tests.
2. **`playout_avail=8640` at stats dump time** — the playout ring reports 8640 samples available (180ms, nearly full at the 9600 capacity). The recv task believes it has successfully written data into the ring. But the AudioTrack playout thread is reading silence (RMS=0 for 12+ seconds).
3. **Recv task continued receiving packets with no gaps**`max_recv_gap_ms=82` across the entire call. The decoded audio was written to the ring continuously.
4. **Silence starts and ends cleanly** — the transition from audio → silence happens within a single 20ms frame (frame 1332: rms=101, frame 1333: rms=0). This is not network degradation (which shows gradual quality loss). It's a discrete state change — the reader suddenly stops seeing data.
5. **Recovery is also discrete** — at ~38.8s (Sharp Hawk) and ~42.7s (Pixel 6), audio snaps back with high-energy frames (rms=3296+). Not a gradual reconnection.
## The Ring Buffer Code
**File:** `crates/wzp-android/src/audio_ring.rs`
```rust
const RING_CAPACITY: usize = 960 * 10; // 9600 samples = 200ms at 48kHz
pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>,
write_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
read_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
}
```
### `available()` — how many samples can be read
```rust
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Acquire);
w.wrapping_sub(r) // relies on usize wrapping arithmetic
}
```
### `write()` — producer (Rust recv task thread, inside tokio block_on)
```rust
pub fn write(&self, samples: &[i16]) -> usize {
let w = self.write_pos.load(Ordering::Relaxed);
let count = samples.len().min(RING_CAPACITY);
// ... write samples at (w + i) % RING_CAPACITY ...
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
// If we overwrote unread data, advance read_pos
if self.available() > RING_CAPACITY {
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
self.read_pos.store(new_read, Ordering::Release);
}
}
```
### `read()` — consumer (Kotlin AudioTrack JVM thread, via JNI)
```rust
pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available();
let count = out.len().min(avail);
let r = self.read_pos.load(Ordering::Relaxed);
// ... read samples at (r + i) % RING_CAPACITY ...
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count
}
```
## Suspected Failure Modes
### 1. Writer advances `read_pos` while reader is mid-read (data race)
The `write()` method at lines 68-72 modifies `read_pos` from the writer thread when it detects overflow. But the `read()` method on the consumer thread also modifies `read_pos`. This violates the SPSC contract — `read_pos` is supposed to be owned by the consumer.
**Scenario:**
1. Reader loads `read_pos = R` (line 82)
2. Writer detects overflow, stores `read_pos = R'` (line 71) where `R' > R`
3. Reader finishes reading, stores `read_pos = R + count` (line 88) — **overwrites** the writer's `R'` with a stale, smaller value
After step 3, the ring's `read_pos` has gone backwards. Now `available()` returns `write_pos.wrapping_sub(old_read_pos)` which is larger than `RING_CAPACITY`. Every subsequent `write()` call hits the overflow branch and keeps advancing `read_pos`, but the reader keeps overwriting it back. The ring is in a corrupted state where the reader and writer are fighting over `read_pos`.
### 2. `wrapping_sub` returns astronomically large values
`available()` uses `w.wrapping_sub(r)`. On a 64-bit platform, if due to the race above `r > w`, `wrapping_sub` returns `usize::MAX - (r - w) + 1` — an enormous number. The `read()` method caps this with `out.len().min(avail)` so it reads `out.len()` samples. But those samples are from indices calculated as `(r + i) % RING_CAPACITY` which wraps correctly. The samples read would be whatever was in the buffer at those positions — potentially stale/old data, or zeros from initialization.
However, the playout RMS CSV shows clean zeros (RMS=0), not garbage. This suggests the ring is returning the zeroed-out initial buffer contents, meaning `read_pos` has jumped far ahead of `write_pos`, pointing to memory that was never written to (or was written long ago and has since been zeroed by the overflow advance logic).
### 3. Why silence lasts exactly 12-16 seconds
After the desync, each `write()` call (every 20ms when a packet is decoded) enters the overflow branch and resets `read_pos`. But the reader immediately overwrites it back in its next `read()` call. This tug-of-war continues until one of two things happens:
- The cursors happen to realign through wrapping arithmetic
- A timing coincidence where the writer's store to `read_pos` happens to "win" the race
The 12-16 second duration is non-deterministic and depends on exact thread scheduling and cursor values.
## Reproduction Pattern
The bug manifests after roughly 25-30 seconds of a call. This timing is suspicious:
- At 48kHz mono, 20ms frames = 50 frames/sec
- Each decoded frame writes 960 samples to the ring
- After 25 seconds: `write_pos ≈ 25 * 50 * 960 = 1,200,000`
- The ring capacity is 9600, so `write_pos` has wrapped around `RING_CAPACITY` about 125 times
The wrapping of the monotonic cursors past certain thresholds, combined with the reader/writer `read_pos` race, likely triggers the desync at this scale.
## Data Files
All data from two independent test sessions (3 calls total) is in `/workspace/wzp/debug/`:
| File | Contents |
|------|----------|
| `wzp_debug_20260406_120546.zip` | Sharp Hawk (Nothing A059) — 51.9s call |
| `wzp_debug_20260406_120549.zip` | Bright Viper (Pixel 6) — 51.9s call |
| `wzp_debug_20260406_111733.zip` | Sharp Hawk — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_111735.zip` | Bright Viper — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_105858.zip` | First session (pre-logging fix), 39.8s call |
| `wzp_debug_20260406_105900.zip` | First session, 39.7s call |
### Key fields in each zip
- `meta.txt` — device, duration, final stats JSON
- `playout_rms.csv` — per-frame (20ms) RMS of AudioTrack output; silence = RMS 0
- `capture_rms.csv` — per-frame RMS of AudioRecord input
- `logcat.txt` — Android logcat filtered to WZP + audio tags
### How to reproduce the analysis
```python
import csv
with open("playout_rms.csv") as f:
for row in csv.DictReader(f):
if int(row['rms']) == 0 and int(row['time_ms']) > 2000:
print(f"SILENCE at {row['time_ms']}ms")
```
## Affected Code
- `crates/wzp-android/src/audio_ring.rs` — the `AudioRing` struct, specifically the `write()` method's overflow handling that mutates `read_pos` from the producer thread
- Any client using `AudioRing` for playout (currently only Android; desktop uses `cpal` directly)
## Constraints for the Fix
1. Must remain lock-free — AudioTrack thread runs at `Thread.MAX_PRIORITY` and cannot block
2. Must handle overflow gracefully — if the reader falls behind, old audio should be dropped, not cause a desync
3. The writer (Rust recv task) and reader (Kotlin AudioTrack via JNI) run on different threads with different scheduling priorities
4. Ring capacity is 200ms which is tight — any fix must not increase latency significantly
5. The `write_pos` and `read_pos` are the only synchronization mechanism (no mutex, no condvar)

View File

@@ -1,123 +0,0 @@
# Incident Report: Send Task Fatal Exit on QUIC Congestion
**Date:** 2026-04-06
**Severity:** High — causes complete audio loss mid-call
**Status:** Fixed in Android client, **pending fix in desktop client and web client**
## Summary
A QUIC congestion event causes `send_datagram()` to return `Err(Blocked)`. The send task treats this as a fatal error and exits, which kills the entire call via `tokio::select!`. Audio becomes one-way (recv still works briefly) then dies completely.
## Root Cause
In the engine's send loop (`run_call` function), `transport.send_media()` errors were handled with `break`:
```rust
// BEFORE (broken)
if let Err(e) = transport.send_media(&source_pkt).await {
error!("send error: {e}");
break; // <-- kills send task, which kills everything
}
```
Quinn's `send_datagram()` is synchronous and returns `Err(SendDatagramError::Blocked)` when the QUIC congestion window is full. This is a **transient condition** — the window opens again once ACKs arrive. But the `break` kills the send task, and since all tasks run under `tokio::select!`, the recv task, stats task, and signal task all die too.
### Why it manifests as "intermittent disconnections"
- Mobile networks have brief congestion spikes (cell tower handoff, WiFi interference)
- A single spike fills the QUIC congestion window
- One `Blocked` error → send task exits → `select!` cancels recv → complete silence
- The QUIC connection stays open (no error logged), so stats polling continues showing stale data
- From the user's perspective: audio drops for 5-20 seconds then "maybe comes back" (it doesn't — they're hearing cached playout ring drain)
### Evidence from debug reports
**Relay logs** confirmed the relay was healthy:
- `max_forward_ms=0` — relay forwards instantly
- `send_errors=0` — no relay-side failures
- The relay saw `large recv gap` warnings on participant 1 (Nothing A059): 722ms → 814ms → 1778ms → 3500ms → 6091ms — the client progressively stopped sending
**Client stats** confirmed:
- `frames_encoded` kept incrementing (Opus encoder running)
- `frames_decoded` froze at a fixed value (recv task died)
- `fec_recovered` froze simultaneously
- RTT, loss, jitter all frozen (stats task died)
## Fix Applied
### Android client (`crates/wzp-android/src/engine.rs`)
```rust
// AFTER (fixed)
if let Err(e) = transport.send_media(&source_pkt).await {
send_errors += 1;
frames_dropped += 1;
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();
}
continue; // <-- drop packet, keep going
}
```
Same pattern applied to FEC repair packet sends.
Recv task also hardened: transient errors (non-closed/reset) are now logged and survived rather than causing exit.
Added periodic health logging to both tasks (5-second intervals):
- Send: `frames_sent`, `frames_dropped`, `send_errors`, `ring_avail`
- Recv: `frames_decoded`, `fec_recovered`, `recv_errors`, `max_recv_gap_ms`, `playout_avail`
### Relay (`crates/wzp-relay/src/room.rs`)
Added debug logging to both plain and trunked forwarding loops:
- Per-recv gap tracking (warns on >200ms gaps)
- Room manager lock contention tracking (warns on >10ms)
- Forward latency tracking (warns on >50ms)
- Send error counting with peer identification
- 5-second periodic stats with all above metrics
## Affected Clients — FIX REQUIRED
### Desktop client (`crates/wzp-client/src/cli.rs`)
**Lines 345-348:**
```rust
if let Err(e) = transport.send_media(pkt).await {
error!("send error: {e}");
break; // <-- SAME BUG
}
```
**Lines 431-434:**
```rust
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return; // <-- SAME BUG
}
```
Both need the same continue-on-error pattern.
### Web client (`crates/wzp-web/src/main.rs`)
Needs audit — WebSocket transport may have different error semantics but same pattern should be checked.
## Testing
After fix, a congestion event will:
1. Log warnings with packet counts: `send_media error (dropping packet): Blocked`
2. Drop affected packets (brief audio glitch — ~20-100ms)
3. Resume normal sending once congestion window opens
4. FEC on the receiver side will recover most dropped packets
5. Call continues uninterrupted
## Timeline
- 10:37 — First crash observed (LinearProgressIndicator compose bug masked investigation)
- 10:58 — Debug reports collected, decoded stall pattern identified
- 11:16 — Relay debug logging deployed, confirmed relay is clean
- 11:17 — Second debug reports collected, send gaps correlated with relay recv gaps
- 11:30 — Root cause identified: `break` on `send_media` error in send task
- 11:45 — Fix applied and deployed

View File

@@ -1,747 +0,0 @@
# WarzonePhone Relay Administration Guide
This document covers deploying, configuring, and operating wzp-relay instances, including federation setup, monitoring, and troubleshooting.
## Relay Deployment
### Binary
Build and run the relay directly:
```bash
# Build release binary
cargo build --release --bin wzp-relay
# Run with defaults (listen on 0.0.0.0:4433, room mode, no auth)
./target/release/wzp-relay
# Run with config file
./target/release/wzp-relay --config /etc/wzp/relay.toml
```
### Remote Build (Linux)
The included build script provisions a temporary Hetzner Cloud VPS, builds all binaries, and downloads them:
```bash
# Requires: hcloud CLI authenticated, SSH key "wz" registered
./scripts/build-linux.sh
# Outputs to: target/linux-x86_64/
```
Produces: `wzp-relay`, `wzp-client`, `wzp-client-audio`, `wzp-web`, `wzp-bench`.
### Docker
```dockerfile
FROM rust:1.85 AS builder
WORKDIR /src
COPY . .
RUN cargo build --release --bin wzp-relay
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/target/release/wzp-relay /usr/local/bin/
EXPOSE 4433/udp
EXPOSE 9090/tcp
VOLUME /data
ENV HOME=/data
ENTRYPOINT ["wzp-relay"]
CMD ["--config", "/data/relay.toml", "--metrics-port", "9090"]
```
Build and run:
```bash
docker build -t wzp-relay .
docker run -d \
--name wzp-relay \
-p 4433:4433/udp \
-p 9090:9090/tcp \
-v /opt/wzp:/data \
wzp-relay
```
### systemd
Create `/etc/systemd/system/wzp-relay.service`:
```ini
[Unit]
Description=WarzonePhone Relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=wzp
Group=wzp
ExecStart=/usr/local/bin/wzp-relay --config /etc/wzp/relay.toml
Restart=always
RestartSec=5
LimitNOFILE=65536
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/wzp
PrivateTmp=yes
Environment=HOME=/var/lib/wzp
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.target
```
Setup:
```bash
# Create service user
useradd --system --home-dir /var/lib/wzp --create-home wzp
# Install binary and config
cp target/release/wzp-relay /usr/local/bin/
mkdir -p /etc/wzp
cp relay.toml /etc/wzp/
# Enable and start
systemctl daemon-reload
systemctl enable --now wzp-relay
journalctl -u wzp-relay -f
```
## TOML Configuration Reference
All fields have defaults. A minimal config file only needs the fields you want to override.
### Core Settings
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `listen_addr` | string (socket addr) | `"0.0.0.0:4433"` | UDP address to listen on for incoming QUIC connections |
| `remote_relay` | string (socket addr) | none | Remote relay address for forward mode. Disables room mode when set |
| `max_sessions` | integer | `100` | Maximum concurrent client sessions |
| `log_level` | string | `"info"` | Logging level: trace, debug, info, warn, error |
### Jitter Buffer
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `jitter_target_depth` | integer | `50` | Target buffer depth in packets (50 = 1 second at 20ms frames) |
| `jitter_max_depth` | integer | `250` | Maximum buffer depth in packets (250 = 5 seconds) |
### Authentication
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `auth_url` | string | none | featherChat auth validation URL. When set, clients must send a bearer token as their first signal message. The relay validates it via `POST <auth_url>` |
### Metrics and Monitoring
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `metrics_port` | integer | none | Port for the Prometheus HTTP metrics endpoint. Disabled if not set |
| `probe_targets` | array of socket addrs | `[]` | Peer relay addresses to probe for health monitoring (1 Ping/s each) |
| `probe_mesh` | boolean | `false` | Enable mesh mode for probe targets |
### Media Processing
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `trunking_enabled` | boolean | `false` | Enable trunk batching for outgoing media. Packs multiple session packets into one QUIC datagram, reducing overhead |
### WebSocket / Browser Support
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `ws_port` | integer | none | Port for WebSocket listener (browser clients). Disabled if not set |
| `static_dir` | string | none | Directory to serve static files (HTML/JS/WASM) |
### Federation
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `peers` | array of PeerConfig | `[]` | Outbound federation peer relays |
| `trusted` | array of TrustedConfig | `[]` | Inbound federation trust list |
| `global_rooms` | array of GlobalRoomConfig | `[]` | Room names to bridge across federation |
### Debugging
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `debug_tap` | string | none | Log packet headers for matching rooms. Use `"*"` for all rooms, or a specific room name |
### PeerConfig Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | yes | Address of the peer relay (e.g., `"193.180.213.68:4433"`) |
| `fingerprint` | string | yes | Expected TLS certificate fingerprint (hex with colons) |
| `label` | string | no | Human-readable label for logging |
### TrustedConfig Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `fingerprint` | string | yes | Expected TLS certificate fingerprint (hex with colons) |
| `label` | string | no | Human-readable label for logging |
### GlobalRoomConfig Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Room name to bridge across federation (e.g., `"android"`) |
## CLI Flags Reference
```
wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>]
[--auth-url <url>] [--metrics-port <port>]
[--probe <addr>]... [--probe-mesh] [--mesh-status]
[--trunking] [--global-room <name>]...
[--debug-tap <room>]
[--ws-port <port>] [--static-dir <dir>]
```
| Flag | Description |
|------|-------------|
| `--config <path>` | Load configuration from TOML file. CLI flags override config file values |
| `--listen <addr>` | Listen address (default: `0.0.0.0:4433`) |
| `--remote <addr>` | Remote relay for forwarding mode. Disables room mode |
| `--auth-url <url>` | featherChat auth endpoint (e.g., `https://chat.example.com/v1/auth/validate`) |
| `--metrics-port <port>` | Prometheus metrics HTTP port (e.g., `9090`) |
| `--probe <addr>` | Peer relay to probe for health monitoring. Repeatable |
| `--probe-mesh` | Enable mesh mode for probes |
| `--mesh-status` | Print mesh health table and exit (diagnostic) |
| `--trunking` | Enable trunk batching for outgoing media |
| `--global-room <name>` | Declare a room as global (bridged across federation). Repeatable |
| `--debug-tap <room>` | Log packet headers for a room (`"*"` for all rooms) |
| `--event-log <path>` | Write JSONL protocol event log for federation debugging |
| `--version`, `-V` | Print build git hash and exit |
| `--ws-port <port>` | WebSocket listener port for browser clients |
| `--static-dir <dir>` | Directory to serve static files from |
| `--help`, `-h` | Print help and exit |
CLI flags always override config file values when both are specified.
## Federation Setup
### Concepts
- **`[[peers]]`** -- outbound: relays we connect TO. Requires address + fingerprint
- **`[[trusted]]`** -- inbound: relays we accept connections FROM. Requires fingerprint only (they connect to us)
- **`[[global_rooms]]`** -- rooms bridged across all federated peers. Participants on different relays in the same global room hear each other
### Getting Your Relay's Fingerprint
When a relay starts, it logs its TLS fingerprint:
```
INFO TLS certificate (deterministic from relay identity) tls_fingerprint="a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
INFO federation: to peer with this relay, add to relay.toml:
INFO [[peers]]
INFO url = "193.180.213.68:4433"
INFO fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
```
Share this information with the administrator of the peer relay.
### Unknown Peer Connections
When an unknown relay tries to federate, the log shows:
```
WARN unknown relay wants to federate addr=10.0.0.5:12345 fp="7f2a:b391:0c44:..."
INFO to accept, add to relay.toml:
INFO [[trusted]]
INFO fingerprint = "7f2a:b391:0c44:..."
INFO label = "Relay at 10.0.0.5:12345"
```
## Example Configurations
### Single Relay (Minimal)
```toml
# /etc/wzp/relay.toml
# Minimal config -- all defaults, just enable metrics
metrics_port = 9090
```
Run:
```bash
wzp-relay --config /etc/wzp/relay.toml
```
### Single Relay (Full Featured)
```toml
# /etc/wzp/relay.toml
listen_addr = "0.0.0.0:4433"
max_sessions = 200
log_level = "info"
# Metrics
metrics_port = 9090
# Authentication
auth_url = "https://chat.example.com/v1/auth/validate"
# Browser support
ws_port = 8080
static_dir = "/opt/wzp/web"
# Performance
trunking_enabled = true
# Jitter buffer tuning
jitter_target_depth = 50
jitter_max_depth = 250
```
### Two-Relay Federation
**Relay A** (`relay-a.toml` on 193.180.213.68):
```toml
listen_addr = "0.0.0.0:4433"
metrics_port = 9090
# Outbound: connect to Relay B
[[peers]]
url = "10.0.0.5:4433"
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
label = "Relay B (US)"
# Accept inbound from Relay B
[[trusted]]
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
label = "Relay B (US)"
# Bridge these rooms
[[global_rooms]]
name = "android"
[[global_rooms]]
name = "general"
```
**Relay B** (`relay-b.toml` on 10.0.0.5):
```toml
listen_addr = "0.0.0.0:4433"
metrics_port = 9090
# Outbound: connect to Relay A
[[peers]]
url = "193.180.213.68:4433"
fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
label = "Relay A (EU)"
# Accept inbound from Relay A
[[trusted]]
fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
label = "Relay A (EU)"
# Same global rooms
[[global_rooms]]
name = "android"
[[global_rooms]]
name = "general"
```
### Three-Relay Chain (Full Mesh)
For three relays (A, B, C) in full mesh federation, each relay needs peers and trusted entries for the other two:
**Relay A** (EU):
```toml
listen_addr = "0.0.0.0:4433"
metrics_port = 9090
# Probe all peers
probe_targets = ["10.0.0.5:4433", "10.0.0.9:4433"]
probe_mesh = true
# Peers
[[peers]]
url = "10.0.0.5:4433"
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
label = "Relay B (US)"
[[peers]]
url = "10.0.0.9:4433"
fingerprint = "3c8e:d2a1:f7b5:6049:81c3:e9d4:a2f6:5678"
label = "Relay C (APAC)"
# Trust
[[trusted]]
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
label = "Relay B (US)"
[[trusted]]
fingerprint = "3c8e:d2a1:f7b5:6049:81c3:e9d4:a2f6:5678"
label = "Relay C (APAC)"
# Global rooms
[[global_rooms]]
name = "android"
[[global_rooms]]
name = "general"
```
**Relay B** and **Relay C** follow the same pattern, listing the other two relays in their `[[peers]]` and `[[trusted]]` sections.
## Monitoring
### Prometheus Metrics
Enable with `--metrics-port <port>` or `metrics_port` in TOML. The relay exposes metrics at `GET /metrics` on the specified HTTP port.
#### Relay Metrics
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `wzp_relay_active_sessions` | Gauge | -- | Current active sessions |
| `wzp_relay_active_rooms` | Gauge | -- | Current active rooms |
| `wzp_relay_packets_forwarded_total` | Counter | `room` | Total packets forwarded |
| `wzp_relay_bytes_forwarded_total` | Counter | `room` | Total bytes forwarded |
| `wzp_relay_auth_attempts_total` | Counter | `result` (ok/fail) | Auth validation attempts |
| `wzp_relay_handshake_duration_seconds` | Histogram | -- | Crypto handshake time |
#### Per-Session Metrics
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `wzp_relay_session_jitter_buffer_depth` | Gauge | `session_id` | Buffer depth per session |
| `wzp_relay_session_loss_pct` | Gauge | `session_id` | Packet loss percentage |
| `wzp_relay_session_rtt_ms` | Gauge | `session_id` | Round-trip time |
| `wzp_relay_session_underruns_total` | Counter | `session_id` | Jitter buffer underruns |
| `wzp_relay_session_overruns_total` | Counter | `session_id` | Jitter buffer overruns |
#### Inter-Relay Probe Metrics
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `wzp_probe_rtt_ms` | Gauge | `target` | RTT to peer relay |
| `wzp_probe_loss_pct` | Gauge | `target` | Loss to peer relay |
| `wzp_probe_jitter_ms` | Gauge | `target` | Jitter to peer relay |
| `wzp_probe_up` | Gauge | `target` | 1 if reachable, 0 if not |
### Prometheus Scrape Config
```yaml
# prometheus.yml
scrape_configs:
- job_name: 'wzp-relay'
static_configs:
- targets:
- 'relay-a:9090'
- 'relay-b:9090'
scrape_interval: 10s
```
### Grafana Dashboard
A pre-built dashboard is available at `docs/grafana-dashboard.json`. Import it into Grafana for:
1. **Relay Health** -- active sessions, rooms, packets/s, bytes/s
2. **Call Quality** -- per-session jitter depth, loss%, RTT, underruns over time
3. **Inter-Relay Mesh** -- latency heatmap, probe status, loss trends
4. **Web Bridge** -- active connections, frames bridged, auth failures
### Event Log (Protocol Analyzer)
Use `--event-log` to write a JSONL event log that traces every federation media packet through the relay pipeline. Essential for debugging federation audio issues.
```bash
wzp-relay --config relay.toml --event-log /tmp/events.jsonl
```
Each media packet emits events at every decision point:
- `federation_ingress` — packet arrived from a peer relay
- `local_deliver` — packet delivered to local participants
- `dedup_drop` — packet dropped as duplicate
- `rate_limit_drop` — packet dropped by rate limiter
- `room_not_found` — packet for unknown room
- `local_deliver_error` — delivery to local client failed
Analyze with:
```bash
# Count events by type
cat events.jsonl | python3 -c "
import json, collections, sys
c = collections.Counter()
for l in sys.stdin: c[json.loads(l)['event']] += 1
for k,v in sorted(c.items(), key=lambda x:-x[1]): print(f' {k}: {v}')
"
```
### Remote Version Check
Verify a deployed relay's version without SSH:
```bash
wzp-client --version-check <relay-addr:port>
```
### Debug Tap
Use `--debug-tap` to log packet headers for debugging:
```bash
# Log headers for room "android"
wzp-relay --debug-tap android
# Log headers for all rooms
wzp-relay --debug-tap '*'
```
Or in TOML:
```toml
debug_tap = "android"
```
### Mesh Status
Print the current mesh health table (diagnostic):
```bash
wzp-relay --mesh-status
```
## Authentication
### featherChat Token Validation
When `--auth-url` is set, the relay requires clients to send an `AuthToken` signal message as their first message after QUIC connection. The relay validates the token by calling:
```
POST <auth_url>
Content-Type: application/json
Authorization: Bearer <token>
```
Expected response:
```json
{
"valid": true,
"fingerprint": "a5d6:e3c6:...",
"alias": "username"
}
```
If validation fails, the client is disconnected.
### Without Authentication
When `--auth-url` is not set, any client can connect. The relay logs:
```
INFO auth disabled -- any client can connect (use --auth-url to enable)
```
## Identity Persistence
### Relay Identity File
The relay stores its identity seed at `~/.wzp/relay-identity` (a 64-character hex string). This seed:
- Is generated automatically on first run
- Persists across restarts
- Derives the relay's Ed25519 signing key and X25519 key agreement key
- Derives the TLS certificate deterministically (same seed = same cert = same fingerprint)
If the identity file is corrupted, the relay generates a new one and logs a warning. This will change the relay's TLS fingerprint, requiring federation peers to update their config.
### Backup
Back up the identity file to preserve the relay's fingerprint:
```bash
cp ~/.wzp/relay-identity /secure/backup/relay-identity
```
To restore, copy the file back before starting the relay.
## Troubleshooting
### Common Issues
| Problem | Cause | Solution |
|---------|-------|---------|
| "unknown argument" on startup | Unrecognized CLI flag | Check `wzp-relay --help` for valid flags |
| "failed to load config" | Invalid TOML syntax | Validate TOML file with `toml-cli` or similar |
| "auth failed" for all clients | Wrong `auth_url` or featherChat server down | Verify URL is reachable: `curl -X POST <auth_url>` |
| "session rejected" | Max sessions reached | Increase `max_sessions` in config |
| Clients cannot connect | Firewall blocking UDP 4433 | Open UDP port 4433 in firewall |
| Federation "unknown relay wants to federate" | Peer's fingerprint not in `[[trusted]]` | Add the logged fingerprint to `[[trusted]]` |
| Federation "fingerprint mismatch" | Peer relay restarted with new identity | Update the fingerprint in `[[peers]]` config |
| Federation audio silent on consecutive connects | Dedup filter or jitter buffer state | Verify relay is running latest build with time-based dedup |
| Federation participant shows wrong relay label | Hub relay not propagating original labels | Update relay to latest build (label preservation fix) |
| Federation disconnect takes >15 seconds | QUIC idle timeout + stale sweeper | Normal: sweeper runs every 5s with 15s TTL. Use latest client with SIGTERM handler for instant disconnect |
| High packet loss between relays | Network congestion or misconfiguration | Check `wzp_probe_loss_pct` metric; consider relay chaining |
| Jitter buffer overruns | Packets arriving faster than playout | Increase `jitter_max_depth` |
| Jitter buffer underruns | Packets arriving too slowly or lost | Check network quality; increase `jitter_target_depth` |
| "probe connection closed" | Peer relay unreachable or crashed | Check peer relay status; will auto-reconnect |
| WebSocket clients cannot connect | `ws_port` not set | Add `--ws-port <port>` or `ws_port` in TOML |
| Browser mic access denied | Not using HTTPS | Use TLS termination in front of the relay or serve via `wzp-web --tls` |
### Log Level Tuning
Set `RUST_LOG` environment variable for fine-grained control:
```bash
# All relay logs at debug level
RUST_LOG=debug wzp-relay
# Only federation at trace, everything else at info
RUST_LOG=info,wzp_relay::federation=trace wzp-relay
# Quiet mode -- only warnings and errors
RUST_LOG=warn wzp-relay
```
### Health Checks
```bash
# Check if relay is listening
nc -zu relay-host 4433
# Check metrics endpoint
curl -s http://relay-host:9090/metrics | head -20
# Check active sessions
curl -s http://relay-host:9090/metrics | grep wzp_relay_active_sessions
# Check federation probe health
curl -s http://relay-host:9090/metrics | grep wzp_probe_up
```
## Build Pipelines
All production artifacts (Android APK, Linux x86_64 binaries, Windows `.exe`) are built on **SepehrHomeserverdk** using Docker, not on developer workstations. The pipelines are fire-and-forget: a local script invokes a `tmux` session on the remote, the build runs in a Docker container, and the artifact is uploaded to `paste.dk.manko.yoga` (rustypaste) with a notification sent to `ntfy.sh/wzp` on start and completion.
### Docker images
Two long-lived images live on the remote:
| Image | Used by | Base | Key contents |
|---|---|---|---|
| `wzp-android-builder` | Android APK (Tauri mobile + legacy Kotlin), Linux x86_64 relay/CLI | Debian bookworm | Rust stable with Android targets, cargo-ndk, NDK 26.1, Android SDK (API 34 + 35 + 36), JDK 17, Gradle 8.5, Node.js 20, cmake, ninja, tauri-cli 2.x |
| `wzp-windows-builder` | Windows x86_64 `.exe` | Debian bookworm | Rust stable with `x86_64-pc-windows-msvc` target, cargo-xwin (with pre-warmed MSVC CRT + Windows SDK cache), Node.js 20, cmake, ninja, clang, lld, nasm |
Both images are rebuilt rarely — once the base toolchain is stable, rebuilds are only needed to pick up new dependencies or security patches.
**Rebuilding an image** (fire-and-forget, ~10 min on a warm base):
```bash
# Windows
./scripts/build-windows-docker.sh --image-build
# Android (upload and rebuild handled by the Android build script itself — see
# its --image-build flag or equivalent)
```
The `--image-build` flag uploads the local Dockerfile to the remote, kicks off `docker build` under `nohup`, and returns immediately. Monitor with:
```bash
ssh SepehrHomeserverdk 'tail -f /tmp/wzp-windows-image-build.log'
```
### Pipeline: Android APK (Tauri Mobile)
```bash
./scripts/build-tauri-android.sh # Full: pull + build + upload + notify
./scripts/build-tauri-android.sh --no-pull # Skip git fetch
./scripts/build-tauri-android.sh --clean # Force-clean Rust target
```
- **Branch**: `android-rewrite`
- **Image**: `wzp-android-builder`
- **Build command**: `cargo tauri android build --release`
- **Output**: `wzp-release.apk` → uploaded to rustypaste
- **Notifications**: start + completion to `ntfy.sh/wzp`
- **Remote artifact path**: `/mnt/storage/manBuilder/data/cache-android/target/…/release/app-release.apk`
### Pipeline: Linux x86_64 (relay + CLI + bench + web)
```bash
./scripts/build-linux-docker.sh # Fire-and-forget
./scripts/build-linux-docker.sh --no-pull # Skip git fetch
./scripts/build-linux-docker.sh --clean # Force-clean target
./scripts/build-linux-docker.sh --install # Wait for completion and download locally
```
- **Branch**: `feat/android-voip-client` (script default — override by editing the script or passing an env var)
- **Image**: `wzp-android-builder` (shared, not a separate Linux-only image)
- **Targets built**: `wzp-relay`, `wzp-client`, `wzp-client-audio` (with `--features audio`), `wzp-web`, `wzp-bench`
- **Output**: `wzp-linux-x86_64.tar.gz` with all five binaries → uploaded to rustypaste
- **Local landing dir** (with `--install`): `target/linux-x86_64/`
### Pipeline: Windows x86_64 (`wzp-desktop.exe`)
```bash
./scripts/build-windows-docker.sh # Full: pull + build + download locally
./scripts/build-windows-docker.sh --no-pull # Skip git fetch
./scripts/build-windows-docker.sh --rust # Force-clean target-windows cache
./scripts/build-windows-docker.sh --image-build # Rebuild the Docker image (fire-and-forget)
```
- **Branch**: `feat/desktop-audio-rewrite`
- **Image**: `wzp-windows-builder`
- **Build command**: `cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop`
- **Output**: `wzp-desktop.exe` (~16 MB) → downloaded to `target/windows-exe/wzp-desktop.exe`, also uploaded to rustypaste
- **Target cache volume**: `target-windows` (separate from the Android target cache to avoid triple cross-contamination)
- **Shared cache volumes**: `cargo-registry`, `cargo-git` (shared with Android — both pipelines pull the same crates)
**A/B-preserving workflow** for testing audio backends: rename the prior `.exe` before re-running the build, so both coexist:
```bash
# Preserve prior build as the noAEC baseline
mv target/windows-exe/wzp-desktop.exe target/windows-exe/wzp-desktop-noAEC.exe
./scripts/build-windows-docker.sh
ls -la target/windows-exe/
# wzp-desktop-noAEC.exe (previous build)
# wzp-desktop.exe (new build)
```
### Alternative pipeline: Windows via Hetzner Cloud VPS
For situations where Docker image rebuilds would be disruptive, or for one-shot debug builds on a clean machine:
```bash
./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
./scripts/build-windows-cloud.sh --prepare # Create VM + install deps, don't build
./scripts/build-windows-cloud.sh --build # Build on existing VM
./scripts/build-windows-cloud.sh --transfer # Download .exe from existing VM
./scripts/build-windows-cloud.sh --destroy # Delete the VM
WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Don't auto-destroy after successful build
```
- **Provider**: Hetzner Cloud
- **Default server type**: `cx33` (8 GB RAM, 8 vCPU — `cx23` with 4 GB OOMs on the tauri+rustls cross-compile)
- **Image**: `ubuntu-24.04`
- **SSH key**: must be named `wz` in Hetzner and loaded in the local ssh-agent
- **Reminder**: set `WZP_KEEP_VM=1` for multi-build sessions, then **remember to `--destroy` at end of day** so the VM isn't left running overnight. This is tracked in the auto-memory as `feedback_keep_windows_builder_vm.md`.
### Notifications
All pipelines post to `https://ntfy.sh/wzp`. Subscribe from your phone via the [ntfy.sh app](https://ntfy.sh/) to get push notifications on build start/success/failure. Messages include the short git hash and the rustypaste URL on success:
```
WZP Windows build OK [03a80a3] (16M)
https://paste.dk.manko.yoga/<uuid>/wzp-desktop.exe
```
### Rustypaste credentials
Build pipelines read `rusty_address` and `rusty_auth_token` from the `.env` file at `/mnt/storage/manBuilder/.env` on SepehrHomeserverdk. Local scripts that upload directly (`build-windows-cloud.sh` when run in `--transfer` mode) read from `~/.wzp/rustypaste.env` with the same variable names. Both files must be kept in sync manually if rotated.

File diff suppressed because it is too large Load Diff

View File

@@ -1,139 +0,0 @@
# Branch: `android-rewrite`
Pivot away from the legacy Kotlin + JNI Android client to a pure-Rust **Tauri 2.x Mobile** app that shares the same frontend and backend code as the desktop client.
## Why this branch exists
The Kotlin + JNI stack was a crash factory. Every failure mode we hit was at the Kotlin ↔ Rust boundary, and each fix uncovered the next layer of the onion:
| Symptom | Root cause | Fix |
|---|---|---|
| App crashed on launch before `onCreate` returned | `__init_tcb` / `pthread_create` bionic private symbols leaking out of `libwzp_android.so` because the Rust crate used `crate-type = ["cdylib", "staticlib"]`. rust-lang/rust#104707 documents that staticlib alongside cdylib leaks non-exported symbols from the staticlib into the cdylib, and Bionic's private internal pthread symbols got bound LOCALLY inside our `.so` instead of resolved against `libc.so` at `dlopen` time | Dropped `staticlib` from the crate-type list. `crate-type = ["cdylib", "rlib"]` only. |
| Stack overflow on `place_call` | `Dispatchers.IO` threads have a ~512 KB stack, too small for the Rust signal-connect path that does TLS handshake + quinn setup inside one closure | Launched JNI calls from a dedicated `java.lang.Thread` with an explicit 8 MB stack |
| `ring` / `libcrypto` TLS reuse crash on second call | tokio runtime got dropped between calls, but `ring` keeps a TLS-stored SSL context that is invalidated when the runtime thread is reused by a new runtime — `ring` sees stale context and segfaults | Single long-lived tokio runtime for the entire signal client lifetime; split `start()` into an inline `connect+register` path and a `run()` path on a separate thread to avoid the `thread::spawn` closure's stack overflow |
| Null dereference on register with fresh install | Identity seed file empty when it existed-but-was-blank, Rust side deref'd the zero-length slice | Generate seed if empty on register |
Every fix kept the app limping along but the fundamental design problem remained: **state management was split across a Kotlin ViewModel and a Rust engine, with a hand-rolled JNI bridge in between that had to be perfect to not crash**. The working desktop Tauri client (with the same Rust backend) had none of these problems because it spoke to the Rust code via in-process `invoke()` from a WebView, not JNI.
So: rewrite the Android app as a **Tauri 2.x Mobile app**, reusing the entire desktop codebase verbatim (`main.ts`, `style.css`, `index.html`, `main.rs`, `engine.rs` — everything). Tauri Mobile added Android support in v2, it's production-ready, and it eliminates the JNI boundary entirely.
The incident postmortem lives at [`docs/incident-tauri-android-init-tcb.md`](incident-tauri-android-init-tcb.md).
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Tauri 2.x Mobile │
│ │
│ Android WebView ────────── HTML/JS/CSS │ ← Shared with desktop
│ │ (main.ts) │
│ │ │
│ invoke() ─────────────── Rust Commands │ ← Shared with desktop
│ (main.rs) │
│ │ │
│ ┌───────────────┼────────────┐ │
│ │ │ │ │
│ SignalMgr CallEngine Identity │ ← Shared crates
│ (signal_hub) (wzp-client) (wzp-crypto)│
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ QUIC to relay Oboe audio (Android) │
│ via wzp-native cdylib │
└─────────────────────────────────────────────────┘
```
**What is reused from desktop verbatim** (zero rewrite):
- `desktop/src/main.ts` — entire frontend
- `desktop/src/style.css` — all styling
- `desktop/src/identicon.ts` — identicon rendering
- `desktop/index.html` — HTML structure
- `desktop/src-tauri/src/main.rs` — all Tauri commands (`connect`, `disconnect`, `register_signal`, `place_call`, …)
- `desktop/src-tauri/src/engine.rs``CallEngine` wrapper
**What is Android-specific**:
- `desktop/src-tauri/src/android_audio.rs` — JVM-side audio routing (`AudioManager.setSpeakerphoneOn` for earpiece/speaker toggle). Runs from Tauri's existing JNI context — no hand-rolled bridge, Tauri owns the JVM hookup.
- `desktop/src-tauri/src/wzp_native.rs` — runtime `dlopen` of `libwzp_native.so`, a standalone cdylib crate (`crates/wzp-native`) that owns all C++ (Oboe bridge). Kept in its own crate so its C/C++ static archives never get statically linked into `wzp-desktop`'s `.so`, which would re-trigger the `__init_tcb` / pthread leak.
- `crates/wzp-native/` — the standalone C++/Oboe bridge cdylib. Loaded via `libloading` at runtime from `wzp_native.rs`. Provides capture + playout streams using Oboe's `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` combo.
- Android-specific target dependencies in `desktop/src-tauri/Cargo.toml` (`jni`, `ndk-context`, `libloading`) — no CPAL, no VPIO.
## Key architectural decisions
### 1. `wzp-native` as a standalone cdylib loaded via `libloading`
The alternative — linking `wzp-native` as a regular Rust dep with C++ static archives — would cause the same `__init_tcb` crash that killed the Kotlin version. By making `wzp-native` its own cdylib and `dlopen`-ing it at runtime, Bionic's `libc.so` resolves every symbol at load time the way it's supposed to, and no private TCB symbols leak.
### 2. `crate-type = ["cdylib", "rlib"]` only (no `staticlib`)
Same reason. The `rlib` output is needed so the `wzp-desktop` binary target can link against the library; `cdylib` is needed for Android's `System.loadLibrary`; `staticlib` would reintroduce the symbol-leak bug.
### 3. Oboe audio config
`Usage::VoiceCommunication` + Java-side `MODE_IN_COMMUNICATION`. **Never** call `setAudioApi(AAudio)` explicitly — on some devices (Nothing Phone in particular) it causes Oboe to open the wrong stream type and audio goes silent. Let Oboe pick the audio API automatically. This is documented in the auto-memory `project_tauri_android_audio.md`.
### 4. Speaker/earpiece toggle uses `tokio::task::spawn_blocking`
Oboe's `stop()` + `start()` cycle is synchronous and can block for 50200 ms. Calling it on the tokio executor stalls every other async task (including the QUIC datagram loop), dropping audio packets. Wrapping the toggle in `spawn_blocking` isolates it to a dedicated thread pool. Fixed in commit `76a4c53`.
## Build pipeline
Docker on SepehrHomeserverdk, same pattern as the Android legacy pipeline and the Windows pipeline:
```
./scripts/build-tauri-android.sh # Full: pull + build + ntfy + rustypaste
./scripts/build-tauri-android.sh --pull # Explicit git pull (default)
./scripts/build-tauri-android.sh --clean # Blow away the Rust target cache
```
**Image**: `wzp-android-builder` (shared with the legacy Kotlin pipeline). The Dockerfile was extended to install Node.js 20 LTS, Android API level 36, build-tools 35.0.0, tauri-cli 2.x, and all four Android Rust targets on top of the legacy NDK 26.1 + cargo-ndk + Gradle setup. Both pipelines coexist in the same image.
**Output**: `wzp-release.apk` uploaded to rustypaste, URL delivered via `ntfy.sh/wzp`.
## Known quirks (Tauri Mobile specific)
1. **tauri-cli `android init` writes absolute paths** into `gradle.properties` for the NDK path. Those paths are local to wherever `android init` was run, so they break any cross-machine build unless overridden with `ANDROID_NDK_HOME` at build time. The build script exports `ANDROID_NDK_HOME` explicitly to work around this.
2. **API 36 vs API 34 coexistence**: the legacy Kotlin pipeline targets API 34, Tauri Mobile 2.x wants compileSdk 36. The shared Docker image installs both SDK levels so neither pipeline needs to reinstall.
3. **Identity seed lives in Android-specific app data dir**: `/data/data/com.wzp.phone/files/.wzp/identity` instead of `$HOME/.wzp/identity`. The shared `load_or_create_seed()` function in `desktop/src-tauri/src/lib.rs` uses Tauri's `app_data_dir()` which resolves correctly on both Android and desktop — no per-platform code needed.
4. **Direct calls on macOS previously hit an identity mismatch bug** — the `CallEngine` was using `$HOME/.wzp/identity` directly while `register_signal` used Tauri's `app_data_dir()`. Fixed by routing both through `load_or_create_seed()` (commit `2fd9465`). This was important for cross-platform consistency.
## Current state (snapshot)
What works:
- Tauri Mobile scaffold builds and runs on Android
- Signal hub connect + register works
- Room mode (SFU group calls) works with Oboe audio
- Direct 1:1 calls work with full parity to desktop
- Speaker/earpiece toggle works without stalling the audio pipeline
- Call history, recent contacts, deregister UI all present (inherited from desktop)
What remains (task list refs in parens):
- Background service for keeping signal alive when app is backgrounded (#19)
- Proper permission requests (microphone, notifications) on first launch (#19)
- Incoming call notification while backgrounded (#19)
- App icon + splash screen (#19)
## Testing
- **Build**: `./scripts/build-tauri-android.sh` — verify the APK lands on rustypaste and installs on device.
- **Smoke test**: Install → open app → Register → Place call → Receive call. No crashes, audio flows both ways.
- **Speaker toggle**: During a call, toggle speaker/earpiece several times in rapid succession. Audio should never stop, and the toggle should respond within ~200 ms.
- **Stress test**: Call for 10+ minutes continuous. No memory growth, no packet loss beyond what's attributable to the network.
## Files of interest
| Path | Purpose |
|---|---|
| `desktop/src-tauri/src/lib.rs` | Shared Tauri commands (desktop + Android) |
| `desktop/src-tauri/src/android_audio.rs` | JVM-side speaker/earpiece routing |
| `desktop/src-tauri/src/wzp_native.rs` | Runtime dlopen of libwzp_native.so |
| `crates/wzp-native/` | Standalone C++/Oboe cdylib, loaded at runtime |
| `scripts/build-tauri-android.sh` | Remote Docker build pipeline |
| `scripts/Dockerfile.android-builder` | Shared Android Docker image (legacy + Tauri) |
| `docs/incident-tauri-android-init-tcb.md` | Postmortem of the Kotlin+JNI crash cascade |

View File

@@ -1,591 +1,168 @@
# WarzonePhone Design Document # WarzonePhone Detailed Design Decisions
> Custom encrypted VoIP protocol built in Rust. Designed for hostile network conditions: 5-70% packet loss, 100-500 kbps throughput, 300-800 ms RTT. Multi-platform: Desktop (Tauri), Android, CLI, Web. ## Why Opus + Codec2 (Not Just One)
## System Overview The dual-codec architecture is driven by the extreme range of network conditions WarzonePhone targets:
WarzonePhone is a voice-over-IP system built from scratch in Rust, targeting reliable encrypted voice communication over severely degraded networks. The protocol uses adaptive codecs (Opus + Codec2), fountain-code FEC (RaptorQ), and end-to-end ChaCha20-Poly1305 encryption over a QUIC transport layer. **Opus** (24/16/6 kbps) is the clear choice for normal to degraded conditions. It offers excellent quality at moderate bitrates, has built-in inband FEC and DTX (discontinuous transmission), and the `audiopus` crate provides mature Rust bindings to libopus. Opus operates at 48 kHz natively.
The system comprises three categories of components: **Codec2** (3200/1200 bps) is a narrowband vocoder designed specifically for HF radio links with extreme bandwidth constraints. At 1200 bps (1.2 kbps), it produces intelligible speech in only 6 bytes per 40ms frame -- roughly 20x lower bitrate than Opus at its minimum. The pure-Rust `codec2` crate means no C dependencies for this codec. Codec2 operates at 8 kHz, so the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently.
1. **Protocol crates** -- a Rust workspace of 7 library crates with a star dependency graph enabling parallel development The `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions.
2. **Client applications** -- Desktop (Tauri), Android (Kotlin + JNI), CLI, and Web (browser bridge)
3. **Relay infrastructure** -- SFU relay daemons with federation, health probing, and Prometheus metrics
### Design Principles **Bandwidth comparison with FEC overhead:**
- **User sovereignty** -- client-driven route selection, BIP39 identity backup, no central authority | Tier | Codec Bitrate | FEC Ratio | Total Bandwidth |
- **End-to-end encryption** -- relays never see plaintext audio; SFU forwarding preserves E2E encryption |------|--------------|-----------|----------------|
- **Adaptive resilience** -- automatic codec and FEC switching based on observed network quality | GOOD | 24 kbps | 20% | ~28.8 kbps |
- **Parallel development** -- star dependency graph allows 5 agents/developers to work simultaneously with zero merge conflicts | DEGRADED | 6 kbps | 50% | ~9.0 kbps |
| CATASTROPHIC | 1.2 kbps | 100% | ~2.4 kbps |
## Architecture At the catastrophic tier, the entire call (audio + FEC + headers) fits within approximately 3 kbps, which is viable even over severely degraded links.
### Crate Overview ## Why RaptorQ Over Reed-Solomon
The workspace contains 7 core crates plus integration binaries: **Reed-Solomon** is a classical block erasure code. It works well but has fixed-rate overhead: you must decide in advance how many repair symbols to generate, and decoding requires receiving exactly K of any K+R symbols.
| Crate | Purpose | Key Dependencies | **RaptorQ** (RFC 6330) is a fountain code with several advantages for VoIP:
|-------|---------|-----------------|
| `wzp-proto` | Protocol types, traits, wire format | serde, bytes |
| `wzp-codec` | Audio codecs (Opus, Codec2, RNNoise) | audiopus, codec2, nnnoiseless |
| `wzp-fec` | Forward error correction | raptorq |
| `wzp-crypto` | Cryptography and identity | ed25519-dalek, x25519-dalek, chacha20poly1305, bip39 |
| `wzp-transport` | QUIC transport layer | quinn, rustls |
| `wzp-relay` | Relay daemon (SFU, federation, metrics) | tokio, prometheus |
| `wzp-client` | Call engine and CLI | All above |
Additional integration targets: `wzp-web` (browser bridge via WebSocket), Android native library (JNI), Desktop (Tauri). 1. **Rateless**: You can generate an arbitrary number of repair symbols on the fly. If conditions worsen mid-block, you can generate additional repair without re-encoding.
### Dependency Graph 2. **Efficient decoding**: RaptorQ can decode from any K symbols with high probability (typically K + 1 or K + 2 suffice), compared to Reed-Solomon which requires exactly K.
```mermaid 3. **Lower computational complexity**: O(K) encoding and decoding time, compared to O(K^2) for Reed-Solomon. This matters for real-time audio at 50 frames/second.
graph TD
PROTO["wzp-proto<br/>(Types, Traits, Wire Format)"]
CODEC["wzp-codec<br/>(Opus + Codec2 + RNNoise)"] 4. **Variable block sizes**: The encoder handles 1-56403 source symbols per block (the WZP implementation uses 5-10, but the flexibility is there).
FEC["wzp-fec<br/>(RaptorQ FEC)"]
CRYPTO["wzp-crypto<br/>(ChaCha20 + Identity)"]
TRANSPORT["wzp-transport<br/>(QUIC / Quinn)"]
RELAY["wzp-relay<br/>(Relay Daemon)"] The `raptorq` crate (v2) provides a well-tested pure-Rust implementation. The WZP FEC layer adds length-prefixed padding (2-byte LE prefix + zero-pad to 256 bytes) so that variable-length audio frames can be recovered exactly.
CLIENT["wzp-client<br/>(CLI + Call Engine)"]
WEB["wzp-web<br/>(Browser Bridge)"]
DESKTOP["Desktop<br/>(Tauri + CPAL)"]
ANDROID["Android<br/>(Kotlin + JNI)"]
PROTO --> CODEC **FEC bandwidth math at different loss rates:**
PROTO --> FEC
PROTO --> CRYPTO
PROTO --> TRANSPORT
CODEC --> CLIENT
FEC --> CLIENT
CRYPTO --> CLIENT
TRANSPORT --> CLIENT
CODEC --> RELAY
FEC --> RELAY
CRYPTO --> RELAY
TRANSPORT --> RELAY
CLIENT --> WEB
CLIENT --> DESKTOP
CLIENT --> ANDROID
TRANSPORT --> WEB
FC["warzone-protocol<br/>(featherChat Identity)"] -.->|path dep| CRYPTO
style PROTO fill:#6c5ce7,color:#fff
style RELAY fill:#ff9f43,color:#fff
style CLIENT fill:#00b894,color:#fff
style WEB fill:#0984e3,color:#fff
style DESKTOP fill:#0984e3,color:#fff
style ANDROID fill:#0984e3,color:#fff
style FC fill:#fd79a8,color:#fff
```
The star pattern ensures each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`) depends only on `wzp-proto` and never on each other. This enables:
- **Parallel development** -- 5 agents work on 5 crates with no merge conflicts
- **Independent testing** -- each crate has self-contained tests
- **Pluggability** -- any implementation can be swapped by implementing the same trait
- **Fast compilation** -- changing one leaf only recompiles that leaf and integration crates
## Audio Pipeline
### Encode Pipeline (Mic to Network)
```mermaid
sequenceDiagram
participant Mic as Microphone
participant RNN as RNNoise Denoise
participant VAD as Silence Detector
participant ENC as Opus/Codec2 Encode
participant FEC as RaptorQ FEC Encode
participant INT as Interleaver
participant HDR as Header Assembly
participant CRYPT as ChaCha20-Poly1305
participant QUIC as QUIC Datagram
Mic->>RNN: PCM i16 x 960 (20ms @ 48kHz)
RNN->>VAD: Denoised samples (2 x 480)
alt Silence detected (>100ms)
VAD->>ENC: ComfortNoise packet (every 200ms)
else Active speech or hangover
VAD->>ENC: Active audio frame
end
ENC->>FEC: Compressed frame (padded to 256 bytes)
FEC->>FEC: Accumulate block (5-10 frames)
FEC->>INT: Source + repair symbols
INT->>HDR: Interleaved packets (depth=3)
HDR->>CRYPT: MediaHeader (12B) or MiniHeader (4B)
CRYPT->>QUIC: Header=AAD, Payload=encrypted
```
### Decode Pipeline (Network to Speaker)
```mermaid
sequenceDiagram
participant QUIC as QUIC Datagram
participant CRYPT as ChaCha20-Poly1305
participant HDR as Header Parse
participant DEINT as De-interleaver
participant FEC as RaptorQ FEC Decode
participant JIT as Jitter Buffer
participant DEC as Opus/Codec2 Decode
participant SPK as Speaker
QUIC->>CRYPT: Encrypted packet
CRYPT->>HDR: Decrypt (header=AAD)
HDR->>DEINT: Parsed MediaHeader + payload
DEINT->>FEC: Reordered symbols
FEC->>FEC: Reconstruct from any K of K+R symbols
FEC->>JIT: Recovered audio frames
JIT->>JIT: Sequence-ordered BTreeMap
JIT->>DEC: Pop when depth >= target
DEC->>SPK: PCM i16 x 960
```
## Codec System
WarzonePhone uses a dual-codec architecture to cover the full range of network conditions:
### Opus (Primary)
Opus is the primary codec for normal to degraded conditions. It operates at 48 kHz natively with built-in inband FEC and DTX (discontinuous transmission). The `audiopus` crate provides mature Rust bindings to libopus.
| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
|---------|---------|---------------|-----------|----------------|----------|
| Studio 64k | 64 kbps | 20ms | 10% | 70.4 kbps | LAN, excellent WiFi |
| Studio 48k | 48 kbps | 20ms | 10% | 52.8 kbps | Good WiFi, wired |
| Studio 32k | 32 kbps | 20ms | 10% | 35.2 kbps | WiFi, LTE |
| Good (24k) | 24 kbps | 20ms | 20% | 28.8 kbps | WiFi, LTE, decent links |
| Opus 16k | 16 kbps | 20ms | 20% | 19.2 kbps | 3G, moderate congestion |
| Degraded (6k) | 6 kbps | 40ms | 50% | 9.0 kbps | 3G, congested WiFi |
### Codec2 (Fallback)
Codec2 is a narrowband vocoder designed for HF radio links with extreme bandwidth constraints. It operates at 8 kHz, and the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently. The pure-Rust `codec2` crate means no C dependencies.
| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
|---------|---------|---------------|-----------|----------------|----------|
| Codec2 3200 | 3.2 kbps | 20ms | 50% | 4.8 kbps | Poor conditions |
| Catastrophic (1200) | 1.2 kbps | 40ms | 100% | 2.4 kbps | Satellite, extreme loss |
### ComfortNoise
When the silence detector identifies no speech activity for over 100ms, the encoder switches to emitting a ComfortNoise packet every 200ms instead of encoding silence. This provides approximately 50% bandwidth savings in typical conversations.
### Adaptive Switching
The `AdaptiveEncoder`/`AdaptiveDecoder` in `wzp-codec` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions. The `AdaptiveQualityController` in `wzp-proto` manages tier transitions with hysteresis:
- **Downgrade**: 3 consecutive bad reports (2 on cellular networks)
- **Upgrade**: 10 consecutive good reports (one tier at a time)
- **Network handoff**: WiFi-to-cellular switch triggers preemptive one-tier downgrade plus a temporary 10-second FEC boost (+20%)
Quality tier classification thresholds:
| Tier | WiFi/Unknown | Cellular |
|------|-------------|----------|
| Good | loss < 10%, RTT < 400ms | loss < 8%, RTT < 300ms |
| Degraded | loss 10-40%, RTT 400-600ms | loss 8-25%, RTT 300-500ms |
| Catastrophic | loss > 40%, RTT > 600ms | loss > 25%, RTT > 500ms |
## Forward Error Correction (FEC)
### Why RaptorQ Over Reed-Solomon
WarzonePhone uses RaptorQ (RFC 6330) fountain codes via the `raptorq` crate:
1. **Rateless** -- generate arbitrary repair symbols on the fly; if conditions worsen mid-block, generate additional repair without re-encoding
2. **Efficient decoding** -- decode from any K symbols with high probability (typically K + 1 or K + 2 suffice)
3. **Lower complexity** -- O(K) encoding/decoding time vs O(K^2) for Reed-Solomon
4. **Variable block sizes** -- 1-56,403 source symbols per block (WZP uses 5-10)
### FEC Block Structure
Each FEC block consists of 5-10 audio frames padded to 256-byte symbols with a 2-byte LE length prefix:
```
[len:u16 LE][audio_frame][zero_padding_to_256_bytes]
```
### Loss Survival by FEC Ratio
With 5 source frames per block: With 5 source frames per block:
- 20% repair (GOOD): 1 repair symbol. Survives loss of 1 out of 6 packets (16.7% loss).
- 50% repair (DEGRADED): 3 repair symbols. Survives loss of 3 out of 8 packets (37.5% loss).
- 100% repair (CATASTROPHIC): 5 repair symbols. Survives loss of 5 out of 10 packets (50% loss).
| FEC Ratio | Repair Symbols | Survives Loss | Profile | The benchmark (`wzp-bench --fec --loss 30`) dynamically scales the FEC ratio to survive the requested loss percentage.
|-----------|---------------|---------------|---------|
| 10% | 1 | 1 of 6 (16.7%) | Studio |
| 20% | 1 | 1 of 6 (16.7%) | Good |
| 50% | 3 | 3 of 8 (37.5%) | Degraded |
| 100% | 5 | 5 of 10 (50.0%) | Catastrophic |
### Interleaving ## Why QUIC Over Raw UDP
Burst loss protection via depth-3 interleaving: packets from 3 consecutive FEC blocks are interleaved before transmission. A burst of 3 consecutive lost packets affects 3 different blocks (1 loss each) rather than destroying 1 block entirely. Raw UDP would be simpler and lower-latency, but QUIC (via the `quinn` crate) provides:
```mermaid 1. **DATAGRAM frames**: Unreliable delivery without head-of-line blocking (RFC 9221). Media packets use this path, so they behave like UDP datagrams but benefit from QUIC's connection management.
graph LR
subgraph "FEC Encoder"
F1[Frame 1] --> BLK[Source Block<br/>5-10 frames]
F2[Frame 2] --> BLK
F3[Frame 3] --> BLK
F4[Frame 4] --> BLK
F5[Frame 5] --> BLK
BLK --> SRC[Source Symbols]
BLK --> REP[Repair Symbols<br/>ratio-dependent]
SRC --> INT[Interleaver<br/>depth=3]
REP --> INT
end
subgraph "Network" 2. **Reliable streams**: Signaling messages (CallOffer, CallAnswer, Rekey, Hangup) require reliable delivery. QUIC provides multiplexed streams without needing a separate TCP connection.
INT --> LOSS{Packet Loss}
LOSS -->|some lost| RCV[Received Symbols]
end
subgraph "FEC Decoder" 3. **Built-in congestion control**: QUIC's congestion control prevents overwhelming degraded links, which is important when chaining relays.
RCV --> DEINT[De-interleaver]
DEINT --> RAPTORQ[RaptorQ Decode<br/>Any K of K+R]
RAPTORQ --> OUT[Original Frames]
end
style LOSS fill:#e17055,color:#fff 4. **Connection migration**: QUIC connections survive IP address changes (e.g., WiFi to cellular handoff), which is valuable for mobile clients.
style RAPTORQ fill:#00b894,color:#fff
```
## Transport Layer 5. **TLS 1.3 built-in**: The QUIC handshake provides encryption at the transport level. While WZP has its own end-to-end ChaCha20 layer, the QUIC TLS protects the header and signaling from eavesdroppers.
### Why QUIC Over Raw UDP 6. **NAT keepalive**: QUIC's built-in keep-alive (configured at 5-second intervals) maintains NAT bindings without application-level pings.
WarzonePhone uses QUIC (via the `quinn` crate) rather than raw UDP for several reasons: 7. **Firewall traversal**: QUIC runs on UDP port 443 by default, which is commonly allowed through firewalls. The `wzp` ALPN protocol identifier distinguishes WZP traffic.
| Feature | Benefit | The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP (QUIC short header + DATAGRAM frame overhead).
|---------|---------|
| DATAGRAM frames (RFC 9221) | Unreliable delivery without head-of-line blocking -- behaves like UDP for media |
| Reliable streams | Multiplexed signaling (CallOffer, Hangup, Rekey) without a separate TCP connection |
| Congestion control | Prevents overwhelming degraded links, important when chaining relays |
| Connection migration | Connections survive IP address changes (WiFi to cellular handoff) |
| TLS 1.3 built-in | Transport-level encryption protects headers and signaling |
| NAT keepalive | 5-second interval maintains NAT bindings without application-level pings |
| Firewall traversal | Runs on UDP port 443 with `wzp` ALPN identifier |
The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP. ## Why ChaCha20-Poly1305 Over AES-GCM
### Wire Formats 1. **Software performance**: ChaCha20-Poly1305 is faster than AES-GCM on hardware without AES-NI instructions. This matters for ARM devices (Android phones, Raspberry Pi relays, embedded systems) where AES hardware acceleration may be absent.
#### MediaHeader (12 bytes) 2. **Constant-time by design**: ChaCha20 uses only add-rotate-XOR operations, making it inherently resistant to timing side-channel attacks. AES-GCM implementations without hardware support often require careful constant-time implementation.
3. **Warzone messenger compatibility**: The existing Warzone messenger uses ChaCha20-Poly1305 for message encryption. Reusing the same primitive simplifies the security audit and allows key material to be shared across messaging and calling.
4. **16-byte overhead**: Both ChaCha20-Poly1305 and AES-128-GCM produce a 16-byte authentication tag. There is no size advantage to AES-GCM.
5. **AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data (AAD), ensuring the header is authenticated but not encrypted. This allows relays to read routing information (block ID, sequence number) without decrypting the payload.
## Why Star Dependency Graph (Parallel Development)
The workspace follows a strict star dependency pattern:
``` ```
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1] wzp-proto (hub)
Byte 1: [FecRatioLo:6][unused:2] / | \ \
Bytes 2-3: sequence (u16 BE) wzp-codec wzp-fec wzp-crypto wzp-transport
Bytes 4-7: timestamp_ms (u32 BE) \ | / /
Byte 8: fec_block_id (u8) wzp-relay
Byte 9: fec_symbol_idx (u8) wzp-client
Byte 10: reserved wzp-web
Byte 11: csrc_count
V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
``` ```
#### MiniHeader (4 bytes, compressed) - `wzp-proto` defines all trait interfaces and wire format types
- Each "leaf" crate (codec, fec, crypto, transport) depends only on `wzp-proto`
- No leaf crate depends on another leaf crate
- Integration crates (relay, client, web) depend on all leaves
``` This enables:
Bytes 0-1: timestamp_delta_ms (u16 BE) 1. **Parallel development**: 5 agents/developers can work on 5 crates simultaneously with zero merge conflicts
Bytes 2-3: payload_len (u16 BE) 2. **Independent testing**: Each crate has comprehensive tests that run without requiring other implementations
3. **Pluggability**: Any implementation can be swapped (e.g., replace RaptorQ with Reed-Solomon) by implementing the same trait
4. **Fast compilation**: Changes to one leaf only recompile that leaf and the integration crates, not other leaves
Preceded by FRAME_TYPE_MINI (0x01). Full header every 50 frames (~1s). ## Jitter Buffer Trade-offs
Saves 8 bytes/packet (67% header reduction).
```
#### TrunkFrame (batched datagrams) The jitter buffer must balance two competing goals:
``` **Lower latency** (smaller buffer):
[count:u16] - Better conversational interactivity
[session_id:2][len:u16][payload:len] x count - Less memory usage
- But more vulnerable to jitter and reordering
Packs multiple session packets into one QUIC datagram. **Higher quality** (larger buffer):
Max 10 entries or 1200 bytes, flushed every 5ms. - More time to receive out-of-order packets
``` - More time for FEC recovery (repair packets may arrive after source packets)
- But adds perceptible delay to the conversation
#### QualityReport (4 bytes, optional trailer) The default configuration:
- Target: 10 packets (200ms) for the client, 50 packets (1s) for the relay
- Minimum: 3 packets (60ms) before playout begins (client), 25 packets (500ms) for relay
- Maximum: 250 packets (5s) absolute cap
``` The relay uses a deeper buffer because it needs to absorb jitter from the lossy inter-relay link. The client uses a shallower buffer for lower latency since it is on the last hop.
Byte 0: loss_pct (0-255 maps to 0-100%)
Byte 1: rtt_4ms (0-255 maps to 0-1020ms)
Byte 2: jitter_ms
Byte 3: bitrate_cap_kbps
```
### Bandwidth Summary **Known issue**: The current jitter buffer does not adapt its depth based on observed jitter. It uses sequence-number ordering only, without timestamp-based playout scheduling. This can lead to drift during long calls, as observed in echo tests.
| Profile | Audio | FEC Overhead | Total | Silence Savings | ## Browser Audio: AudioWorklet vs ScriptProcessorNode
|---------|-------|-------------|-------|----------------|
| Studio 64k | 64 kbps | 10% = 6.4 kbps | **70.4 kbps** | ~50% with DTX |
| Studio 48k | 48 kbps | 10% = 4.8 kbps | **52.8 kbps** | ~50% with DTX |
| Studio 32k | 32 kbps | 10% = 3.2 kbps | **35.2 kbps** | ~50% with DTX |
| Good (24k) | 24 kbps | 20% = 4.8 kbps | **28.8 kbps** | ~50% with DTX |
| Degraded (6k) | 6 kbps | 50% = 3.0 kbps | **9.0 kbps** | ~50% with DTX |
| Catastrophic (1.2k) | 1.2 kbps | 100% = 1.2 kbps | **2.4 kbps** | ~50% with DTX |
Additional savings: MiniHeaders save 8 bytes/packet (67% header reduction). Trunking shares QUIC overhead across multiplexed sessions. The web bridge (`crates/wzp-web/static/`) uses AudioWorklet as the primary audio I/O mechanism, with ScriptProcessorNode as a fallback.
## Security **AudioWorklet** (preferred):
- Runs on a dedicated audio rendering thread
- Lower latency (no main-thread round-trip)
- Consistent 128-sample callback timing
- Supported in Chrome 66+, Firefox 76+, Safari 14.1+
### Identity Model **ScriptProcessorNode** (fallback):
- Runs on the main thread via `onaudioprocess` callback
- Higher latency, potential glitches from main-thread GC pauses
- Deprecated by the Web Audio specification
- Used when AudioWorklet is not available
Every user has a persistent identity derived from a 32-byte seed: Both paths accumulate Float32 samples into 960-sample (20ms) Int16 frames before sending via WebSocket, matching the WZP codec frame size.
```mermaid **Playback** uses an AudioWorklet with a ring buffer capped at 200ms (9600 samples at 48 kHz). When the buffer exceeds this limit, old samples are dropped to prevent unbounded drift. The fallback path uses scheduled `AudioBufferSourceNode` instances.
graph TD
SEED["32-byte Seed<br/>(BIP39 Mnemonic: 24 words)"] --> HKDF1["HKDF<br/>info='warzone-ed25519'"]
SEED --> HKDF2["HKDF<br/>info='warzone-x25519'"]
HKDF1 --> ED["Ed25519 SigningKey<br/>(Digital Signatures)"] ## Room Mode: SFU vs MCU Trade-offs
HKDF2 --> X25519["X25519 StaticSecret<br/>(Key Agreement)"]
ED --> VKEY["Ed25519 VerifyingKey<br/>(Public)"] WarzonePhone implements an **SFU** (Selective Forwarding Unit) architecture:
X25519 --> XPUB["X25519 PublicKey<br/>(Public)"]
VKEY --> FP["Fingerprint<br/>SHA-256(pubkey), truncated 16 bytes<br/>xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"] **SFU** (implemented):
- Relay forwards each participant's packets to all other participants unchanged
- No transcoding -- the relay never decodes or re-encodes audio
- O(N) bandwidth at the relay for N participants (each packet is sent N-1 times)
- Each client receives separate streams from each other participant
- Client must mix/decode multiple streams locally
- Lower relay CPU usage (no transcoding)
- End-to-end encryption is preserved (relay never sees plaintext)
style SEED fill:#6c5ce7,color:#fff **MCU** (not implemented, for comparison):
style FP fill:#fd79a8,color:#fff - Relay would decode all streams, mix them, and re-encode a single combined stream
style ED fill:#ee5a24,color:#fff - O(1) bandwidth to each client (receives one mixed stream)
style X25519 fill:#00b894,color:#fff - Requires the relay to have codec keys (breaks E2E encryption)
``` - Higher relay CPU (decoding N streams + mixing + re-encoding)
- Audio quality loss from re-encoding
**BIP39 Mnemonic Backup**: The 32-byte seed can be encoded as a 24-word BIP39 mnemonic for human-readable backup. The same seed produces the same identity on any platform. The SFU choice is driven by the E2E encryption requirement: since relays never have access to the audio codec keys, they cannot decode, mix, or re-encode. The current room implementation in `crates/wzp-relay/src/room.rs` forwards received datagrams to all other participants in the room with best-effort delivery -- if one send fails, the relay continues to the next participant.
**featherChat Compatibility**: The identity derivation is compatible with the Warzone messenger (featherChat), allowing a shared identity across messaging and calling.
### Cryptographic Handshake
```mermaid
sequenceDiagram
participant C as Caller
participant R as Relay / Callee
Note over C: Derive identity from seed<br/>Ed25519 + X25519 via HKDF
C->>C: Generate ephemeral X25519 keypair
C->>C: Sign(ephemeral_pub || "call-offer")
C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
R->>R: Verify Ed25519 signature
R->>R: Generate ephemeral X25519 keypair
R->>R: shared_secret = DH(eph_b, eph_a)
R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
R->>R: Sign(ephemeral_pub || "call-answer")
R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, profile }
C->>C: Verify signature
C->>C: shared_secret = DH(eph_a, eph_b)
C->>C: session_key = HKDF(shared_secret)
Note over C,R: Both have identical ChaCha20-Poly1305 session key
C->>R: Encrypted media (QUIC datagrams)
R->>C: Encrypted media (QUIC datagrams)
Note over C,R: Rekey every 65,536 packets<br/>New ephemeral DH + HKDF mix
```
### Encryption Details
| Component | Algorithm | Purpose |
|-----------|-----------|---------|
| Identity signing | Ed25519 | Authenticate handshake messages |
| Key agreement | X25519 (ephemeral) | Derive shared secret |
| Key derivation | HKDF-SHA256 | Derive session key from shared secret |
| Media encryption | ChaCha20-Poly1305 | Encrypt audio payloads (16-byte tag) |
| Nonce construction | Deterministic from sequence number | No nonce reuse, no state sync needed |
| Anti-replay | Sliding window (64-packet) | Reject duplicate/old packets |
| Forward secrecy | Rekey every 65,536 packets | New ephemeral DH + HKDF mix |
**Why ChaCha20-Poly1305 over AES-GCM**:
- Faster on hardware without AES-NI (ARM phones, Raspberry Pi relays)
- Inherently constant-time (add-rotate-XOR only)
- Compatible with Warzone messenger (featherChat)
- Same 16-byte authentication tag overhead as AES-GCM
**AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data. The header is authenticated but not encrypted, allowing relays to read routing information (block ID, sequence number) without decrypting the payload.
### Trust on First Use (TOFU)
Clients remember the relay's TLS certificate fingerprint after first connection. If the fingerprint changes on a subsequent connection, the desktop client shows a "Server Key Changed" warning dialog. The relay derives its TLS certificate deterministically from its persisted identity seed, so the fingerprint is stable across restarts.
## Relay Architecture
### Room Mode (Default SFU)
In room mode, the relay acts as a Selective Forwarding Unit. Clients join named rooms via the QUIC SNI (Server Name Indication) field. The relay forwards each participant's encrypted packets to all other participants in the room without decoding or re-encoding.
```mermaid
graph TB
subgraph "Room Mode (SFU)"
C1[Client 1] -->|"QUIC SNI=room-hash"| RM[Room Manager]
C2[Client 2] -->|"QUIC SNI=room-hash"| RM
C3[Client 3] -->|"QUIC SNI=room-hash"| RM
RM --> R1[Room 'podcast']
R1 -->|fan-out| C1
R1 -->|fan-out| C2
R1 -->|fan-out| C3
end
style RM fill:#ff9f43,color:#fff
style R1 fill:#fdcb6e
```
**SFU vs MCU trade-off**: SFU was chosen because it preserves end-to-end encryption (the relay never sees plaintext audio). An MCU would need to decode, mix, and re-encode, breaking E2E encryption. The trade-off is O(N) bandwidth at the relay for N participants.
### Forward Mode
With `--remote`, the relay forwards all traffic to a remote relay. Used for chaining relays across lossy or censored links:
```
Client --> Relay A (--remote B) --> Relay B --> Destination Client
```
The relay pipeline in forward mode: FEC decode, jitter buffer, then FEC re-encode for the next hop.
## Federation
### Overview
Two or more relays form a federation mesh. Each relay is an independent SFU. When configured to trust each other, they bridge **global rooms** -- participants on relay A in a global room hear participants on relay B in the same room.
### Configuration
Federation uses three TOML configuration sections:
- `[[peers]]` -- outbound connections to peer relays (url + TLS fingerprint)
- `[[trusted]]` -- inbound connections accepted from relays (TLS fingerprint only)
- `[[global_rooms]]` -- room names to bridge across all federated peers
### Federation Topology
```mermaid
graph TB
subgraph "Relay A (EU)"
A_RM[Room Manager]
A_FM[Federation Manager]
A1[Alice - local]
A2[Bob - local]
A_RM --> A_FM
end
subgraph "Relay B (US)"
B_RM[Room Manager]
B_FM[Federation Manager]
B1[Charlie - local]
B_RM --> B_FM
end
A_FM <-->|"QUIC SNI='_federation'<br/>GlobalRoomActive/Inactive<br/>Media forwarding"| B_FM
A1 -->|media| A_RM
A2 -->|media| A_RM
B1 -->|media| B_RM
A_RM -->|"federated fan-out"| A1
A_RM -->|"federated fan-out"| A2
B_RM -->|"federated fan-out"| B1
style A_FM fill:#6c5ce7,color:#fff
style B_FM fill:#6c5ce7,color:#fff
style A_RM fill:#ff9f43,color:#fff
style B_RM fill:#ff9f43,color:#fff
```
### Protocol
1. On startup, each relay connects to all configured `[[peers]]` via QUIC with SNI `"_federation"`
2. After QUIC handshake, sends `FederationHello { tls_fingerprint }` for identity verification
3. Peer verifies the fingerprint against its `[[trusted]]` or `[[peers]]` list
4. When a local participant joins a global room, sends `GlobalRoomActive { room }` to all peers
5. When the last local participant leaves, sends `GlobalRoomInactive { room }`
6. Media is forwarded as `[room_hash:8][original_media_packet]` -- the relay does not decrypt
### What Relays Do NOT Do
- **No transcoding** -- media passes through as-is
- **No re-encryption** -- packets are already encrypted E2E
- **No central coordinator** -- each relay independently connects to configured peers
- **No automatic peer discovery** -- peers must be explicitly configured
### Failure Handling
- If a peer goes down, local rooms continue working; federated participants disappear from presence
- Reconnection: every 30 seconds with exponential backoff up to 5 minutes
- If a peer restarts with a different identity, the fingerprint check fails with a clear log message
## Jitter Buffer
The jitter buffer balances latency vs quality:
| Setting | Client | Relay |
|---------|--------|-------|
| Target depth | 10 packets (200ms) | 50 packets (1s) |
| Minimum before playout | 3 packets (60ms) | 25 packets (500ms) |
| Maximum cap | 250 packets (5s) | 250 packets (5s) |
The relay uses a deeper buffer to absorb jitter from lossy inter-relay links. The client uses a shallower buffer for lower latency.
The adaptive playout delay tracks jitter via exponential moving average and adjusts the target depth:
```
target_delay = ceil(jitter_ema / 20ms) + 2
```
**Known limitation**: The current jitter buffer does not use timestamp-based playout scheduling. It relies on sequence-number ordering only, which can lead to drift during long calls.
## Signal Messages
Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
```
[4-byte length prefix][serde_json payload]
```
| Message | Purpose |
|---------|---------|
| `CallOffer` | Identity, ephemeral key, signature, supported profiles |
| `CallAnswer` | Identity, ephemeral key, signature, chosen profile |
| `AuthToken` | featherChat bearer token for relay authentication |
| `Hangup` | Reason: Normal, Busy, Declined, Timeout, Error |
| `Hold` / `Unhold` | Call hold state |
| `Mute` / `Unmute` | Mic mute state |
| `Transfer` | Call transfer to another relay/fingerprint |
| `Rekey` | New ephemeral key for forward secrecy |
| `QualityUpdate` | Quality report + recommended profile |
| `Ping` / `Pong` | Latency measurement (timestamp_ms) |
| `RoomUpdate` | Participant list changes |
| `PresenceUpdate` | Federation presence gossip |
| `RouteQuery` / `RouteResponse` | Presence discovery for routing |
| `FederationHello` | Relay identity during federation setup |
| `GlobalRoomActive` / `GlobalRoomInactive` | Federation room bridging |
## Test Coverage
272 tests across all crates, 0 failures:
| Crate | Tests | Key Coverage |
|-------|-------|-------------|
| wzp-proto | 41 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
| wzp-codec | 31 | Opus/Codec2 roundtrip, silence detection, noise suppression |
| wzp-fec | 22 | RaptorQ encode/decode, loss recovery, interleaving |
| wzp-crypto | 34 + 28 compat | Encrypt/decrypt, handshake, anti-replay, featherChat identity |
| wzp-transport | 2 | QUIC connection setup |
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics |
## Build Requirements
- **Rust** 1.85+ (2024 edition)
- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
- **macOS**: Xcode command line tools (CoreAudio included)
- **Android**: NDK r27c, cmake 3.28+ (from pip)

View File

@@ -1,201 +0,0 @@
# PRD: Adaptive Quality Control (Auto Codec)
## Problem
When a user selects "Auto" quality, the system currently just starts at Opus 24k (GOOD) and never changes. There is no runtime adaptation — if the network degrades mid-call, audio breaks up instead of gracefully stepping down to a lower bitrate codec. Conversely, if the network is excellent, the user stays on 24k when they could have studio-quality 64k.
The relay already sends `QualityReport` messages with loss % and RTT, and a `QualityAdapter` exists in `call.rs` that classifies network conditions into GOOD/DEGRADED/CATASTROPHIC — but none of this is wired into the Android or desktop engines.
## Solution
Wire the existing `QualityAdapter` into both engines so that "Auto" mode continuously monitors network quality and switches codecs mid-call. The full quality range should be used:
```
Excellent network → Studio 64k (best quality)
Good network → Opus 24k (default)
Degraded network → Opus 6k (lower bitrate, more FEC)
Poor network → Codec2 3.2k (vocoder, heavy FEC)
Catastrophic → Codec2 1.2k (minimum viable voice)
```
## Architecture
```
┌─────────────────────┐
Relay ──────────► │ QualityReport │ loss %, RTT, jitter
│ (every ~1s) │
└────────┬────────────┘
┌─────────────────────┐
│ QualityAdapter │ classify + hysteresis
│ (3-report window) │
└────────┬────────────┘
│ recommend new profile
┌──────────────┴──────────────┐
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Encoder │ │ Decoder │
│ set_profile() │ │ (auto-switch │
│ + FEC update │ │ already works)│
└────────────────┘ └────────────────┘
```
## Existing Infrastructure
### What already exists (in `crates/wzp-client/src/call.rs`)
1. **`QualityAdapter`** (lines 97-196):
- Sliding window of `QualityReport` messages
- `classify()`: loss > 15% or RTT > 200ms → CATASTROPHIC, loss > 5% or RTT > 100ms → DEGRADED, else → GOOD
- `should_switch()`: hysteresis — requires 3 consecutive reports recommending the same profile before switching
- Prevents oscillation between profiles
2. **`QualityReport`** (in `wzp-proto/src/packet.rs`):
- Sent by relay piggy-backed on media packets
- Fields: `loss_pct` (u8, 0-255 scaled), `rtt_4ms` (u8, RTT in 4ms units), `jitter_ms`, `bitrate_cap_kbps`
3. **`CallEncoder::set_profile()`** / **`CallDecoder` auto-switch**:
- Encoder can switch codec mid-stream
- Decoder already auto-detects incoming codec from packet headers
### What's missing
1. **QualityReport ingestion** — neither Android engine nor desktop engine reads quality reports from the relay
2. **Profile switch loop** — no periodic check that feeds reports to `QualityAdapter` and applies recommended switches
3. **Upward adaptation**`QualityAdapter` only classifies into 3 tiers (GOOD/DEGRADED/CATASTROPHIC). Needs extension to recommend studio tiers when conditions are excellent (loss < 1%, RTT < 50ms)
4. **Notification to UI** — when quality changes, the UI should show the current active codec
## Requirements
### Phase 1: Basic Adaptive (3-tier)
**Both Android and Desktop:**
1. **Ingest QualityReports**: In the recv loop, extract `quality_report` from incoming `MediaPacket`s when present. Feed to `QualityAdapter`.
2. **Periodic quality check**: Every 1 second (or on each QualityReport), call `adapter.should_switch(&current_profile)`. If it returns `Some(new_profile)`:
- Switch the encoder: `encoder.set_profile(new_profile)`
- Update FEC encoder: `fec_enc = create_encoder(&new_profile)`
- Update frame size if changed (e.g., 20ms → 40ms)
- Log the switch
3. **Frame size adaptation on switch**: When switching from 20ms to 40ms frames (or vice versa):
- Android: update `frame_samples` variable, resize `capture_buf`
- Desktop: same — the send loop reads `frame_samples` dynamically
4. **UI indicator**: Show current active codec in the call screen stats line.
- Android: add to `CallStats` and display in stats text
- Desktop: add to `get_status` response and display in stats div
5. **Only in Auto mode**: Adaptive switching should only happen when the user selected "Auto". If they manually selected a profile, respect their choice.
### Phase 2: Extended Range (5-tier)
Extend `QualityAdapter::classify()` to use the full codec range:
| Condition | Profile | Codec |
|-----------|---------|-------|
| loss < 1% AND RTT < 30ms | STUDIO_64K | Opus 64k |
| loss < 1% AND RTT < 50ms | STUDIO_48K | Opus 48k |
| loss < 2% AND RTT < 80ms | STUDIO_32K | Opus 32k |
| loss < 5% AND RTT < 100ms | GOOD | Opus 24k |
| loss < 15% AND RTT < 200ms | DEGRADED | Opus 6k |
| loss >= 15% OR RTT >= 200ms | CATASTROPHIC | Codec2 1.2k |
With hysteresis:
- **Downgrade**: 3 consecutive reports (fast reaction to degradation)
- **Upgrade**: 5 consecutive reports (slow, cautious improvement)
- **Studio upgrade**: 10 consecutive reports (very conservative — avoid bouncing to 64k on brief good patches)
### Phase 3: Bandwidth Probing
Rather than relying solely on loss/RTT:
1. Start at GOOD
2. After 10 seconds of stable call, probe upward by switching to STUDIO_32K
3. If no quality degradation after 5 seconds, probe to STUDIO_48K
4. If degradation detected, immediately fall back
5. This discovers the true available bandwidth rather than guessing from loss stats
## Implementation Plan
### Android (`crates/wzp-android/src/engine.rs`)
```rust
// In the recv loop, after decoding:
if let Some(ref qr) = pkt.quality_report {
quality_adapter.ingest(qr);
}
// Periodic check (every 50 frames ≈ 1 second):
if auto_profile && frames_decoded % 50 == 0 {
if let Some(new_profile) = quality_adapter.should_switch(&current_profile) {
info!(from = ?current_profile.codec, to = ?new_profile.codec, "auto: switching quality");
let _ = encoder_ref.lock().set_profile(new_profile);
fec_enc_ref.lock() = create_encoder(&new_profile);
current_profile = new_profile;
frame_samples = frame_samples_for(&new_profile);
// Resize capture buffer if needed
}
}
```
**Challenge**: The encoder is in the send task and the quality reports arrive in the recv task. Need shared state (AtomicU8 for profile index, or a channel).
**Recommended approach**: Use an `AtomicU8` that the recv task writes and the send task reads:
```rust
let pending_profile = Arc::new(AtomicU8::new(0xFF)); // 0xFF = no change
// Recv task: when adapter recommends switch
pending_profile.store(new_profile_index, Ordering::Release);
// Send task: check at frame boundary
let p = pending_profile.swap(0xFF, Ordering::Acquire);
if p != 0xFF { /* apply switch */ }
```
### Desktop (`desktop/src-tauri/src/engine.rs`)
Same pattern. The desktop engine already has separate send/recv tasks with shared atomics for mic_muted, etc. Add a `pending_profile: Arc<AtomicU8>` following the same pattern.
### Desktop CLI (`crates/wzp-client/src/call.rs`)
The `CallEncoder` already has `set_profile()`. The `CallDecoder` already auto-switches. Just need to:
1. Add `QualityAdapter` to `CallDecoder`
2. Feed quality reports in `ingest()`
3. Check `should_switch()` in `decode_next()`
4. Emit the recommendation via a callback or return value
## Testing
1. **Local test with tc/netem**: Use Linux traffic control to simulate loss/latency:
```bash
# Simulate 10% loss, 150ms RTT
tc qdisc add dev lo root netem loss 10% delay 75ms
# Run 2 clients in auto mode, verify they switch to DEGRADED
```
2. **CLI test**: Run `wzp-client --profile auto` between two instances with simulated network conditions
3. **Relay quality reports**: Verify the relay actually sends QualityReport messages. If it doesn't yet, that needs to be implemented first (check relay code).
## Open Questions
1. **Does the relay currently send QualityReports?** If not, Phase 1 is blocked until the relay implements per-client loss/RTT tracking and report generation. The relay sees all packets and can compute loss % per sender.
2. **Codec2 3.2k placement**: Should auto mode use Codec2 3.2k between DEGRADED and CATASTROPHIC? It's 20ms frames (lower latency than Opus 6k's 40ms) but speech-only quality.
3. **Cross-client adaptation**: If client A is on GOOD and client B auto-adapts to CATASTROPHIC, client A still sends Opus 24k. Client B can decode it fine (auto-switch on recv). But should A also be told to lower quality to save B's bandwidth? This requires signaling between clients.
## Milestones
| Phase | Scope | Effort | Dependency |
|-------|-------|--------|------------|
| 0 | Verify relay sends QualityReports | 0.5 day | None |
| 1a | Wire QualityAdapter in Android engine | 1 day | Phase 0 |
| 1b | Wire QualityAdapter in desktop engine | 1 day | Phase 0 |
| 1c | UI indicator (current codec) | 0.5 day | Phase 1a/1b |
| 2 | Extended 5-tier classification | 0.5 day | Phase 1 |
| 3 | Bandwidth probing | 2 days | Phase 2 |

View File

@@ -1,198 +0,0 @@
# PRD: Coordinated Codec Switching (Relay-Judged Quality)
## Problem
The current adaptive quality system (`QualityAdapter` in call.rs) exists but isn't wired into either engine. Clients encode at a fixed quality chosen at call start. When network conditions change mid-call, audio degrades instead of gracefully stepping down. When conditions improve, clients stay on low quality unnecessarily.
Additionally, in SFU mode with multiple participants, uncoordinated codec switching creates asymmetry: if client A upgrades to 64k while B stays on 24k, bandwidth is wasted. Participants should switch together.
## Solution
The **relay acts as the quality judge** since it sees both sides of every connection. It monitors packet loss, jitter, and RTT per participant, then signals quality recommendations. Clients react to these signals with coordinated codec switches.
## Architecture
```
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client A │◄──────►│ Relay │◄──────►│ Client B │
│ │ │ (judge) │ │ │
│ Encoder │ │ │ │ Encoder │
│ Decoder │ │ Monitor │ │ Decoder │
└─────────┘ │ per-peer│ └─────────┘
│ quality │
└────┬────┘
Quality Signals:
- StableSignal (conditions good)
- DegradeSignal (conditions bad)
- UpgradeProposal (try higher quality?)
- UpgradeConfirm (all agreed, switch at T)
```
## Quality Classification (Relay-Side)
The relay monitors each participant's connection quality:
| Condition | Classification | Action |
|-----------|---------------|--------|
| loss >= 15% OR RTT >= 200ms | Critical | Immediate downgrade signal |
| loss >= 5% OR RTT >= 100ms | Degraded | Downgrade signal after 3 reports |
| loss < 2% AND RTT < 80ms | Good | Stable signal |
| loss < 1% AND RTT < 50ms for 30s | Excellent | Upgrade proposal |
| loss < 0.5% AND RTT < 30ms for 60s | Studio | Studio upgrade proposal |
## Coordinated Switching Protocol
### Downgrade (fast, safety-first)
1. Relay detects degradation for ANY participant
2. Relay sends `QualityUpdate { recommended_profile: DEGRADED }` to ALL participants
3. ALL participants immediately switch encoder to the recommended profile
4. No negotiation — downgrade is mandatory and instant
### Upgrade (slow, consensual)
1. Relay detects sustained good conditions for ALL participants (threshold: 30s stable)
2. Relay sends `UpgradeProposal { target_profile, switch_timestamp }` to all
3. Each client responds: `UpgradeAccept` or `UpgradeReject`
4. If ALL accept within 5s → Relay sends `UpgradeConfirm { profile, switch_at_ms }`
5. All clients switch encoder at the agreed timestamp (relative to session clock)
6. If ANY rejects or times out → upgrade cancelled, stay on current profile
### Asymmetric Encoding (SFU optimization)
In SFU mode, each client encodes independently. The relay could allow:
- Client A (strong connection): encode at 64k
- Client B (weak connection): encode at 6k
- Relay forwards A's 64k to B's decoder (auto-switch handles it)
- B benefits from A's quality without needing to send at 64k
This requires NO protocol changes — just each client independently following the relay's recommendation for their own encoding quality. The decoder already handles any codec.
### Split Network Consideration
If participant A has great quality but participant C has terrible quality:
- Option 1: **Match weakest link** — everyone encodes at C's level (current approach, simple)
- Option 2: **Per-participant recommendations** — A encodes at 64k, C encodes at 6k. B (good connection) receives and decodes both. Works because decoders auto-switch per packet.
- Option 3: **Relay transcoding** — relay re-encodes A's 64k as 6k for C. Adds CPU on relay, but saves bandwidth for C. Future feature.
Recommended: start with Option 1 (match weakest), add Option 2 later.
## Signal Messages (New/Modified)
```rust
/// Quality signal from relay to client
QualityDirective {
/// Recommended profile to use for encoding
recommended_profile: QualityProfile,
/// Reason for the recommendation
reason: QualityReason,
}
enum QualityReason {
/// Network conditions require this quality level
NetworkCondition,
/// Coordinated upgrade — all participants agreed
CoordinatedUpgrade,
/// Coordinated downgrade — weakest link determines level
CoordinatedDowngrade,
}
/// Upgrade proposal from relay
UpgradeProposal {
target_profile: QualityProfile,
/// Milliseconds from now when the switch would happen
switch_delay_ms: u32,
}
/// Client response to upgrade proposal
UpgradeResponse {
accepted: bool,
}
/// Confirmed upgrade — all clients switch at this time
UpgradeConfirm {
profile: QualityProfile,
/// Session-relative timestamp to switch (ms since call start)
switch_at_session_ms: u64,
}
```
## Relay-Side Implementation
### Per-Participant Quality Tracking
```rust
struct ParticipantQuality {
/// Sliding window of recent observations
loss_samples: VecDeque<f32>, // last 30 seconds
rtt_samples: VecDeque<u32>, // last 30 seconds
jitter_samples: VecDeque<u32>,
/// Current classification
classification: QualityClass,
/// How long current classification has been stable
stable_since: Instant,
}
```
### Quality Monitor Task (on relay)
Runs alongside the SFU forwarding loop:
1. Every 1 second, compute per-participant quality from QUIC connection stats
2. Classify each participant
3. If ANY participant degrades → send downgrade to ALL
4. If ALL participants stable for threshold → propose upgrade
5. Track upgrade negotiation state
### Integration with Existing Code
The relay already has access to:
- `QuinnTransport::path_quality()` → loss, RTT, jitter, bandwidth estimates
- `QualityReport` embedded in media packet headers
- Per-session metrics in `RelayMetrics`
The quality monitor just needs to read these existing metrics and produce signals.
## Client-Side Implementation
### Handling Quality Signals
In the recv loop (both Android engine and desktop engine):
```rust
SignalMessage::QualityDirective { recommended_profile, .. } => {
// Immediate: switch encoder to recommended profile
encoder.set_profile(recommended_profile)?;
fec_enc = create_encoder(&recommended_profile);
frame_samples = frame_samples_for(&recommended_profile);
info!(codec = ?recommended_profile.codec, "quality directive: switched");
}
```
### P2P Quality (simpler case)
For P2P calls (no relay), both clients directly observe quality:
1. Each client runs its own `QualityAdapter` on the direct connection
2. When quality changes, client proposes to peer via signal
3. Simpler negotiation: only 2 parties, no relay middleman
4. Same coordinated switching logic, just peer-to-peer signals
## Backporting P2P → Relay
The quality monitoring and codec switching logic is identical:
- **P2P**: client observes quality directly → proposes switch to peer
- **Relay**: relay observes quality → proposes switch to all clients
The only difference is WHO makes the decision (client vs relay) and HOW many participants need to agree (2 vs N).
Implementation strategy: build for P2P first (simpler, 2 parties), then wrap the same logic with relay-mediated signals for SFU mode.
## Milestones
| Phase | Scope | Effort |
|-------|-------|--------|
| 1 | Relay-side quality monitor (per-participant tracking) | 1 day |
| 2 | Downgrade signal (immediate, match weakest) | 1 day |
| 3 | Client handling of QualityDirective | 1 day (both engines) |
| 4 | Upgrade proposal + negotiation protocol | 2 days |
| 5 | P2P quality adaptation (direct observation) | 1 day |
| 6 | Per-participant asymmetric encoding (Option 2) | 1 day |

Some files were not shown because too many files have changed in this diff Show More