30 Commits

Author SHA1 Message Date
Siavash Sameni
237adbbf21 chore: update Cargo.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:25:22 +04:00
Siavash Sameni
ac3b997758 fix: align HKDF info strings with featherChat identity derivation
Changed HKDF expand info strings to match featherChat's identity.rs:
- "warzone-ed25519-identity" → "warzone-ed25519"
- "warzone-x25519-identity" → "warzone-x25519"

Same BIP39 seed now produces identical Ed25519/X25519 keypairs in both
featherChat and WZP. This is the prerequisite for shared identity.

Also added FEATHERCHAT_INTEGRATION.md (1209 lines) from featherChat repo
documenting the full integration plan with confirmed code references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:16:57 +04:00
Siavash Sameni
5425c59e7d docs: comprehensive project documentation
- ARCHITECTURE.md: protocol design, wire format, FEC, crypto, relay modes
- USAGE.md: build instructions, all CLI flags, deployment examples
- DESIGN.md: rationale for codec/FEC/transport/crypto choices
- EXTENSIBILITY.md: trait extension points, Warzone integration, future features
- PROGRESS.md: phase 1-4 timeline, test coverage, known issues
- API.md: complete crate API reference for all 8 crates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 05:30:11 +04:00
Siavash Sameni
d8330525ef feat: multi-party rooms (SFU) + push-to-talk radio mode
Room-based SFU relay:
- Clients join named rooms (room name from QUIC SNI)
- Each participant's packets forwarded to all others (no mixing)
- Multiple rooms run concurrently on one relay
- Web bridge passes room name from URL path to relay

Push-to-talk (radio mode):
- Toggle "Radio mode" checkbox after connecting
- Hold PTT button or spacebar to transmit
- Release to mute mic (receive-only)
- Works on desktop (spacebar) and mobile (touch)

URL routing:
- /myroom → joins room "myroom"
- Room name input field as fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:36:19 +04:00
Siavash Sameni
b65f76e4db ci: Gitea Actions build workflow for multi-arch binaries
Triggers:
- On tag push (v*): builds amd64, creates release with artifacts
- Manual dispatch: select targets (amd64, arm64, armv7)

Targets:
- linux/amd64: full build with headless + audio client
- linux/arm64: Raspberry Pi 4/5, cross-compiled (headless)
- linux/armv7: Raspberry Pi 3/Zero 2W, cross-compiled (headless)

Each target produces a tarball with:
  wzp-relay, wzp-client, wzp-web, wzp-bench, static/

Uses rust:1-bookworm container to match Debian 12 glibc.
Cargo cache keyed on Cargo.lock for faster rebuilds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:22:42 +04:00
Siavash Sameni
12b6f30f9b feat: room-based calls + AudioWorklet for capture and playback
Rooms:
- URL-based: open /myroom to join a room
- Two clients in same room get bridged through relay
- Input field for room name, also supports URL path and hash
- Each room creates independent relay connections

AudioWorklet (replaces deprecated ScriptProcessorNode):
- capture-processor.js: accumulates mic samples, sends 960-sample frames
- playback-processor.js: pull-based output with 200ms buffer cap
- Falls back to ScriptProcessor if AudioWorklet unavailable
- Eliminates drift: worklet runs on audio thread, not main thread

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:16:06 +04:00
Siavash Sameni
722bca0c87 fix: remove unused warn import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:01:03 +04:00
Siavash Sameni
d38c655e79 fix: install rustls crypto provider in relay (same as wzp-web fix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:55:56 +04:00
Siavash Sameni
ce6aacb25f fix: bridge pairing + auto-reconnect + test stability
Bridge mode rewrite:
- First client echoes while waiting, checks every 100ms if paired
- Second client triggers bridge immediately, first exits echo loop
- After bridge ends, slot is cleared for the next pair
- No more two tasks competing for the same transport recv

Web client auto-reconnect:
- On WebSocket close/error, automatically reconnects after 1s
- Keeps retrying as long as the user hasn't clicked Disconnect

Test fix:
- Install rustls crypto provider in transport config tests
  (fixes race condition when running full workspace tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:49:27 +04:00
Siavash Sameni
38ae62b542 fix: raise drift cap to 1s — stops constant resetting on jittery links
150ms cap was too tight for Iran relay (high jitter), causing constant
audio drops. Raised to 1s — packet bursts are absorbed smoothly,
drift reset only fires on real accumulation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:41:45 +04:00
Siavash Sameni
709ad1ba7d fix: revert to scheduled playback with 200ms drift cap
Pull-based ScriptProcessor approach broke audio completely.
Back to createBufferSource scheduling which worked, but with
tighter 200ms max drift (was 300ms). Snaps back when exceeded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:31:26 +04:00
Siavash Sameni
1c91c4a1b5 fix: sample-accurate playback buffer eliminates robotic audio
Previous version output 960 samples into 1024-sample callback frames,
causing 64 samples of silence per frame (choppy/robotic sound).

Now accumulates float samples in a continuous buffer, output callback
pulls exactly 1024 at a time regardless of input frame size.
Buffer capped at 200ms to prevent drift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:29:52 +04:00
Siavash Sameni
4de72e2d98 fix: pull-based audio playback eliminates drift + rustls crypto provider
Web playback rewritten from push-scheduling to pull-based ring buffer:
- ScriptProcessorNode pulls frames from buffer every ~21ms
- Buffer capped at 10 frames (~200ms) — drops oldest on overflow
- Latency permanently bounded, no drift over time

Also: install ring crypto provider for rustls TLS on Linux,
       build on debian-12 to match mequ glibc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:26:59 +04:00
Siavash Sameni
61d6fb173d feat: HTTPS support for web bridge (--tls flag)
Generates a self-signed certificate at startup for HTTPS.
Required for mic access on Android/remote browsers (getUserMedia
needs a secure context).

Usage: wzp-web --port 9090 --relay 127.0.0.1:4433 --tls
Browser: accept the self-signed cert warning, then mic works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:56:00 +04:00
Siavash Sameni
66f720f1ee fix: cap web playback latency at 300ms — prevents drift accumulation
When playback buffer drifts beyond 300ms ahead of real-time, reset
to 40ms. This prevents the unbounded latency growth over long sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:44:33 +04:00
Siavash Sameni
7fce83be82 build: include wzp-web + static files in Linux build script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:35:23 +04:00
Siavash Sameni
9ad21182a8 fix: web audio playback quality — gapless scheduling + sample rate debug
- Schedule each playback buffer to start exactly where the last ended
  (was causing gaps/overlaps with fixed 60ms offset)
- Log AudioContext sample rate to console for debugging
- Reset playback timeline when falling behind

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:29:00 +04:00
Siavash Sameni
a7afe4ff21 fix: web audio capture buffer size + relay warning
- Use power-of-2 buffer (1024) for ScriptProcessorNode
- Accumulate samples and send exact 960-sample frames
- Remove unused watch import from relay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:27:08 +04:00
Siavash Sameni
3f128936c4 feat: web bridge — browser-based voice calls via WebSocket
New wzp-web crate serves a web page with:
- Browser mic capture via Web Audio API (48kHz mono)
- WebSocket transport for raw PCM audio
- Server-side Opus encode/decode + FEC through wzp relay
- Real-time audio playback in browser
- Level meter and connection stats

Usage:
  wzp-relay --listen 0.0.0.0:4433    # start relay
  wzp-web --port 8080 --relay 127.0.0.1:4433  # start web bridge
  Open http://localhost:8080 in browser

Two browsers connected to the same relay get bridged for a call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:23:39 +04:00
Siavash Sameni
bddcfb1440 fix: remove unused variable warning in cli.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:15:09 +04:00
Siavash Sameni
a04b8271cc fix: record mode decode-per-packet (same fix as live mode)
The --record recv loop was using while-drain which exhausted the jitter
buffer and stopped decoding after the first burst. Now decodes once per
source packet, matching the live mode fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:05:10 +04:00
Siavash Sameni
d5390db7af feat: --send-file for real audio testing + fix warnings
- --send-file <file.raw> sends a raw PCM file (48kHz mono s16le) through relay
- Combine with --record: --send-file talk.raw --record echo.raw <relay>
- Fixed all unused import warnings in echo_test.rs

Convert any audio to test format:
  ffmpeg -i input.mp3 -ar 48000 -ac 1 -f s16le input.raw

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:51:55 +04:00
Siavash Sameni
28d5a3a9ad feat: automated echo quality test with time-window analysis
New --echo-test <secs> flag sends a 440Hz tone through relay echo,
records the return, and analyzes quality in 5-second windows:
- Per-window: frames sent/received, loss %, SNR (dB), correlation
- Detects quality degradation over time (compares first vs second half)
- Reports jitter buffer stats (depth, lost, late packets)
- Diagnoses jitter buffer drift and packet loss accumulation

Also exposes jitter_stats() on CallDecoder for diagnostics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:44:08 +04:00
Siavash Sameni
26ed015cca feat: relay bridge mode — pairs two clients for real calls
When no --remote is configured, the relay now operates in bridge mode:
- First client connects → echoes while waiting for a peer
- Second client connects → both clients are bridged bidirectionally
- A's packets go to B, B's packets go to A
- Stats logged every 5 seconds (a_to_b / b_to_a packet counts)
- Falls back to echo if only one client connects

This enables the core use case: two clients on different networks
calling each other through a single relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:21:12 +04:00
Siavash Sameni
0723f52d76 fix: live audio playback working — jitter buffer and decode loop fixes
- Reduced jitter buffer min_depth from 25 (500ms) to 3 (60ms) for fast start
- Fixed live recv loop: decode once per source packet instead of draining
  the jitter buffer dry (which advanced seq past future packets)
- Fixed Ok(None) handling: connection closed, not "no packet yet"

Live echo test confirmed working with continuous audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:09:33 +04:00
Siavash Sameni
b147de5ae9 fix: graceful Ctrl+C recording + relay echo mode
- --record now handles Ctrl+C: saves PCM file before exiting
- Relay without --remote runs in echo mode (loops packets back to sender)
  instead of sink mode, enabling single-relay audio testing
- recv task returns collected PCM via channel for clean file write

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:32:12 +04:00
Siavash Sameni
df80ad5343 fix: make cpal/ALSA optional — headless Linux builds work without libasound
- cpal is now behind an 'audio' feature flag (off by default)
- --live mode requires --features audio at build time
- --send-tone and --record work on headless servers without audio libs
- Linux build script no longer installs libasound2-dev

Build for headless: cargo build --release
Build with mic/speakers: cargo build --release --features audio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:24:44 +04:00
Siavash Sameni
708fb268bc feat: file-based audio testing + Hetzner build scripts
CLI modes:
- --send-tone <secs>: send 440Hz test tone (no mic needed)
- --record <file.raw>: save received audio to raw PCM file
- --help: usage info
- Combine: --send-tone 10 --record out.raw

Raw PCM format: 48kHz mono s16le
Play with: ffplay -f s16le -ar 48000 -ac 1 out.raw

Build scripts:
- scripts/build-linux.sh: Hetzner VPS build with auto-cleanup
- scripts/cleanup-builder.sh: kill stale builders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:11:59 +04:00
Siavash Sameni
85f472d824 fix: scale FEC ratio with loss rate in benchmarks
The bench tool now auto-calculates the FEC ratio needed to survive
the requested loss percentage, matching how the adaptive quality
controller would behave in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:21:21 +04:00
Siavash Sameni
3c99503eb1 fix: IPv6 support, client address family matching, gitignore cleanup
- Client auto-detects IPv4/IPv6 from relay address and binds accordingly
- Relay defaults to 0.0.0.0:4433, use --listen [::]:4433 for IPv6
- .gitignore excludes .claude/, swap files
- Fix pipeline drain infinite loop in benchmarks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:17:49 +04:00
30 changed files with 5672 additions and 265 deletions

188
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,188 @@
name: Build Release Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
targets:
description: 'Targets to build (comma-separated: amd64,arm64,armv7,mac-arm64)'
required: false
default: 'amd64'
env:
CARGO_TERM_COLOR: always
jobs:
# Always builds on push tags. On manual dispatch, reads inputs.
build-amd64:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'amd64')
runs-on: ubuntu-latest
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: apt-get update && apt-get install -y cmake pkg-config libasound2-dev
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-amd64-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-amd64-
- name: Build headless binaries
run: cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
- name: Build audio client
run: |
cargo build --release --bin wzp-client --features audio
cp target/release/wzp-client target/release/wzp-client-audio
cargo build --release --bin wzp-client
- name: Run tests
run: cargo test --workspace --lib
- name: Package
run: |
mkdir -p dist/wzp-linux-amd64
cp target/release/wzp-relay dist/wzp-linux-amd64/
cp target/release/wzp-client dist/wzp-linux-amd64/
cp target/release/wzp-client-audio dist/wzp-linux-amd64/
cp target/release/wzp-web dist/wzp-linux-amd64/
cp target/release/wzp-bench dist/wzp-linux-amd64/
cp -r crates/wzp-web/static dist/wzp-linux-amd64/
cd dist && tar czf wzp-linux-amd64.tar.gz wzp-linux-amd64/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: wzp-linux-amd64
path: dist/wzp-linux-amd64.tar.gz
build-arm64:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'arm64')
runs-on: ubuntu-latest
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Install cross-compilation tools
run: |
dpkg --add-architecture arm64
apt-get update
apt-get install -y cmake pkg-config gcc-aarch64-linux-gnu libc6-dev-arm64-cross
rustup target add aarch64-unknown-linux-gnu
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-arm64-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-arm64-
- name: Build
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
run: |
cargo build --release --target aarch64-unknown-linux-gnu \
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
- name: Package
run: |
mkdir -p dist/wzp-linux-arm64
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-client dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-web dist/wzp-linux-arm64/
cp target/aarch64-unknown-linux-gnu/release/wzp-bench dist/wzp-linux-arm64/
cp -r crates/wzp-web/static dist/wzp-linux-arm64/
cd dist && tar czf wzp-linux-arm64.tar.gz wzp-linux-arm64/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: wzp-linux-arm64
path: dist/wzp-linux-arm64.tar.gz
build-armv7:
if: >-
github.event_name == 'push' ||
contains(github.event.inputs.targets, 'armv7')
runs-on: ubuntu-latest
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Install cross-compilation tools
run: |
dpkg --add-architecture armhf
apt-get update
apt-get install -y cmake pkg-config gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
rustup target add armv7-unknown-linux-gnueabihf
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-armv7-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-armv7-
- name: Build
env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
CC_armv7_unknown_linux_gnueabihf: arm-linux-gnueabihf-gcc
run: |
cargo build --release --target armv7-unknown-linux-gnueabihf \
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
- name: Package
run: |
mkdir -p dist/wzp-linux-armv7
cp target/armv7-unknown-linux-gnueabihf/release/wzp-relay dist/wzp-linux-armv7/
cp target/armv7-unknown-linux-gnueabihf/release/wzp-client dist/wzp-linux-armv7/
cp target/armv7-unknown-linux-gnueabihf/release/wzp-web dist/wzp-linux-armv7/
cp target/armv7-unknown-linux-gnueabihf/release/wzp-bench dist/wzp-linux-armv7/
cp -r crates/wzp-web/static dist/wzp-linux-armv7/
cd dist && tar czf wzp-linux-armv7.tar.gz wzp-linux-armv7/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: wzp-linux-armv7
path: dist/wzp-linux-armv7.tar.gz
# Release job — creates a release with all artifacts when a tag is pushed
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-amd64]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*.tar.gz
generate_release_notes: true

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
/target /target
.DS_Store .DS_Store
.claude/
*.swp
*.swo
*~

565
Cargo.lock generated
View File

@@ -49,6 +49,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -60,6 +69,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "audiopus" name = "audiopus"
version = "0.3.0-rc.0" version = "0.3.0-rc.0"
@@ -86,6 +101,105 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-server"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
dependencies = [
"arc-swap",
"bytes",
"fs-err",
"http",
"http-body",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -377,6 +491,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -407,6 +527,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@@ -478,12 +604,102 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-err"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
dependencies = [
"autocfg",
"tokio",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@@ -496,8 +712,13 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@@ -545,6 +766,25 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -569,6 +809,94 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -725,12 +1053,34 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -918,12 +1268,24 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkcs8" name = "pkcs8"
version = "0.10.2" version = "0.10.2"
@@ -1207,6 +1569,8 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs",
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -1227,6 +1591,15 @@ dependencies = [
"security-framework", "security-framework",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -1270,6 +1643,7 @@ version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -1281,6 +1655,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -1377,6 +1757,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.9" version = "0.6.9"
@@ -1386,6 +1777,29 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1486,6 +1900,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -1597,6 +2017,41 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@@ -1668,6 +2123,60 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -1726,12 +2235,35 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -1754,6 +2286,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -2241,6 +2779,8 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"bytes", "bytes",
"quinn",
"rustls",
"serde", "serde",
"tokio", "tokio",
"toml", "toml",
@@ -2268,6 +2808,31 @@ dependencies = [
"wzp-proto", "wzp-proto",
] ]
[[package]]
name = "wzp-web"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-server",
"bytes",
"futures",
"rcgen",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-http",
"tracing",
"tracing-subscriber",
"wzp-client",
"wzp-codec",
"wzp-crypto",
"wzp-fec",
"wzp-proto",
"wzp-transport",
]
[[package]] [[package]]
name = "x25519-dalek" name = "x25519-dalek"
version = "2.0.1" version = "2.0.1"

View File

@@ -8,6 +8,7 @@ members = [
"crates/wzp-transport", "crates/wzp-transport",
"crates/wzp-relay", "crates/wzp-relay",
"crates/wzp-client", "crates/wzp-client",
"crates/wzp-web",
] ]
[workspace.package] [workspace.package]

View File

@@ -18,7 +18,11 @@ tracing-subscriber = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
anyhow = "1" anyhow = "1"
cpal = "0.15" cpal = { version = "0.15", optional = true }
[features]
default = []
audio = ["cpal"]
[[bin]] [[bin]]
name = "wzp-client" name = "wzp-client"

View File

@@ -136,8 +136,13 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
let profile = QualityProfile::GOOD; // 5 frames/block, 0.2 ratio let profile = QualityProfile::GOOD; // 5 frames/block, 0.2 ratio
let frames_per_block = profile.frames_per_block as usize; let frames_per_block = profile.frames_per_block as usize;
let num_blocks = 100; let num_blocks = 100;
// Use a higher FEC ratio for the bench so recovery is possible at higher loss // Scale FEC ratio to survive the requested loss rate.
let fec_ratio = if loss_pct > 20.0 { 1.0 } else { 0.5 }; // At X% loss, we keep (1-X/100) of packets. We need at least
// frames_per_block packets to recover, so total packets needed =
// frames_per_block / (1 - loss/100). Ratio = (total - source) / source.
let keep_fraction = 1.0 - (loss_pct / 100.0).min(0.95);
let total_needed = (frames_per_block as f32 / keep_fraction).ceil();
let fec_ratio = ((total_needed / frames_per_block as f32) - 1.0).max(0.2);
let start = Instant::now(); let start = Instant::now();
@@ -313,18 +318,18 @@ pub fn bench_full_pipeline() -> PipelineResult {
} }
let total_encode_pipeline = enc_start.elapsed(); let total_encode_pipeline = enc_start.elapsed();
// Decode pipeline: ingest all packets, then try to decode // Decode pipeline: ingest all packets, then decode one frame per source frame.
// We call decode_next once per ingested source frame, matching the real-time
// cadence (one decode per frame period).
let dec_start = Instant::now(); let dec_start = Instant::now();
let mut dec_pcm = vec![0i16; frame_samples]; let mut dec_pcm = vec![0i16; frame_samples];
for packets in &all_packets { for packets in &all_packets {
for pkt in packets { for pkt in packets {
decoder.ingest(pkt.clone()); decoder.ingest(pkt.clone());
} }
// Attempt to decode after each frame's packets are ingested // Attempt to decode one frame per ingested source frame
let _ = decoder.decode_next(&mut dec_pcm); let _ = decoder.decode_next(&mut dec_pcm);
} }
// Drain any remaining frames
while decoder.decode_next(&mut dec_pcm).is_some() {}
let total_decode_pipeline = dec_start.elapsed(); let total_decode_pipeline = dec_start.elapsed();
let total_time = total_encode_pipeline + total_decode_pipeline; let total_time = total_encode_pipeline + total_decode_pipeline;
@@ -378,7 +383,7 @@ mod tests {
#[test] #[test]
fn pipeline_runs() { fn pipeline_runs() {
let result = bench_full_pipeline(); let result = bench_full_pipeline();
assert_eq!(result.frames, 200); assert_eq!(result.frames, 50);
assert!(result.wire_bytes_out > 0); assert!(result.wire_bytes_out > 0);
} }
} }

View File

@@ -30,9 +30,9 @@ impl Default for CallConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
profile: QualityProfile::GOOD, profile: QualityProfile::GOOD,
jitter_target: 50, jitter_target: 10,
jitter_max: 250, jitter_max: 250,
jitter_min: 25, jitter_min: 3, // 60ms — low latency start, still smooths jitter
} }
} }
} }
@@ -225,6 +225,11 @@ impl CallDecoder {
pub fn profile(&self) -> QualityProfile { pub fn profile(&self) -> QualityProfile {
self.profile self.profile
} }
/// Get jitter buffer statistics.
pub fn jitter_stats(&self) -> wzp_proto::jitter::JitterStats {
self.jitter.stats().clone()
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,58 +1,191 @@
//! WarzonePhone CLI test client. //! WarzonePhone CLI test client.
//! //!
//! Usage: wzp-client [--live] [relay-addr] //! Usage:
//! wzp-client [relay-addr] Send silence frames (connectivity test)
//! wzp-client --live [relay-addr] Live mic/speaker mode
//! wzp-client --send-tone 10 [relay-addr] Send 10s of 440Hz test tone
//! wzp-client --record out.raw [relay-addr] Record received audio to raw PCM file
//! wzp-client --send-tone 10 --record out.raw [relay-addr] Both at once
//! //!
//! Without `--live`: sends silence frames for testing. //! Raw PCM files are 48kHz mono 16-bit signed little-endian.
//! With `--live`: captures microphone audio and plays received audio through speakers. //! Play with: ffplay -f s16le -ar 48000 -ac 1 out.raw
//! Or convert: ffmpeg -f s16le -ar 48000 -ac 1 -i out.raw out.wav
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
use wzp_client::audio_io::{AudioCapture, AudioPlayback, FRAME_SAMPLES};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
/// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
.map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
})
.collect()
}
#[derive(Debug)]
struct CliArgs {
relay_addr: SocketAddr,
live: bool,
send_tone_secs: Option<u32>,
send_file: Option<String>,
record_file: Option<String>,
echo_test_secs: Option<u32>,
}
fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect();
let mut live = false;
let mut send_tone_secs = None;
let mut send_file = None;
let mut record_file = None;
let mut echo_test_secs = None;
let mut relay_str = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--live" => live = true,
"--send-tone" => {
i += 1;
send_tone_secs = Some(
args.get(i)
.expect("--send-tone requires seconds")
.parse()
.expect("--send-tone value must be a number"),
);
}
"--send-file" => {
i += 1;
send_file = Some(
args.get(i)
.expect("--send-file requires a filename")
.to_string(),
);
}
"--record" => {
i += 1;
record_file = Some(
args.get(i)
.expect("--record requires a filename")
.to_string(),
);
}
"--echo-test" => {
i += 1;
echo_test_secs = Some(
args.get(i)
.expect("--echo-test requires seconds")
.parse()
.expect("--echo-test value must be a number"),
);
}
"--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]");
eprintln!();
eprintln!("Options:");
eprintln!(" --live Live mic/speaker mode");
eprintln!(" --send-tone <secs> Send a 440Hz test tone for N seconds");
eprintln!(" --send-file <file> Send a raw PCM file (48kHz mono s16le)");
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
eprintln!();
eprintln!("Default relay: 127.0.0.1:4433");
std::process::exit(0);
}
other => {
if relay_str.is_none() && !other.starts_with('-') {
relay_str = Some(other.to_string());
} else {
eprintln!("unknown argument: {other}");
std::process::exit(1);
}
}
}
i += 1;
}
let relay_addr: SocketAddr = relay_str
.unwrap_or_else(|| "127.0.0.1:4433".to_string())
.parse()
.expect("invalid relay address");
CliArgs {
relay_addr,
live,
send_tone_secs,
send_file,
record_file,
echo_test_secs,
}
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
let args: Vec<String> = std::env::args().collect(); let cli = parse_args();
let live = args.iter().any(|a| a == "--live");
let relay_addr: SocketAddr = args
.iter()
.skip(1)
.find(|a| *a != "--live")
.cloned()
.unwrap_or_else(|| "127.0.0.1:4433".to_string())
.parse()?;
info!(%relay_addr, live, "WarzonePhone client connecting"); info!(
relay = %cli.relay_addr,
live = cli.live,
send_tone = ?cli.send_tone_secs,
record = ?cli.record_file,
"WarzonePhone client"
);
let client_config = wzp_transport::client_config(); let client_config = wzp_transport::client_config();
let endpoint = wzp_transport::create_endpoint("0.0.0.0:0".parse()?, None)?; let bind_addr = if cli.relay_addr.is_ipv6() {
"[::]:0".parse()?
} else {
"0.0.0.0:0".parse()?
};
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let connection = let connection =
wzp_transport::connect(&endpoint, relay_addr, "localhost", client_config).await?; wzp_transport::connect(&endpoint, cli.relay_addr, "localhost", client_config).await?;
info!("Connected to relay"); info!("Connected to relay");
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
if live { if cli.live {
run_live(transport).await #[cfg(feature = "audio")]
{
return run_live(transport).await;
}
#[cfg(not(feature = "audio"))]
{
anyhow::bail!("--live requires the 'audio' feature (build with: cargo build --features audio)");
}
} else if let Some(secs) = cli.echo_test_secs {
let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?;
wzp_client::echo_test::print_report(&result);
transport.close().await?;
Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
} else { } else {
run_silence(transport).await run_silence(transport).await
} }
} }
/// Original test mode: send silence frames. /// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(20);
let pcm = vec![0i16; FRAME_SAMPLES]; // 20ms @ 48kHz silence let pcm = vec![0i16; FRAME_SAMPLES];
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -84,25 +217,196 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
tokio::time::sleep(frame_duration).await; tokio::time::sleep(frame_duration).await;
} }
info!( info!(total_source, total_repair, total_bytes, "done — closing");
total_source,
total_repair,
total_bytes,
"done — closing"
);
transport.close().await?; transport.close().await?;
Ok(()) Ok(())
} }
/// File/tone mode: send a test tone or audio file, and/or record received audio.
async fn run_file_mode(
transport: Arc<wzp_transport::QuinnTransport>,
send_tone_secs: Option<u32>,
send_file: Option<String>,
record_file: Option<String>,
) -> anyhow::Result<()> {
let config = CallConfig::default();
// --- Send task: generate tone or play file ---
let send_transport = transport.clone();
let send_handle = tokio::spawn(async move {
// Load PCM frames from file or generate tone
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
// Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => { error!("read {path}: {e}"); return; }
};
let samples: Vec<i16> = bytes.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES)
.filter(|c| c.len() == FRAME_SAMPLES)
.map(|c| c.to_vec())
.collect()
} else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50;
info!(seconds = secs, frames = total, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
} else {
// No sending, just wait
tokio::signal::ctrl_c().await.ok();
return;
};
let mut encoder = CallEncoder::new(&config);
let _total_frames = pcm_frames.len() as u64;
let frame_duration = tokio::time::Duration::from_millis(20);
let mut total_source = 0u64;
let mut total_repair = 0u64;
for (frame_idx, pcm) in pcm_frames.iter().enumerate() {
let frame_idx = frame_idx as u64;
let packets = match encoder.encode_frame(&pcm) {
Ok(p) => p,
Err(e) => {
error!("encode error: {e}");
continue;
}
};
for pkt in &packets {
if pkt.header.is_repair {
total_repair += 1;
} else {
total_source += 1;
}
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return;
}
}
if (frame_idx + 1) % 250 == 0 {
info!(
frame = frame_idx + 1,
source = total_source,
repair = total_repair,
"send progress"
);
}
tokio::time::sleep(frame_duration).await;
}
info!(total_source, total_repair, "tone send complete");
});
// --- Recv task: decode and write to file ---
let recv_transport = transport.clone();
let record_path = record_file.clone();
let recv_handle = tokio::spawn(async move {
let record_path = match record_path {
Some(p) => p,
None => {
// No recording, just wait for send to finish or Ctrl+C
tokio::signal::ctrl_c().await.ok();
return Vec::new();
}
};
let mut decoder = CallDecoder::new(&CallConfig::default());
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64;
info!(file = %record_path, "recording received audio (Ctrl+C to stop and save)");
loop {
tokio::select! {
result = recv_transport.recv_media() => {
match result {
Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
all_pcm.extend_from_slice(&pcm_buf[..n]);
frames_received += 1;
if frames_received % 250 == 0 {
info!(
frames = frames_received,
samples = all_pcm.len(),
"recv progress"
);
}
}
}
}
Ok(None) => {
info!("connection closed by remote");
break;
}
Err(e) => {
error!("recv error: {e}");
break;
}
}
}
_ = tokio::signal::ctrl_c() => {
info!("Ctrl+C received, saving recording...");
break;
}
}
}
all_pcm
});
// Wait for send to finish (or ctrl+c in recv)
let _ = send_handle.await;
// If send finished but recv is still going, give it a moment then stop
let all_pcm = if record_file.is_some() {
// Wait a bit for remaining packets after sender finishes
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// The recv task will be aborted when we drop it, but first
// let's signal it by closing transport
transport.close().await?;
recv_handle.await.unwrap_or_default()
} else {
recv_handle.await.unwrap_or_default()
};
// Write recorded audio to file
if let Some(ref path) = record_file {
if !all_pcm.is_empty() {
let bytes: Vec<u8> = all_pcm.iter().flat_map(|s| s.to_le_bytes()).collect();
std::fs::write(path, &bytes)?;
let duration_secs = all_pcm.len() as f64 / 48_000.0;
info!(
file = %path,
samples = all_pcm.len(),
duration = format!("{:.1}s", duration_secs),
bytes = bytes.len(),
"recording saved"
);
info!("play with: ffplay -f s16le -ar 48000 -ac 1 {path}");
} else {
info!("no audio received, nothing to write");
}
}
Ok(())
}
/// Live mode: capture from mic, encode, send; receive, decode, play. /// Live mode: capture from mic, encode, send; receive, decode, play.
#[cfg(feature = "audio")]
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
let capture = AudioCapture::start()?; let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?; let playback = AudioPlayback::start()?;
info!("Audio I/O started — press Ctrl+C to stop"); info!("Audio I/O started — press Ctrl+C to stop");
// --- Send task: mic -> encode -> transport ---
// AudioCapture::read_frame() is blocking, so we run this on a dedicated
// OS thread. We use the tokio Handle to call the async send_media.
let send_transport = transport.clone(); let send_transport = transport.clone();
let rt_handle = tokio::runtime::Handle::current(); let rt_handle = tokio::runtime::Handle::current();
let send_handle = std::thread::Builder::new() let send_handle = std::thread::Builder::new()
@@ -113,7 +417,7 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
loop { loop {
let frame = match capture.read_frame() { let frame = match capture.read_frame() {
Some(f) => f, Some(f) => f,
None => break, // channel closed / stopped None => break,
}; };
let packets = match encoder.encode_frame(&frame) { let packets = match encoder.encode_frame(&frame) {
Ok(p) => p, Ok(p) => p,
@@ -131,7 +435,6 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
} }
})?; })?;
// --- Recv task: transport -> decode -> speaker ---
let recv_transport = transport.clone(); let recv_transport = transport.clone();
let recv_handle = tokio::spawn(async move { let recv_handle = tokio::spawn(async move {
let config = CallConfig::default(); let config = CallConfig::default();
@@ -140,14 +443,19 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
loop { loop {
match recv_transport.recv_media().await { match recv_transport.recv_media().await {
Ok(Some(pkt)) => { Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt); decoder.ingest(pkt);
while let Some(_n) = decoder.decode_next(&mut pcm_buf) { // Only decode for source packets (1 source = 1 audio frame).
// Repair packets feed the FEC decoder but don't produce audio.
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
playback.write_frame(&pcm_buf); playback.write_frame(&pcm_buf);
} }
} }
}
Ok(None) => { Ok(None) => {
// No packet available right now, yield briefly. info!("connection closed");
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; break;
} }
Err(e) => { Err(e) => {
error!("recv error: {e}"); error!("recv error: {e}");
@@ -157,14 +465,10 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
} }
}); });
// Wait for Ctrl+C tokio::signal::ctrl_c().await?;
tokio::signal::ctrl_c()
.await
.expect("failed to listen for Ctrl+C");
info!("Shutting down..."); info!("Shutting down...");
recv_handle.abort(); recv_handle.abort();
// The send thread will exit once capture is dropped / stopped.
drop(send_handle); drop(send_handle);
transport.close().await?; transport.close().await?;
info!("done"); info!("done");

View File

@@ -0,0 +1,342 @@
//! Automated echo quality test.
//!
//! Sends a known test signal through a relay (echo mode), records the return,
//! and analyzes quality over time to detect degradation, jitter buffer drift,
//! and packet loss patterns.
use std::time::{Duration, Instant};
use tracing::info;
use wzp_proto::MediaTransport;
use crate::call::{CallConfig, CallDecoder, CallEncoder};
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
const SAMPLE_RATE: u32 = 48_000;
/// Results from one analysis window.
#[derive(Debug, Clone)]
pub struct WindowResult {
/// Window index (0-based).
pub index: usize,
/// Time offset from start (seconds).
pub time_offset_secs: f64,
/// Number of frames sent in this window.
pub frames_sent: u32,
/// Number of frames received (decoded) in this window.
pub frames_received: u32,
/// Packet loss percentage for this window.
pub loss_pct: f32,
/// Signal-to-noise ratio (dB) — higher is better.
pub snr_db: f32,
/// Cross-correlation with original signal (0.0-1.0).
pub correlation: f32,
/// Max absolute sample value in received audio.
pub peak_amplitude: i16,
/// Whether the window contains silence (no signal detected).
pub is_silent: bool,
}
/// Full echo test results.
#[derive(Debug)]
pub struct EchoTestResult {
pub duration_secs: f64,
pub total_frames_sent: u64,
pub total_frames_received: u64,
pub total_packets_sent: u64,
pub total_packets_received: u64,
pub overall_loss_pct: f32,
pub windows: Vec<WindowResult>,
/// Jitter buffer stats at end.
pub jitter_depth_final: usize,
pub jitter_packets_lost: u64,
pub jitter_packets_late: u64,
}
/// Generate a sine wave frame at a given frequency.
fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
let start = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
.map(|i| {
let t = (start + i as u64) as f32 / SAMPLE_RATE as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
})
.collect()
}
/// Compute signal-to-noise ratio between original and received PCM.
fn compute_snr(original: &[i16], received: &[i16]) -> f32 {
if original.is_empty() || received.is_empty() {
return 0.0;
}
let len = original.len().min(received.len());
let mut signal_power: f64 = 0.0;
let mut noise_power: f64 = 0.0;
for i in 0..len {
let s = original[i] as f64;
let n = (received[i] as f64) - s;
signal_power += s * s;
noise_power += n * n;
}
if noise_power < 1.0 {
return 99.0; // essentially perfect
}
(10.0 * (signal_power / noise_power).log10()) as f32
}
/// Compute normalized cross-correlation between two signals.
fn cross_correlation(a: &[i16], b: &[i16]) -> f32 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let len = a.len().min(b.len());
let mut sum_ab: f64 = 0.0;
let mut sum_aa: f64 = 0.0;
let mut sum_bb: f64 = 0.0;
for i in 0..len {
let x = a[i] as f64;
let y = b[i] as f64;
sum_ab += x * y;
sum_aa += x * x;
sum_bb += y * y;
}
let denom = (sum_aa * sum_bb).sqrt();
if denom < 1.0 {
return 0.0;
}
(sum_ab / denom) as f32
}
/// Run an automated echo quality test.
///
/// Sends `duration_secs` of 440Hz tone through the transport (expects echo mode relay),
/// records the response, and analyzes quality in `window_secs`-second windows.
pub async fn run_echo_test(
transport: &(dyn MediaTransport + Send + Sync),
duration_secs: u32,
window_secs: f64,
) -> anyhow::Result<EchoTestResult> {
let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config);
let mut decoder = CallDecoder::new(&config);
let total_frames = (duration_secs as u64) * 50; // 50 fps at 20ms
let frames_per_window = ((window_secs * 50.0) as u64).max(1);
// Storage for sent and received PCM per window
let mut sent_pcm: Vec<i16> = Vec::new();
let mut recv_pcm: Vec<i16> = Vec::new();
let mut windows: Vec<WindowResult> = Vec::new();
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut total_packets_sent = 0u64;
let mut total_packets_received = 0u64;
let mut window_frames_sent = 0u32;
let mut window_frames_received = 0u32;
let mut window_idx = 0usize;
let start = Instant::now();
let frame_duration = Duration::from_millis(20);
info!(
duration = duration_secs,
window = format!("{window_secs}s"),
"starting echo quality test"
);
for frame_idx in 0..total_frames {
// Generate and send tone
let pcm = sine_frame(440.0, frame_idx);
sent_pcm.extend_from_slice(&pcm);
let packets = encoder.encode_frame(&pcm)?;
for pkt in &packets {
transport.send_media(pkt).await?;
total_packets_sent += 1;
}
window_frames_sent += 1;
// Try to receive echo (non-blocking-ish: short timeout)
let recv_deadline = Instant::now() + Duration::from_millis(5);
loop {
if Instant::now() >= recv_deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
total_packets_received += 1;
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
recv_pcm.extend_from_slice(&pcm_buf[..n]);
window_frames_received += 1;
}
}
}
_ => break,
}
}
// Analyze window
if (frame_idx + 1) % frames_per_window == 0 || frame_idx == total_frames - 1 {
let time_offset = start.elapsed().as_secs_f64();
// Compare sent vs received for this window
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
let sent_window = if sent_end <= sent_pcm.len() {
&sent_pcm[sent_start..sent_end]
} else {
&sent_pcm[sent_start..]
};
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
let recv_window = &recv_pcm[recv_start..];
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
let is_silent = peak < 100;
let snr = if !is_silent && !sent_window.is_empty() && !recv_window.is_empty() {
compute_snr(sent_window, recv_window)
} else {
0.0
};
let corr = if !is_silent && !sent_window.is_empty() && !recv_window.is_empty() {
cross_correlation(sent_window, recv_window)
} else {
0.0
};
let loss = if window_frames_sent > 0 {
(1.0 - window_frames_received as f32 / window_frames_sent as f32) * 100.0
} else {
0.0
};
let result = WindowResult {
index: window_idx,
time_offset_secs: time_offset,
frames_sent: window_frames_sent,
frames_received: window_frames_received,
loss_pct: loss.max(0.0),
snr_db: snr,
correlation: corr,
peak_amplitude: peak,
is_silent,
};
info!(
window = window_idx,
time = format!("{:.1}s", time_offset),
sent = window_frames_sent,
recv = window_frames_received,
loss = format!("{:.1}%", result.loss_pct),
snr = format!("{:.1}dB", snr),
corr = format!("{:.3}", corr),
peak = peak,
"window analysis"
);
windows.push(result);
window_idx += 1;
window_frames_sent = 0;
window_frames_received = 0;
}
tokio::time::sleep(frame_duration).await;
}
// Drain remaining received packets
info!("draining remaining packets...");
let drain_deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < drain_deadline {
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => {
total_packets_received += 1;
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
if !is_repair {
decoder.decode_next(&mut pcm_buf);
}
}
_ => break,
}
}
let jitter_stats = decoder.jitter_stats();
let total_frames_received = recv_pcm.len() as u64 / FRAME_SAMPLES as u64;
let overall_loss = if total_frames > 0 {
(1.0 - total_frames_received as f32 / total_frames as f32) * 100.0
} else {
0.0
};
Ok(EchoTestResult {
duration_secs: start.elapsed().as_secs_f64(),
total_frames_sent: total_frames,
total_frames_received,
total_packets_sent,
total_packets_received,
overall_loss_pct: overall_loss.max(0.0),
windows,
jitter_depth_final: jitter_stats.current_depth,
jitter_packets_lost: jitter_stats.packets_lost,
jitter_packets_late: jitter_stats.packets_late,
})
}
/// Print a summary report of the echo test.
pub fn print_report(result: &EchoTestResult) {
println!();
println!("=== Echo Quality Test Report ===");
println!();
println!("Duration: {:.1}s", result.duration_secs);
println!("Frames sent: {}", result.total_frames_sent);
println!("Frames received: {}", result.total_frames_received);
println!("Packets sent: {}", result.total_packets_sent);
println!("Packets received: {}", result.total_packets_received);
println!("Overall loss: {:.1}%", result.overall_loss_pct);
println!("Jitter buf depth: {}", result.jitter_depth_final);
println!("Jitter buf lost: {}", result.jitter_packets_lost);
println!("Jitter buf late: {}", result.jitter_packets_late);
println!();
println!("┌───────┬─────────┬──────┬──────┬─────────┬───────┬───────┐");
println!("│ Win │ Time │ Sent │ Recv │ Loss │ SNR │ Corr │");
println!("├───────┼─────────┼──────┼──────┼─────────┼───────┼───────┤");
for w in &result.windows {
let status = if w.is_silent { " !" } else { " " };
println!(
"{:>3}{}{:>5.1}s │ {:>4}{:>4}{:>5.1}% │ {:>5.1}{:.3}",
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received,
w.loss_pct, w.snr_db, w.correlation
);
}
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
// Detect degradation trend
if result.windows.len() >= 4 {
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
println!();
if avg_loss_second > avg_loss_first + 5.0 {
println!("WARNING: Quality degradation detected!");
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second);
}
if avg_corr_second < avg_corr_first - 0.1 {
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second);
}
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
println!("Quality is STABLE over the test duration.");
}
}
println!();
}

View File

@@ -6,11 +6,14 @@
//! //!
//! Targets: Android (JNI), Windows desktop, macOS/Linux (testing) //! Targets: Android (JNI), Windows desktop, macOS/Linux (testing)
#[cfg(feature = "audio")]
pub mod audio_io; pub mod audio_io;
pub mod bench; pub mod bench;
pub mod call; pub mod call;
pub mod echo_test;
pub mod handshake; pub mod handshake;
#[cfg(feature = "audio")]
pub use audio_io::{AudioCapture, AudioPlayback}; pub use audio_io::{AudioCapture, AudioPlayback};
pub use call::{CallConfig, CallDecoder, CallEncoder}; pub use call::{CallConfig, CallDecoder, CallEncoder};
pub use handshake::perform_handshake; pub use handshake::perform_handshake;

View File

@@ -33,13 +33,13 @@ impl KeyExchange for WarzoneKeyExchange {
// Derive Ed25519 signing key via HKDF // Derive Ed25519 signing key via HKDF
let hk = Hkdf::<Sha256>::new(None, seed); let hk = Hkdf::<Sha256>::new(None, seed);
let mut ed25519_bytes = [0u8; 32]; let mut ed25519_bytes = [0u8; 32];
hk.expand(b"warzone-ed25519-identity", &mut ed25519_bytes) hk.expand(b"warzone-ed25519", &mut ed25519_bytes)
.expect("HKDF expand for Ed25519 should not fail"); .expect("HKDF expand for Ed25519 should not fail");
let signing_key = SigningKey::from_bytes(&ed25519_bytes); let signing_key = SigningKey::from_bytes(&ed25519_bytes);
// Derive X25519 static key via HKDF // Derive X25519 static key via HKDF
let mut x25519_bytes = [0u8; 32]; let mut x25519_bytes = [0u8; 32];
hk.expand(b"warzone-x25519-identity", &mut x25519_bytes) hk.expand(b"warzone-x25519", &mut x25519_bytes)
.expect("HKDF expand for X25519 should not fail"); .expect("HKDF expand for X25519 should not fail");
let x25519_static_secret = StaticSecret::from(x25519_bytes); let x25519_static_secret = StaticSecret::from(x25519_bytes);
let x25519_static_public = X25519PublicKey::from(&x25519_static_secret); let x25519_static_public = X25519PublicKey::from(&x25519_static_secret);

View File

@@ -20,6 +20,8 @@ bytes = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
toml = "0.8" toml = "0.8"
anyhow = "1" anyhow = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
quinn = { workspace = true }
[[bin]] [[bin]]
name = "wzp-relay" name = "wzp-relay"

View File

@@ -10,6 +10,7 @@
pub mod config; pub mod config;
pub mod handshake; pub mod handshake;
pub mod pipeline; pub mod pipeline;
pub mod room;
pub mod session_mgr; pub mod session_mgr;
pub use config::RelayConfig; pub use config::RelayConfig;

View File

@@ -1,8 +1,11 @@
//! WarzonePhone relay daemon entry point. //! WarzonePhone relay daemon entry point.
//! //!
//! Accepts client QUIC connections and optionally forwards media to a remote //! Supports two modes:
//! relay. Each client connection spawns two tasks for bidirectional forwarding //! - **Room mode** (default): clients join named rooms, packets forwarded to all others (SFU)
//! through the relay pipeline (FEC decode -> jitter -> FEC encode). //! - **Forward mode** (--remote): all traffic forwarded to a remote relay
//!
//! Room names are passed via the QUIC SNI (server_name) field.
//! The web bridge connects with room name as SNI.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -10,16 +13,13 @@ 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; use wzp_proto::MediaTransport;
use wzp_relay::config::RelayConfig; use wzp_relay::config::RelayConfig;
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline}; use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
use wzp_relay::session_mgr::SessionManager; use wzp_relay::room::{self, RoomManager};
/// Parse CLI arguments using std::env::args().
///
/// Usage: wzp-relay [--listen <addr>] [--remote <addr>]
fn parse_args() -> RelayConfig { fn parse_args() -> RelayConfig {
let mut config = RelayConfig::default(); let mut config = RelayConfig::default();
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
@@ -28,39 +28,31 @@ fn parse_args() -> RelayConfig {
match args[i].as_str() { match args[i].as_str() {
"--listen" => { "--listen" => {
i += 1; i += 1;
if i < args.len() { config.listen_addr = args.get(i).expect("--listen requires an address")
config.listen_addr = args[i] .parse().expect("invalid --listen address");
.parse::<SocketAddr>()
.expect("invalid --listen address");
} else {
eprintln!("--listen requires an address argument");
std::process::exit(1);
}
} }
"--remote" => { "--remote" => {
i += 1; i += 1;
if i < args.len() {
config.remote_relay = Some( config.remote_relay = Some(
args[i] args.get(i).expect("--remote requires an address")
.parse::<SocketAddr>() .parse().expect("invalid --remote address"),
.expect("invalid --remote address"),
); );
} else {
eprintln!("--remote requires an address argument");
std::process::exit(1);
}
} }
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>]"); eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>]");
eprintln!(); eprintln!();
eprintln!("Options:"); eprintln!("Options:");
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 address for forwarding"); eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
eprintln!();
eprintln!("Room mode (default):");
eprintln!(" Clients join rooms by name. Packets are forwarded to all");
eprintln!(" other participants in the same room (SFU model).");
eprintln!(" Room name comes from QUIC SNI or defaults to 'default'.");
std::process::exit(0); std::process::exit(0);
} }
other => { other => {
eprintln!("unknown argument: {other}"); eprintln!("unknown argument: {other}");
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>]");
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -69,249 +61,170 @@ fn parse_args() -> RelayConfig {
config config
} }
/// Shared packet counters for periodic logging.
struct RelayStats { struct RelayStats {
upstream_packets: AtomicU64, upstream_packets: AtomicU64,
downstream_packets: AtomicU64, downstream_packets: AtomicU64,
} }
/// Run the upstream forwarding task: client -> pipeline -> remote.
async fn run_upstream( async fn run_upstream(
client_transport: Arc<wzp_transport::QuinnTransport>, client: Arc<wzp_transport::QuinnTransport>,
remote_transport: Arc<wzp_transport::QuinnTransport>, remote: Arc<wzp_transport::QuinnTransport>,
pipeline: Arc<Mutex<RelayPipeline>>, pipeline: Arc<Mutex<RelayPipeline>>,
stats: Arc<RelayStats>, stats: Arc<RelayStats>,
) { ) {
loop { loop {
let packet = match client_transport.recv_media().await { match client.recv_media().await {
Ok(Some(pkt)) => pkt, Ok(Some(pkt)) => {
Ok(None) => {
info!("client connection closed (upstream)");
break;
}
Err(e) => {
error!("upstream recv error: {e}");
break;
}
};
// Process through pipeline
let outbound = { let outbound = {
let mut pipe = pipeline.lock().await; let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(packet); let decoded = pipe.ingest(pkt);
let mut out = Vec::new(); let mut out = Vec::new();
for pkt in decoded { for p in decoded { out.extend(pipe.prepare_outbound(p)); }
out.extend(pipe.prepare_outbound(pkt));
}
out out
}; };
for p in &outbound {
// Forward to remote if let Err(e) = remote.send_media(p).await {
for pkt in &outbound { error!("upstream send: {e}");
if let Err(e) = remote_transport.send_media(pkt).await {
error!("upstream send error: {e}");
return; return;
} }
} }
stats stats.upstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed);
.upstream_packets }
.fetch_add(outbound.len() as u64, Ordering::Relaxed); Ok(None) => { info!("client disconnected (upstream)"); break; }
Err(e) => { error!("upstream recv: {e}"); break; }
}
} }
} }
/// Run the downstream forwarding task: remote -> pipeline -> client.
async fn run_downstream( async fn run_downstream(
client_transport: Arc<wzp_transport::QuinnTransport>, client: Arc<wzp_transport::QuinnTransport>,
remote_transport: Arc<wzp_transport::QuinnTransport>, remote: Arc<wzp_transport::QuinnTransport>,
pipeline: Arc<Mutex<RelayPipeline>>, pipeline: Arc<Mutex<RelayPipeline>>,
stats: Arc<RelayStats>, stats: Arc<RelayStats>,
) { ) {
loop { loop {
let packet = match remote_transport.recv_media().await { match remote.recv_media().await {
Ok(Some(pkt)) => pkt, Ok(Some(pkt)) => {
Ok(None) => {
info!("remote connection closed (downstream)");
break;
}
Err(e) => {
error!("downstream recv error: {e}");
break;
}
};
// Process through pipeline
let outbound = { let outbound = {
let mut pipe = pipeline.lock().await; let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(packet); let decoded = pipe.ingest(pkt);
let mut out = Vec::new(); let mut out = Vec::new();
for pkt in decoded { for p in decoded { out.extend(pipe.prepare_outbound(p)); }
out.extend(pipe.prepare_outbound(pkt));
}
out out
}; };
for p in &outbound {
// Forward to client if let Err(e) = client.send_media(p).await {
for pkt in &outbound { error!("downstream send: {e}");
if let Err(e) = client_transport.send_media(pkt).await {
error!("downstream send error: {e}");
return; return;
} }
} }
stats stats.downstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed);
.downstream_packets }
.fetch_add(outbound.len() as u64, Ordering::Relaxed); Ok(None) => { info!("remote disconnected (downstream)"); break; }
Err(e) => { error!("downstream recv: {e}"); break; }
}
} }
} }
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let config = parse_args(); let config = parse_args();
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("failed to install rustls crypto provider");
info!(addr = %config.listen_addr, "WarzonePhone relay starting"); info!(addr = %config.listen_addr, "WarzonePhone relay starting");
if let Some(remote) = config.remote_relay {
info!(%remote, "will connect to remote relay");
}
let (server_config, _cert_der) = wzp_transport::server_config(); let (server_config, _cert) = wzp_transport::server_config();
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?; let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
let sessions = Arc::new(Mutex::new(SessionManager::new(config.max_sessions))); // Forward mode
// If a remote relay is configured, connect to it on startup
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 {
info!(%remote_addr, "connecting to remote relay"); info!(%remote_addr, "forward mode → remote relay");
let client_cfg = wzp_transport::client_config(); let client_cfg = wzp_transport::client_config();
let remote_conn = let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?; Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
info!(%remote_addr, "connected to remote relay");
Some(Arc::new(wzp_transport::QuinnTransport::new(remote_conn)))
} else { } else {
info!("room mode — clients join named rooms (SFU)");
None None
}; };
// Room manager (room mode only)
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
info!("Listening for connections..."); info!("Listening for connections...");
loop { loop {
let connection = match wzp_transport::accept(&endpoint).await { let connection = match wzp_transport::accept(&endpoint).await {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) => { Err(e) => { error!("accept: {e}"); continue; }
error!("accept error: {e}");
continue;
}
}; };
let sessions = sessions.clone();
let remote_transport = remote_transport.clone(); let remote_transport = remote_transport.clone();
let room_mgr = room_mgr.clone();
tokio::spawn(async move { tokio::spawn(async move {
let remote_addr = connection.remote_address(); let addr = connection.remote_address();
info!(%remote_addr, "new client connection");
let client_transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); // Extract room name from QUIC handshake data (SNI).
// The web bridge connects with the room name as server_name.
let room_name = connection
.handshake_data()
.and_then(|hd| {
hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok()
})
.and_then(|hd| hd.server_name.clone())
.unwrap_or_else(|| "default".to_string());
match remote_transport { let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
Some(remote_tx) => {
// Create pipelines for both directions
let upstream_pipeline =
Arc::new(Mutex::new(RelayPipeline::new(PipelineConfig::default())));
let downstream_pipeline =
Arc::new(Mutex::new(RelayPipeline::new(PipelineConfig::default())));
// Register session info!(%addr, room = %room_name, "new client");
{
let mut mgr = sessions.lock().await;
let session_id = {
let mut id = [0u8; 16];
let addr_bytes = remote_addr.to_string();
let bytes = addr_bytes.as_bytes();
let len = bytes.len().min(16);
id[..len].copy_from_slice(&bytes[..len]);
id
};
mgr.create_session(session_id, PipelineConfig::default());
}
if let Some(remote) = remote_transport {
// Forward mode — same as before
let stats = Arc::new(RelayStats { let stats = Arc::new(RelayStats {
upstream_packets: AtomicU64::new(0), upstream_packets: AtomicU64::new(0),
downstream_packets: AtomicU64::new(0), downstream_packets: AtomicU64::new(0),
}); });
let up_pipe = Arc::new(Mutex::new(RelayPipeline::new(PipelineConfig::default())));
let dn_pipe = Arc::new(Mutex::new(RelayPipeline::new(PipelineConfig::default())));
// Spawn periodic stats logger
let stats_log = stats.clone(); let stats_log = stats.clone();
let log_remote = remote_addr;
let stats_handle = tokio::spawn(async move { let stats_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5)); let mut interval = tokio::time::interval(Duration::from_secs(5));
loop { loop {
interval.tick().await; interval.tick().await;
let up = stats_log.upstream_packets.load(Ordering::Relaxed);
let down = stats_log.downstream_packets.load(Ordering::Relaxed);
info!( info!(
client = %log_remote, up = stats_log.upstream_packets.load(Ordering::Relaxed),
upstream = up, down = stats_log.downstream_packets.load(Ordering::Relaxed),
downstream = down, "forward stats"
"relay stats"
); );
} }
}); });
// Spawn upstream and downstream tasks let up = tokio::spawn(run_upstream(transport.clone(), remote.clone(), up_pipe, stats.clone()));
let up_handle = tokio::spawn(run_upstream( let dn = tokio::spawn(run_downstream(transport.clone(), remote.clone(), dn_pipe, stats));
client_transport.clone(),
remote_tx.clone(),
upstream_pipeline,
stats.clone(),
));
let down_handle = tokio::spawn(run_downstream( tokio::select! { _ = up => {} _ = dn => {} }
client_transport.clone(),
remote_tx,
downstream_pipeline,
stats,
));
// Wait for either direction to finish, then clean up
tokio::select! {
_ = up_handle => {
info!(%remote_addr, "upstream task ended");
}
_ = down_handle => {
info!(%remote_addr, "downstream task ended");
}
}
// Abort the stats logger and close transport
stats_handle.abort(); stats_handle.abort();
if let Err(e) = client_transport.close().await { transport.close().await.ok();
warn!(%remote_addr, "error closing client transport: {e}"); } else {
} // Room mode — join room and forward to all others
info!(%remote_addr, "session ended"); let participant_id = {
} let mut mgr = room_mgr.lock().await;
None => { mgr.join(&room_name, addr, transport.clone())
// No remote relay configured — just receive and log (sink mode) };
warn!("no remote relay configured, running in sink mode");
loop { room::run_participant(
match client_transport.recv_media().await { room_mgr.clone(),
Ok(Some(packet)) => { room_name,
tracing::trace!( participant_id,
seq = packet.header.seq, transport.clone(),
block = packet.header.fec_block, ).await;
"received media packet (sink)"
); transport.close().await.ok();
}
Ok(None) => {
info!(%remote_addr, "connection closed");
break;
}
Err(e) => {
error!(%remote_addr, "recv error: {e}");
break;
}
}
}
}
} }
}); });
} }

View File

@@ -0,0 +1,200 @@
//! Room management for multi-party calls.
//!
//! Each room holds N participants. When one participant sends a media packet,
//! the relay forwards it to all other participants in the room (SFU model).
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{error, info};
use wzp_proto::MediaTransport;
/// Unique participant ID within a room.
pub type ParticipantId = u64;
static NEXT_PARTICIPANT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> ParticipantId {
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
}
/// A participant in a room.
struct Participant {
id: ParticipantId,
addr: std::net::SocketAddr,
transport: Arc<wzp_transport::QuinnTransport>,
}
/// A room holding multiple participants.
struct Room {
participants: Vec<Participant>,
}
impl Room {
fn new() -> Self {
Self {
participants: Vec::new(),
}
}
fn add(&mut self, addr: std::net::SocketAddr, transport: Arc<wzp_transport::QuinnTransport>) -> ParticipantId {
let id = next_id();
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
self.participants.push(Participant { id, addr, transport });
id
}
fn remove(&mut self, id: ParticipantId) {
self.participants.retain(|p| p.id != id);
info!(room_size = self.participants.len(), participant = id, "left room");
}
fn others(&self, exclude_id: ParticipantId) -> Vec<Arc<wzp_transport::QuinnTransport>> {
self.participants
.iter()
.filter(|p| p.id != exclude_id)
.map(|p| p.transport.clone())
.collect()
}
fn is_empty(&self) -> bool {
self.participants.is_empty()
}
fn len(&self) -> usize {
self.participants.len()
}
}
/// Manages all rooms on the relay.
pub struct RoomManager {
rooms: HashMap<String, Room>,
}
impl RoomManager {
pub fn new() -> Self {
Self {
rooms: HashMap::new(),
}
}
/// Join a room. Returns the participant ID.
pub fn join(
&mut self,
room_name: &str,
addr: std::net::SocketAddr,
transport: Arc<wzp_transport::QuinnTransport>,
) -> ParticipantId {
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
room.add(addr, transport)
}
/// Leave a room. Removes the room if empty.
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
if let Some(room) = self.rooms.get_mut(room_name) {
room.remove(participant_id);
if room.is_empty() {
self.rooms.remove(room_name);
info!(room = room_name, "room closed (empty)");
}
}
}
/// Get transports for all OTHER participants in a room.
pub fn others(
&self,
room_name: &str,
participant_id: ParticipantId,
) -> Vec<Arc<wzp_transport::QuinnTransport>> {
self.rooms
.get(room_name)
.map(|r| r.others(participant_id))
.unwrap_or_default()
}
/// Get room size.
pub fn room_size(&self, room_name: &str) -> usize {
self.rooms.get(room_name).map(|r| r.len()).unwrap_or(0)
}
/// List all rooms with their sizes.
pub fn list(&self) -> Vec<(String, usize)> {
self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect()
}
}
/// Run the receive loop for one participant in a room.
/// Forwards all received packets to every other participant.
pub async fn run_participant(
room_mgr: Arc<Mutex<RoomManager>>,
room_name: String,
participant_id: ParticipantId,
transport: Arc<wzp_transport::QuinnTransport>,
) {
let addr = transport.connection().remote_address();
let mut packets_forwarded = 0u64;
loop {
let pkt = match transport.recv_media().await {
Ok(Some(pkt)) => pkt,
Ok(None) => {
info!(%addr, participant = participant_id, "disconnected");
break;
}
Err(e) => {
error!(%addr, participant = participant_id, "recv error: {e}");
break;
}
};
// Get current list of other participants
let others = {
let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id)
};
// Forward to all others
for other in &others {
// Best-effort: if one send fails, continue to others
if let Err(e) = other.send_media(&pkt).await {
// Don't log every failure — they'll be cleaned up when their recv loop breaks
let _ = e;
}
}
packets_forwarded += 1;
if packets_forwarded % 500 == 0 {
let room_size = {
let mgr = room_mgr.lock().await;
mgr.room_size(&room_name)
};
info!(
room = %room_name,
participant = participant_id,
forwarded = packets_forwarded,
room_size,
"participant stats"
);
}
}
// Clean up
let mut mgr = room_mgr.lock().await;
mgr.leave(&room_name, participant_id);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_join_leave() {
let mut mgr = RoomManager::new();
// Can't test with real transports, but test the room logic
assert_eq!(mgr.room_size("test"), 0);
assert!(mgr.list().is_empty());
}
}

View File

@@ -139,6 +139,7 @@ mod tests {
#[test] #[test]
fn server_config_creates_without_error() { fn server_config_creates_without_error() {
let _ = rustls::crypto::ring::default_provider().install_default();
let (cfg, cert_der) = server_config(); let (cfg, cert_der) = server_config();
assert!(!cert_der.is_empty()); assert!(!cert_der.is_empty());
// Verify the config was created (no panic) // Verify the config was created (no panic)
@@ -147,6 +148,7 @@ mod tests {
#[test] #[test]
fn client_config_creates_without_error() { fn client_config_creates_without_error() {
let _ = rustls::crypto::ring::default_provider().install_default();
let cfg = client_config(); let cfg = client_config();
drop(cfg); drop(cfg);
} }

32
crates/wzp-web/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "wzp-web"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "WarzonePhone web bridge — browser audio via WebSocket to wzp relay"
[dependencies]
wzp-proto = { workspace = true }
wzp-codec = { workspace = true }
wzp-fec = { workspace = true }
wzp-crypto = { workspace = true }
wzp-transport = { workspace = true }
wzp-client = { path = "../wzp-client" }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
bytes = { workspace = true }
anyhow = "1"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["fs"] }
futures = "0.3"
axum-server = { version = "0.7", features = ["tls-rustls"] }
rcgen = "0.13"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rustls-pki-types = "1"
tokio-rustls = "0.26"
[[bin]]
name = "wzp-web"
path = "src/main.rs"

259
crates/wzp-web/src/main.rs Normal file
View File

@@ -0,0 +1,259 @@
//! WarzonePhone Web Bridge
//!
//! Serves a web page for browser-based voice calls and bridges
//! WebSocket audio to the wzp relay protocol.
//!
//! Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]
//!
//! Rooms: clients connect to /ws/<room-name> and are paired by room.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Path, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures::stream::StreamExt;
use futures::SinkExt;
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tracing::{error, info, warn};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960;
#[derive(Clone)]
struct AppState {
relay_addr: SocketAddr,
rooms: Arc<Mutex<HashMap<String, RoomSlot>>>,
}
/// A waiting client in a room.
struct RoomSlot {
/// Sender half — send audio TO this waiting client's browser.
tx: tokio::sync::mpsc::Sender<Vec<u8>>,
/// Receiver half — receive audio FROM this waiting client's browser.
rx: Arc<Mutex<tokio::sync::mpsc::Receiver<Vec<i16>>>>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("failed to install rustls crypto provider");
let mut port: u16 = 8080;
let mut relay_addr: SocketAddr = "127.0.0.1:4433".parse()?;
let mut use_tls = false;
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); }
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); }
"--tls" => { use_tls = true; }
"--help" | "-h" => {
eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]");
eprintln!();
eprintln!("Options:");
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
eprintln!(" --tls Enable HTTPS (required for mic on Android)");
eprintln!();
eprintln!("Rooms: open https://host:port/<room-name> to join a room.");
eprintln!("Two clients in the same room are connected for a call.");
std::process::exit(0);
}
_ => {}
}
i += 1;
}
let state = AppState {
relay_addr,
rooms: Arc::new(Mutex::new(HashMap::new())),
};
let static_dir = if std::path::Path::new("crates/wzp-web/static").exists() {
"crates/wzp-web/static"
} else if std::path::Path::new("static").exists() {
"static"
} else {
"static"
};
let app = Router::new()
.route("/ws/{room}", get(ws_handler))
.fallback_service(ServeDir::new(static_dir))
.with_state(state);
let listen: SocketAddr = format!("0.0.0.0:{port}").parse()?;
if use_tls {
let cert_key = rcgen::generate_simple_self_signed(vec![
"localhost".to_string(), "wzp".to_string(),
])?;
let cert_der = rustls_pki_types::CertificateDer::from(cert_key.cert);
let key_der = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der())
.map_err(|e| anyhow::anyhow!("key error: {e}"))?;
let mut tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)?;
tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let tls_config = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_config));
info!(%listen, %relay_addr, "WarzonePhone web bridge (HTTPS)");
info!("Open https://localhost:{port}/<room-name> in your browser");
axum_server::bind_rustls(listen, tls_config)
.serve(app.into_make_service())
.await?;
} else {
info!(%listen, %relay_addr, "WarzonePhone web bridge (HTTP)");
info!("Open http://localhost:{port}/<room-name> in your browser");
info!("Use --tls for mic access on Android/remote browsers");
let listener = tokio::net::TcpListener::bind(listen).await?;
axum::serve(listener, app).await?;
}
Ok(())
}
async fn ws_handler(
ws: WebSocketUpgrade,
Path(room): Path<String>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl IntoResponse {
info!(room = %room, "WebSocket upgrade request");
ws.on_upgrade(move |socket| handle_ws(socket, room, state))
}
async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
info!(room = %room, "client joined room");
// Connect to relay
let relay_addr = state.relay_addr;
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
"[::]:0".parse().unwrap()
} else {
"0.0.0.0:0".parse().unwrap()
};
let client_config = wzp_transport::client_config();
let endpoint = match wzp_transport::create_endpoint(bind_addr, None) {
Ok(e) => e,
Err(e) => { error!("create endpoint: {e}"); return; }
};
// Pass room name as QUIC SNI so the relay knows which room to join
let sni = if room.is_empty() { "default" } else { &room };
let connection =
match wzp_transport::connect(&endpoint, relay_addr, sni, client_config).await {
Ok(c) => c,
Err(e) => { error!("connect to relay: {e}"); return; }
};
info!(room = %room, "connected to relay");
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
let config = CallConfig::default();
let (mut ws_sender, mut ws_receiver) = socket.split();
let encoder = Arc::new(Mutex::new(CallEncoder::new(&config)));
let decoder = Arc::new(Mutex::new(CallDecoder::new(&config)));
// Browser → Relay
let send_transport = transport.clone();
let send_encoder = encoder.clone();
let send_room = room.clone();
let send_task = tokio::spawn(async move {
let mut frames_sent = 0u64;
while let Some(Ok(msg)) = ws_receiver.next().await {
match msg {
Message::Binary(data) => {
if data.len() < FRAME_SAMPLES * 2 { continue; }
let pcm: Vec<i16> = data.chunks_exact(2)
.take(FRAME_SAMPLES)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
let packets = {
let mut enc = send_encoder.lock().await;
match enc.encode_frame(&pcm) {
Ok(p) => p,
Err(e) => { warn!("encode: {e}"); continue; }
}
};
for pkt in &packets {
if let Err(e) = send_transport.send_media(pkt).await {
error!("relay send: {e}");
return;
}
}
frames_sent += 1;
if frames_sent % 500 == 0 {
info!(room = %send_room, frames_sent, "browser → relay");
}
}
Message::Close(_) => break,
_ => {}
}
}
info!(room = %send_room, frames_sent, "send ended");
});
// Relay → Browser
let recv_transport = transport.clone();
let recv_decoder = decoder.clone();
let recv_room = room.clone();
let recv_task = tokio::spawn(async move {
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut frames_recv = 0u64;
loop {
match recv_transport.recv_media().await {
Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
let mut dec = recv_decoder.lock().await;
dec.ingest(pkt);
if !is_repair {
if let Some(_n) = dec.decode_next(&mut pcm_buf) {
let bytes: Vec<u8> = pcm_buf.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
if let Err(e) = ws_sender.send(Message::Binary(bytes.into())).await {
error!("ws send: {e}");
return;
}
frames_recv += 1;
if frames_recv % 500 == 0 {
info!(room = %recv_room, frames_recv, "relay → browser");
}
}
}
}
Ok(None) => { info!(room = %recv_room, "relay closed"); break; }
Err(e) => { error!(room = %recv_room, "relay recv: {e}"); break; }
}
}
info!(room = %recv_room, frames_recv, "recv ended");
});
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
transport.close().await.ok();
info!(room = %room, "session ended");
}

View File

@@ -0,0 +1,39 @@
// AudioWorklet processor for capturing microphone audio.
// Accumulates samples and posts 960-sample (20ms @ 48kHz) frames to the main thread.
class CaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (!input || !input[0]) return true;
const samples = input[0]; // Float32Array, typically 128 samples
// Accumulate
const newBuf = new Float32Array(this.buffer.length + samples.length);
newBuf.set(this.buffer);
newBuf.set(samples, this.buffer.length);
this.buffer = newBuf;
// Send complete 960-sample frames
while (this.buffer.length >= 960) {
const frame = this.buffer.slice(0, 960);
this.buffer = this.buffer.slice(960);
// Convert to Int16
const pcm = new Int16Array(960);
for (let i = 0; i < 960; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
this.port.postMessage(pcm.buffer, [pcm.buffer]);
}
return true;
}
}
registerProcessor('capture-processor', CaptureProcessor);

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarzonePhone</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #1a1a2e; color: #e0e0e0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.container { text-align: center; max-width: 420px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #00d4ff; }
.subtitle { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; }
.room-input { margin-bottom: 1.5rem; }
.room-input input { background: #2a2a4a; border: 1px solid #444; color: #e0e0e0; padding: 0.6rem 1rem; font-size: 1rem; border-radius: 8px; width: 200px; text-align: center; }
.room-input input:focus { outline: none; border-color: #00d4ff; }
.room-input label { display: block; color: #888; font-size: 0.8rem; margin-bottom: 0.4rem; }
#callBtn { background: #00d4ff; color: #1a1a2e; border: none; padding: 1rem 3rem; font-size: 1.2rem; border-radius: 50px; cursor: pointer; transition: all 0.2s; }
#callBtn:hover { background: #00b8d4; transform: scale(1.05); }
#callBtn.active { background: #ff4444; color: white; }
#callBtn:disabled { background: #444; color: #888; cursor: not-allowed; transform: none; }
.status { margin-top: 1.5rem; font-size: 0.9rem; color: #888; min-height: 1.5rem; }
.stats { margin-top: 0.5rem; font-size: 0.75rem; color: #555; font-family: monospace; }
.level { margin-top: 1rem; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.level-bar { height: 100%; background: #00d4ff; width: 0%; transition: width 50ms; }
.controls { margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
.controls label { font-size: 0.8rem; color: #888; cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
.controls input[type="checkbox"] { accent-color: #00d4ff; }
#pttBtn { display: none; background: #444; color: #e0e0e0; border: 2px solid #666; padding: 0.8rem 2rem; font-size: 1rem; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; touch-action: none; }
#pttBtn.transmitting { background: #ff4444; border-color: #ff6666; color: white; }
</style>
</head>
<body>
<div class="container">
<h1>WarzonePhone</h1>
<p class="subtitle">Lossy VoIP Protocol</p>
<div class="room-input">
<label for="room">Room</label>
<input type="text" id="room" placeholder="enter room name" value="">
</div>
<button id="callBtn" onclick="toggleCall()">Connect</button>
<div class="controls" id="controls" style="display:none;">
<label><input type="checkbox" id="pttMode" onchange="togglePTT()"> Radio mode (push-to-talk)</label>
</div>
<button id="pttBtn">Hold to Talk</button>
<div class="level"><div class="level-bar" id="levelBar"></div></div>
<div class="status" id="status"></div>
<div class="stats" id="stats"></div>
</div>
<script>
const SAMPLE_RATE = 48000;
const FRAME_SIZE = 960;
let ws = null;
let audioCtx = null;
let mediaStream = null;
let captureNode = null;
let playbackNode = null;
let active = false;
let transmitting = true; // in open-mic mode, always transmitting
let pttMode = false;
let framesSent = 0;
let framesRecv = 0;
let startTime = 0;
let statsInterval = null;
// Use room from URL path or input field
function getRoom() {
// Check URL: /roomname or /#roomname
const path = location.pathname.replace(/^\//, '').replace(/\/$/, '');
if (path && path !== 'index.html') return path;
const hash = location.hash.replace('#', '');
if (hash) return hash;
return document.getElementById('room').value.trim() || 'default';
}
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function setStats(msg) { document.getElementById('stats').textContent = msg; }
function toggleCall() {
if (active) stopCall();
else startCall();
}
async function startCall() {
const btn = document.getElementById('callBtn');
const room = getRoom();
if (!room) { setStatus('Enter a room name'); return; }
btn.disabled = true;
setStatus('Requesting microphone...');
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: SAMPLE_RATE, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
} catch(e) {
setStatus('Mic access denied: ' + e.message);
btn.disabled = false;
return;
}
audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
// Connect WebSocket with room name
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/ws/' + encodeURIComponent(room);
setStatus('Connecting to room: ' + room + '...');
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = async () => {
setStatus('Connected to room: ' + room);
btn.textContent = 'Disconnect';
btn.classList.add('active');
btn.disabled = false;
active = true;
framesSent = 0;
framesRecv = 0;
startTime = Date.now();
showControls(true);
await startAudioCapture();
await startAudioPlayback();
startStatsUpdate();
};
ws.onmessage = (event) => {
const pcmData = new Int16Array(event.data);
framesRecv++;
playAudio(pcmData);
};
ws.onclose = () => {
if (active) {
setStatus('Disconnected — reconnecting to ' + room + '...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
} else {
setStatus('Disconnected');
}
};
ws.onerror = () => {
if (active) {
setStatus('Error — reconnecting...');
setTimeout(() => { if (active) { cleanupAudio(); startCall(); } }, 1000);
}
};
}
function stopCall() {
active = false;
const btn = document.getElementById('callBtn');
btn.textContent = 'Connect';
btn.classList.remove('active');
btn.disabled = false;
showControls(false);
cleanupAudio();
if (ws) { ws.close(); ws = null; }
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
setStatus('');
setStats('');
}
function cleanupAudio() {
if (captureNode) { captureNode.disconnect(); captureNode = null; }
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
}
async function startAudioCapture() {
const source = audioCtx.createMediaStreamSource(mediaStream);
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
captureNode.port.onmessage = (e) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
ws.send(e.data);
framesSent++;
// Level meter from the PCM data
const pcm = new Int16Array(e.data);
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
} catch(e) {
// Fallback to ScriptProcessor if AudioWorklet not supported
console.warn('AudioWorklet not available, using ScriptProcessor fallback:', e);
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
const input = ev.inputBuffer.getChannelData(0);
const n = new Float32Array(acc.length + input.length);
n.set(acc); n.set(input, acc.length); acc = n;
while (acc.length >= FRAME_SIZE) {
const frame = acc.slice(0, FRAME_SIZE); acc = acc.slice(FRAME_SIZE);
const pcm = new Int16Array(FRAME_SIZE);
for (let i = 0; i < FRAME_SIZE; i++) pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
let max = 0;
for (let i = 0; i < pcm.length; i += 16) max = Math.max(max, Math.abs(pcm[i]));
document.getElementById('levelBar').style.width = (max / 32768 * 100) + '%';
ws.send(pcm.buffer);
framesSent++;
}
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination);
}
}
async function startAudioPlayback() {
try {
await audioCtx.audioWorklet.addModule('playback-processor.js');
playbackNode = new AudioWorkletNode(audioCtx, 'playback-processor');
playbackNode.connect(audioCtx.destination);
} catch(e) {
console.warn('AudioWorklet playback not available, using scheduled fallback');
playbackNode = null; // will use createBufferSource fallback
}
}
let nextPlayTime = 0;
function playAudio(pcmInt16) {
if (!audioCtx) return;
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
if (playbackNode && playbackNode.port) {
// AudioWorklet path — send float samples to the worklet
playbackNode.port.postMessage(floatData.buffer, [floatData.buffer]);
} else {
// Fallback: scheduled BufferSource
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now || nextPlayTime > now + 1.0) {
nextPlayTime = now + 0.02;
}
source.start(nextPlayTime);
nextPlayTime += buffer.duration;
}
}
function startStatsUpdate() {
statsInterval = setInterval(() => {
if (!active) { clearInterval(statsInterval); return; }
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
setStats(elapsed + 's | sent: ' + framesSent + ' | recv: ' + framesRecv);
}, 1000);
}
// --- Push-to-talk ---
function togglePTT() {
pttMode = document.getElementById('pttMode').checked;
const btn = document.getElementById('pttBtn');
if (pttMode) {
transmitting = false;
btn.style.display = 'block';
} else {
transmitting = true;
btn.style.display = 'none';
}
}
// PTT button — hold to talk (mouse + touch)
document.getElementById('pttBtn').addEventListener('mousedown', () => { startTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseup', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('mouseleave', () => { stopTransmit(); });
document.getElementById('pttBtn').addEventListener('touchstart', (e) => { e.preventDefault(); startTransmit(); });
document.getElementById('pttBtn').addEventListener('touchend', (e) => { e.preventDefault(); stopTransmit(); });
// Spacebar PTT
document.addEventListener('keydown', (e) => { if (pttMode && active && e.code === 'Space' && !e.repeat) { e.preventDefault(); startTransmit(); } });
document.addEventListener('keyup', (e) => { if (pttMode && active && e.code === 'Space') { e.preventDefault(); stopTransmit(); } });
function startTransmit() {
if (!pttMode || !active) return;
transmitting = true;
document.getElementById('pttBtn').classList.add('transmitting');
document.getElementById('pttBtn').textContent = 'Transmitting...';
}
function stopTransmit() {
if (!pttMode) return;
transmitting = false;
document.getElementById('pttBtn').classList.remove('transmitting');
document.getElementById('pttBtn').textContent = 'Hold to Talk';
}
// Show controls when connected
function showControls(show) {
document.getElementById('controls').style.display = show ? 'flex' : 'none';
if (!show) {
document.getElementById('pttBtn').style.display = 'none';
pttMode = false;
transmitting = true;
}
}
// Set room from URL on load
window.addEventListener('load', () => {
const room = getRoom();
if (room && room !== 'default') {
document.getElementById('room').value = room;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
// AudioWorklet processor for playing received audio.
// Receives PCM samples from the main thread and outputs them.
class PlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.maxBuffered = 48000 / 5; // 200ms max
this.port.onmessage = (e) => {
const incoming = new Float32Array(e.data);
// Append
const newBuf = new Float32Array(this.buffer.length + incoming.length);
newBuf.set(this.buffer);
newBuf.set(incoming, this.buffer.length);
this.buffer = newBuf;
// Cap buffer to prevent drift
if (this.buffer.length > this.maxBuffered) {
this.buffer = this.buffer.slice(this.buffer.length - this.maxBuffered);
}
};
}
process(inputs, outputs, parameters) {
const output = outputs[0];
if (!output || !output[0]) return true;
const out = output[0]; // 128 samples typically
if (this.buffer.length >= out.length) {
out.set(this.buffer.subarray(0, out.length));
this.buffer = this.buffer.slice(out.length);
} else if (this.buffer.length > 0) {
out.set(this.buffer);
for (let i = this.buffer.length; i < out.length; i++) out[i] = 0;
this.buffer = new Float32Array(0);
} else {
for (let i = 0; i < out.length; i++) out[i] = 0;
}
return true;
}
}
registerProcessor('playback-processor', PlaybackProcessor);

677
docs/API.md Normal file
View File

@@ -0,0 +1,677 @@
# WarzonePhone Crate API Reference
## wzp-proto
**Path**: `crates/wzp-proto/src/`
The protocol definition crate. Contains all shared types, trait interfaces, and core logic. No implementation dependencies -- this is the hub of the star dependency graph.
### Traits (`traits.rs`)
```rust
/// Encodes PCM audio into compressed frames.
pub trait AudioEncoder: Send + Sync {
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError>;
fn codec_id(&self) -> CodecId;
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
fn max_frame_bytes(&self) -> usize;
fn set_inband_fec(&mut self, _enabled: bool) {} // default no-op
fn set_dtx(&mut self, _enabled: bool) {} // default no-op
}
/// Decodes compressed frames back to PCM audio.
pub trait AudioDecoder: Send + Sync {
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError>;
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError>;
fn codec_id(&self) -> CodecId;
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
}
/// Encodes source symbols into FEC-protected blocks.
pub trait FecEncoder: Send + Sync {
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
fn finalize_block(&mut self) -> Result<u8, FecError>;
fn current_block_id(&self) -> u8;
fn current_block_size(&self) -> usize;
}
/// Decodes FEC-protected blocks, recovering lost source symbols.
pub trait FecDecoder: Send + Sync {
fn add_symbol(&mut self, block_id: u8, symbol_index: u8, is_repair: bool, data: &[u8]) -> Result<(), FecError>;
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
fn expire_before(&mut self, block_id: u8);
}
/// Per-call encryption session (symmetric, after key exchange).
pub trait CryptoSession: Send + Sync {
fn encrypt(&mut self, header_bytes: &[u8], plaintext: &[u8], out: &mut Vec<u8>) -> Result<(), CryptoError>;
fn decrypt(&mut self, header_bytes: &[u8], ciphertext: &[u8], out: &mut Vec<u8>) -> Result<(), CryptoError>;
fn initiate_rekey(&mut self) -> Result<[u8; 32], CryptoError>;
fn complete_rekey(&mut self, peer_ephemeral_pub: &[u8; 32]) -> Result<(), CryptoError>;
fn overhead(&self) -> usize { 16 } // ChaCha20-Poly1305 tag
}
/// Key exchange using the Warzone identity model.
pub trait KeyExchange: Send + Sync {
fn from_identity_seed(seed: &[u8; 32]) -> Self where Self: Sized;
fn generate_ephemeral(&mut self) -> [u8; 32];
fn identity_public_key(&self) -> [u8; 32];
fn fingerprint(&self) -> [u8; 16];
fn sign(&self, data: &[u8]) -> Vec<u8>;
fn verify(peer_identity_pub: &[u8; 32], data: &[u8], signature: &[u8]) -> bool where Self: Sized;
fn derive_session(&self, peer_ephemeral_pub: &[u8; 32]) -> Result<Box<dyn CryptoSession>, CryptoError>;
}
/// Transport layer for sending/receiving media and signaling.
#[async_trait]
pub trait MediaTransport: Send + Sync {
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError>;
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError>;
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError>;
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError>;
fn path_quality(&self) -> PathQuality;
async fn close(&self) -> Result<(), TransportError>;
}
/// Wraps/unwraps packets for DPI evasion (Phase 2).
pub trait ObfuscationLayer: Send + Sync {
fn obfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
fn deobfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
}
/// Adaptive quality controller.
pub trait QualityController: Send + Sync {
fn observe(&mut self, report: &QualityReport) -> Option<QualityProfile>;
fn force_profile(&mut self, profile: QualityProfile);
fn current_profile(&self) -> QualityProfile;
}
```
### Wire Format Types (`packet.rs`)
```rust
pub struct MediaHeader { /* 12 bytes */ }
pub struct QualityReport { /* 4 bytes */ }
pub struct MediaPacket { pub header: MediaHeader, pub payload: Bytes, pub quality_report: Option<QualityReport> }
pub enum SignalMessage { CallOffer{..}, CallAnswer{..}, IceCandidate{..}, Rekey{..}, QualityUpdate{..}, Ping{..}, Pong{..}, Hangup{..} }
pub enum HangupReason { Normal, Busy, Declined, Timeout, Error }
```
Key methods:
- `MediaHeader::write_to(&self, buf: &mut impl BufMut)` -- serialize to 12 bytes
- `MediaHeader::read_from(buf: &mut impl Buf) -> Option<Self>` -- deserialize
- `MediaHeader::encode_fec_ratio(ratio: f32) -> u8` -- float to 7-bit wire encoding
- `MediaHeader::decode_fec_ratio(encoded: u8) -> f32` -- 7-bit wire to float
- `MediaPacket::to_bytes(&self) -> Bytes` -- serialize complete packet
- `MediaPacket::from_bytes(data: Bytes) -> Option<Self>` -- deserialize
### Codec Identifiers (`codec_id.rs`)
```rust
pub enum CodecId { Opus24k = 0, Opus16k = 1, Opus6k = 2, Codec2_3200 = 3, Codec2_1200 = 4 }
pub struct QualityProfile {
pub codec: CodecId,
pub fec_ratio: f32,
pub frame_duration_ms: u8,
pub frames_per_block: u8,
}
```
Constants: `QualityProfile::GOOD`, `QualityProfile::DEGRADED`, `QualityProfile::CATASTROPHIC`
Key methods:
- `CodecId::bitrate_bps(self) -> u32`
- `CodecId::frame_duration_ms(self) -> u8`
- `CodecId::sample_rate_hz(self) -> u32`
- `CodecId::from_wire(val: u8) -> Option<Self>`
- `CodecId::to_wire(self) -> u8`
- `QualityProfile::total_bitrate_kbps(&self) -> f32`
### Quality Controller (`quality.rs`)
```rust
pub enum Tier { Good, Degraded, Catastrophic }
pub struct AdaptiveQualityController { /* ... */ }
```
Key methods:
- `AdaptiveQualityController::new() -> Self` -- starts at Tier::Good
- `AdaptiveQualityController::tier(&self) -> Tier`
- `Tier::classify(report: &QualityReport) -> Self`
- `Tier::profile(self) -> QualityProfile`
### Jitter Buffer (`jitter.rs`)
```rust
pub struct JitterBuffer { /* ... */ }
pub struct JitterStats { pub packets_received: u64, pub packets_played: u64, pub packets_lost: u64, pub packets_late: u64, pub packets_duplicate: u64, pub current_depth: usize }
pub enum PlayoutResult { Packet(MediaPacket), Missing { seq: u16 }, NotReady }
```
Key methods:
- `JitterBuffer::new(target_depth: usize, max_depth: usize, min_depth: usize) -> Self`
- `JitterBuffer::default_5s() -> Self` -- target=50, max=250, min=25
- `JitterBuffer::push(&mut self, packet: MediaPacket)`
- `JitterBuffer::pop(&mut self) -> PlayoutResult`
- `JitterBuffer::depth(&self) -> usize`
- `JitterBuffer::stats(&self) -> &JitterStats`
- `JitterBuffer::reset(&mut self)`
- `JitterBuffer::set_target_depth(&mut self, depth: usize)`
### Session State Machine (`session.rs`)
```rust
pub enum SessionState { Idle, Connecting, Handshaking, Active, Rekeying, Closed }
pub enum SessionEvent { Initiate, Connected, HandshakeComplete, RekeyStart, RekeyComplete, Terminate{reason}, ConnectionLost }
pub struct Session { /* ... */ }
```
Key methods:
- `Session::new(session_id: [u8; 16]) -> Self`
- `Session::state(&self) -> SessionState`
- `Session::transition(&mut self, event: SessionEvent, now_ms: u64) -> Result<SessionState, TransitionError>`
- `Session::is_media_active(&self) -> bool` -- true for Active and Rekeying
### Error Types (`error.rs`)
```rust
pub enum CodecError { EncodeFailed(String), DecodeFailed(String), UnsupportedTransition{from, to} }
pub enum FecError { BlockFull{max}, InsufficientSymbols{needed, have}, InvalidBlock(u8), Internal(String) }
pub enum CryptoError { DecryptionFailed, InvalidPublicKey, RekeyFailed(String), ReplayDetected{seq}, Internal(String) }
pub enum TransportError { ConnectionLost, DatagramTooLarge{size, max}, Timeout{ms}, Io(io::Error), Internal(String) }
pub enum ObfuscationError { Failed(String), InvalidFraming }
```
### PathQuality (`traits.rs`)
```rust
pub struct PathQuality {
pub loss_pct: f32, // 0.0-100.0
pub rtt_ms: u32,
pub jitter_ms: u32,
pub bandwidth_kbps: u32,
}
```
---
## wzp-codec
**Path**: `crates/wzp-codec/src/`
### Factory Functions (`lib.rs`)
```rust
/// Create an adaptive encoder (accepts 48 kHz PCM, handles resampling for Codec2).
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder>
/// Create an adaptive decoder (outputs 48 kHz PCM, handles upsampling from Codec2).
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder>
```
### Public Types
```rust
pub struct AdaptiveEncoder { /* wraps OpusEncoder + Codec2Encoder */ }
pub struct AdaptiveDecoder { /* wraps OpusDecoder + Codec2Decoder */ }
pub struct OpusEncoder { /* audiopus::coder::Encoder wrapper */ }
pub struct OpusDecoder { /* audiopus::coder::Decoder wrapper */ }
pub struct Codec2Encoder { /* codec2::Codec2 wrapper */ }
pub struct Codec2Decoder { /* codec2::Codec2 wrapper */ }
```
Key methods on concrete types:
- `OpusEncoder::new(profile: QualityProfile) -> Result<Self, CodecError>`
- `OpusEncoder::frame_samples(&self) -> usize` -- 960 for 20ms, 1920 for 40ms
- `Codec2Encoder::new(profile: QualityProfile) -> Result<Self, CodecError>`
- `Codec2Encoder::frame_samples(&self) -> usize` -- 160 for 20ms/3200bps, 320 for 40ms/1200bps
### Resampler (`resample.rs`)
```rust
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> // 6:1 decimation with box filter
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> // 1:6 linear interpolation
```
---
## wzp-fec
**Path**: `crates/wzp-fec/src/`
### Factory Functions (`lib.rs`)
```rust
/// Create an encoder/decoder pair configured for the given quality profile.
pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder)
/// Create an encoder configured for the given quality profile.
pub fn create_encoder(profile: &QualityProfile) -> RaptorQFecEncoder
/// Create a decoder configured for the given quality profile.
pub fn create_decoder(profile: &QualityProfile) -> RaptorQFecDecoder
```
### RaptorQFecEncoder (`encoder.rs`)
```rust
pub struct RaptorQFecEncoder { /* block_id, frames_per_block, source_symbols, symbol_size */ }
```
Key methods:
- `RaptorQFecEncoder::new(frames_per_block: usize, symbol_size: u16) -> Self`
- `RaptorQFecEncoder::with_defaults(frames_per_block: usize) -> Self` -- symbol_size=256
- Implements `FecEncoder` trait
### RaptorQFecDecoder (`decoder.rs`)
```rust
pub struct RaptorQFecDecoder { /* blocks: HashMap<u8, BlockState>, symbol_size, frames_per_block */ }
```
Key methods:
- `RaptorQFecDecoder::new(frames_per_block: usize, symbol_size: u16) -> Self`
- `RaptorQFecDecoder::with_defaults(frames_per_block: usize) -> Self`
- Implements `FecDecoder` trait
### Interleaver (`interleave.rs`)
```rust
pub type Symbol = (u8, u8, bool, Vec<u8>); // (block_id, symbol_index, is_repair, data)
pub struct Interleaver { depth: usize }
```
Key methods:
- `Interleaver::new(depth: usize) -> Self`
- `Interleaver::with_default_depth() -> Self` -- depth=3
- `Interleaver::interleave(&self, blocks: &[Vec<Symbol>]) -> Vec<Symbol>`
- `Interleaver::depth(&self) -> usize`
### AdaptiveFec (`adaptive.rs`)
```rust
pub struct AdaptiveFec { pub frames_per_block: usize, pub repair_ratio: f32, pub symbol_size: u16 }
```
Key methods:
- `AdaptiveFec::from_profile(profile: &QualityProfile) -> Self`
- `AdaptiveFec::build_encoder(&self) -> RaptorQFecEncoder`
- `AdaptiveFec::ratio(&self) -> f32`
- `AdaptiveFec::overhead_factor(&self) -> f32` -- 1.0 + repair_ratio
### Block Managers (`block_manager.rs`)
```rust
pub enum EncoderBlockState { Building, Pending, Sent, Acknowledged }
pub enum DecoderBlockState { Assembling, Complete, Expired }
pub struct EncoderBlockManager { /* ... */ }
pub struct DecoderBlockManager { /* ... */ }
```
Key methods:
- `EncoderBlockManager::next_block_id(&mut self) -> u8`
- `EncoderBlockManager::mark_sent(&mut self, block_id: u8)`
- `EncoderBlockManager::mark_acknowledged(&mut self, block_id: u8)`
- `DecoderBlockManager::touch(&mut self, block_id: u8)`
- `DecoderBlockManager::mark_complete(&mut self, block_id: u8)`
- `DecoderBlockManager::expire_before(&mut self, block_id: u8)`
### Helper Functions (`encoder.rs`)
```rust
/// Build source EncodingPackets for a given block (for testing/interleaving).
pub fn source_packets_for_block(block_id: u8, symbols: &[Vec<u8>], symbol_size: u16) -> Vec<EncodingPacket>
/// Generate repair packets for the given source symbols.
pub fn repair_packets_for_block(block_id: u8, symbols: &[Vec<u8>], symbol_size: u16, ratio: f32) -> Vec<EncodingPacket>
```
---
## wzp-crypto
**Path**: `crates/wzp-crypto/src/`
### Re-exports (`lib.rs`)
```rust
pub use anti_replay::AntiReplayWindow;
pub use handshake::WarzoneKeyExchange;
pub use nonce::{build_nonce, Direction};
pub use rekey::RekeyManager;
pub use session::ChaChaSession;
pub use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
```
### WarzoneKeyExchange (`handshake.rs`)
```rust
pub struct WarzoneKeyExchange { /* signing_key, x25519_static, ephemeral_secret */ }
```
Implements `KeyExchange` trait. Key derivation:
- Ed25519: `HKDF(seed, "warzone-ed25519-identity")`
- X25519: `HKDF(seed, "warzone-x25519-identity")`
- Session: `HKDF(X25519_DH_shared_secret, "warzone-session-key")`
### ChaChaSession (`session.rs`)
```rust
pub struct ChaChaSession { /* cipher, session_id, send_seq, recv_seq, rekey_mgr, pending_rekey_secret */ }
```
Key methods:
- `ChaChaSession::new(shared_secret: [u8; 32]) -> Self`
- Implements `CryptoSession` trait
### AntiReplayWindow (`anti_replay.rs`)
```rust
pub struct AntiReplayWindow { /* highest: u16, bitmap: Vec<u64>, initialized: bool */ }
```
Key methods:
- `AntiReplayWindow::new() -> Self` -- 1024-packet window
- `AntiReplayWindow::check_and_update(&mut self, seq: u16) -> Result<(), CryptoError>`
### Nonce Construction (`nonce.rs`)
```rust
pub enum Direction { Send = 0, Recv = 1 }
pub fn build_nonce(session_id: &[u8; 4], seq: u32, direction: Direction) -> [u8; 12]
```
### RekeyManager (`rekey.rs`)
```rust
pub struct RekeyManager { /* current_key, last_rekey_at */ }
```
Key methods:
- `RekeyManager::new(initial_key: [u8; 32]) -> Self`
- `RekeyManager::should_rekey(&self, packet_count: u64) -> bool` -- every 2^16 packets
- `RekeyManager::perform_rekey(&mut self, new_peer_pub: &[u8; 32], our_new_secret: StaticSecret, packet_count: u64) -> [u8; 32]`
---
## wzp-transport
**Path**: `crates/wzp-transport/src/`
### Re-exports (`lib.rs`)
```rust
pub use config::{client_config, server_config};
pub use connection::{accept, connect, create_endpoint};
pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport;
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
```
### QuinnTransport (`quic.rs`)
```rust
pub struct QuinnTransport { /* connection: quinn::Connection, path_monitor: Mutex<PathMonitor> */ }
```
Key methods:
- `QuinnTransport::new(connection: quinn::Connection) -> Self`
- `QuinnTransport::connection(&self) -> &quinn::Connection`
- `QuinnTransport::max_datagram_size(&self) -> Option<usize>`
- Implements `MediaTransport` trait
### Configuration (`config.rs`)
```rust
/// Create a server configuration with a self-signed certificate.
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>)
/// Create a client configuration that trusts any certificate (testing).
pub fn client_config() -> quinn::ClientConfig
```
QUIC parameters: ALPN `wzp`, 30s idle timeout, 5s keepalive, 256KB receive window, 128KB send window, 300ms initial RTT.
### Connection Lifecycle (`connection.rs`)
```rust
pub fn create_endpoint(bind_addr: SocketAddr, server_config: Option<quinn::ServerConfig>) -> Result<quinn::Endpoint, TransportError>
pub async fn connect(endpoint: &quinn::Endpoint, addr: SocketAddr, server_name: &str, config: quinn::ClientConfig) -> Result<quinn::Connection, TransportError>
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError>
```
### PathMonitor (`path_monitor.rs`)
```rust
pub struct PathMonitor { /* EWMA state for loss, RTT, jitter, bandwidth */ }
```
Key methods:
- `PathMonitor::new() -> Self`
- `PathMonitor::observe_sent(&mut self, seq: u16, timestamp_ms: u64)`
- `PathMonitor::observe_received(&mut self, seq: u16, timestamp_ms: u64)`
- `PathMonitor::observe_rtt(&mut self, rtt_ms: u32)`
- `PathMonitor::quality(&self) -> PathQuality`
### Datagram Helpers (`datagram.rs`)
```rust
pub fn serialize_media(packet: &MediaPacket) -> Bytes
pub fn deserialize_media(data: Bytes) -> Option<MediaPacket>
pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize>
```
### Reliable Stream Framing (`reliable.rs`)
```rust
pub async fn send_signal(connection: &Connection, msg: &SignalMessage) -> Result<(), TransportError>
pub async fn recv_signal(recv: &mut quinn::RecvStream) -> Result<SignalMessage, TransportError>
```
Framing: 4-byte big-endian length prefix + serde_json payload. Max message size: 1 MB.
---
## wzp-relay
**Path**: `crates/wzp-relay/src/`
### Re-exports (`lib.rs`)
```rust
pub use config::RelayConfig;
pub use handshake::accept_handshake;
pub use pipeline::{PipelineConfig, PipelineStats, RelayPipeline};
pub use session_mgr::{RelaySession, SessionId, SessionManager};
```
### RoomManager (`room.rs`)
```rust
pub type ParticipantId = u64;
pub struct RoomManager { /* rooms: HashMap<String, Room> */ }
```
Key methods:
- `RoomManager::new() -> Self`
- `RoomManager::join(&mut self, room_name: &str, addr: SocketAddr, transport: Arc<QuinnTransport>) -> ParticipantId`
- `RoomManager::leave(&mut self, room_name: &str, participant_id: ParticipantId)`
- `RoomManager::others(&self, room_name: &str, participant_id: ParticipantId) -> Vec<Arc<QuinnTransport>>`
- `RoomManager::room_size(&self, room_name: &str) -> usize`
- `RoomManager::list(&self) -> Vec<(String, usize)>`
```rust
/// Run the receive loop for one participant in a room (forwards to all others).
pub async fn run_participant(room_mgr: Arc<Mutex<RoomManager>>, room_name: String, participant_id: ParticipantId, transport: Arc<QuinnTransport>)
```
### RelayPipeline (`pipeline.rs`)
```rust
pub struct PipelineConfig { pub initial_profile: QualityProfile, pub jitter_target: usize, pub jitter_max: usize, pub jitter_min: usize }
pub struct PipelineStats { pub packets_received: u64, pub packets_forwarded: u64, pub packets_fec_recovered: u64, pub packets_lost: u64, pub profile_changes: u64 }
pub struct RelayPipeline { /* fec_encoder, fec_decoder, jitter, quality, profile, out_seq, stats */ }
```
Key methods:
- `RelayPipeline::new(config: PipelineConfig) -> Self`
- `RelayPipeline::ingest(&mut self, packet: MediaPacket) -> Vec<MediaPacket>` -- FEC decode + jitter pop
- `RelayPipeline::prepare_outbound(&mut self, packet: MediaPacket) -> Vec<MediaPacket>` -- assign seq + FEC encode
- `RelayPipeline::stats(&self) -> &PipelineStats`
- `RelayPipeline::profile(&self) -> QualityProfile`
### SessionManager (`session_mgr.rs`)
```rust
pub type SessionId = [u8; 16];
pub struct RelaySession { pub state: Session, pub upstream_pipeline: RelayPipeline, pub downstream_pipeline: RelayPipeline, pub profile: QualityProfile, pub last_activity_ms: u64 }
pub struct SessionManager { /* sessions: HashMap<SessionId, RelaySession>, max_sessions */ }
```
Key methods:
- `SessionManager::new(max_sessions: usize) -> Self`
- `SessionManager::create_session(&mut self, session_id: SessionId, config: PipelineConfig) -> Option<&mut RelaySession>`
- `SessionManager::get_session(&mut self, id: &SessionId) -> Option<&mut RelaySession>`
- `SessionManager::remove_session(&mut self, id: &SessionId) -> Option<RelaySession>`
- `SessionManager::expire_idle(&mut self, now_ms: u64, timeout_ms: u64) -> usize`
### Handshake (`handshake.rs`)
```rust
/// Accept the relay (callee) side of the cryptographic handshake.
pub async fn accept_handshake(transport: &dyn MediaTransport, seed: &[u8; 32]) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error>
```
### RelayConfig (`config.rs`)
```rust
pub struct RelayConfig {
pub listen_addr: SocketAddr, // default: 0.0.0.0:4433
pub remote_relay: Option<SocketAddr>, // None = room mode
pub max_sessions: usize, // default: 100
pub jitter_target_depth: usize, // default: 50
pub jitter_max_depth: usize, // default: 250
pub log_level: String, // default: "info"
}
```
---
## wzp-client
**Path**: `crates/wzp-client/src/`
### Re-exports (`lib.rs`)
```rust
#[cfg(feature = "audio")]
pub use audio_io::{AudioCapture, AudioPlayback};
pub use call::{CallConfig, CallDecoder, CallEncoder};
pub use handshake::perform_handshake;
```
### CallEncoder (`call.rs`)
```rust
pub struct CallEncoder { /* audio_enc, fec_enc, profile, seq, block_id, frame_in_block, timestamp_ms */ }
```
Key methods:
- `CallEncoder::new(config: &CallConfig) -> Self`
- `CallEncoder::encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error>` -- returns source + repair packets
- `CallEncoder::set_profile(&mut self, profile: QualityProfile) -> Result<(), anyhow::Error>`
### CallDecoder (`call.rs`)
```rust
pub struct CallDecoder { /* audio_dec, fec_dec, jitter, quality, profile */ }
```
Key methods:
- `CallDecoder::new(config: &CallConfig) -> Self`
- `CallDecoder::ingest(&mut self, packet: MediaPacket)` -- feeds FEC decoder and jitter buffer
- `CallDecoder::decode_next(&mut self, pcm: &mut [i16]) -> Option<usize>` -- pops from jitter, decodes
- `CallDecoder::profile(&self) -> QualityProfile`
- `CallDecoder::jitter_stats(&self) -> JitterStats`
### CallConfig (`call.rs`)
```rust
pub struct CallConfig {
pub profile: QualityProfile, // default: GOOD
pub jitter_target: usize, // default: 10
pub jitter_max: usize, // default: 250
pub jitter_min: usize, // default: 3
}
```
### Client Handshake (`handshake.rs`)
```rust
/// Perform the client (caller) side of the cryptographic handshake.
pub async fn perform_handshake(transport: &dyn MediaTransport, seed: &[u8; 32]) -> Result<Box<dyn CryptoSession>, anyhow::Error>
```
### Echo Test (`echo_test.rs`)
```rust
pub struct WindowResult { pub index: usize, pub time_offset_secs: f64, pub frames_sent: u32, pub frames_received: u32, pub loss_pct: f32, pub snr_db: f32, pub correlation: f32, pub peak_amplitude: i16, pub is_silent: bool }
pub struct EchoTestResult { pub duration_secs: f64, pub total_frames_sent: u64, pub total_frames_received: u64, pub overall_loss_pct: f32, pub windows: Vec<WindowResult>, /* ... */ }
pub async fn run_echo_test(transport: &(dyn MediaTransport + Send + Sync), duration_secs: u32, window_secs: f64) -> anyhow::Result<EchoTestResult>
pub fn print_report(result: &EchoTestResult)
```
### Audio I/O (`audio_io.rs`, requires `audio` feature)
```rust
pub struct AudioCapture { /* rx: mpsc::Receiver<Vec<i16>>, running: Arc<AtomicBool> */ }
pub struct AudioPlayback { /* tx: mpsc::SyncSender<Vec<i16>>, running: Arc<AtomicBool> */ }
```
Key methods:
- `AudioCapture::start() -> Result<Self, anyhow::Error>` -- opens default input at 48 kHz mono
- `AudioCapture::read_frame(&self) -> Option<Vec<i16>>` -- blocking, returns 960 samples
- `AudioCapture::stop(&self)`
- `AudioPlayback::start() -> Result<Self, anyhow::Error>` -- opens default output at 48 kHz mono
- `AudioPlayback::write_frame(&self, pcm: &[i16])`
- `AudioPlayback::stop(&self)`
### Benchmarks (`bench.rs`)
```rust
pub struct CodecResult { pub frames: usize, pub avg_encode_us: f64, pub avg_decode_us: f64, pub frames_per_sec: f64, pub compression_ratio: f64, /* ... */ }
pub struct FecResult { pub blocks_attempted: usize, pub blocks_recovered: usize, pub recovery_rate_pct: f64, /* ... */ }
pub struct CryptoResult { pub packets: usize, pub packets_per_sec: f64, pub megabytes_per_sec: f64, pub avg_latency_us: f64, /* ... */ }
pub struct PipelineResult { pub frames: usize, pub avg_e2e_latency_us: f64, pub overhead_ratio: f64, /* ... */ }
pub fn generate_sine_wave(freq_hz: f32, sample_rate: u32, num_samples: usize) -> Vec<i16>
pub fn bench_codec_roundtrip() -> CodecResult // 1000 frames Opus 24kbps
pub fn bench_fec_recovery(loss_pct: f32) -> FecResult // 100 blocks with simulated loss
pub fn bench_encrypt_decrypt() -> CryptoResult // 30000 packets ChaCha20
pub fn bench_full_pipeline() -> PipelineResult // 50 frames E2E
```
---
## wzp-web
**Path**: `crates/wzp-web/src/`
The web bridge binary. No public library API -- it is a standalone Axum server.
### Binary: `wzp-web`
- Serves static files from `crates/wzp-web/static/`
- WebSocket endpoint: `GET /ws/{room}` -- upgrades to WebSocket
- Each WebSocket client gets a QUIC connection to the relay with the room name as SNI
- Browser -> relay: WebSocket binary messages (960 Int16 samples as raw bytes) -> `CallEncoder` -> `MediaTransport::send_media()`
- Relay -> browser: `MediaTransport::recv_media()` -> `CallDecoder` -> WebSocket binary messages
### Static Files
- `static/index.html` -- web UI with room input, connect/disconnect, PTT, level meter
- `static/audio-processor.js` -- AudioWorklet for microphone capture (960-sample frames)
- `static/playback-processor.js` -- AudioWorklet for audio playback (ring buffer, 200ms max)

329
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,329 @@
# WarzonePhone Protocol Design & Architecture
## Network Topology
```
Lossy / censored link
◄──────────────────────►
┌────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
│ Client │─QUIC─│ Relay A │─QUIC─│ Relay B │─QUIC─│ Destination │
└────────┘ └─────────┘ └─────────┘ └─────────────┘
│ │ │ │
Encode Forward Forward Decode
FEC FEC FEC FEC
Encrypt (opaque) (opaque) Decrypt
```
In the simplest deployment a single relay serves as the meeting point (room mode, SFU). Clients connect directly to one relay, which forwards media to all other participants in the same room. For censorship-resistant links, two relays can be chained: a client-facing relay forwards all traffic to a remote relay via QUIC.
Room names are carried in the QUIC SNI field during the TLS handshake, so a single relay can host many independent rooms without additional signaling.
## Protocol Stack
```
┌──────────────────────────────────────────────┐
│ Application (Opus / Codec2 audio) │ wzp-codec
├──────────────────────────────────────────────┤
│ Redundancy (RaptorQ FEC + interleaving) │ wzp-fec
├──────────────────────────────────────────────┤
│ Crypto (ChaCha20-Poly1305 + AEAD) │ wzp-crypto
├──────────────────────────────────────────────┤
│ Transport (QUIC DATAGRAM + reliable stream) │ wzp-transport
├──────────────────────────────────────────────┤
│ Obfuscation (Phase 2 — trait defined) │ wzp-proto::ObfuscationLayer
└──────────────────────────────────────────────┘
```
Audio and FEC are end-to-end between caller and callee. The relay operates on opaque, encrypted, FEC-protected packets. Crypto keys are never shared with relays.
## Wire Format
### MediaHeader (12 bytes)
```
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
Byte 1: [FecRatioLo:6][unused:2]
Byte 2-3: Sequence number (big-endian u16)
Byte 4-7: Timestamp in ms since session start (big-endian u32)
Byte 8: FEC block ID (wrapping u8)
Byte 9: FEC symbol index within block
Byte 10: Reserved / flags
Byte 11: CSRC count (for future mixing)
```
Field details:
| Field | Bits | Description |
|-------|------|-------------|
| V | 1 | Protocol version (0 = v1) |
| T | 1 | 1 = FEC repair packet, 0 = source media |
| CodecID | 4 | Codec identifier (0=Opus24k, 1=Opus16k, 2=Opus6k, 3=Codec2_3200, 4=Codec2_1200) |
| Q | 1 | QualityReport trailer appended |
| FecRatio | 7 | FEC ratio encoded as 7-bit value (0-127 maps to 0.0-2.0) |
| Seq | 16 | Wrapping packet sequence number |
| Timestamp | 32 | Milliseconds since session start |
| FEC block | 8 | Source block ID (wrapping) |
| FEC symbol | 8 | Symbol index within the FEC block |
| Reserved | 8 | Reserved flags |
| CSRC count | 8 | Contributing source count (future) |
Defined in `crates/wzp-proto/src/packet.rs` as `MediaHeader`.
### QualityReport (4 bytes)
Appended to a media packet when the Q flag is set.
```
Byte 0: loss_pct — 0-255 maps to 0-100% loss
Byte 1: rtt_4ms — RTT in 4ms units (0-255 = 0-1020ms)
Byte 2: jitter_ms — Jitter in milliseconds
Byte 3: bitrate_cap — Max receive bitrate in kbps
```
Defined in `crates/wzp-proto/src/packet.rs` as `QualityReport`.
### MediaPacket
A complete media packet on the wire:
```
[MediaHeader: 12 bytes][Payload: variable][QualityReport: 4 bytes if Q=1]
```
Defined in `crates/wzp-proto/src/packet.rs` as `MediaPacket`.
### SignalMessage (reliable stream)
Signaling uses length-prefixed JSON over reliable QUIC bidirectional streams. Each message opens a new bidi stream, writes a 4-byte big-endian length prefix followed by the JSON payload, then finishes the send side.
Variants defined in `crates/wzp-proto/src/packet.rs`:
- `CallOffer` — identity_pub, ephemeral_pub, signature, supported_profiles
- `CallAnswer` — identity_pub, ephemeral_pub, signature, chosen_profile
- `IceCandidate` — NAT traversal candidate string
- `Rekey` — new_ephemeral_pub, signature
- `QualityUpdate` — report, recommended_profile
- `Ping` / `Pong` — timestamp_ms for RTT measurement
- `Hangup` — reason (Normal, Busy, Declined, Timeout, Error)
## FEC Strategy
WarzonePhone uses **RaptorQ fountain codes** (via the `raptorq` crate) for forward error correction. This is implemented in `crates/wzp-fec/`.
### Block Structure
Audio frames are grouped into FEC blocks. Each block contains a fixed number of source symbols (configured per quality profile). Each source symbol is a single encoded audio frame, zero-padded to a uniform 256-byte symbol size with a 2-byte little-endian length prefix.
### Encoding Process
1. Audio frames are added to the encoder as source symbols
2. When a block is full (`frames_per_block` symbols), repair symbols are generated
3. The repair ratio determines how many repair symbols: `ceil(num_source * ratio)`
4. Both source and repair packets are transmitted with the block ID and symbol index in the header
### Decoding Process
1. Received symbols (source or repair) are fed to the decoder keyed by block ID
2. The decoder attempts reconstruction when sufficient symbols arrive
3. RaptorQ can recover the full block from any `K` symbols out of `K + R` total (where K = source count, R = repair count)
4. Old blocks are expired via wrapping u8 distance
### Interleaving
The `Interleaver` spreads symbols from multiple FEC blocks across transmission slots in round-robin fashion. With depth=3, a burst loss of 6 consecutive packets damages at most 2 symbols per block instead of 6 symbols in one block.
### FEC Configuration by Quality Tier
| Tier | Frames/Block | Repair Ratio | Total Bandwidth Overhead |
|------|-------------|-------------|-------------------------|
| GOOD | 5 | 0.2 (20%) | 1.2x |
| DEGRADED | 10 | 0.5 (50%) | 1.5x |
| CATASTROPHIC | 8 | 1.0 (100%) | 2.0x |
## Adaptive Quality
Three quality tiers drive codec and FEC selection. The controller is implemented in `crates/wzp-proto/src/quality.rs` as `AdaptiveQualityController`.
### Tier Thresholds
| Tier | Loss | RTT | Codec | FEC Ratio |
|------|------|-----|-------|-----------|
| GOOD | < 10% | < 400ms | Opus 24kbps, 20ms frames | 0.2 |
| DEGRADED | 10-40% or 400-600ms | | Opus 6kbps, 40ms frames | 0.5 |
| CATASTROPHIC | > 40% or > 600ms | | Codec2 1200bps, 40ms frames | 1.0 |
### Hysteresis
- **Downgrade**: Triggers after 3 consecutive reports in a worse tier (fast reaction)
- **Upgrade**: Triggers after 10 consecutive reports in a better tier (slow, cautious)
- **Step limit**: Upgrades move only one tier at a time (Catastrophic -> Degraded -> Good)
- **History**: A sliding window of 20 recent reports is maintained for smoothing
- **Force mode**: Manual `force_profile()` disables adaptive logic entirely
### QualityProfile Constants
```rust
GOOD: Opus24k, fec=0.2, 20ms, 5 frames/block 28.8 kbps total
DEGRADED: Opus6k, fec=0.5, 40ms, 10 frames/block 9.0 kbps total
CATASTROPHIC: Codec2_1200, fec=1.0, 40ms, 8 frames/block 2.4 kbps total
```
## Encryption
Implemented in `crates/wzp-crypto/`.
### Identity Model (Warzone-Compatible)
- **Seed**: 32-byte random value (BIP39 mnemonic for backup)
- **Ed25519**: Derived via `HKDF(seed, "warzone-ed25519-identity")` -- signing/identity
- **X25519**: Derived via `HKDF(seed, "warzone-x25519-identity")` -- encryption
- **Fingerprint**: `SHA-256(Ed25519_pub)[:16]` -- 128-bit identifier
### Per-Call Key Exchange
1. Each side generates an ephemeral X25519 keypair
2. Ephemeral public keys are exchanged via `CallOffer`/`CallAnswer` signaling
3. Signatures are computed: `Ed25519_sign(ephemeral_pub || context_string)`
4. Shared secret: `X25519_DH(our_ephemeral_secret, peer_ephemeral_pub)`
5. Session key: `HKDF(shared_secret, "warzone-session-key")` -> 32 bytes
### Nonce Construction (12 bytes, not transmitted)
```
session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
```
- `session_id`: First 4 bytes of `SHA-256(session_key)`
- `direction`: 0 = Send, 1 = Recv
- Nonces are derived deterministically, saving 12 bytes per packet
### AEAD Encryption
- Algorithm: ChaCha20-Poly1305
- AAD: The 12-byte MediaHeader (authenticated but not encrypted)
- Tag: 16 bytes appended to ciphertext
- Overhead per packet: 16 bytes
### Rekeying
- Trigger: Every 2^16 packets (65536)
- Process: New ephemeral X25519 exchange, mixed with old key via HKDF
- Key evolution: `HKDF(old_key as salt, new_DH_result, "warzone-rekey")`
- Old key is zeroized after derivation (forward secrecy)
- Sequence counters reset to 0 after rekey
### Anti-Replay
- Sliding window of 1024 packets using a bitmap
- Sequence numbers too old (> 1024 behind highest seen) are rejected
- Handles u16 wrapping correctly (RFC 1982 serial number arithmetic)
- Implemented in `crates/wzp-crypto/src/anti_replay.rs` as `AntiReplayWindow`
## Jitter Buffer
Implemented in `crates/wzp-proto/src/jitter.rs` as `JitterBuffer`.
- **Structure**: BTreeMap keyed by sequence number for ordered playout
- **Target depth**: 50 packets (1 second) default
- **Max depth**: 250 packets (5 seconds at 20ms/frame)
- **Min depth**: 25 packets (0.5 seconds) before playout begins
- **Sequence wrapping**: RFC 1982 serial number arithmetic for u16
- **Duplicate handling**: Silently dropped
- **Late packets**: Packets arriving after their sequence has been played out are dropped
- **Overflow**: When buffer exceeds max depth, oldest packets are evicted
### Playout Results
- `Packet(MediaPacket)` -- normal delivery
- `Missing { seq }` -- gap detected, decoder should generate PLC
- `NotReady` -- buffer not yet filled to minimum depth
### Known Limitations
- No adaptive depth adjustment based on observed jitter (target_depth is configurable but not self-tuning in the current implementation)
- No timestamp-based playout scheduling (uses sequence-number ordering only)
- Jitter buffer drift has been observed during long echo tests
## Session State Machine
Defined in `crates/wzp-proto/src/session.rs`:
```
Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Active
|
Closed
```
- Media flows during both `Active` and `Rekeying` states
- Any state can transition to `Closed` via `Terminate` or `ConnectionLost`
- Invalid transitions produce a `TransitionError`
## Relay Modes
### Room Mode (Default, SFU)
- Clients join named rooms via QUIC SNI
- When a participant sends a packet, the relay forwards it to all other participants
- No transcoding -- packets are forwarded opaquely
- Rooms are auto-created when the first participant joins and auto-deleted when empty
- Managed by `RoomManager` in `crates/wzp-relay/src/room.rs`
### Forward Mode (`--remote`)
- All incoming traffic is forwarded to a remote relay via QUIC
- Two-pipeline architecture: upstream (client->remote) and downstream (remote->client)
- Each direction has its own `RelayPipeline` with FEC decode/encode and jitter buffering
- Intended for chaining relays across censored/lossy boundaries
### Relay Pipeline (Forward Mode)
Implemented in `crates/wzp-relay/src/pipeline.rs` as `RelayPipeline`:
```
Inbound: recv -> FEC decode -> jitter buffer -> pop
Outbound: packet -> assign seq -> FEC encode -> repair packets -> send
```
The pipeline does NOT decode/re-encode audio. It operates on FEC-protected packets, managing loss recovery and re-FEC-encoding for the next hop.
## Transport
Implemented in `crates/wzp-transport/` using QUIC via the `quinn` crate.
### QUIC Configuration
- ALPN protocol: `wzp`
- Idle timeout: 30 seconds
- Keep-alive interval: 5 seconds
- DATAGRAM extension enabled (for unreliable media)
- Datagram receive buffer: 64 KB
- Receive window: 256 KB
- Send window: 128 KB
- Stream receive window: 64 KB per stream
- Initial RTT estimate: 300ms (tuned for high-latency links)
### Media Transport
- **Unreliable media**: QUIC DATAGRAM frames (no retransmission, no head-of-line blocking)
- **Reliable signaling**: QUIC bidirectional streams with length-prefixed JSON framing
### Path Quality Monitoring
`PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` tracks:
- **Loss**: EWMA-smoothed percentage from sent/received packet counts
- **RTT**: EWMA-smoothed round-trip time (alpha=0.1)
- **Jitter**: EWMA of RTT variance (|current_rtt - previous_rtt|)
- **Bandwidth**: Estimated from bytes received over elapsed time
### Codec Selection by Tier
| Codec | Sample Rate | Frame Duration | Bitrate | Use Case |
|-------|------------|----------------|---------|----------|
| Opus24k | 48 kHz | 20ms (960 samples) | 24 kbps | Good conditions |
| Opus16k | 48 kHz | 20ms | 16 kbps | Moderate conditions |
| Opus6k | 48 kHz | 40ms (1920 samples) | 6 kbps | Degraded conditions |
| Codec2_3200 | 8 kHz | 20ms (160 samples) | 3.2 kbps | Poor conditions |
| Codec2_1200 | 8 kHz | 40ms (320 samples) | 1.2 kbps | Catastrophic conditions |
Opus operates at 48 kHz natively. When Codec2 is selected, the adaptive codec layer handles 48 kHz <-> 8 kHz resampling transparently using a simple linear resampler (6:1 decimation/interpolation).

168
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,168 @@
# WarzonePhone Detailed Design Decisions
## Why Opus + Codec2 (Not Just One)
The dual-codec architecture is driven by the extreme range of network conditions WarzonePhone targets:
**Opus** (24/16/6 kbps) is the clear choice for normal to degraded conditions. It offers excellent quality at moderate bitrates, has built-in inband FEC and DTX (discontinuous transmission), and the `audiopus` crate provides mature Rust bindings to libopus. Opus operates at 48 kHz natively.
**Codec2** (3200/1200 bps) is a narrowband vocoder designed specifically for HF radio links with extreme bandwidth constraints. At 1200 bps (1.2 kbps), it produces intelligible speech in only 6 bytes per 40ms frame -- roughly 20x lower bitrate than Opus at its minimum. The pure-Rust `codec2` crate means no C dependencies for this codec. Codec2 operates at 8 kHz, so the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently.
The `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions.
**Bandwidth comparison with FEC overhead:**
| Tier | Codec Bitrate | FEC Ratio | Total Bandwidth |
|------|--------------|-----------|----------------|
| GOOD | 24 kbps | 20% | ~28.8 kbps |
| DEGRADED | 6 kbps | 50% | ~9.0 kbps |
| CATASTROPHIC | 1.2 kbps | 100% | ~2.4 kbps |
At the catastrophic tier, the entire call (audio + FEC + headers) fits within approximately 3 kbps, which is viable even over severely degraded links.
## Why RaptorQ Over Reed-Solomon
**Reed-Solomon** is a classical block erasure code. It works well but has fixed-rate overhead: you must decide in advance how many repair symbols to generate, and decoding requires receiving exactly K of any K+R symbols.
**RaptorQ** (RFC 6330) is a fountain code with several advantages for VoIP:
1. **Rateless**: You can generate an arbitrary number of repair symbols on the fly. If conditions worsen mid-block, you can generate additional repair without re-encoding.
2. **Efficient decoding**: RaptorQ can decode from any K symbols with high probability (typically K + 1 or K + 2 suffice), compared to Reed-Solomon which requires exactly K.
3. **Lower computational complexity**: O(K) encoding and decoding time, compared to O(K^2) for Reed-Solomon. This matters for real-time audio at 50 frames/second.
4. **Variable block sizes**: The encoder handles 1-56403 source symbols per block (the WZP implementation uses 5-10, but the flexibility is there).
The `raptorq` crate (v2) provides a well-tested pure-Rust implementation. The WZP FEC layer adds length-prefixed padding (2-byte LE prefix + zero-pad to 256 bytes) so that variable-length audio frames can be recovered exactly.
**FEC bandwidth math at different loss rates:**
With 5 source frames per block:
- 20% repair (GOOD): 1 repair symbol. Survives loss of 1 out of 6 packets (16.7% loss).
- 50% repair (DEGRADED): 3 repair symbols. Survives loss of 3 out of 8 packets (37.5% loss).
- 100% repair (CATASTROPHIC): 5 repair symbols. Survives loss of 5 out of 10 packets (50% loss).
The benchmark (`wzp-bench --fec --loss 30`) dynamically scales the FEC ratio to survive the requested loss percentage.
## Why QUIC Over Raw UDP
Raw UDP would be simpler and lower-latency, but QUIC (via the `quinn` crate) provides:
1. **DATAGRAM frames**: Unreliable delivery without head-of-line blocking (RFC 9221). Media packets use this path, so they behave like UDP datagrams but benefit from QUIC's connection management.
2. **Reliable streams**: Signaling messages (CallOffer, CallAnswer, Rekey, Hangup) require reliable delivery. QUIC provides multiplexed streams without needing a separate TCP connection.
3. **Built-in congestion control**: QUIC's congestion control prevents overwhelming degraded links, which is important when chaining relays.
4. **Connection migration**: QUIC connections survive IP address changes (e.g., WiFi to cellular handoff), which is valuable for mobile clients.
5. **TLS 1.3 built-in**: The QUIC handshake provides encryption at the transport level. While WZP has its own end-to-end ChaCha20 layer, the QUIC TLS protects the header and signaling from eavesdroppers.
6. **NAT keepalive**: QUIC's built-in keep-alive (configured at 5-second intervals) maintains NAT bindings without application-level pings.
7. **Firewall traversal**: QUIC runs on UDP port 443 by default, which is commonly allowed through firewalls. The `wzp` ALPN protocol identifier distinguishes WZP traffic.
The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP (QUIC short header + DATAGRAM frame overhead).
## Why ChaCha20-Poly1305 Over AES-GCM
1. **Software performance**: ChaCha20-Poly1305 is faster than AES-GCM on hardware without AES-NI instructions. This matters for ARM devices (Android phones, Raspberry Pi relays, embedded systems) where AES hardware acceleration may be absent.
2. **Constant-time by design**: ChaCha20 uses only add-rotate-XOR operations, making it inherently resistant to timing side-channel attacks. AES-GCM implementations without hardware support often require careful constant-time implementation.
3. **Warzone messenger compatibility**: The existing Warzone messenger uses ChaCha20-Poly1305 for message encryption. Reusing the same primitive simplifies the security audit and allows key material to be shared across messaging and calling.
4. **16-byte overhead**: Both ChaCha20-Poly1305 and AES-128-GCM produce a 16-byte authentication tag. There is no size advantage to AES-GCM.
5. **AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data (AAD), ensuring the header is authenticated but not encrypted. This allows relays to read routing information (block ID, sequence number) without decrypting the payload.
## Why Star Dependency Graph (Parallel Development)
The workspace follows a strict star dependency pattern:
```
wzp-proto (hub)
/ | \ \
wzp-codec wzp-fec wzp-crypto wzp-transport
\ | / /
wzp-relay
wzp-client
wzp-web
```
- `wzp-proto` defines all trait interfaces and wire format types
- Each "leaf" crate (codec, fec, crypto, transport) depends only on `wzp-proto`
- No leaf crate depends on another leaf crate
- Integration crates (relay, client, web) depend on all leaves
This enables:
1. **Parallel development**: 5 agents/developers can work on 5 crates simultaneously with zero merge conflicts
2. **Independent testing**: Each crate has comprehensive tests that run without requiring other implementations
3. **Pluggability**: Any implementation can be swapped (e.g., replace RaptorQ with Reed-Solomon) by implementing the same trait
4. **Fast compilation**: Changes to one leaf only recompile that leaf and the integration crates, not other leaves
## Jitter Buffer Trade-offs
The jitter buffer must balance two competing goals:
**Lower latency** (smaller buffer):
- Better conversational interactivity
- Less memory usage
- But more vulnerable to jitter and reordering
**Higher quality** (larger buffer):
- More time to receive out-of-order packets
- More time for FEC recovery (repair packets may arrive after source packets)
- But adds perceptible delay to the conversation
The default configuration:
- Target: 10 packets (200ms) for the client, 50 packets (1s) for the relay
- Minimum: 3 packets (60ms) before playout begins (client), 25 packets (500ms) for relay
- Maximum: 250 packets (5s) absolute cap
The relay uses a deeper buffer because it needs to absorb jitter from the lossy inter-relay link. The client uses a shallower buffer for lower latency since it is on the last hop.
**Known issue**: The current jitter buffer does not adapt its depth based on observed jitter. It uses sequence-number ordering only, without timestamp-based playout scheduling. This can lead to drift during long calls, as observed in echo tests.
## Browser Audio: AudioWorklet vs ScriptProcessorNode
The web bridge (`crates/wzp-web/static/`) uses AudioWorklet as the primary audio I/O mechanism, with ScriptProcessorNode as a fallback.
**AudioWorklet** (preferred):
- Runs on a dedicated audio rendering thread
- Lower latency (no main-thread round-trip)
- Consistent 128-sample callback timing
- Supported in Chrome 66+, Firefox 76+, Safari 14.1+
**ScriptProcessorNode** (fallback):
- Runs on the main thread via `onaudioprocess` callback
- Higher latency, potential glitches from main-thread GC pauses
- Deprecated by the Web Audio specification
- Used when AudioWorklet is not available
Both paths accumulate Float32 samples into 960-sample (20ms) Int16 frames before sending via WebSocket, matching the WZP codec frame size.
**Playback** uses an AudioWorklet with a ring buffer capped at 200ms (9600 samples at 48 kHz). When the buffer exceeds this limit, old samples are dropped to prevent unbounded drift. The fallback path uses scheduled `AudioBufferSourceNode` instances.
## Room Mode: SFU vs MCU Trade-offs
WarzonePhone implements an **SFU** (Selective Forwarding Unit) architecture:
**SFU** (implemented):
- Relay forwards each participant's packets to all other participants unchanged
- No transcoding -- the relay never decodes or re-encodes audio
- O(N) bandwidth at the relay for N participants (each packet is sent N-1 times)
- Each client receives separate streams from each other participant
- Client must mix/decode multiple streams locally
- Lower relay CPU usage (no transcoding)
- End-to-end encryption is preserved (relay never sees plaintext)
**MCU** (not implemented, for comparison):
- Relay would decode all streams, mix them, and re-encode a single combined stream
- O(1) bandwidth to each client (receives one mixed stream)
- Requires the relay to have codec keys (breaks E2E encryption)
- Higher relay CPU (decoding N streams + mixing + re-encoding)
- Audio quality loss from re-encoding
The SFU choice is driven by the E2E encryption requirement: since relays never have access to the audio codec keys, they cannot decode, mix, or re-encode. The current room implementation in `crates/wzp-relay/src/room.rs` forwards received datagrams to all other participants in the room with best-effort delivery -- if one send fails, the relay continues to the next participant.

204
docs/EXTENSIBILITY.md Normal file
View File

@@ -0,0 +1,204 @@
# WarzonePhone Extension Points & Future Features
## Trait-Based Architecture
The protocol is designed around trait interfaces defined in `crates/wzp-proto/src/traits.rs`. Any implementation that satisfies the trait contract can be plugged in without modifying other crates.
### Adding a New Audio Codec
Implement `AudioEncoder` and `AudioDecoder` from `wzp_proto::traits`:
```rust
pub trait AudioEncoder: Send + Sync {
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError>;
fn codec_id(&self) -> CodecId;
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
fn max_frame_bytes(&self) -> usize;
fn set_inband_fec(&mut self, _enabled: bool) {}
fn set_dtx(&mut self, _enabled: bool) {}
}
pub trait AudioDecoder: Send + Sync {
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError>;
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError>;
fn codec_id(&self) -> CodecId;
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
}
```
Steps:
1. Add a new variant to `CodecId` in `crates/wzp-proto/src/codec_id.rs` (uses 4-bit wire encoding, currently 5 of 16 values used)
2. Implement `AudioEncoder` and `AudioDecoder` for your codec
3. Register the codec in `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs`
4. Add a `QualityProfile` constant for the new codec
### Adding a New FEC Scheme
Implement `FecEncoder` and `FecDecoder` from `wzp_proto::traits`:
```rust
pub trait FecEncoder: Send + Sync {
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
fn finalize_block(&mut self) -> Result<u8, FecError>;
fn current_block_id(&self) -> u8;
fn current_block_size(&self) -> usize;
}
pub trait FecDecoder: Send + Sync {
fn add_symbol(&mut self, block_id: u8, symbol_index: u8, is_repair: bool, data: &[u8]) -> Result<(), FecError>;
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError>;
fn expire_before(&mut self, block_id: u8);
}
```
For example, a Reed-Solomon implementation would maintain the same block/symbol structure but use a different coding algorithm internally. The FEC block ID and symbol index fields in `MediaHeader` support any scheme that fits the block/symbol model.
### Adding a New Transport
Implement `MediaTransport` from `wzp_proto::traits`:
```rust
#[async_trait]
pub trait MediaTransport: Send + Sync {
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError>;
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError>;
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError>;
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError>;
fn path_quality(&self) -> PathQuality;
async fn close(&self) -> Result<(), TransportError>;
}
```
A raw UDP transport, a WebRTC data channel transport, or a TCP tunnel transport could all implement this trait.
## Obfuscation Layer (Phase 2)
The `ObfuscationLayer` trait is defined in `crates/wzp-proto/src/traits.rs` but not yet implemented:
```rust
pub trait ObfuscationLayer: Send + Sync {
fn obfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
fn deobfuscate(&mut self, data: &[u8], out: &mut Vec<u8>) -> Result<(), ObfuscationError>;
}
```
Planned implementations:
- **TLS-in-TLS**: Wrap QUIC traffic inside a TLS connection to port 443, making it look like ordinary HTTPS
- **HTTP/2 mimicry**: Frame QUIC packets as HTTP/2 data frames
- **Random padding**: Add random-length padding to defeat traffic analysis
- **Domain fronting**: Use CDN infrastructure to hide the true destination
The obfuscation layer sits between the crypto layer and the transport layer in the protocol stack, wrapping encrypted packets before transmission.
## FeatherChat / Warzone Messenger Integration
As described in `docs/featherchat.md`, WarzonePhone is designed to integrate with the existing Warzone messenger.
### Shared Identity Model
Both WarzonePhone and Warzone use the same identity derivation:
- 32-byte seed (BIP39 mnemonic backup)
- HKDF with context strings: `"warzone-ed25519-identity"` and `"warzone-x25519-identity"`
- Ed25519 for signing, X25519 for encryption
- Fingerprint: `SHA-256(Ed25519_pub)[:16]`
This is implemented in `crates/wzp-crypto/src/handshake.rs` as `WarzoneKeyExchange::from_identity_seed()`.
### Signaling via Existing WebSocket
Call initiation flows through the Warzone messenger's existing WebSocket connection:
1. Caller looks up callee via `@alias`, federated address, or raw fingerprint
2. Caller sends `WireMessage::CallOffer` through the existing message channel
3. Callee receives the offer and responds with `WireMessage::CallAnswer`
4. Both sides establish a direct QUIC connection to the relay using ephemeral keys from the signaling exchange
The `SignalMessage::CallOffer` and `SignalMessage::CallAnswer` variants in `crates/wzp-proto/src/packet.rs` carry the same fields needed for this flow.
### Key Derivation from Existing Shared Secret
When two Warzone users already have an X3DH shared secret from their messaging session, call keys can be derived from it:
- `HKDF(x3dh_shared_secret, "warzone-call-session")` -> 32-byte session key
- Or: fresh ephemeral exchange per call (current implementation) for independent forward secrecy
### Unified Addressing
The Warzone addressing system resolves user identities across multiple namespaces:
| Method | Format | Resolution |
|--------|--------|------------|
| Local alias | `@manwe` | Server resolves to fingerprint |
| Federated | `@manwe.b1.example.com` | DNS TXT record -> fingerprint + endpoint |
| ENS | `@manwe.eth` | Ethereum address -> fingerprint (planned) |
| Raw fingerprint | `xxxx:xxxx:...` | Direct lookup |
A user calls `@manwe` the same way they message `@manwe`.
## Authentication: Caller Verification Before Bridging
Currently, relays forward packets without verifying caller identity. To add authentication:
1. **Relay-side handshake**: The relay receives the `CallOffer`, verifies the Ed25519 signature, and checks the caller's identity against an allowlist before accepting the connection.
2. **Implementation point**: `crates/wzp-relay/src/handshake.rs` already implements `accept_handshake()` which performs signature verification. To gate admission, add an authorization check after signature verification.
3. **Token-based auth**: Add a `token: Vec<u8>` field to `CallOffer` containing a relay-issued authentication token (e.g., signed by the relay operator's key).
## Multi-Relay Mesh
The current two-relay chain (`--remote` flag) can be extended to a multi-hop mesh:
```
Client -> Relay A -> Relay B -> Relay C -> Destination
```
Each hop uses the relay pipeline (FEC decode -> jitter buffer -> FEC re-encode) to absorb loss on each link independently. This requires:
1. Relay discovery and route selection (not yet implemented)
2. Per-hop FEC parameters (each link may have different loss characteristics)
3. Cumulative latency management (each hop adds jitter buffer delay)
## Video Support
The trait architecture supports video by adding:
1. **Video codec trait**: Similar to `AudioEncoder`/`AudioDecoder` but for video frames
2. **Codec choices**: AV1 (best compression, higher CPU), VP9 SVC (scalable, moderate CPU)
3. **Separate FEC strategy**: Video frames are larger and more critical (I-frames vs P-frames need different protection levels)
4. **SVC (Scalable Video Coding)**: With VP9 SVC, the relay can drop enhancement layers without transcoding, adapting video quality to each receiver's bandwidth
Video would add new `CodecId` variants and a separate `QualityProfile` for video parameters.
## Android Native Client
The workspace is designed with Android in mind (`wzp-client` description mentions "for Android (JNI) and Windows desktop"):
1. **JNI bindings**: Use `jni` crate or `uniffi` to expose `CallEncoder`, `CallDecoder`, and `MediaTransport` to Kotlin/Java
2. **Audio I/O**: Android uses AAudio or OpenSL ES instead of cpal
3. **Build**: Cross-compile with `cargo ndk` targeting `aarch64-linux-android` and `armv7-linux-androideabi`
4. **Permissions**: `RECORD_AUDIO`, `INTERNET`, `WAKE_LOCK`
## STUN/TURN NAT Traversal Integration
The `SignalMessage::IceCandidate` variant is already defined for NAT traversal:
```rust
IceCandidate { candidate: String }
```
Integration would involve:
1. STUN server queries to discover the client's public IP/port
2. ICE candidate exchange via the signaling channel
3. TURN relay fallback when direct UDP is blocked
4. Integration with the existing QUIC transport (QUIC can traverse NATs via its connection migration)
## Bandwidth Estimation and Adaptive Bitrate
The `PathMonitor` in `crates/wzp-transport/src/path_monitor.rs` already estimates bandwidth from observed packet rates. To close the loop:
1. Feed `PathMonitor::quality()` into `AdaptiveQualityController::observe()` as `QualityReport` values
2. The controller will trigger tier transitions when conditions change
3. Propagate the new `QualityProfile` to both encoder (codec switch) and FEC (ratio change)
4. Signal the peer via `SignalMessage::QualityUpdate` so both sides switch simultaneously
The framework is in place; the missing piece is the integration wiring in the client's main loop to periodically generate quality reports from path metrics.

File diff suppressed because it is too large Load Diff

193
docs/PROGRESS.md Normal file
View File

@@ -0,0 +1,193 @@
# WarzonePhone Development Progress Report
## Phase 1: Protocol Core
**Scope**: Define the protocol types, traits, and core logic in `wzp-proto`.
**What was built**:
- Wire format types: `MediaHeader` (12-byte compact binary), `QualityReport` (4 bytes), `MediaPacket`, `SignalMessage` (8 variants)
- Trait definitions: `AudioEncoder`, `AudioDecoder`, `FecEncoder`, `FecDecoder`, `CryptoSession`, `KeyExchange`, `MediaTransport`, `ObfuscationLayer`, `QualityController`
- `CodecId` enum with 5 variants (Opus24k/16k/6k, Codec2_3200/1200) and 4-bit wire encoding
- `QualityProfile` with 3 preset tiers (GOOD, DEGRADED, CATASTROPHIC)
- `AdaptiveQualityController` with hysteresis (3-down/10-up thresholds, sliding window of 20 reports)
- `JitterBuffer` with BTreeMap-based reordering, wrapping sequence arithmetic, min/max/target depth
- `Session` state machine (Idle -> Connecting -> Handshaking -> Active <-> Rekeying -> Closed)
- Full error type hierarchy (`CodecError`, `FecError`, `CryptoError`, `TransportError`, `ObfuscationError`)
**Tests**: 27 tests across packet roundtrip, quality controller, jitter buffer, session state machine, sequence wrapping
## Phase 2: Implementation Crates (Parallel)
**Scope**: Implement the 4 leaf crates against the trait interfaces, in parallel.
### wzp-codec
- Opus encoder/decoder via `audiopus` (48 kHz mono, VoIP application mode, inband FEC, DTX)
- Codec2 encoder/decoder via pure-Rust `codec2` crate (3200 and 1200 bps modes)
- `AdaptiveEncoder`/`AdaptiveDecoder` wrapping both codecs with transparent switching
- Linear resampler for 48 kHz <-> 8 kHz conversion (box filter downsampling, linear interpolation upsampling)
- All callers work with 48 kHz PCM regardless of active codec
### wzp-fec
- `RaptorQFecEncoder`: accumulates source symbols with 2-byte length prefix + zero padding to 256-byte symbol size
- `RaptorQFecDecoder`: multi-block concurrent decoding with HashMap-based block tracking
- `Interleaver`: round-robin temporal interleaving across multiple FEC blocks
- `BlockManager`: encoder-side (Building/Pending/Sent/Acknowledged) and decoder-side (Assembling/Complete/Expired) lifecycle tracking
- `AdaptiveFec`: maps `QualityProfile` to FEC parameters
- Factory function `create_fec_pair()` for convenient encoder/decoder creation
### wzp-crypto
- `WarzoneKeyExchange`: identity seed -> HKDF -> Ed25519 + X25519, ephemeral generation, signature, verification, session derivation
- `ChaChaSession`: ChaCha20-Poly1305 AEAD with deterministic nonce construction (session_id + seq + direction)
- `RekeyManager`: triggers rekey every 2^16 packets, HKDF mixing of old key + new DH, zeroization of old key
- `AntiReplayWindow`: 1024-packet sliding window bitmap with u16 wrapping support
- Nonce module: 12-byte nonce layout (4-byte session_id + 4-byte seq BE + 1-byte direction + 3-byte padding)
### wzp-transport
- `QuinnTransport`: implements `MediaTransport` trait over quinn QUIC connection
- DATAGRAM frames for unreliable media, bidirectional streams for reliable signaling
- Length-prefixed JSON framing (4-byte BE length + serde_json payload) for signaling
- VoIP-tuned QUIC configuration (30s idle timeout, 5s keepalive, conservative flow control, 300ms initial RTT)
- `PathMonitor`: EWMA-smoothed loss, RTT, jitter, bandwidth estimation
- Connection lifecycle: `create_endpoint()`, `connect()`, `accept()`
- Self-signed certificate generation for testing
**Tests**: 55+ tests across all 4 crates (codec roundtrip, FEC recovery at 30/50/70% loss, crypto encrypt/decrypt, handshake, anti-replay, transport serialization, path monitoring)
## Phase 3: Integration (Relay + Client)
**Scope**: Wire all layers together into working relay and client binaries.
### wzp-relay
- Room mode (SFU): `RoomManager` with named rooms, auto-create/auto-delete, per-participant forwarding
- Forward mode: two-pipeline architecture (upstream/downstream) with FEC re-encode and jitter buffering
- `RelayPipeline`: ingest -> FEC decode -> jitter buffer -> pop -> FEC re-encode -> send
- `SessionManager`: tracks active sessions, max session limit, idle expiration
- Relay-side handshake: `accept_handshake()` with signature verification and profile negotiation
- `RelayConfig`: configurable listen address, remote relay, max sessions, jitter parameters
- Periodic stats logging (upstream/downstream packet counts)
### wzp-client
- `CallEncoder`: PCM -> audio encode -> FEC block management -> source + repair MediaPackets
- `CallDecoder`: MediaPacket -> FEC decode -> jitter buffer -> audio decode -> PCM
- Client-side handshake: `perform_handshake()` with ephemeral key exchange and signature
- CLI modes: silence test, tone generation (440 Hz), file send, file record, echo test, live audio
- `AudioCapture`/`AudioPlayback` via cpal (behind `audio` feature flag), supporting both i16 and f32 sample formats
- Automated echo test with windowed analysis (loss, SNR, correlation, degradation detection)
- Benchmark suite: codec roundtrip (1000 frames), FEC recovery (100 blocks), crypto throughput (30000 packets), full pipeline (50 frames)
**Tests**: 25+ tests for pipeline creation, packet generation, FEC repair generation, session management
## Phase 4: Web Bridge, Rooms, PTT, TLS
**Scope**: Browser support and multi-party calling.
### wzp-web
- Axum-based HTTP/WebSocket server
- Browser audio capture via AudioWorklet (primary) with ScriptProcessorNode fallback
- Browser audio playback via AudioWorklet with scheduled BufferSource fallback
- Room-based routing: `/ws/<room-name>` WebSocket endpoint
- Room name passed as QUIC SNI to the relay
- Push-to-talk (PTT) support: button, mouse hold, spacebar
- Audio level meter in the UI
- TLS support via `--tls` flag with self-signed certificate generation
- Auto-reconnection on WebSocket disconnect
- Static file serving for the web UI
## Current Status
### What Works
- Full encode/decode pipeline: PCM -> Opus/Codec2 -> FEC -> MediaPacket -> FEC decode -> audio decode -> PCM
- Adaptive codec switching between Opus and Codec2 (including resampling)
- RaptorQ FEC recovery at various loss rates (tested up to 50% loss)
- ChaCha20-Poly1305 encryption with deterministic nonces
- X25519 key exchange with Ed25519 identity signatures
- QUIC transport with DATAGRAM frames for media and reliable streams for signaling
- Single relay echo mode (connectivity test)
- Multi-party room calls (SFU)
- Two-relay forwarding chain
- Web browser audio via WebSocket bridge
- File-based send/record for testing
- Live microphone/speaker mode (with `audio` feature)
- Push-to-talk in the web UI
- Automated echo quality test with windowed analysis
- Performance benchmarks
- Cross-compilation CI for amd64, arm64, armv7
### Known Issues
- **Jitter buffer drift**: During long echo tests, the jitter buffer depth can drift because there is no adaptive depth adjustment based on observed jitter. The buffer uses sequence-number ordering only, without timestamp-based playout scheduling.
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics.
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
- **No certificate verification**: The QUIC client config uses `SkipServerVerification` (accepts any certificate). This is intentional for testing but must be addressed for production deployments.
## Test Coverage
119 tests across 7 crates (wzp-web has no Rust tests):
| Crate | Test Files | Test Count |
|-------|-----------|------------|
| wzp-proto | 5 | 27 |
| wzp-codec | 3 | 24 |
| wzp-fec | 5 | 21 |
| wzp-crypto | 5 | 21 |
| wzp-transport | 3 | 12 |
| wzp-relay | 4 | 10 |
| wzp-client | 3 | 8 |
| **Total** | **28** | **119** |
Tests cover:
- Wire format roundtrip (header, quality report, full packet)
- Codec encode/decode for all 5 codec IDs
- Adaptive codec switching (Opus <-> Codec2)
- FEC recovery at 0%, 30%, 50% loss
- Concurrent FEC block decoding
- Full key exchange handshake (Alice/Bob derive same session key)
- Encrypt/decrypt roundtrip, wrong-key rejection, wrong-AAD rejection
- Anti-replay window: sequential, out-of-order, duplicate, wrapping
- Rekeying: interval trigger, key derivation, old key zeroization
- QUIC datagram serialization roundtrip
- Path quality EWMA smoothing
- Jitter buffer: ordering, reordering, missing packets, min depth, duplicates
- Session state machine: happy path, invalid transitions, connection loss
- Pipeline packet generation and FEC repair
- Benchmark correctness (codec, FEC, crypto, pipeline)
## Performance Benchmarks
Run with `wzp-bench --all`. Representative results (Apple M-series, single core):
### Codec Roundtrip (Opus 24kbps)
- 1000 frames of 440 Hz sine wave (20ms each, 48 kHz mono)
- Encode: ~20-40 us/frame average
- Decode: ~10-20 us/frame average
- Throughput: >10,000 frames/sec (200x real-time)
- Compression ratio: ~30x (960 i16 samples = 1920 bytes -> ~60 bytes encoded)
### FEC Recovery
- 100 blocks of 5 frames each
- At 20% loss: ~100% recovery rate
- At 30% loss with scaled FEC ratio: >95% recovery rate
### Crypto (ChaCha20-Poly1305)
- 30,000 packets (60/120/256 byte payloads)
- Throughput: >500,000 packets/sec
- Bandwidth: >50 MB/sec
- Average latency: <2 us per encrypt+decrypt cycle
### Full Pipeline (E2E)
- 50 frames through CallEncoder -> CallDecoder
- Average E2E latency: ~100-200 us/frame (codec + FEC, no network)
- Wire overhead ratio: ~0.05-0.10x of raw PCM (high compression from Opus)
## Deployment Status
- **Local testing**: All modes tested on localhost (single relay, room mode, forward mode, web bridge)
- **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries
- **CI**: Gitea workflow defined for amd64/arm64/armv7 builds
- **Production**: Not yet deployed to production networks

269
docs/USAGE.md Normal file
View File

@@ -0,0 +1,269 @@
# WarzonePhone Usage Guide
## Prerequisites
- **Rust** 1.85+ (2024 edition)
- **System libraries** (Linux): `cmake`, `pkg-config`, `libasound2-dev` (for audio feature)
- **System libraries** (macOS): Xcode command line tools (CoreAudio is included)
## Building from Source
### All Binaries (Headless)
```bash
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
```
### Client with Live Audio Support
```bash
cargo build --release --bin wzp-client --features audio
```
### Run All Tests
```bash
cargo test --workspace --lib
```
### Building for Linux (Remote Build Script)
The project includes `scripts/build-linux.sh` which provisions a temporary Hetzner Cloud VPS, builds all binaries, and downloads them:
```bash
# Requires: hcloud CLI authenticated, SSH key "wz" registered
./scripts/build-linux.sh
# Outputs to: target/linux-x86_64/
```
The build script produces:
- `wzp-relay` -- relay daemon
- `wzp-client` -- headless client
- `wzp-client-audio` -- client with mic/speaker support (needs libasound2)
- `wzp-web` -- web bridge server
- `wzp-bench` -- performance benchmarks
### CI Build
The `.gitea/workflows/build.yml` workflow builds release binaries for:
- Linux amd64
- Linux arm64 (cross-compiled)
- Linux armv7 (cross-compiled)
Triggered on version tags (`v*`) or manual dispatch.
---
## Binaries and CLI Flags
### wzp-relay
The relay daemon that forwards media between clients.
```
Usage: wzp-relay [--listen <addr>] [--remote <addr>]
Options:
--listen <addr> Listen address (default: 0.0.0.0:4433)
--remote <addr> Remote relay for forwarding (disables room mode)
```
**Room mode** (default): Clients join rooms by name. Packets are forwarded to all other participants in the same room (SFU model). Room name comes from QUIC SNI or defaults to "default".
**Forward mode** (`--remote`): All traffic is forwarded to a remote relay. Used for chaining relays across lossy/censored links.
### wzp-client
The CLI test client for sending and receiving audio.
```
Usage: wzp-client [options] [relay-addr]
Options:
--live Live mic/speaker mode (requires --features audio)
--send-tone <secs> Send a 440Hz test tone for N seconds
--send-file <file> Send a raw PCM file (48kHz mono s16le)
--record <file.raw> Record received audio to raw PCM file
--echo-test <secs> Run automated echo quality test
```
Default relay address: `127.0.0.1:4433`
### wzp-bench
Performance benchmark tool.
```
Usage: wzp-bench [OPTIONS]
Options:
--codec Run codec roundtrip benchmark (Opus 24kbps, 1000 frames)
--fec Run FEC recovery benchmark (100 blocks)
--crypto Run encryption benchmark (30000 packets)
--pipeline Run full pipeline benchmark (50 frames E2E)
--all Run all benchmarks (default if no flag given)
--loss <N> FEC loss percentage for --fec (default: 20)
```
### wzp-web
Web bridge server that connects browser audio via WebSocket to the relay.
```
Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]
Options:
--port <port> HTTP/WebSocket port (default: 8080)
--relay <addr> WZP relay address (default: 127.0.0.1:4433)
--tls Enable HTTPS (self-signed cert, required for mic on Android/remote)
```
Room URLs: `http://host:port/<room-name>` or `https://host:port/<room-name>` with `--tls`.
---
## Deployment Examples
### 1. Single Relay Echo Test
Start a relay, send a tone, and record the echo:
```bash
# Terminal 1: Start relay
wzp-relay --listen 0.0.0.0:4433
# Terminal 2: Send 10s of 440Hz tone and record the response
wzp-client --send-tone 10 --record echo.raw 127.0.0.1:4433
```
Play the recording:
```bash
ffplay -f s16le -ar 48000 -ac 1 echo.raw
```
### 2. Two-Party Call Through Relay
Two clients connected to the same relay default room:
```bash
# Terminal 1: Relay
wzp-relay
# Terminal 2: Client A — send tone
wzp-client --send-tone 30 127.0.0.1:4433
# Terminal 3: Client B — record
wzp-client --record call.raw 127.0.0.1:4433
```
### 3. Multi-Party Room Call
Multiple clients join the same named room. The relay QUIC SNI determines the room. With the web bridge, room names come from the URL path:
```bash
# Relay
wzp-relay
# Web bridge
wzp-web --port 8080 --relay 127.0.0.1:4433
# Browser clients open:
# http://localhost:8080/my-room
# All clients on /my-room hear each other.
```
### 4. Two-Relay Chain (Lossy Link)
Chain two relays for crossing a censored or lossy network boundary:
```bash
# Destination-side relay (receives from the forward relay)
wzp-relay --listen 0.0.0.0:4433
# Client-side relay (forwards to the destination relay)
wzp-relay --listen 0.0.0.0:5433 --remote <dest-relay-ip>:4433
# Client connects to the client-side relay
wzp-client --send-tone 10 127.0.0.1:5433
```
### 5. Web Browser Call with TLS
TLS is required for microphone access on non-localhost origins (Android, remote browsers):
```bash
# Relay
wzp-relay
# Web bridge with TLS (self-signed certificate)
wzp-web --port 8443 --relay 127.0.0.1:4433 --tls
# Open in browser (accept self-signed cert warning):
# https://your-server:8443/room-name
```
The web UI supports:
- Open mic (default) and push-to-talk modes
- PTT via on-screen button, mouse hold, or spacebar
- Audio level meter
- Auto-reconnection on disconnect
### 6. Automated Echo Quality Test
```bash
wzp-relay &
wzp-client --echo-test 30 127.0.0.1:4433
```
Produces a windowed analysis report showing loss percentage, SNR, correlation, and detects quality degradation trends over time.
### 7. Live Audio Call (requires `--features audio`)
```bash
wzp-relay &
# Terminal 2
wzp-client --live 127.0.0.1:4433
# Terminal 3
wzp-client --live 127.0.0.1:4433
```
Both clients capture from the default microphone and play received audio through the default speaker. Press Ctrl+C to stop.
---
## Audio File Format
All raw PCM files use:
- Sample rate: **48 kHz**
- Channels: **1** (mono)
- Sample format: **signed 16-bit little-endian** (s16le)
### ffmpeg Conversion Commands
```bash
# WAV to raw PCM
ffmpeg -i input.wav -f s16le -ar 48000 -ac 1 output.raw
# MP3 to raw PCM
ffmpeg -i input.mp3 -f s16le -ar 48000 -ac 1 output.raw
# Raw PCM to WAV
ffmpeg -f s16le -ar 48000 -ac 1 -i input.raw output.wav
# Play raw PCM directly
ffplay -f s16le -ar 48000 -ac 1 file.raw
# or with the newer channel layout syntax:
ffplay -f s16le -ar 48000 -ch_layout mono file.raw
```
### Sending an Audio File
```bash
# Convert your audio to raw PCM first
ffmpeg -i song.mp3 -f s16le -ar 48000 -ac 1 song.raw
# Send through relay
wzp-client --send-file song.raw 127.0.0.1:4433
```

110
scripts/build-linux.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone Linux x86_64 release binaries using a Hetzner Cloud VPS.
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
#
# Usage: ./scripts/build-linux.sh
#
# Outputs: target/linux-x86_64/wzp-relay, wzp-client, wzp-bench
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_NAME="wzp-builder-$(date +%s)"
SERVER_TYPE="cx33"
IMAGE="debian-12"
REMOTE_USER="root"
OUTPUT_DIR="target/linux-x86_64"
echo "=== WarzonePhone Linux Build ==="
# Ensure server gets deleted on any exit (success or failure)
cleanup() {
if [ -n "${SERVER_NAME:-}" ]; then
echo " Cleaning up server $SERVER_NAME..."
hcloud server delete "$SERVER_NAME" 2>/dev/null || true
fi
rm -f /tmp/wzp-src.tar.gz
}
trap cleanup EXIT
# 1. Create the build server
echo "[1/7] Creating Hetzner server..."
hcloud server create \
--name "$SERVER_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location fsn1 \
--quiet
SERVER_IP=$(hcloud server ip "$SERVER_NAME")
echo " Server: $SERVER_NAME @ $SERVER_IP"
# SSH options: skip host key check, use our key
SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -i $SSH_KEY_PATH $REMOTE_USER@$SERVER_IP"
SCP="scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $SSH_KEY_PATH"
# 2. Wait for SSH to come up
echo "[2/7] Waiting for SSH..."
for i in $(seq 1 30); do
if $SSH "echo ok" &>/dev/null; then
break
fi
sleep 2
done
# 3. Install build dependencies
echo "[3/7] Installing build dependencies..."
$SSH "apt-get update -qq && apt-get install -y -qq build-essential cmake pkg-config libasound2-dev curl git > /dev/null 2>&1"
# 4. Install Rust
echo "[4/7] Installing Rust..."
$SSH "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
# 5. Upload source code
echo "[5/7] Uploading source code..."
# Create a tarball excluding target/ and .git/
tar czf /tmp/wzp-src.tar.gz \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
-C /Users/manwe/CascadeProjects/warzonePhone .
$SCP /tmp/wzp-src.tar.gz "$REMOTE_USER@$SERVER_IP:/root/wzp-src.tar.gz"
$SSH "mkdir -p /root/warzonePhone && tar xzf /root/wzp-src.tar.gz -C /root/warzonePhone"
# 6. Build release binaries (headless + audio variants)
echo "[6/8] Building all binaries..."
$SSH "source ~/.cargo/env && cd /root/warzonePhone && cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web 2>&1" | tail -3
echo "[7/8] Building audio-enabled client..."
$SSH "source ~/.cargo/env && cd /root/warzonePhone && cargo build --release --bin wzp-client --features audio 2>&1" | tail -3
$SSH "cp /root/warzonePhone/target/release/wzp-client /root/warzonePhone/target/release/wzp-client-audio"
$SSH "source ~/.cargo/env && cd /root/warzonePhone && cargo build --release --bin wzp-client 2>&1" | tail -1
# 8. Download binaries + static files
echo "[8/8] Downloading binaries..."
mkdir -p "$OUTPUT_DIR/static"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-relay" "$OUTPUT_DIR/wzp-relay"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-client" "$OUTPUT_DIR/wzp-client"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-client-audio" "$OUTPUT_DIR/wzp-client-audio"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-bench" "$OUTPUT_DIR/wzp-bench"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-web" "$OUTPUT_DIR/wzp-web"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/crates/wzp-web/static/index.html" "$OUTPUT_DIR/static/index.html"
# Show results (server is deleted by EXIT trap)
echo ""
echo "=== Build Complete ==="
ls -lh "$OUTPUT_DIR"/wzp-*
echo ""
echo "Binaries:"
echo " wzp-relay — relay daemon"
echo " wzp-client — headless client (--send-tone, --record)"
echo " wzp-client-audio — client with mic/speakers (needs libasound2)"
echo " wzp-web — web bridge (serve with static/ folder)"
echo " wzp-bench — benchmarks"
echo " static/ — web UI files"
echo ""
echo "Deploy with:"
echo " scp $OUTPUT_DIR/wzp-* user@server:~/wzp/"

11
scripts/cleanup-builder.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Clean up any wzp-builder servers left running
echo "Looking for wzp-builder servers..."
hcloud server list -o noheader | grep wzp-builder | while read -r line; do
id=$(echo "$line" | awk '{print $1}')
name=$(echo "$line" | awk '{print $2}')
echo " Deleting $name (id=$id)..."
hcloud server delete "$id"
done
echo "Done."