Compare commits
7 Commits
feat/deskt
...
feature/wz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d33f3ed4e | ||
|
|
2de6e19956 | ||
|
|
ec437afbce | ||
|
|
137e7973c4 | ||
|
|
55d4004f86 | ||
|
|
09a18b086b | ||
|
|
f3c8e11995 |
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: caveman
|
|
||||||
description: >
|
|
||||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
|
||||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
|
||||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
|
||||||
when token efficiency is requested.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Caveman Mode
|
|
||||||
|
|
||||||
## Core Rule
|
|
||||||
|
|
||||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
|
||||||
|
|
||||||
## Grammar
|
|
||||||
|
|
||||||
- Drop articles (a, an, the)
|
|
||||||
- Drop filler (just, really, basically, actually, simply)
|
|
||||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
|
||||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
|
||||||
- No hedging (skip "it might be worth considering")
|
|
||||||
- Fragments fine. No need full sentence
|
|
||||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
|
||||||
- Code blocks unchanged. Caveman speak around code, not in code
|
|
||||||
- Error messages quoted exact. Caveman only for explanation
|
|
||||||
|
|
||||||
## Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
[thing] [action] [reason]. [next step].
|
|
||||||
```
|
|
||||||
|
|
||||||
Not:
|
|
||||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
|
||||||
|
|
||||||
Yes:
|
|
||||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
**User:** Why is my React component re-rendering?
|
|
||||||
|
|
||||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
|
||||||
|
|
||||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**User:** How do I set up a PostgreSQL connection pool?
|
|
||||||
|
|
||||||
**Caveman:**
|
|
||||||
```
|
|
||||||
Use `pg` pool:
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
const pool = new Pool({
|
|
||||||
max: 20,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 2000,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
```
|
|
||||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Code: write normal. Caveman English only
|
|
||||||
- Git commits: normal
|
|
||||||
- PR descriptions: normal
|
|
||||||
- User say "stop caveman" or "normal mode": revert immediately
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[target.aarch64-linux-android]
|
|
||||||
linker = "aarch64-linux-android26-clang"
|
|
||||||
|
|
||||||
[target.armv7-linux-androideabi]
|
|
||||||
linker = "armv7a-linux-androideabi26-clang"
|
|
||||||
@@ -2,57 +2,187 @@ name: Build Release Binaries
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'feat/*'
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
paths-ignore:
|
|
||||||
- '.gitea/**'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
targets:
|
||||||
|
description: 'Targets to build (comma-separated: amd64,arm64,armv7,mac-arm64)'
|
||||||
|
required: false
|
||||||
|
default: 'amd64'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Always builds on push tags. On manual dispatch, reads inputs.
|
||||||
build-amd64:
|
build-amd64:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
contains(github.event.inputs.targets, 'amd64')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: rust:1-bookworm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Init submodules
|
- name: Install dependencies
|
||||||
run: |
|
run: apt-get update && apt-get install -y cmake pkg-config libasound2-dev
|
||||||
git config --global url."https://git.manko.yoga/".insteadOf "ssh://git@git.manko.yoga:222/"
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
- name: Install Rust + dependencies
|
- name: Cache cargo
|
||||||
run: |
|
uses: actions/cache@v4
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
with:
|
||||||
source "$HOME/.cargo/env"
|
path: |
|
||||||
apt-get update && apt-get install -y cmake pkg-config libasound2-dev ninja-build
|
~/.cargo/registry
|
||||||
rustc --version
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: cargo-amd64-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-amd64-
|
||||||
|
|
||||||
- name: Build relay + tools
|
- name: Build headless binaries
|
||||||
|
run: cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||||
|
|
||||||
|
- name: Build audio client
|
||||||
run: |
|
run: |
|
||||||
source "$HOME/.cargo/env"
|
cargo build --release --bin wzp-client --features audio
|
||||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
cp target/release/wzp-client target/release/wzp-client-audio
|
||||||
|
cargo build --release --bin wzp-client
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: cargo test --workspace --lib
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
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 artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wzp-linux-amd64
|
||||||
|
path: dist/wzp-linux-amd64.tar.gz
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
contains(github.event.inputs.targets, 'arm64')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: rust:1-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
run: |
|
||||||
|
dpkg --add-architecture arm64
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y cmake pkg-config gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||||
|
rustup target add aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: cargo-arm64-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-arm64-
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
|
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||||
|
run: |
|
||||||
|
cargo build --release --target aarch64-unknown-linux-gnu \
|
||||||
|
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/wzp-linux-arm64
|
||||||
|
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
|
||||||
|
cp target/aarch64-unknown-linux-gnu/release/wzp-client dist/wzp-linux-arm64/
|
||||||
|
cp target/aarch64-unknown-linux-gnu/release/wzp-web dist/wzp-linux-arm64/
|
||||||
|
cp target/aarch64-unknown-linux-gnu/release/wzp-bench dist/wzp-linux-arm64/
|
||||||
|
cp -r crates/wzp-web/static dist/wzp-linux-arm64/
|
||||||
|
cd dist && tar czf wzp-linux-arm64.tar.gz wzp-linux-arm64/
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wzp-linux-arm64
|
||||||
|
path: dist/wzp-linux-arm64.tar.gz
|
||||||
|
|
||||||
|
build-armv7:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
contains(github.event.inputs.targets, 'armv7')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: rust:1-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
run: |
|
||||||
|
dpkg --add-architecture armhf
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y cmake pkg-config gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||||
|
rustup target add armv7-unknown-linux-gnueabihf
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: cargo-armv7-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-armv7-
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||||
|
CC_armv7_unknown_linux_gnueabihf: arm-linux-gnueabihf-gcc
|
||||||
|
run: |
|
||||||
|
cargo build --release --target armv7-unknown-linux-gnueabihf \
|
||||||
|
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/wzp-linux-armv7
|
||||||
|
cp target/armv7-unknown-linux-gnueabihf/release/wzp-relay dist/wzp-linux-armv7/
|
||||||
|
cp target/armv7-unknown-linux-gnueabihf/release/wzp-client dist/wzp-linux-armv7/
|
||||||
|
cp target/armv7-unknown-linux-gnueabihf/release/wzp-web dist/wzp-linux-armv7/
|
||||||
|
cp target/armv7-unknown-linux-gnueabihf/release/wzp-bench dist/wzp-linux-armv7/
|
||||||
|
cp -r crates/wzp-web/static dist/wzp-linux-armv7/
|
||||||
|
cd dist && tar czf wzp-linux-armv7.tar.gz wzp-linux-armv7/
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wzp-linux-armv7
|
||||||
|
path: dist/wzp-linux-armv7.tar.gz
|
||||||
|
|
||||||
|
# Release job — creates a release with all artifacts when a tag is pushed
|
||||||
|
release:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: [build-amd64]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: artifacts/**/*.tar.gz
|
||||||
|
generate_release_notes: true
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Mirror to GitHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'feat/*'
|
|
||||||
- 'feature/*'
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mirror:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Push to GitHub
|
|
||||||
env:
|
|
||||||
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
git remote add github git@github.com:manawenuz/wzp.git
|
|
||||||
|
|
||||||
# Push the current branch
|
|
||||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
|
||||||
|
|
||||||
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
|
||||||
echo "Pushing tag: ${TAG}"
|
|
||||||
git push github "refs/tags/${TAG}" --force
|
|
||||||
else
|
|
||||||
echo "Pushing branch: ${BRANCH}"
|
|
||||||
git push github "HEAD:refs/heads/${BRANCH}" --force
|
|
||||||
fi
|
|
||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,28 +4,3 @@
|
|||||||
*.swp
|
*.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
|
|
||||||
|
|||||||
3264
Cargo.lock
generated
3264
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
40
Cargo.toml
40
Cargo.toml
@@ -9,9 +9,7 @@ members = [
|
|||||||
"crates/wzp-relay",
|
"crates/wzp-relay",
|
||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-wasm",
|
||||||
"crates/wzp-native",
|
|
||||||
"desktop/src-tauri",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -42,7 +40,7 @@ codec2 = "0.3"
|
|||||||
|
|
||||||
# Crypto
|
# Crypto
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core", "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"
|
||||||
@@ -55,37 +53,3 @@ wzp-fec = { path = "crates/wzp-fec" }
|
|||||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
wzp-transport = { path = "crates/wzp-transport" }
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
wzp-client = { path = "crates/wzp-client" }
|
wzp-client = { path = "crates/wzp-client" }
|
||||||
|
|
||||||
# Fast dev profile: optimized but with debug info and incremental compilation.
|
|
||||||
# Use with: cargo run --profile dev-fast
|
|
||||||
[profile.dev-fast]
|
|
||||||
inherits = "dev"
|
|
||||||
opt-level = 2
|
|
||||||
|
|
||||||
# Optimize heavy compute deps even in debug builds —
|
|
||||||
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
|
||||||
[profile.dev.package.nnnoiseless]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.audiopus_sys]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.audiopus]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.raptorq]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.wzp-codec]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.wzp-fec]
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
# Vendored audiopus_sys with a patched opus/CMakeLists.txt that distinguishes
|
|
||||||
# real cl.exe (MSVC) from clang-cl (used by cargo-xwin for Windows cross-
|
|
||||||
# compiles). Upstream libopus 1.3.1 gates its `-msse4.1` per-file compile
|
|
||||||
# flags on `if(NOT MSVC)`, which is false under clang-cl because CMake sets
|
|
||||||
# MSVC=1 for both compilers — resulting in SSE4.1 source files compiled
|
|
||||||
# without the required target feature and hard failures in silk/NSQ_sse4_1.c.
|
|
||||||
# The vendored copy introduces an `MSVC_CL` var (true only for real cl.exe)
|
|
||||||
# and flips the SIMD guards to use it, restoring per-file SIMD flags for
|
|
||||||
# clang-cl. See vendor/audiopus_sys/opus/CMakeLists.txt for the full diff
|
|
||||||
# and rationale, plus xiph/opus#256 / xiph/opus PR #257 upstream.
|
|
||||||
[patch.crates-io]
|
|
||||||
audiopus_sys = { path = "vendor/audiopus_sys" }
|
|
||||||
|
|||||||
6
android/.gitignore
vendored
6
android/.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
app/build/
|
|
||||||
app/src/main/jniLibs/
|
|
||||||
local.properties
|
|
||||||
keystore/*.jks
|
|
||||||
Binary file not shown.
@@ -1,85 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.wzp.phone"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.wzp.phone"
|
|
||||||
minSdk = 26 // AAudio requires API 26
|
|
||||||
targetSdk = 34
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
ndk { abiFilters += listOf("arm64-v8a") }
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file("${project.rootDir}/keystore/wzp-release.jks")
|
|
||||||
storePassword = "wzphone2024"
|
|
||||||
keyAlias = "wzp-release"
|
|
||||||
keyPassword = "wzphone2024"
|
|
||||||
}
|
|
||||||
getByName("debug") {
|
|
||||||
storeFile = file("${project.rootDir}/keystore/wzp-debug.jks")
|
|
||||||
storePassword = "android"
|
|
||||||
keyAlias = "wzp-debug"
|
|
||||||
keyPassword = "android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
isDebuggable = true
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures { compose = true }
|
|
||||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
|
||||||
|
|
||||||
ndkVersion = "26.1.10909125"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cargo-ndk integration: build the Rust native library for Android targets
|
|
||||||
tasks.register<Exec>("cargoNdkBuild") {
|
|
||||||
workingDir = file("${project.rootDir}/..")
|
|
||||||
commandLine(
|
|
||||||
"cargo", "ndk",
|
|
||||||
"-t", "arm64-v8a",
|
|
||||||
"-o", "${project.projectDir}/src/main/jniLibs",
|
|
||||||
"build", "--release", "-p", "wzp-android"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip cargo-ndk in CI/Docker — .so is pre-built into jniLibs
|
|
||||||
// tasks.named("preBuild") { dependsOn("cargoNdkBuild") }
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
|
||||||
implementation("androidx.compose.ui:ui")
|
|
||||||
implementation("androidx.compose.material3:material3")
|
|
||||||
}
|
|
||||||
9
android/app/proguard-rules.pro
vendored
9
android/app/proguard-rules.pro
vendored
@@ -1,9 +0,0 @@
|
|||||||
# WZPhone ProGuard rules
|
|
||||||
|
|
||||||
# Keep JNI native methods
|
|
||||||
-keepclasseswithmembernames class * {
|
|
||||||
native <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep the WZP engine bridge class
|
|
||||||
-keep class com.wzp.phone.engine.** { *; }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name="com.wzp.WzpApplication"
|
|
||||||
android:label="WZ Phone"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name="com.wzp.ui.call.CallActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name="com.wzp.service.CallService"
|
|
||||||
android:foregroundServiceType="microphone"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package com.wzp
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.os.Build
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application entry point for WarzonePhone.
|
|
||||||
*
|
|
||||||
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
|
|
||||||
*/
|
|
||||||
class WzpApplication : Application() {
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
"Active Call",
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Shown while a VoIP call is in progress"
|
|
||||||
setShowBadge(false)
|
|
||||||
}
|
|
||||||
val nm = getSystemService(NotificationManager::class.java)
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHANNEL_ID = "wzp_call_channel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
package com.wzp.audio
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioFormat
|
|
||||||
import android.media.AudioRecord
|
|
||||||
import android.media.AudioTrack
|
|
||||||
import android.media.MediaRecorder
|
|
||||||
import android.media.audiofx.AcousticEchoCanceler
|
|
||||||
import android.media.audiofx.NoiseSuppressor
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.wzp.engine.WzpEngine
|
|
||||||
import java.io.BufferedOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.OutputStreamWriter
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audio pipeline that captures mic audio and plays received audio using
|
|
||||||
* Android AudioRecord/AudioTrack APIs running on JVM threads.
|
|
||||||
*
|
|
||||||
* PCM samples are shuttled to/from the Rust engine via JNI ring buffers:
|
|
||||||
* - Capture: AudioRecord → WzpEngine.writeAudio() → Rust encoder → network
|
|
||||||
* - Playout: network → Rust decoder → WzpEngine.readAudio() → AudioTrack
|
|
||||||
*
|
|
||||||
* All audio is 48kHz, mono, 16-bit PCM (matching Opus codec requirements).
|
|
||||||
*/
|
|
||||||
class AudioPipeline(private val context: Context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "AudioPipeline"
|
|
||||||
private const val SAMPLE_RATE = 48000
|
|
||||||
private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
|
|
||||||
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
|
|
||||||
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
|
||||||
/** 20ms frame at 48kHz = 960 samples */
|
|
||||||
private const val FRAME_SAMPLES = 960
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var running = false
|
|
||||||
/** Playout (incoming voice) gain in dB. 0 = unity. */
|
|
||||||
@Volatile
|
|
||||||
var playoutGainDb: Float = 0f
|
|
||||||
/** Capture (mic) gain in dB. 0 = unity. */
|
|
||||||
@Volatile
|
|
||||||
var captureGainDb: Float = 0f
|
|
||||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
|
||||||
var aecEnabled: Boolean = true
|
|
||||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
|
||||||
var debugRecording: Boolean = false
|
|
||||||
private var captureThread: Thread? = null
|
|
||||||
private var playoutThread: Thread? = null
|
|
||||||
|
|
||||||
// DirectByteBuffers for zero-copy JNI audio transfer.
|
|
||||||
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
|
||||||
// can null local variables when it replaces the stack frame mid-loop.
|
|
||||||
// These survive OSR because they're on the heap.
|
|
||||||
private val captureDirectBuf: ByteBuffer =
|
|
||||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
private val playoutDirectBuf: ByteBuffer =
|
|
||||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
|
|
||||||
/** Latch counted down by each audio thread after exiting its loop.
|
|
||||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
|
||||||
private var drainLatch: CountDownLatch? = null
|
|
||||||
|
|
||||||
private val debugDir: File by lazy {
|
|
||||||
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start(engine: WzpEngine) {
|
|
||||||
if (running) return
|
|
||||||
running = true
|
|
||||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
|
||||||
|
|
||||||
captureThread = Thread({
|
|
||||||
runCapture(engine)
|
|
||||||
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
|
|
||||||
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
|
||||||
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
|
||||||
parkThread()
|
|
||||||
}, "wzp-capture").apply {
|
|
||||||
isDaemon = true
|
|
||||||
priority = Thread.MAX_PRIORITY
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
playoutThread = Thread({
|
|
||||||
runPlayout(engine)
|
|
||||||
drainLatch?.countDown() // signal: playout loop exited
|
|
||||||
parkThread()
|
|
||||||
}, "wzp-playout").apply {
|
|
||||||
isDaemon = true
|
|
||||||
priority = Thread.MAX_PRIORITY
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "audio pipeline started")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
running = false
|
|
||||||
// Don't join threads — they are parked as daemons to avoid native TLS crash.
|
|
||||||
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
|
|
||||||
Log.i(TAG, "audio pipeline stopped (running=false)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Block until both audio threads have exited their loops (max 200ms).
|
|
||||||
* After this returns, no more JNI calls to the engine will be made. */
|
|
||||||
fun awaitDrain(): Boolean {
|
|
||||||
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
|
||||||
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
|
|
||||||
captureThread = null
|
|
||||||
playoutThread = null
|
|
||||||
drainLatch = null
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
|
||||||
if (db == 0f) return
|
|
||||||
val linear = 10f.pow(db / 20f)
|
|
||||||
for (i in 0 until count) {
|
|
||||||
pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeRms(pcm: ShortArray, count: Int): Int {
|
|
||||||
var sumSq = 0.0
|
|
||||||
for (i in 0 until count) {
|
|
||||||
val s = pcm[i].toDouble()
|
|
||||||
sumSq += s * s
|
|
||||||
}
|
|
||||||
return sqrt(sumSq / count).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parkThread() {
|
|
||||||
try {
|
|
||||||
Thread.sleep(Long.MAX_VALUE)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
// process exiting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runCapture(engine: WzpEngine) {
|
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
Log.e(TAG, "RECORD_AUDIO permission not granted, capture disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING)
|
|
||||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4) // at least 4 frames
|
|
||||||
|
|
||||||
val recorder = try {
|
|
||||||
AudioRecord(
|
|
||||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
|
||||||
SAMPLE_RATE,
|
|
||||||
CHANNEL_IN,
|
|
||||||
ENCODING,
|
|
||||||
bufSize
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "AudioRecord SecurityException: ${e.message}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
|
||||||
Log.e(TAG, "AudioRecord failed to initialize")
|
|
||||||
recorder.release()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach hardware AEC if available and enabled in settings
|
|
||||||
var aec: AcousticEchoCanceler? = null
|
|
||||||
var ns: NoiseSuppressor? = null
|
|
||||||
if (aecEnabled) {
|
|
||||||
if (AcousticEchoCanceler.isAvailable()) {
|
|
||||||
try {
|
|
||||||
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
|
|
||||||
aec?.enabled = true
|
|
||||||
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "AEC init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "AEC not available on this device")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach hardware noise suppressor if available
|
|
||||||
if (NoiseSuppressor.isAvailable()) {
|
|
||||||
try {
|
|
||||||
ns = NoiseSuppressor.create(recorder.audioSessionId)
|
|
||||||
ns?.enabled = true
|
|
||||||
Log.i(TAG, "NoiseSuppressor enabled")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "AEC disabled by user setting")
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder.startRecording()
|
|
||||||
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
|
||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
|
||||||
// Debug: PCM file + RMS CSV
|
|
||||||
var pcmOut: BufferedOutputStream? = null
|
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
|
||||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
var frameIdx = 0L
|
|
||||||
if (debugRecording) {
|
|
||||||
try {
|
|
||||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
|
|
||||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
|
|
||||||
rmsCsv.write("frame,time_ms,rms\n")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "debug recording init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
while (running) {
|
|
||||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
|
||||||
if (read > 0) {
|
|
||||||
applyGain(pcm, read, captureGainDb)
|
|
||||||
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
|
||||||
captureDirectBuf.clear()
|
|
||||||
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
|
||||||
engine.writeAudioDirect(captureDirectBuf, read)
|
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
|
||||||
if (pcmOut != null) {
|
|
||||||
byteConv.clear()
|
|
||||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
|
||||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
|
||||||
}
|
|
||||||
if (rmsCsv != null) {
|
|
||||||
val rms = computeRms(pcm, read)
|
|
||||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
|
||||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
|
||||||
}
|
|
||||||
frameIdx++
|
|
||||||
} else if (read < 0) {
|
|
||||||
Log.e(TAG, "AudioRecord.read error: $read")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pcmOut?.close()
|
|
||||||
rmsCsv?.close()
|
|
||||||
recorder.stop()
|
|
||||||
aec?.release()
|
|
||||||
ns?.release()
|
|
||||||
recorder.release()
|
|
||||||
Log.i(TAG, "capture stopped (frames=$frameIdx)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runPlayout(engine: WzpEngine) {
|
|
||||||
val minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
|
|
||||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4)
|
|
||||||
|
|
||||||
val track = AudioTrack.Builder()
|
|
||||||
.setAudioAttributes(
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.setAudioFormat(
|
|
||||||
AudioFormat.Builder()
|
|
||||||
.setSampleRate(SAMPLE_RATE)
|
|
||||||
.setChannelMask(CHANNEL_OUT)
|
|
||||||
.setEncoding(ENCODING)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.setBufferSizeInBytes(bufSize)
|
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
|
||||||
Log.e(TAG, "AudioTrack failed to initialize")
|
|
||||||
track.release()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
track.play()
|
|
||||||
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
|
|
||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
|
||||||
val silence = ShortArray(FRAME_SAMPLES)
|
|
||||||
// Debug: PCM file + RMS CSV for playout
|
|
||||||
var pcmOut: BufferedOutputStream? = null
|
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
|
||||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
var frameIdx = 0L
|
|
||||||
if (debugRecording) {
|
|
||||||
try {
|
|
||||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
|
|
||||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
|
|
||||||
rmsCsv.write("frame,time_ms,rms\n")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "debug playout recording init failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
while (running) {
|
|
||||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
|
||||||
playoutDirectBuf.clear()
|
|
||||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
|
||||||
if (read >= FRAME_SAMPLES) {
|
|
||||||
playoutDirectBuf.rewind()
|
|
||||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
|
||||||
applyGain(pcm, read, playoutGainDb)
|
|
||||||
track.write(pcm, 0, read)
|
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
|
||||||
if (pcmOut != null) {
|
|
||||||
byteConv.clear()
|
|
||||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
|
||||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
|
||||||
}
|
|
||||||
if (rmsCsv != null) {
|
|
||||||
val rms = computeRms(pcm, read)
|
|
||||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
|
||||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
|
||||||
}
|
|
||||||
frameIdx++
|
|
||||||
} else {
|
|
||||||
track.write(silence, 0, FRAME_SAMPLES)
|
|
||||||
// Log silence frames to RMS as 0
|
|
||||||
if (rmsCsv != null) {
|
|
||||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
|
||||||
rmsCsv.write("$frameIdx,$timeMs,0\n")
|
|
||||||
}
|
|
||||||
frameIdx++
|
|
||||||
Thread.sleep(5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pcmOut?.close()
|
|
||||||
rmsCsv?.close()
|
|
||||||
track.stop()
|
|
||||||
track.release()
|
|
||||||
Log.i(TAG, "playout stopped (frames=$frameIdx)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package com.wzp.audio
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.AudioDeviceCallback
|
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
|
|
||||||
*
|
|
||||||
* Wraps [AudioManager] operations and listens for device connection changes
|
|
||||||
* via [AudioDeviceCallback] (API 23+).
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Call [register] when the call starts
|
|
||||||
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
|
|
||||||
* 3. Call [unregister] when the call ends
|
|
||||||
*/
|
|
||||||
class AudioRouteManager(context: Context) {
|
|
||||||
|
|
||||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
/** Listener for audio route changes. */
|
|
||||||
var onRouteChanged: ((AudioRoute) -> Unit)? = null
|
|
||||||
|
|
||||||
/** Current active route. */
|
|
||||||
var currentRoute: AudioRoute = AudioRoute.EARPIECE
|
|
||||||
private set
|
|
||||||
|
|
||||||
// -- Device callback (API 23+) -------------------------------------------
|
|
||||||
|
|
||||||
private val deviceCallback = object : AudioDeviceCallback() {
|
|
||||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
|
||||||
for (device in addedDevices) {
|
|
||||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
|
||||||
// A Bluetooth headset was connected — optionally auto-switch
|
|
||||||
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
|
||||||
for (device in removedDevices) {
|
|
||||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
|
||||||
// Bluetooth disconnected — fall back to earpiece or speaker
|
|
||||||
val fallback = if (audioManager.isSpeakerphoneOn) {
|
|
||||||
AudioRoute.SPEAKER
|
|
||||||
} else {
|
|
||||||
AudioRoute.EARPIECE
|
|
||||||
}
|
|
||||||
currentRoute = fallback
|
|
||||||
onRouteChanged?.invoke(fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
|
||||||
|
|
||||||
/** Register the device callback. Call when a call starts. */
|
|
||||||
fun register() {
|
|
||||||
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
|
|
||||||
fun unregister() {
|
|
||||||
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
|
||||||
stopBluetoothSco()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable the loudspeaker.
|
|
||||||
*
|
|
||||||
* When enabling speaker, Bluetooth SCO is disconnected.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
fun setSpeaker(enabled: Boolean) {
|
|
||||||
if (enabled) {
|
|
||||||
stopBluetoothSco()
|
|
||||||
}
|
|
||||||
audioManager.isSpeakerphoneOn = enabled
|
|
||||||
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
|
||||||
onRouteChanged?.invoke(currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
|
|
||||||
*
|
|
||||||
* When enabling Bluetooth, the speaker is turned off.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
fun setBluetoothSco(enabled: Boolean) {
|
|
||||||
if (enabled) {
|
|
||||||
audioManager.isSpeakerphoneOn = false
|
|
||||||
audioManager.startBluetoothSco()
|
|
||||||
audioManager.isBluetoothScoOn = true
|
|
||||||
currentRoute = AudioRoute.BLUETOOTH
|
|
||||||
} else {
|
|
||||||
stopBluetoothSco()
|
|
||||||
currentRoute = AudioRoute.EARPIECE
|
|
||||||
}
|
|
||||||
onRouteChanged?.invoke(currentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether a Bluetooth SCO device is currently connected. */
|
|
||||||
fun isBluetoothAvailable(): Boolean {
|
|
||||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
|
||||||
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List available output audio routes. */
|
|
||||||
fun availableRoutes(): List<AudioRoute> {
|
|
||||||
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
|
|
||||||
if (isBluetoothAvailable()) {
|
|
||||||
routes.add(AudioRoute.BLUETOOTH)
|
|
||||||
}
|
|
||||||
return routes
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Internal -------------------------------------------------------------
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun stopBluetoothSco() {
|
|
||||||
if (audioManager.isBluetoothScoOn) {
|
|
||||||
audioManager.isBluetoothScoOn = false
|
|
||||||
audioManager.stopBluetoothSco()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Audio output route. */
|
|
||||||
enum class AudioRoute {
|
|
||||||
/** Phone earpiece (default for calls). */
|
|
||||||
EARPIECE,
|
|
||||||
/** Built-in loudspeaker. */
|
|
||||||
SPEAKER,
|
|
||||||
/** Bluetooth SCO headset/headphones. */
|
|
||||||
BLUETOOTH
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package com.wzp.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import com.wzp.ui.call.ServerEntry
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.security.SecureRandom
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists user settings via SharedPreferences.
|
|
||||||
*
|
|
||||||
* Stores: servers, default server index, room name, alias, gain values,
|
|
||||||
* IPv6 preference, and the identity seed (hex-encoded 32 bytes).
|
|
||||||
*/
|
|
||||||
class SettingsRepository(context: Context) {
|
|
||||||
|
|
||||||
private val prefs: SharedPreferences =
|
|
||||||
context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val KEY_SERVERS = "servers_json"
|
|
||||||
private const val KEY_SELECTED_SERVER = "selected_server"
|
|
||||||
private const val KEY_ROOM = "room_name"
|
|
||||||
private const val KEY_ALIAS = "alias"
|
|
||||||
private const val KEY_PLAYOUT_GAIN = "playout_gain_db"
|
|
||||||
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
|
|
||||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
|
||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
|
||||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
|
||||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
|
||||||
private const val TOFU_PREFIX = "tofu_"
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Servers ---
|
|
||||||
|
|
||||||
fun saveServers(servers: List<ServerEntry>) {
|
|
||||||
val arr = JSONArray()
|
|
||||||
servers.forEach { entry ->
|
|
||||||
arr.put(JSONObject().apply {
|
|
||||||
put("address", entry.address)
|
|
||||||
put("label", entry.label)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_SERVERS, arr.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadServers(): List<ServerEntry>? {
|
|
||||||
val json = prefs.getString(KEY_SERVERS, null) ?: return null
|
|
||||||
return try {
|
|
||||||
val arr = JSONArray(json)
|
|
||||||
(0 until arr.length()).map { i ->
|
|
||||||
val obj = arr.getJSONObject(i)
|
|
||||||
ServerEntry(obj.getString("address"), obj.getString("label"))
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSelectedServer(index: Int) {
|
|
||||||
prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0)
|
|
||||||
|
|
||||||
// --- Room ---
|
|
||||||
|
|
||||||
fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() }
|
|
||||||
fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android"
|
|
||||||
|
|
||||||
// --- Alias ---
|
|
||||||
|
|
||||||
fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load alias, generating a random name on first launch.
|
|
||||||
*/
|
|
||||||
fun getOrCreateAlias(): String {
|
|
||||||
val existing = prefs.getString(KEY_ALIAS, null)
|
|
||||||
if (!existing.isNullOrEmpty()) return existing
|
|
||||||
val name = generateRandomName()
|
|
||||||
prefs.edit().putString(KEY_ALIAS, name).apply()
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateRandomName(): String {
|
|
||||||
val adjectives = listOf(
|
|
||||||
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
|
|
||||||
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
|
|
||||||
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
|
|
||||||
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
|
|
||||||
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc"
|
|
||||||
)
|
|
||||||
val nouns = listOf(
|
|
||||||
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
|
|
||||||
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
|
|
||||||
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
|
|
||||||
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
|
|
||||||
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus"
|
|
||||||
)
|
|
||||||
val adj = adjectives.random()
|
|
||||||
val noun = nouns.random()
|
|
||||||
return "$adj $noun"
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Gain ---
|
|
||||||
|
|
||||||
fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() }
|
|
||||||
fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f)
|
|
||||||
|
|
||||||
fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() }
|
|
||||||
fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f)
|
|
||||||
|
|
||||||
// --- IPv6 ---
|
|
||||||
|
|
||||||
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
|
|
||||||
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
|
|
||||||
|
|
||||||
// --- AEC ---
|
|
||||||
|
|
||||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
|
||||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
|
||||||
|
|
||||||
// --- Debug recording ---
|
|
||||||
|
|
||||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
|
||||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
|
||||||
|
|
||||||
// --- Codec choice ---
|
|
||||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
|
||||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
|
||||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
|
||||||
|
|
||||||
// --- Identity seed ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or generate the identity seed. On first call, generates a random
|
|
||||||
* 32-byte seed and persists it. Subsequent calls return the same seed.
|
|
||||||
*/
|
|
||||||
fun getOrCreateSeedHex(): String {
|
|
||||||
val existing = prefs.getString(KEY_IDENTITY_SEED, null)
|
|
||||||
if (!existing.isNullOrEmpty()) return existing
|
|
||||||
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
|
||||||
val hex = seed.joinToString("") { "%02x".format(it) }
|
|
||||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
|
||||||
return hex
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: ""
|
|
||||||
|
|
||||||
fun saveSeedHex(hex: String) {
|
|
||||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Recent rooms ---
|
|
||||||
|
|
||||||
data class RecentRoom(val relay: String, val room: String)
|
|
||||||
|
|
||||||
fun addRecentRoom(relay: String, room: String) {
|
|
||||||
val rooms = loadRecentRooms().toMutableList()
|
|
||||||
rooms.removeAll { it.relay == relay && it.room == room }
|
|
||||||
rooms.add(0, RecentRoom(relay, room))
|
|
||||||
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
|
||||||
val arr = JSONArray()
|
|
||||||
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
|
||||||
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadRecentRooms(): List<RecentRoom> {
|
|
||||||
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
|
||||||
return try {
|
|
||||||
val arr = JSONArray(json)
|
|
||||||
(0 until arr.length()).map { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
RecentRoom(o.getString("relay"), o.getString("room"))
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearRecentRooms() {
|
|
||||||
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Server fingerprint TOFU ---
|
|
||||||
|
|
||||||
fun saveServerFingerprint(address: String, fingerprint: String) {
|
|
||||||
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadServerFingerprint(address: String): String? {
|
|
||||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Ping RTT cache ---
|
|
||||||
|
|
||||||
fun savePingRtt(address: String, rttMs: Int) {
|
|
||||||
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadPingRtt(address: String): Int {
|
|
||||||
return prefs.getInt("ping_rtt_$address", -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
package com.wzp.debug
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.BufferedOutputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects call debug data (audio recordings, logs, histograms, stats)
|
|
||||||
* into a zip file for email sharing.
|
|
||||||
*/
|
|
||||||
class DebugReporter(private val context: Context) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "DebugReporter"
|
|
||||||
private const val SAMPLE_RATE = 48000
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a zip with all debug data.
|
|
||||||
* Returns the zip File on success, or null on failure.
|
|
||||||
*/
|
|
||||||
suspend fun collectZip(
|
|
||||||
callDurationSecs: Double,
|
|
||||||
finalStatsJson: String,
|
|
||||||
aecEnabled: Boolean,
|
|
||||||
alias: String,
|
|
||||||
server: String,
|
|
||||||
room: String
|
|
||||||
): File? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
|
||||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
|
||||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
|
||||||
|
|
||||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
|
||||||
// 1. Call metadata
|
|
||||||
val meta = buildString {
|
|
||||||
appendLine("=== WZ Phone Debug Report ===")
|
|
||||||
appendLine("Timestamp: $timestamp")
|
|
||||||
appendLine("Alias: $alias")
|
|
||||||
appendLine("Server: $server")
|
|
||||||
appendLine("Room: $room")
|
|
||||||
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
|
|
||||||
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
|
|
||||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
|
||||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
|
||||||
appendLine()
|
|
||||||
appendLine("=== Final Stats ===")
|
|
||||||
appendLine(finalStatsJson)
|
|
||||||
}
|
|
||||||
addTextEntry(zos, "meta.txt", meta)
|
|
||||||
|
|
||||||
// 2. Logcat — WZP-related tags
|
|
||||||
val logcat = collectLogcat()
|
|
||||||
addTextEntry(zos, "logcat.txt", logcat)
|
|
||||||
|
|
||||||
// 3. Capture audio (mic) → WAV
|
|
||||||
val captureRaw = File(debugDir, "capture.pcm")
|
|
||||||
if (captureRaw.exists() && captureRaw.length() > 0) {
|
|
||||||
addWavEntry(zos, "capture.wav", captureRaw)
|
|
||||||
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Playout audio (speaker) → WAV
|
|
||||||
val playoutRaw = File(debugDir, "playout.pcm")
|
|
||||||
if (playoutRaw.exists() && playoutRaw.length() > 0) {
|
|
||||||
addWavEntry(zos, "playout.wav", playoutRaw)
|
|
||||||
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. RMS histogram CSV
|
|
||||||
val captureHist = File(debugDir, "capture_rms.csv")
|
|
||||||
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
|
|
||||||
val playoutHist = File(debugDir, "playout_rms.csv")
|
|
||||||
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
|
|
||||||
|
|
||||||
// Clean up raw debug files (keep zip)
|
|
||||||
debugDir.listFiles()?.forEach { it.delete() }
|
|
||||||
|
|
||||||
zipFile
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "debug report failed", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clean up any leftover debug files from a previous session. */
|
|
||||||
fun prepareForCall() {
|
|
||||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
|
||||||
if (debugDir.exists()) {
|
|
||||||
debugDir.listFiles()?.forEach { it.delete() }
|
|
||||||
}
|
|
||||||
debugDir.mkdirs()
|
|
||||||
// Also clean up old zip files
|
|
||||||
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collectLogcat(): String {
|
|
||||||
return try {
|
|
||||||
val process = Runtime.getRuntime().exec(
|
|
||||||
arrayOf(
|
|
||||||
"logcat", "-d",
|
|
||||||
"-t", "5000",
|
|
||||||
"--format", "threadtime"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
process.waitFor()
|
|
||||||
output.lines()
|
|
||||||
.filter { line ->
|
|
||||||
line.contains("wzp", ignoreCase = true) ||
|
|
||||||
line.contains("WzpEngine") ||
|
|
||||||
line.contains("AudioPipeline") ||
|
|
||||||
line.contains("WzpCall") ||
|
|
||||||
line.contains("CallService") ||
|
|
||||||
line.contains("AudioTrack") ||
|
|
||||||
line.contains("AudioRecord") ||
|
|
||||||
line.contains("AcousticEchoCanceler") ||
|
|
||||||
line.contains("NoiseSuppressor") ||
|
|
||||||
line.contains("FATAL") ||
|
|
||||||
line.contains("ANR") ||
|
|
||||||
line.contains("AudioFlinger") ||
|
|
||||||
line.contains("DebugReporter") ||
|
|
||||||
line.contains("QUIC") ||
|
|
||||||
line.contains("quinn") ||
|
|
||||||
line.contains("send task") ||
|
|
||||||
line.contains("recv task") ||
|
|
||||||
line.contains("send stats") ||
|
|
||||||
line.contains("recv stats") ||
|
|
||||||
line.contains("send_media") ||
|
|
||||||
line.contains("FEC block") ||
|
|
||||||
line.contains("recv gap") ||
|
|
||||||
line.contains("frames_dropped") ||
|
|
||||||
line.contains("opus")
|
|
||||||
}
|
|
||||||
.joinToString("\n")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
"Failed to collect logcat: ${e.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
|
|
||||||
val dataSize = pcmFile.length().toInt()
|
|
||||||
val byteRate = SAMPLE_RATE * 1 * 16 / 8
|
|
||||||
val blockAlign = 1 * 16 / 8
|
|
||||||
|
|
||||||
zos.putNextEntry(ZipEntry(name))
|
|
||||||
|
|
||||||
// Write WAV header (44 bytes)
|
|
||||||
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
header.put("RIFF".toByteArray())
|
|
||||||
header.putInt(36 + dataSize)
|
|
||||||
header.put("WAVE".toByteArray())
|
|
||||||
header.put("fmt ".toByteArray())
|
|
||||||
header.putInt(16)
|
|
||||||
header.putShort(1) // PCM
|
|
||||||
header.putShort(1) // mono
|
|
||||||
header.putInt(SAMPLE_RATE)
|
|
||||||
header.putInt(byteRate)
|
|
||||||
header.putShort(blockAlign.toShort())
|
|
||||||
header.putShort(16) // bits per sample
|
|
||||||
header.put("data".toByteArray())
|
|
||||||
header.putInt(dataSize)
|
|
||||||
zos.write(header.array())
|
|
||||||
|
|
||||||
// Stream PCM data directly (avoids loading entire file into memory)
|
|
||||||
FileInputStream(pcmFile).use { it.copyTo(zos) }
|
|
||||||
zos.closeEntry()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
|
|
||||||
zos.putNextEntry(ZipEntry(name))
|
|
||||||
zos.write(content.toByteArray())
|
|
||||||
zos.closeEntry()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
|
|
||||||
zos.putNextEntry(ZipEntry(name))
|
|
||||||
FileInputStream(file).use { it.copyTo(zos) }
|
|
||||||
zos.closeEntry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package com.wzp.engine
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
|
|
||||||
*
|
|
||||||
* Constructed from the JSON string returned by [WzpEngine.getStats].
|
|
||||||
*/
|
|
||||||
data class CallStats(
|
|
||||||
/** Current call state ordinal (see [CallStateConstants]). */
|
|
||||||
val state: Int = 0,
|
|
||||||
/** Call duration in seconds. */
|
|
||||||
val durationSecs: Double = 0.0,
|
|
||||||
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
|
|
||||||
val qualityTier: Int = 0,
|
|
||||||
/** Observed packet loss percentage (0..100). */
|
|
||||||
val lossPct: Float = 0f,
|
|
||||||
/** Smoothed round-trip time in milliseconds. */
|
|
||||||
val rttMs: Int = 0,
|
|
||||||
/** Jitter in milliseconds. */
|
|
||||||
val jitterMs: Int = 0,
|
|
||||||
/** Current jitter buffer depth in packets. */
|
|
||||||
val jitterBufferDepth: Int = 0,
|
|
||||||
/** Total frames encoded since call start. */
|
|
||||||
val framesEncoded: Long = 0,
|
|
||||||
/** Total frames decoded since call start. */
|
|
||||||
val framesDecoded: Long = 0,
|
|
||||||
/** Number of playout underruns (buffer empty when audio was needed). */
|
|
||||||
val underruns: Long = 0,
|
|
||||||
/** Frames recovered by FEC. */
|
|
||||||
val fecRecovered: Long = 0,
|
|
||||||
/** Current mic audio level (RMS, 0-32767). */
|
|
||||||
val audioLevel: Int = 0,
|
|
||||||
/** Our current outgoing codec (e.g. "Opus24k"). */
|
|
||||||
val currentCodec: String = "",
|
|
||||||
/** Last seen incoming codec from peers. */
|
|
||||||
val peerCodec: String = "",
|
|
||||||
/** Whether auto quality mode is active. */
|
|
||||||
val autoMode: Boolean = false,
|
|
||||||
/** Number of participants in the room. */
|
|
||||||
val roomParticipantCount: Int = 0,
|
|
||||||
/** Participants in the room (fingerprint + optional alias). */
|
|
||||||
val roomParticipants: List<RoomMember> = emptyList(),
|
|
||||||
/** SAS verification code (4-digit, null if not in a call). */
|
|
||||||
val sasCode: Int? = null,
|
|
||||||
/** Incoming call ID (or "relay|room" for CallSetup). */
|
|
||||||
val incomingCallId: String? = null,
|
|
||||||
/** Incoming caller's fingerprint. */
|
|
||||||
val incomingCallerFp: String? = null,
|
|
||||||
/** Incoming caller's alias. */
|
|
||||||
val incomingCallerAlias: String? = null,
|
|
||||||
) {
|
|
||||||
/** Human-readable quality label. */
|
|
||||||
val qualityLabel: String
|
|
||||||
get() = when (qualityTier) {
|
|
||||||
0 -> "Good"
|
|
||||||
1 -> "Degraded"
|
|
||||||
2 -> "Catastrophic"
|
|
||||||
else -> "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun parseParticipants(arr: JSONArray?): List<RoomMember> {
|
|
||||||
if (arr == null) return emptyList()
|
|
||||||
return (0 until arr.length()).map { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
RoomMember(
|
|
||||||
fingerprint = o.optString("fingerprint", ""),
|
|
||||||
alias = if (o.isNull("alias")) null else o.optString("alias", null),
|
|
||||||
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deserialise from the JSON string produced by the native engine. */
|
|
||||||
fun fromJson(json: String): CallStats {
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
CallStats(
|
|
||||||
state = obj.optInt("state", 0),
|
|
||||||
durationSecs = obj.optDouble("duration_secs", 0.0),
|
|
||||||
qualityTier = obj.optInt("quality_tier", 0),
|
|
||||||
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
|
|
||||||
rttMs = obj.optInt("rtt_ms", 0),
|
|
||||||
jitterMs = obj.optInt("jitter_ms", 0),
|
|
||||||
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
|
|
||||||
framesEncoded = obj.optLong("frames_encoded", 0),
|
|
||||||
framesDecoded = obj.optLong("frames_decoded", 0),
|
|
||||||
underruns = obj.optLong("underruns", 0),
|
|
||||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
|
||||||
audioLevel = obj.optInt("audio_level", 0),
|
|
||||||
currentCodec = obj.optString("current_codec", ""),
|
|
||||||
peerCodec = obj.optString("peer_codec", ""),
|
|
||||||
autoMode = obj.optBoolean("auto_mode", false),
|
|
||||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
|
||||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
|
||||||
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
|
||||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
|
||||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
|
||||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
CallStats()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RoomMember(
|
|
||||||
val fingerprint: String,
|
|
||||||
val alias: String? = null,
|
|
||||||
val relayLabel: String? = null
|
|
||||||
) {
|
|
||||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
|
||||||
val displayName: String
|
|
||||||
get() = alias?.takeIf { it.isNotBlank() }
|
|
||||||
?: fingerprint.take(8).ifEmpty { "unknown" }
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.wzp.engine
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback interface for VoIP engine events.
|
|
||||||
*
|
|
||||||
* All callbacks are invoked on the main/UI thread.
|
|
||||||
*/
|
|
||||||
interface WzpCallback {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the call state changes.
|
|
||||||
*
|
|
||||||
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
|
|
||||||
* RECONNECTING(3), CLOSED(4)
|
|
||||||
*/
|
|
||||||
fun onCallStateChanged(state: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the network quality tier changes.
|
|
||||||
*
|
|
||||||
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
|
|
||||||
*/
|
|
||||||
fun onQualityTierChanged(tier: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an error occurs in the native engine.
|
|
||||||
*
|
|
||||||
* @param code numeric error code (negative)
|
|
||||||
* @param message human-readable description
|
|
||||||
*/
|
|
||||||
fun onError(code: Int, message: String)
|
|
||||||
}
|
|
||||||
@@ -1,225 +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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
init {
|
|
||||||
System.loadLibrary("wzp_android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
|
||||||
object CallStateConstants {
|
|
||||||
const val IDLE = 0
|
|
||||||
const val CONNECTING = 1
|
|
||||||
const val ACTIVE = 2
|
|
||||||
const val RECONNECTING = 3
|
|
||||||
const val CLOSED = 4
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.wzp.net
|
|
||||||
|
|
||||||
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
|
||||||
// This file kept for the data class only.
|
|
||||||
|
|
||||||
object RelayPinger {
|
|
||||||
data class PingResult(
|
|
||||||
val rttMs: Int,
|
|
||||||
val reachable: Boolean,
|
|
||||||
val serverFingerprint: String = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package com.wzp.service
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.wzp.WzpApplication
|
|
||||||
import com.wzp.ui.call.CallActivity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Shows a persistent notification during the call
|
|
||||||
* - Acquires a partial wake lock so the CPU stays on
|
|
||||||
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
|
|
||||||
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
|
|
||||||
* - Releases all resources when the call ends
|
|
||||||
*/
|
|
||||||
class CallService : Service() {
|
|
||||||
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
private var wifiLock: WifiManager.WifiLock? = null
|
|
||||||
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
|
|
||||||
|
|
||||||
// -- Lifecycle ------------------------------------------------------------
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
acquireWakeLock()
|
|
||||||
acquireWifiLock()
|
|
||||||
setAudioMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_STOP -> {
|
|
||||||
onStopFromNotification?.invoke()
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, buildNotification())
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
restoreAudioMode()
|
|
||||||
releaseWifiLock()
|
|
||||||
releaseWakeLock()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
// -- Notification ---------------------------------------------------------
|
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
|
||||||
// Tapping the notification returns to the call screen
|
|
||||||
val contentIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, CallActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
// "End call" action button
|
|
||||||
val stopIntent = PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
1,
|
|
||||||
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
|
|
||||||
.setContentTitle("WZ Phone")
|
|
||||||
.setContentText("Call in progress")
|
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(contentIntent)
|
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Wake lock ------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun acquireWakeLock() {
|
|
||||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
wakeLock = pm.newWakeLock(
|
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
|
||||||
"wzp:call_wake_lock"
|
|
||||||
).apply {
|
|
||||||
acquire(MAX_CALL_DURATION_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
|
||||||
wakeLock?.let {
|
|
||||||
if (it.isHeld) it.release()
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Wi-Fi lock -----------------------------------------------------------
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun acquireWifiLock() {
|
|
||||||
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
|
||||||
wifiLock = wm.createWifiLock(
|
|
||||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
|
||||||
"wzp:call_wifi_lock"
|
|
||||||
).apply {
|
|
||||||
acquire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWifiLock() {
|
|
||||||
wifiLock?.let {
|
|
||||||
if (it.isHeld) it.release()
|
|
||||||
}
|
|
||||||
wifiLock = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Audio mode -----------------------------------------------------------
|
|
||||||
|
|
||||||
private fun setAudioMode() {
|
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
previousAudioMode = am.mode
|
|
||||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreAudioMode() {
|
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
am.mode = previousAudioMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Static helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val NOTIFICATION_ID = 1001
|
|
||||||
private const val ACTION_STOP = "com.wzp.service.STOP"
|
|
||||||
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
|
|
||||||
|
|
||||||
/** Called when the user taps "End Call" in the notification. */
|
|
||||||
var onStopFromNotification: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/** Start the foreground call service. */
|
|
||||||
fun start(context: Context) {
|
|
||||||
val intent = Intent(context, CallService::class.java)
|
|
||||||
context.startForegroundService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the foreground call service. */
|
|
||||||
fun stop(context: Context) {
|
|
||||||
val intent = Intent(context, CallService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package com.wzp.ui.call
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import com.wzp.ui.settings.SettingsScreen
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main activity hosting the in-call Compose UI.
|
|
||||||
*
|
|
||||||
* Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
|
|
||||||
* is managed by [com.wzp.service.CallService] foreground service.
|
|
||||||
*/
|
|
||||||
class CallActivity : ComponentActivity() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "CallActivity"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val viewModel: CallViewModel by viewModels()
|
|
||||||
|
|
||||||
private val audioPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
if (!granted) {
|
|
||||||
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
viewModel.setContext(this)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
WzpTheme {
|
|
||||||
var showSettings by remember { mutableStateOf(false) }
|
|
||||||
if (showSettings) {
|
|
||||||
SettingsScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onBack = { showSettings = false }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
InCallScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onHangUp = { viewModel.stopCall() },
|
|
||||||
onOpenSettings = { showSettings = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for debug zip ready → launch email intent
|
|
||||||
lifecycleScope.launch {
|
|
||||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
viewModel.debugZipReady.collect { zipFile ->
|
|
||||||
if (zipFile != null && zipFile.exists()) {
|
|
||||||
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
|
|
||||||
launchEmailIntent(zipFile)
|
|
||||||
viewModel.onDebugReportSent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchEmailIntent(zipFile: java.io.File) {
|
|
||||||
try {
|
|
||||||
val authority = "${applicationContext.packageName}.fileprovider"
|
|
||||||
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
|
|
||||||
val uri = FileProvider.getUriForFile(this, authority, zipFile)
|
|
||||||
Log.i(TAG, "FileProvider URI: $uri")
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "message/rfc822"
|
|
||||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
|
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
|
|
||||||
putExtra(
|
|
||||||
Intent.EXTRA_TEXT,
|
|
||||||
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
|
|
||||||
)
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
startActivity(Intent.createChooser(intent, "Send debug report"))
|
|
||||||
Log.i(TAG, "email intent launched")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "email intent failed", e)
|
|
||||||
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
if (isFinishing) {
|
|
||||||
viewModel.stopCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WzpTheme(content: @Composable () -> Unit) {
|
|
||||||
val darkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val colorScheme = when {
|
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
darkTheme -> darkColorScheme()
|
|
||||||
else -> lightColorScheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,732 +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 connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
|
||||||
private val _signalState = MutableStateFlow(0)
|
|
||||||
val signalState: StateFlow<Int> = _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()
|
|
||||||
|
|
||||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
|
||||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
|
||||||
|
|
||||||
/** Register on relay for direct calls */
|
|
||||||
fun registerForCalls() {
|
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this).also { it.init() }
|
|
||||||
}
|
|
||||||
val serverIdx = _selectedServer.value
|
|
||||||
val serverList = _servers.value
|
|
||||||
if (serverIdx >= serverList.size) return
|
|
||||||
|
|
||||||
val relay = serverList[serverIdx].address
|
|
||||||
val seed = _seedHex.value
|
|
||||||
val alias = _alias.value
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
|
||||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
|
||||||
if (result == 0) {
|
|
||||||
_signalState.value = 5 // Registered
|
|
||||||
startStatsPolling()
|
|
||||||
} else {
|
|
||||||
_errorMessage.value = "Failed to register on relay"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
engine?.placeCall(target)
|
|
||||||
_signalState.value = 6 // Ringing
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer an incoming direct call */
|
|
||||||
fun answerIncomingCall(mode: Int = 2) {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject an incoming direct call */
|
|
||||||
fun rejectIncomingCall() {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, 0) // 0 = Reject
|
|
||||||
_signalState.value = 5 // Back to registered
|
|
||||||
_incomingCallId.value = null
|
|
||||||
_incomingCallerFp.value = null
|
|
||||||
_incomingCallerAlias.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
if (s.state != 0) {
|
|
||||||
_callState.value = s.state
|
|
||||||
}
|
|
||||||
// Track signal state changes for direct calling
|
|
||||||
if (s.state in 5..7) {
|
|
||||||
_signalState.value = s.state
|
|
||||||
}
|
|
||||||
// Incoming call detection
|
|
||||||
if (s.state == 7) { // IncomingCall
|
|
||||||
_incomingCallId.value = s.incomingCallId
|
|
||||||
_incomingCallerFp.value = s.incomingCallerFp
|
|
||||||
_incomingCallerAlias.value = s.incomingCallerAlias
|
|
||||||
}
|
|
||||||
// CallSetup: auto-connect to media room
|
|
||||||
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
|
||||||
// Format: "relay_addr|room_name"
|
|
||||||
val parts = s.incomingCallId.split("|", limit = 2)
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val mediaRelay = parts[0]
|
|
||||||
val mediaRoom = parts[1]
|
|
||||||
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
|
||||||
startCallInternal(mediaRelay, mediaRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (s.state == 2 && !audioStarted) {
|
|
||||||
startAudio()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
delay(500L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopStatsPolling() {
|
|
||||||
statsJob?.cancel()
|
|
||||||
statsJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
Log.i(TAG, "onCleared")
|
|
||||||
teardown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
|||||||
package com.wzp.ui.components
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
|
||||||
* from a hex fingerprint string. Identical algorithm to the desktop
|
|
||||||
* TypeScript implementation in identicon.ts.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Identicon(
|
|
||||||
fingerprint: String,
|
|
||||||
size: Dp = 36.dp,
|
|
||||||
clickToCopy: Boolean = true,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val bytes = hashBytes(fingerprint)
|
|
||||||
val (bg, fg) = deriveColors(bytes)
|
|
||||||
val grid = buildGrid(bytes)
|
|
||||||
|
|
||||||
Canvas(
|
|
||||||
modifier = modifier
|
|
||||||
.size(size)
|
|
||||||
.clip(RoundedCornerShape(size * 0.12f))
|
|
||||||
.then(
|
|
||||||
if (clickToCopy && fingerprint.isNotEmpty()) {
|
|
||||||
Modifier.clickable {
|
|
||||||
clipboard.setText(AnnotatedString(fingerprint))
|
|
||||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val cellW = this.size.width / 5f
|
|
||||||
val cellH = this.size.height / 5f
|
|
||||||
|
|
||||||
// Background
|
|
||||||
drawRect(color = bg, size = this.size)
|
|
||||||
|
|
||||||
// Foreground cells
|
|
||||||
for (y in 0 until 5) {
|
|
||||||
for (x in 0 until 5) {
|
|
||||||
if (grid[y][x]) {
|
|
||||||
drawRect(
|
|
||||||
color = fg,
|
|
||||||
topLeft = Offset(x * cellW, y * cellH),
|
|
||||||
size = Size(cellW, cellH),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fingerprint text that copies to clipboard on tap.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CopyableFingerprint(
|
|
||||||
fingerprint: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
|
||||||
color: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
androidx.compose.material3.Text(
|
|
||||||
text = fingerprint,
|
|
||||||
style = style,
|
|
||||||
color = color,
|
|
||||||
modifier = modifier.clickable {
|
|
||||||
if (fingerprint.isNotEmpty()) {
|
|
||||||
clipboard.setText(AnnotatedString(fingerprint))
|
|
||||||
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Internal helpers (matching desktop identicon.ts) ---
|
|
||||||
|
|
||||||
private fun hashBytes(hex: String): List<Int> {
|
|
||||||
val clean = hex.filter { it.isLetterOrDigit() }
|
|
||||||
val bytes = mutableListOf<Int>()
|
|
||||||
var i = 0
|
|
||||||
while (i + 1 < clean.length) {
|
|
||||||
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
|
||||||
bytes.add(b)
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
// Pad to at least 16 bytes
|
|
||||||
while (bytes.size < 16) bytes.add(0)
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
|
||||||
val hue1 = bytes[0] * 360f / 256f
|
|
||||||
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
|
||||||
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
|
||||||
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
|
||||||
return bg to fg
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
|
||||||
return (0 until 5).map { y ->
|
|
||||||
val left = (0 until 3).map { x ->
|
|
||||||
val idx = 2 + y * 3 + x
|
|
||||||
bytes[idx % bytes.size] > 128
|
|
||||||
}
|
|
||||||
// Mirror: col3 = col1, col4 = col0
|
|
||||||
listOf(left[0], left[1], left[2], left[1], left[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
|
||||||
val k = { n: Float -> (n + h / 30f) % 12f }
|
|
||||||
val a = s * min(l, 1f - l)
|
|
||||||
val f = { n: Float ->
|
|
||||||
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
|
||||||
}
|
|
||||||
return Color(f(0f), f(8f), f(4f))
|
|
||||||
}
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
package com.wzp.ui.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.toMutableStateList
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.wzp.ui.call.CallViewModel
|
|
||||||
import com.wzp.ui.call.ServerEntry
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(
|
|
||||||
viewModel: CallViewModel,
|
|
||||||
onBack: () -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Snapshot current values into local draft state
|
|
||||||
val currentAlias by viewModel.alias.collectAsState()
|
|
||||||
val currentSeedHex by viewModel.seedHex.collectAsState()
|
|
||||||
val currentServers by viewModel.servers.collectAsState()
|
|
||||||
val currentSelectedServer by viewModel.selectedServer.collectAsState()
|
|
||||||
val currentRoomName by viewModel.roomName.collectAsState()
|
|
||||||
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
|
|
||||||
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
|
|
||||||
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
|
|
||||||
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
|
|
||||||
|
|
||||||
// Draft state — initialized from current values
|
|
||||||
var draftAlias by remember { mutableStateOf(currentAlias) }
|
|
||||||
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
|
|
||||||
val draftServers = remember { currentServers.toMutableStateList() }
|
|
||||||
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
|
|
||||||
var draftRoomName by remember { mutableStateOf(currentRoomName) }
|
|
||||||
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
|
|
||||||
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
|
|
||||||
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
|
|
||||||
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
|
|
||||||
|
|
||||||
// Track if anything changed
|
|
||||||
val hasChanges = draftAlias != currentAlias ||
|
|
||||||
draftSeedHex != currentSeedHex ||
|
|
||||||
draftServers.toList() != currentServers ||
|
|
||||||
draftSelectedServer != currentSelectedServer ||
|
|
||||||
draftRoomName != currentRoomName ||
|
|
||||||
draftPreferIPv6 != currentPreferIPv6 ||
|
|
||||||
draftPlayoutGain != currentPlayoutGain ||
|
|
||||||
draftCaptureGain != currentCaptureGain ||
|
|
||||||
draftAecEnabled != currentAecEnabled
|
|
||||||
|
|
||||||
var showAddServerDialog by remember { mutableStateOf(false) }
|
|
||||||
var showRestoreKeyDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.background
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text("< Back")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
text = "Settings",
|
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
// Save button — only enabled when changes exist
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
viewModel.setAlias(draftAlias)
|
|
||||||
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
|
|
||||||
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
|
|
||||||
viewModel.setRoomName(draftRoomName)
|
|
||||||
viewModel.setPreferIPv6(draftPreferIPv6)
|
|
||||||
viewModel.setPlayoutGainDb(draftPlayoutGain)
|
|
||||||
viewModel.setCaptureGainDb(draftCaptureGain)
|
|
||||||
viewModel.setAecEnabled(draftAecEnabled)
|
|
||||||
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
|
|
||||||
onBack()
|
|
||||||
},
|
|
||||||
enabled = hasChanges
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// --- Identity ---
|
|
||||||
SectionHeader("Identity")
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = draftAlias,
|
|
||||||
onValueChange = { draftAlias = it },
|
|
||||||
label = { Text("Display Name") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Fingerprint display with identicon
|
|
||||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
|
||||||
Text(
|
|
||||||
text = "Fingerprint",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
com.wzp.ui.components.Identicon(
|
|
||||||
fingerprint = draftSeedHex,
|
|
||||||
size = 40.dp,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
com.wzp.ui.components.CopyableFingerprint(
|
|
||||||
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Key backup/restore
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilledTonalButton(onClick = {
|
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
|
|
||||||
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
|
|
||||||
}) {
|
|
||||||
Text("Copy Key")
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { showRestoreKeyDialog = true }) {
|
|
||||||
Text("Restore Key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Divider()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Audio ---
|
|
||||||
SectionHeader("Audio Defaults")
|
|
||||||
|
|
||||||
GainSlider(
|
|
||||||
label = "Voice Volume",
|
|
||||||
gainDb = draftPlayoutGain,
|
|
||||||
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
GainSlider(
|
|
||||||
label = "Mic Gain",
|
|
||||||
gainDb = draftCaptureGain,
|
|
||||||
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Echo Cancellation (AEC)",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Disable if audio sounds distorted",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = draftAecEnabled,
|
|
||||||
onCheckedChange = { draftAecEnabled = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
|
||||||
val qualityLabels = listOf(
|
|
||||||
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
|
||||||
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
|
||||||
)
|
|
||||||
// Map slider position to JNI profile int:
|
|
||||||
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
|
||||||
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
|
||||||
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
|
||||||
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
|
||||||
val qualityColors = listOf(
|
|
||||||
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
|
||||||
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
|
||||||
)
|
|
||||||
val currentCodec by viewModel.codecChoice.collectAsState()
|
|
||||||
val sliderPos = profileToSlider[currentCodec] ?: 3
|
|
||||||
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
text = "Decode always accepts all codecs",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = qualityLabels[sliderPos],
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = qualityColors[sliderPos]
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = sliderPos.toFloat(),
|
|
||||||
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
|
||||||
valueRange = 0f..7f,
|
|
||||||
steps = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
|
||||||
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Divider()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Servers ---
|
|
||||||
SectionHeader("Servers")
|
|
||||||
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
draftServers.forEachIndexed { idx, entry ->
|
|
||||||
val isSelected = draftSelectedServer == idx
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = { draftSelectedServer = idx },
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 2.dp)
|
|
||||||
.height(36.dp)
|
|
||||||
.width(140.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors = if (isSelected) {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = entry.label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Show remove button for non-default servers
|
|
||||||
if (idx >= 2) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
draftServers.removeAt(idx)
|
|
||||||
if (draftSelectedServer >= draftServers.size) {
|
|
||||||
draftSelectedServer = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.height(36.dp)
|
|
||||||
) {
|
|
||||||
Text("X", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { showAddServerDialog = true },
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text("+ Add Server")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show selected server address
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Divider()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Network ---
|
|
||||||
SectionHeader("Network")
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Prefer IPv6",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = draftPreferIPv6,
|
|
||||||
onCheckedChange = { draftPreferIPv6 = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Divider()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Room ---
|
|
||||||
SectionHeader("Room")
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = draftRoomName,
|
|
||||||
onValueChange = { draftRoomName = it },
|
|
||||||
label = { Text("Default Room") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAddServerDialog) {
|
|
||||||
AddServerDialog(
|
|
||||||
onDismiss = { showAddServerDialog = false },
|
|
||||||
onAdd = { host, port, label ->
|
|
||||||
draftServers.add(ServerEntry("$host:$port", label))
|
|
||||||
showAddServerDialog = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showRestoreKeyDialog) {
|
|
||||||
RestoreKeyDialog(
|
|
||||||
onDismiss = { showRestoreKeyDialog = false },
|
|
||||||
onRestore = { hex ->
|
|
||||||
draftSeedHex = hex
|
|
||||||
showRestoreKeyDialog = false
|
|
||||||
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionHeader(title: String) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
val sign = if (gainDb >= 0) "+" else ""
|
|
||||||
Text(
|
|
||||||
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = gainDb,
|
|
||||||
onValueChange = onGainChange,
|
|
||||||
valueRange = -20f..20f,
|
|
||||||
steps = 0,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddServerDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onAdd: (host: String, port: String, label: String) -> Unit
|
|
||||||
) {
|
|
||||||
var host by remember { mutableStateOf("") }
|
|
||||||
var port by remember { mutableStateOf("4433") }
|
|
||||||
var label by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Add Server") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = host,
|
|
||||||
onValueChange = { host = it },
|
|
||||||
label = { Text("Host (IP or domain)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = port,
|
|
||||||
onValueChange = { port = it },
|
|
||||||
label = { Text("Port") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = label,
|
|
||||||
onValueChange = { label = it },
|
|
||||||
label = { Text("Label (optional)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (host.isNotBlank()) {
|
|
||||||
val displayLabel = label.ifBlank { host }
|
|
||||||
onAdd(host.trim(), port.trim(), displayLabel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { Text("Add") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RestoreKeyDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onRestore: (hex: String) -> Unit
|
|
||||||
) {
|
|
||||||
var keyInput by remember { mutableStateOf("") }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Restore Identity Key") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "Paste your 64-character hex key below. This will replace your current identity.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = keyInput,
|
|
||||||
onValueChange = {
|
|
||||||
keyInput = it.trim().lowercase()
|
|
||||||
error = null
|
|
||||||
},
|
|
||||||
label = { Text("Identity Key (hex)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = error != null
|
|
||||||
)
|
|
||||||
error?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val cleaned = keyInput.replace("\\s".toRegex(), "")
|
|
||||||
if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) {
|
|
||||||
error = "Key must be exactly 64 hex characters"
|
|
||||||
} else {
|
|
||||||
onRestore(cleaned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { Text("Restore") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<cache-path name="debug" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application") version "8.2.0" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
5
android/gradlew
vendored
5
android/gradlew
vendored
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Gradle wrapper script
|
|
||||||
APP_HOME=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
|
||||||
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "WZPhone"
|
|
||||||
include(":app")
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-android"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
description = "WarzonePhone Android native VoIP engine — Oboe audio, JNI bridge, call pipeline"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wzp-proto = { workspace = true }
|
|
||||||
wzp-codec = { workspace = true }
|
|
||||||
wzp-fec = { workspace = true }
|
|
||||||
wzp-crypto = { workspace = true }
|
|
||||||
wzp-transport = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
|
||||||
bytes = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = "1"
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
anyhow = "1"
|
|
||||||
libc = "0.2"
|
|
||||||
jni = { version = "0.21", default-features = false }
|
|
||||||
rand = { workspace = true }
|
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
|
||||||
tracing-android = "0.2"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
cc = "1"
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
|
|
||||||
if target.contains("android") {
|
|
||||||
// Override broken static getauxval from compiler-rt that crashes
|
|
||||||
// in shared libraries. Must be compiled first to take link priority.
|
|
||||||
cc::Build::new()
|
|
||||||
.file("cpp/getauxval_fix.c")
|
|
||||||
.compile("getauxval_fix");
|
|
||||||
|
|
||||||
let oboe_dir = fetch_oboe();
|
|
||||||
match oboe_dir {
|
|
||||||
Some(oboe_path) => {
|
|
||||||
println!("cargo:warning=Building with Oboe from {:?}", oboe_path);
|
|
||||||
|
|
||||||
let mut build = cc::Build::new();
|
|
||||||
build
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
// Use shared libc++ — avoids pulling in static libc stubs
|
|
||||||
// that crash in shared libraries (getauxval, pthread_create, etc.)
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.include("cpp")
|
|
||||||
.include(oboe_path.join("include"))
|
|
||||||
.include(oboe_path.join("src"))
|
|
||||||
.define("WZP_HAS_OBOE", None)
|
|
||||||
.file("cpp/oboe_bridge.cpp");
|
|
||||||
|
|
||||||
// Compile all Oboe source files
|
|
||||||
let src_dir = oboe_path.join("src");
|
|
||||||
add_cpp_files_recursive(&mut build, &src_dir);
|
|
||||||
|
|
||||||
build.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("cargo:warning=Oboe not found, building with stub");
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic C++ runtime — libc++_shared.so must be in jniLibs alongside
|
|
||||||
// libwzp_android.so. We copy it there from the NDK sysroot.
|
|
||||||
//
|
|
||||||
// WHY NOT STATIC: libc++_static.a + libc++abi.a transitively pull in
|
|
||||||
// object files from libc.a (static libc) which contain broken stubs for
|
|
||||||
// getauxval, __init_tcb, pthread_create, etc. These stubs only work in
|
|
||||||
// statically-linked executables. In shared libraries loaded by dlopen(),
|
|
||||||
// they SIGSEGV because the static libc init hasn't run.
|
|
||||||
// Google's official recommendation: use libc++_shared.so for native libs.
|
|
||||||
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME") {
|
|
||||||
let arch = if target.contains("aarch64") {
|
|
||||||
"aarch64-linux-android"
|
|
||||||
} else if target.contains("armv7") {
|
|
||||||
"arm-linux-androideabi"
|
|
||||||
} else if target.contains("x86_64") {
|
|
||||||
"x86_64-linux-android"
|
|
||||||
} else {
|
|
||||||
"aarch64-linux-android"
|
|
||||||
};
|
|
||||||
let lib_dir = format!(
|
|
||||||
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
|
|
||||||
);
|
|
||||||
println!("cargo:rustc-link-search=native={lib_dir}");
|
|
||||||
|
|
||||||
// Copy libc++_shared.so to the jniLibs directory
|
|
||||||
let shared_so = format!("{lib_dir}/libc++_shared.so");
|
|
||||||
if std::path::Path::new(&shared_so).exists() {
|
|
||||||
let jni_abi = if target.contains("aarch64") {
|
|
||||||
"arm64-v8a"
|
|
||||||
} else if target.contains("armv7") {
|
|
||||||
"armeabi-v7a"
|
|
||||||
} else {
|
|
||||||
"arm64-v8a"
|
|
||||||
};
|
|
||||||
// Try to copy to the Gradle jniLibs directory
|
|
||||||
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
|
||||||
let jni_dir = format!(
|
|
||||||
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
|
|
||||||
);
|
|
||||||
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
|
||||||
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
|
||||||
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oboe needs liblog and libOpenSLES from Android
|
|
||||||
println!("cargo:rustc-link-lib=log");
|
|
||||||
println!("cargo:rustc-link-lib=OpenSLES");
|
|
||||||
} else {
|
|
||||||
// Non-Android: always use stub
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively add all .cpp files from a directory to a cc::Build.
|
|
||||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entry in std::fs::read_dir(dir).unwrap() {
|
|
||||||
let entry = entry.unwrap();
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
add_cpp_files_recursive(build, &path);
|
|
||||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
|
||||||
build.file(&path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to find or fetch Oboe headers + source.
|
|
||||||
fn fetch_oboe() -> Option<PathBuf> {
|
|
||||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
|
||||||
let oboe_dir = out_dir.join("oboe");
|
|
||||||
|
|
||||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
|
||||||
return Some(oboe_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = std::process::Command::new("git")
|
|
||||||
.args([
|
|
||||||
"clone",
|
|
||||||
"--depth=1",
|
|
||||||
"--branch=1.8.1",
|
|
||||||
"https://github.com/google/oboe.git",
|
|
||||||
oboe_dir.to_str().unwrap(),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
|
|
||||||
match status {
|
|
||||||
Ok(s) if s.success() => {
|
|
||||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
|
||||||
Some(oboe_dir)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Override the broken static getauxval from compiler-rt/CRT.
|
|
||||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
|
||||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
|
||||||
// This version calls the real bionic getauxval via dlsym.
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
|
||||||
|
|
||||||
unsigned long getauxval(unsigned long type) {
|
|
||||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
|
||||||
if (!real_getauxval) {
|
|
||||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
|
||||||
if (!real_getauxval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return real_getauxval(type);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// Full Oboe implementation for Android
|
|
||||||
// This file is compiled only when targeting Android
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <oboe/Oboe.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <cstring>
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
#define LOG_TAG "wzp-oboe"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ring buffer helpers (SPSC, lock-free)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
int32_t avail = w - r;
|
|
||||||
if (avail < 0) avail += capacity;
|
|
||||||
return avail;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
|
||||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
|
||||||
const int16_t* src, int32_t count) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
buf[w] = src[i];
|
|
||||||
w++;
|
|
||||||
if (w >= capacity) w = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
|
||||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
|
||||||
int16_t* dst, int32_t count) {
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
dst[i] = buf[r];
|
|
||||||
r++;
|
|
||||||
if (r >= capacity) r = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
|
||||||
static const WzpOboeRings* g_rings = nullptr;
|
|
||||||
static std::atomic<bool> g_running{false};
|
|
||||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
|
||||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Capture callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_write(g_rings->capture_write_idx,
|
|
||||||
g_rings->capture_read_idx,
|
|
||||||
g_rings->capture_capacity);
|
|
||||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
|
||||||
if (to_write > 0) {
|
|
||||||
ring_write(g_rings->capture_buf, g_rings->capture_capacity,
|
|
||||||
g_rings->capture_write_idx, g_rings->capture_read_idx,
|
|
||||||
src, to_write);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Playout callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
|
||||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_read(g_rings->playout_write_idx,
|
|
||||||
g_rings->playout_read_idx,
|
|
||||||
g_rings->playout_capacity);
|
|
||||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
|
||||||
|
|
||||||
if (to_read > 0) {
|
|
||||||
ring_read(g_rings->playout_buf, g_rings->playout_capacity,
|
|
||||||
g_rings->playout_write_idx, g_rings->playout_read_idx,
|
|
||||||
dst, to_read);
|
|
||||||
}
|
|
||||||
// Fill remainder with silence on underrun
|
|
||||||
if (to_read < numFrames) {
|
|
||||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static CaptureCallback g_capture_cb;
|
|
||||||
static PlayoutCallback g_playout_cb;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public C API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
if (g_running.load(std::memory_order_relaxed)) {
|
|
||||||
LOGW("wzp_oboe_start: already running");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_rings = rings;
|
|
||||||
|
|
||||||
// Build capture stream
|
|
||||||
oboe::AudioStreamBuilder captureBuilder;
|
|
||||||
captureBuilder.setDirection(oboe::Direction::Input)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_capture_cb);
|
|
||||||
|
|
||||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build playout stream
|
|
||||||
oboe::AudioStreamBuilder playoutBuilder;
|
|
||||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setUsage(oboe::Usage::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_playout_cb);
|
|
||||||
|
|
||||||
result = playoutBuilder.openStream(g_playout_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_running.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Start both streams
|
|
||||||
result = g_capture_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = g_playout_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -5;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
|
||||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
|
|
||||||
if (g_capture_stream) {
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
}
|
|
||||||
if (g_playout_stream) {
|
|
||||||
g_playout_stream->requestStop();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
g_rings = nullptr;
|
|
||||||
LOGI("Oboe stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
|
||||||
// Provide empty implementations just in case.
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config; (void)rings;
|
|
||||||
return -99;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {}
|
|
||||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
|
||||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
|
||||||
int wzp_oboe_is_running(void) { return 0; }
|
|
||||||
|
|
||||||
#endif // __ANDROID__
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#ifndef WZP_OBOE_BRIDGE_H
|
|
||||||
#define WZP_OBOE_BRIDGE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#include <atomic>
|
|
||||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
|
||||||
extern "C" {
|
|
||||||
#else
|
|
||||||
#include <stdatomic.h>
|
|
||||||
typedef atomic_int wzp_atomic_int;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t sample_rate;
|
|
||||||
int32_t frames_per_burst;
|
|
||||||
int32_t channel_count;
|
|
||||||
} WzpOboeConfig;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int16_t* capture_buf;
|
|
||||||
int32_t capture_capacity;
|
|
||||||
wzp_atomic_int* capture_write_idx;
|
|
||||||
wzp_atomic_int* capture_read_idx;
|
|
||||||
|
|
||||||
int16_t* playout_buf;
|
|
||||||
int32_t playout_capacity;
|
|
||||||
wzp_atomic_int* playout_write_idx;
|
|
||||||
wzp_atomic_int* playout_read_idx;
|
|
||||||
} WzpOboeRings;
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
|
||||||
void wzp_oboe_stop(void);
|
|
||||||
float wzp_oboe_capture_latency_ms(void);
|
|
||||||
float wzp_oboe_playout_latency_ms(void);
|
|
||||||
int wzp_oboe_is_running(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // WZP_OBOE_BRIDGE_H
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config;
|
|
||||||
(void)rings;
|
|
||||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
//! Lock-free SPSC ring buffer audio backend for Android (Oboe).
|
|
||||||
//!
|
|
||||||
//! The ring buffers are shared between Rust and C++: the Oboe callbacks
|
|
||||||
//! (running on a high-priority audio thread) read/write directly into
|
|
||||||
//! the buffers via atomic indices, while the Rust codec thread on the
|
|
||||||
//! other side does the same.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
|
|
||||||
use tracing::info;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Default ring buffer capacity: 8 frames = 160 ms at 48 kHz.
|
|
||||||
const RING_CAPACITY: usize = 7680;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FFI declarations matching oboe_bridge.h
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct WzpOboeConfig {
|
|
||||||
sample_rate: i32,
|
|
||||||
frames_per_burst: i32,
|
|
||||||
channel_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct WzpOboeRings {
|
|
||||||
capture_buf: *mut i16,
|
|
||||||
capture_capacity: i32,
|
|
||||||
capture_write_idx: *mut AtomicI32,
|
|
||||||
capture_read_idx: *mut AtomicI32,
|
|
||||||
|
|
||||||
playout_buf: *mut i16,
|
|
||||||
playout_capacity: i32,
|
|
||||||
playout_write_idx: *mut AtomicI32,
|
|
||||||
playout_read_idx: *mut AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl Send for WzpOboeRings {}
|
|
||||||
unsafe impl Sync for WzpOboeRings {}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
|
||||||
fn wzp_oboe_stop();
|
|
||||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_is_running() -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SPSC Ring Buffer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Single-producer single-consumer lock-free ring buffer.
|
|
||||||
///
|
|
||||||
/// The producer calls `write()` and the consumer calls `read()`.
|
|
||||||
/// Atomics use acquire/release ordering to ensure correct visibility
|
|
||||||
/// across the Oboe audio thread and the Rust codec thread.
|
|
||||||
pub struct RingBuffer {
|
|
||||||
buf: Vec<i16>,
|
|
||||||
capacity: usize,
|
|
||||||
write_idx: AtomicI32,
|
|
||||||
read_idx: AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
/// Create a new ring buffer with the given capacity (in samples).
|
|
||||||
///
|
|
||||||
/// The actual usable capacity is `capacity - 1` to distinguish
|
|
||||||
/// full from empty.
|
|
||||||
pub fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; capacity],
|
|
||||||
capacity,
|
|
||||||
write_idx: AtomicI32::new(0),
|
|
||||||
read_idx: AtomicI32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples available to read.
|
|
||||||
pub fn available_read(&self) -> usize {
|
|
||||||
let w = self.write_idx.load(Ordering::Acquire);
|
|
||||||
let r = self.read_idx.load(Ordering::Relaxed);
|
|
||||||
let avail = w - r;
|
|
||||||
if avail < 0 {
|
|
||||||
(avail + self.capacity as i32) as usize
|
|
||||||
} else {
|
|
||||||
avail as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples that can be written before the buffer is full.
|
|
||||||
pub fn available_write(&self) -> usize {
|
|
||||||
self.capacity - 1 - self.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write samples into the ring buffer (producer side).
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written (may be less than
|
|
||||||
/// `data.len()` if the buffer is nearly full).
|
|
||||||
pub fn write(&self, data: &[i16]) -> usize {
|
|
||||||
let avail = self.available_write();
|
|
||||||
let count = data.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
// SAFETY: w is always in [0, capacity) and we are the sole producer.
|
|
||||||
unsafe {
|
|
||||||
*buf_ptr.add(w) = data[i];
|
|
||||||
}
|
|
||||||
w += 1;
|
|
||||||
if w >= cap {
|
|
||||||
w = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.write_idx.store(w as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read samples from the ring buffer (consumer side).
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually read (may be less than
|
|
||||||
/// `out.len()` if the buffer doesn't have enough data).
|
|
||||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let avail = self.available_read();
|
|
||||||
let count = out.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr();
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
// SAFETY: r is always in [0, capacity) and we are the sole consumer.
|
|
||||||
unsafe {
|
|
||||||
out[i] = *buf_ptr.add(r);
|
|
||||||
}
|
|
||||||
r += 1;
|
|
||||||
if r >= cap {
|
|
||||||
r = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.read_idx.store(r as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the buffer data (for FFI).
|
|
||||||
fn buf_ptr(&self) -> *mut i16 {
|
|
||||||
self.buf.as_ptr() as *mut i16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the write index atomic (for FFI).
|
|
||||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a raw pointer to the read index atomic (for FFI).
|
|
||||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: The ring buffer is designed for SPSC use where producer and consumer
|
|
||||||
// are on different threads. The atomic indices provide the synchronization.
|
|
||||||
unsafe impl Send for RingBuffer {}
|
|
||||||
unsafe impl Sync for RingBuffer {}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Oboe Backend
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Oboe-based audio backend for Android.
|
|
||||||
///
|
|
||||||
/// Owns two SPSC ring buffers (capture and playout) that are shared with
|
|
||||||
/// the C++ Oboe callbacks via raw pointers. The Oboe callbacks run on
|
|
||||||
/// high-priority audio threads managed by the Android audio system.
|
|
||||||
pub struct OboeBackend {
|
|
||||||
capture_ring: RingBuffer,
|
|
||||||
playout_ring: RingBuffer,
|
|
||||||
started: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OboeBackend {
|
|
||||||
/// Create a new backend with default ring buffer sizes (160 ms each).
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
capture_ring: RingBuffer::new(RING_CAPACITY),
|
|
||||||
playout_ring: RingBuffer::new(RING_CAPACITY),
|
|
||||||
started: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start Oboe audio streams.
|
|
||||||
///
|
|
||||||
/// This sets up the ring buffer pointers and calls into the C++ layer
|
|
||||||
/// to open and start the capture and playout Oboe streams.
|
|
||||||
pub fn start(&mut self) -> Result<(), anyhow::Error> {
|
|
||||||
if self.started {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: self.capture_ring.buf_ptr(),
|
|
||||||
capture_capacity: self.capture_ring.capacity as i32,
|
|
||||||
capture_write_idx: self.capture_ring.write_idx_ptr(),
|
|
||||||
capture_read_idx: self.capture_ring.read_idx_ptr(),
|
|
||||||
|
|
||||||
playout_buf: self.playout_ring.buf_ptr(),
|
|
||||||
playout_capacity: self.playout_ring.capacity as i32,
|
|
||||||
playout_write_idx: self.playout_ring.write_idx_ptr(),
|
|
||||||
playout_read_idx: self.playout_ring.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret != 0 {
|
|
||||||
return Err(anyhow::anyhow!("wzp_oboe_start failed with code {}", ret));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.started = true;
|
|
||||||
info!("Oboe backend started");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Oboe audio streams.
|
|
||||||
pub fn stop(&mut self) {
|
|
||||||
if !self.started {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
self.started = false;
|
|
||||||
info!("Oboe backend stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured audio samples from the capture ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually read. The caller should
|
|
||||||
/// provide a buffer of at least `FRAME_SAMPLES` (960) samples.
|
|
||||||
pub fn read_capture(&self, out: &mut [i16]) -> usize {
|
|
||||||
self.capture_ring.read(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write audio samples to the playout ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written.
|
|
||||||
pub fn write_playout(&self, samples: &[i16]) -> usize {
|
|
||||||
self.playout_ring.write(samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current capture latency in milliseconds (from Oboe).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn capture_latency_ms(&self) -> f32 {
|
|
||||||
unsafe { wzp_oboe_capture_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current playout latency in milliseconds (from Oboe).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn playout_latency_ms(&self) -> f32 {
|
|
||||||
unsafe { wzp_oboe_playout_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the Oboe streams are currently running.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
unsafe { wzp_oboe_is_running() != 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for OboeBackend {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Thread affinity / priority helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Pin the current thread to the highest-numbered CPU cores (big cores on
|
|
||||||
/// ARM big.LITTLE architectures). Falls back silently on failure.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn pin_to_big_core() {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
unsafe {
|
|
||||||
let num_cpus = libc::sysconf(libc::_SC_NPROCESSORS_ONLN);
|
|
||||||
if num_cpus <= 0 {
|
|
||||||
warn!("pin_to_big_core: could not determine CPU count");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let num_cpus = num_cpus as usize;
|
|
||||||
|
|
||||||
// Target the upper half of CPUs (big cores on most big.LITTLE SoCs)
|
|
||||||
let start = num_cpus / 2;
|
|
||||||
let mut set: libc::cpu_set_t = std::mem::zeroed();
|
|
||||||
libc::CPU_ZERO(&mut set);
|
|
||||||
for cpu in start..num_cpus {
|
|
||||||
libc::CPU_SET(cpu, &mut set);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ret = libc::sched_setaffinity(
|
|
||||||
0, // current thread
|
|
||||||
std::mem::size_of::<libc::cpu_set_t>(),
|
|
||||||
&set,
|
|
||||||
);
|
|
||||||
if ret != 0 {
|
|
||||||
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
|
||||||
} else {
|
|
||||||
info!(start, num_cpus, "pinned to big cores");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
// No-op on non-Android
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to set SCHED_FIFO real-time priority for the current thread.
|
|
||||||
/// Falls back silently on failure (requires appropriate permissions on Android).
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn set_realtime_priority() {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
unsafe {
|
|
||||||
let param = libc::sched_param {
|
|
||||||
sched_priority: 2, // Low RT priority — enough for audio, safe
|
|
||||||
};
|
|
||||||
let ret = libc::sched_setscheduler(0, libc::SCHED_FIFO, ¶m);
|
|
||||||
if ret != 0 {
|
|
||||||
warn!(
|
|
||||||
"sched_setscheduler(SCHED_FIFO) failed: {}",
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!("set SCHED_FIFO priority 2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
// No-op on non-Android
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_write_read() {
|
|
||||||
let ring = RingBuffer::new(16);
|
|
||||||
let data = [1i16, 2, 3, 4, 5];
|
|
||||||
assert_eq!(ring.write(&data), 5);
|
|
||||||
assert_eq!(ring.available_read(), 5);
|
|
||||||
|
|
||||||
let mut out = [0i16; 5];
|
|
||||||
assert_eq!(ring.read(&mut out), 5);
|
|
||||||
assert_eq!(out, [1, 2, 3, 4, 5]);
|
|
||||||
assert_eq!(ring.available_read(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_wraparound() {
|
|
||||||
let ring = RingBuffer::new(8);
|
|
||||||
let data = [10i16, 20, 30, 40, 50, 60]; // 6 samples, capacity 8 (usable 7)
|
|
||||||
assert_eq!(ring.write(&data), 6);
|
|
||||||
|
|
||||||
let mut out = [0i16; 4];
|
|
||||||
assert_eq!(ring.read(&mut out), 4);
|
|
||||||
assert_eq!(out, [10, 20, 30, 40]);
|
|
||||||
|
|
||||||
// Now write more, which should wrap around
|
|
||||||
let data2 = [70i16, 80, 90, 100];
|
|
||||||
assert_eq!(ring.write(&data2), 4);
|
|
||||||
|
|
||||||
let mut out2 = [0i16; 6];
|
|
||||||
assert_eq!(ring.read(&mut out2), 6);
|
|
||||||
assert_eq!(out2, [50, 60, 70, 80, 90, 100]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ring_buffer_full() {
|
|
||||||
let ring = RingBuffer::new(4); // usable capacity = 3
|
|
||||||
let data = [1i16, 2, 3, 4, 5];
|
|
||||||
assert_eq!(ring.write(&data), 3); // Only 3 fit
|
|
||||||
assert_eq!(ring.available_write(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oboe_backend_stub_start_stop() {
|
|
||||||
let mut backend = OboeBackend::new();
|
|
||||||
backend.start().expect("stub start should succeed");
|
|
||||||
assert!(backend.started);
|
|
||||||
backend.stop();
|
|
||||||
assert!(!backend.started);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
|
||||||
//!
|
|
||||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
|
||||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
|
||||||
//!
|
|
||||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
|
||||||
//! old buffer data. The reader detects the lap via `available() >
|
|
||||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
|
||||||
//!
|
|
||||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
|
||||||
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
|
|
||||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
|
||||||
const RING_CAPACITY: usize = 16384; // 2^14
|
|
||||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
|
||||||
|
|
||||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
|
||||||
pub struct AudioRing {
|
|
||||||
buf: Box<[i16]>,
|
|
||||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
|
||||||
write_pos: AtomicUsize,
|
|
||||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
|
||||||
read_pos: AtomicUsize,
|
|
||||||
/// Incremented by reader when it detects it was lapped (overflow).
|
|
||||||
overflow_count: AtomicU64,
|
|
||||||
/// Incremented by reader when ring is empty (underrun).
|
|
||||||
underrun_count: AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
|
||||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
|
||||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
|
||||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
|
||||||
unsafe impl Send for AudioRing {}
|
|
||||||
unsafe impl Sync for AudioRing {}
|
|
||||||
|
|
||||||
impl AudioRing {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
|
||||||
write_pos: AtomicUsize::new(0),
|
|
||||||
read_pos: AtomicUsize::new(0),
|
|
||||||
overflow_count: AtomicU64::new(0),
|
|
||||||
underrun_count: AtomicU64::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples available to read (clamped to capacity).
|
|
||||||
pub fn available(&self) -> usize {
|
|
||||||
let w = self.write_pos.load(Ordering::Acquire);
|
|
||||||
let r = self.read_pos.load(Ordering::Relaxed);
|
|
||||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of samples that can be written without overwriting unread data.
|
|
||||||
pub fn free_space(&self) -> usize {
|
|
||||||
RING_CAPACITY.saturating_sub(self.available())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write samples into the ring. Returns number of samples written.
|
|
||||||
///
|
|
||||||
/// If the ring is full, old data is silently overwritten. The reader
|
|
||||||
/// will detect the lap and self-correct. The writer NEVER touches
|
|
||||||
/// `read_pos` — this is the key invariant that prevents cursor desync.
|
|
||||||
pub fn write(&self, samples: &[i16]) -> usize {
|
|
||||||
let count = samples.len().min(RING_CAPACITY);
|
|
||||||
let w = self.write_pos.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
unsafe {
|
|
||||||
let ptr = self.buf.as_ptr() as *mut i16;
|
|
||||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
|
||||||
///
|
|
||||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
|
||||||
/// forward to the oldest valid data. This is safe because only the
|
|
||||||
/// reader thread writes `read_pos`.
|
|
||||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let w = self.write_pos.load(Ordering::Acquire);
|
|
||||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
let mut avail = w.wrapping_sub(r);
|
|
||||||
|
|
||||||
// Lap detection: writer has overwritten our unread data.
|
|
||||||
// Snap read_pos forward to oldest valid data in the buffer.
|
|
||||||
if avail > RING_CAPACITY {
|
|
||||||
r = w.wrapping_sub(RING_CAPACITY);
|
|
||||||
avail = RING_CAPACITY;
|
|
||||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = out.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
if w == r {
|
|
||||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
|
||||||
}
|
|
||||||
|
|
||||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of overflow events (reader was lapped by writer).
|
|
||||||
pub fn overflow_count(&self) -> u64 {
|
|
||||||
self.overflow_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of underrun events (reader found empty buffer).
|
|
||||||
pub fn underrun_count(&self) -> u64 {
|
|
||||||
self.underrun_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
//! Engine commands sent from the JNI/UI thread to the engine.
|
|
||||||
|
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
/// Commands that can be sent to the running engine.
|
|
||||||
pub enum EngineCommand {
|
|
||||||
/// Mute or unmute the microphone.
|
|
||||||
SetMute(bool),
|
|
||||||
/// Enable or disable speaker (loudspeaker) mode.
|
|
||||||
SetSpeaker(bool),
|
|
||||||
/// Force a specific quality profile (overrides adaptive logic).
|
|
||||||
ForceProfile(QualityProfile),
|
|
||||||
/// Stop the call and shut down the engine.
|
|
||||||
Stop,
|
|
||||||
/// Place a direct call to a fingerprint (requires signal connection).
|
|
||||||
PlaceCall { target_fingerprint: String },
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
AnswerCall {
|
|
||||||
call_id: String,
|
|
||||||
accept_mode: wzp_proto::CallAcceptMode,
|
|
||||||
},
|
|
||||||
/// Reject an incoming direct call.
|
|
||||||
RejectCall { call_id: String },
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +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();
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Direct calling JNI functions ──
|
|
||||||
|
|
||||||
/// Start persistent signaling connection to relay for direct calls.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
relay_addr_j: JString,
|
|
||||||
seed_hex_j: JString,
|
|
||||||
token_j: JString,
|
|
||||||
alias_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let relay_addr: String = env.get_string(&relay_addr_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();
|
|
||||||
|
|
||||||
h.engine.start_signaling(
|
|
||||||
&relay_addr,
|
|
||||||
&seed_hex,
|
|
||||||
if token.is_empty() { None } else { Some(&token) },
|
|
||||||
if alias.is_empty() { None } else { Some(&alias) },
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("start_signaling panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
target_fp_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
h.engine.place_call(&target)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("place_call panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
call_id_j: JString,
|
|
||||||
mode: jint,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_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,
|
|
||||||
};
|
|
||||||
h.engine.answer_call(&call_id, accept_mode)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("answer_call panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +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 stats;
|
|
||||||
pub mod jni_bridge;
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
//! Codec pipeline — encode/decode with FEC and jitter buffer.
|
|
||||||
//!
|
|
||||||
//! Runs on a dedicated thread, processing 20 ms frames at 48 kHz.
|
|
||||||
//! The pipeline is NOT Send/Sync (Opus encoder state) — it is owned
|
|
||||||
//! exclusively by the codec thread.
|
|
||||||
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
|
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
|
||||||
use wzp_proto::quality::AdaptiveQualityController;
|
|
||||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
|
||||||
use wzp_proto::traits::QualityController;
|
|
||||||
use wzp_proto::{MediaPacket, QualityProfile};
|
|
||||||
|
|
||||||
use crate::audio_android::FRAME_SAMPLES;
|
|
||||||
|
|
||||||
/// Maximum encoded frame size (Opus worst case at highest bitrate).
|
|
||||||
const MAX_ENCODED_BYTES: usize = 1275;
|
|
||||||
|
|
||||||
/// Pipeline statistics snapshot.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct PipelineStats {
|
|
||||||
pub frames_encoded: u64,
|
|
||||||
pub frames_decoded: u64,
|
|
||||||
pub underruns: u64,
|
|
||||||
pub jitter_depth: usize,
|
|
||||||
pub quality_tier: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The codec pipeline: encode, FEC, jitter buffer, decode.
|
|
||||||
///
|
|
||||||
/// This struct is owned by the codec thread and not shared.
|
|
||||||
pub struct Pipeline {
|
|
||||||
encoder: AdaptiveEncoder,
|
|
||||||
decoder: AdaptiveDecoder,
|
|
||||||
fec_encoder: RaptorQFecEncoder,
|
|
||||||
fec_decoder: RaptorQFecDecoder,
|
|
||||||
jitter_buffer: JitterBuffer,
|
|
||||||
quality_ctrl: AdaptiveQualityController,
|
|
||||||
/// Acoustic echo canceller applied before encoding.
|
|
||||||
aec: EchoCanceller,
|
|
||||||
/// Automatic gain control applied before encoding.
|
|
||||||
agc: AutoGainControl,
|
|
||||||
/// Last decoded PCM frame, used as the AEC far-end reference.
|
|
||||||
last_decoded_farend: Option<Vec<i16>>,
|
|
||||||
// Pre-allocated scratch buffers
|
|
||||||
capture_buf: Vec<i16>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
playout_buf: Vec<i16>,
|
|
||||||
encode_out: Vec<u8>,
|
|
||||||
// Stats counters
|
|
||||||
frames_encoded: u64,
|
|
||||||
frames_decoded: u64,
|
|
||||||
underruns: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Pipeline {
|
|
||||||
/// Create a new pipeline configured for the given quality profile.
|
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
|
||||||
let encoder = AdaptiveEncoder::new(profile)
|
|
||||||
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
|
||||||
let decoder = AdaptiveDecoder::new(profile)
|
|
||||||
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
|
||||||
let fec_encoder =
|
|
||||||
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
|
||||||
let fec_decoder =
|
|
||||||
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
|
||||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
|
||||||
let quality_ctrl = AdaptiveQualityController::new();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
encoder,
|
|
||||||
decoder,
|
|
||||||
fec_encoder,
|
|
||||||
fec_decoder,
|
|
||||||
jitter_buffer,
|
|
||||||
quality_ctrl,
|
|
||||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
|
||||||
agc: AutoGainControl::new(),
|
|
||||||
last_decoded_farend: None,
|
|
||||||
capture_buf: vec![0i16; FRAME_SAMPLES],
|
|
||||||
playout_buf: vec![0i16; FRAME_SAMPLES],
|
|
||||||
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
|
||||||
frames_encoded: 0,
|
|
||||||
frames_decoded: 0,
|
|
||||||
underruns: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a PCM frame into a compressed packet.
|
|
||||||
///
|
|
||||||
/// If `muted` is true, a silence frame is encoded (all zeros).
|
|
||||||
/// Returns the encoded bytes, or `None` on encoder error.
|
|
||||||
pub fn encode_frame(&mut self, pcm: &[i16], muted: bool) -> Option<Vec<u8>> {
|
|
||||||
let input = if muted {
|
|
||||||
// Zero the capture buffer for silence
|
|
||||||
for s in self.capture_buf.iter_mut() {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
&self.capture_buf[..]
|
|
||||||
} else {
|
|
||||||
// Feed the last decoded playout as AEC far-end reference.
|
|
||||||
if let Some(ref farend) = self.last_decoded_farend {
|
|
||||||
self.aec.feed_farend(farend);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply AEC + AGC to the captured PCM.
|
|
||||||
let len = pcm.len().min(self.capture_buf.len());
|
|
||||||
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
|
|
||||||
self.aec.process_frame(&mut self.capture_buf[..len]);
|
|
||||||
self.agc.process_frame(&mut self.capture_buf[..len]);
|
|
||||||
&self.capture_buf[..len]
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.encoder.encode(input, &mut self.encode_out) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_encoded += 1;
|
|
||||||
let encoded = self.encode_out[..n].to_vec();
|
|
||||||
|
|
||||||
// Feed into FEC encoder
|
|
||||||
if let Err(e) = self.fec_encoder.add_source_symbol(&encoded) {
|
|
||||||
warn!("FEC encode error: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(encoded)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("encode error: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a received media packet into the jitter buffer.
|
|
||||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
|
||||||
// Feed FEC symbols if present
|
|
||||||
let header = &packet.header;
|
|
||||||
if header.fec_block != 0 || header.fec_symbol != 0 {
|
|
||||||
let is_repair = header.is_repair;
|
|
||||||
if let Err(e) = self.fec_decoder.add_symbol(
|
|
||||||
header.fec_block,
|
|
||||||
header.fec_symbol,
|
|
||||||
is_repair,
|
|
||||||
&packet.payload,
|
|
||||||
) {
|
|
||||||
debug!("FEC symbol feed error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.jitter_buffer.push(packet);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the next frame from the jitter buffer.
|
|
||||||
///
|
|
||||||
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
|
||||||
/// Decoded PCM is also stored as the AEC far-end reference for the next
|
|
||||||
/// encode cycle.
|
|
||||||
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
|
||||||
let result = match self.jitter_buffer.pop() {
|
|
||||||
PlayoutResult::Packet(pkt) => {
|
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
|
||||||
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_decoded += 1;
|
|
||||||
pcm.truncate(n);
|
|
||||||
Some(pcm)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("decode error: {e}");
|
|
||||||
// Attempt PLC
|
|
||||||
self.generate_plc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PlayoutResult::Missing { seq } => {
|
|
||||||
debug!(seq, "jitter buffer: missing packet, generating PLC");
|
|
||||||
self.generate_plc()
|
|
||||||
}
|
|
||||||
PlayoutResult::NotReady => {
|
|
||||||
self.underruns += 1;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save decoded PCM as far-end reference for AEC.
|
|
||||||
if let Some(ref pcm) = result {
|
|
||||||
self.last_decoded_farend = Some(pcm.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate packet loss concealment output.
|
|
||||||
fn generate_plc(&mut self) -> Option<Vec<i16>> {
|
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
|
||||||
match self.decoder.decode_lost(&mut pcm) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.frames_decoded += 1;
|
|
||||||
pcm.truncate(n);
|
|
||||||
Some(pcm)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("PLC error: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a quality report into the adaptive quality controller.
|
|
||||||
///
|
|
||||||
/// Returns a new profile if a tier transition occurred.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn observe_quality(
|
|
||||||
&mut self,
|
|
||||||
report: &wzp_proto::QualityReport,
|
|
||||||
) -> Option<QualityProfile> {
|
|
||||||
let new_profile = self.quality_ctrl.observe(report);
|
|
||||||
if let Some(ref profile) = new_profile {
|
|
||||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
|
||||||
warn!("encoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = self.decoder.set_profile(*profile) {
|
|
||||||
warn!("decoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
new_profile
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force a specific quality profile.
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn force_profile(&mut self, profile: QualityProfile) {
|
|
||||||
self.quality_ctrl.force_profile(profile);
|
|
||||||
if let Err(e) = self.encoder.set_profile(profile) {
|
|
||||||
warn!("encoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = self.decoder.set_profile(profile) {
|
|
||||||
warn!("decoder set_profile error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current pipeline statistics.
|
|
||||||
pub fn stats(&self) -> PipelineStats {
|
|
||||||
PipelineStats {
|
|
||||||
frames_encoded: self.frames_encoded,
|
|
||||||
frames_decoded: self.frames_decoded,
|
|
||||||
underruns: self.underruns,
|
|
||||||
jitter_depth: self.jitter_buffer.stats().current_depth,
|
|
||||||
quality_tier: self.quality_ctrl.tier() as u8,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable acoustic echo cancellation.
|
|
||||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
|
||||||
self.aec.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable automatic gain control.
|
|
||||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
|
||||||
self.agc.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
//! Call statistics for the Android engine.
|
|
||||||
|
|
||||||
/// State of the call.
|
|
||||||
/// Serializes as integer for easy parsing on the Kotlin side:
|
|
||||||
/// 0=Idle, 1=Connecting, 2=Active, 3=Reconnecting, 4=Closed
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub enum CallState {
|
|
||||||
#[default]
|
|
||||||
Idle,
|
|
||||||
Connecting,
|
|
||||||
Active,
|
|
||||||
Reconnecting,
|
|
||||||
Closed,
|
|
||||||
/// Connected to relay signal channel, registered for direct calls.
|
|
||||||
Registered,
|
|
||||||
/// Outgoing call ringing on callee's side.
|
|
||||||
Ringing,
|
|
||||||
/// Incoming call received, waiting for user to accept/reject.
|
|
||||||
IncomingCall,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl serde::Serialize for CallState {
|
|
||||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
let n: u8 = match self {
|
|
||||||
CallState::Idle => 0,
|
|
||||||
CallState::Connecting => 1,
|
|
||||||
CallState::Active => 2,
|
|
||||||
CallState::Reconnecting => 3,
|
|
||||||
CallState::Closed => 4,
|
|
||||||
CallState::Registered => 5,
|
|
||||||
CallState::Ringing => 6,
|
|
||||||
CallState::IncomingCall => 7,
|
|
||||||
};
|
|
||||||
serializer.serialize_u8(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregated call statistics, serializable for JNI bridge.
|
|
||||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
|
||||||
pub struct CallStats {
|
|
||||||
/// Current call state.
|
|
||||||
pub state: CallState,
|
|
||||||
/// Call duration in seconds.
|
|
||||||
pub duration_secs: f64,
|
|
||||||
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
|
|
||||||
pub quality_tier: u8,
|
|
||||||
/// Observed packet loss percentage.
|
|
||||||
pub loss_pct: f32,
|
|
||||||
/// Smoothed round-trip time in milliseconds.
|
|
||||||
pub rtt_ms: u32,
|
|
||||||
/// Jitter in milliseconds.
|
|
||||||
pub jitter_ms: u32,
|
|
||||||
/// Current jitter buffer depth in packets.
|
|
||||||
pub jitter_buffer_depth: usize,
|
|
||||||
/// Total frames encoded since call start.
|
|
||||||
pub frames_encoded: u64,
|
|
||||||
/// Total frames decoded since call start.
|
|
||||||
pub frames_decoded: u64,
|
|
||||||
/// Number of playout underruns (buffer empty when audio needed).
|
|
||||||
pub underruns: u64,
|
|
||||||
/// Frames recovered by FEC.
|
|
||||||
pub fec_recovered: u64,
|
|
||||||
/// Playout ring overflow count (reader was lapped by writer).
|
|
||||||
pub playout_overflows: u64,
|
|
||||||
/// Playout ring underrun count (reader found empty buffer).
|
|
||||||
pub playout_underruns: u64,
|
|
||||||
/// Capture ring overflow count.
|
|
||||||
pub capture_overflows: u64,
|
|
||||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
|
||||||
pub audio_level: u32,
|
|
||||||
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
|
||||||
pub current_codec: String,
|
|
||||||
/// Last seen incoming codec from other participants.
|
|
||||||
pub peer_codec: String,
|
|
||||||
/// Whether auto quality mode is active.
|
|
||||||
pub auto_mode: bool,
|
|
||||||
/// Number of participants in the room (from last RoomUpdate).
|
|
||||||
pub room_participant_count: u32,
|
|
||||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
|
||||||
pub room_participants: Vec<RoomMember>,
|
|
||||||
/// SAS code for verbal verification (None if not in a call).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sas_code: Option<u32>,
|
|
||||||
/// Incoming call info (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_call_id: Option<String>,
|
|
||||||
/// Fingerprint of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_fp: Option<String>,
|
|
||||||
/// Alias of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_alias: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A room member entry, serialized into the stats JSON.
|
|
||||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
|
||||||
pub struct RoomMember {
|
|
||||||
pub fingerprint: String,
|
|
||||||
pub alias: Option<String>,
|
|
||||||
pub relay_label: Option<String>,
|
|
||||||
}
|
|
||||||
@@ -23,71 +23,10 @@ serde_json = "1"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
|
||||||
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
|
||||||
# pulling in a crate that can only link against Apple frameworks.
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
coreaudio-rs = { version = "0.11", optional = true }
|
|
||||||
|
|
||||||
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
|
|
||||||
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
|
|
||||||
# only the audio + COM subfeatures we need — the crate is organized as
|
|
||||||
# a massive optional-feature tree, so enabling just these keeps compile
|
|
||||||
# times reasonable (~5s for these features vs ~60s for the full crate).
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
windows = { version = "0.58", optional = true, features = [
|
|
||||||
"Win32_Foundation",
|
|
||||||
"Win32_Media_Audio",
|
|
||||||
"Win32_Security",
|
|
||||||
"Win32_System_Com",
|
|
||||||
"Win32_System_Com_StructuredStorage",
|
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_Variant",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
|
|
||||||
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
|
|
||||||
# webrtc-audio-processing` crate, which links against Debian's
|
|
||||||
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
|
|
||||||
#
|
|
||||||
# Note: we attempted the 2.x line with its `bundled` sub-feature first
|
|
||||||
# (which would give us AEC3 instead of AEC2), but both the crates.io
|
|
||||||
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
|
|
||||||
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
|
|
||||||
# --reconfigure unconditionally even on first-run empty build dirs,
|
|
||||||
# causing the bundled build to fail with "Directory does not contain a
|
|
||||||
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
|
|
||||||
# this entirely by linking the apt-provided library. AEC2 is older than
|
|
||||||
# AEC3 but still the same algorithm family — this is what PulseAudio's
|
|
||||||
# module-echo-cancel and PipeWire's filter-chain use by default on
|
|
||||||
# current Debian-family distros.
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
webrtc-audio-processing = { version = "0.3", optional = true }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
|
|
||||||
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
|
|
||||||
# module is also #[cfg(target_os = "macos")] in lib.rs).
|
|
||||||
vpio = ["dep:coreaudio-rs"]
|
|
||||||
# windows-aec enables a direct WASAPI capture backend that opens the
|
|
||||||
# microphone under AudioCategory_Communications, turning on Windows's
|
|
||||||
# OS-level communications audio processing (AEC + noise suppression +
|
|
||||||
# AGC). The `windows` dep is itself target-gated to Windows above, so
|
|
||||||
# enabling this feature on non-Windows targets is a no-op (the
|
|
||||||
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
|
|
||||||
windows-aec = ["dep:windows"]
|
|
||||||
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
|
|
||||||
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
|
|
||||||
# Teams) in-process, using the playback PCM as the reference signal for
|
|
||||||
# echo cancellation. The webrtc-audio-processing dep is target-gated to
|
|
||||||
# Linux above, so enabling this feature on non-Linux targets is a no-op
|
|
||||||
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
|
|
||||||
# lib.rs).
|
|
||||||
linux-aec = ["dep:webrtc-audio-processing"]
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||||
//!
|
//!
|
||||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
||||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
||||||
|
//! channel handles.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -14,8 +16,6 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
@@ -23,25 +23,23 @@ pub const FRAME_SAMPLES: usize = 960;
|
|||||||
// AudioCapture
|
// AudioCapture
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
|
/// Captures microphone input and yields 960-sample PCM frames.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioCapture {
|
pub struct AudioCapture {
|
||||||
ring: Arc<AudioRing>,
|
rx: mpsc::Receiver<Vec<i16>>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCapture {
|
impl AudioCapture {
|
||||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let ring = Arc::new(AudioRing::new());
|
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
let running_clone = running.clone();
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-capture".into())
|
.name("wzp-audio-capture".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -61,51 +59,53 @@ impl AudioCapture {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
let use_f32 = !supports_i16_input(&device)?;
|
||||||
|
|
||||||
|
let buf = Arc::new(std::sync::Mutex::new(
|
||||||
|
Vec::<i16>::with_capacity(FRAME_SAMPLES),
|
||||||
|
));
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("input stream error: {e}");
|
warn!("input stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let logged_cb_size = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring_cb.clone();
|
let buf = buf.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
let logged = logged_cb_size.clone();
|
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
let mut lock = buf.lock().unwrap();
|
||||||
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
for &s in data {
|
||||||
}
|
lock.push(f32_to_i16(s));
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
if lock.len() == FRAME_SAMPLES {
|
||||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
let frame = lock.drain(..).collect();
|
||||||
let n = chunk.len();
|
let _ = tx.try_send(frame);
|
||||||
for i in 0..n {
|
|
||||||
tmp[i] = f32_to_i16(chunk[i]);
|
|
||||||
}
|
}
|
||||||
ring.write(&tmp[..n]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring_cb.clone();
|
let buf = buf.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
let logged = logged_cb_size.clone();
|
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
let mut lock = buf.lock().unwrap();
|
||||||
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
for &s in data {
|
||||||
|
lock.push(s);
|
||||||
|
if lock.len() == FRAME_SAMPLES {
|
||||||
|
let frame = lock.drain(..).collect();
|
||||||
|
let _ = tx.try_send(frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ring.write(data);
|
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
@@ -114,6 +114,7 @@ impl AudioCapture {
|
|||||||
|
|
||||||
stream.play().context("failed to start input stream")?;
|
stream.play().context("failed to start input stream")?;
|
||||||
|
|
||||||
|
// Signal success to the caller before parking.
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -134,12 +135,15 @@ impl AudioCapture {
|
|||||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
Ok(Self { rx, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the capture ring buffer for direct polling.
|
/// Read the next frame of 960 PCM samples (blocking until available).
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
///
|
||||||
&self.ring
|
/// Returns `None` when the stream has been stopped or the channel is
|
||||||
|
/// disconnected.
|
||||||
|
pub fn read_frame(&self) -> Option<Vec<i16>> {
|
||||||
|
self.rx.recv().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop capturing.
|
/// Stop capturing.
|
||||||
@@ -148,35 +152,27 @@ impl AudioCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AudioCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AudioPlayback
|
// AudioPlayback
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
|
/// Plays PCM frames through the default output device at 48 kHz mono.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioPlayback {
|
pub struct AudioPlayback {
|
||||||
ring: Arc<AudioRing>,
|
tx: mpsc::SyncSender<Vec<i16>>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Create and start playback on the default output device at 48 kHz mono.
|
/// Create and start playback on the default output device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let ring = Arc::new(AudioRing::new());
|
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
let running_clone = running.clone();
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-playback".into())
|
.name("wzp-audio-playback".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -196,40 +192,62 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
let use_f32 = !supports_i16_output(&device)?;
|
||||||
|
|
||||||
|
// Shared ring of samples the cpal callback drains from.
|
||||||
|
let ring = Arc::new(std::sync::Mutex::new(
|
||||||
|
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Background drainer: moves frames from the mpsc channel into the ring.
|
||||||
|
{
|
||||||
|
let ring = ring.clone();
|
||||||
|
let running = running_clone.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-playback-drain".into())
|
||||||
|
.spawn(move || {
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
let mut lock = ring.lock().unwrap();
|
||||||
|
lock.extend(frame);
|
||||||
|
while lock.len() > FRAME_SAMPLES * 16 {
|
||||||
|
lock.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("output stream error: {e}");
|
warn!("output stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring_cb.clone();
|
let ring = ring.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
let mut lock = ring.lock().unwrap();
|
||||||
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
for sample in data.iter_mut() {
|
||||||
let n = chunk.len();
|
*sample = match lock.pop_front() {
|
||||||
let read = ring.read(&mut tmp[..n]);
|
Some(s) => i16_to_f32(s),
|
||||||
for i in 0..read {
|
None => 0.0,
|
||||||
chunk[i] = i16_to_f32(tmp[i]);
|
};
|
||||||
}
|
|
||||||
// Fill remainder with silence if ring underran
|
|
||||||
for i in read..n {
|
|
||||||
chunk[i] = 0.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring_cb.clone();
|
let ring = ring.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
let read = ring.read(data);
|
let mut lock = ring.lock().unwrap();
|
||||||
// Fill remainder with silence if ring underran
|
for sample in data.iter_mut() {
|
||||||
for sample in &mut data[read..] {
|
*sample = lock.pop_front().unwrap_or(0);
|
||||||
*sample = 0;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
@@ -239,6 +257,7 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
stream.play().context("failed to start output stream")?;
|
stream.play().context("failed to start output stream")?;
|
||||||
|
|
||||||
|
// Signal success to the caller before parking.
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -259,12 +278,12 @@ impl AudioPlayback {
|
|||||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
Ok(Self { tx, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the playout ring buffer for direct writing.
|
/// Write a frame of PCM samples for playback.
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
pub fn write_frame(&self, pcm: &[i16]) {
|
||||||
&self.ring
|
let _ = self.tx.try_send(pcm.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop playback.
|
/// Stop playback.
|
||||||
@@ -273,16 +292,11 @@ impl AudioPlayback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AudioPlayback {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Check if the input device supports i16 at 48 kHz mono.
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_input_configs()
|
.supported_input_configs()
|
||||||
@@ -299,6 +313,7 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the output device supports i16 at 48 kHz mono.
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_output_configs()
|
.supported_output_configs()
|
||||||
|
|||||||
@@ -1,537 +0,0 @@
|
|||||||
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
|
|
||||||
//! Processing Module (AEC3 + noise suppression + high-pass filter).
|
|
||||||
//!
|
|
||||||
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
|
|
||||||
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
|
|
||||||
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
|
|
||||||
//! identically on ALSA / PulseAudio / PipeWire systems.
|
|
||||||
//!
|
|
||||||
//! ## Architecture
|
|
||||||
//!
|
|
||||||
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
|
|
||||||
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
|
|
||||||
//! mono):
|
|
||||||
//!
|
|
||||||
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
|
|
||||||
//! output thread, but wraps each chunk in a call to
|
|
||||||
//! `Processor::process_render_frame` **before** handing it to CPAL. That
|
|
||||||
//! gives APM an authoritative reference of exactly what's going out to
|
|
||||||
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
|
|
||||||
//! what to cancel when it sees echo in the capture stream.
|
|
||||||
//!
|
|
||||||
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
|
|
||||||
//! input thread, and runs `Processor::process_capture_frame` on each
|
|
||||||
//! incoming mic chunk **in place** before pushing it into the ring
|
|
||||||
//! buffer. The AEC subtracts the echo using the render reference it
|
|
||||||
//! saw on the playback side.
|
|
||||||
//!
|
|
||||||
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
|
|
||||||
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
|
|
||||||
//! frame is split into two 480-sample halves, APM is called twice, and the
|
|
||||||
//! halves are stitched back together.
|
|
||||||
//!
|
|
||||||
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
|
|
||||||
//! before the call and f32 → i16 after (with clamping on the return path).
|
|
||||||
//!
|
|
||||||
//! ## Stream delay
|
|
||||||
//!
|
|
||||||
//! AEC needs to know roughly how long it takes between a sample being passed
|
|
||||||
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
|
|
||||||
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
|
|
||||||
//! → CPAL capture. AEC3's internal estimator tracks this within a window
|
|
||||||
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
|
|
||||||
//! starting point for typical Linux audio stacks; the delay estimator does
|
|
||||||
//! the fine-tuning automatically.
|
|
||||||
//!
|
|
||||||
//! ## Thread safety
|
|
||||||
//!
|
|
||||||
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
|
|
||||||
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
|
|
||||||
//! needs a `Mutex` around it for cross-thread sharing. The capture and
|
|
||||||
//! playback threads each acquire the lock briefly (sub-millisecond per
|
|
||||||
//! 10 ms frame) so contention is minimal at our frame rates.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|
||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use webrtc_audio_processing::{
|
|
||||||
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
|
||||||
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
|
|
||||||
/// Imported from the webrtc-audio-processing crate so we can't drift out
|
|
||||||
/// of sync with whatever sample rate / frame length the C++ lib is using.
|
|
||||||
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
|
|
||||||
const APM_NUM_CHANNELS: usize = 1;
|
|
||||||
/// Round-trip delay hint passed to APM; the estimator refines from here.
|
|
||||||
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const STREAM_DELAY_MS: i32 = 60;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared APM instance
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Module-level lazily-initialized APM. Shared between capture and playback
|
|
||||||
/// so they operate on the same echo-cancellation state — the render frames
|
|
||||||
/// pushed by playback are what the capture path subtracts from the mic input.
|
|
||||||
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
|
|
||||||
/// process_capture_frame and process_render_frame.
|
|
||||||
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
|
||||||
if let Some(p) = PROCESSOR.get() {
|
|
||||||
return Ok(p.clone());
|
|
||||||
}
|
|
||||||
let init_config = InitializationConfig {
|
|
||||||
num_capture_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
num_render_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut processor = Processor::new(&init_config)
|
|
||||||
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
echo_cancellation: Some(EchoCancellation {
|
|
||||||
suppression_level: EchoCancellationSuppressionLevel::High,
|
|
||||||
stream_delay_ms: Some(STREAM_DELAY_MS),
|
|
||||||
enable_delay_agnostic: true,
|
|
||||||
enable_extended_filter: true,
|
|
||||||
}),
|
|
||||||
noise_suppression: Some(NoiseSuppression {
|
|
||||||
suppression_level: NoiseSuppressionLevel::High,
|
|
||||||
}),
|
|
||||||
enable_high_pass_filter: true,
|
|
||||||
// AGC left off for now — it can fight the Opus encoder's own gain
|
|
||||||
// staging and the adaptive-quality controller. Add later if users
|
|
||||||
// report low mic levels.
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
processor.set_config(config);
|
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(processor));
|
|
||||||
let _ = PROCESSOR.set(arc.clone());
|
|
||||||
info!(
|
|
||||||
stream_delay_ms = STREAM_DELAY_MS,
|
|
||||||
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
|
|
||||||
);
|
|
||||||
Ok(arc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers: i16 ↔ f32 and APM frame processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn i16_to_f32(s: i16) -> f32 {
|
|
||||||
s as f32 / 32768.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn f32_to_i16(s: f32) -> i16 {
|
|
||||||
(s.clamp(-1.0, 1.0) * 32767.0) as i16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
|
|
||||||
/// Splits into two 10 ms halves because APM is strict about frame size.
|
|
||||||
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
|
|
||||||
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_render_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_render_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in render path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
|
|
||||||
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
|
|
||||||
/// results back into the caller's buffer. Briefly holds the Mutex once
|
|
||||||
/// per 10 ms half.
|
|
||||||
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_capture_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_capture_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in capture path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (i, d) in half.iter_mut().enumerate() {
|
|
||||||
*d = f32_to_i16(buf[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Microphone capture with WebRTC AEC3 applied in place before the codec
|
|
||||||
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
/// downstream code doesn't change.
|
|
||||||
pub struct LinuxAecCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecCapture {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
// Eagerly init the APM so the playback side can find it already
|
|
||||||
// configured, and so init errors surface on the caller thread
|
|
||||||
// instead of silently failing inside the capture thread.
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
|
||||||
let apm_capture = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_input_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default input audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC input stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Leftover buffer for when CPAL gives us partial frames.
|
|
||||||
// We need exactly 960-sample chunks to feed APM.
|
|
||||||
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.reserve(data.len());
|
|
||||||
for &s in data {
|
|
||||||
lv.push(f32_to_i16(s));
|
|
||||||
}
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.extend_from_slice(data);
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC input stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC capture started (AEC3 active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull whole 960-sample frames out of the leftover buffer, run them through
|
|
||||||
/// APM's capture-side processing, and push to the ring. Leaves any partial
|
|
||||||
/// sub-960 remainder in `leftover` for the next callback.
|
|
||||||
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
while leftover.len() >= FRAME_SAMPLES {
|
|
||||||
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
|
|
||||||
process_capture_frame_20ms(apm, &mut frame);
|
|
||||||
ring.write(&frame);
|
|
||||||
leftover.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Speaker playback with a render-side tee: each frame written to CPAL is
|
|
||||||
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
|
|
||||||
/// reference signal. This is the "tee the playback ring" approach (Zoom,
|
|
||||||
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
|
|
||||||
/// PipeWire monitor sources.
|
|
||||||
pub struct LinuxAecPlayback {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecPlayback {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
|
||||||
let apm_render = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-playback-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_output_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default output audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC output stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Same 960-sample batching approach as the capture side:
|
|
||||||
// CPAL may ask for N samples in a callback where N doesn't
|
|
||||||
// divide 960. We accumulate partial frames in a Vec and
|
|
||||||
// feed APM as soon as we have a whole 20 ms frame.
|
|
||||||
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_f32(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_i16(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC output stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC playback started (render tee active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecPlayback {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_i16(
|
|
||||||
data: &mut [i16],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let read = ring.read(data);
|
|
||||||
for s in &mut data[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
tee_render_samples(data, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_f32(
|
|
||||||
data: &mut [f32],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let mut tmp = vec![0i16; data.len()];
|
|
||||||
let read = ring.read(&mut tmp);
|
|
||||||
for s in &mut tmp[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
|
|
||||||
*d = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
tee_render_samples(&tmp, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
|
|
||||||
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
|
|
||||||
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
|
|
||||||
let mut lv = carry.lock().unwrap();
|
|
||||||
lv.extend_from_slice(samples);
|
|
||||||
while lv.len() >= FRAME_SAMPLES {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
|
|
||||||
push_render_frame_20ms(apm, &frame);
|
|
||||||
lv.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
|
|
||||||
// independent — each backend file is a self-contained unit)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_input_configs()
|
|
||||||
.context("failed to query input configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_output_configs()
|
|
||||||
.context("failed to query output configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
@@ -1,122 +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.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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`.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
if avail > RING_CAPACITY {
|
|
||||||
r = w.wrapping_sub(RING_CAPACITY);
|
|
||||||
avail = RING_CAPACITY;
|
|
||||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = out.len().min(avail);
|
|
||||||
if count == 0 {
|
|
||||||
if w == r {
|
|
||||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in 0..count {
|
|
||||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
|
||||||
}
|
|
||||||
|
|
||||||
self.read_pos
|
|
||||||
.store(r.wrapping_add(count), Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of overflow events (reader was lapped by writer).
|
|
||||||
pub fn overflow_count(&self) -> u64 {
|
|
||||||
self.overflow_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of underrun events (reader found empty buffer).
|
|
||||||
pub fn underrun_count(&self) -> u64 {
|
|
||||||
self.underrun_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
//! macOS Voice Processing I/O — uses Apple's VoiceProcessingIO audio unit
|
|
||||||
//! for hardware-accelerated echo cancellation, AGC, and noise suppression.
|
|
||||||
//!
|
|
||||||
//! VoiceProcessingIO is a combined input+output unit that knows what's going
|
|
||||||
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
|
||||||
//! This is the same engine FaceTime and other Apple apps use.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
|
||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
|
|
||||||
use coreaudio::sys;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Combined capture + playback via macOS VoiceProcessingIO.
|
|
||||||
///
|
|
||||||
/// The OS handles AEC internally — no manual far-end feeding needed.
|
|
||||||
pub struct VpioAudio {
|
|
||||||
capture_ring: Arc<AudioRing>,
|
|
||||||
playout_ring: Arc<AudioRing>,
|
|
||||||
_audio_unit: AudioUnit,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VpioAudio {
|
|
||||||
/// Start VoiceProcessingIO with AEC enabled.
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let capture_ring = Arc::new(AudioRing::new());
|
|
||||||
let playout_ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
|
||||||
.context("failed to create VoiceProcessingIO audio unit")?;
|
|
||||||
|
|
||||||
// Must uninitialize before configuring properties.
|
|
||||||
au.uninitialize()
|
|
||||||
.context("failed to uninitialize VPIO for configuration")?;
|
|
||||||
|
|
||||||
// Enable input (mic) on Element::Input (bus 1).
|
|
||||||
let enable: u32 = 1;
|
|
||||||
au.set_property(
|
|
||||||
sys::kAudioOutputUnitProperty_EnableIO,
|
|
||||||
Scope::Input,
|
|
||||||
Element::Input,
|
|
||||||
Some(&enable),
|
|
||||||
)
|
|
||||||
.context("failed to enable VPIO input")?;
|
|
||||||
|
|
||||||
// Output (speaker) is enabled by default on VPIO, but be explicit.
|
|
||||||
au.set_property(
|
|
||||||
sys::kAudioOutputUnitProperty_EnableIO,
|
|
||||||
Scope::Output,
|
|
||||||
Element::Output,
|
|
||||||
Some(&enable),
|
|
||||||
)
|
|
||||||
.context("failed to enable VPIO output")?;
|
|
||||||
|
|
||||||
// Configure stream format: 48kHz mono f32 non-interleaved
|
|
||||||
let stream_format = StreamFormat {
|
|
||||||
sample_rate: 48_000.0,
|
|
||||||
sample_format: SampleFormat::F32,
|
|
||||||
flags: LinearPcmFlags::IS_FLOAT
|
|
||||||
| LinearPcmFlags::IS_PACKED
|
|
||||||
| LinearPcmFlags::IS_NON_INTERLEAVED,
|
|
||||||
channels: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let asbd = stream_format.to_asbd();
|
|
||||||
|
|
||||||
// Input: set format on Output scope of Input element
|
|
||||||
// (= the format the AU delivers to us from the mic)
|
|
||||||
au.set_property(
|
|
||||||
sys::kAudioUnitProperty_StreamFormat,
|
|
||||||
Scope::Output,
|
|
||||||
Element::Input,
|
|
||||||
Some(&asbd),
|
|
||||||
)
|
|
||||||
.context("failed to set input stream format")?;
|
|
||||||
|
|
||||||
// Output: set format on Input scope of Output element
|
|
||||||
// (= the format we feed to the AU for the speaker)
|
|
||||||
au.set_property(
|
|
||||||
sys::kAudioUnitProperty_StreamFormat,
|
|
||||||
Scope::Input,
|
|
||||||
Element::Output,
|
|
||||||
Some(&asbd),
|
|
||||||
)
|
|
||||||
.context("failed to set output stream format")?;
|
|
||||||
|
|
||||||
// Set up input callback (mic capture with AEC applied)
|
|
||||||
let cap_ring = capture_ring.clone();
|
|
||||||
let cap_running = running.clone();
|
|
||||||
let logged = Arc::new(AtomicBool::new(false));
|
|
||||||
au.set_input_callback(
|
|
||||||
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
|
||||||
if !cap_running.load(Ordering::Relaxed) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let mut buffers = args.data.channels();
|
|
||||||
if let Some(ch) = buffers.next() {
|
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
|
||||||
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
|
||||||
}
|
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
|
||||||
for chunk in ch.chunks(FRAME_SAMPLES) {
|
|
||||||
let n = chunk.len();
|
|
||||||
for i in 0..n {
|
|
||||||
tmp[i] = (chunk[i].clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
}
|
|
||||||
cap_ring.write(&tmp[..n]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.context("failed to set input callback")?;
|
|
||||||
|
|
||||||
// Set up output callback (speaker playback — AEC uses this as reference)
|
|
||||||
let play_ring = playout_ring.clone();
|
|
||||||
au.set_render_callback(
|
|
||||||
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
|
||||||
let mut buffers = args.data.channels_mut();
|
|
||||||
if let Some(ch) = buffers.next() {
|
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
|
||||||
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
|
||||||
let n = chunk.len();
|
|
||||||
let read = play_ring.read(&mut tmp[..n]);
|
|
||||||
for i in 0..read {
|
|
||||||
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
|
||||||
}
|
|
||||||
for i in read..n {
|
|
||||||
chunk[i] = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.context("failed to set render callback")?;
|
|
||||||
|
|
||||||
au.initialize().context("failed to initialize VoiceProcessingIO")?;
|
|
||||||
au.start().context("failed to start VoiceProcessingIO")?;
|
|
||||||
|
|
||||||
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
capture_ring,
|
|
||||||
playout_ring,
|
|
||||||
_audio_unit: au,
|
|
||||||
running,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn capture_ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.capture_ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn playout_ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.playout_ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for VpioAudio {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
|
|
||||||
//!
|
|
||||||
//! Bypasses CPAL and opens the default capture endpoint directly via
|
|
||||||
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
|
|
||||||
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
|
|
||||||
//! the switch that tells Windows "this is a VoIP call" — the OS then
|
|
||||||
//! enables its communications audio processing chain (AEC, noise
|
|
||||||
//! suppression, automatic gain control) for the stream. AEC operates at
|
|
||||||
//! the OS level using the currently-playing audio as the reference
|
|
||||||
//! signal, so it cancels echo from our CPAL playback (and any other app's
|
|
||||||
//! audio) without us having to plumb a reference signal ourselves.
|
|
||||||
//!
|
|
||||||
//! Platform: Windows only, compiled only when the `windows-aec` feature
|
|
||||||
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
|
||||||
//! `AudioCapture`.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use windows::core::{Interface, GUID};
|
|
||||||
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
|
|
||||||
use windows::Win32::Media::Audio::{
|
|
||||||
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
|
|
||||||
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
|
|
||||||
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
|
|
||||||
WAVE_FORMAT_PCM,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Com::{
|
|
||||||
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono. Matches the rest of the audio pipeline.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Microphone capture via WASAPI with Windows's communications AEC enabled.
|
|
||||||
///
|
|
||||||
/// The WASAPI capture stream runs on a dedicated OS thread. This handle is
|
|
||||||
/// `Send + Sync`. Dropping it stops the stream and joins the thread.
|
|
||||||
pub struct WasapiAudioCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasapiAudioCapture {
|
|
||||||
/// Open the default communications microphone, enable OS AEC, and start
|
|
||||||
/// streaming PCM into a lock-free ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns only after the capture thread has successfully initialized
|
|
||||||
/// the stream, or propagates the error back to the caller.
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_cb = running.clone();
|
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-wasapi".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = unsafe { capture_thread_main(ring_cb, running_cb.clone(), &init_tx) };
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!("wasapi capture thread exited with error: {e}");
|
|
||||||
// If we failed before signaling init, signal now so the
|
|
||||||
// caller unblocks. Double-send is harmless (channel is
|
|
||||||
// bounded to 1 and we only hit the second send path on
|
|
||||||
// late errors).
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.context("failed to spawn WASAPI capture thread")?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("WASAPI capture thread exited before signaling init"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
ring,
|
|
||||||
running,
|
|
||||||
thread: Some(thread),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to the capture ring buffer for direct polling.
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop capturing.
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WasapiAudioCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
if let Some(handle) = self.thread.take() {
|
|
||||||
// Join best-effort. The thread loop polls `running` every 200ms
|
|
||||||
// via a short WaitForSingleObject timeout, so it should exit
|
|
||||||
// within ~200ms of `stop()`.
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WASAPI thread entry point — everything below this line runs on the
|
|
||||||
// dedicated wzp-audio-capture-wasapi thread.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
unsafe fn capture_thread_main(
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
init_tx: &std::sync::mpsc::SyncSender<Result<(), String>>,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
// COM init for the capture thread. MULTITHREADED because we're not
|
|
||||||
// running a message pump. Must be balanced by CoUninitialize on exit.
|
|
||||||
CoInitializeEx(None, COINIT_MULTITHREADED)
|
|
||||||
.ok()
|
|
||||||
.context("CoInitializeEx failed")?;
|
|
||||||
|
|
||||||
// Use a guard struct so CoUninitialize runs even on early returns.
|
|
||||||
struct ComGuard;
|
|
||||||
impl Drop for ComGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe { CoUninitialize() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _com_guard = ComGuard;
|
|
||||||
|
|
||||||
let enumerator: IMMDeviceEnumerator =
|
|
||||||
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
|
||||||
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
|
||||||
|
|
||||||
// eCommunications role (not eConsole) — this picks the device the user
|
|
||||||
// has designated for communications in Sound Settings. It's the one
|
|
||||||
// Windows's AEC is actually tuned for and the one Teams/Zoom use.
|
|
||||||
let device = enumerator
|
|
||||||
.GetDefaultAudioEndpoint(eCapture, eCommunications)
|
|
||||||
.context("GetDefaultAudioEndpoint(eCapture, eCommunications) failed")?;
|
|
||||||
|
|
||||||
if let Ok(name) = device_name(&device) {
|
|
||||||
info!(device = %name, "opening WASAPI communications capture endpoint");
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_client: IAudioClient = device
|
|
||||||
.Activate(CLSCTX_ALL, None)
|
|
||||||
.context("IMMDevice::Activate(IAudioClient) failed")?;
|
|
||||||
|
|
||||||
// IAudioClient2 exposes SetClientProperties, which is the ONLY way to
|
|
||||||
// set AudioCategory_Communications pre-Initialize. Calling it on the
|
|
||||||
// base IAudioClient would not compile, and setting it after Initialize
|
|
||||||
// is a no-op.
|
|
||||||
let audio_client2: IAudioClient2 = audio_client
|
|
||||||
.cast()
|
|
||||||
.context("QueryInterface IAudioClient2 failed")?;
|
|
||||||
|
|
||||||
let mut props = AudioClientProperties {
|
|
||||||
cbSize: std::mem::size_of::<AudioClientProperties>() as u32,
|
|
||||||
bIsOffload: BOOL(0),
|
|
||||||
eCategory: AudioCategory_Communications,
|
|
||||||
// 0 = AUDCLNT_STREAMOPTIONS_NONE. The `windows` crate doesn't
|
|
||||||
// export the enum constant in all versions, so use 0 directly.
|
|
||||||
Options: Default::default(),
|
|
||||||
};
|
|
||||||
audio_client2
|
|
||||||
.SetClientProperties(&mut props as *mut _)
|
|
||||||
.context("SetClientProperties(AudioCategory_Communications) failed")?;
|
|
||||||
|
|
||||||
// Request 48 kHz mono i16 directly. AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
// tells Windows to do any needed format conversion inside the audio
|
|
||||||
// engine rather than rejecting our format. SRC_DEFAULT_QUALITY picks
|
|
||||||
// the standard Windows resampler quality (fine for voice).
|
|
||||||
let wave_format = WAVEFORMATEX {
|
|
||||||
wFormatTag: WAVE_FORMAT_PCM as u16,
|
|
||||||
nChannels: 1,
|
|
||||||
nSamplesPerSec: 48_000,
|
|
||||||
nAvgBytesPerSec: 48_000 * 2, // 1 ch * 2 bytes/sample * 48000 Hz
|
|
||||||
nBlockAlign: 2, // 1 ch * 2 bytes/sample
|
|
||||||
wBitsPerSample: 16,
|
|
||||||
cbSize: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1,000,000 hns = 100 ms buffer (hns = 100-nanosecond units). Windows
|
|
||||||
// treats this as the minimum; the engine may give us a larger one.
|
|
||||||
const BUFFER_DURATION_HNS: i64 = 1_000_000;
|
|
||||||
|
|
||||||
audio_client
|
|
||||||
.Initialize(
|
|
||||||
AUDCLNT_SHAREMODE_SHARED,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
|
|
||||||
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
|
||||||
BUFFER_DURATION_HNS,
|
|
||||||
0,
|
|
||||||
&wave_format,
|
|
||||||
Some(&GUID::zeroed()),
|
|
||||||
)
|
|
||||||
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
|
|
||||||
|
|
||||||
// Event-driven capture: Windows signals this handle each time a new
|
|
||||||
// audio packet is available. We wait on it from the loop below.
|
|
||||||
let event = CreateEventW(None, false, false, None)
|
|
||||||
.context("CreateEventW failed")?;
|
|
||||||
audio_client
|
|
||||||
.SetEventHandle(event)
|
|
||||||
.context("SetEventHandle failed")?;
|
|
||||||
|
|
||||||
let capture_client: IAudioCaptureClient = audio_client
|
|
||||||
.GetService()
|
|
||||||
.context("IAudioClient::GetService(IAudioCaptureClient) failed")?;
|
|
||||||
|
|
||||||
audio_client.Start().context("IAudioClient::Start failed")?;
|
|
||||||
|
|
||||||
// Signal to the parent thread that init succeeded before entering the
|
|
||||||
// hot loop. From this point on, errors get logged but don't propagate
|
|
||||||
// back to the caller (they'd just cause the ring buffer to stop
|
|
||||||
// filling, which the main thread detects as underruns).
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("WASAPI communications-mode capture started with OS AEC enabled");
|
|
||||||
|
|
||||||
let mut logged_first_packet = false;
|
|
||||||
|
|
||||||
// Main capture loop. Exit when `running` goes false (from Drop or an
|
|
||||||
// explicit stop() call).
|
|
||||||
while running.load(Ordering::Relaxed) {
|
|
||||||
// 200 ms timeout so we check `running` regularly even if the audio
|
|
||||||
// engine stops delivering packets (e.g. device unplugged).
|
|
||||||
let wait = WaitForSingleObject(event, 200);
|
|
||||||
if wait.0 != WAIT_OBJECT_0.0 {
|
|
||||||
// Timeout or failure — just loop and re-check running.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain all available packets. Windows may have queued more than
|
|
||||||
// one since we were last scheduled.
|
|
||||||
loop {
|
|
||||||
let packet_length = match capture_client.GetNextPacketSize() {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("GetNextPacketSize failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if packet_length == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buffer_ptr: *mut u8 = std::ptr::null_mut();
|
|
||||||
let mut num_frames: u32 = 0;
|
|
||||||
let mut flags: u32 = 0;
|
|
||||||
let mut device_position: u64 = 0;
|
|
||||||
let mut qpc_position: u64 = 0;
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.GetBuffer(
|
|
||||||
&mut buffer_ptr,
|
|
||||||
&mut num_frames,
|
|
||||||
&mut flags,
|
|
||||||
Some(&mut device_position),
|
|
||||||
Some(&mut qpc_position),
|
|
||||||
) {
|
|
||||||
warn!("GetBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if num_frames > 0 && !buffer_ptr.is_null() {
|
|
||||||
if !logged_first_packet {
|
|
||||||
info!(
|
|
||||||
frames = num_frames,
|
|
||||||
flags, "WASAPI capture: first packet received"
|
|
||||||
);
|
|
||||||
logged_first_packet = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because we asked for 48 kHz mono i16, each frame is
|
|
||||||
// exactly one i16. Windows's AUTOCONVERTPCM handles the
|
|
||||||
// conversion from whatever the engine mix format is.
|
|
||||||
let samples = std::slice::from_raw_parts(
|
|
||||||
buffer_ptr as *const i16,
|
|
||||||
num_frames as usize,
|
|
||||||
);
|
|
||||||
ring.write(samples);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.ReleaseBuffer(num_frames) {
|
|
||||||
warn!("ReleaseBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("WASAPI capture thread stopping");
|
|
||||||
let _ = audio_client.Stop();
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
// _com_guard drops here, calling CoUninitialize.
|
|
||||||
|
|
||||||
// Silence INFINITE unused-import warning — it's referenced by the
|
|
||||||
// `windows` crate's WaitForSingleObject alternative but we use the
|
|
||||||
// 200 ms timeout variant instead. Explicit suppression for clarity.
|
|
||||||
let _ = INFINITE;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Best-effort device ID string for logging. Grabbing the friendly name via
|
|
||||||
/// PKEY_Device_FriendlyName requires IPropertyStore + PROPVARIANT plumbing
|
|
||||||
/// that's far more ceremony than a log line justifies; the ID is already
|
|
||||||
/// sufficient to confirm we opened the right endpoint.
|
|
||||||
///
|
|
||||||
/// Rust 2024 edition's `unsafe_op_in_unsafe_fn` lint requires explicit
|
|
||||||
/// `unsafe { ... }` blocks inside `unsafe fn` bodies for each unsafe call,
|
|
||||||
/// even though the whole function is already marked unsafe.
|
|
||||||
unsafe fn device_name(
|
|
||||||
device: &windows::Win32::Media::Audio::IMMDevice,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
|
||||||
let id = unsafe { device.GetId() }.context("IMMDevice::GetId failed")?;
|
|
||||||
Ok(unsafe { id.to_string() }.unwrap_or_else(|_| "<non-utf16>".to_string()))
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||||
@@ -42,9 +42,6 @@ pub struct CallConfig {
|
|||||||
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
||||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||||
pub mini_frames_enabled: bool,
|
pub mini_frames_enabled: bool,
|
||||||
/// AEC far-end delay compensation in milliseconds (default: 40).
|
|
||||||
/// Compensates for the round-trip audio latency from playout to mic capture.
|
|
||||||
pub aec_delay_ms: u32,
|
|
||||||
/// Enable adaptive jitter buffer (default: true).
|
/// Enable adaptive jitter buffer (default: true).
|
||||||
///
|
///
|
||||||
/// When true, the jitter buffer target depth is automatically adjusted
|
/// When true, the jitter buffer target depth is automatically adjusted
|
||||||
@@ -66,7 +63,6 @@ impl Default for CallConfig {
|
|||||||
noise_suppression: true,
|
noise_suppression: true,
|
||||||
mini_frames_enabled: true,
|
mini_frames_enabled: true,
|
||||||
adaptive_jitter: true,
|
adaptive_jitter: true,
|
||||||
aec_delay_ms: 40,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,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.
|
||||||
@@ -245,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::with_delay(48000, 60, config.aec_delay_ms),
|
|
||||||
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,
|
||||||
@@ -284,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) {
|
||||||
@@ -416,24 +400,6 @@ impl CallEncoder {
|
|||||||
self.frame_in_block = 0;
|
self.frame_in_block = 0;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed decoded playout audio as the echo reference signal.
|
|
||||||
///
|
|
||||||
/// Must be called with each decoded frame BEFORE the corresponding
|
|
||||||
/// microphone frame is processed.
|
|
||||||
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
|
||||||
self.aec.feed_farend(farend);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable acoustic echo cancellation.
|
|
||||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
|
||||||
self.aec.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable automatic gain control.
|
|
||||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
|
||||||
self.agc.set_enabled(enabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages the recv/decode side of a call.
|
/// Manages the recv/decode side of a call.
|
||||||
@@ -500,52 +466,6 @@ impl CallDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch the decoder to match an incoming packet's codec if it differs
|
|
||||||
/// from the current profile. This enables cross-codec interop (e.g. one
|
|
||||||
/// client sends Opus, the other sends Codec2).
|
|
||||||
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
|
|
||||||
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let new_profile = Self::profile_for_codec(incoming_codec);
|
|
||||||
info!(
|
|
||||||
from = ?self.profile.codec,
|
|
||||||
to = ?incoming_codec,
|
|
||||||
"decoder switching codec to match incoming packet"
|
|
||||||
);
|
|
||||||
if let Err(e) = self.audio_dec.set_profile(new_profile) {
|
|
||||||
warn!("failed to switch decoder profile: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.fec_dec = wzp_fec::create_decoder(&new_profile);
|
|
||||||
self.profile = new_profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
|
|
||||||
fn profile_for_codec(codec: CodecId) -> QualityProfile {
|
|
||||||
match codec {
|
|
||||||
CodecId::Opus24k => QualityProfile::GOOD,
|
|
||||||
CodecId::Opus16k => QualityProfile {
|
|
||||||
codec: CodecId::Opus16k,
|
|
||||||
fec_ratio: 0.3,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
},
|
|
||||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
|
||||||
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
|
||||||
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
|
||||||
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
|
||||||
CodecId::Codec2_3200 => QualityProfile {
|
|
||||||
codec: CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
},
|
|
||||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
|
||||||
CodecId::ComfortNoise => QualityProfile::GOOD,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the next audio frame from the jitter buffer.
|
/// Decode the next audio frame from the jitter buffer.
|
||||||
///
|
///
|
||||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||||
@@ -560,9 +480,6 @@ impl CallDecoder {
|
|||||||
return Some(pcm.len());
|
return Some(pcm.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-switch decoder if incoming codec differs from current.
|
|
||||||
self.switch_decoder_if_needed(pkt.header.codec_id);
|
|
||||||
|
|
||||||
self.last_was_cn = false;
|
self.last_was_cn = false;
|
||||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||||
Ok(n) => Some(n),
|
Ok(n) => Some(n),
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,23 +109,12 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
|
||||||
SignalMessage::FederationHello { .. }
|
|
||||||
| SignalMessage::GlobalRoomActive { .. }
|
|
||||||
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
|
||||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
|
||||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
|
||||||
SignalMessage::RegisterPresence { .. }
|
|
||||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_roundtrip() {
|
fn payload_roundtrip() {
|
||||||
@@ -134,7 +123,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"));
|
||||||
@@ -152,7 +140,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));
|
||||||
|
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,6 @@
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
#[cfg(feature = "audio")]
|
|
||||||
pub mod audio_ring;
|
|
||||||
// VoiceProcessingIO is an Apple Core Audio API — only compile the module
|
|
||||||
// when the `vpio` feature is on AND we're targeting macOS. Enabling the
|
|
||||||
// feature on Windows/Linux was previously silently broken.
|
|
||||||
#[cfg(all(feature = "vpio", target_os = "macos"))]
|
|
||||||
pub mod audio_vpio;
|
|
||||||
// WASAPI-direct capture with Windows's OS-level AEC (AudioCategory_Communications).
|
|
||||||
// Only compiled when `windows-aec` feature is on AND target is Windows. The
|
|
||||||
// `windows` dependency is itself gated to Windows in Cargo.toml, so enabling
|
|
||||||
// this feature on non-Windows targets is a no-op.
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub mod audio_wasapi;
|
|
||||||
// WebRTC AEC3 (Audio Processing Module) wrapper around CPAL capture + playback
|
|
||||||
// on Linux. Only compiled when `linux-aec` feature is on AND target is Linux.
|
|
||||||
// The webrtc-audio-processing dep is itself gated to Linux in Cargo.toml.
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub mod audio_linux_aec;
|
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
@@ -35,48 +17,7 @@ pub mod handshake;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod sweep;
|
pub mod sweep;
|
||||||
|
|
||||||
// AudioPlayback: three possible backends depending on feature flags.
|
#[cfg(feature = "audio")]
|
||||||
// 1. Default CPAL (`audio_io::AudioPlayback`) — baseline on every platform.
|
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||||
// 2. Linux AEC (`audio_linux_aec::LinuxAecPlayback`) — CPAL + WebRTC APM
|
|
||||||
// render-side tee, so echo from speakers gets cancelled from the mic.
|
|
||||||
//
|
|
||||||
// On macOS and Windows we always use the default CPAL playback because:
|
|
||||||
// - macOS: VoiceProcessingIO handles AEC at the capture side (Apple's
|
|
||||||
// native hardware AEC uses its own reference signal handling).
|
|
||||||
// - Windows: WASAPI AudioCategory_Communications AEC uses the system
|
|
||||||
// render mix as reference — no per-process plumbing needed.
|
|
||||||
//
|
|
||||||
// Linux is the only platform where the in-app approach is necessary, so
|
|
||||||
// the AEC playback path is gated to target_os = "linux".
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioPlayback;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecPlayback as AudioPlayback;
|
|
||||||
|
|
||||||
// AudioCapture: three possible backends depending on feature flags.
|
|
||||||
// 1. Default CPAL (`audio_io::AudioCapture`) — baseline on every platform.
|
|
||||||
// 2. Windows AEC (`audio_wasapi::WasapiAudioCapture`) — direct WASAPI
|
|
||||||
// with AudioCategory_Communications, OS APO chain does AEC.
|
|
||||||
// 3. Linux AEC (`audio_linux_aec::LinuxAecCapture`) — CPAL + WebRTC APM
|
|
||||||
// capture-side echo cancellation using the playback tee as reference.
|
|
||||||
// All three expose the same public API (`start`, `ring`, `stop`, `Drop`).
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "windows-aec"), not(target_os = "windows")),
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub use audio_wasapi::WasapiAudioCapture as AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecCapture as AudioCapture;
|
|
||||||
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
pub use handshake::perform_handshake;
|
pub use handshake::perform_handshake;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
|
|||||||
use crate::codec2_enc::Codec2Encoder;
|
use crate::codec2_enc::Codec2Encoder;
|
||||||
use crate::opus_dec::OpusDecoder;
|
use crate::opus_dec::OpusDecoder;
|
||||||
use crate::opus_enc::OpusEncoder;
|
use crate::opus_enc::OpusEncoder;
|
||||||
use crate::resample::{Downsampler48to8, Upsampler8to48};
|
use crate::resample;
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ pub struct AdaptiveEncoder {
|
|||||||
opus: OpusEncoder,
|
opus: OpusEncoder,
|
||||||
codec2: Codec2Encoder,
|
codec2: Codec2Encoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
downsampler: Downsampler48to8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveEncoder {
|
impl AdaptiveEncoder {
|
||||||
@@ -67,7 +66,6 @@ impl AdaptiveEncoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
downsampler: Downsampler48to8::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +74,7 @@ impl AudioEncoder for AdaptiveEncoder {
|
|||||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||||
if is_codec2(self.active) {
|
if is_codec2(self.active) {
|
||||||
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||||
let pcm_8k = self.downsampler.process(pcm);
|
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
||||||
self.codec2.encode(&pcm_8k, out)
|
self.codec2.encode(&pcm_8k, out)
|
||||||
} else {
|
} else {
|
||||||
self.opus.encode(pcm, out)
|
self.opus.encode(pcm, out)
|
||||||
@@ -128,7 +126,6 @@ pub struct AdaptiveDecoder {
|
|||||||
opus: OpusDecoder,
|
opus: OpusDecoder,
|
||||||
codec2: Codec2Decoder,
|
codec2: Codec2Decoder,
|
||||||
active: CodecId,
|
active: CodecId,
|
||||||
upsampler: Upsampler8to48,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveDecoder {
|
impl AdaptiveDecoder {
|
||||||
@@ -141,7 +138,6 @@ impl AdaptiveDecoder {
|
|||||||
opus,
|
opus,
|
||||||
codec2,
|
codec2,
|
||||||
active: profile.codec,
|
active: profile.codec,
|
||||||
upsampler: Upsampler8to48::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +149,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
@@ -167,7 +163,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
|||||||
let c2_samples = self.codec2_frame_samples();
|
let c2_samples = self.codec2_frame_samples();
|
||||||
let mut buf_8k = vec![0i16; c2_samples];
|
let mut buf_8k = vec![0i16; c2_samples];
|
||||||
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||||
let out_len = pcm_48k.len().min(pcm.len());
|
let out_len = pcm_48k.len().min(pcm.len());
|
||||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||||
Ok(out_len)
|
Ok(out_len)
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
|
|
||||||
//! Geigel double-talk detection.
|
|
||||||
//!
|
|
||||||
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
|
|
||||||
//! → air → mic → capture) is 30–50ms. The far-end reference must be delayed
|
|
||||||
//! by this amount so the adaptive filter models the *echo path*, not the
|
|
||||||
//! *system delay + echo path*.
|
|
||||||
//!
|
|
||||||
//! The leaky coefficient decay prevents the filter from diverging when the
|
|
||||||
//! echo path changes (e.g. hand near laptop) or when the delay estimate
|
|
||||||
//! is slightly off.
|
|
||||||
|
|
||||||
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
|
|
||||||
pub struct EchoCanceller {
|
|
||||||
// --- Adaptive filter ---
|
|
||||||
filter: Vec<f32>,
|
|
||||||
filter_len: usize,
|
|
||||||
/// Circular buffer of far-end reference samples (after delay).
|
|
||||||
far_buf: Vec<f32>,
|
|
||||||
far_pos: usize,
|
|
||||||
/// NLMS step size.
|
|
||||||
mu: f32,
|
|
||||||
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
|
||||||
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
|
||||||
leak: f32,
|
|
||||||
enabled: bool,
|
|
||||||
|
|
||||||
// --- Delay buffer ---
|
|
||||||
/// Raw far-end samples before delay compensation.
|
|
||||||
delay_ring: Vec<f32>,
|
|
||||||
delay_write: usize,
|
|
||||||
delay_read: usize,
|
|
||||||
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
|
||||||
delay_samples: usize,
|
|
||||||
/// Capacity of the delay ring.
|
|
||||||
delay_cap: usize,
|
|
||||||
|
|
||||||
// --- Double-talk detection (Geigel) ---
|
|
||||||
/// Peak far-end level over the last filter_len samples.
|
|
||||||
far_peak: f32,
|
|
||||||
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
|
||||||
geigel_threshold: f32,
|
|
||||||
/// Holdover counter: keep DTD active for a few frames after detection.
|
|
||||||
dtd_holdover: u32,
|
|
||||||
dtd_hold_frames: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EchoCanceller {
|
|
||||||
/// Create a new echo canceller.
|
|
||||||
///
|
|
||||||
/// * `sample_rate` — typically 48000
|
|
||||||
/// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
|
|
||||||
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
|
|
||||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
|
||||||
Self::with_delay(sample_rate, filter_ms, 40)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
|
||||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
|
||||||
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
|
||||||
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
|
||||||
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
|
||||||
Self {
|
|
||||||
filter: vec![0.0; filter_len],
|
|
||||||
filter_len,
|
|
||||||
far_buf: vec![0.0; filter_len],
|
|
||||||
far_pos: 0,
|
|
||||||
mu: 0.01,
|
|
||||||
leak: 0.0001,
|
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
delay_ring: vec![0.0; delay_cap],
|
|
||||||
delay_write: 0,
|
|
||||||
delay_read: 0,
|
|
||||||
delay_samples,
|
|
||||||
delay_cap,
|
|
||||||
|
|
||||||
far_peak: 0.0,
|
|
||||||
geigel_threshold: 0.7,
|
|
||||||
dtd_holdover: 0,
|
|
||||||
dtd_hold_frames: 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
|
||||||
/// once enough samples have accumulated, they are released to the filter's
|
|
||||||
/// circular buffer with the correct delay offset.
|
|
||||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
|
||||||
// Write raw samples into the delay ring.
|
|
||||||
for &s in farend {
|
|
||||||
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
|
||||||
self.delay_write += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release delayed samples to the filter's far-end buffer.
|
|
||||||
while self.delay_available() >= 1 {
|
|
||||||
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
|
||||||
self.delay_read += 1;
|
|
||||||
|
|
||||||
self.far_buf[self.far_pos] = sample;
|
|
||||||
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
|
||||||
|
|
||||||
// Track peak far-end level for Geigel DTD.
|
|
||||||
let abs_s = sample.abs();
|
|
||||||
if abs_s > self.far_peak {
|
|
||||||
self.far_peak = abs_s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
|
||||||
self.far_peak *= 0.9995;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of delayed samples available to release.
|
|
||||||
fn delay_available(&self) -> usize {
|
|
||||||
let buffered = self.delay_write - self.delay_read;
|
|
||||||
if buffered > self.delay_samples {
|
|
||||||
buffered - self.delay_samples
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
|
||||||
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;
|
|
||||||
|
|
||||||
// --- Geigel double-talk detection ---
|
|
||||||
// If any near-end sample exceeds threshold * far_peak, assume
|
|
||||||
// the local speaker is active and freeze adaptation.
|
|
||||||
let mut is_doubletalk = self.dtd_holdover > 0;
|
|
||||||
if !is_doubletalk {
|
|
||||||
let threshold_level = self.geigel_threshold * self.far_peak;
|
|
||||||
for &s in nearend.iter() {
|
|
||||||
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
|
|
||||||
is_doubletalk = true;
|
|
||||||
self.dtd_holdover = self.dtd_hold_frames;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.dtd_holdover > 0 {
|
|
||||||
self.dtd_holdover -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if far-end is active (otherwise nothing to cancel).
|
|
||||||
let far_active = self.far_peak > 100.0;
|
|
||||||
|
|
||||||
// --- Leaky coefficient decay ---
|
|
||||||
// Applied once per frame for efficiency.
|
|
||||||
let decay = 1.0 - self.leak;
|
|
||||||
for c in self.filter.iter_mut() {
|
|
||||||
*c *= decay;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Position of far-end "now" for this near-end sample.
|
|
||||||
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
|
||||||
|
|
||||||
// --- Echo estimation: dot(filter, far_end_window) ---
|
|
||||||
let mut echo_est: f32 = 0.0;
|
|
||||||
let mut power: f32 = 0.0;
|
|
||||||
|
|
||||||
for k in 0..fl {
|
|
||||||
let fe_idx = (base + fl - k) % fl;
|
|
||||||
let fe = self.far_buf[fe_idx];
|
|
||||||
echo_est += self.filter[k] * fe;
|
|
||||||
power += fe * fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
let error = near_f - echo_est;
|
|
||||||
|
|
||||||
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
|
||||||
if far_active && !is_doubletalk && power > 10.0 {
|
|
||||||
let step = self.mu * error / (power + 1.0);
|
|
||||||
for k in 0..fl {
|
|
||||||
let fe_idx = (base + fl - k) % fl;
|
|
||||||
self.filter[k] += step * self.far_buf[fe_idx];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let out = error.clamp(-32768.0, 32767.0);
|
|
||||||
nearend[i] = out as i16;
|
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64).powi(2);
|
|
||||||
sum_err_sq += (out as f64).powi(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if sum_err_sq < 1.0 {
|
|
||||||
100.0
|
|
||||||
} else {
|
|
||||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
|
||||||
self.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
|
||||||
self.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.filter.iter_mut().for_each(|c| *c = 0.0);
|
|
||||||
self.far_buf.iter_mut().for_each(|s| *s = 0.0);
|
|
||||||
self.far_pos = 0;
|
|
||||||
self.far_peak = 0.0;
|
|
||||||
self.delay_ring.iter_mut().for_each(|s| *s = 0.0);
|
|
||||||
self.delay_write = 0;
|
|
||||||
self.delay_read = 0;
|
|
||||||
self.dtd_holdover = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn creates_with_correct_sizes() {
|
|
||||||
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
|
||||||
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
|
||||||
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn passthrough_when_disabled() {
|
|
||||||
let mut aec = EchoCanceller::new(48000, 60);
|
|
||||||
aec.set_enabled(false);
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
|
|
||||||
let mut frame = original.clone();
|
|
||||||
aec.process_frame(&mut frame);
|
|
||||||
assert_eq!(frame, original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn silence_passthrough() {
|
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
|
||||||
aec.feed_farend(&vec![0i16; 960]);
|
|
||||||
let mut frame = vec![0i16; 960];
|
|
||||||
aec.process_frame(&mut frame);
|
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reduces_echo_with_no_delay() {
|
|
||||||
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
|
||||||
// (realistic — speaker to mic on laptop loses volume).
|
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
|
||||||
|
|
||||||
let frame_len = 480;
|
|
||||||
let make_tone = |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()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut last_erle = 1.0f32;
|
|
||||||
for frame_idx in 0..100 {
|
|
||||||
let farend = make_tone(frame_idx * frame_len);
|
|
||||||
aec.feed_farend(&farend);
|
|
||||||
|
|
||||||
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
|
||||||
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
|
||||||
last_erle = aec.process_frame(&mut nearend);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
last_erle > 1.0,
|
|
||||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preserves_nearend_during_doubletalk() {
|
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
|
||||||
|
|
||||||
let frame_len = 960;
|
|
||||||
let nearend: Vec<i16> = (0..frame_len)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 48000.0;
|
|
||||||
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Feed silence as far-end (no echo source).
|
|
||||||
aec.feed_farend(&vec![0i16; frame_len]);
|
|
||||||
|
|
||||||
let mut frame = nearend.clone();
|
|
||||||
aec.process_frame(&mut frame);
|
|
||||||
|
|
||||||
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
|
|
||||||
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
|
|
||||||
let ratio = output_energy / input_energy;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
ratio > 0.8,
|
|
||||||
"near-end speech should be preserved, energy ratio = {ratio:.3}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delay_buffer_holds_samples() {
|
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
|
|
||||||
// 20ms delay = 960 samples @ 48kHz.
|
|
||||||
// After feeding, feed_farend auto-drains available samples to far_buf.
|
|
||||||
// So delay_available() is always 0 after feed_farend returns.
|
|
||||||
// Instead, verify far_pos advances only after the delay is filled.
|
|
||||||
|
|
||||||
// Feed 960 samples (= delay amount). No samples released yet.
|
|
||||||
aec.feed_farend(&vec![1i16; 960]);
|
|
||||||
// far_buf should still be all zeros (nothing released).
|
|
||||||
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
|
|
||||||
|
|
||||||
// Feed 480 more. 480 should be released to far_buf.
|
|
||||||
aec.feed_farend(&vec![2i16; 480]);
|
|
||||||
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
|
|
||||||
assert!(non_zero > 0, "samples should have been released to far_buf");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
//! Automatic Gain Control (AGC) with two-stage smoothing.
|
|
||||||
//!
|
|
||||||
//! Uses a fast attack / slow release envelope follower to keep the
|
|
||||||
//! output signal near a configurable target RMS level. This prevents
|
|
||||||
//! both clipping (when the speaker is too loud) and inaudibility (when
|
|
||||||
//! the speaker is too quiet or far from the mic).
|
|
||||||
|
|
||||||
/// Two-stage automatic gain control.
|
|
||||||
///
|
|
||||||
/// The gain is adjusted per-frame based on the measured RMS energy,
|
|
||||||
/// with a fast attack (gain decreases quickly when signal gets louder)
|
|
||||||
/// and a slow release (gain increases gradually when signal gets quieter).
|
|
||||||
pub struct AutoGainControl {
|
|
||||||
target_rms: f64,
|
|
||||||
current_gain: f64,
|
|
||||||
min_gain: f64,
|
|
||||||
max_gain: f64,
|
|
||||||
attack_alpha: f64,
|
|
||||||
release_alpha: f64,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoGainControl {
|
|
||||||
/// Create a new AGC with sensible VoIP defaults.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
|
||||||
current_gain: 1.0,
|
|
||||||
min_gain: 0.5,
|
|
||||||
max_gain: 32.0,
|
|
||||||
attack_alpha: 0.3, // fast attack
|
|
||||||
release_alpha: 0.02, // slow release
|
|
||||||
enabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a frame of PCM audio in-place, applying gain adjustment.
|
|
||||||
pub fn process_frame(&mut self, pcm: &mut [i16]) {
|
|
||||||
if !self.enabled {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute RMS of the frame.
|
|
||||||
let rms = Self::compute_rms(pcm);
|
|
||||||
|
|
||||||
// Don't amplify near-silence — it would just boost noise.
|
|
||||||
if rms < 10.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desired instantaneous gain.
|
|
||||||
let desired_gain = (self.target_rms / rms).clamp(self.min_gain, self.max_gain);
|
|
||||||
|
|
||||||
// Smooth the gain transition.
|
|
||||||
let alpha = if desired_gain < self.current_gain {
|
|
||||||
// Signal is louder than target → reduce gain quickly (attack).
|
|
||||||
self.attack_alpha
|
|
||||||
} else {
|
|
||||||
// Signal is quieter than target → raise gain slowly (release).
|
|
||||||
self.release_alpha
|
|
||||||
};
|
|
||||||
|
|
||||||
self.current_gain = self.current_gain * (1.0 - alpha) + desired_gain * alpha;
|
|
||||||
|
|
||||||
// Apply gain to each sample with hard limiting at ±31000 (~0.946 * i16::MAX).
|
|
||||||
const LIMIT: f64 = 31000.0;
|
|
||||||
let gain = self.current_gain;
|
|
||||||
for sample in pcm.iter_mut() {
|
|
||||||
let amplified = (*sample as f64) * gain;
|
|
||||||
let clamped = amplified.clamp(-LIMIT, LIMIT);
|
|
||||||
*sample = clamped as i16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable or disable the AGC.
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
|
||||||
self.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the AGC is currently enabled.
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
|
||||||
self.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current gain expressed in dB.
|
|
||||||
pub fn current_gain_db(&self) -> f64 {
|
|
||||||
20.0 * self.current_gain.log10()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the RMS (root mean square) of a PCM buffer.
|
|
||||||
fn compute_rms(pcm: &[i16]) -> f64 {
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
|
||||||
(sum_sq / pcm.len() as f64).sqrt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoGainControl {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_creates_with_defaults() {
|
|
||||||
let agc = AutoGainControl::new();
|
|
||||||
assert!(agc.is_enabled());
|
|
||||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_passthrough_when_disabled() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
agc.set_enabled(false);
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..960).map(|i| (i * 5) as i16).collect();
|
|
||||||
let mut frame = original.clone();
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
|
|
||||||
assert_eq!(frame, original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_does_not_amplify_silence() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
let mut frame = vec![0i16; 960];
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
|
||||||
// Gain should remain at initial value.
|
|
||||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_amplifies_quiet_signal() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
|
|
||||||
// Very quiet signal (RMS ~ 50).
|
|
||||||
let mut frame: Vec<i16> = (0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 48000.0;
|
|
||||||
(50.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Process several frames to let the gain ramp up.
|
|
||||||
for _ in 0..50 {
|
|
||||||
let mut f = frame.clone();
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
frame = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gain should have increased past 1.0.
|
|
||||||
assert!(
|
|
||||||
agc.current_gain > 1.05,
|
|
||||||
"expected gain > 1.05 for quiet signal, got {}",
|
|
||||||
agc.current_gain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_attenuates_loud_signal() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
|
|
||||||
// Loud signal (RMS ~ 20000).
|
|
||||||
let frame: Vec<i16> = (0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 48000.0;
|
|
||||||
(28000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Process several frames.
|
|
||||||
for _ in 0..20 {
|
|
||||||
let mut f = frame.clone();
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gain should have decreased below 1.0.
|
|
||||||
assert!(
|
|
||||||
agc.current_gain < 1.0,
|
|
||||||
"expected gain < 1.0 for loud signal, got {}",
|
|
||||||
agc.current_gain
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_output_within_limits() {
|
|
||||||
let mut agc = AutoGainControl::new();
|
|
||||||
// Force a high gain by processing many quiet frames first.
|
|
||||||
for _ in 0..100 {
|
|
||||||
let mut f: Vec<i16> = vec![100; 960];
|
|
||||||
agc.process_frame(&mut f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now send a louder frame — output should still be within ±31000.
|
|
||||||
let mut frame: Vec<i16> = vec![20000; 960];
|
|
||||||
agc.process_frame(&mut frame);
|
|
||||||
assert!(
|
|
||||||
frame.iter().all(|&s| s.abs() <= 31000),
|
|
||||||
"output samples must be within ±31000"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn agc_gain_db_at_unity() {
|
|
||||||
let agc = AutoGainControl::new();
|
|
||||||
let db = agc.current_gain_db();
|
|
||||||
assert!(
|
|
||||||
db.abs() < 0.01,
|
|
||||||
"expected ~0 dB at unity gain, got {db}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
//! trait-object encoders/decoders that handle adaptive switching internally.
|
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||||
|
|
||||||
pub mod adaptive;
|
pub mod adaptive;
|
||||||
pub mod aec;
|
|
||||||
pub mod agc;
|
|
||||||
pub mod codec2_dec;
|
pub mod codec2_dec;
|
||||||
pub mod codec2_enc;
|
pub mod codec2_enc;
|
||||||
pub mod denoise;
|
pub mod denoise;
|
||||||
@@ -21,8 +19,6 @@ pub mod resample;
|
|||||||
pub mod silence;
|
pub mod silence;
|
||||||
|
|
||||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||||
pub use aec::EchoCanceller;
|
|
||||||
pub use agc::AutoGainControl;
|
|
||||||
pub use denoise::NoiseSupressor;
|
pub use denoise::NoiseSupressor;
|
||||||
pub use silence::{ComfortNoise, SilenceDetector};
|
pub use silence::{ComfortNoise, SilenceDetector};
|
||||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
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(())
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ impl OpusEncoder {
|
|||||||
.set_signal(Signal::Voice)
|
.set_signal(Signal::Voice)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
|
||||||
enc.inner
|
|
||||||
.set_complexity(7)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
|
||||||
|
|
||||||
Ok(enc)
|
Ok(enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,21 +56,6 @@ impl OpusEncoder {
|
|||||||
pub fn frame_samples(&self) -> usize {
|
pub fn frame_samples(&self) -> usize {
|
||||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the encoder complexity (0-10). Higher values produce better quality
|
|
||||||
/// at the cost of more CPU. Default is 7.
|
|
||||||
pub fn set_complexity(&mut self, complexity: i32) {
|
|
||||||
let c = (complexity as u8).min(10);
|
|
||||||
let _ = self.inner.set_complexity(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
|
||||||
///
|
|
||||||
/// Higher values cause the encoder to use more redundancy to survive
|
|
||||||
/// packet loss, at the expense of slightly higher bitrate.
|
|
||||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
|
||||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioEncoder for OpusEncoder {
|
impl AudioEncoder for OpusEncoder {
|
||||||
@@ -100,7 +80,7 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
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)?;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-native"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
|
|
||||||
|
|
||||||
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
|
|
||||||
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
|
|
||||||
# standalone .so, which is the same path the legacy wzp-android crate uses
|
|
||||||
# successfully on the same phone / same NDK. Keeping the crate-type single
|
|
||||||
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
|
|
||||||
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
|
|
||||||
# archive pulled bionic's internal pthread_create into the final .so.
|
|
||||||
[lib]
|
|
||||||
name = "wzp_native"
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
# cc is SAFE to use here because this crate is a single-cdylib: no
|
|
||||||
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
|
|
||||||
# legacy wzp-android crate uses the same setup and works.
|
|
||||||
cc = "1"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
|
|
||||||
# audio pipeline via extern "C" into the bundled C++ and expose our own
|
|
||||||
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
|
|
||||||
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
|
|
||||||
# instead of calling back into wzp-desktop via callbacks.
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
|
|
||||||
//!
|
|
||||||
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
|
|
||||||
//! work). The crucial distinction: this crate is a single-cdylib (no
|
|
||||||
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
|
|
||||||
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
|
|
||||||
//! UND and resolve against libc.so at runtime, as they should.
|
|
||||||
//!
|
|
||||||
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
|
|
||||||
//! `cargo check --target <host>` still works for IDEs and CI.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
|
|
||||||
if target.contains("android") {
|
|
||||||
// getauxval_fix: override compiler-rt's broken static getauxval
|
|
||||||
// stub that SIGSEGVs in shared libraries.
|
|
||||||
cc::Build::new()
|
|
||||||
.file("cpp/getauxval_fix.c")
|
|
||||||
.compile("wzp_native_getauxval_fix");
|
|
||||||
|
|
||||||
let oboe_dir = fetch_oboe();
|
|
||||||
match oboe_dir {
|
|
||||||
Some(oboe_path) => {
|
|
||||||
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path);
|
|
||||||
let mut build = cc::Build::new();
|
|
||||||
build
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
// Shared libc++ — matches legacy wzp-android setup.
|
|
||||||
.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");
|
|
||||||
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
|
|
||||||
build.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("cargo:warning=wzp-native: Oboe not found, building stub");
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oboe needs log + OpenSLES backends at runtime.
|
|
||||||
println!("cargo:rustc-link-lib=log");
|
|
||||||
println!("cargo:rustc-link-lib=OpenSLES");
|
|
||||||
|
|
||||||
// Re-run if any cpp file changes
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
|
||||||
} else {
|
|
||||||
// Non-Android hosts: compile the empty stub so lib.rs's extern
|
|
||||||
// declarations resolve when someone runs `cargo check` on macOS
|
|
||||||
// or Linux without an NDK.
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
|
|
||||||
/// legacy wzp-android crate's build.rs.
|
|
||||||
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() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => {
|
|
||||||
Some(oboe_dir)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Override the broken static getauxval from compiler-rt/CRT.
|
|
||||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
|
||||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
|
||||||
// This version calls the real bionic getauxval via dlsym.
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
|
||||||
|
|
||||||
unsigned long getauxval(unsigned long type) {
|
|
||||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
|
||||||
if (!real_getauxval) {
|
|
||||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
|
||||||
if (!real_getauxval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return real_getauxval(type);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,420 +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;
|
|
||||||
// Value copy — the WzpOboeRings the Rust side passes us lives on the caller's
|
|
||||||
// stack frame and goes away as soon as wzp_oboe_start returns. The raw
|
|
||||||
// int16/atomic pointers INSIDE the struct point into the Rust-owned, leaked-
|
|
||||||
// for-the-lifetime-of-the-process AudioBackend singleton, so copying the
|
|
||||||
// struct by value is safe and keeps the inner pointers valid indefinitely.
|
|
||||||
// g_rings_valid guards the audio-callback-side read; clearing it in stop()
|
|
||||||
// signals "no backend" to the callbacks which then return silence + Stop.
|
|
||||||
static WzpOboeRings g_rings{};
|
|
||||||
static std::atomic<bool> g_rings_valid{false};
|
|
||||||
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:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_written = 0;
|
|
||||||
uint64_t ring_full_drops = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_written += to_write;
|
|
||||||
if (to_write < numFrames) {
|
|
||||||
ring_full_drops += (numFrames - to_write);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample-range probe on the FIRST callback to prove we get real audio
|
|
||||||
if (calls == 0 && numFrames > 0) {
|
|
||||||
int16_t lo = src[0], hi = src[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < numFrames; i++) {
|
|
||||||
if (src[i] < lo) lo = src[i];
|
|
||||||
if (src[i] > hi) hi = src[i];
|
|
||||||
sumsq += (int32_t)src[i] * (int32_t)src[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (int32_t) (numFrames > 0 ? (int32_t)__builtin_sqrt((double)sumsq / (double)numFrames) : 0);
|
|
||||||
LOGI("capture cb#0: numFrames=%d sample_range=[%d..%d] rms=%d to_write=%d",
|
|
||||||
numFrames, lo, hi, rms, to_write);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
LOGI("capture heartbeat: calls=%llu numFrames=%d ring_avail_write=%d to_write=%d full_drops=%llu total_written=%llu",
|
|
||||||
(unsigned long long)calls, numFrames, avail, to_write,
|
|
||||||
(unsigned long long)ring_full_drops, (unsigned long long)total_written);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_played_real = 0;
|
|
||||||
uint64_t underrun_frames = 0;
|
|
||||||
uint64_t nonempty_calls = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
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);
|
|
||||||
nonempty_calls++;
|
|
||||||
}
|
|
||||||
// Fill remainder with silence on underrun
|
|
||||||
if (to_read < numFrames) {
|
|
||||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
|
||||||
underrun_frames += (numFrames - to_read);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_played_real += to_read;
|
|
||||||
|
|
||||||
// First callback: log requested config + prove we're being called
|
|
||||||
if (calls == 0) {
|
|
||||||
LOGI("playout cb#0: numFrames=%d ring_avail_read=%d to_read=%d",
|
|
||||||
numFrames, avail, to_read);
|
|
||||||
}
|
|
||||||
// On the first callback that actually has data, log the sample range
|
|
||||||
// so we can tell if the samples coming out of the ring look like real
|
|
||||||
// audio vs constant-zeroes vs garbage.
|
|
||||||
if (to_read > 0 && nonempty_calls == 1) {
|
|
||||||
int16_t lo = dst[0], hi = dst[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < to_read; i++) {
|
|
||||||
if (dst[i] < lo) lo = dst[i];
|
|
||||||
if (dst[i] > hi) hi = dst[i];
|
|
||||||
sumsq += (int32_t)dst[i] * (int32_t)dst[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (to_read > 0) ? (int32_t)__builtin_sqrt((double)sumsq / (double)to_read) : 0;
|
|
||||||
LOGI("playout FIRST nonempty read: to_read=%d sample_range=[%d..%d] rms=%d",
|
|
||||||
to_read, lo, hi, rms);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
int state = (int)stream->getState();
|
|
||||||
auto xrunRes = stream->getXRunCount();
|
|
||||||
int xruns = xrunRes ? xrunRes.value() : -1;
|
|
||||||
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu state=%d xruns=%d",
|
|
||||||
(unsigned long long)calls, (unsigned long long)nonempty_calls,
|
|
||||||
numFrames, avail, to_read,
|
|
||||||
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
|
|
||||||
state, xruns);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep-copy the rings struct into static storage BEFORE we publish it to
|
|
||||||
// the audio callbacks — `rings` points at the caller's stack frame and
|
|
||||||
// goes away as soon as this function returns.
|
|
||||||
g_rings = *rings;
|
|
||||||
g_rings_valid.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
LOGI("capture stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_capture_stream->getSampleRate(),
|
|
||||||
g_capture_stream->getChannelCount(),
|
|
||||||
(int)g_capture_stream->getFormat(),
|
|
||||||
g_capture_stream->getFramesPerBurst(),
|
|
||||||
g_capture_stream->getFramesPerDataCallback(),
|
|
||||||
g_capture_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_capture_stream->getSharingMode(),
|
|
||||||
(int)g_capture_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
// Build playout stream.
|
|
||||||
//
|
|
||||||
// Regression triangulation between builds:
|
|
||||||
// 96be740 (Usage::Media, default API): playout callback DID drain
|
|
||||||
// the ring at steady 50Hz (playout heartbeat: calls=1100,
|
|
||||||
// total_played_real=1055040). Audio not audible because OS routing
|
|
||||||
// sent it to a silent output.
|
|
||||||
//
|
|
||||||
// 8c36fb5 (Usage::VoiceCommunication + setAudioApi(AAudio) +
|
|
||||||
// ContentType::Speech): playout callback fired cb#0 once then
|
|
||||||
// stopped draining the ring entirely. written_samples stuck at
|
|
||||||
// ring capacity (7679) across all subsequent heartbeats, so Oboe
|
|
||||||
// accepted zero samples after startup. Still inaudible.
|
|
||||||
//
|
|
||||||
// Hypothesis: forcing setAudioApi(AAudio) + VoiceCommunication on
|
|
||||||
// Pixel 6 / Android 15 opens a stream that succeeds at cb#0 but
|
|
||||||
// then detaches from the real audio driver. Reverting to the
|
|
||||||
// config that at least drove callbacks correctly, plus the
|
|
||||||
// Kotlin-side MODE_IN_COMMUNICATION + setSpeakerphoneOn(true)
|
|
||||||
// handled in MainActivity.kt to route audio to the loud speaker.
|
|
||||||
// Usage::VoiceCommunication is the correct Oboe usage for a VoIP app
|
|
||||||
// — it respects Android's in-call audio routing and lets
|
|
||||||
// AudioManager.setSpeakerphoneOn/setBluetoothScoOn actually switch
|
|
||||||
// between earpiece, loudspeaker, and Bluetooth headset. Combined with
|
|
||||||
// MODE_IN_COMMUNICATION set from MainActivity.kt and
|
|
||||||
// speakerphoneOn=false by default, this produces handset/earpiece as
|
|
||||||
// the default output.
|
|
||||||
//
|
|
||||||
// IMPORTANT: do NOT add setAudioApi(AAudio) here. Build 8c36fb5 proved
|
|
||||||
// forcing AAudio with Usage::VoiceCommunication makes the playout
|
|
||||||
// callback stop draining the ring after cb#0, even though the stream
|
|
||||||
// opens successfully. Letting Oboe pick the API (which will be AAudio
|
|
||||||
// on API ≥ 27 but via a different codepath) kept callbacks firing in
|
|
||||||
// every other build.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
LOGI("playout stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_playout_stream->getSampleRate(),
|
|
||||||
g_playout_stream->getChannelCount(),
|
|
||||||
(int)g_playout_stream->getFormat(),
|
|
||||||
g_playout_stream->getFramesPerBurst(),
|
|
||||||
g_playout_stream->getFramesPerDataCallback(),
|
|
||||||
g_playout_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_playout_stream->getSharingMode(),
|
|
||||||
(int)g_playout_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
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);
|
|
||||||
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
|
|
||||||
// the streams, so any in-flight callback returns Stop instead of reading
|
|
||||||
// stale pointers.
|
|
||||||
g_rings_valid.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
|
||||||
// Provide empty implementations just in case.
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config; (void)rings;
|
|
||||||
return -99;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {}
|
|
||||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
|
||||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
|
||||||
int wzp_oboe_is_running(void) { return 0; }
|
|
||||||
|
|
||||||
#endif // __ANDROID__
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#ifndef WZP_OBOE_BRIDGE_H
|
|
||||||
#define WZP_OBOE_BRIDGE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#include <atomic>
|
|
||||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
|
||||||
extern "C" {
|
|
||||||
#else
|
|
||||||
#include <stdatomic.h>
|
|
||||||
typedef atomic_int wzp_atomic_int;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t sample_rate;
|
|
||||||
int32_t frames_per_burst;
|
|
||||||
int32_t channel_count;
|
|
||||||
} WzpOboeConfig;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int16_t* capture_buf;
|
|
||||||
int32_t capture_capacity;
|
|
||||||
wzp_atomic_int* capture_write_idx;
|
|
||||||
wzp_atomic_int* capture_read_idx;
|
|
||||||
|
|
||||||
int16_t* playout_buf;
|
|
||||||
int32_t playout_capacity;
|
|
||||||
wzp_atomic_int* playout_write_idx;
|
|
||||||
wzp_atomic_int* playout_read_idx;
|
|
||||||
} WzpOboeRings;
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
|
||||||
void wzp_oboe_stop(void);
|
|
||||||
float wzp_oboe_capture_latency_ms(void);
|
|
||||||
float wzp_oboe_playout_latency_ms(void);
|
|
||||||
int wzp_oboe_is_running(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // WZP_OBOE_BRIDGE_H
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config;
|
|
||||||
(void)rings;
|
|
||||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
//! wzp-native — standalone Android cdylib for all the C++ audio code.
|
|
||||||
//!
|
|
||||||
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
|
|
||||||
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
|
|
||||||
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
|
|
||||||
//!
|
|
||||||
//! Phase 2: real Oboe audio backend.
|
|
||||||
//!
|
|
||||||
//! Architecture: Oboe runs capture + playout streams on its own high-
|
|
||||||
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
|
|
||||||
//! buffers (capture and playout) are shared between the C++ callbacks
|
|
||||||
//! and the Rust side via atomic indices — no locks on the hot path.
|
|
||||||
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
|
|
||||||
//! the playout ring with decoded PCM.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
|
|
||||||
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
|
|
||||||
|
|
||||||
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
|
|
||||||
/// work before any audio code runs.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_version() -> i32 {
|
|
||||||
42
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
|
|
||||||
/// returns bytes written excluding the NUL.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
|
|
||||||
const MSG: &[u8] = b"hello from wzp-native\0";
|
|
||||||
if out.is_null() || cap == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let n = MSG.len().min(cap);
|
|
||||||
unsafe {
|
|
||||||
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
|
|
||||||
*out.add(n - 1) = 0;
|
|
||||||
}
|
|
||||||
n - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C++ Oboe bridge FFI ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WzpOboeConfig {
|
|
||||||
sample_rate: i32,
|
|
||||||
frames_per_burst: i32,
|
|
||||||
channel_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: atomics synchronise producer/consumer; raw pointers are owned
|
|
||||||
// by the AudioBackend singleton below whose lifetime covers all calls.
|
|
||||||
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 (shared with C++ via AtomicI32) ────────────────────
|
|
||||||
|
|
||||||
/// 20 ms @ 48 kHz mono = 960 samples.
|
|
||||||
const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// ~160 ms headroom at 48 kHz.
|
|
||||||
const RING_CAPACITY: usize = 7680;
|
|
||||||
|
|
||||||
struct RingBuffer {
|
|
||||||
buf: Vec<i16>,
|
|
||||||
capacity: usize,
|
|
||||||
write_idx: AtomicI32,
|
|
||||||
read_idx: AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: SPSC with atomic read/write cursors; producer and consumer
|
|
||||||
// are always on different threads.
|
|
||||||
unsafe impl Send for RingBuffer {}
|
|
||||||
unsafe impl Sync for RingBuffer {}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; capacity],
|
|
||||||
capacity,
|
|
||||||
write_idx: AtomicI32::new(0),
|
|
||||||
read_idx: AtomicI32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn available_write(&self) -> usize {
|
|
||||||
self.capacity - 1 - self.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(&self, data: &[i16]) -> usize {
|
|
||||||
let count = data.len().min(self.available_write());
|
|
||||||
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 sample in &data[..count] {
|
|
||||||
unsafe { *buf_ptr.add(w) = *sample; }
|
|
||||||
w += 1;
|
|
||||||
if w >= cap { w = 0; }
|
|
||||||
}
|
|
||||||
self.write_idx.store(w as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let count = out.len().min(self.available_read());
|
|
||||||
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 slot in &mut out[..count] {
|
|
||||||
unsafe { *slot = *buf_ptr.add(r); }
|
|
||||||
r += 1;
|
|
||||||
if r >= cap { r = 0; }
|
|
||||||
}
|
|
||||||
self.read_idx.store(r as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn buf_ptr(&self) -> *mut i16 {
|
|
||||||
self.buf.as_ptr() as *mut i16
|
|
||||||
}
|
|
||||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AudioBackend singleton ──────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// There is one global AudioBackend instance because Oboe's C++ side
|
|
||||||
// holds its own singleton of the streams. The `Box::leak`'d statics own
|
|
||||||
// the ring buffers for the lifetime of the process — dropping them while
|
|
||||||
// Oboe is still running would cause use-after-free in the audio callback.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
struct AudioBackend {
|
|
||||||
capture: RingBuffer,
|
|
||||||
playout: RingBuffer,
|
|
||||||
started: std::sync::Mutex<bool>,
|
|
||||||
/// Per-write logging throttle counter for wzp_native_audio_write_playout.
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
|
|
||||||
|
|
||||||
fn backend() -> &'static AudioBackend {
|
|
||||||
BACKEND.get_or_init(|| {
|
|
||||||
Box::leak(Box::new(AudioBackend {
|
|
||||||
capture: RingBuffer::new(RING_CAPACITY),
|
|
||||||
playout: RingBuffer::new(RING_CAPACITY),
|
|
||||||
started: std::sync::Mutex::new(false),
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C FFI for wzp-desktop ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Start the Oboe audio streams. Returns 0 on success, non-zero on error.
|
|
||||||
/// Idempotent — calling while already running is a no-op that returns 0.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_start() -> i32 {
|
|
||||||
let b = backend();
|
|
||||||
let mut started = match b.started.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
|
||||||
if *started {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
};
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: b.capture.buf_ptr(),
|
|
||||||
capture_capacity: b.capture.capacity as i32,
|
|
||||||
capture_write_idx: b.capture.write_idx_ptr(),
|
|
||||||
capture_read_idx: b.capture.read_idx_ptr(),
|
|
||||||
playout_buf: b.playout.buf_ptr(),
|
|
||||||
playout_capacity: b.playout.capacity as i32,
|
|
||||||
playout_write_idx: b.playout.write_idx_ptr(),
|
|
||||||
playout_read_idx: b.playout.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret != 0 {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
*started = true;
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Oboe. Idempotent. Safe to call from any thread.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_stop() {
|
|
||||||
let b = backend();
|
|
||||||
if let Ok(mut started) = b.started.lock() {
|
|
||||||
if *started {
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
*started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured PCM samples from the capture ring. Returns the number
|
|
||||||
/// of `i16` samples actually copied into `out` (may be less than
|
|
||||||
/// `out_len` if the ring is empty).
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_read_capture(out: *mut i16, out_len: usize) -> usize {
|
|
||||||
if out.is_null() || out_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts_mut(out, out_len) };
|
|
||||||
backend().capture.read(slice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write PCM samples into the playout ring. Returns the number of
|
|
||||||
/// samples actually enqueued (may be less than `in_len` if the ring
|
|
||||||
/// is nearly full — in practice the caller should pace to 20 ms
|
|
||||||
/// frames and spin briefly if the ring is full).
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_len: usize) -> usize {
|
|
||||||
if input.is_null() || in_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
|
|
||||||
let b = backend();
|
|
||||||
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let written = b.playout.write(slice);
|
|
||||||
// First few writes: log ring state + sample range so we can compare what
|
|
||||||
// engine.rs hands us to what the C++ playout callback reads.
|
|
||||||
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if first_writes < 3 || first_writes % 50 == 0 {
|
|
||||||
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
|
|
||||||
for &s in slice.iter() {
|
|
||||||
if s < lo { lo = s; }
|
|
||||||
if s > hi { hi = s; }
|
|
||||||
sumsq += (s as i64) * (s as i64);
|
|
||||||
}
|
|
||||||
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
|
|
||||||
let avail_w_after = b.playout.available_write();
|
|
||||||
let avail_r_after = b.playout.available_read();
|
|
||||||
let msg = format!(
|
|
||||||
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
|
|
||||||
slice.len(), written
|
|
||||||
);
|
|
||||||
unsafe {
|
|
||||||
android_log(msg.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
written
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal android logcat shim so we can print from the cdylib without pulling
|
|
||||||
// in android_logger crate (which would add another dep that has to build with
|
|
||||||
// cargo-ndk). Uses libc's __android_log_print via extern linkage.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn __android_log_write(prio: i32, tag: *const u8, text: *const u8) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe fn android_log(msg: &str) {
|
|
||||||
// ANDROID_LOG_INFO = 4. Tag and text must be NUL-terminated.
|
|
||||||
let tag = b"wzp-native\0";
|
|
||||||
let mut buf = Vec::with_capacity(msg.len() + 1);
|
|
||||||
buf.extend_from_slice(msg.as_bytes());
|
|
||||||
buf.push(0);
|
|
||||||
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
unsafe fn android_log(_msg: &str) {}
|
|
||||||
|
|
||||||
/// Current capture latency reported by Oboe, in milliseconds. Returns
|
|
||||||
/// NaN / 0.0 if the stream isn't running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_capture_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_capture_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current playout latency reported by Oboe, in milliseconds.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_playout_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_playout_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-zero if both Oboe streams are currently running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_is_running() -> i32 {
|
|
||||||
unsafe { wzp_oboe_is_running() }
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -648,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.
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Get git hash at build time
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let hash = match output {
|
|
||||||
Ok(o) if o.status.success() => {
|
|
||||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
|
||||||
}
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("cargo:rustc-env=WZP_BUILD_HASH={hash}");
|
|
||||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//! Direct call state tracking.
|
|
||||||
//!
|
|
||||||
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
|
|
||||||
//! Each call goes through: Pending → Ringing → Active → Ended.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// State of a direct call.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum DirectCallState {
|
|
||||||
/// Offer sent to callee, waiting for response.
|
|
||||||
Pending,
|
|
||||||
/// Callee acknowledged, ringing.
|
|
||||||
Ringing,
|
|
||||||
/// Call accepted, media room active.
|
|
||||||
Active,
|
|
||||||
/// Call ended (hangup, reject, timeout, or error).
|
|
||||||
Ended,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tracked direct call between two users.
|
|
||||||
pub struct DirectCall {
|
|
||||||
pub call_id: String,
|
|
||||||
pub caller_fingerprint: String,
|
|
||||||
pub callee_fingerprint: String,
|
|
||||||
pub state: DirectCallState,
|
|
||||||
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
|
|
||||||
/// Private room name (set when accepted).
|
|
||||||
pub room_name: Option<String>,
|
|
||||||
pub created_at: Instant,
|
|
||||||
pub answered_at: Option<Instant>,
|
|
||||||
pub ended_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry of active direct calls.
|
|
||||||
pub struct CallRegistry {
|
|
||||||
calls: HashMap<String, DirectCall>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallRegistry {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
calls: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new pending call. Returns the call_id.
|
|
||||||
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
|
|
||||||
let call = DirectCall {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
caller_fingerprint: caller_fp,
|
|
||||||
callee_fingerprint: callee_fp,
|
|
||||||
state: DirectCallState::Pending,
|
|
||||||
accept_mode: None,
|
|
||||||
room_name: None,
|
|
||||||
created_at: Instant::now(),
|
|
||||||
answered_at: None,
|
|
||||||
ended_at: None,
|
|
||||||
};
|
|
||||||
self.calls.insert(call_id.clone(), call);
|
|
||||||
self.calls.get(&call_id).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a call by ID.
|
|
||||||
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
|
||||||
self.calls.get(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a mutable call by ID.
|
|
||||||
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
|
|
||||||
self.calls.get_mut(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transition to Ringing state.
|
|
||||||
pub fn set_ringing(&mut self, call_id: &str) -> bool {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
if call.state == DirectCallState::Pending {
|
|
||||||
call.state = DirectCallState::Ringing;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transition to Active state.
|
|
||||||
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
|
|
||||||
call.state = DirectCallState::Active;
|
|
||||||
call.accept_mode = Some(mode);
|
|
||||||
call.room_name = Some(room);
|
|
||||||
call.answered_at = Some(Instant::now());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End a call.
|
|
||||||
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
call.state = DirectCallState::Ended;
|
|
||||||
call.ended_at = Some(Instant::now());
|
|
||||||
}
|
|
||||||
self.calls.remove(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find active/pending calls involving a fingerprint.
|
|
||||||
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
|
|
||||||
self.calls.values()
|
|
||||||
.filter(|c| {
|
|
||||||
c.state != DirectCallState::Ended
|
|
||||||
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the peer's fingerprint in a call.
|
|
||||||
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
|
|
||||||
self.calls.get(call_id).map(|c| {
|
|
||||||
if c.caller_fingerprint == my_fp {
|
|
||||||
c.callee_fingerprint.as_str()
|
|
||||||
} else {
|
|
||||||
c.caller_fingerprint.as_str()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove calls that have been pending longer than the timeout.
|
|
||||||
/// Returns call IDs of expired calls.
|
|
||||||
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
|
|
||||||
let now = Instant::now();
|
|
||||||
let expired: Vec<String> = self.calls.iter()
|
|
||||||
.filter(|(_, c)| {
|
|
||||||
c.state == DirectCallState::Pending
|
|
||||||
&& now.duration_since(c.created_at) > timeout
|
|
||||||
})
|
|
||||||
.map(|(id, _)| id.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
expired.into_iter()
|
|
||||||
.filter_map(|id| self.calls.remove(&id))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of active (non-ended) calls.
|
|
||||||
pub fn active_count(&self) -> usize {
|
|
||||||
self.calls.values()
|
|
||||||
.filter(|c| c.state != DirectCallState::Ended)
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn call_lifecycle() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
|
|
||||||
assert!(reg.set_ringing("c1"));
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
|
|
||||||
|
|
||||||
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
|
|
||||||
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
|
|
||||||
|
|
||||||
let ended = reg.end_call("c1").unwrap();
|
|
||||||
assert_eq!(ended.state, DirectCallState::Ended);
|
|
||||||
assert_eq!(reg.active_count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expire_stale_calls() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
|
|
||||||
// Not expired yet
|
|
||||||
let expired = reg.expire_stale(Duration::from_secs(30));
|
|
||||||
assert!(expired.is_empty());
|
|
||||||
|
|
||||||
// Force expiry with 0 timeout
|
|
||||||
let expired = reg.expire_stale(Duration::from_secs(0));
|
|
||||||
assert_eq!(expired.len(), 1);
|
|
||||||
assert_eq!(expired[0].call_id, "c1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_lookup() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
|
|
||||||
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,41 +3,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use 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 = "*"
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
//! JSONL event log for protocol analysis.
|
|
||||||
//!
|
|
||||||
//! When `--event-log <path>` is set, every media packet emits a structured
|
|
||||||
//! event at each decision point (recv, forward, drop, deliver).
|
|
||||||
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
/// A single protocol event for JSONL output.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct Event {
|
|
||||||
/// ISO 8601 timestamp with microseconds.
|
|
||||||
pub ts: String,
|
|
||||||
/// Event type.
|
|
||||||
pub event: &'static str,
|
|
||||||
/// Room name.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub room: Option<String>,
|
|
||||||
/// Source address or peer label.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub src: Option<String>,
|
|
||||||
/// Packet sequence number.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub seq: Option<u16>,
|
|
||||||
/// Codec identifier.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codec: Option<String>,
|
|
||||||
/// FEC block ID.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub fec_block: Option<u8>,
|
|
||||||
/// FEC symbol index.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub fec_sym: Option<u8>,
|
|
||||||
/// Is FEC repair packet.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub repair: Option<bool>,
|
|
||||||
/// Payload length in bytes.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub len: Option<usize>,
|
|
||||||
/// Number of recipients.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub to_count: Option<usize>,
|
|
||||||
/// Peer label (for federation events).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub peer: Option<String>,
|
|
||||||
/// Drop/error reason.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reason: Option<String>,
|
|
||||||
/// Presence action (active/inactive).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub action: Option<String>,
|
|
||||||
/// Participant count (presence events).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub participants: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event {
|
|
||||||
fn now() -> String {
|
|
||||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a minimal event with just type and timestamp.
|
|
||||||
pub fn new(event: &'static str) -> Self {
|
|
||||||
Self {
|
|
||||||
ts: Self::now(),
|
|
||||||
event,
|
|
||||||
room: None,
|
|
||||||
src: None,
|
|
||||||
seq: None,
|
|
||||||
codec: None,
|
|
||||||
fec_block: None,
|
|
||||||
fec_sym: None,
|
|
||||||
repair: None,
|
|
||||||
len: None,
|
|
||||||
to_count: None,
|
|
||||||
peer: None,
|
|
||||||
reason: None,
|
|
||||||
action: None,
|
|
||||||
participants: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set room.
|
|
||||||
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
|
|
||||||
/// Set source.
|
|
||||||
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
|
|
||||||
/// Set packet header fields from a MediaPacket.
|
|
||||||
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
|
|
||||||
self.seq = Some(pkt.header.seq);
|
|
||||||
self.codec = Some(format!("{:?}", pkt.header.codec_id));
|
|
||||||
self.fec_block = Some(pkt.header.fec_block);
|
|
||||||
self.fec_sym = Some(pkt.header.fec_symbol);
|
|
||||||
self.repair = Some(pkt.header.is_repair);
|
|
||||||
self.len = Some(pkt.payload.len());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
/// Set seq only (when full packet not available).
|
|
||||||
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
|
|
||||||
/// Set payload length.
|
|
||||||
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
|
|
||||||
/// Set recipient count.
|
|
||||||
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
|
|
||||||
/// Set peer label.
|
|
||||||
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
|
|
||||||
/// Set drop reason.
|
|
||||||
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
|
|
||||||
/// Set presence action.
|
|
||||||
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
|
|
||||||
/// Set participant count.
|
|
||||||
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle for emitting events. Cheap to clone.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct EventLog {
|
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLog {
|
|
||||||
/// Emit an event (non-blocking, drops if channel is full).
|
|
||||||
pub fn emit(&self, event: Event) {
|
|
||||||
let _ = self.tx.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No-op event log for when `--event-log` is not set.
|
|
||||||
/// All methods are no-ops that compile to nothing.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct NoopEventLog;
|
|
||||||
|
|
||||||
/// Unified event log handle — either real or no-op.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum EventLogger {
|
|
||||||
Active(EventLog),
|
|
||||||
Noop,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLogger {
|
|
||||||
pub fn emit(&self, event: Event) {
|
|
||||||
if let EventLogger::Active(log) = self {
|
|
||||||
log.emit(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
|
||||||
matches!(self, EventLogger::Active(_))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the event log writer. Returns an `EventLogger` handle.
|
|
||||||
pub fn start_event_log(path: Option<PathBuf>) -> EventLogger {
|
|
||||||
match path {
|
|
||||||
Some(path) => {
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
tokio::spawn(writer_task(path, rx));
|
|
||||||
info!("event log enabled");
|
|
||||||
EventLogger::Active(EventLog { tx })
|
|
||||||
}
|
|
||||||
None => EventLogger::Noop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Background task that writes events to a JSONL file.
|
|
||||||
async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
|
|
||||||
let file = match tokio::fs::File::create(&path).await {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to create event log {}: {e}", path.display());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut writer = tokio::io::BufWriter::new(file);
|
|
||||||
let mut count: u64 = 0;
|
|
||||||
|
|
||||||
while let Some(event) = rx.recv().await {
|
|
||||||
match serde_json::to_string(&event) {
|
|
||||||
Ok(json) => {
|
|
||||||
if writer.write_all(json.as_bytes()).await.is_err() { break; }
|
|
||||||
if writer.write_all(b"\n").await.is_err() { break; }
|
|
||||||
count += 1;
|
|
||||||
// Flush every 100 events
|
|
||||||
if count % 100 == 0 {
|
|
||||||
let _ = writer.flush().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("event log serialize error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = writer.flush().await;
|
|
||||||
info!(events = count, "event log closed");
|
|
||||||
}
|
|
||||||
@@ -1,966 +0,0 @@
|
|||||||
//! Relay federation — global room routing between peer relays.
|
|
||||||
//!
|
|
||||||
//! Each relay maintains a forwarding table per global room. When a local participant
|
|
||||||
//! sends media in a global room, it's forwarded to all peer relays that have the room
|
|
||||||
//! active. Incoming federated media is delivered to local participants and optionally
|
|
||||||
//! forwarded to other active peers (multi-hop).
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
|
||||||
use sha2::{Sha256, Digest};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
|
||||||
use wzp_transport::QuinnTransport;
|
|
||||||
|
|
||||||
use crate::config::{PeerConfig, TrustedConfig};
|
|
||||||
use crate::event_log::{Event, EventLogger};
|
|
||||||
use crate::room::{self, FederationMediaOut, RoomEvent, RoomManager};
|
|
||||||
|
|
||||||
/// Compute 8-byte room hash for federation datagram tagging.
|
|
||||||
pub fn room_hash(room_name: &str) -> [u8; 8] {
|
|
||||||
let h = Sha256::digest(room_name.as_bytes());
|
|
||||||
let mut out = [0u8; 8];
|
|
||||||
out.copy_from_slice(&h[..8]);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a fingerprint string (remove colons, lowercase).
|
|
||||||
fn normalize_fp(fp: &str) -> String {
|
|
||||||
fp.replace(':', "").to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Time-based dedup filter for federation datagrams.
|
|
||||||
/// Tracks recently seen packets and expires entries older than 2 seconds.
|
|
||||||
/// This prevents duplicate delivery when the same packet arrives via
|
|
||||||
/// multiple federation paths, while allowing new senders that happen to
|
|
||||||
/// reuse the same seq numbers.
|
|
||||||
struct Deduplicator {
|
|
||||||
/// Recently seen packet keys with insertion time.
|
|
||||||
entries: HashMap<u64, Instant>,
|
|
||||||
/// Expiry duration.
|
|
||||||
ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deduplicator {
|
|
||||||
fn new(_capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
entries: HashMap::with_capacity(512),
|
|
||||||
ttl: Duration::from_secs(2),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this packet is a duplicate (already seen within TTL).
|
|
||||||
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
|
|
||||||
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
// Periodic cleanup (every ~256 packets)
|
|
||||||
if self.entries.len() > 256 {
|
|
||||||
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ts) = self.entries.get(&key) {
|
|
||||||
if now.duration_since(*ts) < self.ttl {
|
|
||||||
return true; // seen recently — duplicate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.entries.insert(key, now);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-room token bucket rate limiter for federation forwarding.
|
|
||||||
struct RateLimiter {
|
|
||||||
/// Max packets per second per room.
|
|
||||||
max_pps: u32,
|
|
||||||
/// Tokens remaining in current window.
|
|
||||||
tokens: u32,
|
|
||||||
/// When the current window started.
|
|
||||||
window_start: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RateLimiter {
|
|
||||||
fn new(max_pps: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
max_pps,
|
|
||||||
tokens: max_pps,
|
|
||||||
window_start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the packet should be allowed through.
|
|
||||||
fn allow(&mut self) -> bool {
|
|
||||||
let elapsed = self.window_start.elapsed();
|
|
||||||
if elapsed >= Duration::from_secs(1) {
|
|
||||||
self.tokens = self.max_pps;
|
|
||||||
self.window_start = Instant::now();
|
|
||||||
}
|
|
||||||
if self.tokens > 0 {
|
|
||||||
self.tokens -= 1;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active link to a peer relay.
|
|
||||||
struct PeerLink {
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
label: String,
|
|
||||||
/// Global rooms that this peer has reported as active.
|
|
||||||
active_rooms: HashSet<String>,
|
|
||||||
/// Remote participants per room (for federated presence in RoomUpdate).
|
|
||||||
remote_participants: HashMap<String, Vec<wzp_proto::packet::RoomParticipant>>,
|
|
||||||
/// Last time we received any data (signal or media) from this peer.
|
|
||||||
last_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Max federation packets per second per room (0 = unlimited).
|
|
||||||
const FEDERATION_RATE_LIMIT_PPS: u32 = 500;
|
|
||||||
/// Dedup window size (number of recent packets to remember).
|
|
||||||
const DEDUP_WINDOW_SIZE: usize = 4096;
|
|
||||||
/// Remote participants are considered stale after this duration with no updates.
|
|
||||||
const REMOTE_PARTICIPANT_STALE_SECS: u64 = 15;
|
|
||||||
|
|
||||||
/// Manages federation connections and global room forwarding.
|
|
||||||
pub struct FederationManager {
|
|
||||||
peers: Vec<PeerConfig>,
|
|
||||||
trusted: Vec<TrustedConfig>,
|
|
||||||
global_rooms: HashSet<String>,
|
|
||||||
room_mgr: Arc<Mutex<RoomManager>>,
|
|
||||||
endpoint: quinn::Endpoint,
|
|
||||||
local_tls_fp: String,
|
|
||||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
|
||||||
/// Active peer connections, keyed by normalized fingerprint.
|
|
||||||
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
|
|
||||||
/// Dedup filter for incoming federation datagrams.
|
|
||||||
dedup: Mutex<Deduplicator>,
|
|
||||||
/// Per-room seq counter for federation media delivered to local clients.
|
|
||||||
/// Ensures clients see monotonically increasing seq regardless of federation sender.
|
|
||||||
local_delivery_seq: std::sync::atomic::AtomicU16,
|
|
||||||
/// JSONL event log for protocol analysis.
|
|
||||||
event_log: EventLogger,
|
|
||||||
/// Per-room rate limiters for inbound federation media.
|
|
||||||
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FederationManager {
|
|
||||||
pub fn new(
|
|
||||||
peers: Vec<PeerConfig>,
|
|
||||||
trusted: Vec<TrustedConfig>,
|
|
||||||
global_rooms: HashSet<String>,
|
|
||||||
room_mgr: Arc<Mutex<RoomManager>>,
|
|
||||||
endpoint: quinn::Endpoint,
|
|
||||||
local_tls_fp: String,
|
|
||||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
|
||||||
event_log: EventLogger,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
peers,
|
|
||||||
trusted,
|
|
||||||
global_rooms,
|
|
||||||
room_mgr,
|
|
||||||
endpoint,
|
|
||||||
local_tls_fp,
|
|
||||||
metrics,
|
|
||||||
peer_links: Arc::new(Mutex::new(HashMap::new())),
|
|
||||||
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
|
|
||||||
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
|
|
||||||
event_log,
|
|
||||||
rate_limiters: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a room name (which may be hashed) is a global room.
|
|
||||||
pub fn is_global_room(&self, room: &str) -> bool {
|
|
||||||
self.resolve_global_room(room).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a room name (raw or hashed) to the canonical global room name.
|
|
||||||
/// Returns the configured global room name if it matches.
|
|
||||||
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
|
|
||||||
// Direct match (raw room name, e.g. Android clients)
|
|
||||||
if self.global_rooms.contains(room) {
|
|
||||||
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
|
|
||||||
}
|
|
||||||
// Hashed match (desktop clients hash room names for SNI privacy)
|
|
||||||
self.global_rooms.iter().find(|name| {
|
|
||||||
wzp_crypto::hash_room_name(name) == room
|
|
||||||
}).map(|s| s.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the canonical federation room hash for a room.
|
|
||||||
/// Always uses the configured global room name, not the client-provided name.
|
|
||||||
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
|
|
||||||
if let Some(canonical) = self.resolve_global_room(room) {
|
|
||||||
room_hash(canonical)
|
|
||||||
} else {
|
|
||||||
room_hash(room)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start federation — spawns connection loops + event dispatcher.
|
|
||||||
pub async fn run(self: Arc<Self>) {
|
|
||||||
if self.peers.is_empty() && self.global_rooms.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
peers = self.peers.len(),
|
|
||||||
global_rooms = self.global_rooms.len(),
|
|
||||||
"federation starting"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
|
|
||||||
// Per-peer outbound connection loops
|
|
||||||
for peer in &self.peers {
|
|
||||||
let this = self.clone();
|
|
||||||
let peer = peer.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_peer_loop(this, peer).await;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room event dispatcher
|
|
||||||
let room_events = {
|
|
||||||
let mgr = self.room_mgr.lock().await;
|
|
||||||
mgr.subscribe_events()
|
|
||||||
};
|
|
||||||
let this = self.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_room_event_dispatcher(this, room_events).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Stale presence sweeper — purges remote participants from dead peers
|
|
||||||
let this = self.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_stale_presence_sweeper(this).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
for h in handles {
|
|
||||||
let _ = h.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an inbound federation connection from a recognized peer.
|
|
||||||
pub async fn handle_inbound(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
peer_config: PeerConfig,
|
|
||||||
) {
|
|
||||||
let peer_fp = normalize_fp(&peer_config.fingerprint);
|
|
||||||
let label = peer_config.label.unwrap_or_else(|| peer_config.url.clone());
|
|
||||||
info!(peer = %label, "inbound federation link active");
|
|
||||||
if let Err(e) = run_federation_link(self.clone(), transport, peer_fp, label.clone()).await {
|
|
||||||
warn!(peer = %label, "inbound federation link ended: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all remote participants for a room from all peer links.
|
|
||||||
/// Deduplicates by fingerprint (same participant may appear via multiple links).
|
|
||||||
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
|
||||||
let canonical = self.resolve_global_room(room);
|
|
||||||
let links = self.peer_links.lock().await;
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for link in links.values() {
|
|
||||||
// Check canonical name
|
|
||||||
if let Some(c) = canonical {
|
|
||||||
if let Some(remote) = link.remote_participants.get(c) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
// Also check raw room name, but only if different from canonical
|
|
||||||
if c != room {
|
|
||||||
if let Some(remote) = link.remote_participants.get(room) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Some(remote) = link.remote_participants.get(room) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward locally-generated media to all connected peers.
|
|
||||||
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
|
|
||||||
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
|
|
||||||
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
|
||||||
let links = self.peer_links.lock().await;
|
|
||||||
if links.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (_fp, link) in links.iter() {
|
|
||||||
let mut tagged = Vec::with_capacity(8 + media_data.len());
|
|
||||||
tagged.extend_from_slice(room_hash);
|
|
||||||
tagged.extend_from_slice(media_data);
|
|
||||||
match link.transport.send_raw_datagram(&tagged) {
|
|
||||||
Ok(()) => {
|
|
||||||
self.metrics.federation_packets_forwarded
|
|
||||||
.with_label_values(&[&link.label, "out"]).inc();
|
|
||||||
}
|
|
||||||
Err(e) => warn!(peer = %link.label, "federation send error: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Trust verification (kept from previous implementation) ──
|
|
||||||
|
|
||||||
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
|
|
||||||
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
|
|
||||||
let addr_ip = addr.ip();
|
|
||||||
self.peers.iter().find(|p| {
|
|
||||||
p.url.parse::<SocketAddr>()
|
|
||||||
.map(|sa| sa.ip() == addr_ip)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
|
|
||||||
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
|
|
||||||
if let Some(peer) = self.find_peer_by_addr(addr) {
|
|
||||||
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
|
|
||||||
}
|
|
||||||
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
|
|
||||||
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Outbound media egress task ──
|
|
||||||
|
|
||||||
/// Drains the federation media channel and forwards to active peers.
|
|
||||||
pub async fn run_federation_media_egress(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
mut rx: tokio::sync::mpsc::Receiver<FederationMediaOut>,
|
|
||||||
) {
|
|
||||||
let mut count: u64 = 0;
|
|
||||||
while let Some(out) = rx.recv().await {
|
|
||||||
count += 1;
|
|
||||||
if count == 1 || count % 250 == 0 {
|
|
||||||
info!(room = %out.room_name, count, "federation egress: forwarding media");
|
|
||||||
}
|
|
||||||
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
|
|
||||||
}
|
|
||||||
info!(total = count, "federation egress task ended");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Room event dispatcher ──
|
|
||||||
|
|
||||||
/// Watches RoomManager events and sends GlobalRoomActive/Inactive to peers.
|
|
||||||
async fn run_room_event_dispatcher(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
mut events: tokio::sync::broadcast::Receiver<RoomEvent>,
|
|
||||||
) {
|
|
||||||
loop {
|
|
||||||
match events.recv().await {
|
|
||||||
Ok(RoomEvent::LocalJoin { room }) => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
let participants = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.local_participant_list(&room)
|
|
||||||
};
|
|
||||||
info!(room = %room, count = participants.len(), "global room now active, announcing to peers");
|
|
||||||
let msg = SignalMessage::GlobalRoomActive { room, participants };
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
let _ = link.transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(RoomEvent::LocalLeave { room }) => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
info!(room = %room, "global room now inactive, announcing to peers");
|
|
||||||
let msg = SignalMessage::GlobalRoomInactive { room };
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
let _ = link.transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
warn!(missed = n, "room event receiver lagged");
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stale presence sweeper ──
|
|
||||||
|
|
||||||
/// Periodically checks for stale remote participants and purges them.
|
|
||||||
/// This handles the case where a peer link dies without sending GlobalRoomInactive
|
|
||||||
/// (e.g., QUIC timeout, network partition, crash).
|
|
||||||
async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let stale_threshold = Duration::from_secs(REMOTE_PARTICIPANT_STALE_SECS);
|
|
||||||
|
|
||||||
// Find peers with stale remote_participants whose link is also gone or idle
|
|
||||||
let stale_rooms: Vec<(String, String)> = {
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
let mut stale = Vec::new();
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
|
|
||||||
for room in link.remote_participants.keys() {
|
|
||||||
stale.push((fp.clone(), room.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stale
|
|
||||||
};
|
|
||||||
|
|
||||||
if stale_rooms.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge stale entries and collect affected rooms
|
|
||||||
let mut affected_rooms = HashSet::new();
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
for (fp, room) in &stale_rooms {
|
|
||||||
if let Some(link) = links.get_mut(fp.as_str()) {
|
|
||||||
if link.last_seen.elapsed() > stale_threshold {
|
|
||||||
info!(peer = %link.label, room = %room, "purging stale remote participants (no data for {}s)", link.last_seen.elapsed().as_secs());
|
|
||||||
link.remote_participants.remove(room);
|
|
||||||
link.active_rooms.remove(room);
|
|
||||||
affected_rooms.insert(room.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate for affected rooms
|
|
||||||
for room in &affected_rooms {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(room) {
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
let remote = fm.get_remote_participants(&local_room).await;
|
|
||||||
all_participants.extend(remote);
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
info!(room = %room, "swept stale presence — broadcast updated RoomUpdate");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Peer connection management ──
|
|
||||||
|
|
||||||
/// Persistent connection loop for one peer — reconnects with backoff.
|
|
||||||
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
|
|
||||||
let mut backoff = Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
|
|
||||||
match connect_to_peer(&fm, &peer).await {
|
|
||||||
Ok(transport) => {
|
|
||||||
backoff = Duration::from_secs(5);
|
|
||||||
let peer_fp = normalize_fp(&peer.fingerprint);
|
|
||||||
let label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
|
|
||||||
if let Err(e) = run_federation_link(fm.clone(), transport, peer_fp, label).await {
|
|
||||||
warn!(peer_url = %peer.url, "federation link ended: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(backoff).await;
|
|
||||||
backoff = (backoff * 2).min(Duration::from_secs(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a peer relay and send hello.
|
|
||||||
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
|
|
||||||
let addr: SocketAddr = peer.url.parse()?;
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
|
||||||
let transport = Arc::new(QuinnTransport::new(conn));
|
|
||||||
|
|
||||||
// Send hello with our TLS fingerprint
|
|
||||||
let hello = SignalMessage::FederationHello {
|
|
||||||
tls_fingerprint: fm.local_tls_fp.clone(),
|
|
||||||
};
|
|
||||||
transport.send_signal(&hello).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
|
|
||||||
|
|
||||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
|
|
||||||
Ok(transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Federation link (runs on a single QUIC connection) ──
|
|
||||||
|
|
||||||
/// Run the federation link: exchange global room state and forward media.
|
|
||||||
async fn run_federation_link(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
peer_fp: String,
|
|
||||||
peer_label: String,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
// Register peer link + metrics
|
|
||||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
links.insert(peer_fp.clone(), PeerLink {
|
|
||||||
transport: transport.clone(),
|
|
||||||
label: peer_label.clone(),
|
|
||||||
active_rooms: HashSet::new(),
|
|
||||||
remote_participants: HashMap::new(),
|
|
||||||
last_seen: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Announce our currently active global rooms to this new peer
|
|
||||||
// Collect all announcements first, then send (avoid holding locks across await)
|
|
||||||
let announcements = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
let active = mgr.active_rooms();
|
|
||||||
let mut msgs = Vec::new();
|
|
||||||
|
|
||||||
// Local rooms
|
|
||||||
for room_name in &active {
|
|
||||||
if fm.is_global_room(room_name) {
|
|
||||||
let participants = mgr.local_participant_list(room_name);
|
|
||||||
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
|
|
||||||
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote rooms from OTHER peers (for multi-hop propagation)
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != &peer_fp {
|
|
||||||
for (room, participants) in &link.remote_participants {
|
|
||||||
if fm.is_global_room(room) {
|
|
||||||
info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer");
|
|
||||||
msgs.push(SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: participants.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msgs
|
|
||||||
};
|
|
||||||
for msg in &announcements {
|
|
||||||
let _ = transport.send_signal(msg).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Three concurrent tasks: signal recv + media recv + RTT monitor
|
|
||||||
let signal_transport = transport.clone();
|
|
||||||
let media_transport = transport.clone();
|
|
||||||
let rtt_transport = transport.clone();
|
|
||||||
let fm_signal = fm.clone();
|
|
||||||
let fm_media = fm.clone();
|
|
||||||
let fm_rtt = fm.clone();
|
|
||||||
let peer_fp_signal = peer_fp.clone();
|
|
||||||
let peer_fp_media = peer_fp.clone();
|
|
||||||
let label_signal = peer_label.clone();
|
|
||||||
let label_rtt = peer_label.clone();
|
|
||||||
|
|
||||||
let signal_task = async move {
|
|
||||||
loop {
|
|
||||||
match signal_transport.recv_signal().await {
|
|
||||||
Ok(Some(msg)) => {
|
|
||||||
handle_signal(&fm_signal, &peer_fp_signal, &label_signal, msg).await;
|
|
||||||
}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => {
|
|
||||||
error!(peer = %label_signal, "federation signal error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let peer_label_media = peer_label.clone();
|
|
||||||
let media_task = async move {
|
|
||||||
let mut media_count: u64 = 0;
|
|
||||||
loop {
|
|
||||||
match media_transport.connection().read_datagram().await {
|
|
||||||
Ok(data) => {
|
|
||||||
media_count += 1;
|
|
||||||
if media_count == 1 || media_count % 250 == 0 {
|
|
||||||
info!(peer = %peer_label_media, media_count, len = data.len(), "federation: received datagram");
|
|
||||||
}
|
|
||||||
handle_datagram(&fm_media, &peer_fp_media, data).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
info!(peer = %peer_label_media, "federation media task ended: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// RTT monitor: periodically sample QUIC RTT for this peer
|
|
||||||
let rtt_task = async move {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = signal_task => {}
|
|
||||||
_ = media_task => {}
|
|
||||||
_ = rtt_task => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: remove peer link + metrics
|
|
||||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
links.remove(&peer_fp);
|
|
||||||
}
|
|
||||||
info!(peer = %peer_label, "federation link ended");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an incoming federation signal.
|
|
||||||
async fn handle_signal(
|
|
||||||
fm: &Arc<FederationManager>,
|
|
||||||
peer_fp: &str,
|
|
||||||
peer_label: &str,
|
|
||||||
msg: SignalMessage,
|
|
||||||
) {
|
|
||||||
// Update last_seen for this peer
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.last_seen = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
SignalMessage::GlobalRoomActive { room, participants } => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active");
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.active_rooms.insert(room.clone());
|
|
||||||
}
|
|
||||||
// Update active rooms metric
|
|
||||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
|
||||||
fm.metrics.federation_active_rooms.set(total as i64);
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
// Tag remote participants with their relay label
|
|
||||||
let tagged: Vec<_> = participants.iter().map(|p| {
|
|
||||||
let mut tagged = p.clone();
|
|
||||||
if tagged.relay_label.is_none() {
|
|
||||||
tagged.relay_label = Some(link.label.clone());
|
|
||||||
}
|
|
||||||
tagged
|
|
||||||
}).collect();
|
|
||||||
link.remote_participants.insert(room.clone(), tagged);
|
|
||||||
}
|
|
||||||
// Propagate to other peers (with relay labels preserved)
|
|
||||||
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
|
|
||||||
let label = link.label.clone();
|
|
||||||
participants.iter().map(|p| {
|
|
||||||
let mut t = p.clone();
|
|
||||||
if t.relay_label.is_none() {
|
|
||||||
t.relay_label = Some(label.clone());
|
|
||||||
}
|
|
||||||
t
|
|
||||||
}).collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
participants.clone()
|
|
||||||
};
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != peer_fp {
|
|
||||||
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: tagged_for_propagation.clone(),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(links);
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate to local clients in this room
|
|
||||||
// Find the local room name (may be hashed or raw)
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
// Build merged participant list: local + all remote (deduped)
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
if let Some(canonical) = fm.resolve_global_room(&local_room) {
|
|
||||||
if let Some(remote) = link.remote_participants.get(canonical) {
|
|
||||||
all_participants.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
// Also check raw room name, but only if different from canonical
|
|
||||||
if canonical != local_room {
|
|
||||||
if let Some(remote) = link.remote_participants.get(&local_room) {
|
|
||||||
all_participants.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(links);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SignalMessage::GlobalRoomInactive { room } => {
|
|
||||||
info!(peer = %peer_label, room = %room, "peer global room now inactive");
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.active_rooms.remove(&room);
|
|
||||||
// Clear remote participants for this peer+room
|
|
||||||
link.remote_participants.remove(&room);
|
|
||||||
// Also try canonical name
|
|
||||||
if let Some(canonical) = fm.resolve_global_room(&room) {
|
|
||||||
link.remote_participants.remove(canonical);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update active rooms metric
|
|
||||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
|
||||||
fm.metrics.federation_active_rooms.set(total as i64);
|
|
||||||
|
|
||||||
// Build remaining remote participants (from all peers except the one going inactive)
|
|
||||||
let remaining_remote: Vec<wzp_proto::packet::RoomParticipant> = {
|
|
||||||
let canonical = fm.resolve_global_room(&room);
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp == peer_fp { continue; }
|
|
||||||
if let Some(c) = canonical {
|
|
||||||
if let Some(remote) = link.remote_participants.get(c) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
result
|
|
||||||
};
|
|
||||||
|
|
||||||
// Propagate to other peers: send updated GlobalRoomActive with revised list,
|
|
||||||
// or GlobalRoomInactive if no participants remain anywhere
|
|
||||||
let local_active = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room))
|
|
||||||
};
|
|
||||||
let has_remaining = !remaining_remote.is_empty() || local_active;
|
|
||||||
|
|
||||||
// Collect peer transports to send to (avoid holding lock across await)
|
|
||||||
let peer_sends: Vec<_> = links.iter()
|
|
||||||
.filter(|(fp, _)| *fp != peer_fp)
|
|
||||||
.map(|(_, link)| link.transport.clone())
|
|
||||||
.collect();
|
|
||||||
drop(links);
|
|
||||||
|
|
||||||
if has_remaining {
|
|
||||||
// Send updated participant list to other peers
|
|
||||||
let mut updated_participants = remaining_remote.clone();
|
|
||||||
if local_active {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
updated_participants.extend(mgr.local_participant_list(&local_room));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let msg = SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: updated_participants,
|
|
||||||
};
|
|
||||||
for transport in &peer_sends {
|
|
||||||
let _ = transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No participants left anywhere — propagate inactive
|
|
||||||
let msg = SignalMessage::GlobalRoomInactive { room: room.clone() };
|
|
||||||
for transport in &peer_sends {
|
|
||||||
let _ = transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate to local clients (remote participant removed)
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
all_participants.extend(remaining_remote.iter().cloned());
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
info!(room = %room, "broadcast updated presence (remote participant removed)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // ignore other signals
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an incoming federation datagram (room-hash-tagged media).
|
|
||||||
async fn handle_datagram(
|
|
||||||
fm: &Arc<FederationManager>,
|
|
||||||
source_peer_fp: &str,
|
|
||||||
data: Bytes,
|
|
||||||
) {
|
|
||||||
if data.len() < 12 { return; } // 8-byte hash + min packet
|
|
||||||
|
|
||||||
let mut rh = [0u8; 8];
|
|
||||||
rh.copy_from_slice(&data[..8]);
|
|
||||||
let media_bytes = data.slice(8..);
|
|
||||||
|
|
||||||
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
|
|
||||||
Some(pkt) => pkt,
|
|
||||||
None => {
|
|
||||||
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event log: federation ingress
|
|
||||||
let peer_label = {
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
|
|
||||||
};
|
|
||||||
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
|
|
||||||
|
|
||||||
// Count inbound federation packet + update last_seen
|
|
||||||
fm.metrics.federation_packets_forwarded
|
|
||||||
.with_label_values(&[source_peer_fp, "in"]).inc();
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(source_peer_fp) {
|
|
||||||
link.last_seen = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup: drop packets we've already seen (multi-path duplicates).
|
|
||||||
// Key uses a hash of the actual payload bytes — unique per Opus frame,
|
|
||||||
// so different senders with the same seq/timestamp never collide.
|
|
||||||
let payload_hash = {
|
|
||||||
let mut h = 0u64;
|
|
||||||
for (i, &b) in media_bytes.iter().take(16).enumerate() {
|
|
||||||
h ^= (b as u64) << ((i % 8) * 8);
|
|
||||||
}
|
|
||||||
h
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut dedup = fm.dedup.lock().await;
|
|
||||||
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
|
|
||||||
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find room by hash — check local rooms AND global room config
|
|
||||||
let room_name = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
let active = mgr.active_rooms();
|
|
||||||
// First: check local rooms (has participants)
|
|
||||||
active.iter().find(|r| room_hash(r) == rh).cloned()
|
|
||||||
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
|
|
||||||
// Second: check global room config (hub relay may have no local participants)
|
|
||||||
.or_else(|| {
|
|
||||||
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let room_name = match room_name {
|
|
||||||
Some(r) => r,
|
|
||||||
None => {
|
|
||||||
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rate limit per room
|
|
||||||
if FEDERATION_RATE_LIMIT_PPS > 0 {
|
|
||||||
let mut limiters = fm.rate_limiters.lock().await;
|
|
||||||
let limiter = limiters.entry(room_name.clone())
|
|
||||||
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
|
|
||||||
if !limiter.allow() {
|
|
||||||
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliver to all local participants — forward the raw bytes as-is.
|
|
||||||
// The original sender's MediaPacket is preserved exactly (no re-serialization).
|
|
||||||
let locals = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.local_senders(&room_name)
|
|
||||||
};
|
|
||||||
for sender in &locals {
|
|
||||||
match sender {
|
|
||||||
room::ParticipantSender::Quic(t) => {
|
|
||||||
if let Err(e) = t.send_raw_datagram(&media_bytes) {
|
|
||||||
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
|
|
||||||
warn!("federation local delivery error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
|
|
||||||
|
|
||||||
// Multi-hop: forward to ALL other connected peers (not the source)
|
|
||||||
// Don't filter by active_rooms — the receiving peer decides whether to deliver
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != source_peer_fp {
|
|
||||||
let mut tagged = Vec::with_capacity(8 + media_bytes.len());
|
|
||||||
tagged.extend_from_slice(&rh);
|
|
||||||
tagged.extend_from_slice(&media_bytes);
|
|
||||||
let _ = link.transport.send_raw_datagram(&tagged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,27 +15,25 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
|||||||
/// 5. Derive shared ChaCha20-Poly1305 session
|
/// 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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,115 +207,14 @@ 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))?;
|
||||||
|
|
||||||
// Compute the IP address we should advertise in CallSetup for direct
|
|
||||||
// calls. If the relay is bound to a specific IP, use it as-is; if bound
|
|
||||||
// to 0.0.0.0, use the trick of "connect" a UDP socket to an arbitrary
|
|
||||||
// external address and read its local_addr — the OS binds to whichever
|
|
||||||
// local interface IP would route packets to that destination, which is
|
|
||||||
// the primary outbound interface. This is the same IP clients on the
|
|
||||||
// LAN use to reach us.
|
|
||||||
let advertised_ip: std::net::IpAddr = {
|
|
||||||
let listen_ip = config.listen_addr.ip();
|
|
||||||
if !listen_ip.is_unspecified() {
|
|
||||||
listen_ip
|
|
||||||
} else {
|
|
||||||
// Probe via a dummy "connected" UDP socket. Never actually sends.
|
|
||||||
match std::net::UdpSocket::bind("0.0.0.0:0")
|
|
||||||
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) })
|
|
||||||
.and_then(|s| s.local_addr())
|
|
||||||
{
|
|
||||||
Ok(a) if !a.ip().is_loopback() => a.ip(),
|
|
||||||
_ => std::net::IpAddr::from([127u8, 0, 0, 1]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let advertised_addr_str = format!("{}:{}", advertised_ip, config.listen_addr.port());
|
|
||||||
info!(%advertised_addr_str, "relay advertised address for CallSetup");
|
|
||||||
|
|
||||||
// Forward mode
|
// Forward mode
|
||||||
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
||||||
if let Some(remote_addr) = config.remote_relay {
|
if let Some(remote_addr) = config.remote_relay {
|
||||||
@@ -418,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(
|
||||||
@@ -487,32 +267,13 @@ 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...");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Pull the next Incoming off the queue. Deliberately do NOT await
|
let connection = match wzp_transport::accept(&endpoint).await {
|
||||||
// the QUIC handshake here — move that into the per-connection
|
Ok(conn) => conn,
|
||||||
// spawned task below. Previously we used wzp_transport::accept
|
Err(e) => { error!("accept: {e}"); continue; }
|
||||||
// which did both, which meant a single slow handshake would block
|
|
||||||
// the entire accept loop and prevent ALL subsequent connections
|
|
||||||
// from being processed. Surfaced as direct-call hangs where the
|
|
||||||
// callee's call-* connection never completes its QUIC handshake.
|
|
||||||
let incoming = match endpoint.accept().await {
|
|
||||||
Some(inc) => inc,
|
|
||||||
None => {
|
|
||||||
error!("endpoint.accept() returned None — endpoint closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let remote_transport = remote_transport.clone();
|
let remote_transport = remote_transport.clone();
|
||||||
@@ -522,28 +283,10 @@ 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 advertised_addr_str = advertised_addr_str.clone();
|
|
||||||
|
|
||||||
let incoming_addr = incoming.remote_address();
|
|
||||||
info!(%incoming_addr, "accept queue: new Incoming, spawning handshake task");
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Drive the QUIC handshake inside the spawned task so that
|
|
||||||
// slow or hung handshakes never block the outer accept loop.
|
|
||||||
let connection = match incoming.await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
error!(%incoming_addr, "QUIC handshake failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
info!(%incoming_addr, "QUIC handshake complete");
|
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
|
|
||||||
let room_name = connection
|
let room_name = connection
|
||||||
@@ -556,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" {
|
||||||
@@ -659,290 +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.
|
|
||||||
//
|
|
||||||
// BUG FIX: the previous version of this used `addr.ip()`
|
|
||||||
// which is `connection.remote_address()` — the CLIENT'S
|
|
||||||
// IP, not the relay's. So CallSetup told both parties to
|
|
||||||
// dial the answerer's own IP, which meant the caller was
|
|
||||||
// sending QUIC Initials into the callee's client (no
|
|
||||||
// server listening there) and the callee was sending to
|
|
||||||
// itself. In both cases endpoint.connect() hung forever.
|
|
||||||
//
|
|
||||||
// Use the relay's precomputed advertised address instead.
|
|
||||||
let relay_addr_for_setup = advertised_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 {
|
||||||
@@ -989,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 {
|
||||||
@@ -1006,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");
|
||||||
@@ -1083,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);
|
||||||
@@ -1145,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,
|
||||||
@@ -1172,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
|
||||||
@@ -1197,5 +553,4 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ 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,
|
||||||
@@ -67,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",
|
||||||
@@ -136,12 +107,6 @@ impl RelayMetrics {
|
|||||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||||
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
||||||
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
||||||
registry.register(Box::new(federation_peer_status.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_deduped.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_active_rooms.clone())).expect("register");
|
|
||||||
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
||||||
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
||||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||||
@@ -155,12 +120,6 @@ impl RelayMetrics {
|
|||||||
bytes_forwarded,
|
bytes_forwarded,
|
||||||
auth_attempts,
|
auth_attempts,
|
||||||
handshake_duration,
|
handshake_duration,
|
||||||
federation_peer_status,
|
|
||||||
federation_peer_rtt_ms,
|
|
||||||
federation_packets_forwarded,
|
|
||||||
federation_packets_deduped,
|
|
||||||
federation_packets_rate_limited,
|
|
||||||
federation_active_rooms,
|
|
||||||
session_buffer_depth,
|
session_buffer_depth,
|
||||||
session_loss_pct,
|
session_loss_pct,
|
||||||
session_rtt_ms,
|
session_rtt_ms,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{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]`.
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
//! Persistent signaling connection manager.
|
|
||||||
//!
|
|
||||||
//! Tracks clients connected via `_signal` SNI. Routes call signals
|
|
||||||
//! (DirectCallOffer, DirectCallAnswer, Hangup) between registered users.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
|
||||||
use wzp_transport::QuinnTransport;
|
|
||||||
|
|
||||||
/// A client connected via `_signal` for direct calling.
|
|
||||||
pub struct SignalClient {
|
|
||||||
pub fingerprint: String,
|
|
||||||
pub alias: Option<String>,
|
|
||||||
pub transport: Arc<QuinnTransport>,
|
|
||||||
pub connected_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages persistent signaling connections.
|
|
||||||
pub struct SignalHub {
|
|
||||||
clients: HashMap<String, SignalClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SignalHub {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
clients: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a new signaling client.
|
|
||||||
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
|
|
||||||
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
|
|
||||||
self.clients.insert(fp.clone(), SignalClient {
|
|
||||||
fingerprint: fp,
|
|
||||||
alias,
|
|
||||||
transport,
|
|
||||||
connected_at: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unregister a signaling client. Returns the client if found.
|
|
||||||
pub fn unregister(&mut self, fp: &str) -> Option<SignalClient> {
|
|
||||||
let client = self.clients.remove(fp);
|
|
||||||
if client.is_some() {
|
|
||||||
info!(fingerprint = %fp, "signal client unregistered");
|
|
||||||
}
|
|
||||||
client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a client by fingerprint.
|
|
||||||
pub fn get(&self, fp: &str) -> Option<&SignalClient> {
|
|
||||||
self.clients.get(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a fingerprint is online.
|
|
||||||
pub fn is_online(&self, fp: &str) -> bool {
|
|
||||||
self.clients.contains_key(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a signal message to a client by fingerprint.
|
|
||||||
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
|
|
||||||
match self.clients.get(fp) {
|
|
||||||
Some(client) => {
|
|
||||||
client.transport.send_signal(msg).await
|
|
||||||
.map_err(|e| format!("send to {fp}: {e}"))
|
|
||||||
}
|
|
||||||
None => Err(format!("{fp} not online")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of connected signaling clients.
|
|
||||||
pub fn online_count(&self) -> usize {
|
|
||||||
self.clients.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all online fingerprints.
|
|
||||||
pub fn online_fingerprints(&self) -> Vec<&str> {
|
|
||||||
self.clients.keys().map(|s| s.as_str()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get alias for a fingerprint.
|
|
||||||
pub fn alias(&self, fp: &str) -> Option<&str> {
|
|
||||||
self.clients.get(fp).and_then(|c| c.alias.as_deref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn register_unregister() {
|
|
||||||
let mut hub = SignalHub::new();
|
|
||||||
assert_eq!(hub.online_count(), 0);
|
|
||||||
assert!(!hub.is_online("alice"));
|
|
||||||
|
|
||||||
// Can't easily construct QuinnTransport in a unit test,
|
|
||||||
// so we just test the HashMap logic conceptually.
|
|
||||||
// Integration tests cover the full flow.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,9 +16,6 @@ async-trait = { workspace = true }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
ed25519-dalek = { workspace = true }
|
|
||||||
hkdf = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -6,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)
|
||||||
|
|||||||
@@ -22,13 +22,8 @@ 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;
|
||||||
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||||
|
|
||||||
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can
|
|
||||||
// thread a shared endpoint between signaling and media connections without
|
|
||||||
// needing to depend on quinn directly.
|
|
||||||
pub use quinn::Endpoint;
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user