Compare commits
196 Commits
9eed94850d
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ceb6f45d5 | ||
|
|
07873ea598 | ||
|
|
cc00f7cace | ||
|
|
eb9de988d6 | ||
|
|
4ba77c8c0e | ||
|
|
7b8a2d0fba | ||
|
|
5cd7a20152 | ||
|
|
a5c00fe5cb | ||
|
|
ec41f179cd | ||
|
|
4e9244eb00 | ||
|
|
03a80a3196 | ||
|
|
7fecf285ea | ||
|
|
0683dde5d3 | ||
|
|
53f57eea07 | ||
|
|
ff3f7e8e4f | ||
|
|
48d2bd4f65 | ||
|
|
234a798df2 | ||
|
|
fa042b130c | ||
|
|
990b6f1ee0 | ||
|
|
7949266e11 | ||
|
|
d774f5f8c5 | ||
|
|
2fd94651e4 | ||
|
|
da09fdb6e9 | ||
|
|
510eae2089 | ||
|
|
76a4c53e21 | ||
|
|
4c6aac654a | ||
|
|
4f2ad65418 | ||
|
|
0178cbd91d | ||
|
|
9e37201198 | ||
|
|
da106bd939 | ||
|
|
8c36fb5651 | ||
|
|
cfa9ff67cf | ||
|
|
96be740fd9 | ||
|
|
8c4d640f89 | ||
|
|
49f101d785 | ||
|
|
d7b37a5749 | ||
|
|
b35a6b7d92 | ||
|
|
0105b0fbf3 | ||
|
|
5beea7de40 | ||
|
|
fdbe502524 | ||
|
|
c769a476a2 | ||
|
|
7cc53aedc7 | ||
|
|
711137da96 | ||
|
|
6071eb1b02 | ||
|
|
c9cd043657 | ||
|
|
6dd62c94c9 | ||
|
|
4c998312aa | ||
|
|
22701830c2 | ||
|
|
47a037368c | ||
|
|
191e8761d5 | ||
|
|
0d74366592 | ||
|
|
0224ce654c | ||
|
|
aa240c6d83 | ||
|
|
d216dcc7a3 | ||
|
|
4250f1b44a | ||
|
|
a852cad15e | ||
|
|
19fd3dd9cc | ||
|
|
c69195fe06 | ||
|
|
ae4f366b05 | ||
|
|
f96d7ce3e1 | ||
|
|
530993854f | ||
|
|
e2e023d2bc | ||
|
|
5df9d418c9 | ||
|
|
2718402e96 | ||
|
|
1a8288c95f | ||
|
|
f015be63ec | ||
|
|
79e876126c | ||
|
|
903a07c1d4 | ||
|
|
af20fa418a | ||
|
|
b314138caf | ||
|
|
35642d1c54 | ||
|
|
6b8107504e | ||
|
|
7639aaf08d | ||
|
|
69ee3115b6 | ||
|
|
e6f77a78a7 | ||
|
|
04a985912a | ||
|
|
2288c1ae07 | ||
|
|
0d3f0d4dcb | ||
|
|
c184d5e1f3 | ||
|
|
5d8e743cbf | ||
|
|
6694aebfd9 | ||
|
|
d27e85ecf2 | ||
|
|
39ac181d63 | ||
|
|
3351cb6473 | ||
|
|
54a4d91f3e | ||
|
|
3b962bd4cb | ||
|
|
1118eac752 | ||
|
|
f935bd69cd | ||
|
|
1c684f6b47 | ||
|
|
c92db7e9b7 | ||
|
|
c3bd657224 | ||
|
|
8b79cdc6fc | ||
|
|
2eab56beec | ||
|
|
7dadc1ddd6 | ||
|
|
be0441295a | ||
|
|
b9f4e7f102 | ||
|
|
28f4a0fb6f | ||
|
|
3d76acf528 | ||
|
|
f4b5996bdf | ||
|
|
fc721c4217 | ||
|
|
5c24adf1c1 | ||
|
|
8dbda3e052 | ||
|
|
c8a3aaacb6 | ||
|
|
395a0c557e | ||
|
|
54cb6c3b71 | ||
|
|
da593f9510 | ||
|
|
a3ebf5616f | ||
|
|
ff6d0444c0 | ||
|
|
8080713098 | ||
|
|
e813362395 | ||
|
|
d52b8befd6 | ||
|
|
0abecf7fd8 | ||
|
|
f4cc3b1a6b | ||
|
|
af4c89f5f0 | ||
|
|
406461d460 | ||
|
|
7064f484af | ||
|
|
1d2222a25a | ||
|
|
270e139f20 | ||
|
|
d9b2e0fd53 | ||
|
|
898c1ea32b | ||
|
|
b00db5dfdc | ||
|
|
bc8bb3d790 | ||
|
|
ea51d068e6 | ||
|
|
7271942c6a | ||
|
|
da84ed332c | ||
|
|
e50925e05a | ||
|
|
6be36e43c2 | ||
|
|
2f2720802d | ||
|
|
087bfd2335 | ||
|
|
0a05e62c7f | ||
|
|
b97f32ce46 | ||
|
|
d66d583583 | ||
|
|
d06cf66538 | ||
|
|
7bddc6b5a6 | ||
|
|
c8bcc5c974 | ||
|
|
760126b6ab | ||
|
|
53f8bf8fff | ||
|
|
3b85604b41 | ||
|
|
a8c2011445 | ||
|
|
ded49bdb7b | ||
|
|
b3cdad0c75 | ||
|
|
fa3c7f1cef | ||
|
|
369347ce54 | ||
|
|
44f04b55e8 | ||
|
|
85c2146760 | ||
|
|
96ccb4f333 | ||
|
|
95a905e1b5 | ||
|
|
f7ccb67b02 | ||
|
|
4df08eadbd | ||
|
|
6d776097c8 | ||
|
|
68b56d9172 | ||
|
|
7973c8c6a3 | ||
|
|
3e9539e5da | ||
|
|
a1ccb3f390 | ||
|
|
7751439e2b | ||
|
|
20bc290c18 | ||
|
|
a8dc350a65 | ||
|
|
00fa109f07 | ||
|
|
1e40dec468 | ||
|
|
aecef0905d | ||
|
|
18f7faa279 | ||
|
|
eeb85aeac2 | ||
|
|
00b405aa87 | ||
|
|
d09e21965e | ||
|
|
97bcc79f9b | ||
|
|
9f7962a6cd | ||
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 | ||
|
|
8c9befb15d | ||
|
|
5e93cb74f2 | ||
|
|
b56b4a759c | ||
|
|
6f99841cc7 | ||
|
|
3f869a4cd7 | ||
|
|
3b0811ce2e | ||
|
|
2263e898e5 | ||
|
|
9ab57ba037 | ||
|
|
7806d4ec04 | ||
|
|
d31b81a21d | ||
|
|
c268ce419a | ||
|
|
61b6e67610 | ||
|
|
dddf5d2e2d | ||
|
|
ed272d29f8 | ||
|
|
21f5b24cbf | ||
|
|
9b733010ab | ||
|
|
80d5bd7628 | ||
|
|
4a195a923a | ||
|
|
f726f8cfa4 | ||
|
|
e468454464 | ||
|
|
d1c96cd71f | ||
|
|
1b00b5e2a4 | ||
|
|
cfb48df1ef | ||
|
|
ba29d8354f | ||
|
|
0908507a7a | ||
|
|
860c90394d |
72
.agents/skills/caveman/SKILL.md
Normal file
72
.agents/skills/caveman/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: caveman
|
||||||
|
description: >
|
||||||
|
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
||||||
|
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
||||||
|
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
||||||
|
when token efficiency is requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Caveman Mode
|
||||||
|
|
||||||
|
## Core Rule
|
||||||
|
|
||||||
|
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
||||||
|
|
||||||
|
## Grammar
|
||||||
|
|
||||||
|
- Drop articles (a, an, the)
|
||||||
|
- Drop filler (just, really, basically, actually, simply)
|
||||||
|
- Drop pleasantries (sure, certainly, of course, happy to)
|
||||||
|
- Short synonyms (big not extensive, fix not "implement a solution for")
|
||||||
|
- No hedging (skip "it might be worth considering")
|
||||||
|
- Fragments fine. No need full sentence
|
||||||
|
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
||||||
|
- Code blocks unchanged. Caveman speak around code, not in code
|
||||||
|
- Error messages quoted exact. Caveman only for explanation
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
[thing] [action] [reason]. [next step].
|
||||||
|
```
|
||||||
|
|
||||||
|
Not:
|
||||||
|
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
||||||
|
|
||||||
|
Yes:
|
||||||
|
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**User:** Why is my React component re-rendering?
|
||||||
|
|
||||||
|
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||||
|
|
||||||
|
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**User:** How do I set up a PostgreSQL connection pool?
|
||||||
|
|
||||||
|
**Caveman:**
|
||||||
|
```
|
||||||
|
Use `pg` pool:
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
const pool = new Pool({
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
```
|
||||||
|
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Code: write normal. Caveman English only
|
||||||
|
- Git commits: normal
|
||||||
|
- PR descriptions: normal
|
||||||
|
- User say "stop caveman" or "normal mode": revert immediately
|
||||||
@@ -7,6 +7,8 @@ on:
|
|||||||
- 'feat/*'
|
- 'feat/*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '.gitea/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
43
.gitea/workflows/mirror-github.yml
Normal file
43
.gitea/workflows/mirror-github.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feat/*'
|
||||||
|
- 'feature/*'
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to GitHub
|
||||||
|
env:
|
||||||
|
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
git remote add github git@github.com:manawenuz/wzp.git
|
||||||
|
|
||||||
|
# Push the current branch
|
||||||
|
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||||
|
echo "Pushing tag: ${TAG}"
|
||||||
|
git push github "refs/tags/${TAG}" --force
|
||||||
|
else
|
||||||
|
echo "Pushing branch: ${BRANCH}"
|
||||||
|
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||||
|
fi
|
||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,3 +4,28 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
dev-debug.log
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
# OS specific
|
||||||
|
|
||||||
|
# Taskmaster (local workflow tool)
|
||||||
|
.taskmaster/
|
||||||
|
.env.example
|
||||||
|
|||||||
3382
Cargo.lock
generated
3382
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
Cargo.toml
@@ -10,6 +10,8 @@ members = [
|
|||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-android",
|
||||||
|
"crates/wzp-native",
|
||||||
|
"desktop/src-tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -40,7 +42,7 @@ codec2 = "0.3"
|
|||||||
|
|
||||||
# Crypto
|
# Crypto
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
@@ -53,3 +55,37 @@ wzp-fec = { path = "crates/wzp-fec" }
|
|||||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
wzp-transport = { path = "crates/wzp-transport" }
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
wzp-client = { path = "crates/wzp-client" }
|
wzp-client = { path = "crates/wzp-client" }
|
||||||
|
|
||||||
|
# Fast dev profile: optimized but with debug info and incremental compilation.
|
||||||
|
# Use with: cargo run --profile dev-fast
|
||||||
|
[profile.dev-fast]
|
||||||
|
inherits = "dev"
|
||||||
|
opt-level = 2
|
||||||
|
|
||||||
|
# Optimize heavy compute deps even in debug builds —
|
||||||
|
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
||||||
|
[profile.dev.package.nnnoiseless]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.audiopus_sys]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.audiopus]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.raptorq]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.wzp-codec]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.wzp-fec]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Vendored audiopus_sys with a patched opus/CMakeLists.txt that distinguishes
|
||||||
|
# real cl.exe (MSVC) from clang-cl (used by cargo-xwin for Windows cross-
|
||||||
|
# compiles). Upstream libopus 1.3.1 gates its `-msse4.1` per-file compile
|
||||||
|
# flags on `if(NOT MSVC)`, which is false under clang-cl because CMake sets
|
||||||
|
# MSVC=1 for both compilers — resulting in SSE4.1 source files compiled
|
||||||
|
# without the required target feature and hard failures in silk/NSQ_sse4_1.c.
|
||||||
|
# The vendored copy introduces an `MSVC_CL` var (true only for real cl.exe)
|
||||||
|
# and flips the SIMD guards to use it, restoring per-file SIMD flags for
|
||||||
|
# clang-cl. See vendor/audiopus_sys/opus/CMakeLists.txt for the full diff
|
||||||
|
# and rationale, plus xiph/opus#256 / xiph/opus PR #257 upstream.
|
||||||
|
[patch.crates-io]
|
||||||
|
audiopus_sys = { path = "vendor/audiopus_sys" }
|
||||||
|
|||||||
@@ -57,9 +57,19 @@ class AudioPipeline(private val context: Context) {
|
|||||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||||
var aecEnabled: Boolean = true
|
var aecEnabled: Boolean = true
|
||||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||||
var debugRecording: Boolean = true
|
var debugRecording: Boolean = false
|
||||||
private var captureThread: Thread? = null
|
private var captureThread: Thread? = null
|
||||||
private var playoutThread: Thread? = null
|
private var playoutThread: Thread? = null
|
||||||
|
|
||||||
|
// DirectByteBuffers for zero-copy JNI audio transfer.
|
||||||
|
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
||||||
|
// can null local variables when it replaces the stack frame mid-loop.
|
||||||
|
// These survive OSR because they're on the heap.
|
||||||
|
private val captureDirectBuf: ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
private val playoutDirectBuf: ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
/** Latch counted down by each audio thread after exiting its loop.
|
/** Latch counted down by each audio thread after exiting its loop.
|
||||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||||
private var drainLatch: CountDownLatch? = null
|
private var drainLatch: CountDownLatch? = null
|
||||||
@@ -205,8 +215,6 @@ class AudioPipeline(private val context: Context) {
|
|||||||
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
val pcm = ShortArray(FRAME_SAMPLES)
|
||||||
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
|
|
||||||
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
// Debug: PCM file + RMS CSV
|
// Debug: PCM file + RMS CSV
|
||||||
var pcmOut: BufferedOutputStream? = null
|
var pcmOut: BufferedOutputStream? = null
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
var rmsCsv: OutputStreamWriter? = null
|
||||||
@@ -226,10 +234,10 @@ class AudioPipeline(private val context: Context) {
|
|||||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||||
if (read > 0) {
|
if (read > 0) {
|
||||||
applyGain(pcm, read, captureGainDb)
|
applyGain(pcm, read, captureGainDb)
|
||||||
// Zero-copy write via DirectByteBuffer (no GC array interaction)
|
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
directBuf.clear()
|
captureDirectBuf.clear()
|
||||||
directBuf.asShortBuffer().put(pcm, 0, read)
|
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||||
engine.writeAudioDirect(directBuf, read)
|
engine.writeAudioDirect(captureDirectBuf, read)
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
// Debug: write raw PCM + RMS
|
||||||
if (pcmOut != null) {
|
if (pcmOut != null) {
|
||||||
@@ -292,8 +300,6 @@ class AudioPipeline(private val context: Context) {
|
|||||||
|
|
||||||
val pcm = ShortArray(FRAME_SAMPLES)
|
val pcm = ShortArray(FRAME_SAMPLES)
|
||||||
val silence = ShortArray(FRAME_SAMPLES)
|
val silence = ShortArray(FRAME_SAMPLES)
|
||||||
// DirectByteBuffer for zero-copy JNI (avoids ART GC SIGBUS on Android 16)
|
|
||||||
val directBuf = ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
// Debug: PCM file + RMS CSV for playout
|
// Debug: PCM file + RMS CSV for playout
|
||||||
var pcmOut: BufferedOutputStream? = null
|
var pcmOut: BufferedOutputStream? = null
|
||||||
var rmsCsv: OutputStreamWriter? = null
|
var rmsCsv: OutputStreamWriter? = null
|
||||||
@@ -310,14 +316,12 @@ class AudioPipeline(private val context: Context) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
while (running) {
|
while (running) {
|
||||||
// Zero-copy read via DirectByteBuffer
|
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||||
directBuf.clear()
|
playoutDirectBuf.clear()
|
||||||
val read = engine.readAudioDirect(directBuf, FRAME_SAMPLES)
|
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||||
if (read > 0) {
|
|
||||||
directBuf.rewind()
|
|
||||||
directBuf.asShortBuffer().get(pcm, 0, read)
|
|
||||||
}
|
|
||||||
if (read >= FRAME_SAMPLES) {
|
if (read >= FRAME_SAMPLES) {
|
||||||
|
playoutDirectBuf.rewind()
|
||||||
|
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||||
applyGain(pcm, read, playoutGainDb)
|
applyGain(pcm, read, playoutGainDb)
|
||||||
track.write(pcm, 0, read)
|
track.write(pcm, 0, read)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class SettingsRepository(context: Context) {
|
|||||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||||
|
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
||||||
|
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||||
|
private const val TOFU_PREFIX = "tofu_"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Servers ---
|
// --- Servers ---
|
||||||
@@ -118,6 +121,16 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||||
|
|
||||||
|
// --- Debug recording ---
|
||||||
|
|
||||||
|
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
||||||
|
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
||||||
|
|
||||||
|
// --- Codec choice ---
|
||||||
|
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
||||||
|
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
||||||
|
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
||||||
|
|
||||||
// --- Identity seed ---
|
// --- Identity seed ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,4 +151,53 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveSeedHex(hex: String) {
|
fun saveSeedHex(hex: String) {
|
||||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Recent rooms ---
|
||||||
|
|
||||||
|
data class RecentRoom(val relay: String, val room: String)
|
||||||
|
|
||||||
|
fun addRecentRoom(relay: String, room: String) {
|
||||||
|
val rooms = loadRecentRooms().toMutableList()
|
||||||
|
rooms.removeAll { it.relay == relay && it.room == room }
|
||||||
|
rooms.add(0, RecentRoom(relay, room))
|
||||||
|
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
||||||
|
val arr = JSONArray()
|
||||||
|
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
||||||
|
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRecentRooms(): List<RecentRoom> {
|
||||||
|
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
RecentRoom(o.getString("relay"), o.getString("room"))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearRecentRooms() {
|
||||||
|
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server fingerprint TOFU ---
|
||||||
|
|
||||||
|
fun saveServerFingerprint(address: String, fingerprint: String) {
|
||||||
|
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadServerFingerprint(address: String): String? {
|
||||||
|
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ping RTT cache ---
|
||||||
|
|
||||||
|
fun savePingRtt(address: String, rttMs: Int) {
|
||||||
|
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPingRtt(address: String): Int {
|
||||||
|
return prefs.getInt("ping_rtt_$address", -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,24 @@ data class CallStats(
|
|||||||
val fecRecovered: Long = 0,
|
val fecRecovered: Long = 0,
|
||||||
/** Current mic audio level (RMS, 0-32767). */
|
/** Current mic audio level (RMS, 0-32767). */
|
||||||
val audioLevel: Int = 0,
|
val audioLevel: Int = 0,
|
||||||
|
/** Our current outgoing codec (e.g. "Opus24k"). */
|
||||||
|
val currentCodec: String = "",
|
||||||
|
/** Last seen incoming codec from peers. */
|
||||||
|
val peerCodec: String = "",
|
||||||
|
/** Whether auto quality mode is active. */
|
||||||
|
val autoMode: Boolean = false,
|
||||||
/** Number of participants in the room. */
|
/** Number of participants in the room. */
|
||||||
val roomParticipantCount: Int = 0,
|
val roomParticipantCount: Int = 0,
|
||||||
/** Participants in the room (fingerprint + optional alias). */
|
/** Participants in the room (fingerprint + optional alias). */
|
||||||
val roomParticipants: List<RoomMember> = emptyList(),
|
val roomParticipants: List<RoomMember> = emptyList(),
|
||||||
|
/** SAS verification code (4-digit, null if not in a call). */
|
||||||
|
val sasCode: Int? = null,
|
||||||
|
/** Incoming call ID (or "relay|room" for CallSetup). */
|
||||||
|
val incomingCallId: String? = null,
|
||||||
|
/** Incoming caller's fingerprint. */
|
||||||
|
val incomingCallerFp: String? = null,
|
||||||
|
/** Incoming caller's alias. */
|
||||||
|
val incomingCallerAlias: String? = null,
|
||||||
) {
|
) {
|
||||||
/** Human-readable quality label. */
|
/** Human-readable quality label. */
|
||||||
val qualityLabel: String
|
val qualityLabel: String
|
||||||
@@ -54,7 +68,8 @@ data class CallStats(
|
|||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
RoomMember(
|
RoomMember(
|
||||||
fingerprint = o.optString("fingerprint", ""),
|
fingerprint = o.optString("fingerprint", ""),
|
||||||
alias = if (o.isNull("alias")) null else o.optString("alias", null)
|
alias = if (o.isNull("alias")) null else o.optString("alias", null),
|
||||||
|
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,8 +91,15 @@ data class CallStats(
|
|||||||
underruns = obj.optLong("underruns", 0),
|
underruns = obj.optLong("underruns", 0),
|
||||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||||
audioLevel = obj.optInt("audio_level", 0),
|
audioLevel = obj.optInt("audio_level", 0),
|
||||||
|
currentCodec = obj.optString("current_codec", ""),
|
||||||
|
peerCodec = obj.optString("peer_codec", ""),
|
||||||
|
autoMode = obj.optBoolean("auto_mode", false),
|
||||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
|
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
||||||
|
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
||||||
|
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
||||||
|
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
||||||
|
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
CallStats()
|
CallStats()
|
||||||
@@ -88,7 +110,8 @@ data class CallStats(
|
|||||||
|
|
||||||
data class RoomMember(
|
data class RoomMember(
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
val alias: String? = null
|
val alias: String? = null,
|
||||||
|
val relayLabel: String? = null
|
||||||
) {
|
) {
|
||||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||||
val displayName: String
|
val displayName: String
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
* @param alias display name sent to relay for room participant list
|
* @param alias display name sent to relay for room participant list
|
||||||
* @return 0 on success, negative error code on failure
|
* @return 0 on success, negative error code on failure
|
||||||
*/
|
*/
|
||||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = ""): Int {
|
/**
|
||||||
|
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
||||||
|
*/
|
||||||
|
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias)
|
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
||||||
if (result == 0) {
|
if (result == 0) {
|
||||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||||
} else {
|
} else {
|
||||||
@@ -50,6 +53,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the active call. Safe to call when no call is active. */
|
/** Stop the active call. Safe to call when no call is active. */
|
||||||
|
@Synchronized
|
||||||
fun stopCall() {
|
fun stopCall() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeStopCall(nativeHandle)
|
nativeStopCall(nativeHandle)
|
||||||
@@ -73,6 +77,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
*
|
*
|
||||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||||
*/
|
*/
|
||||||
|
@Synchronized
|
||||||
fun getStats(): String {
|
fun getStats(): String {
|
||||||
if (nativeHandle == 0L) return "{}"
|
if (nativeHandle == 0L) return "{}"
|
||||||
return try {
|
return try {
|
||||||
@@ -92,6 +97,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||||
|
@Synchronized
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeDestroy(nativeHandle)
|
nativeDestroy(nativeHandle)
|
||||||
@@ -141,7 +147,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
|
|
||||||
private external fun nativeInit(): Long
|
private external fun nativeInit(): Long
|
||||||
private external fun nativeStartCall(
|
private external fun nativeStartCall(
|
||||||
handle: Long, relay: String, room: String, seed: String, token: String, alias: String
|
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
||||||
): Int
|
): Int
|
||||||
private external fun nativeStopCall(handle: Long)
|
private external fun nativeStopCall(handle: Long)
|
||||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||||
@@ -153,6 +159,54 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
||||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
|
private external fun nativePingRelay(handle: Long, relay: String): String?
|
||||||
|
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
||||||
|
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
||||||
|
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping a relay server. Requires engine to be initialized.
|
||||||
|
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
||||||
|
*/
|
||||||
|
fun pingRelay(address: String): String? {
|
||||||
|
if (nativeHandle == 0L) return null
|
||||||
|
return nativePingRelay(nativeHandle, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start persistent signaling connection for direct 1:1 calls.
|
||||||
|
* The engine registers on the relay and listens for incoming calls.
|
||||||
|
* Call state updates are available via [getStats].
|
||||||
|
*
|
||||||
|
* @return 0 on success, -1 on error
|
||||||
|
*/
|
||||||
|
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
|
||||||
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
|
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a direct call to a peer by fingerprint.
|
||||||
|
* Requires [startSignaling] to have been called first.
|
||||||
|
*
|
||||||
|
* @return 0 on success, -1 on error
|
||||||
|
*/
|
||||||
|
fun placeCall(targetFingerprint: String): Int {
|
||||||
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
|
return nativePlaceCall(nativeHandle, targetFingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer an incoming direct call.
|
||||||
|
*
|
||||||
|
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
|
||||||
|
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
|
||||||
|
* @return 0 on success, -1 on error
|
||||||
|
*/
|
||||||
|
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||||
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
|
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
|
|||||||
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.wzp.net
|
||||||
|
|
||||||
|
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
||||||
|
// This file kept for the data class only.
|
||||||
|
|
||||||
|
object RelayPinger {
|
||||||
|
data class PingResult(
|
||||||
|
val rttMs: Int,
|
||||||
|
val reachable: Boolean,
|
||||||
|
val serverFingerprint: String = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import com.wzp.engine.CallStats
|
|||||||
import com.wzp.service.CallService
|
import com.wzp.service.CallService
|
||||||
import com.wzp.engine.WzpCallback
|
import com.wzp.engine.WzpCallback
|
||||||
import com.wzp.engine.WzpEngine
|
import com.wzp.engine.WzpEngine
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
@@ -26,6 +29,14 @@ import java.net.InetAddress
|
|||||||
|
|
||||||
data class ServerEntry(val address: String, val label: String)
|
data class ServerEntry(val address: String, val label: String)
|
||||||
|
|
||||||
|
data class PingResult(
|
||||||
|
val rttMs: Int,
|
||||||
|
val serverFingerprint: String = "",
|
||||||
|
val reachable: Boolean = rttMs > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||||
|
|
||||||
class CallViewModel : ViewModel(), WzpCallback {
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
private var engine: WzpEngine? = null
|
private var engine: WzpEngine? = null
|
||||||
@@ -70,6 +81,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _preferIPv6 = MutableStateFlow(false)
|
private val _preferIPv6 = MutableStateFlow(false)
|
||||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||||
|
|
||||||
|
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
||||||
|
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
||||||
|
|
||||||
|
/** Ping results keyed by server address. */
|
||||||
|
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
||||||
|
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
||||||
|
|
||||||
|
/** Known server fingerprints (TOFU). */
|
||||||
|
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
|
||||||
private val _playoutGainDb = MutableStateFlow(0f)
|
private val _playoutGainDb = MutableStateFlow(0f)
|
||||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||||
|
|
||||||
@@ -85,6 +106,18 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _aecEnabled = MutableStateFlow(true)
|
private val _aecEnabled = MutableStateFlow(true)
|
||||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _debugRecording = MutableStateFlow(false)
|
||||||
|
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
||||||
|
|
||||||
|
// Quality profile index (matches JNI bridge profile_from_int)
|
||||||
|
private val _codecChoice = MutableStateFlow(0)
|
||||||
|
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
||||||
|
|
||||||
|
/** Key-change warning dialog state. */
|
||||||
|
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
||||||
|
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
||||||
|
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
||||||
|
|
||||||
/** True when a call just ended and debug report can be sent. */
|
/** True when a call just ended and debug report can be sent. */
|
||||||
private val _debugReportAvailable = MutableStateFlow(false)
|
private val _debugReportAvailable = MutableStateFlow(false)
|
||||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||||
@@ -99,13 +132,91 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
|
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
|
// ── Direct calling state ──
|
||||||
|
/** 0=room mode, 1=direct call mode */
|
||||||
|
private val _callMode = MutableStateFlow(0)
|
||||||
|
val callMode: StateFlow<Int> = _callMode.asStateFlow()
|
||||||
|
|
||||||
|
/** Target fingerprint for direct call */
|
||||||
|
private val _targetFingerprint = MutableStateFlow("")
|
||||||
|
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||||
|
|
||||||
|
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
||||||
|
private val _signalState = MutableStateFlow(0)
|
||||||
|
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
||||||
|
|
||||||
|
/** Incoming call info */
|
||||||
|
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||||
|
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingCallerFp = MutableStateFlow<String?>(null)
|
||||||
|
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||||
|
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||||
|
|
||||||
|
fun setCallMode(mode: Int) { _callMode.value = mode }
|
||||||
|
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||||
|
|
||||||
|
/** Register on relay for direct calls */
|
||||||
|
fun registerForCalls() {
|
||||||
|
if (engine == null) {
|
||||||
|
engine = WzpEngine(this).also { it.init() }
|
||||||
|
}
|
||||||
|
val serverIdx = _selectedServer.value
|
||||||
|
val serverList = _servers.value
|
||||||
|
if (serverIdx >= serverList.size) return
|
||||||
|
|
||||||
|
val relay = serverList[serverIdx].address
|
||||||
|
val seed = _seedHex.value
|
||||||
|
val alias = _alias.value
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||||
|
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
||||||
|
if (result == 0) {
|
||||||
|
_signalState.value = 5 // Registered
|
||||||
|
startStatsPolling()
|
||||||
|
} else {
|
||||||
|
_errorMessage.value = "Failed to register on relay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place a direct call to the target fingerprint */
|
||||||
|
fun placeDirectCall() {
|
||||||
|
val target = _targetFingerprint.value.trim()
|
||||||
|
if (target.isEmpty()) {
|
||||||
|
_errorMessage.value = "Enter a fingerprint to call"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine?.placeCall(target)
|
||||||
|
_signalState.value = 6 // Ringing
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer an incoming direct call */
|
||||||
|
fun answerIncomingCall(mode: Int = 2) {
|
||||||
|
val callId = _incomingCallId.value ?: return
|
||||||
|
engine?.answerCall(callId, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject an incoming direct call */
|
||||||
|
fun rejectIncomingCall() {
|
||||||
|
val callId = _incomingCallId.value ?: return
|
||||||
|
engine?.answerCall(callId, 0) // 0 = Reject
|
||||||
|
_signalState.value = 5 // Back to registered
|
||||||
|
_incomingCallId.value = null
|
||||||
|
_incomingCallerFp.value = null
|
||||||
|
_incomingCallerAlias.value = null
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WzpCall"
|
private const val TAG = "WzpCall"
|
||||||
val DEFAULT_SERVERS = listOf(
|
val DEFAULT_SERVERS = listOf(
|
||||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||||
)
|
)
|
||||||
const val DEFAULT_ROOM = "android"
|
const val DEFAULT_ROOM = "general"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContext(context: Context) {
|
fun setContext(context: Context) {
|
||||||
@@ -139,6 +250,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_captureGainDb.value = s.loadCaptureGain()
|
_captureGainDb.value = s.loadCaptureGain()
|
||||||
_seedHex.value = s.getOrCreateSeedHex()
|
_seedHex.value = s.getOrCreateSeedHex()
|
||||||
_aecEnabled.value = s.loadAecEnabled()
|
_aecEnabled.value = s.loadAecEnabled()
|
||||||
|
_debugRecording.value = s.loadDebugRecording()
|
||||||
|
_codecChoice.value = s.loadCodecChoice()
|
||||||
|
_recentRooms.value = s.loadRecentRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectServer(index: Int) {
|
fun selectServer(index: Int) {
|
||||||
@@ -182,6 +296,70 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping all servers via native QUIC. Requires engine to be initialized.
|
||||||
|
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
|
||||||
|
*/
|
||||||
|
fun pingAllServers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Ensure engine exists
|
||||||
|
if (engine == null || engine?.isInitialized != true) {
|
||||||
|
try {
|
||||||
|
engine = WzpEngine(this@CallViewModel).also { it.init() }
|
||||||
|
engineInitialized = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "engine init for ping failed: $e")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val eng = engine ?: return@launch
|
||||||
|
|
||||||
|
val results = mutableMapOf<String, PingResult>()
|
||||||
|
val known = mutableMapOf<String, String>()
|
||||||
|
_servers.value.forEach { server ->
|
||||||
|
val json = withContext(Dispatchers.IO) {
|
||||||
|
eng.pingRelay(server.address)
|
||||||
|
}
|
||||||
|
if (json != null) {
|
||||||
|
try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val rtt = obj.getInt("rtt_ms")
|
||||||
|
val fp = obj.optString("server_fingerprint", "")
|
||||||
|
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
|
||||||
|
// TOFU
|
||||||
|
if (fp.isNotEmpty()) {
|
||||||
|
val saved = settings?.loadServerFingerprint(server.address)
|
||||||
|
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
|
||||||
|
known[server.address] = saved ?: fp
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pingResults.value = results
|
||||||
|
_knownFingerprints.value = known
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load saved TOFU fingerprints. */
|
||||||
|
fun loadSavedFingerprints() {
|
||||||
|
val known = mutableMapOf<String, String>()
|
||||||
|
_servers.value.forEach { server ->
|
||||||
|
settings?.loadServerFingerprint(server.address)?.let {
|
||||||
|
known[server.address] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_knownFingerprints.value = known
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get lock status for a server. */
|
||||||
|
fun lockStatus(address: String): LockStatus {
|
||||||
|
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
||||||
|
if (!pr.reachable) return LockStatus.OFFLINE
|
||||||
|
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
||||||
|
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||||
|
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||||
|
}
|
||||||
|
|
||||||
fun setRoomName(name: String) {
|
fun setRoomName(name: String) {
|
||||||
_roomName.value = name
|
_roomName.value = name
|
||||||
settings?.saveRoom(name)
|
settings?.saveRoom(name)
|
||||||
@@ -214,6 +392,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveAecEnabled(enabled)
|
settings?.saveAecEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDebugRecording(enabled: Boolean) {
|
||||||
|
_debugRecording.value = enabled
|
||||||
|
settings?.saveDebugRecording(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCodecChoice(choice: Int) {
|
||||||
|
_codecChoice.value = choice
|
||||||
|
settings?.saveCodecChoice(choice)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||||
* since Rust's DNS resolution may not work on Android.
|
* since Rust's DNS resolution may not work on Android.
|
||||||
@@ -280,13 +468,82 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
Log.i(TAG, "teardown: done")
|
Log.i(TAG, "teardown: done")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept the new server key and proceed with the call. */
|
||||||
|
fun acceptNewFingerprint() {
|
||||||
|
val info = _keyWarning.value ?: return
|
||||||
|
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
||||||
|
it[info.address] = info.newFp
|
||||||
|
}
|
||||||
|
settings?.saveServerFingerprint(info.address, info.newFp)
|
||||||
|
_keyWarning.value = null
|
||||||
|
startCallInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissKeyWarning() {
|
||||||
|
_keyWarning.value = null
|
||||||
|
}
|
||||||
|
|
||||||
fun startCall() {
|
fun startCall() {
|
||||||
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
|
// Check for key change before connecting
|
||||||
|
val ls = lockStatus(serverEntry.address)
|
||||||
|
if (ls == LockStatus.CHANGED) {
|
||||||
|
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
||||||
|
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
||||||
|
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startCallInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a call to a specific relay + room (used by direct call setup). */
|
||||||
|
private fun startCallInternal(relay: String, room: String) {
|
||||||
|
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
|
||||||
|
try {
|
||||||
|
// Don't teardown — keep the signal connection alive
|
||||||
|
engine = WzpEngine(this)
|
||||||
|
engine!!.init()
|
||||||
|
engineInitialized = true
|
||||||
|
_callState.value = 1
|
||||||
|
_errorMessage.value = null
|
||||||
|
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
||||||
|
Log.w(TAG, "service start err: $e")
|
||||||
|
}
|
||||||
|
startStatsPolling()
|
||||||
|
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val seed = _seedHex.value
|
||||||
|
val name = _alias.value
|
||||||
|
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||||
|
CallService.onStopFromNotification = { stopCall() }
|
||||||
|
if (result != 0) {
|
||||||
|
_callState.value = 0
|
||||||
|
_errorMessage.value = "Failed to connect to call room (code $result)"
|
||||||
|
appContext?.let { CallService.stop(it) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startCallDirect error", e)
|
||||||
|
_callState.value = 0
|
||||||
|
_errorMessage.value = "Engine error: ${e.message}"
|
||||||
|
appContext?.let { CallService.stop(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startCallDirect error", e)
|
||||||
|
_callState.value = 0
|
||||||
|
_errorMessage.value = "Engine error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCallInternal() {
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
val room = _roomName.value
|
val room = _roomName.value
|
||||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||||
_debugReportAvailable.value = false
|
_debugReportAvailable.value = false
|
||||||
_debugReportStatus.value = null
|
_debugReportStatus.value = null
|
||||||
lastCallServer = serverEntry.address
|
lastCallServer = serverEntry.address
|
||||||
|
settings?.addRecentRoom(serverEntry.address, room)
|
||||||
|
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||||
debugReporter?.prepareForCall()
|
debugReporter?.prepareForCall()
|
||||||
try {
|
try {
|
||||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||||
@@ -309,7 +566,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
val seed = _seedHex.value
|
val seed = _seedHex.value
|
||||||
val name = _alias.value
|
val name = _alias.value
|
||||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1
|
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||||
Log.i(TAG, "startCall: engine returned $result")
|
Log.i(TAG, "startCall: engine returned $result")
|
||||||
// Only wire up notification callback after engine is running
|
// Only wire up notification callback after engine is running
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
CallService.onStopFromNotification = { stopCall() }
|
||||||
@@ -400,6 +657,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
it.playoutGainDb = _playoutGainDb.value
|
it.playoutGainDb = _playoutGainDb.value
|
||||||
it.captureGainDb = _captureGainDb.value
|
it.captureGainDb = _captureGainDb.value
|
||||||
it.aecEnabled = _aecEnabled.value
|
it.aecEnabled = _aecEnabled.value
|
||||||
|
it.debugRecording = _debugRecording.value
|
||||||
it.start(e)
|
it.start(e)
|
||||||
}
|
}
|
||||||
audioRouteManager?.register()
|
audioRouteManager?.register()
|
||||||
@@ -430,6 +688,27 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
if (s.state != 0) {
|
if (s.state != 0) {
|
||||||
_callState.value = s.state
|
_callState.value = s.state
|
||||||
}
|
}
|
||||||
|
// Track signal state changes for direct calling
|
||||||
|
if (s.state in 5..7) {
|
||||||
|
_signalState.value = s.state
|
||||||
|
}
|
||||||
|
// Incoming call detection
|
||||||
|
if (s.state == 7) { // IncomingCall
|
||||||
|
_incomingCallId.value = s.incomingCallId
|
||||||
|
_incomingCallerFp.value = s.incomingCallerFp
|
||||||
|
_incomingCallerAlias.value = s.incomingCallerAlias
|
||||||
|
}
|
||||||
|
// CallSetup: auto-connect to media room
|
||||||
|
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
||||||
|
// Format: "relay_addr|room_name"
|
||||||
|
val parts = s.incomingCallId.split("|", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val mediaRelay = parts[0]
|
||||||
|
val mediaRoom = parts[1]
|
||||||
|
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
||||||
|
startCallInternal(mediaRelay, mediaRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (s.state == 2 && !audioStarted) {
|
if (s.state == 2 && !audioStarted) {
|
||||||
startAudio()
|
startAudio()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package com.wzp.ui.components
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
||||||
|
* from a hex fingerprint string. Identical algorithm to the desktop
|
||||||
|
* TypeScript implementation in identicon.ts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Identicon(
|
||||||
|
fingerprint: String,
|
||||||
|
size: Dp = 36.dp,
|
||||||
|
clickToCopy: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bytes = hashBytes(fingerprint)
|
||||||
|
val (bg, fg) = deriveColors(bytes)
|
||||||
|
val grid = buildGrid(bytes)
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(RoundedCornerShape(size * 0.12f))
|
||||||
|
.then(
|
||||||
|
if (clickToCopy && fingerprint.isNotEmpty()) {
|
||||||
|
Modifier.clickable {
|
||||||
|
clipboard.setText(AnnotatedString(fingerprint))
|
||||||
|
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val cellW = this.size.width / 5f
|
||||||
|
val cellH = this.size.height / 5f
|
||||||
|
|
||||||
|
// Background
|
||||||
|
drawRect(color = bg, size = this.size)
|
||||||
|
|
||||||
|
// Foreground cells
|
||||||
|
for (y in 0 until 5) {
|
||||||
|
for (x in 0 until 5) {
|
||||||
|
if (grid[y][x]) {
|
||||||
|
drawRect(
|
||||||
|
color = fg,
|
||||||
|
topLeft = Offset(x * cellW, y * cellH),
|
||||||
|
size = Size(cellW, cellH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint text that copies to clipboard on tap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CopyableFingerprint(
|
||||||
|
fingerprint: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
text = fingerprint,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
modifier = modifier.clickable {
|
||||||
|
if (fingerprint.isNotEmpty()) {
|
||||||
|
clipboard.setText(AnnotatedString(fingerprint))
|
||||||
|
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers (matching desktop identicon.ts) ---
|
||||||
|
|
||||||
|
private fun hashBytes(hex: String): List<Int> {
|
||||||
|
val clean = hex.filter { it.isLetterOrDigit() }
|
||||||
|
val bytes = mutableListOf<Int>()
|
||||||
|
var i = 0
|
||||||
|
while (i + 1 < clean.length) {
|
||||||
|
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
||||||
|
bytes.add(b)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
// Pad to at least 16 bytes
|
||||||
|
while (bytes.size < 16) bytes.add(0)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
||||||
|
val hue1 = bytes[0] * 360f / 256f
|
||||||
|
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
||||||
|
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
||||||
|
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
||||||
|
return bg to fg
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
||||||
|
return (0 until 5).map { y ->
|
||||||
|
val left = (0 until 3).map { x ->
|
||||||
|
val idx = 2 + y * 3 + x
|
||||||
|
bytes[idx % bytes.size] > 128
|
||||||
|
}
|
||||||
|
// Mirror: col3 = col1, col4 = col0
|
||||||
|
listOf(left[0], left[1], left[2], left[1], left[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||||
|
val k = { n: Float -> (n + h / 30f) % 12f }
|
||||||
|
val a = s * min(l, 1f - l)
|
||||||
|
val f = { n: Float ->
|
||||||
|
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
||||||
|
}
|
||||||
|
return Color(f(0f), f(8f), f(4f))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.wzp.ui.settings
|
package com.wzp.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -22,6 +23,7 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
@@ -158,20 +160,30 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Fingerprint display
|
// Fingerprint display with identicon
|
||||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||||
Text(
|
Text(
|
||||||
text = "Fingerprint",
|
text = "Fingerprint",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Row(
|
||||||
text = fingerprint.chunked(4).joinToString(" "),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
fontFamily = FontFamily.Monospace
|
) {
|
||||||
),
|
com.wzp.ui.components.Identicon(
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
fingerprint = draftSeedHex,
|
||||||
)
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
com.wzp.ui.components.CopyableFingerprint(
|
||||||
|
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
@@ -231,6 +243,51 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
||||||
|
val qualityLabels = listOf(
|
||||||
|
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
||||||
|
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
||||||
|
)
|
||||||
|
// Map slider position to JNI profile int:
|
||||||
|
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
||||||
|
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
||||||
|
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
||||||
|
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
||||||
|
val qualityColors = listOf(
|
||||||
|
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
||||||
|
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
||||||
|
)
|
||||||
|
val currentCodec by viewModel.codecChoice.collectAsState()
|
||||||
|
val sliderPos = profileToSlider[currentCodec] ?: 3
|
||||||
|
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
text = "Decode always accepts all codecs",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = qualityLabels[sliderPos],
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = qualityColors[sliderPos]
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = sliderPos.toFloat(),
|
||||||
|
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
||||||
|
valueRange = 0f..7f,
|
||||||
|
steps = 6,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
||||||
|
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
Divider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
|
|||||||
wzp-transport = { workspace = true }
|
wzp-transport = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -12,4 +12,13 @@ pub enum EngineCommand {
|
|||||||
ForceProfile(QualityProfile),
|
ForceProfile(QualityProfile),
|
||||||
/// Stop the call and shut down the engine.
|
/// Stop the call and shut down the engine.
|
||||||
Stop,
|
Stop,
|
||||||
|
/// Place a direct call to a fingerprint (requires signal connection).
|
||||||
|
PlaceCall { target_fingerprint: String },
|
||||||
|
/// Answer an incoming direct call.
|
||||||
|
AnswerCall {
|
||||||
|
call_id: String,
|
||||||
|
accept_mode: wzp_proto::CallAcceptMode,
|
||||||
|
},
|
||||||
|
/// Reject an incoming direct call.
|
||||||
|
RejectCall { call_id: String },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,32 +9,58 @@
|
|||||||
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use wzp_codec::agc::AutoGainControl;
|
use wzp_codec::agc::AutoGainControl;
|
||||||
use wzp_codec::opus_dec::OpusDecoder;
|
|
||||||
use wzp_codec::opus_enc::OpusEncoder;
|
|
||||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::{
|
use wzp_proto::{
|
||||||
AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||||
MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
|
MediaHeader, MediaPacket, MediaTransport, QualityController, QualityProfile, SignalMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
use crate::audio_ring::AudioRing;
|
||||||
use crate::commands::EngineCommand;
|
use crate::commands::EngineCommand;
|
||||||
use crate::stats::{CallState, CallStats};
|
use crate::stats::{CallState, CallStats};
|
||||||
|
|
||||||
/// Opus frame size at 48kHz mono, 20ms = 960 samples.
|
/// Max frame size at 48kHz mono (40ms = 1920 samples, for Codec2/Opus6k).
|
||||||
const FRAME_SAMPLES: usize = 960;
|
const MAX_FRAME_SAMPLES: usize = 1920;
|
||||||
|
|
||||||
|
/// Sentinel value: no profile change pending.
|
||||||
|
const PROFILE_NO_CHANGE: u8 = 0xFF;
|
||||||
|
|
||||||
|
/// All quality profiles in index order, for AtomicU8-based signaling.
|
||||||
|
const PROFILES: [QualityProfile; 6] = [
|
||||||
|
QualityProfile::STUDIO_64K, // 0
|
||||||
|
QualityProfile::STUDIO_48K, // 1
|
||||||
|
QualityProfile::STUDIO_32K, // 2
|
||||||
|
QualityProfile::GOOD, // 3
|
||||||
|
QualityProfile::DEGRADED, // 4
|
||||||
|
QualityProfile::CATASTROPHIC, // 5
|
||||||
|
];
|
||||||
|
|
||||||
|
fn profile_to_index(p: &QualityProfile) -> u8 {
|
||||||
|
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
|
||||||
|
PROFILES.get(idx as usize).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute frame samples at 48kHz for a given profile.
|
||||||
|
fn frame_samples_for(profile: &QualityProfile) -> usize {
|
||||||
|
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration to start a call.
|
/// Configuration to start a call.
|
||||||
pub struct CallStartConfig {
|
pub struct CallStartConfig {
|
||||||
pub profile: QualityProfile,
|
pub profile: QualityProfile,
|
||||||
|
/// When true, use the relay's chosen_profile from CallAnswer instead of local profile.
|
||||||
|
pub auto_profile: bool,
|
||||||
pub relay_addr: String,
|
pub relay_addr: String,
|
||||||
pub room: String,
|
pub room: String,
|
||||||
pub auth_token: Vec<u8>,
|
pub auth_token: Vec<u8>,
|
||||||
@@ -46,6 +72,7 @@ impl Default for CallStartConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
profile: QualityProfile::GOOD,
|
profile: QualityProfile::GOOD,
|
||||||
|
auto_profile: false,
|
||||||
relay_addr: String::new(),
|
relay_addr: String::new(),
|
||||||
room: String::new(),
|
room: String::new(),
|
||||||
auth_token: Vec::new(),
|
auth_token: Vec::new(),
|
||||||
@@ -123,6 +150,7 @@ impl WzpEngine {
|
|||||||
let room = config.room.clone();
|
let room = config.room.clone();
|
||||||
let identity_seed = config.identity_seed;
|
let identity_seed = config.identity_seed;
|
||||||
let profile = config.profile;
|
let profile = config.profile;
|
||||||
|
let auto_profile = config.auto_profile;
|
||||||
let alias = config.alias.clone();
|
let alias = config.alias.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
@@ -131,7 +159,7 @@ impl WzpEngine {
|
|||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
runtime.block_on(async move {
|
runtime.block_on(async move {
|
||||||
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, alias.as_deref(), state_clone).await
|
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await
|
||||||
{
|
{
|
||||||
error!("call failed: {e}");
|
error!("call failed: {e}");
|
||||||
}
|
}
|
||||||
@@ -169,6 +197,203 @@ impl WzpEngine {
|
|||||||
info!("stop_call: done");
|
info!("stop_call: done");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ping a relay — same pattern as start_call (creates runtime on calling thread).
|
||||||
|
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
||||||
|
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
||||||
|
let addr: SocketAddr = address.parse()?;
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let result = rt.block_on(async {
|
||||||
|
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind, None)?;
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let conn_result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Always close endpoint to prevent resource leaks
|
||||||
|
endpoint.close(0u32.into(), b"done");
|
||||||
|
|
||||||
|
let conn = conn_result.map_err(|_| anyhow::anyhow!("timeout"))??;
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||||
|
let server_fp = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||||
|
.and_then(|certs| certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
}))
|
||||||
|
.unwrap_or_default();
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
|
||||||
|
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shutdown runtime cleanly with timeout
|
||||||
|
rt.shutdown_timeout(std::time::Duration::from_millis(500));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start persistent signaling connection for direct calls.
|
||||||
|
/// Spawns a background task that maintains the `_signal` connection.
|
||||||
|
pub fn start_signaling(
|
||||||
|
&mut self,
|
||||||
|
relay_addr: &str,
|
||||||
|
seed_hex: &str,
|
||||||
|
token: Option<&str>,
|
||||||
|
alias: Option<&str>,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
|
|
||||||
|
let addr: SocketAddr = relay_addr.parse()?;
|
||||||
|
let seed = if seed_hex.is_empty() {
|
||||||
|
wzp_crypto::Seed::generate()
|
||||||
|
} else {
|
||||||
|
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
|
||||||
|
};
|
||||||
|
let identity = seed.derive_identity();
|
||||||
|
let pub_id = identity.public_identity();
|
||||||
|
let identity_pub = *pub_id.signing.as_bytes();
|
||||||
|
let fp = pub_id.fingerprint.to_string();
|
||||||
|
let token = token.map(|s| s.to_string());
|
||||||
|
let alias = alias.map(|s| s.to_string());
|
||||||
|
let state = self.state.clone();
|
||||||
|
let seed_bytes = seed.0;
|
||||||
|
|
||||||
|
info!(fingerprint = %fp, relay = %addr, "starting signaling");
|
||||||
|
|
||||||
|
// Create runtime for signaling (separate from call runtime)
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(1)
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let signal_state = state.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => { error!("signal endpoint: {e}"); return; }
|
||||||
|
};
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => { error!("signal connect: {e}"); return; }
|
||||||
|
};
|
||||||
|
let transport = std::sync::Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Auth if token provided
|
||||||
|
if let Some(ref tok) = token {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register presence
|
||||||
|
let _ = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||||
|
identity_pub,
|
||||||
|
signature: vec![],
|
||||||
|
alias: alias.clone(),
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
// Wait for ack
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
||||||
|
info!(fingerprint = %fp, "signal: registered");
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::Registered;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
error!("signal registration failed: {other:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal recv loop
|
||||||
|
loop {
|
||||||
|
if !signal_state.running.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
||||||
|
info!(call_id = %call_id, "signal: ringing");
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::Ringing;
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||||
|
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::IncomingCall;
|
||||||
|
stats.incoming_call_id = Some(call_id);
|
||||||
|
stats.incoming_caller_fp = Some(caller_fingerprint);
|
||||||
|
stats.incoming_caller_alias = caller_alias;
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
||||||
|
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
||||||
|
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
|
||||||
|
// Connect to media room via the existing start_call mechanism
|
||||||
|
// Store the room info so Kotlin can call startCall with it
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::Connecting;
|
||||||
|
// Store call setup info for Kotlin to pick up
|
||||||
|
stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||||
|
info!(reason = ?reason, "signal: call ended by remote");
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::Closed;
|
||||||
|
stats.incoming_call_id = None;
|
||||||
|
stats.incoming_caller_fp = None;
|
||||||
|
stats.incoming_caller_alias = None;
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
info!("signal: connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stats = signal_state.stats.lock().unwrap();
|
||||||
|
stats.state = crate::stats::CallState::Closed;
|
||||||
|
});
|
||||||
|
|
||||||
|
self.tokio_runtime = Some(rt);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a direct call to a target fingerprint via the signal connection.
|
||||||
|
pub fn place_call(&self, target_fingerprint: &str) -> Result<(), anyhow::Error> {
|
||||||
|
let _ = self.state.command_tx.send(EngineCommand::PlaceCall {
|
||||||
|
target_fingerprint: target_fingerprint.to_string(),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming direct call.
|
||||||
|
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
||||||
|
let _ = self.state.command_tx.send(EngineCommand::AnswerCall {
|
||||||
|
call_id: call_id.to_string(),
|
||||||
|
accept_mode: mode,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_mute(&self, muted: bool) {
|
pub fn set_mute(&self, muted: bool) {
|
||||||
self.state.muted.store(muted, Ordering::Relaxed);
|
self.state.muted.store(muted, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
@@ -227,6 +452,7 @@ async fn run_call(
|
|||||||
room: &str,
|
room: &str,
|
||||||
identity_seed: &[u8; 32],
|
identity_seed: &[u8; 32],
|
||||||
profile: QualityProfile,
|
profile: QualityProfile,
|
||||||
|
auto_profile: bool,
|
||||||
alias: Option<&str>,
|
alias: Option<&str>,
|
||||||
state: Arc<EngineState>,
|
state: Arc<EngineState>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
@@ -261,6 +487,9 @@ async fn run_call(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
|
QualityProfile::STUDIO_64K,
|
||||||
|
QualityProfile::STUDIO_48K,
|
||||||
|
QualityProfile::STUDIO_32K,
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
@@ -275,8 +504,8 @@ async fn run_call(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||||
|
|
||||||
let relay_ephemeral_pub = match answer {
|
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
||||||
SignalMessage::CallAnswer { ephemeral_pub, .. } => ephemeral_pub,
|
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
||||||
other => {
|
other => {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"expected CallAnswer, got {:?}",
|
"expected CallAnswer, got {:?}",
|
||||||
@@ -285,19 +514,25 @@ async fn run_call(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto mode: use the relay's chosen profile instead of the local preference
|
||||||
|
let profile = if auto_profile {
|
||||||
|
info!(chosen = ?chosen_profile.codec, "auto mode: using relay's chosen profile");
|
||||||
|
chosen_profile
|
||||||
|
} else {
|
||||||
|
profile
|
||||||
|
};
|
||||||
|
|
||||||
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
||||||
info!("handshake complete, call active");
|
info!(codec = ?profile.codec, "handshake complete, call active");
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut stats = state.stats.lock().unwrap();
|
let mut stats = state.stats.lock().unwrap();
|
||||||
stats.state = CallState::Active;
|
stats.state = CallState::Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Opus codec
|
// Initialize codec (Opus or Codec2 based on profile)
|
||||||
let mut encoder =
|
let mut encoder = wzp_codec::create_encoder(profile);
|
||||||
OpusEncoder::new(profile).map_err(|e| anyhow::anyhow!("opus encoder init: {e}"))?;
|
let mut decoder = wzp_codec::create_decoder(profile);
|
||||||
let mut decoder =
|
|
||||||
OpusDecoder::new(profile).map_err(|e| anyhow::anyhow!("opus decoder init: {e}"))?;
|
|
||||||
|
|
||||||
// Initialize FEC encoder/decoder
|
// Initialize FEC encoder/decoder
|
||||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||||
@@ -307,21 +542,37 @@ async fn run_call(
|
|||||||
let mut capture_agc = AutoGainControl::new();
|
let mut capture_agc = AutoGainControl::new();
|
||||||
let mut playout_agc = AutoGainControl::new();
|
let mut playout_agc = AutoGainControl::new();
|
||||||
|
|
||||||
|
let mut frame_samples = frame_samples_for(&profile);
|
||||||
info!(
|
info!(
|
||||||
|
codec = ?profile.codec,
|
||||||
fec_ratio = profile.fec_ratio,
|
fec_ratio = profile.fec_ratio,
|
||||||
frames_per_block = profile.frames_per_block,
|
frames_per_block = profile.frames_per_block,
|
||||||
"codec + FEC + AGC initialized (48kHz mono, 20ms frames)"
|
frame_ms = profile.frame_duration_ms,
|
||||||
|
frame_samples,
|
||||||
|
"codec + FEC + AGC initialized"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut stats = state.stats.lock().unwrap();
|
||||||
|
stats.current_codec = format!("{:?}", profile.codec);
|
||||||
|
stats.auto_mode = auto_profile;
|
||||||
|
}
|
||||||
|
|
||||||
let seq = AtomicU16::new(0);
|
let seq = AtomicU16::new(0);
|
||||||
let ts = AtomicU32::new(0);
|
let ts = AtomicU32::new(0);
|
||||||
let transport_recv = transport.clone();
|
let transport_recv = transport.clone();
|
||||||
|
|
||||||
// Pre-allocate buffers
|
// Adaptive quality: shared AtomicU8 between recv task (writer) and send task (reader).
|
||||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
// 0xFF = no change pending, 0-5 = index into PROFILES array.
|
||||||
|
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
|
||||||
|
let pending_profile_recv = pending_profile.clone();
|
||||||
|
|
||||||
|
// Pre-allocate buffers (sized for current profile)
|
||||||
|
let mut capture_buf = vec![0i16; frame_samples];
|
||||||
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
||||||
let mut frame_in_block: u8 = 0;
|
let mut frame_in_block: u8 = 0;
|
||||||
let mut block_id: u8 = 0;
|
let mut block_id: u8 = 0;
|
||||||
|
let mut current_profile = profile;
|
||||||
|
|
||||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||||
//
|
//
|
||||||
@@ -347,14 +598,47 @@ async fn run_call(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for adaptive profile switch from recv task
|
||||||
|
if auto_profile {
|
||||||
|
let p = pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
||||||
|
if p != PROFILE_NO_CHANGE {
|
||||||
|
if let Some(new_profile) = index_to_profile(p) {
|
||||||
|
info!(
|
||||||
|
from = ?current_profile.codec,
|
||||||
|
to = ?new_profile.codec,
|
||||||
|
"auto: switching encoder profile"
|
||||||
|
);
|
||||||
|
if let Err(e) = encoder.set_profile(new_profile) {
|
||||||
|
warn!("encoder set_profile failed: {e}");
|
||||||
|
} else {
|
||||||
|
fec_enc = wzp_fec::create_encoder(&new_profile);
|
||||||
|
current_profile = new_profile;
|
||||||
|
let new_frame_samples = frame_samples_for(&new_profile);
|
||||||
|
if new_frame_samples != frame_samples {
|
||||||
|
frame_samples = new_frame_samples;
|
||||||
|
capture_buf.resize(frame_samples, 0);
|
||||||
|
}
|
||||||
|
encode_buf.resize(encoder.max_frame_bytes(), 0);
|
||||||
|
// Reset FEC block state for clean switch
|
||||||
|
frame_in_block = 0;
|
||||||
|
block_id = block_id.wrapping_add(1);
|
||||||
|
// Update stats with new codec
|
||||||
|
if let Ok(mut stats) = state.stats.lock() {
|
||||||
|
stats.current_codec = format!("{:?}", new_profile.codec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let avail = state.capture_ring.available();
|
let avail = state.capture_ring.available();
|
||||||
if avail < FRAME_SAMPLES {
|
if avail < frame_samples {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let read = state.capture_ring.read(&mut capture_buf);
|
let read = state.capture_ring.read(&mut capture_buf);
|
||||||
if read < FRAME_SAMPLES {
|
if read < frame_samples {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,15 +667,15 @@ async fn run_call(
|
|||||||
|
|
||||||
// Build source packet
|
// Build source packet
|
||||||
let s = seq.fetch_add(1, Ordering::Relaxed);
|
let s = seq.fetch_add(1, Ordering::Relaxed);
|
||||||
let t = ts.fetch_add(FRAME_SAMPLES as u32, Ordering::Relaxed);
|
let t = ts.fetch_add(frame_samples as u32, Ordering::Relaxed);
|
||||||
|
|
||||||
let source_pkt = MediaPacket {
|
let source_pkt = MediaPacket {
|
||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
version: 0,
|
version: 0,
|
||||||
is_repair: false,
|
is_repair: false,
|
||||||
codec_id: profile.codec,
|
codec_id: current_profile.codec,
|
||||||
has_quality_report: false,
|
has_quality_report: false,
|
||||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(profile.fec_ratio),
|
fec_ratio_encoded: MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
||||||
seq: s,
|
seq: s,
|
||||||
timestamp: t,
|
timestamp: t,
|
||||||
fec_block: block_id,
|
fec_block: block_id,
|
||||||
@@ -433,8 +717,8 @@ async fn run_call(
|
|||||||
frame_in_block += 1;
|
frame_in_block += 1;
|
||||||
|
|
||||||
// When block is full, generate repair packets
|
// When block is full, generate repair packets
|
||||||
if frame_in_block >= profile.frames_per_block {
|
if frame_in_block >= current_profile.frames_per_block {
|
||||||
match fec_enc.generate_repair(profile.fec_ratio) {
|
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
||||||
Ok(repairs) => {
|
Ok(repairs) => {
|
||||||
let repair_count = repairs.len();
|
let repair_count = repairs.len();
|
||||||
for (sym_idx, repair_data) in repairs {
|
for (sym_idx, repair_data) in repairs {
|
||||||
@@ -443,10 +727,10 @@ async fn run_call(
|
|||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
version: 0,
|
version: 0,
|
||||||
is_repair: true,
|
is_repair: true,
|
||||||
codec_id: profile.codec,
|
codec_id: current_profile.codec,
|
||||||
has_quality_report: false,
|
has_quality_report: false,
|
||||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||||
profile.fec_ratio,
|
current_profile.fec_ratio,
|
||||||
),
|
),
|
||||||
seq: rs,
|
seq: rs,
|
||||||
timestamp: t,
|
timestamp: t,
|
||||||
@@ -469,7 +753,7 @@ async fn run_call(
|
|||||||
info!(
|
info!(
|
||||||
block_id,
|
block_id,
|
||||||
repair_count,
|
repair_count,
|
||||||
fec_ratio = profile.fec_ratio,
|
fec_ratio = current_profile.fec_ratio,
|
||||||
"FEC block complete"
|
"FEC block complete"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -511,8 +795,8 @@ async fn run_call(
|
|||||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-allocate decode buffer
|
// Pre-allocate decode buffer (max size to handle any incoming codec)
|
||||||
let mut decode_buf = vec![0i16; FRAME_SAMPLES];
|
let mut decode_buf = vec![0i16; MAX_FRAME_SAMPLES];
|
||||||
|
|
||||||
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
||||||
let recv_task = async {
|
let recv_task = async {
|
||||||
@@ -522,6 +806,8 @@ async fn run_call(
|
|||||||
let mut last_recv_instant = Instant::now();
|
let mut last_recv_instant = Instant::now();
|
||||||
let mut max_recv_gap_ms: u64 = 0;
|
let mut max_recv_gap_ms: u64 = 0;
|
||||||
let mut last_stats_log = Instant::now();
|
let mut last_stats_log = Instant::now();
|
||||||
|
let mut quality_ctrl = AdaptiveQualityController::new();
|
||||||
|
let mut last_peer_codec: Option<CodecId> = None;
|
||||||
info!("recv task started (Opus + RaptorQ FEC)");
|
info!("recv task started (Opus + RaptorQ FEC)");
|
||||||
loop {
|
loop {
|
||||||
if !state.running.load(Ordering::Relaxed) {
|
if !state.running.load(Ordering::Relaxed) {
|
||||||
@@ -544,6 +830,23 @@ async fn run_call(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adaptive quality: ingest quality reports from relay
|
||||||
|
if auto_profile {
|
||||||
|
if let Some(ref qr) = pkt.quality_report {
|
||||||
|
if let Some(new_profile) = quality_ctrl.observe(qr) {
|
||||||
|
let idx = profile_to_index(&new_profile);
|
||||||
|
info!(
|
||||||
|
loss = qr.loss_percent(),
|
||||||
|
rtt = qr.rtt_ms(),
|
||||||
|
tier = ?quality_ctrl.tier(),
|
||||||
|
to = ?new_profile.codec,
|
||||||
|
"auto: quality adapter recommends switch"
|
||||||
|
);
|
||||||
|
pending_profile_recv.store(idx, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let is_repair = pkt.header.is_repair;
|
let is_repair = pkt.header.is_repair;
|
||||||
let pkt_block = pkt.header.fec_block;
|
let pkt_block = pkt.header.fec_block;
|
||||||
let pkt_symbol = pkt.header.fec_symbol;
|
let pkt_symbol = pkt.header.fec_symbol;
|
||||||
@@ -557,7 +860,34 @@ async fn run_call(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Source packets: decode directly
|
// Source packets: decode directly
|
||||||
if !is_repair {
|
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||||
|
// Switch decoder to match incoming codec if different
|
||||||
|
if pkt.header.codec_id != decoder.codec_id() {
|
||||||
|
let switch_profile = match pkt.header.codec_id {
|
||||||
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||||
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
},
|
||||||
|
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||||
|
};
|
||||||
|
info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||||
|
let _ = decoder.set_profile(switch_profile);
|
||||||
|
}
|
||||||
|
// Track peer codec for UI display
|
||||||
|
if last_peer_codec != Some(pkt.header.codec_id) {
|
||||||
|
last_peer_codec = Some(pkt.header.codec_id);
|
||||||
|
if let Ok(mut stats) = state.stats.lock() {
|
||||||
|
stats.peer_codec = format!("{:?}", pkt.header.codec_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||||
@@ -672,6 +1002,7 @@ async fn run_call(
|
|||||||
.map(|p| crate::stats::RoomMember {
|
.map(|p| crate::stats::RoomMember {
|
||||||
fingerprint: p.fingerprint.clone(),
|
fingerprint: p.fingerprint.clone(),
|
||||||
alias: p.alias.clone(),
|
alias: p.alias.clone(),
|
||||||
|
relay_label: p.relay_label.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut stats = state_signal.stats.lock().unwrap();
|
let mut stats = state_signal.stats.lock().unwrap();
|
||||||
|
|||||||
@@ -21,11 +21,24 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
|||||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 7 = auto (use relay's chosen profile)
|
||||||
|
const PROFILE_AUTO: jint = 7;
|
||||||
|
|
||||||
fn profile_from_int(value: jint) -> QualityProfile {
|
fn profile_from_int(value: jint) -> QualityProfile {
|
||||||
match value {
|
match value {
|
||||||
1 => QualityProfile::DEGRADED,
|
0 => QualityProfile::GOOD, // Opus 24k
|
||||||
2 => QualityProfile::CATASTROPHIC,
|
1 => QualityProfile::DEGRADED, // Opus 6k
|
||||||
_ => QualityProfile::GOOD,
|
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
||||||
|
3 => QualityProfile { // Codec2 3.2k
|
||||||
|
codec: wzp_proto::CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
},
|
||||||
|
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
||||||
|
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
||||||
|
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
||||||
|
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +54,17 @@ fn init_logging() {
|
|||||||
let _ = std::panic::catch_unwind(|| {
|
let _ = std::panic::catch_unwind(|| {
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||||
let _ = tracing_subscriber::registry().with(layer).try_init();
|
// Filter: INFO for our crates, WARN for everything else.
|
||||||
|
// The jni crate emits VERBOSE logs for every method lookup
|
||||||
|
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||||
|
// and causes the system to kill the app.
|
||||||
|
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||||
|
let _ = tracing_subscriber::registry()
|
||||||
|
.with(layer)
|
||||||
|
.with(filter)
|
||||||
|
.try_init();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -76,6 +98,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
seed_hex_j: JString,
|
seed_hex_j: JString,
|
||||||
token_j: JString,
|
token_j: JString,
|
||||||
alias_j: JString,
|
alias_j: JString,
|
||||||
|
profile_j: jint,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||||
@@ -101,7 +124,8 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = CallStartConfig {
|
let config = CallStartConfig {
|
||||||
profile: QualityProfile::GOOD,
|
profile: profile_from_int(profile_j),
|
||||||
|
auto_profile: profile_j == PROFILE_AUTO,
|
||||||
relay_addr,
|
relay_addr,
|
||||||
room,
|
room,
|
||||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||||
@@ -308,3 +332,116 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|||||||
drop(h);
|
drop(h);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ping a relay server — instance method, requires engine handle.
|
||||||
|
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
relay_j: JString,
|
||||||
|
) -> jstring {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
match h.engine.ping_relay(&relay) {
|
||||||
|
Ok(json) => Some(json),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let json = match result {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return JObject::null().into_raw(),
|
||||||
|
};
|
||||||
|
env.new_string(&json)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Direct calling JNI functions ──
|
||||||
|
|
||||||
|
/// Start persistent signaling connection to relay for direct calls.
|
||||||
|
/// Returns 0 on success, -1 on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
relay_addr_j: JString,
|
||||||
|
seed_hex_j: JString,
|
||||||
|
token_j: JString,
|
||||||
|
alias_j: JString,
|
||||||
|
) -> jint {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
|
||||||
|
h.engine.start_signaling(
|
||||||
|
&relay_addr,
|
||||||
|
&seed_hex,
|
||||||
|
if token.is_empty() { None } else { Some(&token) },
|
||||||
|
if alias.is_empty() { None } else { Some(&alias) },
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => 0,
|
||||||
|
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
|
||||||
|
Err(_) => { error!("start_signaling panicked"); -1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a direct call to a target fingerprint.
|
||||||
|
/// Returns 0 on success, -1 on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
target_fp_j: JString,
|
||||||
|
) -> jint {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
h.engine.place_call(&target)
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => 0,
|
||||||
|
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
|
||||||
|
Err(_) => { error!("place_call panicked"); -1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming direct call.
|
||||||
|
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
call_id_j: JString,
|
||||||
|
mode: jint,
|
||||||
|
) -> jint {
|
||||||
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let accept_mode = match mode {
|
||||||
|
0 => wzp_proto::CallAcceptMode::Reject,
|
||||||
|
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
||||||
|
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||||
|
};
|
||||||
|
h.engine.answer_call(&call_id, accept_mode)
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => 0,
|
||||||
|
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
|
||||||
|
Err(_) => { error!("answer_call panicked"); -1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ pub enum CallState {
|
|||||||
Active,
|
Active,
|
||||||
Reconnecting,
|
Reconnecting,
|
||||||
Closed,
|
Closed,
|
||||||
|
/// Connected to relay signal channel, registered for direct calls.
|
||||||
|
Registered,
|
||||||
|
/// Outgoing call ringing on callee's side.
|
||||||
|
Ringing,
|
||||||
|
/// Incoming call received, waiting for user to accept/reject.
|
||||||
|
IncomingCall,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for CallState {
|
impl serde::Serialize for CallState {
|
||||||
@@ -21,6 +27,9 @@ impl serde::Serialize for CallState {
|
|||||||
CallState::Active => 2,
|
CallState::Active => 2,
|
||||||
CallState::Reconnecting => 3,
|
CallState::Reconnecting => 3,
|
||||||
CallState::Closed => 4,
|
CallState::Closed => 4,
|
||||||
|
CallState::Registered => 5,
|
||||||
|
CallState::Ringing => 6,
|
||||||
|
CallState::IncomingCall => 7,
|
||||||
};
|
};
|
||||||
serializer.serialize_u8(n)
|
serializer.serialize_u8(n)
|
||||||
}
|
}
|
||||||
@@ -59,10 +68,28 @@ pub struct CallStats {
|
|||||||
pub capture_overflows: u64,
|
pub capture_overflows: u64,
|
||||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||||
pub audio_level: u32,
|
pub audio_level: u32,
|
||||||
|
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
||||||
|
pub current_codec: String,
|
||||||
|
/// Last seen incoming codec from other participants.
|
||||||
|
pub peer_codec: String,
|
||||||
|
/// Whether auto quality mode is active.
|
||||||
|
pub auto_mode: bool,
|
||||||
/// Number of participants in the room (from last RoomUpdate).
|
/// Number of participants in the room (from last RoomUpdate).
|
||||||
pub room_participant_count: u32,
|
pub room_participant_count: u32,
|
||||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||||
pub room_participants: Vec<RoomMember>,
|
pub room_participants: Vec<RoomMember>,
|
||||||
|
/// SAS code for verbal verification (None if not in a call).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sas_code: Option<u32>,
|
||||||
|
/// Incoming call info (present when state == IncomingCall).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_call_id: Option<String>,
|
||||||
|
/// Fingerprint of the caller (present when state == IncomingCall).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_fp: Option<String>,
|
||||||
|
/// Alias of the caller (present when state == IncomingCall).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_alias: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room member entry, serialized into the stats JSON.
|
/// A room member entry, serialized into the stats JSON.
|
||||||
@@ -70,4 +97,5 @@ pub struct CallStats {
|
|||||||
pub struct RoomMember {
|
pub struct RoomMember {
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
|
pub relay_label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,71 @@ serde_json = "1"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
||||||
|
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
||||||
|
# pulling in a crate that can only link against Apple frameworks.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
coreaudio-rs = { version = "0.11", optional = true }
|
||||||
|
|
||||||
|
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
|
||||||
|
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
|
||||||
|
# only the audio + COM subfeatures we need — the crate is organized as
|
||||||
|
# a massive optional-feature tree, so enabling just these keeps compile
|
||||||
|
# times reasonable (~5s for these features vs ~60s for the full crate).
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", optional = true, features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Media_Audio",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_System_Com_StructuredStorage",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_Variant",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
|
||||||
|
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
|
||||||
|
# webrtc-audio-processing` crate, which links against Debian's
|
||||||
|
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
|
||||||
|
#
|
||||||
|
# Note: we attempted the 2.x line with its `bundled` sub-feature first
|
||||||
|
# (which would give us AEC3 instead of AEC2), but both the crates.io
|
||||||
|
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
|
||||||
|
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
|
||||||
|
# --reconfigure unconditionally even on first-run empty build dirs,
|
||||||
|
# causing the bundled build to fail with "Directory does not contain a
|
||||||
|
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
|
||||||
|
# this entirely by linking the apt-provided library. AEC2 is older than
|
||||||
|
# AEC3 but still the same algorithm family — this is what PulseAudio's
|
||||||
|
# module-echo-cancel and PipeWire's filter-chain use by default on
|
||||||
|
# current Debian-family distros.
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
webrtc-audio-processing = { version = "0.3", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
|
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
|
||||||
|
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
|
||||||
|
# module is also #[cfg(target_os = "macos")] in lib.rs).
|
||||||
|
vpio = ["dep:coreaudio-rs"]
|
||||||
|
# windows-aec enables a direct WASAPI capture backend that opens the
|
||||||
|
# microphone under AudioCategory_Communications, turning on Windows's
|
||||||
|
# OS-level communications audio processing (AEC + noise suppression +
|
||||||
|
# AGC). The `windows` dep is itself target-gated to Windows above, so
|
||||||
|
# enabling this feature on non-Windows targets is a no-op (the
|
||||||
|
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
|
||||||
|
windows-aec = ["dep:windows"]
|
||||||
|
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
|
||||||
|
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
|
||||||
|
# Teams) in-process, using the playback PCM as the reference signal for
|
||||||
|
# echo cancellation. The webrtc-audio-processing dep is target-gated to
|
||||||
|
# Linux above, so enabling this feature on non-Linux targets is a no-op
|
||||||
|
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
|
||||||
|
# lib.rs).
|
||||||
|
linux-aec = ["dep:webrtc-audio-processing"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||||
//!
|
//!
|
||||||
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||||
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||||
//! channel handles.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
|
|||||||
// AudioCapture
|
// AudioCapture
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Captures microphone input and yields 960-sample PCM frames.
|
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioCapture {
|
pub struct AudioCapture {
|
||||||
rx: mpsc::Receiver<Vec<i16>>,
|
ring: Arc<AudioRing>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCapture {
|
impl AudioCapture {
|
||||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
let ring = Arc::new(AudioRing::new());
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_clone = running.clone();
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-capture".into())
|
.name("wzp-audio-capture".into())
|
||||||
@@ -59,53 +61,51 @@ impl AudioCapture {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
let use_f32 = !supports_i16_input(&device)?;
|
||||||
|
|
||||||
let buf = Arc::new(std::sync::Mutex::new(
|
|
||||||
Vec::<i16>::with_capacity(FRAME_SAMPLES),
|
|
||||||
));
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("input stream error: {e}");
|
warn!("input stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let logged_cb_size = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let buf = buf.clone();
|
let ring = ring_cb.clone();
|
||||||
let tx = tx.clone();
|
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
|
let logged = logged_cb_size.clone();
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut lock = buf.lock().unwrap();
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
for &s in data {
|
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
||||||
lock.push(f32_to_i16(s));
|
}
|
||||||
if lock.len() == FRAME_SAMPLES {
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
let frame = lock.drain(..).collect();
|
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||||
let _ = tx.try_send(frame);
|
let n = chunk.len();
|
||||||
|
for i in 0..n {
|
||||||
|
tmp[i] = f32_to_i16(chunk[i]);
|
||||||
}
|
}
|
||||||
|
ring.write(&tmp[..n]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let buf = buf.clone();
|
let ring = ring_cb.clone();
|
||||||
let tx = tx.clone();
|
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
|
let logged = logged_cb_size.clone();
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut lock = buf.lock().unwrap();
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
for &s in data {
|
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
||||||
lock.push(s);
|
|
||||||
if lock.len() == FRAME_SAMPLES {
|
|
||||||
let frame = lock.drain(..).collect();
|
|
||||||
let _ = tx.try_send(frame);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ring.write(data);
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
@@ -114,7 +114,6 @@ impl AudioCapture {
|
|||||||
|
|
||||||
stream.play().context("failed to start input stream")?;
|
stream.play().context("failed to start input stream")?;
|
||||||
|
|
||||||
// Signal success to the caller before parking.
|
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -135,15 +134,12 @@ impl AudioCapture {
|
|||||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { rx, running })
|
Ok(Self { ring, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the next frame of 960 PCM samples (blocking until available).
|
/// Get a reference to the capture ring buffer for direct polling.
|
||||||
///
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
/// Returns `None` when the stream has been stopped or the channel is
|
&self.ring
|
||||||
/// disconnected.
|
|
||||||
pub fn read_frame(&self) -> Option<Vec<i16>> {
|
|
||||||
self.rx.recv().ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop capturing.
|
/// Stop capturing.
|
||||||
@@ -152,26 +148,34 @@ impl AudioCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AudioPlayback
|
// AudioPlayback
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Plays PCM frames through the default output device at 48 kHz mono.
|
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioPlayback {
|
pub struct AudioPlayback {
|
||||||
tx: mpsc::SyncSender<Vec<i16>>,
|
ring: Arc<AudioRing>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Create and start playback on the default output device at 48 kHz mono.
|
/// Create and start playback on the default output device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
let ring = Arc::new(AudioRing::new());
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_clone = running.clone();
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-playback".into())
|
.name("wzp-audio-playback".into())
|
||||||
@@ -192,62 +196,40 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
let use_f32 = !supports_i16_output(&device)?;
|
||||||
|
|
||||||
// Shared ring of samples the cpal callback drains from.
|
|
||||||
let ring = Arc::new(std::sync::Mutex::new(
|
|
||||||
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Background drainer: moves frames from the mpsc channel into the ring.
|
|
||||||
{
|
|
||||||
let ring = ring.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-playback-drain".into())
|
|
||||||
.spawn(move || {
|
|
||||||
while running.load(Ordering::Relaxed) {
|
|
||||||
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
|
||||||
Ok(frame) => {
|
|
||||||
let mut lock = ring.lock().unwrap();
|
|
||||||
lock.extend(frame);
|
|
||||||
while lock.len() > FRAME_SAMPLES * 16 {
|
|
||||||
lock.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("output stream error: {e}");
|
warn!("output stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring.clone();
|
let ring = ring_cb.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut lock = ring.lock().unwrap();
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
for sample in data.iter_mut() {
|
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
||||||
*sample = match lock.pop_front() {
|
let n = chunk.len();
|
||||||
Some(s) => i16_to_f32(s),
|
let read = ring.read(&mut tmp[..n]);
|
||||||
None => 0.0,
|
for i in 0..read {
|
||||||
};
|
chunk[i] = i16_to_f32(tmp[i]);
|
||||||
|
}
|
||||||
|
// Fill remainder with silence if ring underran
|
||||||
|
for i in read..n {
|
||||||
|
chunk[i] = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring.clone();
|
let ring = ring_cb.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut lock = ring.lock().unwrap();
|
let read = ring.read(data);
|
||||||
for sample in data.iter_mut() {
|
// Fill remainder with silence if ring underran
|
||||||
*sample = lock.pop_front().unwrap_or(0);
|
for sample in &mut data[read..] {
|
||||||
|
*sample = 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
@@ -257,7 +239,6 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
stream.play().context("failed to start output stream")?;
|
stream.play().context("failed to start output stream")?;
|
||||||
|
|
||||||
// Signal success to the caller before parking.
|
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -278,12 +259,12 @@ impl AudioPlayback {
|
|||||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { tx, running })
|
Ok(Self { ring, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a frame of PCM samples for playback.
|
/// Get a reference to the playout ring buffer for direct writing.
|
||||||
pub fn write_frame(&self, pcm: &[i16]) {
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
let _ = self.tx.try_send(pcm.to_vec());
|
&self.ring
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop playback.
|
/// Stop playback.
|
||||||
@@ -292,11 +273,16 @@ impl AudioPlayback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioPlayback {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Check if the input device supports i16 at 48 kHz mono.
|
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_input_configs()
|
.supported_input_configs()
|
||||||
@@ -313,7 +299,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the output device supports i16 at 48 kHz mono.
|
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_output_configs()
|
.supported_output_configs()
|
||||||
|
|||||||
537
crates/wzp-client/src/audio_linux_aec.rs
Normal file
537
crates/wzp-client/src/audio_linux_aec.rs
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
|
||||||
|
//! Processing Module (AEC3 + noise suppression + high-pass filter).
|
||||||
|
//!
|
||||||
|
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
|
||||||
|
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
|
||||||
|
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
|
||||||
|
//! identically on ALSA / PulseAudio / PipeWire systems.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
|
||||||
|
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
|
||||||
|
//! mono):
|
||||||
|
//!
|
||||||
|
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
|
||||||
|
//! output thread, but wraps each chunk in a call to
|
||||||
|
//! `Processor::process_render_frame` **before** handing it to CPAL. That
|
||||||
|
//! gives APM an authoritative reference of exactly what's going out to
|
||||||
|
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
|
||||||
|
//! what to cancel when it sees echo in the capture stream.
|
||||||
|
//!
|
||||||
|
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
|
||||||
|
//! input thread, and runs `Processor::process_capture_frame` on each
|
||||||
|
//! incoming mic chunk **in place** before pushing it into the ring
|
||||||
|
//! buffer. The AEC subtracts the echo using the render reference it
|
||||||
|
//! saw on the playback side.
|
||||||
|
//!
|
||||||
|
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
|
||||||
|
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
|
||||||
|
//! frame is split into two 480-sample halves, APM is called twice, and the
|
||||||
|
//! halves are stitched back together.
|
||||||
|
//!
|
||||||
|
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
|
||||||
|
//! before the call and f32 → i16 after (with clamping on the return path).
|
||||||
|
//!
|
||||||
|
//! ## Stream delay
|
||||||
|
//!
|
||||||
|
//! AEC needs to know roughly how long it takes between a sample being passed
|
||||||
|
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
|
||||||
|
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
|
||||||
|
//! → CPAL capture. AEC3's internal estimator tracks this within a window
|
||||||
|
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
|
||||||
|
//! starting point for typical Linux audio stacks; the delay estimator does
|
||||||
|
//! the fine-tuning automatically.
|
||||||
|
//!
|
||||||
|
//! ## Thread safety
|
||||||
|
//!
|
||||||
|
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
|
||||||
|
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
|
||||||
|
//! needs a `Mutex` around it for cross-thread sharing. The capture and
|
||||||
|
//! playback threads each acquire the lock briefly (sub-millisecond per
|
||||||
|
//! 10 ms frame) so contention is minimal at our frame rates.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use webrtc_audio_processing::{
|
||||||
|
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
||||||
|
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
|
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
|
||||||
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
|
||||||
|
/// Imported from the webrtc-audio-processing crate so we can't drift out
|
||||||
|
/// of sync with whatever sample rate / frame length the C++ lib is using.
|
||||||
|
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
|
||||||
|
const APM_NUM_CHANNELS: usize = 1;
|
||||||
|
/// Round-trip delay hint passed to APM; the estimator refines from here.
|
||||||
|
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
const STREAM_DELAY_MS: i32 = 60;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared APM instance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Module-level lazily-initialized APM. Shared between capture and playback
|
||||||
|
/// so they operate on the same echo-cancellation state — the render frames
|
||||||
|
/// pushed by playback are what the capture path subtracts from the mic input.
|
||||||
|
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
|
||||||
|
/// process_capture_frame and process_render_frame.
|
||||||
|
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
||||||
|
if let Some(p) = PROCESSOR.get() {
|
||||||
|
return Ok(p.clone());
|
||||||
|
}
|
||||||
|
let init_config = InitializationConfig {
|
||||||
|
num_capture_channels: APM_NUM_CHANNELS as i32,
|
||||||
|
num_render_channels: APM_NUM_CHANNELS as i32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut processor = Processor::new(&init_config)
|
||||||
|
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
echo_cancellation: Some(EchoCancellation {
|
||||||
|
suppression_level: EchoCancellationSuppressionLevel::High,
|
||||||
|
stream_delay_ms: Some(STREAM_DELAY_MS),
|
||||||
|
enable_delay_agnostic: true,
|
||||||
|
enable_extended_filter: true,
|
||||||
|
}),
|
||||||
|
noise_suppression: Some(NoiseSuppression {
|
||||||
|
suppression_level: NoiseSuppressionLevel::High,
|
||||||
|
}),
|
||||||
|
enable_high_pass_filter: true,
|
||||||
|
// AGC left off for now — it can fight the Opus encoder's own gain
|
||||||
|
// staging and the adaptive-quality controller. Add later if users
|
||||||
|
// report low mic levels.
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
processor.set_config(config);
|
||||||
|
|
||||||
|
let arc = Arc::new(Mutex::new(processor));
|
||||||
|
let _ = PROCESSOR.set(arc.clone());
|
||||||
|
info!(
|
||||||
|
stream_delay_ms = STREAM_DELAY_MS,
|
||||||
|
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
|
||||||
|
);
|
||||||
|
Ok(arc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers: i16 ↔ f32 and APM frame processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn i16_to_f32(s: i16) -> f32 {
|
||||||
|
s as f32 / 32768.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn f32_to_i16(s: f32) -> i16 {
|
||||||
|
(s.clamp(-1.0, 1.0) * 32767.0) as i16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
|
||||||
|
/// Splits into two 10 ms halves because APM is strict about frame size.
|
||||||
|
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
|
||||||
|
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
|
||||||
|
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
||||||
|
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
||||||
|
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
|
||||||
|
for (i, &s) in half.iter().enumerate() {
|
||||||
|
buf[i] = i16_to_f32(s);
|
||||||
|
}
|
||||||
|
match apm.lock() {
|
||||||
|
Ok(mut p) => {
|
||||||
|
if let Err(e) = p.process_render_frame(&mut buf) {
|
||||||
|
warn!("webrtc APM process_render_frame failed: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("webrtc APM mutex poisoned in render path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
|
||||||
|
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
|
||||||
|
/// results back into the caller's buffer. Briefly holds the Mutex once
|
||||||
|
/// per 10 ms half.
|
||||||
|
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
|
||||||
|
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
||||||
|
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
||||||
|
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
|
||||||
|
for (i, &s) in half.iter().enumerate() {
|
||||||
|
buf[i] = i16_to_f32(s);
|
||||||
|
}
|
||||||
|
match apm.lock() {
|
||||||
|
Ok(mut p) => {
|
||||||
|
if let Err(e) = p.process_capture_frame(&mut buf) {
|
||||||
|
warn!("webrtc APM process_capture_frame failed: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("webrtc APM mutex poisoned in capture path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, d) in half.iter_mut().enumerate() {
|
||||||
|
*d = f32_to_i16(buf[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Microphone capture with WebRTC AEC3 applied in place before the codec
|
||||||
|
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
|
||||||
|
/// downstream code doesn't change.
|
||||||
|
pub struct LinuxAecCapture {
|
||||||
|
ring: Arc<AudioRing>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxAecCapture {
|
||||||
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
|
// Eagerly init the APM so the playback side can find it already
|
||||||
|
// configured, and so init errors surface on the caller thread
|
||||||
|
// instead of silently failing inside the capture thread.
|
||||||
|
let apm = get_or_init_processor()?;
|
||||||
|
|
||||||
|
let ring = Arc::new(AudioRing::new());
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
let apm_capture = apm.clone();
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-audio-capture-linuxaec".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = (|| -> Result<(), anyhow::Error> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or_else(|| anyhow!("no default input audio device found"))?;
|
||||||
|
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
|
||||||
|
|
||||||
|
let config = StreamConfig {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: SampleRate(48_000),
|
||||||
|
buffer_size: cpal::BufferSize::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_f32 = !supports_i16_input(&device)?;
|
||||||
|
|
||||||
|
let err_cb = |e: cpal::StreamError| {
|
||||||
|
warn!("LinuxAEC input stream error: {e}");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leftover buffer for when CPAL gives us partial frames.
|
||||||
|
// We need exactly 960-sample chunks to feed APM.
|
||||||
|
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
||||||
|
|
||||||
|
let stream = if use_f32 {
|
||||||
|
let ring = ring_cb.clone();
|
||||||
|
let running = running_clone.clone();
|
||||||
|
let apm = apm_capture.clone();
|
||||||
|
device.build_input_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||||
|
if !running.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut lv = leftover.lock().unwrap();
|
||||||
|
lv.reserve(data.len());
|
||||||
|
for &s in data {
|
||||||
|
lv.push(f32_to_i16(s));
|
||||||
|
}
|
||||||
|
drain_frames_through_apm(&mut lv, &apm, &ring);
|
||||||
|
},
|
||||||
|
err_cb,
|
||||||
|
None,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
let ring = ring_cb.clone();
|
||||||
|
let running = running_clone.clone();
|
||||||
|
let apm = apm_capture.clone();
|
||||||
|
device.build_input_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||||
|
if !running.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut lv = leftover.lock().unwrap();
|
||||||
|
lv.extend_from_slice(data);
|
||||||
|
drain_frames_through_apm(&mut lv, &apm, &ring);
|
||||||
|
},
|
||||||
|
err_cb,
|
||||||
|
None,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.play().context("failed to start LinuxAEC input stream")?;
|
||||||
|
let _ = init_tx.send(Ok(()));
|
||||||
|
info!("LinuxAEC capture started (AEC3 active)");
|
||||||
|
|
||||||
|
while running_clone.load(Ordering::Relaxed) {
|
||||||
|
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = init_tx.send(Err(e.to_string()));
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
init_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
Ok(Self { ring, running })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LinuxAecCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull whole 960-sample frames out of the leftover buffer, run them through
|
||||||
|
/// APM's capture-side processing, and push to the ring. Leaves any partial
|
||||||
|
/// sub-960 remainder in `leftover` for the next callback.
|
||||||
|
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
|
||||||
|
let mut frame = [0i16; FRAME_SAMPLES];
|
||||||
|
while leftover.len() >= FRAME_SAMPLES {
|
||||||
|
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
|
||||||
|
process_capture_frame_20ms(apm, &mut frame);
|
||||||
|
ring.write(&frame);
|
||||||
|
leftover.drain(..FRAME_SAMPLES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Speaker playback with a render-side tee: each frame written to CPAL is
|
||||||
|
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
|
||||||
|
/// reference signal. This is the "tee the playback ring" approach (Zoom,
|
||||||
|
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
|
||||||
|
/// PipeWire monitor sources.
|
||||||
|
pub struct LinuxAecPlayback {
|
||||||
|
ring: Arc<AudioRing>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxAecPlayback {
|
||||||
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
|
let apm = get_or_init_processor()?;
|
||||||
|
|
||||||
|
let ring = Arc::new(AudioRing::new());
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
let apm_render = apm.clone();
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-audio-playback-linuxaec".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = (|| -> Result<(), anyhow::Error> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or_else(|| anyhow!("no default output audio device found"))?;
|
||||||
|
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
|
||||||
|
|
||||||
|
let config = StreamConfig {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: SampleRate(48_000),
|
||||||
|
buffer_size: cpal::BufferSize::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_f32 = !supports_i16_output(&device)?;
|
||||||
|
|
||||||
|
let err_cb = |e: cpal::StreamError| {
|
||||||
|
warn!("LinuxAEC output stream error: {e}");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same 960-sample batching approach as the capture side:
|
||||||
|
// CPAL may ask for N samples in a callback where N doesn't
|
||||||
|
// divide 960. We accumulate partial frames in a Vec and
|
||||||
|
// feed APM as soon as we have a whole 20 ms frame.
|
||||||
|
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
||||||
|
|
||||||
|
let stream = if use_f32 {
|
||||||
|
let ring = ring_cb.clone();
|
||||||
|
let apm = apm_render.clone();
|
||||||
|
device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
|
fill_output_and_tee_f32(data, &ring, &apm, &carry);
|
||||||
|
},
|
||||||
|
err_cb,
|
||||||
|
None,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
let ring = ring_cb.clone();
|
||||||
|
let apm = apm_render.clone();
|
||||||
|
device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
|
fill_output_and_tee_i16(data, &ring, &apm, &carry);
|
||||||
|
},
|
||||||
|
err_cb,
|
||||||
|
None,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.play().context("failed to start LinuxAEC output stream")?;
|
||||||
|
let _ = init_tx.send(Ok(()));
|
||||||
|
info!("LinuxAEC playback started (render tee active)");
|
||||||
|
|
||||||
|
while running_clone.load(Ordering::Relaxed) {
|
||||||
|
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = init_tx.send(Err(e.to_string()));
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
init_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
Ok(Self { ring, running })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LinuxAecPlayback {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_output_and_tee_i16(
|
||||||
|
data: &mut [i16],
|
||||||
|
ring: &AudioRing,
|
||||||
|
apm: &Mutex<Processor>,
|
||||||
|
carry: &std::sync::Mutex<Vec<i16>>,
|
||||||
|
) {
|
||||||
|
let read = ring.read(data);
|
||||||
|
for s in &mut data[read..] {
|
||||||
|
*s = 0;
|
||||||
|
}
|
||||||
|
tee_render_samples(data, apm, carry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_output_and_tee_f32(
|
||||||
|
data: &mut [f32],
|
||||||
|
ring: &AudioRing,
|
||||||
|
apm: &Mutex<Processor>,
|
||||||
|
carry: &std::sync::Mutex<Vec<i16>>,
|
||||||
|
) {
|
||||||
|
let mut tmp = vec![0i16; data.len()];
|
||||||
|
let read = ring.read(&mut tmp);
|
||||||
|
for s in &mut tmp[read..] {
|
||||||
|
*s = 0;
|
||||||
|
}
|
||||||
|
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
|
||||||
|
*d = i16_to_f32(s);
|
||||||
|
}
|
||||||
|
tee_render_samples(&tmp, apm, carry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
|
||||||
|
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
|
||||||
|
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
|
||||||
|
let mut lv = carry.lock().unwrap();
|
||||||
|
lv.extend_from_slice(samples);
|
||||||
|
while lv.len() >= FRAME_SAMPLES {
|
||||||
|
let mut frame = [0i16; FRAME_SAMPLES];
|
||||||
|
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
|
||||||
|
push_render_frame_20ms(apm, &frame);
|
||||||
|
lv.drain(..FRAME_SAMPLES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
|
||||||
|
// independent — each backend file is a self-contained unit)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
|
let supported = device
|
||||||
|
.supported_input_configs()
|
||||||
|
.context("failed to query input configs")?;
|
||||||
|
for cfg in supported {
|
||||||
|
if cfg.sample_format() == SampleFormat::I16
|
||||||
|
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
||||||
|
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
||||||
|
&& cfg.channels() >= 1
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
|
let supported = device
|
||||||
|
.supported_output_configs()
|
||||||
|
.context("failed to query output configs")?;
|
||||||
|
for cfg in supported {
|
||||||
|
if cfg.sample_format() == SampleFormat::I16
|
||||||
|
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
||||||
|
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
||||||
|
&& cfg.channels() >= 1
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
122
crates/wzp-client/src/audio_ring.rs
Normal file
122
crates/wzp-client/src/audio_ring.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||||
|
//!
|
||||||
|
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||||
|
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||||
|
//!
|
||||||
|
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||||
|
//! old buffer data. The reader detects the lap via `available() >
|
||||||
|
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||||
|
//!
|
||||||
|
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||||
|
/// 16384 samples = 341.3ms at 48kHz mono.
|
||||||
|
const RING_CAPACITY: usize = 16384; // 2^14
|
||||||
|
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||||
|
|
||||||
|
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||||
|
pub struct AudioRing {
|
||||||
|
buf: Box<[i16]>,
|
||||||
|
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||||
|
write_pos: AtomicUsize,
|
||||||
|
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||||
|
read_pos: AtomicUsize,
|
||||||
|
/// Incremented by reader when it detects it was lapped (overflow).
|
||||||
|
overflow_count: AtomicU64,
|
||||||
|
/// Incremented by reader when ring is empty (underrun).
|
||||||
|
underrun_count: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||||
|
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||||
|
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||||
|
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||||
|
unsafe impl Send for AudioRing {}
|
||||||
|
unsafe impl Sync for AudioRing {}
|
||||||
|
|
||||||
|
impl AudioRing {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||||
|
Self {
|
||||||
|
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||||
|
write_pos: AtomicUsize::new(0),
|
||||||
|
read_pos: AtomicUsize::new(0),
|
||||||
|
overflow_count: AtomicU64::new(0),
|
||||||
|
underrun_count: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of samples available to read (clamped to capacity).
|
||||||
|
pub fn available(&self) -> usize {
|
||||||
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
|
let r = self.read_pos.load(Ordering::Relaxed);
|
||||||
|
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write samples into the ring. Returns number of samples written.
|
||||||
|
///
|
||||||
|
/// If the ring is full, old data is silently overwritten. The reader
|
||||||
|
/// will detect the lap and self-correct. The writer NEVER touches
|
||||||
|
/// `read_pos`.
|
||||||
|
pub fn write(&self, samples: &[i16]) -> usize {
|
||||||
|
let count = samples.len().min(RING_CAPACITY);
|
||||||
|
let w = self.write_pos.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
unsafe {
|
||||||
|
let ptr = self.buf.as_ptr() as *mut i16;
|
||||||
|
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_pos
|
||||||
|
.store(w.wrapping_add(count), Ordering::Release);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||||
|
///
|
||||||
|
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||||
|
/// forward to the oldest valid data.
|
||||||
|
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||||
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
|
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
let mut avail = w.wrapping_sub(r);
|
||||||
|
|
||||||
|
// Lap detection: writer has overwritten our unread data.
|
||||||
|
if avail > RING_CAPACITY {
|
||||||
|
r = w.wrapping_sub(RING_CAPACITY);
|
||||||
|
avail = RING_CAPACITY;
|
||||||
|
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = out.len().min(avail);
|
||||||
|
if count == 0 {
|
||||||
|
if w == r {
|
||||||
|
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||||
|
}
|
||||||
|
|
||||||
|
self.read_pos
|
||||||
|
.store(r.wrapping_add(count), Ordering::Release);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of overflow events (reader was lapped by writer).
|
||||||
|
pub fn overflow_count(&self) -> u64 {
|
||||||
|
self.overflow_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of underrun events (reader found empty buffer).
|
||||||
|
pub fn underrun_count(&self) -> u64 {
|
||||||
|
self.underrun_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
crates/wzp-client/src/audio_vpio.rs
Normal file
179
crates/wzp-client/src/audio_vpio.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! macOS Voice Processing I/O — uses Apple's VoiceProcessingIO audio unit
|
||||||
|
//! for hardware-accelerated echo cancellation, AGC, and noise suppression.
|
||||||
|
//!
|
||||||
|
//! VoiceProcessingIO is a combined input+output unit that knows what's going
|
||||||
|
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
||||||
|
//! This is the same engine FaceTime and other Apple apps use.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||||
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
|
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
|
||||||
|
use coreaudio::sys;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
|
/// Combined capture + playback via macOS VoiceProcessingIO.
|
||||||
|
///
|
||||||
|
/// The OS handles AEC internally — no manual far-end feeding needed.
|
||||||
|
pub struct VpioAudio {
|
||||||
|
capture_ring: Arc<AudioRing>,
|
||||||
|
playout_ring: Arc<AudioRing>,
|
||||||
|
_audio_unit: AudioUnit,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VpioAudio {
|
||||||
|
/// Start VoiceProcessingIO with AEC enabled.
|
||||||
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
|
let capture_ring = Arc::new(AudioRing::new());
|
||||||
|
let playout_ring = Arc::new(AudioRing::new());
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
||||||
|
.context("failed to create VoiceProcessingIO audio unit")?;
|
||||||
|
|
||||||
|
// Must uninitialize before configuring properties.
|
||||||
|
au.uninitialize()
|
||||||
|
.context("failed to uninitialize VPIO for configuration")?;
|
||||||
|
|
||||||
|
// Enable input (mic) on Element::Input (bus 1).
|
||||||
|
let enable: u32 = 1;
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioOutputUnitProperty_EnableIO,
|
||||||
|
Scope::Input,
|
||||||
|
Element::Input,
|
||||||
|
Some(&enable),
|
||||||
|
)
|
||||||
|
.context("failed to enable VPIO input")?;
|
||||||
|
|
||||||
|
// Output (speaker) is enabled by default on VPIO, but be explicit.
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioOutputUnitProperty_EnableIO,
|
||||||
|
Scope::Output,
|
||||||
|
Element::Output,
|
||||||
|
Some(&enable),
|
||||||
|
)
|
||||||
|
.context("failed to enable VPIO output")?;
|
||||||
|
|
||||||
|
// Configure stream format: 48kHz mono f32 non-interleaved
|
||||||
|
let stream_format = StreamFormat {
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
sample_format: SampleFormat::F32,
|
||||||
|
flags: LinearPcmFlags::IS_FLOAT
|
||||||
|
| LinearPcmFlags::IS_PACKED
|
||||||
|
| LinearPcmFlags::IS_NON_INTERLEAVED,
|
||||||
|
channels: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let asbd = stream_format.to_asbd();
|
||||||
|
|
||||||
|
// Input: set format on Output scope of Input element
|
||||||
|
// (= the format the AU delivers to us from the mic)
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioUnitProperty_StreamFormat,
|
||||||
|
Scope::Output,
|
||||||
|
Element::Input,
|
||||||
|
Some(&asbd),
|
||||||
|
)
|
||||||
|
.context("failed to set input stream format")?;
|
||||||
|
|
||||||
|
// Output: set format on Input scope of Output element
|
||||||
|
// (= the format we feed to the AU for the speaker)
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioUnitProperty_StreamFormat,
|
||||||
|
Scope::Input,
|
||||||
|
Element::Output,
|
||||||
|
Some(&asbd),
|
||||||
|
)
|
||||||
|
.context("failed to set output stream format")?;
|
||||||
|
|
||||||
|
// Set up input callback (mic capture with AEC applied)
|
||||||
|
let cap_ring = capture_ring.clone();
|
||||||
|
let cap_running = running.clone();
|
||||||
|
let logged = Arc::new(AtomicBool::new(false));
|
||||||
|
au.set_input_callback(
|
||||||
|
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
|
if !cap_running.load(Ordering::Relaxed) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut buffers = args.data.channels();
|
||||||
|
if let Some(ch) = buffers.next() {
|
||||||
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
||||||
|
}
|
||||||
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
|
for chunk in ch.chunks(FRAME_SAMPLES) {
|
||||||
|
let n = chunk.len();
|
||||||
|
for i in 0..n {
|
||||||
|
tmp[i] = (chunk[i].clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
cap_ring.write(&tmp[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("failed to set input callback")?;
|
||||||
|
|
||||||
|
// Set up output callback (speaker playback — AEC uses this as reference)
|
||||||
|
let play_ring = playout_ring.clone();
|
||||||
|
au.set_render_callback(
|
||||||
|
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
|
let mut buffers = args.data.channels_mut();
|
||||||
|
if let Some(ch) = buffers.next() {
|
||||||
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
|
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
||||||
|
let n = chunk.len();
|
||||||
|
let read = play_ring.read(&mut tmp[..n]);
|
||||||
|
for i in 0..read {
|
||||||
|
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
||||||
|
}
|
||||||
|
for i in read..n {
|
||||||
|
chunk[i] = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("failed to set render callback")?;
|
||||||
|
|
||||||
|
au.initialize().context("failed to initialize VoiceProcessingIO")?;
|
||||||
|
au.start().context("failed to start VoiceProcessingIO")?;
|
||||||
|
|
||||||
|
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
capture_ring,
|
||||||
|
playout_ring,
|
||||||
|
_audio_unit: au,
|
||||||
|
running,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.capture_ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn playout_ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.playout_ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VpioAudio {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
332
crates/wzp-client/src/audio_wasapi.rs
Normal file
332
crates/wzp-client/src/audio_wasapi.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
|
||||||
|
//!
|
||||||
|
//! Bypasses CPAL and opens the default capture endpoint directly via
|
||||||
|
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
|
||||||
|
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
|
||||||
|
//! the switch that tells Windows "this is a VoIP call" — the OS then
|
||||||
|
//! enables its communications audio processing chain (AEC, noise
|
||||||
|
//! suppression, automatic gain control) for the stream. AEC operates at
|
||||||
|
//! the OS level using the currently-playing audio as the reference
|
||||||
|
//! signal, so it cancels echo from our CPAL playback (and any other app's
|
||||||
|
//! audio) without us having to plumb a reference signal ourselves.
|
||||||
|
//!
|
||||||
|
//! Platform: Windows only, compiled only when the `windows-aec` feature
|
||||||
|
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
|
||||||
|
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
||||||
|
//! `AudioCapture`.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use windows::core::{Interface, GUID};
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
|
||||||
|
use windows::Win32::Media::Audio::{
|
||||||
|
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
|
||||||
|
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
|
||||||
|
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
||||||
|
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
|
||||||
|
WAVE_FORMAT_PCM,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
|
/// 20 ms at 48 kHz, mono. Matches the rest of the audio pipeline.
|
||||||
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
|
/// Microphone capture via WASAPI with Windows's communications AEC enabled.
|
||||||
|
///
|
||||||
|
/// The WASAPI capture stream runs on a dedicated OS thread. This handle is
|
||||||
|
/// `Send + Sync`. Dropping it stops the stream and joins the thread.
|
||||||
|
pub struct WasapiAudioCapture {
|
||||||
|
ring: Arc<AudioRing>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WasapiAudioCapture {
|
||||||
|
/// Open the default communications microphone, enable OS AEC, and start
|
||||||
|
/// streaming PCM into a lock-free ring buffer.
|
||||||
|
///
|
||||||
|
/// Returns only after the capture thread has successfully initialized
|
||||||
|
/// the stream, or propagates the error back to the caller.
|
||||||
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
|
let ring = Arc::new(AudioRing::new());
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_cb = running.clone();
|
||||||
|
|
||||||
|
let thread = std::thread::Builder::new()
|
||||||
|
.name("wzp-audio-capture-wasapi".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = unsafe { capture_thread_main(ring_cb, running_cb.clone(), &init_tx) };
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("wasapi capture thread exited with error: {e}");
|
||||||
|
// If we failed before signaling init, signal now so the
|
||||||
|
// caller unblocks. Double-send is harmless (channel is
|
||||||
|
// bounded to 1 and we only hit the second send path on
|
||||||
|
// late errors).
|
||||||
|
let _ = init_tx.send(Err(e.to_string()));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.context("failed to spawn WASAPI capture thread")?;
|
||||||
|
|
||||||
|
init_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| anyhow!("WASAPI capture thread exited before signaling init"))?
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ring,
|
||||||
|
running,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the capture ring buffer for direct polling.
|
||||||
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.ring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop capturing.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for WasapiAudioCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
if let Some(handle) = self.thread.take() {
|
||||||
|
// Join best-effort. The thread loop polls `running` every 200ms
|
||||||
|
// via a short WaitForSingleObject timeout, so it should exit
|
||||||
|
// within ~200ms of `stop()`.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WASAPI thread entry point — everything below this line runs on the
|
||||||
|
// dedicated wzp-audio-capture-wasapi thread.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
unsafe fn capture_thread_main(
|
||||||
|
ring: Arc<AudioRing>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
init_tx: &std::sync::mpsc::SyncSender<Result<(), String>>,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
// COM init for the capture thread. MULTITHREADED because we're not
|
||||||
|
// running a message pump. Must be balanced by CoUninitialize on exit.
|
||||||
|
CoInitializeEx(None, COINIT_MULTITHREADED)
|
||||||
|
.ok()
|
||||||
|
.context("CoInitializeEx failed")?;
|
||||||
|
|
||||||
|
// Use a guard struct so CoUninitialize runs even on early returns.
|
||||||
|
struct ComGuard;
|
||||||
|
impl Drop for ComGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { CoUninitialize() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _com_guard = ComGuard;
|
||||||
|
|
||||||
|
let enumerator: IMMDeviceEnumerator =
|
||||||
|
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
||||||
|
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
||||||
|
|
||||||
|
// eCommunications role (not eConsole) — this picks the device the user
|
||||||
|
// has designated for communications in Sound Settings. It's the one
|
||||||
|
// Windows's AEC is actually tuned for and the one Teams/Zoom use.
|
||||||
|
let device = enumerator
|
||||||
|
.GetDefaultAudioEndpoint(eCapture, eCommunications)
|
||||||
|
.context("GetDefaultAudioEndpoint(eCapture, eCommunications) failed")?;
|
||||||
|
|
||||||
|
if let Ok(name) = device_name(&device) {
|
||||||
|
info!(device = %name, "opening WASAPI communications capture endpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_client: IAudioClient = device
|
||||||
|
.Activate(CLSCTX_ALL, None)
|
||||||
|
.context("IMMDevice::Activate(IAudioClient) failed")?;
|
||||||
|
|
||||||
|
// IAudioClient2 exposes SetClientProperties, which is the ONLY way to
|
||||||
|
// set AudioCategory_Communications pre-Initialize. Calling it on the
|
||||||
|
// base IAudioClient would not compile, and setting it after Initialize
|
||||||
|
// is a no-op.
|
||||||
|
let audio_client2: IAudioClient2 = audio_client
|
||||||
|
.cast()
|
||||||
|
.context("QueryInterface IAudioClient2 failed")?;
|
||||||
|
|
||||||
|
let mut props = AudioClientProperties {
|
||||||
|
cbSize: std::mem::size_of::<AudioClientProperties>() as u32,
|
||||||
|
bIsOffload: BOOL(0),
|
||||||
|
eCategory: AudioCategory_Communications,
|
||||||
|
// 0 = AUDCLNT_STREAMOPTIONS_NONE. The `windows` crate doesn't
|
||||||
|
// export the enum constant in all versions, so use 0 directly.
|
||||||
|
Options: Default::default(),
|
||||||
|
};
|
||||||
|
audio_client2
|
||||||
|
.SetClientProperties(&mut props as *mut _)
|
||||||
|
.context("SetClientProperties(AudioCategory_Communications) failed")?;
|
||||||
|
|
||||||
|
// Request 48 kHz mono i16 directly. AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||||
|
// tells Windows to do any needed format conversion inside the audio
|
||||||
|
// engine rather than rejecting our format. SRC_DEFAULT_QUALITY picks
|
||||||
|
// the standard Windows resampler quality (fine for voice).
|
||||||
|
let wave_format = WAVEFORMATEX {
|
||||||
|
wFormatTag: WAVE_FORMAT_PCM as u16,
|
||||||
|
nChannels: 1,
|
||||||
|
nSamplesPerSec: 48_000,
|
||||||
|
nAvgBytesPerSec: 48_000 * 2, // 1 ch * 2 bytes/sample * 48000 Hz
|
||||||
|
nBlockAlign: 2, // 1 ch * 2 bytes/sample
|
||||||
|
wBitsPerSample: 16,
|
||||||
|
cbSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1,000,000 hns = 100 ms buffer (hns = 100-nanosecond units). Windows
|
||||||
|
// treats this as the minimum; the engine may give us a larger one.
|
||||||
|
const BUFFER_DURATION_HNS: i64 = 1_000_000;
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.Initialize(
|
||||||
|
AUDCLNT_SHAREMODE_SHARED,
|
||||||
|
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
|
||||||
|
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||||
|
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
||||||
|
BUFFER_DURATION_HNS,
|
||||||
|
0,
|
||||||
|
&wave_format,
|
||||||
|
Some(&GUID::zeroed()),
|
||||||
|
)
|
||||||
|
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
|
||||||
|
|
||||||
|
// Event-driven capture: Windows signals this handle each time a new
|
||||||
|
// audio packet is available. We wait on it from the loop below.
|
||||||
|
let event = CreateEventW(None, false, false, None)
|
||||||
|
.context("CreateEventW failed")?;
|
||||||
|
audio_client
|
||||||
|
.SetEventHandle(event)
|
||||||
|
.context("SetEventHandle failed")?;
|
||||||
|
|
||||||
|
let capture_client: IAudioCaptureClient = audio_client
|
||||||
|
.GetService()
|
||||||
|
.context("IAudioClient::GetService(IAudioCaptureClient) failed")?;
|
||||||
|
|
||||||
|
audio_client.Start().context("IAudioClient::Start failed")?;
|
||||||
|
|
||||||
|
// Signal to the parent thread that init succeeded before entering the
|
||||||
|
// hot loop. From this point on, errors get logged but don't propagate
|
||||||
|
// back to the caller (they'd just cause the ring buffer to stop
|
||||||
|
// filling, which the main thread detects as underruns).
|
||||||
|
let _ = init_tx.send(Ok(()));
|
||||||
|
info!("WASAPI communications-mode capture started with OS AEC enabled");
|
||||||
|
|
||||||
|
let mut logged_first_packet = false;
|
||||||
|
|
||||||
|
// Main capture loop. Exit when `running` goes false (from Drop or an
|
||||||
|
// explicit stop() call).
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
// 200 ms timeout so we check `running` regularly even if the audio
|
||||||
|
// engine stops delivering packets (e.g. device unplugged).
|
||||||
|
let wait = WaitForSingleObject(event, 200);
|
||||||
|
if wait.0 != WAIT_OBJECT_0.0 {
|
||||||
|
// Timeout or failure — just loop and re-check running.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain all available packets. Windows may have queued more than
|
||||||
|
// one since we were last scheduled.
|
||||||
|
loop {
|
||||||
|
let packet_length = match capture_client.GetNextPacketSize() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("GetNextPacketSize failed: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if packet_length == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buffer_ptr: *mut u8 = std::ptr::null_mut();
|
||||||
|
let mut num_frames: u32 = 0;
|
||||||
|
let mut flags: u32 = 0;
|
||||||
|
let mut device_position: u64 = 0;
|
||||||
|
let mut qpc_position: u64 = 0;
|
||||||
|
|
||||||
|
if let Err(e) = capture_client.GetBuffer(
|
||||||
|
&mut buffer_ptr,
|
||||||
|
&mut num_frames,
|
||||||
|
&mut flags,
|
||||||
|
Some(&mut device_position),
|
||||||
|
Some(&mut qpc_position),
|
||||||
|
) {
|
||||||
|
warn!("GetBuffer failed: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if num_frames > 0 && !buffer_ptr.is_null() {
|
||||||
|
if !logged_first_packet {
|
||||||
|
info!(
|
||||||
|
frames = num_frames,
|
||||||
|
flags, "WASAPI capture: first packet received"
|
||||||
|
);
|
||||||
|
logged_first_packet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Because we asked for 48 kHz mono i16, each frame is
|
||||||
|
// exactly one i16. Windows's AUTOCONVERTPCM handles the
|
||||||
|
// conversion from whatever the engine mix format is.
|
||||||
|
let samples = std::slice::from_raw_parts(
|
||||||
|
buffer_ptr as *const i16,
|
||||||
|
num_frames as usize,
|
||||||
|
);
|
||||||
|
ring.write(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = capture_client.ReleaseBuffer(num_frames) {
|
||||||
|
warn!("ReleaseBuffer failed: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("WASAPI capture thread stopping");
|
||||||
|
let _ = audio_client.Stop();
|
||||||
|
let _ = CloseHandle(event);
|
||||||
|
// _com_guard drops here, calling CoUninitialize.
|
||||||
|
|
||||||
|
// Silence INFINITE unused-import warning — it's referenced by the
|
||||||
|
// `windows` crate's WaitForSingleObject alternative but we use the
|
||||||
|
// 200 ms timeout variant instead. Explicit suppression for clarity.
|
||||||
|
let _ = INFINITE;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Best-effort device ID string for logging. Grabbing the friendly name via
|
||||||
|
/// PKEY_Device_FriendlyName requires IPropertyStore + PROPVARIANT plumbing
|
||||||
|
/// that's far more ceremony than a log line justifies; the ID is already
|
||||||
|
/// sufficient to confirm we opened the right endpoint.
|
||||||
|
///
|
||||||
|
/// Rust 2024 edition's `unsafe_op_in_unsafe_fn` lint requires explicit
|
||||||
|
/// `unsafe { ... }` blocks inside `unsafe fn` bodies for each unsafe call,
|
||||||
|
/// even though the whole function is already marked unsafe.
|
||||||
|
unsafe fn device_name(
|
||||||
|
device: &windows::Win32::Media::Audio::IMMDevice,
|
||||||
|
) -> Result<String, anyhow::Error> {
|
||||||
|
let id = unsafe { device.GetId() }.context("IMMDevice::GetId failed")?;
|
||||||
|
Ok(unsafe { id.to_string() }.unwrap_or_else(|_| "<non-utf16>".to_string()))
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ pub struct CallConfig {
|
|||||||
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
||||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||||
pub mini_frames_enabled: bool,
|
pub mini_frames_enabled: bool,
|
||||||
|
/// AEC far-end delay compensation in milliseconds (default: 40).
|
||||||
|
/// Compensates for the round-trip audio latency from playout to mic capture.
|
||||||
|
pub aec_delay_ms: u32,
|
||||||
/// Enable adaptive jitter buffer (default: true).
|
/// Enable adaptive jitter buffer (default: true).
|
||||||
///
|
///
|
||||||
/// When true, the jitter buffer target depth is automatically adjusted
|
/// When true, the jitter buffer target depth is automatically adjusted
|
||||||
@@ -63,6 +66,7 @@ impl Default for CallConfig {
|
|||||||
noise_suppression: true,
|
noise_suppression: true,
|
||||||
mini_frames_enabled: true,
|
mini_frames_enabled: true,
|
||||||
adaptive_jitter: true,
|
adaptive_jitter: true,
|
||||||
|
aec_delay_ms: 40,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +245,7 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
aec: EchoCanceller::with_delay(48000, 60, config.aec_delay_ms),
|
||||||
agc: AutoGainControl::new(),
|
agc: AutoGainControl::new(),
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
@@ -496,6 +500,52 @@ impl CallDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch the decoder to match an incoming packet's codec if it differs
|
||||||
|
/// from the current profile. This enables cross-codec interop (e.g. one
|
||||||
|
/// client sends Opus, the other sends Codec2).
|
||||||
|
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
|
||||||
|
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_profile = Self::profile_for_codec(incoming_codec);
|
||||||
|
info!(
|
||||||
|
from = ?self.profile.codec,
|
||||||
|
to = ?incoming_codec,
|
||||||
|
"decoder switching codec to match incoming packet"
|
||||||
|
);
|
||||||
|
if let Err(e) = self.audio_dec.set_profile(new_profile) {
|
||||||
|
warn!("failed to switch decoder profile: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.fec_dec = wzp_fec::create_decoder(&new_profile);
|
||||||
|
self.profile = new_profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
|
||||||
|
fn profile_for_codec(codec: CodecId) -> QualityProfile {
|
||||||
|
match codec {
|
||||||
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
|
CodecId::Opus16k => QualityProfile {
|
||||||
|
codec: CodecId::Opus16k,
|
||||||
|
fec_ratio: 0.3,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
},
|
||||||
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
},
|
||||||
|
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||||
|
CodecId::ComfortNoise => QualityProfile::GOOD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode the next audio frame from the jitter buffer.
|
/// Decode the next audio frame from the jitter buffer.
|
||||||
///
|
///
|
||||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||||
@@ -510,6 +560,9 @@ impl CallDecoder {
|
|||||||
return Some(pcm.len());
|
return Some(pcm.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-switch decoder if incoming codec differs from current.
|
||||||
|
self.switch_decoder_if_needed(pkt.header.codec_id);
|
||||||
|
|
||||||
self.last_was_cn = false;
|
self.last_was_cn = false;
|
||||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||||
Ok(n) => Some(n),
|
Ok(n) => Some(n),
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ struct CliArgs {
|
|||||||
room: Option<String>,
|
room: Option<String>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
_metrics_file: Option<String>,
|
_metrics_file: Option<String>,
|
||||||
|
version_check: bool,
|
||||||
|
/// Connect to relay for persistent signaling (direct calls).
|
||||||
|
signal: bool,
|
||||||
|
/// Place a direct call to a fingerprint (requires --signal).
|
||||||
|
call_target: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliArgs {
|
impl CliArgs {
|
||||||
@@ -88,12 +93,20 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut room = None;
|
let mut room = None;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
let mut metrics_file = None;
|
let mut metrics_file = None;
|
||||||
|
let mut version_check = false;
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
|
let mut signal = false;
|
||||||
|
let mut call_target = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
"--live" => live = true,
|
"--live" => live = true,
|
||||||
|
"--signal" => signal = true,
|
||||||
|
"--call" => {
|
||||||
|
i += 1;
|
||||||
|
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||||
|
}
|
||||||
"--send-tone" => {
|
"--send-tone" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
send_tone_secs = Some(
|
send_tone_secs = Some(
|
||||||
@@ -169,6 +182,7 @@ fn parse_args() -> CliArgs {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
|
"--version-check" => { version_check = true; }
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -221,6 +235,9 @@ fn parse_args() -> CliArgs {
|
|||||||
room,
|
room,
|
||||||
token,
|
token,
|
||||||
_metrics_file: metrics_file,
|
_metrics_file: metrics_file,
|
||||||
|
version_check,
|
||||||
|
signal,
|
||||||
|
call_target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +256,32 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --version-check: query relay version over QUIC and exit
|
||||||
|
if cli.version_check {
|
||||||
|
let client_config = wzp_transport::client_config();
|
||||||
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
|
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
|
||||||
|
match conn.accept_uni().await {
|
||||||
|
Ok(mut recv) => {
|
||||||
|
let data = recv.read_to_end(256).await.unwrap_or_default();
|
||||||
|
let version = String::from_utf8_lossy(&data);
|
||||||
|
println!("{} {}", cli.relay_addr, version.trim());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint.close(0u32.into(), b"done");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --signal mode: persistent signaling for direct calls
|
||||||
|
if cli.signal {
|
||||||
|
let seed = cli.resolve_seed();
|
||||||
|
return run_signal_mode(cli.relay_addr, seed, cli.token, cli.call_target).await;
|
||||||
|
}
|
||||||
|
|
||||||
let seed = cli.resolve_seed();
|
let seed = cli.resolve_seed();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -250,12 +293,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"WarzonePhone client"
|
"WarzonePhone client"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hash room name for SNI privacy (or "default" if none specified)
|
// Use raw room name as SNI (consistent with Android + Desktop clients for federation)
|
||||||
let sni = match &cli.room {
|
let sni = match &cli.room {
|
||||||
Some(name) => {
|
Some(name) => {
|
||||||
let hashed = wzp_crypto::hash_room_name(name);
|
info!(room = %name, "using room name as SNI");
|
||||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
name.clone()
|
||||||
hashed
|
|
||||||
}
|
}
|
||||||
None => "default".to_string(),
|
None => "default".to_string(),
|
||||||
};
|
};
|
||||||
@@ -274,6 +316,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
|
// Register shutdown handler so SIGTERM/SIGINT always closes QUIC cleanly.
|
||||||
|
// Without this, killed clients leave zombie connections on the relay for ~30s.
|
||||||
|
{
|
||||||
|
let shutdown_transport = transport.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to register SIGTERM handler");
|
||||||
|
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
||||||
|
.expect("failed to register SIGINT handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
|
||||||
|
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
|
||||||
|
}
|
||||||
|
// Close the QUIC connection immediately (APPLICATION_CLOSE frame).
|
||||||
|
// Don't call process::exit — let the main task detect the closed
|
||||||
|
// connection and perform clean shutdown (e.g., save recordings).
|
||||||
|
shutdown_transport.connection().close(0u32.into(), b"shutdown");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send auth token if provided (relay with --auth-url expects this first)
|
// Send auth token if provided (relay with --auth-url expects this first)
|
||||||
if let Some(ref token) = cli.token {
|
if let Some(ref token) = cli.token {
|
||||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||||
@@ -624,3 +686,195 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
|||||||
info!("done");
|
info!("done");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent signaling mode for direct 1:1 calls.
|
||||||
|
async fn run_signal_mode(
|
||||||
|
relay_addr: SocketAddr,
|
||||||
|
seed: wzp_crypto::Seed,
|
||||||
|
token: Option<String>,
|
||||||
|
call_target: Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
use wzp_proto::SignalMessage;
|
||||||
|
|
||||||
|
let identity = seed.derive_identity();
|
||||||
|
let pub_id = identity.public_identity();
|
||||||
|
let fp = pub_id.fingerprint.to_string();
|
||||||
|
let identity_pub = *pub_id.signing.as_bytes();
|
||||||
|
info!(fingerprint = %fp, "signal mode");
|
||||||
|
|
||||||
|
// Connect to relay with SNI "_signal"
|
||||||
|
let client_config = wzp_transport::client_config();
|
||||||
|
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
|
||||||
|
"[::]:0".parse()?
|
||||||
|
} else {
|
||||||
|
"0.0.0.0:0".parse()?
|
||||||
|
};
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
|
let conn = wzp_transport::connect(&endpoint, relay_addr, "_signal", client_config).await?;
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
info!("connected to relay (signal channel)");
|
||||||
|
|
||||||
|
// Auth if token provided
|
||||||
|
if let Some(ref tok) = token {
|
||||||
|
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register presence (signature not verified in Phase 1)
|
||||||
|
transport.send_signal(&SignalMessage::RegisterPresence {
|
||||||
|
identity_pub,
|
||||||
|
signature: vec![], // Phase 1: not verified
|
||||||
|
alias: None,
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
// Wait for ack
|
||||||
|
match transport.recv_signal().await? {
|
||||||
|
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
|
||||||
|
info!(fingerprint = %fp, "registered on relay — waiting for calls");
|
||||||
|
}
|
||||||
|
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => {
|
||||||
|
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
anyhow::bail!("unexpected response: {other:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If --call specified, place the call
|
||||||
|
if let Some(ref target) = call_target {
|
||||||
|
info!(target = %target, "placing direct call...");
|
||||||
|
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||||
|
|
||||||
|
transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||||
|
caller_fingerprint: fp.clone(),
|
||||||
|
caller_alias: None,
|
||||||
|
target_fingerprint: target.clone(),
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
identity_pub,
|
||||||
|
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
|
||||||
|
signature: vec![],
|
||||||
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
|
}).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal recv loop — handle incoming signals
|
||||||
|
let signal_transport = transport.clone();
|
||||||
|
let relay = relay_addr;
|
||||||
|
let my_fp = fp.clone();
|
||||||
|
let my_seed = seed.0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match signal_transport.recv_signal().await {
|
||||||
|
Ok(Some(msg)) => match msg {
|
||||||
|
SignalMessage::CallRinging { call_id } => {
|
||||||
|
info!(call_id = %call_id, "ringing...");
|
||||||
|
}
|
||||||
|
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
|
||||||
|
info!(
|
||||||
|
from = %caller_fingerprint,
|
||||||
|
alias = ?caller_alias,
|
||||||
|
call_id = %call_id,
|
||||||
|
"incoming call — auto-accepting (generic)"
|
||||||
|
);
|
||||||
|
// Auto-accept for CLI testing
|
||||||
|
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||||
|
call_id,
|
||||||
|
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||||
|
identity_pub: Some(identity_pub),
|
||||||
|
ephemeral_pub: None,
|
||||||
|
signature: None,
|
||||||
|
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
||||||
|
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
||||||
|
}
|
||||||
|
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay } => {
|
||||||
|
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
||||||
|
|
||||||
|
// Connect to the media room
|
||||||
|
let media_relay: SocketAddr = setup_relay.parse().unwrap_or(relay);
|
||||||
|
let media_cfg = wzp_transport::client_config();
|
||||||
|
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
|
||||||
|
Ok(media_conn) => {
|
||||||
|
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn));
|
||||||
|
|
||||||
|
// Crypto handshake
|
||||||
|
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await {
|
||||||
|
Ok(_session) => {
|
||||||
|
info!("media connected — sending tone (press Ctrl+C to hang up)");
|
||||||
|
|
||||||
|
// Simple tone sender for testing
|
||||||
|
let mt = media_transport.clone();
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
let config = wzp_client::call::CallConfig::default();
|
||||||
|
let mut encoder = wzp_client::call::CallEncoder::new(&config);
|
||||||
|
let duration = tokio::time::Duration::from_millis(20);
|
||||||
|
loop {
|
||||||
|
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
||||||
|
.map(|_| 0i16) // silence — could be tone
|
||||||
|
.collect();
|
||||||
|
if let Ok(pkts) = encoder.encode_frame(&pcm) {
|
||||||
|
for pkt in &pkts {
|
||||||
|
if mt.send_media(pkt).await.is_err() { return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(duration).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for hangup or ctrl+c
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
sig = signal_transport.recv_signal() => {
|
||||||
|
match sig {
|
||||||
|
Ok(Some(SignalMessage::Hangup { .. })) => {
|
||||||
|
info!("remote hung up");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
info!("hanging up...");
|
||||||
|
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send_task.abort();
|
||||||
|
media_transport.close().await.ok();
|
||||||
|
info!("call ended");
|
||||||
|
}
|
||||||
|
Err(e) => error!("media handshake failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error!("media connect failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalMessage::Hangup { reason } => {
|
||||||
|
info!(reason = ?reason, "call ended by remote");
|
||||||
|
}
|
||||||
|
SignalMessage::Pong { .. } => {}
|
||||||
|
other => {
|
||||||
|
info!("signal: {:?}", std::mem::discriminant(&other));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
info!("signal connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.close().await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
|
SignalMessage::FederationHello { .. }
|
||||||
|
| SignalMessage::GlobalRoomActive { .. }
|
||||||
|
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
||||||
|
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
||||||
|
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
||||||
|
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
||||||
|
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
||||||
|
SignalMessage::RegisterPresence { .. }
|
||||||
|
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ pub async fn perform_handshake(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
|
QualityProfile::STUDIO_64K,
|
||||||
|
QualityProfile::STUDIO_48K,
|
||||||
|
QualityProfile::STUDIO_32K,
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
|
|||||||
@@ -8,6 +8,24 @@
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
|
#[cfg(feature = "audio")]
|
||||||
|
pub mod audio_ring;
|
||||||
|
// VoiceProcessingIO is an Apple Core Audio API — only compile the module
|
||||||
|
// when the `vpio` feature is on AND we're targeting macOS. Enabling the
|
||||||
|
// feature on Windows/Linux was previously silently broken.
|
||||||
|
#[cfg(all(feature = "vpio", target_os = "macos"))]
|
||||||
|
pub mod audio_vpio;
|
||||||
|
// WASAPI-direct capture with Windows's OS-level AEC (AudioCategory_Communications).
|
||||||
|
// Only compiled when `windows-aec` feature is on AND target is Windows. The
|
||||||
|
// `windows` dependency is itself gated to Windows in Cargo.toml, so enabling
|
||||||
|
// this feature on non-Windows targets is a no-op.
|
||||||
|
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
||||||
|
pub mod audio_wasapi;
|
||||||
|
// WebRTC AEC3 (Audio Processing Module) wrapper around CPAL capture + playback
|
||||||
|
// on Linux. Only compiled when `linux-aec` feature is on AND target is Linux.
|
||||||
|
// The webrtc-audio-processing dep is itself gated to Linux in Cargo.toml.
|
||||||
|
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
||||||
|
pub mod audio_linux_aec;
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
@@ -17,7 +35,48 @@ pub mod handshake;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod sweep;
|
pub mod sweep;
|
||||||
|
|
||||||
#[cfg(feature = "audio")]
|
// AudioPlayback: three possible backends depending on feature flags.
|
||||||
pub use audio_io::{AudioCapture, AudioPlayback};
|
// 1. Default CPAL (`audio_io::AudioPlayback`) — baseline on every platform.
|
||||||
|
// 2. Linux AEC (`audio_linux_aec::LinuxAecPlayback`) — CPAL + WebRTC APM
|
||||||
|
// render-side tee, so echo from speakers gets cancelled from the mic.
|
||||||
|
//
|
||||||
|
// On macOS and Windows we always use the default CPAL playback because:
|
||||||
|
// - macOS: VoiceProcessingIO handles AEC at the capture side (Apple's
|
||||||
|
// native hardware AEC uses its own reference signal handling).
|
||||||
|
// - Windows: WASAPI AudioCategory_Communications AEC uses the system
|
||||||
|
// render mix as reference — no per-process plumbing needed.
|
||||||
|
//
|
||||||
|
// Linux is the only platform where the in-app approach is necessary, so
|
||||||
|
// the AEC playback path is gated to target_os = "linux".
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "audio",
|
||||||
|
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
||||||
|
))]
|
||||||
|
pub use audio_io::AudioPlayback;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
||||||
|
pub use audio_linux_aec::LinuxAecPlayback as AudioPlayback;
|
||||||
|
|
||||||
|
// AudioCapture: three possible backends depending on feature flags.
|
||||||
|
// 1. Default CPAL (`audio_io::AudioCapture`) — baseline on every platform.
|
||||||
|
// 2. Windows AEC (`audio_wasapi::WasapiAudioCapture`) — direct WASAPI
|
||||||
|
// with AudioCategory_Communications, OS APO chain does AEC.
|
||||||
|
// 3. Linux AEC (`audio_linux_aec::LinuxAecCapture`) — CPAL + WebRTC APM
|
||||||
|
// capture-side echo cancellation using the playback tee as reference.
|
||||||
|
// All three expose the same public API (`start`, `ring`, `stop`, `Drop`).
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "audio",
|
||||||
|
any(not(feature = "windows-aec"), not(target_os = "windows")),
|
||||||
|
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
||||||
|
))]
|
||||||
|
pub use audio_io::AudioCapture;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
||||||
|
pub use audio_wasapi::WasapiAudioCapture as AudioCapture;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
||||||
|
pub use audio_linux_aec::LinuxAecCapture as AudioCapture;
|
||||||
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
pub use handshake::perform_handshake;
|
pub use handshake::perform_handshake;
|
||||||
|
|||||||
@@ -1,53 +1,127 @@
|
|||||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
|
||||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
//! Geigel double-talk detection.
|
||||||
|
//!
|
||||||
|
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
|
||||||
|
//! → air → mic → capture) is 30–50ms. The far-end reference must be delayed
|
||||||
|
//! by this amount so the adaptive filter models the *echo path*, not the
|
||||||
|
//! *system delay + echo path*.
|
||||||
|
//!
|
||||||
|
//! The leaky coefficient decay prevents the filter from diverging when the
|
||||||
|
//! echo path changes (e.g. hand near laptop) or when the delay estimate
|
||||||
|
//! is slightly off.
|
||||||
|
|
||||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
|
||||||
///
|
|
||||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
|
||||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
|
||||||
/// the estimated echo from the near-end in real time.
|
|
||||||
pub struct EchoCanceller {
|
pub struct EchoCanceller {
|
||||||
filter_coeffs: Vec<f32>,
|
// --- Adaptive filter ---
|
||||||
|
filter: Vec<f32>,
|
||||||
filter_len: usize,
|
filter_len: usize,
|
||||||
far_end_buf: Vec<f32>,
|
/// Circular buffer of far-end reference samples (after delay).
|
||||||
far_end_pos: usize,
|
far_buf: Vec<f32>,
|
||||||
|
far_pos: usize,
|
||||||
|
/// NLMS step size.
|
||||||
mu: f32,
|
mu: f32,
|
||||||
|
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
||||||
|
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
||||||
|
leak: f32,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
|
||||||
|
// --- Delay buffer ---
|
||||||
|
/// Raw far-end samples before delay compensation.
|
||||||
|
delay_ring: Vec<f32>,
|
||||||
|
delay_write: usize,
|
||||||
|
delay_read: usize,
|
||||||
|
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
||||||
|
delay_samples: usize,
|
||||||
|
/// Capacity of the delay ring.
|
||||||
|
delay_cap: usize,
|
||||||
|
|
||||||
|
// --- Double-talk detection (Geigel) ---
|
||||||
|
/// Peak far-end level over the last filter_len samples.
|
||||||
|
far_peak: f32,
|
||||||
|
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
||||||
|
geigel_threshold: f32,
|
||||||
|
/// Holdover counter: keep DTD active for a few frames after detection.
|
||||||
|
dtd_holdover: u32,
|
||||||
|
dtd_hold_frames: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EchoCanceller {
|
impl EchoCanceller {
|
||||||
/// Create a new echo canceller.
|
/// Create a new echo canceller.
|
||||||
///
|
///
|
||||||
/// * `sample_rate` — typically 48000
|
/// * `sample_rate` — typically 48000
|
||||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
/// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
|
||||||
|
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
|
||||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||||
|
Self::with_delay(sample_rate, filter_ms, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
||||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||||
|
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
||||||
|
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
||||||
|
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
||||||
Self {
|
Self {
|
||||||
filter_coeffs: vec![0.0f32; filter_len],
|
filter: vec![0.0; filter_len],
|
||||||
filter_len,
|
filter_len,
|
||||||
far_end_buf: vec![0.0f32; filter_len],
|
far_buf: vec![0.0; filter_len],
|
||||||
far_end_pos: 0,
|
far_pos: 0,
|
||||||
mu: 0.01,
|
mu: 0.01,
|
||||||
|
leak: 0.0001,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
delay_ring: vec![0.0; delay_cap],
|
||||||
|
delay_write: 0,
|
||||||
|
delay_read: 0,
|
||||||
|
delay_samples,
|
||||||
|
delay_cap,
|
||||||
|
|
||||||
|
far_peak: 0.0,
|
||||||
|
geigel_threshold: 0.7,
|
||||||
|
dtd_holdover: 0,
|
||||||
|
dtd_hold_frames: 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
||||||
///
|
/// once enough samples have accumulated, they are released to the filter's
|
||||||
/// Must be called with the audio that was played out through the speaker
|
/// circular buffer with the correct delay offset.
|
||||||
/// *before* the corresponding near-end frame is processed.
|
|
||||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||||
|
// Write raw samples into the delay ring.
|
||||||
for &s in farend {
|
for &s in farend {
|
||||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
||||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
self.delay_write += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release delayed samples to the filter's far-end buffer.
|
||||||
|
while self.delay_available() >= 1 {
|
||||||
|
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
||||||
|
self.delay_read += 1;
|
||||||
|
|
||||||
|
self.far_buf[self.far_pos] = sample;
|
||||||
|
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
||||||
|
|
||||||
|
// Track peak far-end level for Geigel DTD.
|
||||||
|
let abs_s = sample.abs();
|
||||||
|
if abs_s > self.far_peak {
|
||||||
|
self.far_peak = abs_s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
||||||
|
self.far_peak *= 0.9995;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of delayed samples available to release.
|
||||||
|
fn delay_available(&self) -> usize {
|
||||||
|
let buffered = self.delay_write - self.delay_read;
|
||||||
|
if buffered > self.delay_samples {
|
||||||
|
buffered - self.delay_samples
|
||||||
|
} else {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||||
///
|
|
||||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
|
||||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
|
||||||
/// mean echo was reduced.
|
|
||||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
@@ -56,85 +130,96 @@ impl EchoCanceller {
|
|||||||
let n = nearend.len();
|
let n = nearend.len();
|
||||||
let fl = self.filter_len;
|
let fl = self.filter_len;
|
||||||
|
|
||||||
|
// --- Geigel double-talk detection ---
|
||||||
|
// If any near-end sample exceeds threshold * far_peak, assume
|
||||||
|
// the local speaker is active and freeze adaptation.
|
||||||
|
let mut is_doubletalk = self.dtd_holdover > 0;
|
||||||
|
if !is_doubletalk {
|
||||||
|
let threshold_level = self.geigel_threshold * self.far_peak;
|
||||||
|
for &s in nearend.iter() {
|
||||||
|
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
|
||||||
|
is_doubletalk = true;
|
||||||
|
self.dtd_holdover = self.dtd_hold_frames;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.dtd_holdover > 0 {
|
||||||
|
self.dtd_holdover -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if far-end is active (otherwise nothing to cancel).
|
||||||
|
let far_active = self.far_peak > 100.0;
|
||||||
|
|
||||||
|
// --- Leaky coefficient decay ---
|
||||||
|
// Applied once per frame for efficiency.
|
||||||
|
let decay = 1.0 - self.leak;
|
||||||
|
for c in self.filter.iter_mut() {
|
||||||
|
*c *= decay;
|
||||||
|
}
|
||||||
|
|
||||||
let mut sum_near_sq: f64 = 0.0;
|
let mut sum_near_sq: f64 = 0.0;
|
||||||
let mut sum_err_sq: f64 = 0.0;
|
let mut sum_err_sq: f64 = 0.0;
|
||||||
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let near_f = nearend[i] as f32;
|
let near_f = nearend[i] as f32;
|
||||||
|
|
||||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
// Position of far-end "now" for this near-end sample.
|
||||||
// The far-end window for this sample starts at
|
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
|
||||||
// and goes back filter_len samples.
|
// --- Echo estimation: dot(filter, far_end_window) ---
|
||||||
let mut echo_est: f32 = 0.0;
|
let mut echo_est: f32 = 0.0;
|
||||||
let mut power: f32 = 0.0;
|
let mut power: f32 = 0.0;
|
||||||
|
|
||||||
// Position of the most-recent far-end sample for this near-end sample.
|
|
||||||
// far_end_pos points to the *next write* position, so the most-recent
|
|
||||||
// sample written is at far_end_pos - 1. We have already called
|
|
||||||
// feed_farend for this block, so the relevant samples are the last
|
|
||||||
// filter_len entries ending just before the current write position,
|
|
||||||
// offset by how far we are into this near-end frame.
|
|
||||||
//
|
|
||||||
// For sample i of the near-end frame, the corresponding far-end
|
|
||||||
// "now" is far_end_pos - n + i (wrapping).
|
|
||||||
// far_end_pos points to next-write, so most recent sample is at
|
|
||||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
|
||||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
|
||||||
// repeatedly to avoid underflow on the usize subtraction.
|
|
||||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
|
||||||
|
|
||||||
for k in 0..fl {
|
for k in 0..fl {
|
||||||
let fe_idx = (base + fl - k) % fl;
|
let fe_idx = (base + fl - k) % fl;
|
||||||
let fe = self.far_end_buf[fe_idx];
|
let fe = self.far_buf[fe_idx];
|
||||||
echo_est += self.filter_coeffs[k] * fe;
|
echo_est += self.filter[k] * fe;
|
||||||
power += fe * fe;
|
power += fe * fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
let error = near_f - echo_est;
|
let error = near_f - echo_est;
|
||||||
|
|
||||||
// --- NLMS coefficient update ---
|
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
||||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
if far_active && !is_doubletalk && power > 10.0 {
|
||||||
let step = self.mu * error / norm;
|
let step = self.mu * error / (power + 1.0);
|
||||||
|
for k in 0..fl {
|
||||||
for k in 0..fl {
|
let fe_idx = (base + fl - k) % fl;
|
||||||
let fe_idx = (base + fl - k) % fl;
|
self.filter[k] += step * self.far_buf[fe_idx];
|
||||||
let fe = self.far_end_buf[fe_idx];
|
}
|
||||||
self.filter_coeffs[k] += step * fe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp output
|
let out = error.clamp(-32768.0, 32767.0);
|
||||||
let out = error.max(-32768.0).min(32767.0);
|
|
||||||
nearend[i] = out as i16;
|
nearend[i] = out as i16;
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
sum_near_sq += (near_f as f64).powi(2);
|
||||||
sum_err_sq += (out as f64) * (out as f64);
|
sum_err_sq += (out as f64).powi(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ERLE ratio
|
|
||||||
if sum_err_sq < 1.0 {
|
if sum_err_sq < 1.0 {
|
||||||
return 100.0; // near-perfect cancellation
|
100.0
|
||||||
|
} else {
|
||||||
|
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||||
}
|
}
|
||||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable echo cancellation.
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
self.enabled = enabled;
|
self.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether echo cancellation is currently enabled.
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
pub fn is_enabled(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the adaptive filter to its initial state.
|
|
||||||
///
|
|
||||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
self.filter.iter_mut().for_each(|c| *c = 0.0);
|
||||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
self.far_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||||
self.far_end_pos = 0;
|
self.far_pos = 0;
|
||||||
|
self.far_peak = 0.0;
|
||||||
|
self.delay_ring.iter_mut().for_each(|s| *s = 0.0);
|
||||||
|
self.delay_write = 0;
|
||||||
|
self.delay_read = 0;
|
||||||
|
self.dtd_holdover = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,50 +228,40 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_creates_with_correct_filter_len() {
|
fn creates_with_correct_sizes() {
|
||||||
let aec = EchoCanceller::new(48000, 100);
|
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
||||||
assert_eq!(aec.filter_len, 4800);
|
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
||||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
||||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_passthrough_when_disabled() {
|
fn passthrough_when_disabled() {
|
||||||
let mut aec = EchoCanceller::new(48000, 100);
|
let mut aec = EchoCanceller::new(48000, 60);
|
||||||
aec.set_enabled(false);
|
aec.set_enabled(false);
|
||||||
assert!(!aec.is_enabled());
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
|
||||||
let mut frame = original.clone();
|
let mut frame = original.clone();
|
||||||
let erle = aec.process_frame(&mut frame);
|
aec.process_frame(&mut frame);
|
||||||
assert_eq!(erle, 1.0);
|
|
||||||
assert_eq!(frame, original);
|
assert_eq!(frame, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_reset_zeroes_state() {
|
fn silence_passthrough() {
|
||||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
aec.feed_farend(&vec![0i16; 960]);
|
||||||
aec.feed_farend(&farend);
|
let mut frame = vec![0i16; 960];
|
||||||
|
aec.process_frame(&mut frame);
|
||||||
aec.reset();
|
assert!(frame.iter().all(|&s| s == 0));
|
||||||
|
|
||||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
|
||||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
|
||||||
assert_eq!(aec.far_end_pos, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_reduces_echo_of_known_signal() {
|
fn reduces_echo_with_no_delay() {
|
||||||
// Use a small filter for speed. Feed a known far-end signal, then
|
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
||||||
// present the *same* signal as near-end (perfect echo, no room).
|
// (realistic — speaker to mic on laptop loses volume).
|
||||||
// After adaptation the output energy should drop.
|
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
||||||
let filter_ms = 5; // 240 taps at 48 kHz
|
|
||||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
|
||||||
|
|
||||||
// Generate a simple repeating pattern.
|
let frame_len = 480;
|
||||||
let frame_len = 480usize;
|
let make_tone = |offset: usize| -> Vec<i16> {
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..frame_len)
|
(0..frame_len)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let t = (offset + i) as f64 / 48000.0;
|
let t = (offset + i) as f64 / 48000.0;
|
||||||
@@ -195,18 +270,16 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warm up the adaptive filter with several frames.
|
|
||||||
let mut last_erle = 1.0f32;
|
let mut last_erle = 1.0f32;
|
||||||
for frame_idx in 0..40 {
|
for frame_idx in 0..100 {
|
||||||
let farend = make_frame(frame_idx * frame_len);
|
let farend = make_tone(frame_idx * frame_len);
|
||||||
aec.feed_farend(&farend);
|
aec.feed_farend(&farend);
|
||||||
|
|
||||||
// Near-end = exact copy of far-end (pure echo).
|
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
||||||
let mut nearend = farend.clone();
|
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
||||||
last_erle = aec.process_frame(&mut nearend);
|
last_erle = aec.process_frame(&mut nearend);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After 40 frames the ERLE should be meaningfully > 1.
|
|
||||||
assert!(
|
assert!(
|
||||||
last_erle > 1.0,
|
last_erle > 1.0,
|
||||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||||
@@ -214,15 +287,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_silence_passthrough() {
|
fn preserves_nearend_during_doubletalk() {
|
||||||
let mut aec = EchoCanceller::new(48000, 10);
|
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||||
// Feed silence far-end
|
|
||||||
aec.feed_farend(&vec![0i16; 480]);
|
let frame_len = 960;
|
||||||
// Near-end is silence too
|
let nearend: Vec<i16> = (0..frame_len)
|
||||||
let mut frame = vec![0i16; 480];
|
.map(|i| {
|
||||||
let erle = aec.process_frame(&mut frame);
|
let t = i as f64 / 48000.0;
|
||||||
assert!(erle >= 1.0);
|
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||||
// Output should still be silence
|
})
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
.collect();
|
||||||
|
|
||||||
|
// Feed silence as far-end (no echo source).
|
||||||
|
aec.feed_farend(&vec![0i16; frame_len]);
|
||||||
|
|
||||||
|
let mut frame = nearend.clone();
|
||||||
|
aec.process_frame(&mut frame);
|
||||||
|
|
||||||
|
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||||
|
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||||
|
let ratio = output_energy / input_energy;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ratio > 0.8,
|
||||||
|
"near-end speech should be preserved, energy ratio = {ratio:.3}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delay_buffer_holds_samples() {
|
||||||
|
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
|
||||||
|
// 20ms delay = 960 samples @ 48kHz.
|
||||||
|
// After feeding, feed_farend auto-drains available samples to far_buf.
|
||||||
|
// So delay_available() is always 0 after feed_farend returns.
|
||||||
|
// Instead, verify far_pos advances only after the delay is filled.
|
||||||
|
|
||||||
|
// Feed 960 samples (= delay amount). No samples released yet.
|
||||||
|
aec.feed_farend(&vec![1i16; 960]);
|
||||||
|
// far_buf should still be all zeros (nothing released).
|
||||||
|
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
|
||||||
|
|
||||||
|
// Feed 480 more. 480 should be released to far_buf.
|
||||||
|
aec.feed_farend(&vec![2i16; 480]);
|
||||||
|
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
|
||||||
|
assert!(non_zero > 0, "samples should have been released to far_buf");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
self.apply_bitrate(profile.codec)?;
|
self.apply_bitrate(profile.codec)?;
|
||||||
|
|||||||
@@ -110,7 +110,18 @@ impl KeyExchange for WarzoneKeyExchange {
|
|||||||
hk.expand(b"warzone-session-key", &mut session_key)
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
.expect("HKDF expand for session key should not fail");
|
.expect("HKDF expand for session key should not fail");
|
||||||
|
|
||||||
Ok(Box::new(ChaChaSession::new(session_key)))
|
// Derive SAS (Short Authentication String) from shared secret only.
|
||||||
|
// The shared secret is identical on both sides (X25519 DH property).
|
||||||
|
// A MITM would produce a different shared secret → different SAS.
|
||||||
|
// We use a dedicated HKDF label so SAS is independent of the session key.
|
||||||
|
let mut sas_key = [0u8; 4];
|
||||||
|
hk.expand(b"warzone-sas-code", &mut sas_key)
|
||||||
|
.expect("HKDF expand for SAS should not fail");
|
||||||
|
let sas_code = u32::from_be_bytes(sas_key) % 10000;
|
||||||
|
|
||||||
|
let mut session = ChaChaSession::new(session_key);
|
||||||
|
session.set_sas(sas_code);
|
||||||
|
Ok(Box::new(session))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +222,47 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(&decrypted, plaintext);
|
assert_eq!(&decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sas_codes_match_between_peers() {
|
||||||
|
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||||
|
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||||
|
|
||||||
|
let alice_eph_pub = alice.generate_ephemeral();
|
||||||
|
let bob_eph_pub = bob.generate_ephemeral();
|
||||||
|
|
||||||
|
let alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||||
|
let bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||||
|
|
||||||
|
let alice_sas = alice_session.sas_code();
|
||||||
|
let bob_sas = bob_session.sas_code();
|
||||||
|
|
||||||
|
assert!(alice_sas.is_some(), "Alice should have SAS");
|
||||||
|
assert!(bob_sas.is_some(), "Bob should have SAS");
|
||||||
|
assert_eq!(alice_sas, bob_sas, "SAS codes must match between peers");
|
||||||
|
assert!(alice_sas.unwrap() < 10000, "SAS should be 4 digits");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sas_differs_for_different_peers() {
|
||||||
|
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||||
|
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||||
|
let mut eve = WarzoneKeyExchange::from_identity_seed(&[0xEE; 32]);
|
||||||
|
|
||||||
|
let alice_eph = alice.generate_ephemeral();
|
||||||
|
let bob_eph = bob.generate_ephemeral();
|
||||||
|
let eve_eph = eve.generate_ephemeral();
|
||||||
|
|
||||||
|
let alice_bob_session = alice.derive_session(&bob_eph).unwrap();
|
||||||
|
|
||||||
|
// Eve does separate handshake with Bob (MITM scenario)
|
||||||
|
let eve_bob_session = eve.derive_session(&bob_eph).unwrap();
|
||||||
|
|
||||||
|
// SAS codes should differ — Eve's session has different shared secret
|
||||||
|
assert_ne!(
|
||||||
|
alice_bob_session.sas_code(),
|
||||||
|
eve_bob_session.sas_code(),
|
||||||
|
"MITM session should produce different SAS"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub struct ChaChaSession {
|
|||||||
rekey_mgr: RekeyManager,
|
rekey_mgr: RekeyManager,
|
||||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||||
pending_rekey_secret: Option<StaticSecret>,
|
pending_rekey_secret: Option<StaticSecret>,
|
||||||
|
/// Short Authentication String (4-digit code for verbal verification).
|
||||||
|
sas_code: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChaChaSession {
|
impl ChaChaSession {
|
||||||
@@ -46,9 +48,15 @@ impl ChaChaSession {
|
|||||||
recv_seq: 0,
|
recv_seq: 0,
|
||||||
rekey_mgr: RekeyManager::new(shared_secret),
|
rekey_mgr: RekeyManager::new(shared_secret),
|
||||||
pending_rekey_secret: None,
|
pending_rekey_secret: None,
|
||||||
|
sas_code: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the SAS code (called by key exchange after derivation).
|
||||||
|
pub fn set_sas(&mut self, code: u32) {
|
||||||
|
self.sas_code = Some(code);
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a new key (after rekeying).
|
/// Install a new key (after rekeying).
|
||||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
@@ -136,6 +144,10 @@ impl CryptoSession for ChaChaSession {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sas_code(&self) -> Option<u32> {
|
||||||
|
self.sas_code
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||||
use wzp_proto::error::FecError;
|
use wzp_proto::error::FecError;
|
||||||
@@ -9,6 +10,9 @@ use wzp_proto::FecDecoder;
|
|||||||
/// Length prefix size (u16 little-endian), must match encoder.
|
/// Length prefix size (u16 little-endian), must match encoder.
|
||||||
const LEN_PREFIX: usize = 2;
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
|
/// Decoded blocks older than this are eligible for reuse by a new sender.
|
||||||
|
const BLOCK_STALE_SECS: u64 = 2;
|
||||||
|
|
||||||
/// State for one in-flight block being decoded.
|
/// State for one in-flight block being decoded.
|
||||||
struct BlockState {
|
struct BlockState {
|
||||||
/// Number of source symbols expected.
|
/// Number of source symbols expected.
|
||||||
@@ -21,6 +25,8 @@ struct BlockState {
|
|||||||
decoded: bool,
|
decoded: bool,
|
||||||
/// Cached decoded result.
|
/// Cached decoded result.
|
||||||
result: Option<Vec<Vec<u8>>>,
|
result: Option<Vec<Vec<u8>>>,
|
||||||
|
/// When this block was last decoded (for staleness check).
|
||||||
|
decoded_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||||
@@ -58,6 +64,7 @@ impl RaptorQFecDecoder {
|
|||||||
symbol_size: self.symbol_size,
|
symbol_size: self.symbol_size,
|
||||||
decoded: false,
|
decoded: false,
|
||||||
result: None,
|
result: None,
|
||||||
|
decoded_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,8 +81,20 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
let block = self.get_or_create_block(block_id);
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
if block.decoded {
|
if block.decoded {
|
||||||
// Already decoded, ignore additional symbols.
|
// If the block was decoded recently, skip (normal duplicate).
|
||||||
return Ok(());
|
// If it's stale (>2s), a new sender is reusing this block_id — reset it.
|
||||||
|
if let Some(at) = block.decoded_at {
|
||||||
|
if at.elapsed().as_secs() >= BLOCK_STALE_SECS {
|
||||||
|
block.decoded = false;
|
||||||
|
block.result = None;
|
||||||
|
block.decoded_at = None;
|
||||||
|
block.packets.clear();
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||||
@@ -132,6 +151,7 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
|
|
||||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||||
block.decoded = true;
|
block.decoded = true;
|
||||||
|
block.decoded_at = Some(Instant::now());
|
||||||
block.result = Some(frames.clone());
|
block.result = Some(frames.clone());
|
||||||
Ok(Some(frames))
|
Ok(Some(frames))
|
||||||
}
|
}
|
||||||
|
|||||||
29
crates/wzp-native/Cargo.toml
Normal file
29
crates/wzp-native/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-native"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
|
||||||
|
|
||||||
|
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
|
||||||
|
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
|
||||||
|
# standalone .so, which is the same path the legacy wzp-android crate uses
|
||||||
|
# successfully on the same phone / same NDK. Keeping the crate-type single
|
||||||
|
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
|
||||||
|
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
|
||||||
|
# archive pulled bionic's internal pthread_create into the final .so.
|
||||||
|
[lib]
|
||||||
|
name = "wzp_native"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
# cc is SAFE to use here because this crate is a single-cdylib: no
|
||||||
|
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
|
||||||
|
# legacy wzp-android crate uses the same setup and works.
|
||||||
|
cc = "1"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
|
||||||
|
# audio pipeline via extern "C" into the bundled C++ and expose our own
|
||||||
|
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
|
||||||
|
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
|
||||||
|
# instead of calling back into wzp-desktop via callbacks.
|
||||||
119
crates/wzp-native/build.rs
Normal file
119
crates/wzp-native/build.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
|
||||||
|
//!
|
||||||
|
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
|
||||||
|
//! work). The crucial distinction: this crate is a single-cdylib (no
|
||||||
|
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
|
||||||
|
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
|
||||||
|
//! UND and resolve against libc.so at runtime, as they should.
|
||||||
|
//!
|
||||||
|
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
|
||||||
|
//! `cargo check --target <host>` still works for IDEs and CI.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let target = std::env::var("TARGET").unwrap_or_default();
|
||||||
|
|
||||||
|
if target.contains("android") {
|
||||||
|
// getauxval_fix: override compiler-rt's broken static getauxval
|
||||||
|
// stub that SIGSEGVs in shared libraries.
|
||||||
|
cc::Build::new()
|
||||||
|
.file("cpp/getauxval_fix.c")
|
||||||
|
.compile("wzp_native_getauxval_fix");
|
||||||
|
|
||||||
|
let oboe_dir = fetch_oboe();
|
||||||
|
match oboe_dir {
|
||||||
|
Some(oboe_path) => {
|
||||||
|
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path);
|
||||||
|
let mut build = cc::Build::new();
|
||||||
|
build
|
||||||
|
.cpp(true)
|
||||||
|
.std("c++17")
|
||||||
|
// Shared libc++ — matches legacy wzp-android setup.
|
||||||
|
.cpp_link_stdlib(Some("c++_shared"))
|
||||||
|
.include("cpp")
|
||||||
|
.include(oboe_path.join("include"))
|
||||||
|
.include(oboe_path.join("src"))
|
||||||
|
.define("WZP_HAS_OBOE", None)
|
||||||
|
.file("cpp/oboe_bridge.cpp");
|
||||||
|
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
|
||||||
|
build.compile("wzp_native_oboe_bridge");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("cargo:warning=wzp-native: Oboe not found, building stub");
|
||||||
|
cc::Build::new()
|
||||||
|
.cpp(true)
|
||||||
|
.std("c++17")
|
||||||
|
.cpp_link_stdlib(Some("c++_shared"))
|
||||||
|
.file("cpp/oboe_stub.cpp")
|
||||||
|
.include("cpp")
|
||||||
|
.compile("wzp_native_oboe_bridge");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oboe needs log + OpenSLES backends at runtime.
|
||||||
|
println!("cargo:rustc-link-lib=log");
|
||||||
|
println!("cargo:rustc-link-lib=OpenSLES");
|
||||||
|
|
||||||
|
// Re-run if any cpp file changes
|
||||||
|
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
|
||||||
|
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
|
||||||
|
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
||||||
|
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
||||||
|
} else {
|
||||||
|
// Non-Android hosts: compile the empty stub so lib.rs's extern
|
||||||
|
// declarations resolve when someone runs `cargo check` on macOS
|
||||||
|
// or Linux without an NDK.
|
||||||
|
cc::Build::new()
|
||||||
|
.cpp(true)
|
||||||
|
.std("c++17")
|
||||||
|
.file("cpp/oboe_stub.cpp")
|
||||||
|
.include("cpp")
|
||||||
|
.compile("wzp_native_oboe_bridge");
|
||||||
|
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively add all `.cpp` files from a directory to a cc::Build.
|
||||||
|
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(dir).unwrap() {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
add_cpp_files_recursive(build, &path);
|
||||||
|
} else if path.extension().map_or(false, |e| e == "cpp") {
|
||||||
|
build.file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
|
||||||
|
/// legacy wzp-android crate's build.rs.
|
||||||
|
fn fetch_oboe() -> Option<PathBuf> {
|
||||||
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||||
|
let oboe_dir = out_dir.join("oboe");
|
||||||
|
|
||||||
|
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||||
|
return Some(oboe_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = std::process::Command::new("git")
|
||||||
|
.args([
|
||||||
|
"clone",
|
||||||
|
"--depth=1",
|
||||||
|
"--branch=1.8.1",
|
||||||
|
"https://github.com/google/oboe.git",
|
||||||
|
oboe_dir.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => {
|
||||||
|
Some(oboe_dir)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/wzp-native/cpp/getauxval_fix.c
Normal file
21
crates/wzp-native/cpp/getauxval_fix.c
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Override the broken static getauxval from compiler-rt/CRT.
|
||||||
|
// The static version reads from __libc_auxv which is NULL in shared libs
|
||||||
|
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
||||||
|
// This version calls the real bionic getauxval via dlsym.
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
typedef unsigned long (*getauxval_fn)(unsigned long);
|
||||||
|
|
||||||
|
unsigned long getauxval(unsigned long type) {
|
||||||
|
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
||||||
|
if (!real_getauxval) {
|
||||||
|
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
||||||
|
if (!real_getauxval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return real_getauxval(type);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
420
crates/wzp-native/cpp/oboe_bridge.cpp
Normal file
420
crates/wzp-native/cpp/oboe_bridge.cpp
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
// Full Oboe implementation for Android
|
||||||
|
// This file is compiled only when targeting Android
|
||||||
|
|
||||||
|
#include "oboe_bridge.h"
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <oboe/Oboe.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
#define LOG_TAG "wzp-oboe"
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
|
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ring buffer helpers (SPSC, lock-free)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
||||||
|
const wzp_atomic_int* read_idx,
|
||||||
|
int32_t capacity) {
|
||||||
|
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
||||||
|
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||||
|
int32_t avail = w - r;
|
||||||
|
if (avail < 0) avail += capacity;
|
||||||
|
return avail;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
||||||
|
const wzp_atomic_int* read_idx,
|
||||||
|
int32_t capacity) {
|
||||||
|
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void ring_write(int16_t* buf, int32_t capacity,
|
||||||
|
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
||||||
|
const int16_t* src, int32_t count) {
|
||||||
|
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
||||||
|
for (int32_t i = 0; i < count; i++) {
|
||||||
|
buf[w] = src[i];
|
||||||
|
w++;
|
||||||
|
if (w >= capacity) w = 0;
|
||||||
|
}
|
||||||
|
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void ring_read(int16_t* buf, int32_t capacity,
|
||||||
|
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
||||||
|
int16_t* dst, int32_t count) {
|
||||||
|
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||||
|
for (int32_t i = 0; i < count; i++) {
|
||||||
|
dst[i] = buf[r];
|
||||||
|
r++;
|
||||||
|
if (r >= capacity) r = 0;
|
||||||
|
}
|
||||||
|
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
||||||
|
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
||||||
|
// Value copy — the WzpOboeRings the Rust side passes us lives on the caller's
|
||||||
|
// stack frame and goes away as soon as wzp_oboe_start returns. The raw
|
||||||
|
// int16/atomic pointers INSIDE the struct point into the Rust-owned, leaked-
|
||||||
|
// for-the-lifetime-of-the-process AudioBackend singleton, so copying the
|
||||||
|
// struct by value is safe and keeps the inner pointers valid indefinitely.
|
||||||
|
// g_rings_valid guards the audio-callback-side read; clearing it in stop()
|
||||||
|
// signals "no backend" to the callbacks which then return silence + Stop.
|
||||||
|
static WzpOboeRings g_rings{};
|
||||||
|
static std::atomic<bool> g_rings_valid{false};
|
||||||
|
static std::atomic<bool> g_running{false};
|
||||||
|
static std::atomic<float> g_capture_latency_ms{0.0f};
|
||||||
|
static std::atomic<float> g_playout_latency_ms{0.0f};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Capture callback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
||||||
|
public:
|
||||||
|
uint64_t calls = 0;
|
||||||
|
uint64_t total_frames = 0;
|
||||||
|
uint64_t total_written = 0;
|
||||||
|
uint64_t ring_full_drops = 0;
|
||||||
|
|
||||||
|
oboe::DataCallbackResult onAudioReady(
|
||||||
|
oboe::AudioStream* stream,
|
||||||
|
void* audioData,
|
||||||
|
int32_t numFrames) override {
|
||||||
|
if (!g_running.load(std::memory_order_relaxed) ||
|
||||||
|
!g_rings_valid.load(std::memory_order_acquire)) {
|
||||||
|
return oboe::DataCallbackResult::Stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int16_t* src = static_cast<const int16_t*>(audioData);
|
||||||
|
int32_t avail = ring_available_write(g_rings.capture_write_idx,
|
||||||
|
g_rings.capture_read_idx,
|
||||||
|
g_rings.capture_capacity);
|
||||||
|
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
||||||
|
if (to_write > 0) {
|
||||||
|
ring_write(g_rings.capture_buf, g_rings.capture_capacity,
|
||||||
|
g_rings.capture_write_idx, g_rings.capture_read_idx,
|
||||||
|
src, to_write);
|
||||||
|
}
|
||||||
|
total_frames += numFrames;
|
||||||
|
total_written += to_write;
|
||||||
|
if (to_write < numFrames) {
|
||||||
|
ring_full_drops += (numFrames - to_write);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample-range probe on the FIRST callback to prove we get real audio
|
||||||
|
if (calls == 0 && numFrames > 0) {
|
||||||
|
int16_t lo = src[0], hi = src[0];
|
||||||
|
int32_t sumsq = 0;
|
||||||
|
for (int32_t i = 0; i < numFrames; i++) {
|
||||||
|
if (src[i] < lo) lo = src[i];
|
||||||
|
if (src[i] > hi) hi = src[i];
|
||||||
|
sumsq += (int32_t)src[i] * (int32_t)src[i];
|
||||||
|
}
|
||||||
|
int32_t rms = (int32_t) (numFrames > 0 ? (int32_t)__builtin_sqrt((double)sumsq / (double)numFrames) : 0);
|
||||||
|
LOGI("capture cb#0: numFrames=%d sample_range=[%d..%d] rms=%d to_write=%d",
|
||||||
|
numFrames, lo, hi, rms, to_write);
|
||||||
|
}
|
||||||
|
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
||||||
|
calls++;
|
||||||
|
if ((calls % 50) == 0) {
|
||||||
|
LOGI("capture heartbeat: calls=%llu numFrames=%d ring_avail_write=%d to_write=%d full_drops=%llu total_written=%llu",
|
||||||
|
(unsigned long long)calls, numFrames, avail, to_write,
|
||||||
|
(unsigned long long)ring_full_drops, (unsigned long long)total_written);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update latency estimate
|
||||||
|
auto result = stream->calculateLatencyMillis();
|
||||||
|
if (result) {
|
||||||
|
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oboe::DataCallbackResult::Continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playout callback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
||||||
|
public:
|
||||||
|
uint64_t calls = 0;
|
||||||
|
uint64_t total_frames = 0;
|
||||||
|
uint64_t total_played_real = 0;
|
||||||
|
uint64_t underrun_frames = 0;
|
||||||
|
uint64_t nonempty_calls = 0;
|
||||||
|
|
||||||
|
oboe::DataCallbackResult onAudioReady(
|
||||||
|
oboe::AudioStream* stream,
|
||||||
|
void* audioData,
|
||||||
|
int32_t numFrames) override {
|
||||||
|
if (!g_running.load(std::memory_order_relaxed) ||
|
||||||
|
!g_rings_valid.load(std::memory_order_acquire)) {
|
||||||
|
memset(audioData, 0, numFrames * sizeof(int16_t));
|
||||||
|
return oboe::DataCallbackResult::Stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t* dst = static_cast<int16_t*>(audioData);
|
||||||
|
int32_t avail = ring_available_read(g_rings.playout_write_idx,
|
||||||
|
g_rings.playout_read_idx,
|
||||||
|
g_rings.playout_capacity);
|
||||||
|
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
||||||
|
|
||||||
|
if (to_read > 0) {
|
||||||
|
ring_read(g_rings.playout_buf, g_rings.playout_capacity,
|
||||||
|
g_rings.playout_write_idx, g_rings.playout_read_idx,
|
||||||
|
dst, to_read);
|
||||||
|
nonempty_calls++;
|
||||||
|
}
|
||||||
|
// Fill remainder with silence on underrun
|
||||||
|
if (to_read < numFrames) {
|
||||||
|
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
||||||
|
underrun_frames += (numFrames - to_read);
|
||||||
|
}
|
||||||
|
total_frames += numFrames;
|
||||||
|
total_played_real += to_read;
|
||||||
|
|
||||||
|
// First callback: log requested config + prove we're being called
|
||||||
|
if (calls == 0) {
|
||||||
|
LOGI("playout cb#0: numFrames=%d ring_avail_read=%d to_read=%d",
|
||||||
|
numFrames, avail, to_read);
|
||||||
|
}
|
||||||
|
// On the first callback that actually has data, log the sample range
|
||||||
|
// so we can tell if the samples coming out of the ring look like real
|
||||||
|
// audio vs constant-zeroes vs garbage.
|
||||||
|
if (to_read > 0 && nonempty_calls == 1) {
|
||||||
|
int16_t lo = dst[0], hi = dst[0];
|
||||||
|
int32_t sumsq = 0;
|
||||||
|
for (int32_t i = 0; i < to_read; i++) {
|
||||||
|
if (dst[i] < lo) lo = dst[i];
|
||||||
|
if (dst[i] > hi) hi = dst[i];
|
||||||
|
sumsq += (int32_t)dst[i] * (int32_t)dst[i];
|
||||||
|
}
|
||||||
|
int32_t rms = (to_read > 0) ? (int32_t)__builtin_sqrt((double)sumsq / (double)to_read) : 0;
|
||||||
|
LOGI("playout FIRST nonempty read: to_read=%d sample_range=[%d..%d] rms=%d",
|
||||||
|
to_read, lo, hi, rms);
|
||||||
|
}
|
||||||
|
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
||||||
|
calls++;
|
||||||
|
if ((calls % 50) == 0) {
|
||||||
|
int state = (int)stream->getState();
|
||||||
|
auto xrunRes = stream->getXRunCount();
|
||||||
|
int xruns = xrunRes ? xrunRes.value() : -1;
|
||||||
|
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu state=%d xruns=%d",
|
||||||
|
(unsigned long long)calls, (unsigned long long)nonempty_calls,
|
||||||
|
numFrames, avail, to_read,
|
||||||
|
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
|
||||||
|
state, xruns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update latency estimate
|
||||||
|
auto result = stream->calculateLatencyMillis();
|
||||||
|
if (result) {
|
||||||
|
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
||||||
|
std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return oboe::DataCallbackResult::Continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static CaptureCallback g_capture_cb;
|
||||||
|
static PlayoutCallback g_playout_cb;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public C API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||||
|
if (g_running.load(std::memory_order_relaxed)) {
|
||||||
|
LOGW("wzp_oboe_start: already running");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep-copy the rings struct into static storage BEFORE we publish it to
|
||||||
|
// the audio callbacks — `rings` points at the caller's stack frame and
|
||||||
|
// goes away as soon as this function returns.
|
||||||
|
g_rings = *rings;
|
||||||
|
g_rings_valid.store(true, std::memory_order_release);
|
||||||
|
|
||||||
|
// Build capture stream
|
||||||
|
oboe::AudioStreamBuilder captureBuilder;
|
||||||
|
captureBuilder.setDirection(oboe::Direction::Input)
|
||||||
|
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||||
|
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||||
|
->setFormat(oboe::AudioFormat::I16)
|
||||||
|
->setChannelCount(config->channel_count)
|
||||||
|
->setSampleRate(config->sample_rate)
|
||||||
|
->setFramesPerDataCallback(config->frames_per_burst)
|
||||||
|
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
||||||
|
->setDataCallback(&g_capture_cb);
|
||||||
|
|
||||||
|
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
LOGI("capture stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
||||||
|
g_capture_stream->getSampleRate(),
|
||||||
|
g_capture_stream->getChannelCount(),
|
||||||
|
(int)g_capture_stream->getFormat(),
|
||||||
|
g_capture_stream->getFramesPerBurst(),
|
||||||
|
g_capture_stream->getFramesPerDataCallback(),
|
||||||
|
g_capture_stream->getBufferCapacityInFrames(),
|
||||||
|
(int)g_capture_stream->getSharingMode(),
|
||||||
|
(int)g_capture_stream->getPerformanceMode());
|
||||||
|
|
||||||
|
// Build playout stream.
|
||||||
|
//
|
||||||
|
// Regression triangulation between builds:
|
||||||
|
// 96be740 (Usage::Media, default API): playout callback DID drain
|
||||||
|
// the ring at steady 50Hz (playout heartbeat: calls=1100,
|
||||||
|
// total_played_real=1055040). Audio not audible because OS routing
|
||||||
|
// sent it to a silent output.
|
||||||
|
//
|
||||||
|
// 8c36fb5 (Usage::VoiceCommunication + setAudioApi(AAudio) +
|
||||||
|
// ContentType::Speech): playout callback fired cb#0 once then
|
||||||
|
// stopped draining the ring entirely. written_samples stuck at
|
||||||
|
// ring capacity (7679) across all subsequent heartbeats, so Oboe
|
||||||
|
// accepted zero samples after startup. Still inaudible.
|
||||||
|
//
|
||||||
|
// Hypothesis: forcing setAudioApi(AAudio) + VoiceCommunication on
|
||||||
|
// Pixel 6 / Android 15 opens a stream that succeeds at cb#0 but
|
||||||
|
// then detaches from the real audio driver. Reverting to the
|
||||||
|
// config that at least drove callbacks correctly, plus the
|
||||||
|
// Kotlin-side MODE_IN_COMMUNICATION + setSpeakerphoneOn(true)
|
||||||
|
// handled in MainActivity.kt to route audio to the loud speaker.
|
||||||
|
// Usage::VoiceCommunication is the correct Oboe usage for a VoIP app
|
||||||
|
// — it respects Android's in-call audio routing and lets
|
||||||
|
// AudioManager.setSpeakerphoneOn/setBluetoothScoOn actually switch
|
||||||
|
// between earpiece, loudspeaker, and Bluetooth headset. Combined with
|
||||||
|
// MODE_IN_COMMUNICATION set from MainActivity.kt and
|
||||||
|
// speakerphoneOn=false by default, this produces handset/earpiece as
|
||||||
|
// the default output.
|
||||||
|
//
|
||||||
|
// IMPORTANT: do NOT add setAudioApi(AAudio) here. Build 8c36fb5 proved
|
||||||
|
// forcing AAudio with Usage::VoiceCommunication makes the playout
|
||||||
|
// callback stop draining the ring after cb#0, even though the stream
|
||||||
|
// opens successfully. Letting Oboe pick the API (which will be AAudio
|
||||||
|
// on API ≥ 27 but via a different codepath) kept callbacks firing in
|
||||||
|
// every other build.
|
||||||
|
oboe::AudioStreamBuilder playoutBuilder;
|
||||||
|
playoutBuilder.setDirection(oboe::Direction::Output)
|
||||||
|
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||||
|
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||||
|
->setFormat(oboe::AudioFormat::I16)
|
||||||
|
->setChannelCount(config->channel_count)
|
||||||
|
->setSampleRate(config->sample_rate)
|
||||||
|
->setFramesPerDataCallback(config->frames_per_burst)
|
||||||
|
->setUsage(oboe::Usage::VoiceCommunication)
|
||||||
|
->setDataCallback(&g_playout_cb);
|
||||||
|
|
||||||
|
result = playoutBuilder.openStream(g_playout_stream);
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
||||||
|
g_capture_stream->close();
|
||||||
|
g_capture_stream.reset();
|
||||||
|
return -3;
|
||||||
|
}
|
||||||
|
LOGI("playout stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
||||||
|
g_playout_stream->getSampleRate(),
|
||||||
|
g_playout_stream->getChannelCount(),
|
||||||
|
(int)g_playout_stream->getFormat(),
|
||||||
|
g_playout_stream->getFramesPerBurst(),
|
||||||
|
g_playout_stream->getFramesPerDataCallback(),
|
||||||
|
g_playout_stream->getBufferCapacityInFrames(),
|
||||||
|
(int)g_playout_stream->getSharingMode(),
|
||||||
|
(int)g_playout_stream->getPerformanceMode());
|
||||||
|
|
||||||
|
g_running.store(true, std::memory_order_release);
|
||||||
|
|
||||||
|
// Start both streams
|
||||||
|
result = g_capture_stream->requestStart();
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
||||||
|
g_running.store(false, std::memory_order_release);
|
||||||
|
g_capture_stream->close();
|
||||||
|
g_playout_stream->close();
|
||||||
|
g_capture_stream.reset();
|
||||||
|
g_playout_stream.reset();
|
||||||
|
return -4;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = g_playout_stream->requestStart();
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
||||||
|
g_running.store(false, std::memory_order_release);
|
||||||
|
g_capture_stream->requestStop();
|
||||||
|
g_capture_stream->close();
|
||||||
|
g_playout_stream->close();
|
||||||
|
g_capture_stream.reset();
|
||||||
|
g_playout_stream.reset();
|
||||||
|
return -5;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
||||||
|
config->sample_rate, config->frames_per_burst, config->channel_count);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wzp_oboe_stop(void) {
|
||||||
|
g_running.store(false, std::memory_order_release);
|
||||||
|
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
|
||||||
|
// the streams, so any in-flight callback returns Stop instead of reading
|
||||||
|
// stale pointers.
|
||||||
|
g_rings_valid.store(false, std::memory_order_release);
|
||||||
|
|
||||||
|
if (g_capture_stream) {
|
||||||
|
g_capture_stream->requestStop();
|
||||||
|
g_capture_stream->close();
|
||||||
|
g_capture_stream.reset();
|
||||||
|
}
|
||||||
|
if (g_playout_stream) {
|
||||||
|
g_playout_stream->requestStop();
|
||||||
|
g_playout_stream->close();
|
||||||
|
g_playout_stream.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGI("Oboe stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
float wzp_oboe_capture_latency_ms(void) {
|
||||||
|
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
float wzp_oboe_playout_latency_ms(void) {
|
||||||
|
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
int wzp_oboe_is_running(void) {
|
||||||
|
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
||||||
|
// Provide empty implementations just in case.
|
||||||
|
|
||||||
|
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||||
|
(void)config; (void)rings;
|
||||||
|
return -99;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wzp_oboe_stop(void) {}
|
||||||
|
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
||||||
|
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
||||||
|
int wzp_oboe_is_running(void) { return 0; }
|
||||||
|
|
||||||
|
#endif // __ANDROID__
|
||||||
43
crates/wzp-native/cpp/oboe_bridge.h
Normal file
43
crates/wzp-native/cpp/oboe_bridge.h
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#ifndef WZP_OBOE_BRIDGE_H
|
||||||
|
#define WZP_OBOE_BRIDGE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
#include <atomic>
|
||||||
|
typedef std::atomic<int32_t> wzp_atomic_int;
|
||||||
|
extern "C" {
|
||||||
|
#else
|
||||||
|
#include <stdatomic.h>
|
||||||
|
typedef atomic_int wzp_atomic_int;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int32_t sample_rate;
|
||||||
|
int32_t frames_per_burst;
|
||||||
|
int32_t channel_count;
|
||||||
|
} WzpOboeConfig;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int16_t* capture_buf;
|
||||||
|
int32_t capture_capacity;
|
||||||
|
wzp_atomic_int* capture_write_idx;
|
||||||
|
wzp_atomic_int* capture_read_idx;
|
||||||
|
|
||||||
|
int16_t* playout_buf;
|
||||||
|
int32_t playout_capacity;
|
||||||
|
wzp_atomic_int* playout_write_idx;
|
||||||
|
wzp_atomic_int* playout_read_idx;
|
||||||
|
} WzpOboeRings;
|
||||||
|
|
||||||
|
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
||||||
|
void wzp_oboe_stop(void);
|
||||||
|
float wzp_oboe_capture_latency_ms(void);
|
||||||
|
float wzp_oboe_playout_latency_ms(void);
|
||||||
|
int wzp_oboe_is_running(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif // WZP_OBOE_BRIDGE_H
|
||||||
27
crates/wzp-native/cpp/oboe_stub.cpp
Normal file
27
crates/wzp-native/cpp/oboe_stub.cpp
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
||||||
|
|
||||||
|
#include "oboe_bridge.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||||
|
(void)config;
|
||||||
|
(void)rings;
|
||||||
|
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wzp_oboe_stop(void) {
|
||||||
|
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
float wzp_oboe_capture_latency_ms(void) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float wzp_oboe_playout_latency_ms(void) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wzp_oboe_is_running(void) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
331
crates/wzp-native/src/lib.rs
Normal file
331
crates/wzp-native/src/lib.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! wzp-native — standalone Android cdylib for all the C++ audio code.
|
||||||
|
//!
|
||||||
|
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
|
||||||
|
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
|
||||||
|
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
|
||||||
|
//!
|
||||||
|
//! Phase 2: real Oboe audio backend.
|
||||||
|
//!
|
||||||
|
//! Architecture: Oboe runs capture + playout streams on its own high-
|
||||||
|
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
|
||||||
|
//! buffers (capture and playout) are shared between the C++ callbacks
|
||||||
|
//! and the Rust side via atomic indices — no locks on the hot path.
|
||||||
|
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
|
||||||
|
//! the playout ring with decoded PCM.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicI32, Ordering};
|
||||||
|
|
||||||
|
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
|
||||||
|
|
||||||
|
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
|
||||||
|
/// work before any audio code runs.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_version() -> i32 {
|
||||||
|
42
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
|
||||||
|
/// returns bytes written excluding the NUL.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
|
||||||
|
const MSG: &[u8] = b"hello from wzp-native\0";
|
||||||
|
if out.is_null() || cap == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let n = MSG.len().min(cap);
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
|
||||||
|
*out.add(n - 1) = 0;
|
||||||
|
}
|
||||||
|
n - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── C++ Oboe bridge FFI ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct WzpOboeConfig {
|
||||||
|
sample_rate: i32,
|
||||||
|
frames_per_burst: i32,
|
||||||
|
channel_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct WzpOboeRings {
|
||||||
|
capture_buf: *mut i16,
|
||||||
|
capture_capacity: i32,
|
||||||
|
capture_write_idx: *mut AtomicI32,
|
||||||
|
capture_read_idx: *mut AtomicI32,
|
||||||
|
playout_buf: *mut i16,
|
||||||
|
playout_capacity: i32,
|
||||||
|
playout_write_idx: *mut AtomicI32,
|
||||||
|
playout_read_idx: *mut AtomicI32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: atomics synchronise producer/consumer; raw pointers are owned
|
||||||
|
// by the AudioBackend singleton below whose lifetime covers all calls.
|
||||||
|
unsafe impl Send for WzpOboeRings {}
|
||||||
|
unsafe impl Sync for WzpOboeRings {}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
||||||
|
fn wzp_oboe_stop();
|
||||||
|
fn wzp_oboe_capture_latency_ms() -> f32;
|
||||||
|
fn wzp_oboe_playout_latency_ms() -> f32;
|
||||||
|
fn wzp_oboe_is_running() -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SPSC ring buffer (shared with C++ via AtomicI32) ────────────────────
|
||||||
|
|
||||||
|
/// 20 ms @ 48 kHz mono = 960 samples.
|
||||||
|
const FRAME_SAMPLES: usize = 960;
|
||||||
|
/// ~160 ms headroom at 48 kHz.
|
||||||
|
const RING_CAPACITY: usize = 7680;
|
||||||
|
|
||||||
|
struct RingBuffer {
|
||||||
|
buf: Vec<i16>,
|
||||||
|
capacity: usize,
|
||||||
|
write_idx: AtomicI32,
|
||||||
|
read_idx: AtomicI32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: SPSC with atomic read/write cursors; producer and consumer
|
||||||
|
// are always on different threads.
|
||||||
|
unsafe impl Send for RingBuffer {}
|
||||||
|
unsafe impl Sync for RingBuffer {}
|
||||||
|
|
||||||
|
impl RingBuffer {
|
||||||
|
fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: vec![0i16; capacity],
|
||||||
|
capacity,
|
||||||
|
write_idx: AtomicI32::new(0),
|
||||||
|
read_idx: AtomicI32::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_read(&self) -> usize {
|
||||||
|
let w = self.write_idx.load(Ordering::Acquire);
|
||||||
|
let r = self.read_idx.load(Ordering::Relaxed);
|
||||||
|
let avail = w - r;
|
||||||
|
if avail < 0 { (avail + self.capacity as i32) as usize } else { avail as usize }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_write(&self) -> usize {
|
||||||
|
self.capacity - 1 - self.available_read()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, data: &[i16]) -> usize {
|
||||||
|
let count = data.len().min(self.available_write());
|
||||||
|
if count == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
||||||
|
let cap = self.capacity;
|
||||||
|
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
||||||
|
for sample in &data[..count] {
|
||||||
|
unsafe { *buf_ptr.add(w) = *sample; }
|
||||||
|
w += 1;
|
||||||
|
if w >= cap { w = 0; }
|
||||||
|
}
|
||||||
|
self.write_idx.store(w as i32, Ordering::Release);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, out: &mut [i16]) -> usize {
|
||||||
|
let count = out.len().min(self.available_read());
|
||||||
|
if count == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
||||||
|
let cap = self.capacity;
|
||||||
|
let buf_ptr = self.buf.as_ptr();
|
||||||
|
for slot in &mut out[..count] {
|
||||||
|
unsafe { *slot = *buf_ptr.add(r); }
|
||||||
|
r += 1;
|
||||||
|
if r >= cap { r = 0; }
|
||||||
|
}
|
||||||
|
self.read_idx.store(r as i32, Ordering::Release);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buf_ptr(&self) -> *mut i16 {
|
||||||
|
self.buf.as_ptr() as *mut i16
|
||||||
|
}
|
||||||
|
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
||||||
|
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
||||||
|
}
|
||||||
|
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
||||||
|
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AudioBackend singleton ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// There is one global AudioBackend instance because Oboe's C++ side
|
||||||
|
// holds its own singleton of the streams. The `Box::leak`'d statics own
|
||||||
|
// the ring buffers for the lifetime of the process — dropping them while
|
||||||
|
// Oboe is still running would cause use-after-free in the audio callback.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
struct AudioBackend {
|
||||||
|
capture: RingBuffer,
|
||||||
|
playout: RingBuffer,
|
||||||
|
started: std::sync::Mutex<bool>,
|
||||||
|
/// Per-write logging throttle counter for wzp_native_audio_write_playout.
|
||||||
|
playout_write_log_count: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
|
||||||
|
|
||||||
|
fn backend() -> &'static AudioBackend {
|
||||||
|
BACKEND.get_or_init(|| {
|
||||||
|
Box::leak(Box::new(AudioBackend {
|
||||||
|
capture: RingBuffer::new(RING_CAPACITY),
|
||||||
|
playout: RingBuffer::new(RING_CAPACITY),
|
||||||
|
started: std::sync::Mutex::new(false),
|
||||||
|
playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── C FFI for wzp-desktop ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Start the Oboe audio streams. Returns 0 on success, non-zero on error.
|
||||||
|
/// Idempotent — calling while already running is a no-op that returns 0.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_audio_start() -> i32 {
|
||||||
|
let b = backend();
|
||||||
|
let mut started = match b.started.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
if *started {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = WzpOboeConfig {
|
||||||
|
sample_rate: 48_000,
|
||||||
|
frames_per_burst: FRAME_SAMPLES as i32,
|
||||||
|
channel_count: 1,
|
||||||
|
};
|
||||||
|
let rings = WzpOboeRings {
|
||||||
|
capture_buf: b.capture.buf_ptr(),
|
||||||
|
capture_capacity: b.capture.capacity as i32,
|
||||||
|
capture_write_idx: b.capture.write_idx_ptr(),
|
||||||
|
capture_read_idx: b.capture.read_idx_ptr(),
|
||||||
|
playout_buf: b.playout.buf_ptr(),
|
||||||
|
playout_capacity: b.playout.capacity as i32,
|
||||||
|
playout_write_idx: b.playout.write_idx_ptr(),
|
||||||
|
playout_read_idx: b.playout.read_idx_ptr(),
|
||||||
|
};
|
||||||
|
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
||||||
|
if ret != 0 {
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
*started = true;
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop Oboe. Idempotent. Safe to call from any thread.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_audio_stop() {
|
||||||
|
let b = backend();
|
||||||
|
if let Ok(mut started) = b.started.lock() {
|
||||||
|
if *started {
|
||||||
|
unsafe { wzp_oboe_stop() };
|
||||||
|
*started = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read captured PCM samples from the capture ring. Returns the number
|
||||||
|
/// of `i16` samples actually copied into `out` (may be less than
|
||||||
|
/// `out_len` if the ring is empty).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn wzp_native_audio_read_capture(out: *mut i16, out_len: usize) -> usize {
|
||||||
|
if out.is_null() || out_len == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let slice = unsafe { std::slice::from_raw_parts_mut(out, out_len) };
|
||||||
|
backend().capture.read(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write PCM samples into the playout ring. Returns the number of
|
||||||
|
/// samples actually enqueued (may be less than `in_len` if the ring
|
||||||
|
/// is nearly full — in practice the caller should pace to 20 ms
|
||||||
|
/// frames and spin briefly if the ring is full).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_len: usize) -> usize {
|
||||||
|
if input.is_null() || in_len == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
|
||||||
|
let b = backend();
|
||||||
|
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let written = b.playout.write(slice);
|
||||||
|
// First few writes: log ring state + sample range so we can compare what
|
||||||
|
// engine.rs hands us to what the C++ playout callback reads.
|
||||||
|
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if first_writes < 3 || first_writes % 50 == 0 {
|
||||||
|
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
|
||||||
|
for &s in slice.iter() {
|
||||||
|
if s < lo { lo = s; }
|
||||||
|
if s > hi { hi = s; }
|
||||||
|
sumsq += (s as i64) * (s as i64);
|
||||||
|
}
|
||||||
|
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
|
||||||
|
let avail_w_after = b.playout.available_write();
|
||||||
|
let avail_r_after = b.playout.available_read();
|
||||||
|
let msg = format!(
|
||||||
|
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
|
||||||
|
slice.len(), written
|
||||||
|
);
|
||||||
|
unsafe {
|
||||||
|
android_log(msg.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal android logcat shim so we can print from the cdylib without pulling
|
||||||
|
// in android_logger crate (which would add another dep that has to build with
|
||||||
|
// cargo-ndk). Uses libc's __android_log_print via extern linkage.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn __android_log_write(prio: i32, tag: *const u8, text: *const u8) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
unsafe fn android_log(msg: &str) {
|
||||||
|
// ANDROID_LOG_INFO = 4. Tag and text must be NUL-terminated.
|
||||||
|
let tag = b"wzp-native\0";
|
||||||
|
let mut buf = Vec::with_capacity(msg.len() + 1);
|
||||||
|
buf.extend_from_slice(msg.as_bytes());
|
||||||
|
buf.push(0);
|
||||||
|
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
unsafe fn android_log(_msg: &str) {}
|
||||||
|
|
||||||
|
/// Current capture latency reported by Oboe, in milliseconds. Returns
|
||||||
|
/// NaN / 0.0 if the stream isn't running.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_audio_capture_latency_ms() -> f32 {
|
||||||
|
unsafe { wzp_oboe_capture_latency_ms() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current playout latency reported by Oboe, in milliseconds.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_audio_playout_latency_ms() -> f32 {
|
||||||
|
unsafe { wzp_oboe_playout_latency_ms() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-zero if both Oboe streams are currently running.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn wzp_native_audio_is_running() -> i32 {
|
||||||
|
unsafe { wzp_oboe_is_running() }
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ pub enum CodecId {
|
|||||||
Codec2_1200 = 4,
|
Codec2_1200 = 4,
|
||||||
/// Comfort noise descriptor (silence suppression)
|
/// Comfort noise descriptor (silence suppression)
|
||||||
ComfortNoise = 5,
|
ComfortNoise = 5,
|
||||||
|
/// Opus at 32kbps (studio low)
|
||||||
|
Opus32k = 6,
|
||||||
|
/// Opus at 48kbps (studio)
|
||||||
|
Opus48k = 7,
|
||||||
|
/// Opus at 64kbps (studio high)
|
||||||
|
Opus64k = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecId {
|
impl CodecId {
|
||||||
@@ -27,6 +33,9 @@ impl CodecId {
|
|||||||
Self::Opus24k => 24_000,
|
Self::Opus24k => 24_000,
|
||||||
Self::Opus16k => 16_000,
|
Self::Opus16k => 16_000,
|
||||||
Self::Opus6k => 6_000,
|
Self::Opus6k => 6_000,
|
||||||
|
Self::Opus32k => 32_000,
|
||||||
|
Self::Opus48k => 48_000,
|
||||||
|
Self::Opus64k => 64_000,
|
||||||
Self::Codec2_3200 => 3_200,
|
Self::Codec2_3200 => 3_200,
|
||||||
Self::Codec2_1200 => 1_200,
|
Self::Codec2_1200 => 1_200,
|
||||||
Self::ComfortNoise => 0,
|
Self::ComfortNoise => 0,
|
||||||
@@ -36,8 +45,7 @@ impl CodecId {
|
|||||||
/// Preferred frame duration in milliseconds.
|
/// Preferred frame duration in milliseconds.
|
||||||
pub const fn frame_duration_ms(self) -> u8 {
|
pub const fn frame_duration_ms(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k => 20,
|
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||||
Self::Opus16k => 20,
|
|
||||||
Self::Opus6k => 40,
|
Self::Opus6k => 40,
|
||||||
Self::Codec2_3200 => 20,
|
Self::Codec2_3200 => 20,
|
||||||
Self::Codec2_1200 => 40,
|
Self::Codec2_1200 => 40,
|
||||||
@@ -48,7 +56,8 @@ impl CodecId {
|
|||||||
/// Sample rate expected by this codec.
|
/// Sample rate expected by this codec.
|
||||||
pub const fn sample_rate_hz(self) -> u32 {
|
pub const fn sample_rate_hz(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
||||||
|
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
||||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||||
Self::ComfortNoise => 48_000,
|
Self::ComfortNoise => 48_000,
|
||||||
}
|
}
|
||||||
@@ -63,6 +72,9 @@ impl CodecId {
|
|||||||
3 => Some(Self::Codec2_3200),
|
3 => Some(Self::Codec2_3200),
|
||||||
4 => Some(Self::Codec2_1200),
|
4 => Some(Self::Codec2_1200),
|
||||||
5 => Some(Self::ComfortNoise),
|
5 => Some(Self::ComfortNoise),
|
||||||
|
6 => Some(Self::Opus32k),
|
||||||
|
7 => Some(Self::Opus48k),
|
||||||
|
8 => Some(Self::Opus64k),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +83,12 @@ impl CodecId {
|
|||||||
pub const fn to_wire(self) -> u8 {
|
pub const fn to_wire(self) -> u8 {
|
||||||
self as u8
|
self as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if this is an Opus variant.
|
||||||
|
pub const fn is_opus(self) -> bool {
|
||||||
|
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
||||||
|
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the complete quality configuration for a call session.
|
/// Describes the complete quality configuration for a call session.
|
||||||
@@ -111,6 +129,30 @@ impl QualityProfile {
|
|||||||
frames_per_block: 8,
|
frames_per_block: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Studio low: Opus 32kbps, minimal FEC.
|
||||||
|
pub const STUDIO_32K: Self = Self {
|
||||||
|
codec: CodecId::Opus32k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Studio: Opus 48kbps, minimal FEC.
|
||||||
|
pub const STUDIO_48K: Self = Self {
|
||||||
|
codec: CodecId::Opus48k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Studio high: Opus 64kbps, minimal FEC.
|
||||||
|
pub const STUDIO_64K: Self = Self {
|
||||||
|
codec: CodecId::Opus64k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||||
|
|||||||
@@ -273,10 +273,21 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out)
|
// Check if packet is too old (already played out).
|
||||||
|
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
||||||
|
// federation room — reset instead of dropping.
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
self.stats.packets_late += 1;
|
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||||
return;
|
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
||||||
|
if backward_distance > 100 {
|
||||||
|
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
||||||
|
self.buffer.clear();
|
||||||
|
self.next_playout_seq = seq;
|
||||||
|
self.stats.packets_late = 0;
|
||||||
|
} else {
|
||||||
|
self.stats.packets_late += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
@@ -412,10 +423,21 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out)
|
// Check if packet is too old (already played out).
|
||||||
|
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
||||||
|
// federation room — reset instead of dropping.
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
self.stats.packets_late += 1;
|
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||||
return;
|
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
||||||
|
if backward_distance > 100 {
|
||||||
|
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
||||||
|
self.buffer.clear();
|
||||||
|
self.next_playout_seq = seq;
|
||||||
|
self.stats.packets_late = 0;
|
||||||
|
} else {
|
||||||
|
self.stats.packets_late += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ pub mod traits;
|
|||||||
pub use codec_id::{CodecId, QualityProfile};
|
pub use codec_id::{CodecId, QualityProfile};
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
||||||
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
||||||
|
FRAME_TYPE_MINI,
|
||||||
};
|
};
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||||
|
|||||||
@@ -656,6 +656,112 @@ pub enum SignalMessage {
|
|||||||
/// List of participants currently in the room.
|
/// List of participants currently in the room.
|
||||||
participants: Vec<RoomParticipant>,
|
participants: Vec<RoomParticipant>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Federation signals (relay-to-relay) ──
|
||||||
|
|
||||||
|
/// Federation: initial handshake — the connecting relay identifies itself.
|
||||||
|
FederationHello {
|
||||||
|
/// TLS certificate fingerprint of the connecting relay.
|
||||||
|
tls_fingerprint: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Federation: this relay now has local participants in a global room.
|
||||||
|
GlobalRoomActive {
|
||||||
|
room: String,
|
||||||
|
/// Participants on the announcing relay (for federated presence).
|
||||||
|
#[serde(default)]
|
||||||
|
participants: Vec<RoomParticipant>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Federation: this relay's last local participant left a global room.
|
||||||
|
GlobalRoomInactive {
|
||||||
|
room: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Direct calling signals (client ↔ relay signaling) ──
|
||||||
|
|
||||||
|
/// Register on relay for direct calls. Sent on `_signal` connections
|
||||||
|
/// after optional AuthToken.
|
||||||
|
RegisterPresence {
|
||||||
|
/// Client's Ed25519 identity public key.
|
||||||
|
identity_pub: [u8; 32],
|
||||||
|
/// Signature over ("register-presence" || identity_pub).
|
||||||
|
signature: Vec<u8>,
|
||||||
|
/// Optional display name.
|
||||||
|
alias: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Relay confirms presence registration.
|
||||||
|
RegisterPresenceAck {
|
||||||
|
success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Direct call offer routed through the relay to a specific peer.
|
||||||
|
DirectCallOffer {
|
||||||
|
/// Caller's fingerprint.
|
||||||
|
caller_fingerprint: String,
|
||||||
|
/// Caller's display name.
|
||||||
|
caller_alias: Option<String>,
|
||||||
|
/// Target's fingerprint.
|
||||||
|
target_fingerprint: String,
|
||||||
|
/// Unique call session ID (UUID).
|
||||||
|
call_id: String,
|
||||||
|
/// Caller's Ed25519 identity pub.
|
||||||
|
identity_pub: [u8; 32],
|
||||||
|
/// Caller's ephemeral X25519 pub (for key exchange on media connect).
|
||||||
|
ephemeral_pub: [u8; 32],
|
||||||
|
/// Signature over (ephemeral_pub || target_fingerprint || call_id).
|
||||||
|
signature: Vec<u8>,
|
||||||
|
/// Supported quality profiles.
|
||||||
|
supported_profiles: Vec<crate::QualityProfile>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Callee's response to a direct call.
|
||||||
|
DirectCallAnswer {
|
||||||
|
call_id: String,
|
||||||
|
/// How the callee accepts (or rejects).
|
||||||
|
accept_mode: CallAcceptMode,
|
||||||
|
/// Callee's identity pub (present when accepting).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
identity_pub: Option<[u8; 32]>,
|
||||||
|
/// Callee's ephemeral pub (present when accepting).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
ephemeral_pub: Option<[u8; 32]>,
|
||||||
|
/// Signature (present when accepting).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<Vec<u8>>,
|
||||||
|
/// Chosen quality profile (present when accepting).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
chosen_profile: Option<crate::QualityProfile>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Relay tells both parties: media room is ready.
|
||||||
|
CallSetup {
|
||||||
|
call_id: String,
|
||||||
|
/// Room name on the relay for the media session (e.g., "_call:a1b2c3d4").
|
||||||
|
room: String,
|
||||||
|
/// Relay address for the QUIC media connection.
|
||||||
|
relay_addr: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Ringing notification (relay → caller, callee received the offer).
|
||||||
|
CallRinging {
|
||||||
|
call_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the callee responds to a direct call.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CallAcceptMode {
|
||||||
|
/// Reject the call.
|
||||||
|
Reject,
|
||||||
|
/// Accept with trust — in Phase 2, this enables P2P (reveals IP).
|
||||||
|
/// In Phase 1, behaves the same as AcceptGeneric.
|
||||||
|
AcceptTrusted,
|
||||||
|
/// Accept with privacy — relay always mediates media.
|
||||||
|
AcceptGeneric,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A participant entry in a RoomUpdate message.
|
/// A participant entry in a RoomUpdate message.
|
||||||
@@ -665,6 +771,10 @@ pub struct RoomParticipant {
|
|||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
/// Optional display name set by the client.
|
/// Optional display name set by the client.
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
|
/// Relay label — identifies which relay this participant is connected to.
|
||||||
|
/// None for local participants, Some("Relay B") for federated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay_label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasons for ending a call.
|
/// Reasons for ending a call.
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ pub trait CryptoSession: Send + Sync {
|
|||||||
fn overhead(&self) -> usize {
|
fn overhead(&self) -> usize {
|
||||||
16 // ChaCha20-Poly1305 tag
|
16 // ChaCha20-Poly1305 tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short Authentication String (SAS) — 4-digit code for verbal verification.
|
||||||
|
/// Both peers derive the same code from the shared secret + identity keys.
|
||||||
|
/// If a MITM relay is intercepting, the codes will differ.
|
||||||
|
/// Returns None if SAS was not computed (e.g., relay-side sessions).
|
||||||
|
fn sas_code(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key exchange using the Warzone identity model.
|
/// Key exchange using the Warzone identity model.
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ prometheus = "0.13"
|
|||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
dirs = "6"
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
18
crates/wzp-relay/build.rs
Normal file
18
crates/wzp-relay/build.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Get git hash at build time
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let hash = match output {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
_ => "unknown".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=WZP_BUILD_HASH={hash}");
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
}
|
||||||
199
crates/wzp-relay/src/call_registry.rs
Normal file
199
crates/wzp-relay/src/call_registry.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//! Direct call state tracking.
|
||||||
|
//!
|
||||||
|
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
|
||||||
|
//! Each call goes through: Pending → Ringing → Active → Ended.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// State of a direct call.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DirectCallState {
|
||||||
|
/// Offer sent to callee, waiting for response.
|
||||||
|
Pending,
|
||||||
|
/// Callee acknowledged, ringing.
|
||||||
|
Ringing,
|
||||||
|
/// Call accepted, media room active.
|
||||||
|
Active,
|
||||||
|
/// Call ended (hangup, reject, timeout, or error).
|
||||||
|
Ended,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tracked direct call between two users.
|
||||||
|
pub struct DirectCall {
|
||||||
|
pub call_id: String,
|
||||||
|
pub caller_fingerprint: String,
|
||||||
|
pub callee_fingerprint: String,
|
||||||
|
pub state: DirectCallState,
|
||||||
|
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
|
||||||
|
/// Private room name (set when accepted).
|
||||||
|
pub room_name: Option<String>,
|
||||||
|
pub created_at: Instant,
|
||||||
|
pub answered_at: Option<Instant>,
|
||||||
|
pub ended_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of active direct calls.
|
||||||
|
pub struct CallRegistry {
|
||||||
|
calls: HashMap<String, DirectCall>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new pending call. Returns the call_id.
|
||||||
|
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
|
||||||
|
let call = DirectCall {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
caller_fingerprint: caller_fp,
|
||||||
|
callee_fingerprint: callee_fp,
|
||||||
|
state: DirectCallState::Pending,
|
||||||
|
accept_mode: None,
|
||||||
|
room_name: None,
|
||||||
|
created_at: Instant::now(),
|
||||||
|
answered_at: None,
|
||||||
|
ended_at: None,
|
||||||
|
};
|
||||||
|
self.calls.insert(call_id.clone(), call);
|
||||||
|
self.calls.get(&call_id).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a call by ID.
|
||||||
|
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
||||||
|
self.calls.get(call_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable call by ID.
|
||||||
|
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
|
||||||
|
self.calls.get_mut(call_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Ringing state.
|
||||||
|
pub fn set_ringing(&mut self, call_id: &str) -> bool {
|
||||||
|
if let Some(call) = self.calls.get_mut(call_id) {
|
||||||
|
if call.state == DirectCallState::Pending {
|
||||||
|
call.state = DirectCallState::Ringing;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transition to Active state.
|
||||||
|
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
|
||||||
|
if let Some(call) = self.calls.get_mut(call_id) {
|
||||||
|
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
|
||||||
|
call.state = DirectCallState::Active;
|
||||||
|
call.accept_mode = Some(mode);
|
||||||
|
call.room_name = Some(room);
|
||||||
|
call.answered_at = Some(Instant::now());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a call.
|
||||||
|
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
|
||||||
|
if let Some(call) = self.calls.get_mut(call_id) {
|
||||||
|
call.state = DirectCallState::Ended;
|
||||||
|
call.ended_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
self.calls.remove(call_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find active/pending calls involving a fingerprint.
|
||||||
|
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
|
||||||
|
self.calls.values()
|
||||||
|
.filter(|c| {
|
||||||
|
c.state != DirectCallState::Ended
|
||||||
|
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the peer's fingerprint in a call.
|
||||||
|
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
|
||||||
|
self.calls.get(call_id).map(|c| {
|
||||||
|
if c.caller_fingerprint == my_fp {
|
||||||
|
c.callee_fingerprint.as_str()
|
||||||
|
} else {
|
||||||
|
c.caller_fingerprint.as_str()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove calls that have been pending longer than the timeout.
|
||||||
|
/// Returns call IDs of expired calls.
|
||||||
|
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let expired: Vec<String> = self.calls.iter()
|
||||||
|
.filter(|(_, c)| {
|
||||||
|
c.state == DirectCallState::Pending
|
||||||
|
&& now.duration_since(c.created_at) > timeout
|
||||||
|
})
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
expired.into_iter()
|
||||||
|
.filter_map(|id| self.calls.remove(&id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of active (non-ended) calls.
|
||||||
|
pub fn active_count(&self) -> usize {
|
||||||
|
self.calls.values()
|
||||||
|
.filter(|c| c.state != DirectCallState::Ended)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_lifecycle() {
|
||||||
|
let mut reg = CallRegistry::new();
|
||||||
|
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||||
|
|
||||||
|
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
|
||||||
|
assert!(reg.set_ringing("c1"));
|
||||||
|
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
|
||||||
|
|
||||||
|
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
|
||||||
|
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
|
||||||
|
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
|
||||||
|
|
||||||
|
let ended = reg.end_call("c1").unwrap();
|
||||||
|
assert_eq!(ended.state, DirectCallState::Ended);
|
||||||
|
assert_eq!(reg.active_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expire_stale_calls() {
|
||||||
|
let mut reg = CallRegistry::new();
|
||||||
|
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||||
|
|
||||||
|
// Not expired yet
|
||||||
|
let expired = reg.expire_stale(Duration::from_secs(30));
|
||||||
|
assert!(expired.is_empty());
|
||||||
|
|
||||||
|
// Force expiry with 0 timeout
|
||||||
|
let expired = reg.expire_stale(Duration::from_secs(0));
|
||||||
|
assert_eq!(expired.len(), 1);
|
||||||
|
assert_eq!(expired[0].call_id, "c1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_lookup() {
|
||||||
|
let mut reg = CallRegistry::new();
|
||||||
|
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||||
|
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
|
||||||
|
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,41 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
/// Configuration for the relay daemon.
|
/// A federated peer relay.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PeerConfig {
|
||||||
|
/// Address of the peer relay (e.g., "193.180.213.68:4433").
|
||||||
|
pub url: String,
|
||||||
|
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||||
|
pub fingerprint: String,
|
||||||
|
/// Optional human-readable label.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trusted relay — accepts inbound federation without needing the peer's address.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TrustedConfig {
|
||||||
|
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||||
|
pub fingerprint: String,
|
||||||
|
/// Optional human-readable label.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A room declared global — bridged across all federated peers.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GlobalRoomConfig {
|
||||||
|
/// Room name to bridge (e.g., "android").
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the relay daemon.
|
||||||
|
///
|
||||||
|
/// All fields have defaults, so a minimal TOML file only needs the
|
||||||
|
/// fields you want to override (e.g., just `[[peers]]`).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct RelayConfig {
|
pub struct RelayConfig {
|
||||||
/// Address to listen on for incoming connections (client-facing).
|
/// Address to listen on for incoming connections (client-facing).
|
||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
@@ -44,6 +77,22 @@ pub struct RelayConfig {
|
|||||||
pub ws_port: Option<u16>,
|
pub ws_port: Option<u16>,
|
||||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||||
pub static_dir: Option<String>,
|
pub static_dir: Option<String>,
|
||||||
|
/// Federation peer relays.
|
||||||
|
#[serde(default)]
|
||||||
|
pub peers: Vec<PeerConfig>,
|
||||||
|
/// Global rooms bridged across federation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_rooms: Vec<GlobalRoomConfig>,
|
||||||
|
/// Trusted relay fingerprints — accept inbound federation from these relays.
|
||||||
|
/// Unlike [[peers]], no url is needed — the peer connects to us.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted: Vec<TrustedConfig>,
|
||||||
|
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
|
||||||
|
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
|
||||||
|
pub debug_tap: Option<String>,
|
||||||
|
/// JSONL event log path for protocol analysis (--event-log).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub event_log: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RelayConfig {
|
impl Default for RelayConfig {
|
||||||
@@ -62,6 +111,100 @@ impl Default for RelayConfig {
|
|||||||
trunking_enabled: false,
|
trunking_enabled: false,
|
||||||
ws_port: None,
|
ws_port: None,
|
||||||
static_dir: None,
|
static_dir: None,
|
||||||
|
peers: Vec::new(),
|
||||||
|
global_rooms: Vec::new(),
|
||||||
|
trusted: Vec::new(),
|
||||||
|
debug_tap: None,
|
||||||
|
event_log: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load relay configuration from a TOML file.
|
||||||
|
pub fn load_config(path: &str) -> Result<RelayConfig, anyhow::Error> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let config: RelayConfig = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info about this relay instance, used to generate personalized example configs.
|
||||||
|
pub struct RelayInfo {
|
||||||
|
pub listen_addr: String,
|
||||||
|
pub tls_fingerprint: String,
|
||||||
|
pub public_ip: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config from path, or create a personalized example config if it doesn't exist.
|
||||||
|
pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<RelayConfig, anyhow::Error> {
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
if p.exists() {
|
||||||
|
return load_config(path);
|
||||||
|
}
|
||||||
|
// Create parent directory if needed
|
||||||
|
if let Some(parent) = p.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
// Generate personalized example config
|
||||||
|
let example = generate_example_config(info);
|
||||||
|
std::fs::write(p, &example)?;
|
||||||
|
eprintln!("Created example config at {path} — edit it and restart.");
|
||||||
|
let config: RelayConfig = toml::from_str(&example)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an example TOML config, personalized with this relay's info if available.
|
||||||
|
fn generate_example_config(info: Option<&RelayInfo>) -> String {
|
||||||
|
let listen = info.map(|i| i.listen_addr.as_str()).unwrap_or("0.0.0.0:4433");
|
||||||
|
let peer_example = if let Some(i) = info {
|
||||||
|
let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip");
|
||||||
|
format!(
|
||||||
|
r#"# Other relays can peer with this relay using:
|
||||||
|
# [[peers]]
|
||||||
|
# url = "{ip}:{port}"
|
||||||
|
# fingerprint = "{fp}"
|
||||||
|
# label = "This Relay""#,
|
||||||
|
port = listen.rsplit(':').next().unwrap_or("4433"),
|
||||||
|
fp = i.tls_fingerprint,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"# To peer with another relay, add its url + fingerprint:".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"# WarzonePhone Relay Configuration
|
||||||
|
# See docs/ADMINISTRATION.md for full reference.
|
||||||
|
|
||||||
|
# Listen address for client connections
|
||||||
|
listen_addr = "{listen}"
|
||||||
|
|
||||||
|
# Maximum concurrent sessions
|
||||||
|
# max_sessions = 100
|
||||||
|
|
||||||
|
# Prometheus metrics endpoint (uncomment to enable)
|
||||||
|
# metrics_port = 9090
|
||||||
|
|
||||||
|
# featherChat auth endpoint (uncomment to enable)
|
||||||
|
# auth_url = "https://chat.example.com/v1/auth/validate"
|
||||||
|
|
||||||
|
{peer_example}
|
||||||
|
|
||||||
|
# Federation: peer relays we connect to (outbound)
|
||||||
|
# [[peers]]
|
||||||
|
# url = "other-relay.example.com:4433"
|
||||||
|
# fingerprint = "aa:bb:cc:dd:..."
|
||||||
|
# label = "Relay B"
|
||||||
|
|
||||||
|
# Federation: relays we trust inbound connections from
|
||||||
|
# [[trusted]]
|
||||||
|
# fingerprint = "ee:ff:00:11:..."
|
||||||
|
# label = "Relay X"
|
||||||
|
|
||||||
|
# Global rooms bridged across all federated peers
|
||||||
|
# [[global_rooms]]
|
||||||
|
# name = "general"
|
||||||
|
|
||||||
|
# Debug: log packet headers for a room ("*" for all)
|
||||||
|
# debug_tap = "*"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
201
crates/wzp-relay/src/event_log.rs
Normal file
201
crates/wzp-relay/src/event_log.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! JSONL event log for protocol analysis.
|
||||||
|
//!
|
||||||
|
//! When `--event-log <path>` is set, every media packet emits a structured
|
||||||
|
//! event at each decision point (recv, forward, drop, deliver).
|
||||||
|
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
/// A single protocol event for JSONL output.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Event {
|
||||||
|
/// ISO 8601 timestamp with microseconds.
|
||||||
|
pub ts: String,
|
||||||
|
/// Event type.
|
||||||
|
pub event: &'static str,
|
||||||
|
/// Room name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub room: Option<String>,
|
||||||
|
/// Source address or peer label.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub src: Option<String>,
|
||||||
|
/// Packet sequence number.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub seq: Option<u16>,
|
||||||
|
/// Codec identifier.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codec: Option<String>,
|
||||||
|
/// FEC block ID.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fec_block: Option<u8>,
|
||||||
|
/// FEC symbol index.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fec_sym: Option<u8>,
|
||||||
|
/// Is FEC repair packet.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repair: Option<bool>,
|
||||||
|
/// Payload length in bytes.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub len: Option<usize>,
|
||||||
|
/// Number of recipients.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub to_count: Option<usize>,
|
||||||
|
/// Peer label (for federation events).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub peer: Option<String>,
|
||||||
|
/// Drop/error reason.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
/// Presence action (active/inactive).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub action: Option<String>,
|
||||||
|
/// Participant count (presence events).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub participants: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
fn now() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a minimal event with just type and timestamp.
|
||||||
|
pub fn new(event: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
ts: Self::now(),
|
||||||
|
event,
|
||||||
|
room: None,
|
||||||
|
src: None,
|
||||||
|
seq: None,
|
||||||
|
codec: None,
|
||||||
|
fec_block: None,
|
||||||
|
fec_sym: None,
|
||||||
|
repair: None,
|
||||||
|
len: None,
|
||||||
|
to_count: None,
|
||||||
|
peer: None,
|
||||||
|
reason: None,
|
||||||
|
action: None,
|
||||||
|
participants: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set room.
|
||||||
|
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
|
||||||
|
/// Set source.
|
||||||
|
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
|
||||||
|
/// Set packet header fields from a MediaPacket.
|
||||||
|
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
|
||||||
|
self.seq = Some(pkt.header.seq);
|
||||||
|
self.codec = Some(format!("{:?}", pkt.header.codec_id));
|
||||||
|
self.fec_block = Some(pkt.header.fec_block);
|
||||||
|
self.fec_sym = Some(pkt.header.fec_symbol);
|
||||||
|
self.repair = Some(pkt.header.is_repair);
|
||||||
|
self.len = Some(pkt.payload.len());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Set seq only (when full packet not available).
|
||||||
|
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
|
||||||
|
/// Set payload length.
|
||||||
|
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
|
||||||
|
/// Set recipient count.
|
||||||
|
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
|
||||||
|
/// Set peer label.
|
||||||
|
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
|
||||||
|
/// Set drop reason.
|
||||||
|
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
|
||||||
|
/// Set presence action.
|
||||||
|
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
|
||||||
|
/// Set participant count.
|
||||||
|
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle for emitting events. Cheap to clone.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EventLog {
|
||||||
|
tx: mpsc::UnboundedSender<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventLog {
|
||||||
|
/// Emit an event (non-blocking, drops if channel is full).
|
||||||
|
pub fn emit(&self, event: Event) {
|
||||||
|
let _ = self.tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op event log for when `--event-log` is not set.
|
||||||
|
/// All methods are no-ops that compile to nothing.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NoopEventLog;
|
||||||
|
|
||||||
|
/// Unified event log handle — either real or no-op.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum EventLogger {
|
||||||
|
Active(EventLog),
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventLogger {
|
||||||
|
pub fn emit(&self, event: Event) {
|
||||||
|
if let EventLogger::Active(log) = self {
|
||||||
|
log.emit(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
matches!(self, EventLogger::Active(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the event log writer. Returns an `EventLogger` handle.
|
||||||
|
pub fn start_event_log(path: Option<PathBuf>) -> EventLogger {
|
||||||
|
match path {
|
||||||
|
Some(path) => {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
tokio::spawn(writer_task(path, rx));
|
||||||
|
info!("event log enabled");
|
||||||
|
EventLogger::Active(EventLog { tx })
|
||||||
|
}
|
||||||
|
None => EventLogger::Noop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background task that writes events to a JSONL file.
|
||||||
|
async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let file = match tokio::fs::File::create(&path).await {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
error!("failed to create event log {}: {e}", path.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut writer = tokio::io::BufWriter::new(file);
|
||||||
|
let mut count: u64 = 0;
|
||||||
|
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match serde_json::to_string(&event) {
|
||||||
|
Ok(json) => {
|
||||||
|
if writer.write_all(json.as_bytes()).await.is_err() { break; }
|
||||||
|
if writer.write_all(b"\n").await.is_err() { break; }
|
||||||
|
count += 1;
|
||||||
|
// Flush every 100 events
|
||||||
|
if count % 100 == 0 {
|
||||||
|
let _ = writer.flush().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("event log serialize error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = writer.flush().await;
|
||||||
|
info!(events = count, "event log closed");
|
||||||
|
}
|
||||||
966
crates/wzp-relay/src/federation.rs
Normal file
966
crates/wzp-relay/src/federation.rs
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
//! Relay federation — global room routing between peer relays.
|
||||||
|
//!
|
||||||
|
//! Each relay maintains a forwarding table per global room. When a local participant
|
||||||
|
//! sends media in a global room, it's forwarded to all peer relays that have the room
|
||||||
|
//! active. Incoming federated media is delivered to local participants and optionally
|
||||||
|
//! forwarded to other active peers (multi-hop).
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
|
use wzp_transport::QuinnTransport;
|
||||||
|
|
||||||
|
use crate::config::{PeerConfig, TrustedConfig};
|
||||||
|
use crate::event_log::{Event, EventLogger};
|
||||||
|
use crate::room::{self, FederationMediaOut, RoomEvent, RoomManager};
|
||||||
|
|
||||||
|
/// Compute 8-byte room hash for federation datagram tagging.
|
||||||
|
pub fn room_hash(room_name: &str) -> [u8; 8] {
|
||||||
|
let h = Sha256::digest(room_name.as_bytes());
|
||||||
|
let mut out = [0u8; 8];
|
||||||
|
out.copy_from_slice(&h[..8]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a fingerprint string (remove colons, lowercase).
|
||||||
|
fn normalize_fp(fp: &str) -> String {
|
||||||
|
fp.replace(':', "").to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time-based dedup filter for federation datagrams.
|
||||||
|
/// Tracks recently seen packets and expires entries older than 2 seconds.
|
||||||
|
/// This prevents duplicate delivery when the same packet arrives via
|
||||||
|
/// multiple federation paths, while allowing new senders that happen to
|
||||||
|
/// reuse the same seq numbers.
|
||||||
|
struct Deduplicator {
|
||||||
|
/// Recently seen packet keys with insertion time.
|
||||||
|
entries: HashMap<u64, Instant>,
|
||||||
|
/// Expiry duration.
|
||||||
|
ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deduplicator {
|
||||||
|
fn new(_capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::with_capacity(512),
|
||||||
|
ttl: Duration::from_secs(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this packet is a duplicate (already seen within TTL).
|
||||||
|
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
|
||||||
|
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// Periodic cleanup (every ~256 packets)
|
||||||
|
if self.entries.len() > 256 {
|
||||||
|
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ts) = self.entries.get(&key) {
|
||||||
|
if now.duration_since(*ts) < self.ttl {
|
||||||
|
return true; // seen recently — duplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.entries.insert(key, now);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-room token bucket rate limiter for federation forwarding.
|
||||||
|
struct RateLimiter {
|
||||||
|
/// Max packets per second per room.
|
||||||
|
max_pps: u32,
|
||||||
|
/// Tokens remaining in current window.
|
||||||
|
tokens: u32,
|
||||||
|
/// When the current window started.
|
||||||
|
window_start: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
fn new(max_pps: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
max_pps,
|
||||||
|
tokens: max_pps,
|
||||||
|
window_start: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the packet should be allowed through.
|
||||||
|
fn allow(&mut self) -> bool {
|
||||||
|
let elapsed = self.window_start.elapsed();
|
||||||
|
if elapsed >= Duration::from_secs(1) {
|
||||||
|
self.tokens = self.max_pps;
|
||||||
|
self.window_start = Instant::now();
|
||||||
|
}
|
||||||
|
if self.tokens > 0 {
|
||||||
|
self.tokens -= 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active link to a peer relay.
|
||||||
|
struct PeerLink {
|
||||||
|
transport: Arc<QuinnTransport>,
|
||||||
|
label: String,
|
||||||
|
/// Global rooms that this peer has reported as active.
|
||||||
|
active_rooms: HashSet<String>,
|
||||||
|
/// Remote participants per room (for federated presence in RoomUpdate).
|
||||||
|
remote_participants: HashMap<String, Vec<wzp_proto::packet::RoomParticipant>>,
|
||||||
|
/// Last time we received any data (signal or media) from this peer.
|
||||||
|
last_seen: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max federation packets per second per room (0 = unlimited).
|
||||||
|
const FEDERATION_RATE_LIMIT_PPS: u32 = 500;
|
||||||
|
/// Dedup window size (number of recent packets to remember).
|
||||||
|
const DEDUP_WINDOW_SIZE: usize = 4096;
|
||||||
|
/// Remote participants are considered stale after this duration with no updates.
|
||||||
|
const REMOTE_PARTICIPANT_STALE_SECS: u64 = 15;
|
||||||
|
|
||||||
|
/// Manages federation connections and global room forwarding.
|
||||||
|
pub struct FederationManager {
|
||||||
|
peers: Vec<PeerConfig>,
|
||||||
|
trusted: Vec<TrustedConfig>,
|
||||||
|
global_rooms: HashSet<String>,
|
||||||
|
room_mgr: Arc<Mutex<RoomManager>>,
|
||||||
|
endpoint: quinn::Endpoint,
|
||||||
|
local_tls_fp: String,
|
||||||
|
metrics: Arc<crate::metrics::RelayMetrics>,
|
||||||
|
/// Active peer connections, keyed by normalized fingerprint.
|
||||||
|
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
|
||||||
|
/// Dedup filter for incoming federation datagrams.
|
||||||
|
dedup: Mutex<Deduplicator>,
|
||||||
|
/// Per-room seq counter for federation media delivered to local clients.
|
||||||
|
/// Ensures clients see monotonically increasing seq regardless of federation sender.
|
||||||
|
local_delivery_seq: std::sync::atomic::AtomicU16,
|
||||||
|
/// JSONL event log for protocol analysis.
|
||||||
|
event_log: EventLogger,
|
||||||
|
/// Per-room rate limiters for inbound federation media.
|
||||||
|
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationManager {
|
||||||
|
pub fn new(
|
||||||
|
peers: Vec<PeerConfig>,
|
||||||
|
trusted: Vec<TrustedConfig>,
|
||||||
|
global_rooms: HashSet<String>,
|
||||||
|
room_mgr: Arc<Mutex<RoomManager>>,
|
||||||
|
endpoint: quinn::Endpoint,
|
||||||
|
local_tls_fp: String,
|
||||||
|
metrics: Arc<crate::metrics::RelayMetrics>,
|
||||||
|
event_log: EventLogger,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
peers,
|
||||||
|
trusted,
|
||||||
|
global_rooms,
|
||||||
|
room_mgr,
|
||||||
|
endpoint,
|
||||||
|
local_tls_fp,
|
||||||
|
metrics,
|
||||||
|
peer_links: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
|
||||||
|
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
|
||||||
|
event_log,
|
||||||
|
rate_limiters: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a room name (which may be hashed) is a global room.
|
||||||
|
pub fn is_global_room(&self, room: &str) -> bool {
|
||||||
|
self.resolve_global_room(room).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a room name (raw or hashed) to the canonical global room name.
|
||||||
|
/// Returns the configured global room name if it matches.
|
||||||
|
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
|
||||||
|
// Direct match (raw room name, e.g. Android clients)
|
||||||
|
if self.global_rooms.contains(room) {
|
||||||
|
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
|
||||||
|
}
|
||||||
|
// Hashed match (desktop clients hash room names for SNI privacy)
|
||||||
|
self.global_rooms.iter().find(|name| {
|
||||||
|
wzp_crypto::hash_room_name(name) == room
|
||||||
|
}).map(|s| s.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the canonical federation room hash for a room.
|
||||||
|
/// Always uses the configured global room name, not the client-provided name.
|
||||||
|
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
|
||||||
|
if let Some(canonical) = self.resolve_global_room(room) {
|
||||||
|
room_hash(canonical)
|
||||||
|
} else {
|
||||||
|
room_hash(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start federation — spawns connection loops + event dispatcher.
|
||||||
|
pub async fn run(self: Arc<Self>) {
|
||||||
|
if self.peers.is_empty() && self.global_rooms.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
peers = self.peers.len(),
|
||||||
|
global_rooms = self.global_rooms.len(),
|
||||||
|
"federation starting"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
// Per-peer outbound connection loops
|
||||||
|
for peer in &self.peers {
|
||||||
|
let this = self.clone();
|
||||||
|
let peer = peer.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
run_peer_loop(this, peer).await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room event dispatcher
|
||||||
|
let room_events = {
|
||||||
|
let mgr = self.room_mgr.lock().await;
|
||||||
|
mgr.subscribe_events()
|
||||||
|
};
|
||||||
|
let this = self.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
run_room_event_dispatcher(this, room_events).await;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stale presence sweeper — purges remote participants from dead peers
|
||||||
|
let this = self.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
run_stale_presence_sweeper(this).await;
|
||||||
|
}));
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
let _ = h.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an inbound federation connection from a recognized peer.
|
||||||
|
pub async fn handle_inbound(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
transport: Arc<QuinnTransport>,
|
||||||
|
peer_config: PeerConfig,
|
||||||
|
) {
|
||||||
|
let peer_fp = normalize_fp(&peer_config.fingerprint);
|
||||||
|
let label = peer_config.label.unwrap_or_else(|| peer_config.url.clone());
|
||||||
|
info!(peer = %label, "inbound federation link active");
|
||||||
|
if let Err(e) = run_federation_link(self.clone(), transport, peer_fp, label.clone()).await {
|
||||||
|
warn!(peer = %label, "inbound federation link ended: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all remote participants for a room from all peer links.
|
||||||
|
/// Deduplicates by fingerprint (same participant may appear via multiple links).
|
||||||
|
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||||
|
let canonical = self.resolve_global_room(room);
|
||||||
|
let links = self.peer_links.lock().await;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for link in links.values() {
|
||||||
|
// Check canonical name
|
||||||
|
if let Some(c) = canonical {
|
||||||
|
if let Some(remote) = link.remote_participants.get(c) {
|
||||||
|
result.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
// Also check raw room name, but only if different from canonical
|
||||||
|
if c != room {
|
||||||
|
if let Some(remote) = link.remote_participants.get(room) {
|
||||||
|
result.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(remote) = link.remote_participants.get(room) {
|
||||||
|
result.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deduplicate by fingerprint
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward locally-generated media to all connected peers.
|
||||||
|
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
|
||||||
|
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
|
||||||
|
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
||||||
|
let links = self.peer_links.lock().await;
|
||||||
|
if links.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (_fp, link) in links.iter() {
|
||||||
|
let mut tagged = Vec::with_capacity(8 + media_data.len());
|
||||||
|
tagged.extend_from_slice(room_hash);
|
||||||
|
tagged.extend_from_slice(media_data);
|
||||||
|
match link.transport.send_raw_datagram(&tagged) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.metrics.federation_packets_forwarded
|
||||||
|
.with_label_values(&[&link.label, "out"]).inc();
|
||||||
|
}
|
||||||
|
Err(e) => warn!(peer = %link.label, "federation send error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trust verification (kept from previous implementation) ──
|
||||||
|
|
||||||
|
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
|
||||||
|
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
|
||||||
|
let addr_ip = addr.ip();
|
||||||
|
self.peers.iter().find(|p| {
|
||||||
|
p.url.parse::<SocketAddr>()
|
||||||
|
.map(|sa| sa.ip() == addr_ip)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
|
||||||
|
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
|
||||||
|
if let Some(peer) = self.find_peer_by_addr(addr) {
|
||||||
|
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
|
||||||
|
}
|
||||||
|
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
|
||||||
|
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbound media egress task ──
|
||||||
|
|
||||||
|
/// Drains the federation media channel and forwards to active peers.
|
||||||
|
pub async fn run_federation_media_egress(
|
||||||
|
fm: Arc<FederationManager>,
|
||||||
|
mut rx: tokio::sync::mpsc::Receiver<FederationMediaOut>,
|
||||||
|
) {
|
||||||
|
let mut count: u64 = 0;
|
||||||
|
while let Some(out) = rx.recv().await {
|
||||||
|
count += 1;
|
||||||
|
if count == 1 || count % 250 == 0 {
|
||||||
|
info!(room = %out.room_name, count, "federation egress: forwarding media");
|
||||||
|
}
|
||||||
|
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
|
||||||
|
}
|
||||||
|
info!(total = count, "federation egress task ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room event dispatcher ──
|
||||||
|
|
||||||
|
/// Watches RoomManager events and sends GlobalRoomActive/Inactive to peers.
|
||||||
|
async fn run_room_event_dispatcher(
|
||||||
|
fm: Arc<FederationManager>,
|
||||||
|
mut events: tokio::sync::broadcast::Receiver<RoomEvent>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
match events.recv().await {
|
||||||
|
Ok(RoomEvent::LocalJoin { room }) => {
|
||||||
|
if fm.is_global_room(&room) {
|
||||||
|
let participants = {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
mgr.local_participant_list(&room)
|
||||||
|
};
|
||||||
|
info!(room = %room, count = participants.len(), "global room now active, announcing to peers");
|
||||||
|
let msg = SignalMessage::GlobalRoomActive { room, participants };
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
for link in links.values() {
|
||||||
|
let _ = link.transport.send_signal(&msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RoomEvent::LocalLeave { room }) => {
|
||||||
|
if fm.is_global_room(&room) {
|
||||||
|
info!(room = %room, "global room now inactive, announcing to peers");
|
||||||
|
let msg = SignalMessage::GlobalRoomInactive { room };
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
for link in links.values() {
|
||||||
|
let _ = link.transport.send_signal(&msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
warn!(missed = n, "room event receiver lagged");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stale presence sweeper ──
|
||||||
|
|
||||||
|
/// Periodically checks for stale remote participants and purges them.
|
||||||
|
/// This handles the case where a peer link dies without sending GlobalRoomInactive
|
||||||
|
/// (e.g., QUIC timeout, network partition, crash).
|
||||||
|
async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let stale_threshold = Duration::from_secs(REMOTE_PARTICIPANT_STALE_SECS);
|
||||||
|
|
||||||
|
// Find peers with stale remote_participants whose link is also gone or idle
|
||||||
|
let stale_rooms: Vec<(String, String)> = {
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
let mut stale = Vec::new();
|
||||||
|
for (fp, link) in links.iter() {
|
||||||
|
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
|
||||||
|
for room in link.remote_participants.keys() {
|
||||||
|
stale.push((fp.clone(), room.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stale
|
||||||
|
};
|
||||||
|
|
||||||
|
if stale_rooms.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge stale entries and collect affected rooms
|
||||||
|
let mut affected_rooms = HashSet::new();
|
||||||
|
{
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
for (fp, room) in &stale_rooms {
|
||||||
|
if let Some(link) = links.get_mut(fp.as_str()) {
|
||||||
|
if link.last_seen.elapsed() > stale_threshold {
|
||||||
|
info!(peer = %link.label, room = %room, "purging stale remote participants (no data for {}s)", link.last_seen.elapsed().as_secs());
|
||||||
|
link.remote_participants.remove(room);
|
||||||
|
link.active_rooms.remove(room);
|
||||||
|
affected_rooms.insert(room.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast updated RoomUpdate for affected rooms
|
||||||
|
for room in &affected_rooms {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
for local_room in mgr.active_rooms() {
|
||||||
|
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(room) {
|
||||||
|
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||||
|
let remote = fm.get_remote_participants(&local_room).await;
|
||||||
|
all_participants.extend(remote);
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
let update = SignalMessage::RoomUpdate {
|
||||||
|
count: all_participants.len() as u32,
|
||||||
|
participants: all_participants,
|
||||||
|
};
|
||||||
|
let senders = mgr.local_senders(&local_room);
|
||||||
|
drop(mgr);
|
||||||
|
room::broadcast_signal(&senders, &update).await;
|
||||||
|
info!(room = %room, "swept stale presence — broadcast updated RoomUpdate");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer connection management ──
|
||||||
|
|
||||||
|
/// Persistent connection loop for one peer — reconnects with backoff.
|
||||||
|
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
|
||||||
|
let mut backoff = Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
|
||||||
|
match connect_to_peer(&fm, &peer).await {
|
||||||
|
Ok(transport) => {
|
||||||
|
backoff = Duration::from_secs(5);
|
||||||
|
let peer_fp = normalize_fp(&peer.fingerprint);
|
||||||
|
let label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
|
||||||
|
if let Err(e) = run_federation_link(fm.clone(), transport, peer_fp, label).await {
|
||||||
|
warn!(peer_url = %peer.url, "federation link ended: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
|
backoff = (backoff * 2).min(Duration::from_secs(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a peer relay and send hello.
|
||||||
|
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
|
||||||
|
let addr: SocketAddr = peer.url.parse()?;
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
||||||
|
let transport = Arc::new(QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Send hello with our TLS fingerprint
|
||||||
|
let hello = SignalMessage::FederationHello {
|
||||||
|
tls_fingerprint: fm.local_tls_fp.clone(),
|
||||||
|
};
|
||||||
|
transport.send_signal(&hello).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
|
||||||
|
|
||||||
|
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
|
||||||
|
Ok(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Federation link (runs on a single QUIC connection) ──
|
||||||
|
|
||||||
|
/// Run the federation link: exchange global room state and forward media.
|
||||||
|
async fn run_federation_link(
|
||||||
|
fm: Arc<FederationManager>,
|
||||||
|
transport: Arc<QuinnTransport>,
|
||||||
|
peer_fp: String,
|
||||||
|
peer_label: String,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
// Register peer link + metrics
|
||||||
|
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
|
||||||
|
{
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
links.insert(peer_fp.clone(), PeerLink {
|
||||||
|
transport: transport.clone(),
|
||||||
|
label: peer_label.clone(),
|
||||||
|
active_rooms: HashSet::new(),
|
||||||
|
remote_participants: HashMap::new(),
|
||||||
|
last_seen: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce our currently active global rooms to this new peer
|
||||||
|
// Collect all announcements first, then send (avoid holding locks across await)
|
||||||
|
let announcements = {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
let active = mgr.active_rooms();
|
||||||
|
let mut msgs = Vec::new();
|
||||||
|
|
||||||
|
// Local rooms
|
||||||
|
for room_name in &active {
|
||||||
|
if fm.is_global_room(room_name) {
|
||||||
|
let participants = mgr.local_participant_list(room_name);
|
||||||
|
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
|
||||||
|
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote rooms from OTHER peers (for multi-hop propagation)
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
for (fp, link) in links.iter() {
|
||||||
|
if fp != &peer_fp {
|
||||||
|
for (room, participants) in &link.remote_participants {
|
||||||
|
if fm.is_global_room(room) {
|
||||||
|
info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer");
|
||||||
|
msgs.push(SignalMessage::GlobalRoomActive {
|
||||||
|
room: room.clone(),
|
||||||
|
participants: participants.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs
|
||||||
|
};
|
||||||
|
for msg in &announcements {
|
||||||
|
let _ = transport.send_signal(msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three concurrent tasks: signal recv + media recv + RTT monitor
|
||||||
|
let signal_transport = transport.clone();
|
||||||
|
let media_transport = transport.clone();
|
||||||
|
let rtt_transport = transport.clone();
|
||||||
|
let fm_signal = fm.clone();
|
||||||
|
let fm_media = fm.clone();
|
||||||
|
let fm_rtt = fm.clone();
|
||||||
|
let peer_fp_signal = peer_fp.clone();
|
||||||
|
let peer_fp_media = peer_fp.clone();
|
||||||
|
let label_signal = peer_label.clone();
|
||||||
|
let label_rtt = peer_label.clone();
|
||||||
|
|
||||||
|
let signal_task = async move {
|
||||||
|
loop {
|
||||||
|
match signal_transport.recv_signal().await {
|
||||||
|
Ok(Some(msg)) => {
|
||||||
|
handle_signal(&fm_signal, &peer_fp_signal, &label_signal, msg).await;
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
error!(peer = %label_signal, "federation signal error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let peer_label_media = peer_label.clone();
|
||||||
|
let media_task = async move {
|
||||||
|
let mut media_count: u64 = 0;
|
||||||
|
loop {
|
||||||
|
match media_transport.connection().read_datagram().await {
|
||||||
|
Ok(data) => {
|
||||||
|
media_count += 1;
|
||||||
|
if media_count == 1 || media_count % 250 == 0 {
|
||||||
|
info!(peer = %peer_label_media, media_count, len = data.len(), "federation: received datagram");
|
||||||
|
}
|
||||||
|
handle_datagram(&fm_media, &peer_fp_media, data).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!(peer = %peer_label_media, "federation media task ended: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// RTT monitor: periodically sample QUIC RTT for this peer
|
||||||
|
let rtt_task = async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = signal_task => {}
|
||||||
|
_ = media_task => {}
|
||||||
|
_ = rtt_task => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: remove peer link + metrics
|
||||||
|
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
|
||||||
|
{
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
links.remove(&peer_fp);
|
||||||
|
}
|
||||||
|
info!(peer = %peer_label, "federation link ended");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming federation signal.
|
||||||
|
async fn handle_signal(
|
||||||
|
fm: &Arc<FederationManager>,
|
||||||
|
peer_fp: &str,
|
||||||
|
peer_label: &str,
|
||||||
|
msg: SignalMessage,
|
||||||
|
) {
|
||||||
|
// Update last_seen for this peer
|
||||||
|
{
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
if let Some(link) = links.get_mut(peer_fp) {
|
||||||
|
link.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
SignalMessage::GlobalRoomActive { room, participants } => {
|
||||||
|
if fm.is_global_room(&room) {
|
||||||
|
info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active");
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
if let Some(link) = links.get_mut(peer_fp) {
|
||||||
|
link.active_rooms.insert(room.clone());
|
||||||
|
}
|
||||||
|
// Update active rooms metric
|
||||||
|
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
||||||
|
fm.metrics.federation_active_rooms.set(total as i64);
|
||||||
|
if let Some(link) = links.get_mut(peer_fp) {
|
||||||
|
// Tag remote participants with their relay label
|
||||||
|
let tagged: Vec<_> = participants.iter().map(|p| {
|
||||||
|
let mut tagged = p.clone();
|
||||||
|
if tagged.relay_label.is_none() {
|
||||||
|
tagged.relay_label = Some(link.label.clone());
|
||||||
|
}
|
||||||
|
tagged
|
||||||
|
}).collect();
|
||||||
|
link.remote_participants.insert(room.clone(), tagged);
|
||||||
|
}
|
||||||
|
// Propagate to other peers (with relay labels preserved)
|
||||||
|
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
|
||||||
|
let label = link.label.clone();
|
||||||
|
participants.iter().map(|p| {
|
||||||
|
let mut t = p.clone();
|
||||||
|
if t.relay_label.is_none() {
|
||||||
|
t.relay_label = Some(label.clone());
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
participants.clone()
|
||||||
|
};
|
||||||
|
for (fp, link) in links.iter() {
|
||||||
|
if fp != peer_fp {
|
||||||
|
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
|
||||||
|
room: room.clone(),
|
||||||
|
participants: tagged_for_propagation.clone(),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(links);
|
||||||
|
|
||||||
|
// Broadcast updated RoomUpdate to local clients in this room
|
||||||
|
// Find the local room name (may be hashed or raw)
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
for local_room in mgr.active_rooms() {
|
||||||
|
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||||
|
// Build merged participant list: local + all remote (deduped)
|
||||||
|
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
for link in links.values() {
|
||||||
|
if let Some(canonical) = fm.resolve_global_room(&local_room) {
|
||||||
|
if let Some(remote) = link.remote_participants.get(canonical) {
|
||||||
|
all_participants.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
// Also check raw room name, but only if different from canonical
|
||||||
|
if canonical != local_room {
|
||||||
|
if let Some(remote) = link.remote_participants.get(&local_room) {
|
||||||
|
all_participants.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deduplicate by fingerprint
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
let update = SignalMessage::RoomUpdate {
|
||||||
|
count: all_participants.len() as u32,
|
||||||
|
participants: all_participants,
|
||||||
|
};
|
||||||
|
let senders = mgr.local_senders(&local_room);
|
||||||
|
drop(links);
|
||||||
|
drop(mgr);
|
||||||
|
room::broadcast_signal(&senders, &update).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalMessage::GlobalRoomInactive { room } => {
|
||||||
|
info!(peer = %peer_label, room = %room, "peer global room now inactive");
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
if let Some(link) = links.get_mut(peer_fp) {
|
||||||
|
link.active_rooms.remove(&room);
|
||||||
|
// Clear remote participants for this peer+room
|
||||||
|
link.remote_participants.remove(&room);
|
||||||
|
// Also try canonical name
|
||||||
|
if let Some(canonical) = fm.resolve_global_room(&room) {
|
||||||
|
link.remote_participants.remove(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active rooms metric
|
||||||
|
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
||||||
|
fm.metrics.federation_active_rooms.set(total as i64);
|
||||||
|
|
||||||
|
// Build remaining remote participants (from all peers except the one going inactive)
|
||||||
|
let remaining_remote: Vec<wzp_proto::packet::RoomParticipant> = {
|
||||||
|
let canonical = fm.resolve_global_room(&room);
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for (fp, link) in links.iter() {
|
||||||
|
if fp == peer_fp { continue; }
|
||||||
|
if let Some(c) = canonical {
|
||||||
|
if let Some(remote) = link.remote_participants.get(c) {
|
||||||
|
result.extend(remote.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
// Propagate to other peers: send updated GlobalRoomActive with revised list,
|
||||||
|
// or GlobalRoomInactive if no participants remain anywhere
|
||||||
|
let local_active = {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room))
|
||||||
|
};
|
||||||
|
let has_remaining = !remaining_remote.is_empty() || local_active;
|
||||||
|
|
||||||
|
// Collect peer transports to send to (avoid holding lock across await)
|
||||||
|
let peer_sends: Vec<_> = links.iter()
|
||||||
|
.filter(|(fp, _)| *fp != peer_fp)
|
||||||
|
.map(|(_, link)| link.transport.clone())
|
||||||
|
.collect();
|
||||||
|
drop(links);
|
||||||
|
|
||||||
|
if has_remaining {
|
||||||
|
// Send updated participant list to other peers
|
||||||
|
let mut updated_participants = remaining_remote.clone();
|
||||||
|
if local_active {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
for local_room in mgr.active_rooms() {
|
||||||
|
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||||
|
updated_participants.extend(mgr.local_participant_list(&local_room));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let msg = SignalMessage::GlobalRoomActive {
|
||||||
|
room: room.clone(),
|
||||||
|
participants: updated_participants,
|
||||||
|
};
|
||||||
|
for transport in &peer_sends {
|
||||||
|
let _ = transport.send_signal(&msg).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No participants left anywhere — propagate inactive
|
||||||
|
let msg = SignalMessage::GlobalRoomInactive { room: room.clone() };
|
||||||
|
for transport in &peer_sends {
|
||||||
|
let _ = transport.send_signal(&msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast updated RoomUpdate to local clients (remote participant removed)
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
for local_room in mgr.active_rooms() {
|
||||||
|
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
||||||
|
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||||
|
all_participants.extend(remaining_remote.iter().cloned());
|
||||||
|
// Deduplicate by fingerprint
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
let update = SignalMessage::RoomUpdate {
|
||||||
|
count: all_participants.len() as u32,
|
||||||
|
participants: all_participants,
|
||||||
|
};
|
||||||
|
let senders = mgr.local_senders(&local_room);
|
||||||
|
drop(mgr);
|
||||||
|
room::broadcast_signal(&senders, &update).await;
|
||||||
|
info!(room = %room, "broadcast updated presence (remote participant removed)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // ignore other signals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming federation datagram (room-hash-tagged media).
|
||||||
|
async fn handle_datagram(
|
||||||
|
fm: &Arc<FederationManager>,
|
||||||
|
source_peer_fp: &str,
|
||||||
|
data: Bytes,
|
||||||
|
) {
|
||||||
|
if data.len() < 12 { return; } // 8-byte hash + min packet
|
||||||
|
|
||||||
|
let mut rh = [0u8; 8];
|
||||||
|
rh.copy_from_slice(&data[..8]);
|
||||||
|
let media_bytes = data.slice(8..);
|
||||||
|
|
||||||
|
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
|
||||||
|
Some(pkt) => pkt,
|
||||||
|
None => {
|
||||||
|
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event log: federation ingress
|
||||||
|
let peer_label = {
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
|
||||||
|
};
|
||||||
|
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
|
||||||
|
|
||||||
|
// Count inbound federation packet + update last_seen
|
||||||
|
fm.metrics.federation_packets_forwarded
|
||||||
|
.with_label_values(&[source_peer_fp, "in"]).inc();
|
||||||
|
{
|
||||||
|
let mut links = fm.peer_links.lock().await;
|
||||||
|
if let Some(link) = links.get_mut(source_peer_fp) {
|
||||||
|
link.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: drop packets we've already seen (multi-path duplicates).
|
||||||
|
// Key uses a hash of the actual payload bytes — unique per Opus frame,
|
||||||
|
// so different senders with the same seq/timestamp never collide.
|
||||||
|
let payload_hash = {
|
||||||
|
let mut h = 0u64;
|
||||||
|
for (i, &b) in media_bytes.iter().take(16).enumerate() {
|
||||||
|
h ^= (b as u64) << ((i % 8) * 8);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut dedup = fm.dedup.lock().await;
|
||||||
|
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
|
||||||
|
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find room by hash — check local rooms AND global room config
|
||||||
|
let room_name = {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
let active = mgr.active_rooms();
|
||||||
|
// First: check local rooms (has participants)
|
||||||
|
active.iter().find(|r| room_hash(r) == rh).cloned()
|
||||||
|
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
|
||||||
|
// Second: check global room config (hub relay may have no local participants)
|
||||||
|
.or_else(|| {
|
||||||
|
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let room_name = match room_name {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limit per room
|
||||||
|
if FEDERATION_RATE_LIMIT_PPS > 0 {
|
||||||
|
let mut limiters = fm.rate_limiters.lock().await;
|
||||||
|
let limiter = limiters.entry(room_name.clone())
|
||||||
|
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
|
||||||
|
if !limiter.allow() {
|
||||||
|
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to all local participants — forward the raw bytes as-is.
|
||||||
|
// The original sender's MediaPacket is preserved exactly (no re-serialization).
|
||||||
|
let locals = {
|
||||||
|
let mgr = fm.room_mgr.lock().await;
|
||||||
|
mgr.local_senders(&room_name)
|
||||||
|
};
|
||||||
|
for sender in &locals {
|
||||||
|
match sender {
|
||||||
|
room::ParticipantSender::Quic(t) => {
|
||||||
|
if let Err(e) = t.send_raw_datagram(&media_bytes) {
|
||||||
|
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
|
||||||
|
warn!("federation local delivery error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
|
||||||
|
|
||||||
|
// Multi-hop: forward to ALL other connected peers (not the source)
|
||||||
|
// Don't filter by active_rooms — the receiving peer decides whether to deliver
|
||||||
|
let links = fm.peer_links.lock().await;
|
||||||
|
for (fp, link) in links.iter() {
|
||||||
|
if fp != source_peer_fp {
|
||||||
|
let mut tagged = Vec::with_capacity(8 + media_bytes.len());
|
||||||
|
tagged.extend_from_slice(&rh);
|
||||||
|
tagged.extend_from_slice(&media_bytes);
|
||||||
|
let _ = link.transport.send_raw_datagram(&tagged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,31 +78,26 @@ pub async fn accept_handshake(
|
|||||||
};
|
};
|
||||||
transport.send_signal(&answer).await?;
|
transport.send_signal(&answer).await?;
|
||||||
|
|
||||||
// Derive caller fingerprint from their identity public key (first 8 bytes as hex)
|
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
|
||||||
let caller_fp = caller_identity_pub[..8]
|
// Must match the format used in signal registration and presence.
|
||||||
.iter()
|
let caller_fp = {
|
||||||
.map(|b| format!("{b:02x}"))
|
use sha2::{Sha256, Digest};
|
||||||
.collect::<String>();
|
let hash = Sha256::digest(&caller_identity_pub);
|
||||||
|
let fp = wzp_crypto::Fingerprint([
|
||||||
|
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
||||||
|
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
||||||
|
]);
|
||||||
|
fp.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the best quality profile from those the caller supports.
|
/// Select the best quality profile from those the caller supports.
|
||||||
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||||
// Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
|
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested
|
||||||
if supported.is_empty() {
|
// for federation reliability (large packets may exceed path MTU).
|
||||||
return QualityProfile::GOOD;
|
QualityProfile::GOOD
|
||||||
}
|
|
||||||
// Pick the profile with the highest bitrate.
|
|
||||||
supported
|
|
||||||
.iter()
|
|
||||||
.max_by(|a, b| {
|
|
||||||
a.total_bitrate_kbps()
|
|
||||||
.partial_cmp(&b.total_bitrate_kbps())
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
})
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(QualityProfile::GOOD)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
//! quality transitions.
|
//! quality transitions.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod call_registry;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod event_log;
|
||||||
|
pub mod federation;
|
||||||
|
pub mod signal_hub;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
use wzp_relay::config::RelayConfig;
|
use wzp_relay::config::RelayConfig;
|
||||||
use wzp_relay::metrics::RelayMetrics;
|
use wzp_relay::metrics::RelayMetrics;
|
||||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||||
@@ -23,12 +23,54 @@ use wzp_relay::presence::PresenceRegistry;
|
|||||||
use wzp_relay::room::{self, RoomManager};
|
use wzp_relay::room::{self, RoomManager};
|
||||||
use wzp_relay::session_mgr::SessionManager;
|
use wzp_relay::session_mgr::SessionManager;
|
||||||
|
|
||||||
fn parse_args() -> RelayConfig {
|
/// Parsed CLI result — config + identity path.
|
||||||
let mut config = RelayConfig::default();
|
struct CliResult {
|
||||||
|
config: RelayConfig,
|
||||||
|
identity_path: Option<String>,
|
||||||
|
config_file: Option<String>,
|
||||||
|
config_needs_create: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> CliResult {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
// First pass: extract --config and --identity
|
||||||
|
let mut config_file = None;
|
||||||
|
let mut identity_path = None;
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
|
"--config" | "-c" => { i += 1; config_file = args.get(i).cloned(); }
|
||||||
|
"--identity" | "-i" => { i += 1; identity_path = args.get(i).cloned(); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track if we need to create the config after identity is known
|
||||||
|
let config_needs_create = config_file.as_ref().map(|p| !std::path::Path::new(p).exists()).unwrap_or(false);
|
||||||
|
|
||||||
|
let mut config = if let Some(ref path) = config_file {
|
||||||
|
if config_needs_create {
|
||||||
|
// Will be re-created with personalized info after identity is loaded
|
||||||
|
RelayConfig::default()
|
||||||
|
} else {
|
||||||
|
wzp_relay::config::load_config(path)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("failed to load config from {path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RelayConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// CLI flags override config file values
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--config" | "-c" => { i += 1; } // already handled
|
||||||
|
"--identity" | "-i" => { i += 1; } // already handled
|
||||||
"--listen" => {
|
"--listen" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
config.listen_addr = args.get(i).expect("--listen requires an address")
|
config.listen_addr = args.get(i).expect("--listen requires an address")
|
||||||
@@ -81,6 +123,28 @@ fn parse_args() -> RelayConfig {
|
|||||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"--global-room" => {
|
||||||
|
i += 1;
|
||||||
|
config.global_rooms.push(wzp_relay::config::GlobalRoomConfig {
|
||||||
|
name: args.get(i).expect("--global-room requires a room name").to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--debug-tap" => {
|
||||||
|
i += 1;
|
||||||
|
config.debug_tap = Some(
|
||||||
|
args.get(i).expect("--debug-tap requires a room name (or '*' for all)").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--event-log" => {
|
||||||
|
i += 1;
|
||||||
|
config.event_log = Some(
|
||||||
|
args.get(i).expect("--event-log requires a file path").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--version" | "-V" => {
|
||||||
|
println!("wzp-relay {}", env!("WZP_BUILD_HASH"));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
"--mesh-status" => {
|
"--mesh-status" => {
|
||||||
// Print mesh table from a fresh registry and exit.
|
// Print mesh table from a fresh registry and exit.
|
||||||
// In practice this is useful after the relay has been running;
|
// In practice this is useful after the relay has been running;
|
||||||
@@ -90,9 +154,11 @@ fn parse_args() -> RelayConfig {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
|
eprintln!(" -c, --config <path> Load config from TOML file (creates example if missing)");
|
||||||
|
eprintln!(" -i, --identity <path> Identity file path (creates if missing, uses OsRng)");
|
||||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||||
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||||
@@ -102,6 +168,8 @@ fn parse_args() -> RelayConfig {
|
|||||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||||
|
eprintln!(" --global-room <name> Declare a room as global (bridged across federation). Repeatable.");
|
||||||
|
eprintln!(" --debug-tap <room> Log packet headers for a room ('*' for all rooms).");
|
||||||
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
||||||
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -116,7 +184,7 @@ fn parse_args() -> RelayConfig {
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
config
|
CliResult { config, identity_path, config_file, config_needs_create }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RelayStats {
|
struct RelayStats {
|
||||||
@@ -184,10 +252,29 @@ async fn run_downstream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect a non-loopback IP address from local interfaces.
|
||||||
|
/// Prefers public IPs over private (10.x, 172.16-31.x, 192.168.x).
|
||||||
|
fn detect_public_ip() -> Option<String> {
|
||||||
|
use std::net::UdpSocket;
|
||||||
|
// Connect to a public address to find our outbound IP (doesn't actually send anything)
|
||||||
|
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
||||||
|
if socket.connect("8.8.8.8:80").is_ok() {
|
||||||
|
if let Ok(addr) = socket.local_addr() {
|
||||||
|
return Some(addr.ip().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build-time git hash, set by build.rs or env.
|
||||||
|
const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let config = parse_args();
|
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args();
|
||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
|
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
||||||
rustls::crypto::ring::default_provider()
|
rustls::crypto::ring::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.expect("failed to install rustls crypto provider");
|
.expect("failed to install rustls crypto provider");
|
||||||
@@ -207,14 +294,115 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ephemeral relay identity for crypto handshake
|
// Load or generate relay identity
|
||||||
let relay_seed = wzp_crypto::Seed::generate();
|
let relay_seed = {
|
||||||
|
let id_path = match identity_path {
|
||||||
|
Some(ref p) => std::path::PathBuf::from(p),
|
||||||
|
None => dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".wzp")
|
||||||
|
.join("relay-identity"),
|
||||||
|
};
|
||||||
|
if id_path.exists() {
|
||||||
|
if let Ok(hex) = std::fs::read_to_string(&id_path) {
|
||||||
|
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||||
|
info!("loaded relay identity from {}", id_path.display());
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
warn!("corrupt identity file {}, generating new", id_path.display());
|
||||||
|
let s = wzp_crypto::Seed::generate();
|
||||||
|
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let _ = std::fs::write(&id_path, &hex);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let s = wzp_crypto::Seed::generate();
|
||||||
|
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let _ = std::fs::write(&id_path, &hex);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let s = wzp_crypto::Seed::generate();
|
||||||
|
if let Some(parent) = id_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let _ = std::fs::write(&id_path, &hex);
|
||||||
|
info!("generated relay identity at {}", id_path.display());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||||
|
|
||||||
let (server_config, _cert) = wzp_transport::server_config();
|
let (server_config, cert_der) = wzp_transport::server_config_from_seed(&relay_seed.0);
|
||||||
|
let tls_fp = wzp_transport::tls_fingerprint(&cert_der);
|
||||||
|
info!(tls_fingerprint = %tls_fp, "TLS certificate (deterministic from relay identity)");
|
||||||
|
|
||||||
|
// Create personalized config file if it was missing
|
||||||
|
let public_ip = detect_public_ip();
|
||||||
|
if config_needs_create {
|
||||||
|
if let Some(ref path) = config_file {
|
||||||
|
let info = wzp_relay::config::RelayInfo {
|
||||||
|
listen_addr: config.listen_addr.to_string(),
|
||||||
|
tls_fingerprint: tls_fp.clone(),
|
||||||
|
public_ip: public_ip.clone(),
|
||||||
|
};
|
||||||
|
if let Err(e) = wzp_relay::config::load_or_create_config(path, Some(&info)) {
|
||||||
|
warn!("failed to create config: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print federation hint with our public IP + listen port + TLS fingerprint
|
||||||
|
let listen_port = config.listen_addr.port();
|
||||||
|
if let Some(ip) = &public_ip {
|
||||||
|
info!("federation: to peer with this relay, add to relay.toml:");
|
||||||
|
info!(" [[peers]]");
|
||||||
|
info!(" url = \"{ip}:{listen_port}\"");
|
||||||
|
info!(" fingerprint = \"{tls_fp}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log configured peers and trusted relays
|
||||||
|
if !config.peers.is_empty() {
|
||||||
|
info!(count = config.peers.len(), "federation peers configured");
|
||||||
|
for p in &config.peers {
|
||||||
|
info!(url = %p.url, label = ?p.label, " peer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !config.trusted.is_empty() {
|
||||||
|
info!(count = config.trusted.len(), "trusted relays configured");
|
||||||
|
for t in &config.trusted {
|
||||||
|
info!(fingerprint = %t.fingerprint, label = ?t.label, " trusted");
|
||||||
|
}
|
||||||
|
}
|
||||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||||
|
|
||||||
|
// Compute the IP address we should advertise in CallSetup for direct
|
||||||
|
// calls. If the relay is bound to a specific IP, use it as-is; if bound
|
||||||
|
// to 0.0.0.0, use the trick of "connect" a UDP socket to an arbitrary
|
||||||
|
// external address and read its local_addr — the OS binds to whichever
|
||||||
|
// local interface IP would route packets to that destination, which is
|
||||||
|
// the primary outbound interface. This is the same IP clients on the
|
||||||
|
// LAN use to reach us.
|
||||||
|
let advertised_ip: std::net::IpAddr = {
|
||||||
|
let listen_ip = config.listen_addr.ip();
|
||||||
|
if !listen_ip.is_unspecified() {
|
||||||
|
listen_ip
|
||||||
|
} else {
|
||||||
|
// Probe via a dummy "connected" UDP socket. Never actually sends.
|
||||||
|
match std::net::UdpSocket::bind("0.0.0.0:0")
|
||||||
|
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) })
|
||||||
|
.and_then(|s| s.local_addr())
|
||||||
|
{
|
||||||
|
Ok(a) if !a.ip().is_loopback() => a.ip(),
|
||||||
|
_ => std::net::IpAddr::from([127u8, 0, 0, 1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let advertised_addr_str = format!("{}:{}", advertised_ip, config.listen_addr.port());
|
||||||
|
info!(%advertised_addr_str, "relay advertised address for CallSetup");
|
||||||
|
|
||||||
// Forward mode
|
// Forward mode
|
||||||
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
||||||
if let Some(remote_addr) = config.remote_relay {
|
if let Some(remote_addr) = config.remote_relay {
|
||||||
@@ -230,9 +418,41 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Room manager (room mode only)
|
// Room manager (room mode only)
|
||||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||||
|
|
||||||
|
// Event log for protocol analysis
|
||||||
|
let event_log = wzp_relay::event_log::start_event_log(
|
||||||
|
config.event_log.as_ref().map(std::path::PathBuf::from)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Federation manager
|
||||||
|
let global_room_set: std::collections::HashSet<String> = config.global_rooms.iter()
|
||||||
|
.map(|g| g.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
|
||||||
|
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
|
||||||
|
config.peers.clone(),
|
||||||
|
config.trusted.clone(),
|
||||||
|
global_room_set.clone(),
|
||||||
|
room_mgr.clone(),
|
||||||
|
endpoint.clone(),
|
||||||
|
tls_fp.clone(),
|
||||||
|
metrics.clone(),
|
||||||
|
event_log.clone(),
|
||||||
|
));
|
||||||
|
let fm_run = fm.clone();
|
||||||
|
tokio::spawn(async move { fm_run.run().await });
|
||||||
|
Some(fm)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Session manager — enforces max concurrent sessions
|
// Session manager — enforces max concurrent sessions
|
||||||
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
||||||
|
|
||||||
|
// Signal hub + call registry for direct 1:1 calls
|
||||||
|
let signal_hub = Arc::new(Mutex::new(wzp_relay::signal_hub::SignalHub::new()));
|
||||||
|
let call_registry = Arc::new(Mutex::new(wzp_relay::call_registry::CallRegistry::new()));
|
||||||
|
|
||||||
// Spawn inter-relay health probes via ProbeMesh coordinator
|
// Spawn inter-relay health probes via ProbeMesh coordinator
|
||||||
if !config.probe_targets.is_empty() {
|
if !config.probe_targets.is_empty() {
|
||||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||||
@@ -267,13 +487,32 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
||||||
}
|
}
|
||||||
|
if !config.global_rooms.is_empty() {
|
||||||
|
info!(count = config.global_rooms.len(), "global rooms configured");
|
||||||
|
for g in &config.global_rooms {
|
||||||
|
info!(name = %g.name, " global room");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref tap) = config.debug_tap {
|
||||||
|
info!(filter = %tap, "debug tap enabled — logging packet headers");
|
||||||
|
}
|
||||||
|
|
||||||
info!("Listening for connections...");
|
info!("Listening for connections...");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let connection = match wzp_transport::accept(&endpoint).await {
|
// Pull the next Incoming off the queue. Deliberately do NOT await
|
||||||
Ok(conn) => conn,
|
// the QUIC handshake here — move that into the per-connection
|
||||||
Err(e) => { error!("accept: {e}"); continue; }
|
// spawned task below. Previously we used wzp_transport::accept
|
||||||
|
// which did both, which meant a single slow handshake would block
|
||||||
|
// the entire accept loop and prevent ALL subsequent connections
|
||||||
|
// from being processed. Surfaced as direct-call hangs where the
|
||||||
|
// callee's call-* connection never completes its QUIC handshake.
|
||||||
|
let incoming = match endpoint.accept().await {
|
||||||
|
Some(inc) => inc,
|
||||||
|
None => {
|
||||||
|
error!("endpoint.accept() returned None — endpoint closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let remote_transport = remote_transport.clone();
|
let remote_transport = remote_transport.clone();
|
||||||
@@ -283,10 +522,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let relay_seed_bytes = relay_seed.0;
|
let relay_seed_bytes = relay_seed.0;
|
||||||
let metrics = metrics.clone();
|
let metrics = metrics.clone();
|
||||||
let trunking_enabled = config.trunking_enabled;
|
let trunking_enabled = config.trunking_enabled;
|
||||||
|
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap { room_filter: filter.clone() });
|
||||||
let presence = presence.clone();
|
let presence = presence.clone();
|
||||||
let route_resolver = route_resolver.clone();
|
let route_resolver = route_resolver.clone();
|
||||||
|
let federation_mgr = federation_mgr.clone();
|
||||||
|
let signal_hub = signal_hub.clone();
|
||||||
|
let call_registry = call_registry.clone();
|
||||||
|
let advertised_addr_str = advertised_addr_str.clone();
|
||||||
|
|
||||||
|
let incoming_addr = incoming.remote_address();
|
||||||
|
info!(%incoming_addr, "accept queue: new Incoming, spawning handshake task");
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Drive the QUIC handshake inside the spawned task so that
|
||||||
|
// slow or hung handshakes never block the outer accept loop.
|
||||||
|
let connection = match incoming.await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!(%incoming_addr, "QUIC handshake failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(%incoming_addr, "QUIC handshake complete");
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
|
|
||||||
let room_name = connection
|
let room_name = connection
|
||||||
@@ -299,6 +556,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
|
// Ping connections: client just measures QUIC connect RTT.
|
||||||
|
if room_name == "ping" {
|
||||||
|
info!(%addr, "ping connection (RTT probe)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version query: respond with build hash over a uni stream.
|
||||||
|
if room_name == "version" {
|
||||||
|
if let Ok(mut send) = transport.connection().open_uni().await {
|
||||||
|
let _ = send.write_all(BUILD_GIT_HASH.as_bytes()).await;
|
||||||
|
let _ = send.finish();
|
||||||
|
// Wait for client to read before closing
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Probe connections use SNI "_probe" to identify themselves.
|
// Probe connections use SNI "_probe" to identify themselves.
|
||||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||||
if room_name == "_probe" {
|
if room_name == "_probe" {
|
||||||
@@ -385,6 +659,290 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Federation connections use SNI "_federation"
|
||||||
|
if room_name == "_federation" {
|
||||||
|
if let Some(ref fm) = federation_mgr {
|
||||||
|
// Wait for FederationHello to identify the connecting relay
|
||||||
|
let hello_fp = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
transport.recv_signal(),
|
||||||
|
).await {
|
||||||
|
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint,
|
||||||
|
_ => {
|
||||||
|
warn!(%addr, "federation: no hello received, closing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(label) = fm.check_inbound_trust(addr, &hello_fp) {
|
||||||
|
let peer_config = wzp_relay::config::PeerConfig {
|
||||||
|
url: addr.to_string(),
|
||||||
|
fingerprint: hello_fp,
|
||||||
|
label: Some(label.clone()),
|
||||||
|
};
|
||||||
|
let fm = fm.clone();
|
||||||
|
info!(%addr, label = %label, "inbound federation accepted (trusted)");
|
||||||
|
fm.handle_inbound(transport, peer_config).await;
|
||||||
|
} else {
|
||||||
|
warn!(%addr, fp = %hello_fp, "unknown relay wants to federate");
|
||||||
|
info!(" to accept, add to relay.toml:");
|
||||||
|
info!(" [[trusted]]");
|
||||||
|
info!(" fingerprint = \"{hello_fp}\"");
|
||||||
|
info!(" label = \"Relay at {addr}\"");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!(%addr, "federation connection rejected (no federation configured)");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct calling: persistent signaling connection
|
||||||
|
if room_name == "_signal" {
|
||||||
|
info!(%addr, "signal connection");
|
||||||
|
|
||||||
|
// Optional auth
|
||||||
|
let auth_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::AuthToken { token })) => {
|
||||||
|
match wzp_relay::auth::validate_token(url, &token).await {
|
||||||
|
Ok(client) => Some(client.fingerprint),
|
||||||
|
Err(e) => {
|
||||||
|
error!(%addr, "signal auth failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => { warn!(%addr, "signal: expected AuthToken"); return; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for RegisterPresence
|
||||||
|
let (client_fp, client_alias) = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
transport.recv_signal(),
|
||||||
|
).await {
|
||||||
|
Ok(Ok(Some(SignalMessage::RegisterPresence { identity_pub, signature: _, alias }))) => {
|
||||||
|
// Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type
|
||||||
|
let fp = {
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
let hash = Sha256::digest(&identity_pub);
|
||||||
|
let fingerprint = wzp_crypto::Fingerprint([
|
||||||
|
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
||||||
|
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
||||||
|
]);
|
||||||
|
fingerprint.to_string()
|
||||||
|
};
|
||||||
|
let fp = auth_fp.unwrap_or(fp);
|
||||||
|
(fp, alias)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!(%addr, "signal: no RegisterPresence received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register in signal hub + presence
|
||||||
|
{
|
||||||
|
let mut hub = signal_hub.lock().await;
|
||||||
|
hub.register(client_fp.clone(), transport.clone(), client_alias.clone());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut reg = presence.lock().await;
|
||||||
|
reg.register_local(&client_fp, client_alias.clone(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ack
|
||||||
|
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
|
||||||
|
|
||||||
|
// Signal recv loop
|
||||||
|
loop {
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(msg)) => {
|
||||||
|
match msg {
|
||||||
|
SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, ref caller_alias, .. } => {
|
||||||
|
let target_fp = target_fingerprint.clone();
|
||||||
|
let call_id = call_id.clone();
|
||||||
|
|
||||||
|
// Check if target is online
|
||||||
|
let online = {
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
hub.is_online(&target_fp)
|
||||||
|
};
|
||||||
|
if !online {
|
||||||
|
info!(%addr, target = %target_fp, "call target not online");
|
||||||
|
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create call in registry
|
||||||
|
{
|
||||||
|
let mut reg = call_registry.lock().await;
|
||||||
|
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward offer to callee
|
||||||
|
info!(caller = %client_fp, callee = %target_fp, call_id = %call_id, "routing direct call offer");
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
if let Err(e) = hub.send_to(&target_fp, &msg).await {
|
||||||
|
warn!("failed to forward call offer: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ringing to caller
|
||||||
|
drop(hub);
|
||||||
|
let _ = transport.send_signal(&SignalMessage::CallRinging {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalMessage::DirectCallAnswer { ref call_id, ref accept_mode, .. } => {
|
||||||
|
let call_id = call_id.clone();
|
||||||
|
let mode = *accept_mode;
|
||||||
|
|
||||||
|
let peer_fp = {
|
||||||
|
let reg = call_registry.lock().await;
|
||||||
|
reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(peer_fp) = peer_fp else {
|
||||||
|
warn!(call_id = %call_id, "answer for unknown call");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if mode == wzp_proto::CallAcceptMode::Reject {
|
||||||
|
info!(call_id = %call_id, "call rejected");
|
||||||
|
let mut reg = call_registry.lock().await;
|
||||||
|
reg.end_call(&call_id);
|
||||||
|
drop(reg);
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
} else {
|
||||||
|
// Accept — create private room
|
||||||
|
let room = format!("call-{call_id}");
|
||||||
|
{
|
||||||
|
let mut reg = call_registry.lock().await;
|
||||||
|
reg.set_active(&call_id, mode, room.clone());
|
||||||
|
}
|
||||||
|
info!(call_id = %call_id, room = %room, mode = ?mode, "call accepted, creating room");
|
||||||
|
|
||||||
|
// Forward answer to caller
|
||||||
|
{
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
let _ = hub.send_to(&peer_fp, &msg).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send CallSetup to both parties.
|
||||||
|
//
|
||||||
|
// BUG FIX: the previous version of this used `addr.ip()`
|
||||||
|
// which is `connection.remote_address()` — the CLIENT'S
|
||||||
|
// IP, not the relay's. So CallSetup told both parties to
|
||||||
|
// dial the answerer's own IP, which meant the caller was
|
||||||
|
// sending QUIC Initials into the callee's client (no
|
||||||
|
// server listening there) and the callee was sending to
|
||||||
|
// itself. In both cases endpoint.connect() hung forever.
|
||||||
|
//
|
||||||
|
// Use the relay's precomputed advertised address instead.
|
||||||
|
let relay_addr_for_setup = advertised_addr_str.clone();
|
||||||
|
let setup = SignalMessage::CallSetup {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
room: room.clone(),
|
||||||
|
relay_addr: relay_addr_for_setup,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
let _ = hub.send_to(&peer_fp, &setup).await;
|
||||||
|
let _ = hub.send_to(&client_fp, &setup).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalMessage::Hangup { .. } => {
|
||||||
|
// Forward hangup to all active calls for this user
|
||||||
|
let calls = {
|
||||||
|
let reg = call_registry.lock().await;
|
||||||
|
reg.calls_for_fingerprint(&client_fp)
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||||
|
c.callee_fingerprint.clone()
|
||||||
|
} else {
|
||||||
|
c.caller_fingerprint.clone()
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for (call_id, peer_fp) in &calls {
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
let _ = hub.send_to(peer_fp, &msg).await;
|
||||||
|
drop(hub);
|
||||||
|
let mut reg = call_registry.lock().await;
|
||||||
|
reg.end_call(call_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalMessage::Ping { timestamp_ms } => {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
other => {
|
||||||
|
warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info!(%addr, "signal connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%addr, "signal recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: unregister + end active calls
|
||||||
|
let active_calls = {
|
||||||
|
let reg = call_registry.lock().await;
|
||||||
|
reg.calls_for_fingerprint(&client_fp)
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||||
|
c.callee_fingerprint.clone()
|
||||||
|
} else {
|
||||||
|
c.caller_fingerprint.clone()
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for (call_id, peer_fp) in &active_calls {
|
||||||
|
let hub = signal_hub.lock().await;
|
||||||
|
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
drop(hub);
|
||||||
|
let mut reg = call_registry.lock().await;
|
||||||
|
reg.end_call(call_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut hub = signal_hub.lock().await;
|
||||||
|
hub.unregister(&client_fp);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut reg = presence.lock().await;
|
||||||
|
reg.unregister_local(&client_fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth check: if --auth-url is set, expect first signal message to be a token
|
// Auth check: if --auth-url is set, expect first signal message to be a token
|
||||||
// Auth: if --auth-url is set, expect AuthToken as first signal
|
// Auth: if --auth-url is set, expect AuthToken as first signal
|
||||||
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||||
@@ -451,6 +1009,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Use the caller's identity fingerprint from the handshake
|
// Use the caller's identity fingerprint from the handshake
|
||||||
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
||||||
|
|
||||||
|
// ACL: call rooms (call-*) are restricted to the two authorized participants.
|
||||||
|
// Only the relay's call orchestrator creates these rooms — random clients can't join.
|
||||||
|
if room_name.starts_with("call-") {
|
||||||
|
let call_id = &room_name[5..]; // strip "call-" prefix
|
||||||
|
let authorized = {
|
||||||
|
let reg = call_registry.lock().await;
|
||||||
|
match reg.get(call_id) {
|
||||||
|
Some(call) => {
|
||||||
|
call.caller_fingerprint == participant_fp
|
||||||
|
|| call.callee_fingerprint == participant_fp
|
||||||
|
}
|
||||||
|
None => false, // unknown call — reject
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !authorized {
|
||||||
|
warn!(%addr, room = %room_name, fp = %participant_fp, "rejected: not authorized for this call room");
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!(%addr, room = %room_name, fp = %participant_fp, "authorized for call room");
|
||||||
|
}
|
||||||
|
|
||||||
// Register in presence registry
|
// Register in presence registry
|
||||||
{
|
{
|
||||||
let mut reg = presence.lock().await;
|
let mut reg = presence.lock().await;
|
||||||
@@ -503,6 +1083,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
metrics.active_sessions.inc();
|
metrics.active_sessions.inc();
|
||||||
|
|
||||||
|
// Call rooms: enforce 2-participant limit
|
||||||
|
if room_name.starts_with("call-") {
|
||||||
|
let mgr = room_mgr.lock().await;
|
||||||
|
if mgr.room_size(&room_name) >= 2 {
|
||||||
|
drop(mgr);
|
||||||
|
warn!(%addr, room = %room_name, "call room full (max 2 participants)");
|
||||||
|
metrics.active_sessions.dec();
|
||||||
|
let mut smgr = session_mgr.lock().await;
|
||||||
|
smgr.remove_session(session_id);
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let participant_id = {
|
let participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
match mgr.join(
|
match mgr.join(
|
||||||
@@ -515,7 +1109,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok((id, update, senders)) => {
|
Ok((id, update, senders)) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
drop(mgr); // release lock before async broadcast
|
drop(mgr); // release lock before async broadcast
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
|
// Merge federated participants into RoomUpdate if this is a global room
|
||||||
|
let merged_update = if let Some(ref fm) = federation_mgr {
|
||||||
|
if fm.is_global_room(&room_name) {
|
||||||
|
if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts } = update {
|
||||||
|
let remote = fm.get_remote_participants(&room_name).await;
|
||||||
|
local_parts.extend(remote);
|
||||||
|
// Deduplicate by fingerprint
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
local_parts.retain(|p| seen.insert(p.fingerprint.clone()));
|
||||||
|
SignalMessage::RoomUpdate {
|
||||||
|
count: local_parts.len() as u32,
|
||||||
|
participants: local_parts,
|
||||||
|
}
|
||||||
|
} else { update }
|
||||||
|
} else { update }
|
||||||
|
} else { update };
|
||||||
|
|
||||||
|
room::broadcast_signal(&senders, &merged_update).await;
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -533,6 +1145,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect();
|
.collect();
|
||||||
|
// Set up federation media channel if this is a global room
|
||||||
|
let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr {
|
||||||
|
let is_global = fm.is_global_room(&room_name);
|
||||||
|
if is_global {
|
||||||
|
let canonical_hash = fm.global_room_hash(&room_name);
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let fm_clone = fm.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
wzp_relay::federation::run_federation_media_egress(fm_clone, rx).await;
|
||||||
|
});
|
||||||
|
info!(room = %room_name, canonical = ?fm.resolve_global_room(&room_name), "federation egress created (global room)");
|
||||||
|
(Some(tx), Some(canonical_hash))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
room::run_participant(
|
room::run_participant(
|
||||||
room_mgr.clone(),
|
room_mgr.clone(),
|
||||||
room_name,
|
room_name,
|
||||||
@@ -541,6 +1172,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
metrics.clone(),
|
metrics.clone(),
|
||||||
&session_id_str,
|
&session_id_str,
|
||||||
trunking_enabled,
|
trunking_enabled,
|
||||||
|
debug_tap,
|
||||||
|
federation_tx,
|
||||||
|
federation_room_hash,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
// Participant disconnected — clean up presence + per-session metrics
|
// Participant disconnected — clean up presence + per-session metrics
|
||||||
@@ -563,4 +1197,5 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ pub struct RelayMetrics {
|
|||||||
pub bytes_forwarded: IntCounter,
|
pub bytes_forwarded: IntCounter,
|
||||||
pub auth_attempts: IntCounterVec,
|
pub auth_attempts: IntCounterVec,
|
||||||
pub handshake_duration: Histogram,
|
pub handshake_duration: Histogram,
|
||||||
|
// Federation metrics
|
||||||
|
pub federation_peer_status: IntGaugeVec,
|
||||||
|
pub federation_peer_rtt_ms: GaugeVec,
|
||||||
|
pub federation_packets_forwarded: IntCounterVec,
|
||||||
|
pub federation_packets_deduped: IntCounter,
|
||||||
|
pub federation_packets_rate_limited: IntCounter,
|
||||||
|
pub federation_active_rooms: IntGauge,
|
||||||
// Per-session metrics
|
// Per-session metrics
|
||||||
pub session_buffer_depth: IntGaugeVec,
|
pub session_buffer_depth: IntGaugeVec,
|
||||||
pub session_loss_pct: GaugeVec,
|
pub session_loss_pct: GaugeVec,
|
||||||
@@ -60,6 +67,28 @@ impl RelayMetrics {
|
|||||||
)
|
)
|
||||||
.expect("metric");
|
.expect("metric");
|
||||||
|
|
||||||
|
let federation_peer_status = IntGaugeVec::new(
|
||||||
|
Opts::new("wzp_federation_peer_status", "Peer connection status (0=disconnected, 1=connected)"),
|
||||||
|
&["peer"],
|
||||||
|
).expect("metric");
|
||||||
|
let federation_peer_rtt_ms = GaugeVec::new(
|
||||||
|
Opts::new("wzp_federation_peer_rtt_ms", "QUIC RTT to federated peer in milliseconds"),
|
||||||
|
&["peer"],
|
||||||
|
).expect("metric");
|
||||||
|
let federation_packets_forwarded = IntCounterVec::new(
|
||||||
|
Opts::new("wzp_federation_packets_forwarded_total", "Packets forwarded to/from federated peers"),
|
||||||
|
&["peer", "direction"],
|
||||||
|
).expect("metric");
|
||||||
|
let federation_packets_deduped = IntCounter::with_opts(
|
||||||
|
Opts::new("wzp_federation_packets_deduped_total", "Duplicate federation packets dropped"),
|
||||||
|
).expect("metric");
|
||||||
|
let federation_packets_rate_limited = IntCounter::with_opts(
|
||||||
|
Opts::new("wzp_federation_packets_rate_limited_total", "Federation packets dropped by rate limiter"),
|
||||||
|
).expect("metric");
|
||||||
|
let federation_active_rooms = IntGauge::with_opts(
|
||||||
|
Opts::new("wzp_federation_active_rooms", "Number of federated rooms currently active"),
|
||||||
|
).expect("metric");
|
||||||
|
|
||||||
let session_buffer_depth = IntGaugeVec::new(
|
let session_buffer_depth = IntGaugeVec::new(
|
||||||
Opts::new(
|
Opts::new(
|
||||||
"wzp_relay_session_jitter_buffer_depth",
|
"wzp_relay_session_jitter_buffer_depth",
|
||||||
@@ -107,6 +136,12 @@ impl RelayMetrics {
|
|||||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||||
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
||||||
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_peer_status.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_packets_deduped.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register");
|
||||||
|
registry.register(Box::new(federation_active_rooms.clone())).expect("register");
|
||||||
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
||||||
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
||||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||||
@@ -120,6 +155,12 @@ impl RelayMetrics {
|
|||||||
bytes_forwarded,
|
bytes_forwarded,
|
||||||
auth_attempts,
|
auth_attempts,
|
||||||
handshake_duration,
|
handshake_duration,
|
||||||
|
federation_peer_status,
|
||||||
|
federation_peer_rtt_ms,
|
||||||
|
federation_packets_forwarded,
|
||||||
|
federation_packets_deduped,
|
||||||
|
federation_packets_rate_limited,
|
||||||
|
federation_active_rooms,
|
||||||
session_buffer_depth,
|
session_buffer_depth,
|
||||||
session_loss_pct,
|
session_loss_pct,
|
||||||
session_rtt_ms,
|
session_rtt_ms,
|
||||||
|
|||||||
@@ -18,6 +18,38 @@ use wzp_proto::MediaTransport;
|
|||||||
use crate::metrics::RelayMetrics;
|
use crate::metrics::RelayMetrics;
|
||||||
use crate::trunk::TrunkBatcher;
|
use crate::trunk::TrunkBatcher;
|
||||||
|
|
||||||
|
/// Debug tap: logs packet metadata for matching rooms.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DebugTap {
|
||||||
|
/// Room name filter ("*" = all rooms, or specific room name/hash).
|
||||||
|
pub room_filter: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DebugTap {
|
||||||
|
pub fn matches(&self, room_name: &str) -> bool {
|
||||||
|
self.room_filter == "*" || self.room_filter == room_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_packet(&self, room: &str, dir: &str, addr: &std::net::SocketAddr, pkt: &wzp_proto::MediaPacket, fan_out: usize) {
|
||||||
|
let h = &pkt.header;
|
||||||
|
info!(
|
||||||
|
target: "debug_tap",
|
||||||
|
room = %room,
|
||||||
|
dir = dir,
|
||||||
|
addr = %addr,
|
||||||
|
seq = h.seq,
|
||||||
|
codec = ?h.codec_id,
|
||||||
|
ts = h.timestamp,
|
||||||
|
fec_block = h.fec_block,
|
||||||
|
fec_sym = h.fec_symbol,
|
||||||
|
repair = h.is_repair,
|
||||||
|
len = pkt.payload.len(),
|
||||||
|
fan_out,
|
||||||
|
"TAP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unique participant ID within a room.
|
/// Unique participant ID within a room.
|
||||||
pub type ParticipantId = u64;
|
pub type ParticipantId = u64;
|
||||||
|
|
||||||
@@ -27,6 +59,22 @@ fn next_id() -> ParticipantId {
|
|||||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Events emitted by RoomManager for federation to observe.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum RoomEvent {
|
||||||
|
/// First local participant joined this room.
|
||||||
|
LocalJoin { room: String },
|
||||||
|
/// Last local participant left this room.
|
||||||
|
LocalLeave { room: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbound federation media from a local participant.
|
||||||
|
pub struct FederationMediaOut {
|
||||||
|
pub room_name: String,
|
||||||
|
pub room_hash: [u8; 8],
|
||||||
|
pub data: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ParticipantSender {
|
pub enum ParticipantSender {
|
||||||
@@ -132,6 +180,7 @@ impl Room {
|
|||||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||||
alias: p.alias.clone(),
|
alias: p.alias.clone(),
|
||||||
|
relay_label: None, // local participant
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -157,24 +206,35 @@ pub struct RoomManager {
|
|||||||
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
||||||
/// fingerprints can join the corresponding room.
|
/// fingerprints can join the corresponding room.
|
||||||
acl: Option<HashMap<String, HashSet<String>>>,
|
acl: Option<HashMap<String, HashSet<String>>>,
|
||||||
|
/// Channel for room lifecycle events (federation subscribes).
|
||||||
|
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomManager {
|
impl RoomManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: None,
|
acl: None,
|
||||||
|
event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a room manager with ACL enforcement enabled.
|
/// Create a room manager with ACL enforcement enabled.
|
||||||
pub fn with_acl() -> Self {
|
pub fn with_acl() -> Self {
|
||||||
|
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: Some(HashMap::new()),
|
acl: Some(HashMap::new()),
|
||||||
|
event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to room lifecycle events (for federation).
|
||||||
|
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<RoomEvent> {
|
||||||
|
self.event_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Grant a fingerprint access to a room.
|
/// Grant a fingerprint access to a room.
|
||||||
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
||||||
if let Some(ref mut acl) = self.acl {
|
if let Some(ref mut acl) = self.acl {
|
||||||
@@ -213,8 +273,13 @@ impl RoomManager {
|
|||||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||||
return Err("not authorized for this room".to_string());
|
return Err("not authorized for this room".to_string());
|
||||||
}
|
}
|
||||||
|
let was_empty = !self.rooms.contains_key(room_name)
|
||||||
|
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty());
|
||||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||||
|
if was_empty {
|
||||||
|
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
|
||||||
|
}
|
||||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||||
count: room.len() as u32,
|
count: room.len() as u32,
|
||||||
participants: room.participant_list(),
|
participants: room.participant_list(),
|
||||||
@@ -235,12 +300,34 @@ impl RoomManager {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get list of active room names.
|
||||||
|
pub fn active_rooms(&self) -> Vec<String> {
|
||||||
|
self.rooms.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get participant list for a room (fingerprint + alias).
|
||||||
|
pub fn local_participant_list(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||||
|
self.rooms.get(room_name)
|
||||||
|
.map(|room| room.participant_list())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all senders for participants in a room (for federation inbound media delivery).
|
||||||
|
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
|
||||||
|
self.rooms.get(room_name)
|
||||||
|
.map(|room| room.participants.iter()
|
||||||
|
.map(|p| p.sender.clone())
|
||||||
|
.collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||||
room.remove(participant_id);
|
room.remove(participant_id);
|
||||||
if room.is_empty() {
|
if room.is_empty() {
|
||||||
self.rooms.remove(room_name);
|
self.rooms.remove(room_name);
|
||||||
|
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
||||||
info!(room = room_name, "room closed (empty)");
|
info!(room = room_name, "room closed (empty)");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -350,6 +437,9 @@ pub async fn run_participant(
|
|||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
trunking_enabled: bool,
|
trunking_enabled: bool,
|
||||||
|
debug_tap: Option<DebugTap>,
|
||||||
|
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||||
|
federation_room_hash: Option<[u8; 8]>,
|
||||||
) {
|
) {
|
||||||
if trunking_enabled {
|
if trunking_enabled {
|
||||||
run_participant_trunked(
|
run_participant_trunked(
|
||||||
@@ -358,7 +448,7 @@ pub async fn run_participant(
|
|||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
run_participant_plain(
|
run_participant_plain(
|
||||||
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -372,6 +462,9 @@ async fn run_participant_plain(
|
|||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
debug_tap: Option<DebugTap>,
|
||||||
|
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||||
|
federation_room_hash: Option<[u8; 8]>,
|
||||||
) {
|
) {
|
||||||
let addr = transport.connection().remote_address();
|
let addr = transport.connection().remote_address();
|
||||||
let mut packets_forwarded = 0u64;
|
let mut packets_forwarded = 0u64;
|
||||||
@@ -445,6 +538,13 @@ async fn run_participant_plain(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug tap: log packet metadata
|
||||||
|
if let Some(ref tap) = debug_tap {
|
||||||
|
if tap.matches(&room_name) {
|
||||||
|
tap.log_packet(&room_name, "in", &addr, &pkt, others.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forward to all others
|
// Forward to all others
|
||||||
let fwd_start = std::time::Instant::now();
|
let fwd_start = std::time::Instant::now();
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
@@ -469,6 +569,17 @@ async fn run_participant_plain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Federation: forward to active peer relays via channel
|
||||||
|
if let Some(ref fed_tx) = federation_tx {
|
||||||
|
let data = pkt.to_bytes();
|
||||||
|
let _ = fed_tx.try_send(FederationMediaOut {
|
||||||
|
room_name: room_name.clone(),
|
||||||
|
room_hash: federation_room_hash.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||||
if fwd_ms > max_forward_ms {
|
if fwd_ms > max_forward_ms {
|
||||||
max_forward_ms = fwd_ms;
|
max_forward_ms = fwd_ms;
|
||||||
|
|||||||
105
crates/wzp-relay/src/signal_hub.rs
Normal file
105
crates/wzp-relay/src/signal_hub.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! Persistent signaling connection manager.
|
||||||
|
//!
|
||||||
|
//! Tracks clients connected via `_signal` SNI. Routes call signals
|
||||||
|
//! (DirectCallOffer, DirectCallAnswer, Hangup) between registered users.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
|
use wzp_transport::QuinnTransport;
|
||||||
|
|
||||||
|
/// A client connected via `_signal` for direct calling.
|
||||||
|
pub struct SignalClient {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
pub transport: Arc<QuinnTransport>,
|
||||||
|
pub connected_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages persistent signaling connections.
|
||||||
|
pub struct SignalHub {
|
||||||
|
clients: HashMap<String, SignalClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalHub {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
clients: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new signaling client.
|
||||||
|
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
|
||||||
|
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
|
||||||
|
self.clients.insert(fp.clone(), SignalClient {
|
||||||
|
fingerprint: fp,
|
||||||
|
alias,
|
||||||
|
transport,
|
||||||
|
connected_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister a signaling client. Returns the client if found.
|
||||||
|
pub fn unregister(&mut self, fp: &str) -> Option<SignalClient> {
|
||||||
|
let client = self.clients.remove(fp);
|
||||||
|
if client.is_some() {
|
||||||
|
info!(fingerprint = %fp, "signal client unregistered");
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a client by fingerprint.
|
||||||
|
pub fn get(&self, fp: &str) -> Option<&SignalClient> {
|
||||||
|
self.clients.get(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a fingerprint is online.
|
||||||
|
pub fn is_online(&self, fp: &str) -> bool {
|
||||||
|
self.clients.contains_key(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a signal message to a client by fingerprint.
|
||||||
|
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
|
||||||
|
match self.clients.get(fp) {
|
||||||
|
Some(client) => {
|
||||||
|
client.transport.send_signal(msg).await
|
||||||
|
.map_err(|e| format!("send to {fp}: {e}"))
|
||||||
|
}
|
||||||
|
None => Err(format!("{fp} not online")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of connected signaling clients.
|
||||||
|
pub fn online_count(&self) -> usize {
|
||||||
|
self.clients.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all online fingerprints.
|
||||||
|
pub fn online_fingerprints(&self) -> Vec<&str> {
|
||||||
|
self.clients.keys().map(|s| s.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get alias for a fingerprint.
|
||||||
|
pub fn alias(&self, fp: &str) -> Option<&str> {
|
||||||
|
self.clients.get(fp).and_then(|c| c.alias.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_unregister() {
|
||||||
|
let mut hub = SignalHub::new();
|
||||||
|
assert_eq!(hub.online_count(), 0);
|
||||||
|
assert!(!hub.is_online("alice"));
|
||||||
|
|
||||||
|
// Can't easily construct QuinnTransport in a unit test,
|
||||||
|
// so we just test the HashMap logic conceptually.
|
||||||
|
// Integration tests cover the full flow.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ async-trait = { workspace = true }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
hkdf = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -6,20 +6,74 @@ use std::time::Duration;
|
|||||||
use quinn::crypto::rustls::QuicClientConfig;
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
use quinn::crypto::rustls::QuicServerConfig;
|
use quinn::crypto::rustls::QuicServerConfig;
|
||||||
|
|
||||||
/// Create a server configuration with a self-signed certificate (for testing).
|
/// Create a server configuration with a self-signed certificate (random keypair).
|
||||||
///
|
///
|
||||||
/// Tunes QUIC transport parameters for lossy VoIP:
|
/// The certificate changes on every call. Use `server_config_from_seed` for
|
||||||
/// - 30s idle timeout
|
/// a deterministic certificate that survives relay restarts.
|
||||||
/// - 5s keep-alive interval
|
|
||||||
/// - DATAGRAM extension enabled
|
|
||||||
/// - Conservative flow control for bandwidth-constrained links
|
|
||||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||||
.expect("failed to generate self-signed cert");
|
.expect("failed to generate self-signed cert");
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||||
let key_der =
|
let key_der =
|
||||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||||
|
build_server_config(cert_der, key_der)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a server configuration with a deterministic self-signed certificate
|
||||||
|
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
|
||||||
|
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
|
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
// Derive Ed25519 key bytes from seed via HKDF
|
||||||
|
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||||
|
let mut ed_bytes = [0u8; 32];
|
||||||
|
hk.expand(b"wzp-tls-ed25519", &mut ed_bytes)
|
||||||
|
.expect("HKDF expand failed");
|
||||||
|
|
||||||
|
// Create Ed25519 signing key and export as PKCS8 DER
|
||||||
|
let signing_key = SigningKey::from_bytes(&ed_bytes);
|
||||||
|
let pkcs8_doc = signing_key.to_pkcs8_der()
|
||||||
|
.expect("failed to encode Ed25519 key as PKCS8");
|
||||||
|
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
|
||||||
|
.expect("failed to wrap PKCS8 DER");
|
||||||
|
|
||||||
|
// Create rcgen KeyPair from DER
|
||||||
|
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(
|
||||||
|
&key_der_for_rcgen,
|
||||||
|
&rcgen::PKCS_ED25519,
|
||||||
|
)
|
||||||
|
.expect("failed to create KeyPair from seed-derived Ed25519 key");
|
||||||
|
|
||||||
|
// Build self-signed cert with this deterministic keypair
|
||||||
|
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
|
||||||
|
.expect("failed to create CertificateParams");
|
||||||
|
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert");
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
|
||||||
|
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
|
||||||
|
.expect("failed to serialize key DER");
|
||||||
|
|
||||||
|
build_server_config(cert_der, key_der)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a hex-formatted SHA-256 fingerprint of a DER-encoded certificate.
|
||||||
|
///
|
||||||
|
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
|
||||||
|
pub fn tls_fingerprint(cert_der: &[u8]) -> String {
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
let hash = Sha256::digest(cert_der);
|
||||||
|
hash.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_server_config(
|
||||||
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
|
) -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
let mut server_crypto = rustls::ServerConfig::builder()
|
let mut server_crypto = rustls::ServerConfig::builder()
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ pub mod path_monitor;
|
|||||||
pub mod quic;
|
pub mod quic;
|
||||||
pub mod reliable;
|
pub mod reliable;
|
||||||
|
|
||||||
pub use config::{client_config, server_config};
|
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
||||||
pub use connection::{accept, connect, create_endpoint};
|
pub use connection::{accept, connect, create_endpoint};
|
||||||
pub use path_monitor::PathMonitor;
|
pub use path_monitor::PathMonitor;
|
||||||
pub use quic::QuinnTransport;
|
pub use quic::QuinnTransport;
|
||||||
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||||
|
|
||||||
|
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can
|
||||||
|
// thread a shared endpoint between signaling and media connections without
|
||||||
|
// needing to depend on quinn directly.
|
||||||
|
pub use quinn::Endpoint;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ impl QuinnTransport {
|
|||||||
&self.connection
|
&self.connection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
||||||
|
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||||
|
self.connection
|
||||||
|
.send_datagram(bytes::Bytes::copy_from_slice(data))
|
||||||
|
.map_err(|e| TransportError::Internal(format!("datagram: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||||
/// The relay will detect the close and remove this participant from the room.
|
/// The relay will detect the close and remove this participant from the room.
|
||||||
pub fn close_now(&self) {
|
pub fn close_now(&self) {
|
||||||
@@ -136,7 +143,7 @@ impl MediaTransport for QuinnTransport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match datagram::deserialize_media(data) {
|
match datagram::deserialize_media(data.clone()) {
|
||||||
Some(packet) => {
|
Some(packet) => {
|
||||||
// Record receive observation
|
// Record receive observation
|
||||||
{
|
{
|
||||||
@@ -149,8 +156,10 @@ impl MediaTransport for QuinnTransport {
|
|||||||
Ok(Some(packet))
|
Ok(Some(packet))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("received malformed media datagram");
|
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
|
||||||
Ok(None)
|
// Don't return Ok(None) — that signals connection closed.
|
||||||
|
// Recurse to read the next datagram instead.
|
||||||
|
Box::pin(self.recv_media()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
crates/wzp-web/static/wasm/package.json
Normal file
16
crates/wzp-web/static/wasm/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "wzp-wasm",
|
||||||
|
"type": "module",
|
||||||
|
"description": "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"wzp_wasm_bg.wasm",
|
||||||
|
"wzp_wasm.js",
|
||||||
|
"wzp_wasm.d.ts"
|
||||||
|
],
|
||||||
|
"main": "wzp_wasm.js",
|
||||||
|
"types": "wzp_wasm.d.ts",
|
||||||
|
"sideEffects": [
|
||||||
|
"./snippets/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
Normal file
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
*
|
||||||
|
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
* and key setup are identical so WASM and native peers interoperate.
|
||||||
|
*/
|
||||||
|
export class WzpCryptoSession {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Decrypt a media payload with AAD.
|
||||||
|
*
|
||||||
|
* Returns plaintext on success, or throws on auth failure.
|
||||||
|
*/
|
||||||
|
decrypt(header_aad: Uint8Array, ciphertext: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
*
|
||||||
|
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
*/
|
||||||
|
encrypt(header_aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
*/
|
||||||
|
constructor(shared_secret: Uint8Array);
|
||||||
|
/**
|
||||||
|
* Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
*/
|
||||||
|
recv_seq(): number;
|
||||||
|
/**
|
||||||
|
* Current send sequence number (for diagnostics / UI stats).
|
||||||
|
*/
|
||||||
|
send_seq(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WzpFecDecoder {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Feed a received symbol.
|
||||||
|
*
|
||||||
|
* Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
* enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
*/
|
||||||
|
add_symbol(block_id: number, symbol_idx: number, _is_repair: boolean, data: Uint8Array): Uint8Array | undefined;
|
||||||
|
/**
|
||||||
|
* Create a new FEC decoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — expected number of source symbols per block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
*/
|
||||||
|
constructor(block_size: number, symbol_size: number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WzpFecEncoder {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Add a source symbol (audio frame).
|
||||||
|
*
|
||||||
|
* Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
* or `undefined` if the block is still accumulating.
|
||||||
|
*
|
||||||
|
* Each returned packet carries the 3-byte header:
|
||||||
|
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
*/
|
||||||
|
add_symbol(data: Uint8Array): Uint8Array | undefined;
|
||||||
|
/**
|
||||||
|
* Force-flush the current (possibly partial) block.
|
||||||
|
*
|
||||||
|
* Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
* symbols have been accumulated.
|
||||||
|
*/
|
||||||
|
flush(): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Create a new FEC encoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
*/
|
||||||
|
constructor(block_size: number, symbol_size: number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
*
|
||||||
|
* Usage from JS:
|
||||||
|
* ```js
|
||||||
|
* const kx = new WzpKeyExchange();
|
||||||
|
* const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
* // ... send ourPub to peer, receive peerPub ...
|
||||||
|
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
* const session = new WzpCryptoSession(secret);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WzpKeyExchange {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte session key from the peer's public key.
|
||||||
|
*
|
||||||
|
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
*/
|
||||||
|
derive_shared_secret(peer_public: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Generate a new random X25519 keypair.
|
||||||
|
*/
|
||||||
|
constructor();
|
||||||
|
/**
|
||||||
|
* Our public key (32 bytes).
|
||||||
|
*/
|
||||||
|
public_key(): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
export interface InitOutput {
|
||||||
|
readonly memory: WebAssembly.Memory;
|
||||||
|
readonly __wbg_wzpcryptosession_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpfecencoder_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
|
||||||
|
readonly wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
readonly wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
readonly wzpcryptosession_new: (a: number, b: number) => [number, number, number];
|
||||||
|
readonly wzpcryptosession_recv_seq: (a: number) => number;
|
||||||
|
readonly wzpcryptosession_send_seq: (a: number) => number;
|
||||||
|
readonly wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||||
|
readonly wzpfecdecoder_new: (a: number, b: number) => number;
|
||||||
|
readonly wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
|
||||||
|
readonly wzpfecencoder_flush: (a: number) => [number, number];
|
||||||
|
readonly wzpfecencoder_new: (a: number, b: number) => number;
|
||||||
|
readonly wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
|
||||||
|
readonly wzpkeyexchange_new: () => number;
|
||||||
|
readonly wzpkeyexchange_public_key: (a: number) => [number, number];
|
||||||
|
readonly __wbindgen_exn_store: (a: number) => void;
|
||||||
|
readonly __externref_table_alloc: () => number;
|
||||||
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
readonly __externref_table_dealloc: (a: number) => void;
|
||||||
|
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
readonly __wbindgen_start: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates the given `module`, which can either be bytes or
|
||||||
|
* a precompiled `WebAssembly.Module`.
|
||||||
|
*
|
||||||
|
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {InitOutput}
|
||||||
|
*/
|
||||||
|
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||||
|
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||||
|
*
|
||||||
|
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {Promise<InitOutput>}
|
||||||
|
*/
|
||||||
|
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||||
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
Normal file
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const memory: WebAssembly.Memory;
|
||||||
|
export const __wbg_wzpcryptosession_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpfecencoder_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
|
||||||
|
export const wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
export const wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
export const wzpcryptosession_new: (a: number, b: number) => [number, number, number];
|
||||||
|
export const wzpcryptosession_recv_seq: (a: number) => number;
|
||||||
|
export const wzpcryptosession_send_seq: (a: number) => number;
|
||||||
|
export const wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||||
|
export const wzpfecdecoder_new: (a: number, b: number) => number;
|
||||||
|
export const wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
|
||||||
|
export const wzpfecencoder_flush: (a: number) => [number, number];
|
||||||
|
export const wzpfecencoder_new: (a: number, b: number) => number;
|
||||||
|
export const wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
|
||||||
|
export const wzpkeyexchange_new: () => number;
|
||||||
|
export const wzpkeyexchange_public_key: (a: number) => [number, number];
|
||||||
|
export const __wbindgen_exn_store: (a: number) => void;
|
||||||
|
export const __externref_table_alloc: () => number;
|
||||||
|
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
export const __externref_table_dealloc: (a: number) => void;
|
||||||
|
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
export const __wbindgen_start: () => void;
|
||||||
2
desktop/.gitignore
vendored
Normal file
2
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
8
desktop/.vite/deps/_metadata.json
Normal file
8
desktop/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "9046c0bf",
|
||||||
|
"configHash": "ef0fc96f",
|
||||||
|
"lockfileHash": "d66891b1",
|
||||||
|
"browserHash": "8171ed59",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
desktop/.vite/deps/package.json
Normal file
3
desktop/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
235
desktop/index.html
Normal file
235
desktop/index.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>WarzonePhone</title>
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Connect screen -->
|
||||||
|
<div id="connect-screen">
|
||||||
|
<h1>WarzonePhone</h1>
|
||||||
|
<p class="subtitle">Encrypted Voice</p>
|
||||||
|
<div class="form">
|
||||||
|
<label>Relay
|
||||||
|
<button id="relay-selected" class="relay-selected" type="button">
|
||||||
|
<span id="relay-dot" class="dot"></span>
|
||||||
|
<span id="relay-label">Select relay...</span>
|
||||||
|
<span class="arrow">⚙</span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<label>Room
|
||||||
|
<input id="room" type="text" value="general" />
|
||||||
|
</label>
|
||||||
|
<label>Alias
|
||||||
|
<input id="alias" type="text" placeholder="your name" />
|
||||||
|
</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="os-aec" type="checkbox" checked />
|
||||||
|
OS Echo Cancel
|
||||||
|
</label>
|
||||||
|
<button id="settings-btn-home" class="icon-btn" title="Settings (Cmd+,)">⚙</button>
|
||||||
|
</div>
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="mode-toggle" style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<button id="mode-room" class="mode-btn active" style="flex:1">Room</button>
|
||||||
|
<button id="mode-direct" class="mode-btn" style="flex:1">Direct Call</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Room mode (default) -->
|
||||||
|
<div id="room-mode">
|
||||||
|
<button id="connect-btn" class="primary">Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direct call mode -->
|
||||||
|
<div id="direct-mode" class="hidden">
|
||||||
|
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
|
||||||
|
<div id="direct-registered" class="hidden" style="margin-top:12px">
|
||||||
|
<div class="direct-registered-header">
|
||||||
|
<p style="color:var(--green);font-size:13px;margin:0">✅ Registered — waiting for calls</p>
|
||||||
|
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
|
||||||
|
</div>
|
||||||
|
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
|
||||||
|
<p style="font-weight:bold;margin:0 0 4px 0">Incoming Call</p>
|
||||||
|
<p id="incoming-caller" style="font-size:12px;opacity:0.8;margin:0 0 8px 0">From: unknown</p>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button id="accept-call-btn" style="flex:1;background:var(--green);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Accept</button>
|
||||||
|
<button id="reject-call-btn" style="flex:1;background:var(--red);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent contacts -->
|
||||||
|
<div id="recent-contacts-section" class="hidden">
|
||||||
|
<div class="history-header">Recent contacts</div>
|
||||||
|
<div id="recent-contacts-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Call history -->
|
||||||
|
<div id="call-history-section" class="hidden">
|
||||||
|
<div class="history-header">
|
||||||
|
History
|
||||||
|
<button id="clear-history-btn" class="link-btn">clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="call-history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="margin-top:8px">Call by fingerprint
|
||||||
|
<input id="target-fp" type="text" placeholder="xxxx:xxxx:xxxx:..." />
|
||||||
|
</label>
|
||||||
|
<button id="call-btn" class="primary" style="margin-top:8px">Call</button>
|
||||||
|
<p id="call-status-text" style="color:var(--yellow);font-size:13px;margin-top:4px"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="connect-error" class="error"></p>
|
||||||
|
</div>
|
||||||
|
<div class="identity-info">
|
||||||
|
<span id="my-identicon"></span>
|
||||||
|
<span id="my-fingerprint" class="fp-display"></span>
|
||||||
|
</div>
|
||||||
|
<div class="recent-rooms" id="recent-rooms"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In-call screen -->
|
||||||
|
<div id="call-screen" class="hidden">
|
||||||
|
<div class="call-header">
|
||||||
|
<div class="call-header-row">
|
||||||
|
<div id="room-name" class="room-name"></div>
|
||||||
|
<button id="settings-btn-call" class="icon-btn small" title="Settings (Cmd+,)">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="call-meta">
|
||||||
|
<span id="call-status" class="status-dot"></span>
|
||||||
|
<span id="call-timer" class="call-timer">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-meter">
|
||||||
|
<div id="level-bar" class="level-bar-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="participants" class="participants"></div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
||||||
|
<span class="icon" id="mic-icon">Mic</span>
|
||||||
|
</button>
|
||||||
|
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
|
||||||
|
<span class="icon">End</span>
|
||||||
|
</button>
|
||||||
|
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
|
||||||
|
<span class="icon" id="spk-icon">Spk</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="stats" class="stats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings panel -->
|
||||||
|
<div id="settings-panel" class="hidden">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button id="settings-close" class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Connection</h3>
|
||||||
|
<label>Default Room
|
||||||
|
<input id="s-room" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>Alias
|
||||||
|
<input id="s-alias" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Audio</h3>
|
||||||
|
<div class="quality-control">
|
||||||
|
<div class="quality-header">
|
||||||
|
<span class="setting-label">QUALITY</span>
|
||||||
|
<span id="s-quality-label" class="quality-label">Auto</span>
|
||||||
|
</div>
|
||||||
|
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
|
||||||
|
<div class="quality-ticks">
|
||||||
|
<span>64k</span>
|
||||||
|
<span>48k</span>
|
||||||
|
<span>32k</span>
|
||||||
|
<span>Auto</span>
|
||||||
|
<span>24k</span>
|
||||||
|
<span>6k</span>
|
||||||
|
<span>C2</span>
|
||||||
|
<span>1.2k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="s-os-aec" type="checkbox" />
|
||||||
|
OS Echo Cancellation (macOS VoiceProcessingIO)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="s-agc" type="checkbox" checked />
|
||||||
|
Automatic Gain Control
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Identity</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Fingerprint</span>
|
||||||
|
<span id="s-fingerprint" class="fp-display-large"></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Identity file</span>
|
||||||
|
<span class="fp-display">~/.wzp/identity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Recent Rooms</h3>
|
||||||
|
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
||||||
|
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<button id="settings-save" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Relays dialog -->
|
||||||
|
<div id="relay-dialog" class="hidden">
|
||||||
|
<div class="settings-card relay-dialog-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Manage Relays</h2>
|
||||||
|
<button id="relay-dialog-close" class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="relay-dialog-list" class="relay-dialog-list"></div>
|
||||||
|
<div class="relay-add-row">
|
||||||
|
<div class="relay-add-inputs">
|
||||||
|
<input id="relay-add-name" type="text" placeholder="Name" />
|
||||||
|
<input id="relay-add-addr" type="text" placeholder="host:port" />
|
||||||
|
</div>
|
||||||
|
<button id="relay-add-btn" class="primary">Add Relay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Key changed warning dialog -->
|
||||||
|
<div id="key-warning" class="hidden">
|
||||||
|
<div class="settings-card key-warning-card">
|
||||||
|
<div class="key-warning-icon">⚠</div>
|
||||||
|
<h2>Server Key Changed</h2>
|
||||||
|
<p class="key-warning-text">The relay's identity has changed since you last connected. This usually happens when the server was restarted, but could also indicate a security issue.</p>
|
||||||
|
<div class="key-warning-fps">
|
||||||
|
<div class="key-fp-row">
|
||||||
|
<span class="key-fp-label">Previously known</span>
|
||||||
|
<code id="kw-old-fp" class="key-fp"></code>
|
||||||
|
</div>
|
||||||
|
<div class="key-fp-row">
|
||||||
|
<span class="key-fp-label">New key</span>
|
||||||
|
<code id="kw-new-fp" class="key-fp"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-warning-actions">
|
||||||
|
<button id="kw-accept" class="primary">Accept New Key</button>
|
||||||
|
<button id="kw-cancel" class="secondary-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1350
desktop/package-lock.json
generated
Normal file
1350
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
desktop/package.json
Normal file
19
desktop/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "wzp-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite": "^6",
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
107
desktop/src-tauri/Cargo.toml
Normal file
107
desktop/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "WarzonePhone Desktop — encrypted VoIP client"
|
||||||
|
default-run = "wzp-desktop"
|
||||||
|
|
||||||
|
# Library target — required for Tauri mobile (Android/iOS link the app as a cdylib)
|
||||||
|
# and also used by the desktop binary below.
|
||||||
|
#
|
||||||
|
# `staticlib` was DROPPED from crate-type because rust-lang/rust#104707
|
||||||
|
# documents that having staticlib alongside cdylib leaks non-exported
|
||||||
|
# symbols from staticlibs into the cdylib. Bionic's private `__init_tcb`
|
||||||
|
# / `pthread_create` symbols end up bound LOCALLY inside our .so instead
|
||||||
|
# of resolved dynamically against libc.so at dlopen time — which crashes
|
||||||
|
# at launch as soon as tao tries to std::thread::spawn() from the JNI
|
||||||
|
# onCreate callback. The legacy wzp-android crate uses ["cdylib", "rlib"]
|
||||||
|
# and runs fine on the same phone with the same NDK + Rust toolchain.
|
||||||
|
#
|
||||||
|
# iOS Tauri builds that actually need staticlib can re-add it behind a
|
||||||
|
# target cfg if we ever ship on iOS.
|
||||||
|
[lib]
|
||||||
|
name = "wzp_desktop_lib"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wzp-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
# cc is no longer needed — all C++ moved to crates/wzp-native (built with
|
||||||
|
# cargo-ndk and loaded via libloading at runtime). wzp-desktop's .so on
|
||||||
|
# Android is now pure Rust.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
anyhow = "1"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
|
||||||
|
# WarzonePhone crates — protocol layer is platform-independent
|
||||||
|
wzp-proto = { path = "../../crates/wzp-proto" }
|
||||||
|
wzp-codec = { path = "../../crates/wzp-codec" }
|
||||||
|
wzp-fec = { path = "../../crates/wzp-fec" }
|
||||||
|
wzp-crypto = { path = "../../crates/wzp-crypto" }
|
||||||
|
wzp-transport = { path = "../../crates/wzp-transport" }
|
||||||
|
|
||||||
|
# wzp-client pulls in CPAL on every desktop target and, additionally on
|
||||||
|
# macOS, VoiceProcessingIO (coreaudio-rs behind the "vpio" feature). The
|
||||||
|
# vpio feature MUST NOT be enabled on Windows / Linux because coreaudio-rs
|
||||||
|
# is Apple-framework-only and will fail to build. Task #24 will add a
|
||||||
|
# matching Windows Voice Capture DSP path behind its own feature; until
|
||||||
|
# then, Windows desktops use plain CPAL with AEC disabled.
|
||||||
|
|
||||||
|
# macOS: CPAL + VoiceProcessingIO (hardware AEC via Core Audio).
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
|
||||||
|
|
||||||
|
# Windows: CPAL for playback + direct WASAPI for capture with OS-level
|
||||||
|
# AEC (AudioCategory_Communications). The wzp-client `windows-aec`
|
||||||
|
# feature swaps the default CPAL AudioCapture for a WASAPI one that
|
||||||
|
# opens the mic under AudioCategory_Communications, turning on Windows's
|
||||||
|
# communications audio processing chain (AEC, NS, AGC). The reference
|
||||||
|
# signal for AEC is the system render mix, so echo from our CPAL
|
||||||
|
# playback is cancelled automatically without extra plumbing.
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "windows-aec"] }
|
||||||
|
|
||||||
|
# Linux: CPAL playback+capture baseline. AEC is enabled via the top-level
|
||||||
|
# `linux-aec` feature in wzp-desktop, which forwards to wzp-client/linux-aec.
|
||||||
|
# Keeping it opt-in at the wzp-desktop level (rather than forcing it always
|
||||||
|
# on here) lets `cargo tauri build` produce two variants from the same
|
||||||
|
# source tree — a noAEC baseline and an AEC build — by toggling the feature
|
||||||
|
# at build time: `cargo tauri build -- --features wzp-desktop/linux-aec`.
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", features = ["audio"] }
|
||||||
|
|
||||||
|
# Android: no CPAL, no vpio — audio goes through the standalone wzp-native
|
||||||
|
# cdylib that we dlopen via libloading at runtime. See the wzp_native
|
||||||
|
# module in src/.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", default-features = false }
|
||||||
|
# libloading: runtime dlopen of libwzp_native.so — the standalone cdylib
|
||||||
|
# crate that owns all C++ (Oboe bridge). Keeps wzp-desktop's .so free of
|
||||||
|
# any C/C++ static archives that would otherwise leak bionic's internal
|
||||||
|
# pthread_create into our cdylib and trigger the __init_tcb crash.
|
||||||
|
libloading = "0.8"
|
||||||
|
# jni + ndk-context: called from android_audio.rs to invoke
|
||||||
|
# AudioManager.setSpeakerphoneOn on the JVM side at runtime, so the
|
||||||
|
# Oboe playout stream (opened with Usage::VoiceCommunication) can route
|
||||||
|
# between earpiece and loud speaker without restarting.
|
||||||
|
jni = "0.21"
|
||||||
|
ndk-context = "0.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
# linux-aec: forwards to wzp-client/linux-aec so `cargo tauri build -- --features
|
||||||
|
# wzp-desktop/linux-aec` enables the WebRTC AEC3 backend on Linux. No-op on
|
||||||
|
# other targets because wzp-client/linux-aec is itself cfg(target_os = "linux").
|
||||||
|
linux-aec = ["wzp-client/linux-aec"]
|
||||||
26
desktop/src-tauri/build.rs
Normal file
26
desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Capture short git hash so the running app can prove which build it is.
|
||||||
|
// Falls back to "unknown" if git isn't available (e.g. when building from
|
||||||
|
// a tarball without a .git dir).
|
||||||
|
let git_hash = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=WZP_GIT_HASH={git_hash}");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/refs/heads");
|
||||||
|
|
||||||
|
// No cc::Build of ANY kind on Android — all C++ lives in the standalone
|
||||||
|
// `wzp-native` crate which is built separately with cargo-ndk and loaded
|
||||||
|
// via libloading at runtime. See docs/incident-tauri-android-init-tcb.md
|
||||||
|
// for why this split exists.
|
||||||
|
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
26
desktop/src-tauri/capabilities/default.json
Normal file
26
desktop/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"platforms": [
|
||||||
|
"linux",
|
||||||
|
"macOS",
|
||||||
|
"windows",
|
||||||
|
"android",
|
||||||
|
"iOS"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-emit-to",
|
||||||
|
"core:path:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:app:default",
|
||||||
|
"core:webview:default",
|
||||||
|
"shell:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||||
|
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.wzp_desktop"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.wzp.desktop
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WzpMainActivity"
|
||||||
|
private const val AUDIO_PERMISSIONS_REQUEST = 4242
|
||||||
|
private val REQUIRED_AUDIO_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
Manifest.permission.MODIFY_AUDIO_SETTINGS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Request RECORD_AUDIO early so Oboe (inside libwzp_native.so) can open
|
||||||
|
// the AAudio input stream without silently failing. The grant is
|
||||||
|
// persisted, so after the first launch the dialog no longer appears.
|
||||||
|
// MODIFY_AUDIO_SETTINGS is needed to switch AudioManager mode + speaker.
|
||||||
|
val needsRequest = REQUIRED_AUDIO_PERMISSIONS.any {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needsRequest) {
|
||||||
|
Log.i(TAG, "requesting audio permissions")
|
||||||
|
ActivityCompat.requestPermissions(this, REQUIRED_AUDIO_PERMISSIONS, AUDIO_PERMISSIONS_REQUEST)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "audio permissions already granted")
|
||||||
|
configureAudioForCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == AUDIO_PERMISSIONS_REQUEST) {
|
||||||
|
val allGranted = grantResults.isNotEmpty() &&
|
||||||
|
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||||
|
Log.i(TAG, "audio permissions result: allGranted=$allGranted grants=${grantResults.toList()}")
|
||||||
|
if (allGranted) {
|
||||||
|
configureAudioForCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the phone into VoIP call mode with handset (earpiece) as the
|
||||||
|
* default output. The Oboe playout stream is opened with
|
||||||
|
* Usage::VoiceCommunication which honours this routing, so:
|
||||||
|
*
|
||||||
|
* MODE_IN_COMMUNICATION + speakerphoneOn=false → earpiece (handset)
|
||||||
|
* MODE_IN_COMMUNICATION + speakerphoneOn=true → loudspeaker
|
||||||
|
* MODE_IN_COMMUNICATION + bluetoothScoOn=true → bluetooth headset
|
||||||
|
*
|
||||||
|
* The speaker/handset/BT toggle itself is wired up via the Tauri
|
||||||
|
* command `set_speakerphone(on)` in a follow-up build. For now the
|
||||||
|
* default is handset, matching the user's stated preference.
|
||||||
|
*
|
||||||
|
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
|
||||||
|
* slider is separate from media volume on most devices.
|
||||||
|
*/
|
||||||
|
private fun configureAudioForCall() {
|
||||||
|
try {
|
||||||
|
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
||||||
|
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
|
||||||
|
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
|
||||||
|
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
|
||||||
|
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
||||||
|
|
||||||
|
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
|
am.isSpeakerphoneOn = false // default: handset / earpiece
|
||||||
|
|
||||||
|
// Crank both voice-call and music volumes so nothing silent slips
|
||||||
|
// through regardless of which stream actually ends up driving.
|
||||||
|
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
|
||||||
|
am.setStreamVolume(AudioManager.STREAM_VOICE_CALL, maxVoice, 0)
|
||||||
|
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
|
||||||
|
|
||||||
|
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
||||||
|
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
|
||||||
|
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default"],"platforms":["linux","macOS","windows","android","iOS"]}}
|
||||||
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
desktop/src-tauri/icons/icon.ico
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
BIN
desktop/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
98
desktop/src-tauri/src/android_audio.rs
Normal file
98
desktop/src-tauri/src/android_audio.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! Runtime bridge to Android's `AudioManager` for in-call audio routing.
|
||||||
|
//!
|
||||||
|
//! We own a quinn+Oboe VoIP pipeline entirely from Rust, but routing the
|
||||||
|
//! playout stream between earpiece / loudspeaker / Bluetooth headset has to
|
||||||
|
//! happen at the JVM level because those toggles are AudioManager-only.
|
||||||
|
//! This module uses the global JavaVM handle that `ndk_context` exposes
|
||||||
|
//! (populated by Tauri's mobile runtime) + the `jni` crate to reach into
|
||||||
|
//! the Android framework without needing a Tauri plugin.
|
||||||
|
//!
|
||||||
|
//! All callers must be inside an Android target (`#[cfg(target_os = "android")]`).
|
||||||
|
|
||||||
|
#![cfg(target_os = "android")]
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
use jni::JavaVM;
|
||||||
|
|
||||||
|
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's
|
||||||
|
/// mobile runtime sets up at process startup.
|
||||||
|
fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm_ptr = ctx.vm() as *mut jni::sys::JavaVM;
|
||||||
|
if vm_ptr.is_null() {
|
||||||
|
return Err("ndk_context: JavaVM pointer is null".into());
|
||||||
|
}
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm_ptr) }
|
||||||
|
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
||||||
|
let activity_ptr = ctx.context() as jni::sys::jobject;
|
||||||
|
if activity_ptr.is_null() {
|
||||||
|
return Err("ndk_context: activity pointer is null".into());
|
||||||
|
}
|
||||||
|
// SAFETY: ndk_context guarantees the pointer lives for the process
|
||||||
|
// lifetime; we wrap it as a JObject<'static> for convenience.
|
||||||
|
let activity: JObject<'static> = unsafe { JObject::from_raw(activity_ptr) };
|
||||||
|
Ok((vm, activity))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Android's `AudioManager` via `activity.getSystemService("audio")`.
|
||||||
|
fn audio_manager<'local>(
|
||||||
|
env: &mut jni::AttachGuard<'local>,
|
||||||
|
activity: &JObject<'local>,
|
||||||
|
) -> Result<JObject<'local>, String> {
|
||||||
|
let svc_name: JString<'local> = env
|
||||||
|
.new_string("audio")
|
||||||
|
.map_err(|e| format!("new_string(audio): {e}"))?;
|
||||||
|
let am = env
|
||||||
|
.call_method(
|
||||||
|
activity,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[JValue::Object(&svc_name)],
|
||||||
|
)
|
||||||
|
.and_then(|v| v.l())
|
||||||
|
.map_err(|e| format!("getSystemService(audio): {e}"))?;
|
||||||
|
if am.is_null() {
|
||||||
|
return Err("getSystemService returned null".into());
|
||||||
|
}
|
||||||
|
Ok(am)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
|
||||||
|
///
|
||||||
|
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
|
||||||
|
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
|
||||||
|
/// sets this at startup, so by the time a call is up this is always true.
|
||||||
|
pub fn set_speakerphone(on: bool) -> Result<(), String> {
|
||||||
|
let (vm, activity) = jvm_and_activity()?;
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
let am = audio_manager(&mut env, &activity)?;
|
||||||
|
|
||||||
|
env.call_method(
|
||||||
|
&am,
|
||||||
|
"setSpeakerphoneOn",
|
||||||
|
"(Z)V",
|
||||||
|
&[JValue::Bool(if on { 1 } else { 0 })],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("setSpeakerphoneOn({on}): {e}"))?;
|
||||||
|
|
||||||
|
tracing::info!(on, "AudioManager.setSpeakerphoneOn");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the current speakerphone state. Returns true if routing is on the
|
||||||
|
/// loud speaker, false if on earpiece / BT headset / wired headset.
|
||||||
|
pub fn is_speakerphone_on() -> Result<bool, String> {
|
||||||
|
let (vm, activity) = jvm_and_activity()?;
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
let am = audio_manager(&mut env, &activity)?;
|
||||||
|
|
||||||
|
let on = env
|
||||||
|
.call_method(&am, "isSpeakerphoneOn", "()Z", &[])
|
||||||
|
.and_then(|v| v.z())
|
||||||
|
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
|
||||||
|
Ok(on)
|
||||||
|
}
|
||||||
909
desktop/src-tauri/src/engine.rs
Normal file
909
desktop/src-tauri/src/engine.rs
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
//! Call engine for the desktop app — wraps wzp-client audio + transport
|
||||||
|
//! into a clean async interface for Tauri commands.
|
||||||
|
//!
|
||||||
|
//! Step C of the incremental Android rewrite: the module now compiles on
|
||||||
|
//! Android too (previously cfg-gated out entirely in lib.rs), but the
|
||||||
|
//! actual `CallEngine::start()` body uses CPAL via `wzp_client::audio_io`
|
||||||
|
//! which is only available on desktop. On Android we expose a stub
|
||||||
|
//! `start()` that returns an error, so the frontend's `connect` command
|
||||||
|
//! still fails cleanly but the rest of the engine code links in.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
// CPAL audio I/O is only available on desktop (wzp-client's `audio` feature).
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||||
|
|
||||||
|
// Codec + handshake pipelines are platform-independent Rust (no CPAL
|
||||||
|
// dependency) so they're available from wzp-client on both desktop and
|
||||||
|
// Android (where wzp-client is pulled in with default-features=false).
|
||||||
|
use wzp_client::call::{CallConfig, CallEncoder};
|
||||||
|
|
||||||
|
use wzp_proto::{CodecId, MediaTransport, QualityProfile};
|
||||||
|
|
||||||
|
const FRAME_SAMPLES_40MS: usize = 1920;
|
||||||
|
|
||||||
|
/// Resolve a quality string from the UI to a QualityProfile.
|
||||||
|
/// Returns None for "auto" (use default adaptive behavior).
|
||||||
|
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
|
||||||
|
match quality {
|
||||||
|
"good" | "opus" => Some(QualityProfile::GOOD),
|
||||||
|
"degraded" | "opus6k" => Some(QualityProfile::DEGRADED),
|
||||||
|
"catastrophic" | "codec2-1200" => Some(QualityProfile::CATASTROPHIC),
|
||||||
|
"codec2-3200" => Some(QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
}),
|
||||||
|
"studio-32k" => Some(QualityProfile::STUDIO_32K),
|
||||||
|
"studio-48k" => Some(QualityProfile::STUDIO_48K),
|
||||||
|
"studio-64k" => Some(QualityProfile::STUDIO_64K),
|
||||||
|
_ => None, // "auto" or unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper to make non-Sync audio handles safe to store in shared state.
|
||||||
|
/// The audio handle is only accessed from the thread that created it (drop),
|
||||||
|
/// never shared across threads — Sync is safe.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct SyncWrapper(Box<dyn std::any::Any + Send>);
|
||||||
|
unsafe impl Sync for SyncWrapper {}
|
||||||
|
|
||||||
|
pub struct ParticipantInfo {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
pub relay_label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EngineStatus {
|
||||||
|
pub mic_muted: bool,
|
||||||
|
pub spk_muted: bool,
|
||||||
|
pub participants: Vec<ParticipantInfo>,
|
||||||
|
pub frames_sent: u64,
|
||||||
|
pub frames_received: u64,
|
||||||
|
pub audio_level: u32,
|
||||||
|
pub call_duration_secs: f64,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub tx_codec: String,
|
||||||
|
pub rx_codec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CallEngine {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
mic_muted: Arc<AtomicBool>,
|
||||||
|
spk_muted: Arc<AtomicBool>,
|
||||||
|
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
|
||||||
|
frames_sent: Arc<AtomicU64>,
|
||||||
|
frames_received: Arc<AtomicU64>,
|
||||||
|
audio_level: Arc<AtomicU32>,
|
||||||
|
tx_codec: Arc<Mutex<String>>,
|
||||||
|
rx_codec: Arc<Mutex<String>>,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
start_time: Instant,
|
||||||
|
fingerprint: String,
|
||||||
|
/// Keep audio handles alive for the duration of the call.
|
||||||
|
/// Wrapped in SyncWrapper because AudioUnit isn't Sync.
|
||||||
|
_audio_handle: SyncWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallEngine {
|
||||||
|
/// Android engine path — uses the standalone `wzp-native` cdylib
|
||||||
|
/// (loaded at startup via `crate::wzp_native::init()`) for Oboe-backed
|
||||||
|
/// capture and playout instead of CPAL. Mirrors the desktop send/recv
|
||||||
|
/// task structure otherwise.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub async fn start<F>(
|
||||||
|
relay: String,
|
||||||
|
room: String,
|
||||||
|
alias: String,
|
||||||
|
_os_aec: bool,
|
||||||
|
quality: String,
|
||||||
|
reuse_endpoint: Option<wzp_transport::Endpoint>,
|
||||||
|
event_cb: F,
|
||||||
|
) -> Result<Self, anyhow::Error>
|
||||||
|
where
|
||||||
|
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
info!(%relay, %room, %alias, %quality, has_reuse = reuse_endpoint.is_some(), "CallEngine::start (android) invoked");
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let relay_addr: SocketAddr = relay.parse()?;
|
||||||
|
info!(%relay_addr, "resolved relay addr");
|
||||||
|
|
||||||
|
// Identity via shared helper (uses Tauri path().app_data_dir()).
|
||||||
|
let seed = crate::load_or_create_seed()
|
||||||
|
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
|
||||||
|
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||||
|
let fingerprint = fp.to_string();
|
||||||
|
info!(%fp, "identity loaded");
|
||||||
|
|
||||||
|
// QUIC transport + handshake.
|
||||||
|
//
|
||||||
|
// If a `reuse_endpoint` was passed in (the direct-call path, where we
|
||||||
|
// already opened a quinn::Endpoint for the signal connection), reuse
|
||||||
|
// it: a second quinn::Endpoint on Android silently fails to complete
|
||||||
|
// the QUIC handshake against the same relay. Reusing the existing
|
||||||
|
// socket lets quinn multiplex the signal + media connections on one
|
||||||
|
// UDP port.
|
||||||
|
let endpoint = if let Some(ep) = reuse_endpoint {
|
||||||
|
info!(local_addr = ?ep.local_addr().ok(), "reusing signal endpoint for media connection");
|
||||||
|
ep
|
||||||
|
} else {
|
||||||
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let ep = wzp_transport::create_endpoint(bind_addr, None)
|
||||||
|
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?;
|
||||||
|
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
|
||||||
|
ep
|
||||||
|
};
|
||||||
|
let client_config = wzp_transport::client_config();
|
||||||
|
let conn = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
wzp_transport::connect(&endpoint, relay_addr, &room, client_config),
|
||||||
|
).await {
|
||||||
|
Ok(Ok(c)) => c,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!("connect failed: {e}");
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
error!("connect TIMED OUT after 10s — QUIC handshake never completed. Relay may be unreachable from this endpoint.");
|
||||||
|
return Err(anyhow::anyhow!("QUIC connect timeout (10s)"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("QUIC connection established, performing handshake");
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
let _session = wzp_client::handshake::perform_handshake(
|
||||||
|
&*transport,
|
||||||
|
&seed.0,
|
||||||
|
Some(&alias),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
|
||||||
|
info!("connected to relay, handshake complete");
|
||||||
|
event_cb("connected", &format!("joined room {room}"));
|
||||||
|
|
||||||
|
// Oboe audio via the wzp-native cdylib that was dlopen'd at
|
||||||
|
// startup. `wzp_native::audio_start()` brings up the capture +
|
||||||
|
// playout streams; send/recv tasks below pull/push PCM through
|
||||||
|
// the extern "C" bridge rings.
|
||||||
|
if !crate::wzp_native::is_loaded() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"wzp-native not loaded — dlopen failed at startup"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(code) = crate::wzp_native::audio_start() {
|
||||||
|
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
|
||||||
|
}
|
||||||
|
info!("wzp-native audio started");
|
||||||
|
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let mic_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let spk_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
|
||||||
|
let frames_sent = Arc::new(AtomicU64::new(0));
|
||||||
|
let frames_received = Arc::new(AtomicU64::new(0));
|
||||||
|
let audio_level = Arc::new(AtomicU32::new(0));
|
||||||
|
let tx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
let rx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
||||||
|
// Send task — drain Oboe capture ring, Opus-encode, push to transport.
|
||||||
|
let send_t = transport.clone();
|
||||||
|
let send_r = running.clone();
|
||||||
|
let send_mic = mic_muted.clone();
|
||||||
|
let send_fs = frames_sent.clone();
|
||||||
|
let send_level = audio_level.clone();
|
||||||
|
let send_drops = Arc::new(AtomicU64::new(0));
|
||||||
|
let send_quality = quality.clone();
|
||||||
|
let send_tx_codec = tx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let profile = resolve_quality(&send_quality);
|
||||||
|
let config = match profile {
|
||||||
|
Some(p) => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::from_profile(p)
|
||||||
|
},
|
||||||
|
None => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
|
||||||
|
info!(codec = ?config.profile.codec, frame_samples, "send task starting (android/oboe)");
|
||||||
|
*send_tx_codec.lock().await = format!("{:?}", config.profile.codec);
|
||||||
|
let mut encoder = CallEncoder::new(&config);
|
||||||
|
encoder.set_aec_enabled(false);
|
||||||
|
let mut buf = vec![0i16; frame_samples];
|
||||||
|
|
||||||
|
let mut heartbeat = std::time::Instant::now();
|
||||||
|
let mut last_rms: u32 = 0;
|
||||||
|
let mut last_pkt_bytes: usize = 0;
|
||||||
|
let mut short_reads: u64 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !send_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// wzp-native doesn't expose `available()`, so we just try
|
||||||
|
// to read a full frame and sleep briefly if the ring is
|
||||||
|
// short. Oboe's capture callback fills at a steady rate
|
||||||
|
// so in steady state this spins once per frame.
|
||||||
|
let read = crate::wzp_native::audio_read_capture(&mut buf);
|
||||||
|
if read < frame_samples {
|
||||||
|
short_reads += 1;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RMS for UI meter
|
||||||
|
let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||||
|
let rms = (sum_sq / buf.len() as f64).sqrt() as u32;
|
||||||
|
send_level.store(rms, Ordering::Relaxed);
|
||||||
|
last_rms = rms;
|
||||||
|
|
||||||
|
if send_mic.load(Ordering::Relaxed) {
|
||||||
|
buf.fill(0);
|
||||||
|
}
|
||||||
|
match encoder.encode_frame(&buf) {
|
||||||
|
Ok(pkts) => {
|
||||||
|
for pkt in &pkts {
|
||||||
|
last_pkt_bytes = pkt.payload.len();
|
||||||
|
if let Err(e) = send_t.send_media(pkt).await {
|
||||||
|
send_drops.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if send_drops.load(Ordering::Relaxed) <= 3 {
|
||||||
|
tracing::warn!("send_media error (dropping packet): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_fs.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) => error!("encode: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat every 2s with capture+encode+send state
|
||||||
|
if heartbeat.elapsed() >= std::time::Duration::from_secs(2) {
|
||||||
|
let fs = send_fs.load(Ordering::Relaxed);
|
||||||
|
let drops = send_drops.load(Ordering::Relaxed);
|
||||||
|
info!(
|
||||||
|
frames_sent = fs,
|
||||||
|
last_rms,
|
||||||
|
last_pkt_bytes,
|
||||||
|
short_reads,
|
||||||
|
send_drops = drops,
|
||||||
|
"send heartbeat (android)"
|
||||||
|
);
|
||||||
|
heartbeat = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recv task — decode incoming packets, push PCM into Oboe playout.
|
||||||
|
let recv_t = transport.clone();
|
||||||
|
let recv_r = running.clone();
|
||||||
|
let recv_spk = spk_muted.clone();
|
||||||
|
let recv_fr = frames_received.clone();
|
||||||
|
let recv_rx_codec = rx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
|
||||||
|
let mut decoder = wzp_codec::create_decoder(initial_profile);
|
||||||
|
let mut current_codec = initial_profile.codec;
|
||||||
|
let mut agc = wzp_codec::AutoGainControl::new();
|
||||||
|
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS];
|
||||||
|
info!(codec = ?current_codec, "recv task starting (android/oboe)");
|
||||||
|
|
||||||
|
// ─── Decoded-PCM recorder (debug) ────────────────────────────
|
||||||
|
// Dumps the first ~10 seconds of post-AGC PCM to a raw i16 LE
|
||||||
|
// file in the app's private data dir so we can adb pull it and
|
||||||
|
// play it back to prove the pipeline is producing real audio
|
||||||
|
// independent of Oboe routing. Convert locally with e.g.
|
||||||
|
// ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav
|
||||||
|
use std::io::Write;
|
||||||
|
let recorder_path = crate::APP_DATA_DIR
|
||||||
|
.get()
|
||||||
|
.map(|p| p.join("decoded.pcm"));
|
||||||
|
let mut recorder = match recorder_path.as_ref() {
|
||||||
|
Some(p) => match std::fs::File::create(p) {
|
||||||
|
Ok(f) => {
|
||||||
|
info!(path = %p.display(), "decoded-pcm recorder open");
|
||||||
|
Some(std::io::BufWriter::new(f))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path = %p.display(), error = %e, "decoded-pcm recorder open failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut recorder_bytes: u64 = 0;
|
||||||
|
// Stop writing after ~10 seconds @ 48kHz mono i16 = ~960KB.
|
||||||
|
const RECORDER_MAX_BYTES: u64 = 48_000 * 2 * 10;
|
||||||
|
|
||||||
|
let mut heartbeat = std::time::Instant::now();
|
||||||
|
let mut decoded_frames: u64 = 0;
|
||||||
|
let mut written_samples: u64 = 0;
|
||||||
|
let mut last_decode_n: usize = 0;
|
||||||
|
let mut last_written: usize = 0;
|
||||||
|
let mut decode_errs: u64 = 0;
|
||||||
|
let mut first_packet_logged = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !recv_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
recv_t.recv_media(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(pkt))) => {
|
||||||
|
if !first_packet_logged {
|
||||||
|
info!(codec_id = ?pkt.header.codec_id, payload_bytes = pkt.payload.len(), is_repair = pkt.header.is_repair, "recv: first media packet received");
|
||||||
|
first_packet_logged = true;
|
||||||
|
}
|
||||||
|
if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||||
|
{
|
||||||
|
let mut rx = recv_rx_codec.lock().await;
|
||||||
|
let codec_name = format!("{:?}", pkt.header.codec_id);
|
||||||
|
if *rx != codec_name { *rx = codec_name; }
|
||||||
|
}
|
||||||
|
if pkt.header.codec_id != current_codec {
|
||||||
|
let new_profile = match pkt.header.codec_id {
|
||||||
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||||
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5, frame_duration_ms: 20, frames_per_block: 5,
|
||||||
|
},
|
||||||
|
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||||
|
};
|
||||||
|
info!(from = ?current_codec, to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||||
|
let _ = decoder.set_profile(new_profile);
|
||||||
|
current_codec = pkt.header.codec_id;
|
||||||
|
}
|
||||||
|
match decoder.decode(&pkt.payload, &mut pcm) {
|
||||||
|
Ok(n) => {
|
||||||
|
last_decode_n = n;
|
||||||
|
decoded_frames += 1;
|
||||||
|
// Log sample range for the first few decoded frames and periodically
|
||||||
|
if decoded_frames <= 3 || decoded_frames % 100 == 0 {
|
||||||
|
let slice = &pcm[..n];
|
||||||
|
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
|
||||||
|
for &s in slice.iter() {
|
||||||
|
if s < lo { lo = s; }
|
||||||
|
if s > hi { hi = s; }
|
||||||
|
sumsq += (s as i64) * (s as i64);
|
||||||
|
}
|
||||||
|
let rms = (sumsq as f64 / n as f64).sqrt() as i32;
|
||||||
|
info!(
|
||||||
|
decoded_frames,
|
||||||
|
n,
|
||||||
|
sample_lo = lo,
|
||||||
|
sample_hi = hi,
|
||||||
|
rms,
|
||||||
|
codec = ?current_codec,
|
||||||
|
"recv: decoded PCM sample range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
agc.process_frame(&mut pcm[..n]);
|
||||||
|
|
||||||
|
// Dump to debug recorder before playout
|
||||||
|
// so we capture post-AGC samples that
|
||||||
|
// are exactly what we hand to Oboe.
|
||||||
|
if let Some(rec) = recorder.as_mut() {
|
||||||
|
if recorder_bytes < RECORDER_MAX_BYTES {
|
||||||
|
let slice = &pcm[..n];
|
||||||
|
// SAFETY: i16 is Plain Old Data;
|
||||||
|
// writing its little-endian bytes
|
||||||
|
// is well-defined on all targets
|
||||||
|
// we build for.
|
||||||
|
let byte_slice: &[u8] = unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
slice.as_ptr() as *const u8,
|
||||||
|
slice.len() * 2,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _ = rec.write_all(byte_slice);
|
||||||
|
recorder_bytes = recorder_bytes
|
||||||
|
.saturating_add(byte_slice.len() as u64);
|
||||||
|
if recorder_bytes >= RECORDER_MAX_BYTES {
|
||||||
|
let _ = rec.flush();
|
||||||
|
info!(recorder_bytes, "decoded-pcm recorder: stopped after limit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !recv_spk.load(Ordering::Relaxed) {
|
||||||
|
let w = crate::wzp_native::audio_write_playout(&pcm[..n]);
|
||||||
|
last_written = w;
|
||||||
|
written_samples = written_samples.saturating_add(w as u64);
|
||||||
|
if w < n && decoded_frames <= 10 {
|
||||||
|
tracing::warn!(n, w, "recv: partial playout write (ring nearly full)");
|
||||||
|
}
|
||||||
|
} else if decoded_frames <= 3 || decoded_frames % 100 == 0 {
|
||||||
|
// User clicked spk-mute — log it so we don't chase ghost bugs
|
||||||
|
tracing::info!(decoded_frames, "recv: spk_muted=true, skipping playout write");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
decode_errs += 1;
|
||||||
|
if decode_errs <= 3 {
|
||||||
|
tracing::warn!("decode error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recv_fr.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("closed") || msg.contains("reset") {
|
||||||
|
error!("recv fatal: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat every 2s with decode+playout state
|
||||||
|
if heartbeat.elapsed() >= std::time::Duration::from_secs(2) {
|
||||||
|
let fr = recv_fr.load(Ordering::Relaxed);
|
||||||
|
info!(
|
||||||
|
recv_fr = fr,
|
||||||
|
decoded_frames,
|
||||||
|
last_decode_n,
|
||||||
|
last_written,
|
||||||
|
written_samples,
|
||||||
|
decode_errs,
|
||||||
|
codec = ?current_codec,
|
||||||
|
"recv heartbeat (android)"
|
||||||
|
);
|
||||||
|
heartbeat = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal task (presence — same shape as desktop).
|
||||||
|
let sig_t = transport.clone();
|
||||||
|
let sig_r = running.clone();
|
||||||
|
let sig_p = participants.clone();
|
||||||
|
let event_cb = Arc::new(event_cb);
|
||||||
|
let sig_cb = event_cb.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if !sig_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
sig_t.recv_signal(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
|
||||||
|
participants: parts,
|
||||||
|
..
|
||||||
|
}))) => {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let unique: Vec<ParticipantInfo> = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
|
||||||
|
.map(|p| ParticipantInfo {
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
alias: p.alias,
|
||||||
|
relay_label: p.relay_label,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let count = unique.len();
|
||||||
|
*sig_p.lock().await = unique;
|
||||||
|
sig_cb("room-update", &format!("{count} participants"));
|
||||||
|
}
|
||||||
|
Ok(Ok(Some(_))) => {}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(_)) => break,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
running,
|
||||||
|
mic_muted,
|
||||||
|
spk_muted,
|
||||||
|
participants,
|
||||||
|
frames_sent,
|
||||||
|
frames_received,
|
||||||
|
audio_level,
|
||||||
|
transport,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
fingerprint,
|
||||||
|
tx_codec,
|
||||||
|
rx_codec,
|
||||||
|
// No CPAL / VPIO handle to keep alive on Android — wzp_native
|
||||||
|
// is a static dlopen'd library, the audio streams live inside
|
||||||
|
// the standalone cdylib's process-global singleton.
|
||||||
|
_audio_handle: SyncWrapper(Box::new(())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub async fn start<F>(
|
||||||
|
relay: String,
|
||||||
|
room: String,
|
||||||
|
alias: String,
|
||||||
|
_os_aec: bool,
|
||||||
|
quality: String,
|
||||||
|
reuse_endpoint: Option<wzp_transport::Endpoint>,
|
||||||
|
event_cb: F,
|
||||||
|
) -> Result<Self, anyhow::Error>
|
||||||
|
where
|
||||||
|
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
info!(%relay, %room, %alias, %quality, has_reuse = reuse_endpoint.is_some(), "CallEngine::start (desktop) invoked");
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let relay_addr: SocketAddr = relay.parse()?;
|
||||||
|
|
||||||
|
// Identity via the SHARED helper — same path resolution as
|
||||||
|
// register_signal (Tauri app_data_dir, e.g. on macOS
|
||||||
|
// ~/Library/Application Support/com.wzp.desktop/.wzp/identity).
|
||||||
|
//
|
||||||
|
// The previous implementation loaded the seed manually from
|
||||||
|
// $HOME/.wzp/identity which is a DIFFERENT file on macOS, so
|
||||||
|
// register_signal and CallEngine::start were using different
|
||||||
|
// identities — direct calls placed from desktop were routed
|
||||||
|
// by the relay under the CallEngine fingerprint but the callee
|
||||||
|
// had registered under a different fingerprint, making the
|
||||||
|
// call unroutable.
|
||||||
|
let seed = crate::load_or_create_seed()
|
||||||
|
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
|
||||||
|
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||||
|
let fingerprint = fp.to_string();
|
||||||
|
info!(%fp, "identity loaded");
|
||||||
|
|
||||||
|
// Connect — reuse the signal endpoint if the direct-call path gave
|
||||||
|
// us one, otherwise create a fresh one (SFU room join path).
|
||||||
|
let endpoint = if let Some(ep) = reuse_endpoint {
|
||||||
|
info!(local_addr = ?ep.local_addr().ok(), "reusing signal endpoint for media connection");
|
||||||
|
ep
|
||||||
|
} else {
|
||||||
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let ep = wzp_transport::create_endpoint(bind_addr, None)
|
||||||
|
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?;
|
||||||
|
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
|
||||||
|
ep
|
||||||
|
};
|
||||||
|
let client_config = wzp_transport::client_config();
|
||||||
|
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| { error!("connect failed: {e}"); e })?;
|
||||||
|
info!("QUIC connection established, performing handshake");
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Handshake
|
||||||
|
let _session = wzp_client::handshake::perform_handshake(
|
||||||
|
&*transport,
|
||||||
|
&seed.0,
|
||||||
|
Some(&alias),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?;
|
||||||
|
|
||||||
|
info!("connected to relay, handshake complete");
|
||||||
|
event_cb("connected", &format!("joined room {room}"));
|
||||||
|
|
||||||
|
// Audio I/O — VPIO (OS AEC) on macOS, plain CPAL otherwise.
|
||||||
|
// The audio handle must be stored in CallEngine to keep streams alive.
|
||||||
|
let (capture_ring, playout_ring, audio_handle): (_, _, Box<dyn std::any::Any + Send>) =
|
||||||
|
if _os_aec {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match wzp_client::audio_vpio::VpioAudio::start() {
|
||||||
|
Ok(v) => {
|
||||||
|
let cr = v.capture_ring().clone();
|
||||||
|
let pr = v.playout_ring().clone();
|
||||||
|
info!("using VoiceProcessingIO (OS AEC)");
|
||||||
|
(cr, pr, Box::new(v))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!("VPIO failed ({e}), falling back to CPAL");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
info!("OS AEC not available on this platform, using CPAL");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let mic_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let spk_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
|
||||||
|
let frames_sent = Arc::new(AtomicU64::new(0));
|
||||||
|
let frames_received = Arc::new(AtomicU64::new(0));
|
||||||
|
let audio_level = Arc::new(AtomicU32::new(0));
|
||||||
|
let tx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
let rx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
||||||
|
// Send task
|
||||||
|
let send_t = transport.clone();
|
||||||
|
let send_r = running.clone();
|
||||||
|
let send_mic = mic_muted.clone();
|
||||||
|
let send_fs = frames_sent.clone();
|
||||||
|
let send_level = audio_level.clone();
|
||||||
|
let send_drops = Arc::new(AtomicU64::new(0));
|
||||||
|
let send_quality = quality.clone();
|
||||||
|
let send_tx_codec = tx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let profile = resolve_quality(&send_quality);
|
||||||
|
let config = match profile {
|
||||||
|
Some(p) => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::from_profile(p)
|
||||||
|
},
|
||||||
|
None => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
|
||||||
|
info!(codec = ?config.profile.codec, frame_samples, "send task starting");
|
||||||
|
*send_tx_codec.lock().await = format!("{:?}", config.profile.codec);
|
||||||
|
let mut encoder = CallEncoder::new(&config);
|
||||||
|
encoder.set_aec_enabled(false); // OS AEC or none
|
||||||
|
let mut buf = vec![0i16; frame_samples];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !send_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if capture_ring.available() < frame_samples {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
capture_ring.read(&mut buf);
|
||||||
|
|
||||||
|
// Compute RMS audio level for UI meter
|
||||||
|
if !buf.is_empty() {
|
||||||
|
let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||||
|
let rms = (sum_sq / buf.len() as f64).sqrt() as u32;
|
||||||
|
send_level.store(rms, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send_mic.load(Ordering::Relaxed) {
|
||||||
|
buf.fill(0);
|
||||||
|
}
|
||||||
|
match encoder.encode_frame(&buf) {
|
||||||
|
Ok(pkts) => {
|
||||||
|
for pkt in &pkts {
|
||||||
|
if let Err(e) = send_t.send_media(pkt).await {
|
||||||
|
// Transient congestion (Blocked) — drop packet, keep going
|
||||||
|
send_drops.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if send_drops.load(Ordering::Relaxed) <= 3 {
|
||||||
|
tracing::warn!("send_media error (dropping packet): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_fs.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) => error!("encode: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recv task (direct playout with auto codec switch)
|
||||||
|
let recv_t = transport.clone();
|
||||||
|
let recv_r = running.clone();
|
||||||
|
let recv_spk = spk_muted.clone();
|
||||||
|
let recv_fr = frames_received.clone();
|
||||||
|
let recv_rx_codec = rx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
|
||||||
|
let mut decoder = wzp_codec::create_decoder(initial_profile);
|
||||||
|
let mut current_codec = initial_profile.codec;
|
||||||
|
let mut agc = wzp_codec::AutoGainControl::new();
|
||||||
|
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !recv_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
recv_t.recv_media(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(pkt))) => {
|
||||||
|
if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||||
|
// Track RX codec
|
||||||
|
{
|
||||||
|
let mut rx = recv_rx_codec.lock().await;
|
||||||
|
let codec_name = format!("{:?}", pkt.header.codec_id);
|
||||||
|
if *rx != codec_name { *rx = codec_name; }
|
||||||
|
}
|
||||||
|
// Auto-switch decoder if incoming codec differs
|
||||||
|
if pkt.header.codec_id != current_codec {
|
||||||
|
let new_profile = match pkt.header.codec_id {
|
||||||
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||||
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5, frame_duration_ms: 20, frames_per_block: 5,
|
||||||
|
},
|
||||||
|
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||||
|
};
|
||||||
|
info!(from = ?current_codec, to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||||
|
let _ = decoder.set_profile(new_profile);
|
||||||
|
current_codec = pkt.header.codec_id;
|
||||||
|
}
|
||||||
|
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
|
||||||
|
agc.process_frame(&mut pcm[..n]);
|
||||||
|
if !recv_spk.load(Ordering::Relaxed) {
|
||||||
|
playout_ring.write(&pcm[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recv_fr.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("closed") || msg.contains("reset") {
|
||||||
|
error!("recv fatal: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal task (presence)
|
||||||
|
let sig_t = transport.clone();
|
||||||
|
let sig_r = running.clone();
|
||||||
|
let sig_p = participants.clone();
|
||||||
|
let event_cb = Arc::new(event_cb);
|
||||||
|
let sig_cb = event_cb.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if !sig_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
sig_t.recv_signal(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
|
||||||
|
participants: parts,
|
||||||
|
..
|
||||||
|
}))) => {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let unique: Vec<ParticipantInfo> = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
|
||||||
|
.map(|p| ParticipantInfo {
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
alias: p.alias,
|
||||||
|
relay_label: p.relay_label,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let count = unique.len();
|
||||||
|
*sig_p.lock().await = unique;
|
||||||
|
sig_cb("room-update", &format!("{count} participants"));
|
||||||
|
}
|
||||||
|
Ok(Ok(Some(_))) => {}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(_)) => break,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
running,
|
||||||
|
mic_muted,
|
||||||
|
spk_muted,
|
||||||
|
participants,
|
||||||
|
frames_sent,
|
||||||
|
frames_received,
|
||||||
|
audio_level,
|
||||||
|
transport,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
fingerprint,
|
||||||
|
tx_codec,
|
||||||
|
rx_codec,
|
||||||
|
_audio_handle: SyncWrapper(audio_handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mic(&self) -> bool {
|
||||||
|
let was = self.mic_muted.load(Ordering::Relaxed);
|
||||||
|
self.mic_muted.store(!was, Ordering::Relaxed);
|
||||||
|
!was
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_speaker(&self) -> bool {
|
||||||
|
let was = self.spk_muted.load(Ordering::Relaxed);
|
||||||
|
self.spk_muted.store(!was, Ordering::Relaxed);
|
||||||
|
!was
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> EngineStatus {
|
||||||
|
let participants = {
|
||||||
|
let parts = self.participants.lock().await;
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.map(|p| ParticipantInfo {
|
||||||
|
fingerprint: p.fingerprint.clone(),
|
||||||
|
alias: p.alias.clone(),
|
||||||
|
relay_label: p.relay_label.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}; // lock dropped here
|
||||||
|
EngineStatus {
|
||||||
|
mic_muted: self.mic_muted.load(Ordering::Relaxed),
|
||||||
|
spk_muted: self.spk_muted.load(Ordering::Relaxed),
|
||||||
|
participants,
|
||||||
|
frames_sent: self.frames_sent.load(Ordering::Relaxed),
|
||||||
|
frames_received: self.frames_received.load(Ordering::Relaxed),
|
||||||
|
audio_level: self.audio_level.load(Ordering::Relaxed),
|
||||||
|
call_duration_secs: self.start_time.elapsed().as_secs_f64(),
|
||||||
|
fingerprint: self.fingerprint.clone(),
|
||||||
|
tx_codec: self.tx_codec.lock().await.clone(),
|
||||||
|
rx_codec: self.rx_codec.lock().await.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(self) {
|
||||||
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
self.transport.close().await.ok();
|
||||||
|
// On Android, the Oboe capture/playout streams live inside the
|
||||||
|
// wzp-native cdylib as a process-global singleton. Explicitly stop
|
||||||
|
// them here so the mic + speaker are released between calls, matching
|
||||||
|
// the desktop behaviour where dropping _audio_handle tears down CPAL.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
crate::wzp_native::audio_stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
desktop/src-tauri/src/history.rs
Normal file
180
desktop/src-tauri/src/history.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//! Call history store.
|
||||||
|
//!
|
||||||
|
//! Keeps a rolling JSON file of the last N direct-call events so the UI can
|
||||||
|
//! show "recent contacts" + "call history with callback buttons" on the
|
||||||
|
//! direct-call screen. Storage lives in `<APP_DATA_DIR>/call_history.json`
|
||||||
|
//! alongside the identity file. The file is read lazily on first access and
|
||||||
|
//! cached in an RwLock behind a OnceLock.
|
||||||
|
//!
|
||||||
|
//! This is a v1 — no duration tracking yet, entries are logged at the
|
||||||
|
//! moment the direction is decided (placed / received / missed).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Maximum number of history entries we keep. Older ones are pruned FIFO.
|
||||||
|
const MAX_ENTRIES: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CallDirection {
|
||||||
|
/// Local user placed the call.
|
||||||
|
Placed,
|
||||||
|
/// Remote user called and local user answered.
|
||||||
|
Received,
|
||||||
|
/// Remote user called but local user did not answer (rejected or
|
||||||
|
/// missed entirely — the UI treats these identically).
|
||||||
|
Missed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CallHistoryEntry {
|
||||||
|
pub call_id: String,
|
||||||
|
pub peer_fp: String,
|
||||||
|
pub peer_alias: Option<String>,
|
||||||
|
pub direction: CallDirection,
|
||||||
|
/// Seconds since UNIX epoch, UTC.
|
||||||
|
pub timestamp_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── In-process store (loaded from disk once) ─────────────────────────────
|
||||||
|
|
||||||
|
static STORE: OnceLock<RwLock<Vec<CallHistoryEntry>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn store() -> &'static RwLock<Vec<CallHistoryEntry>> {
|
||||||
|
STORE.get_or_init(|| RwLock::new(load_from_disk()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_path() -> PathBuf {
|
||||||
|
crate::APP_DATA_DIR
|
||||||
|
.get()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
PathBuf::from(home).join(".wzp")
|
||||||
|
})
|
||||||
|
.join("call_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_disk() -> Vec<CallHistoryEntry> {
|
||||||
|
let path = history_path();
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
serde_json::from_slice::<Vec<CallHistoryEntry>>(&bytes)
|
||||||
|
.inspect_err(|e| tracing::warn!(path = %path.display(), error = %e, "call_history.json parse failed"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_disk(entries: &[CallHistoryEntry]) {
|
||||||
|
let path = history_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
|
||||||
|
// Atomic write via temp file + rename so a crash mid-write doesn't
|
||||||
|
// leave us with a half-file on disk.
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
if std::fs::write(&tmp, &json).is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, &path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Append a new entry to the store and persist to disk. Trims the store to
|
||||||
|
/// `MAX_ENTRIES` after insertion.
|
||||||
|
pub fn log(
|
||||||
|
call_id: String,
|
||||||
|
peer_fp: String,
|
||||||
|
peer_alias: Option<String>,
|
||||||
|
direction: CallDirection,
|
||||||
|
) {
|
||||||
|
tracing::info!(
|
||||||
|
%call_id, %peer_fp, ?direction,
|
||||||
|
alias = ?peer_alias,
|
||||||
|
"history::log"
|
||||||
|
);
|
||||||
|
let entry = CallHistoryEntry {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
peer_fp,
|
||||||
|
peer_alias,
|
||||||
|
direction,
|
||||||
|
timestamp_unix: now_unix(),
|
||||||
|
};
|
||||||
|
let mut guard = store().write().unwrap();
|
||||||
|
// If an entry for this call_id already exists, update it in-place
|
||||||
|
// rather than appending a duplicate. Protects against the caller
|
||||||
|
// side adding a second Missed row when the callee's DirectCallOffer
|
||||||
|
// bounces back through federation / loopback, or when some future
|
||||||
|
// relay routing edge case double-emits a signal. The dedup keeps
|
||||||
|
// history tidy and matches what the user intuitively expects (one
|
||||||
|
// history row per call, not one per signal event).
|
||||||
|
if let Some(existing) = guard.iter_mut().rev().find(|e| e.call_id == call_id) {
|
||||||
|
tracing::info!(%call_id, from = ?existing.direction, to = ?direction, "history::log replacing existing entry");
|
||||||
|
existing.direction = direction;
|
||||||
|
existing.timestamp_unix = entry.timestamp_unix;
|
||||||
|
save_to_disk(&guard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guard.push(entry);
|
||||||
|
if guard.len() > MAX_ENTRIES {
|
||||||
|
let drop_n = guard.len() - MAX_ENTRIES;
|
||||||
|
guard.drain(0..drop_n);
|
||||||
|
}
|
||||||
|
save_to_disk(&guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a copy of all entries in reverse-chronological order
|
||||||
|
/// (most recent first).
|
||||||
|
pub fn all() -> Vec<CallHistoryEntry> {
|
||||||
|
let guard = store().read().unwrap();
|
||||||
|
guard.iter().rev().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique peer contacts sorted by most recent interaction. Each contact
|
||||||
|
/// is represented by the newest history entry for that fingerprint.
|
||||||
|
pub fn contacts() -> Vec<CallHistoryEntry> {
|
||||||
|
let guard = store().read().unwrap();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// iterate newest → oldest
|
||||||
|
for entry in guard.iter().rev() {
|
||||||
|
if seen.insert(entry.peer_fp.clone()) {
|
||||||
|
out.push(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the entire history and persist the empty file.
|
||||||
|
pub fn clear() {
|
||||||
|
let mut guard = store().write().unwrap();
|
||||||
|
guard.clear();
|
||||||
|
save_to_disk(&guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a Missed-candidate entry that matches `call_id` and hasn't been
|
||||||
|
/// answered yet. Used by the signal loop to turn "pending incoming" into
|
||||||
|
/// "Received" when the user accepts.
|
||||||
|
pub fn mark_received_if_pending(call_id: &str) -> bool {
|
||||||
|
let mut guard = store().write().unwrap();
|
||||||
|
for entry in guard.iter_mut().rev() {
|
||||||
|
if entry.call_id == call_id && entry.direction == CallDirection::Missed {
|
||||||
|
entry.direction = CallDirection::Received;
|
||||||
|
save_to_disk(&guard);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
700
desktop/src-tauri/src/lib.rs
Normal file
700
desktop/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
// WarzonePhone Tauri backend — shared between desktop (macOS/Windows/Linux)
|
||||||
|
// and Tauri mobile (Android/iOS). Platform-specific audio is cfg-gated.
|
||||||
|
|
||||||
|
#![cfg_attr(
|
||||||
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
|
windows_subsystem = "windows"
|
||||||
|
)]
|
||||||
|
|
||||||
|
// Call engine — now compiled on every platform. On desktop it runs the real
|
||||||
|
// CPAL/VPIO audio pipeline; on Android the engine calls into the standalone
|
||||||
|
// wzp-native cdylib (via the wzp_native module) for Oboe-backed audio.
|
||||||
|
mod engine;
|
||||||
|
|
||||||
|
// Android runtime binding to libwzp_native.so (Oboe audio backend, built as
|
||||||
|
// a standalone cdylib with cargo-ndk to avoid the Tauri staticlib symbol
|
||||||
|
// leak — see docs/incident-tauri-android-init-tcb.md).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod wzp_native;
|
||||||
|
|
||||||
|
// Android AudioManager bridge (routing earpiece / speaker / BT).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_audio;
|
||||||
|
|
||||||
|
// Direct-call history store (persisted JSON in app data dir).
|
||||||
|
mod history;
|
||||||
|
|
||||||
|
// CallEngine has a unified impl on both targets now — the Android branch of
|
||||||
|
// CallEngine::start() routes audio through the standalone wzp-native cdylib
|
||||||
|
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
|
||||||
|
use engine::CallEngine;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use wzp_proto::MediaTransport;
|
||||||
|
|
||||||
|
/// Short git hash captured at compile time by build.rs.
|
||||||
|
const GIT_HASH: &str = env!("WZP_GIT_HASH");
|
||||||
|
|
||||||
|
/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
|
||||||
|
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
|
||||||
|
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
|
||||||
|
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Adjective list — keep in sync with the noun list below. Both are powers of
|
||||||
|
/// 2 friendly so the modulo bias is negligible.
|
||||||
|
const ALIAS_ADJECTIVES: &[&str] = &[
|
||||||
|
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
|
||||||
|
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
|
||||||
|
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
|
||||||
|
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
|
||||||
|
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc",
|
||||||
|
];
|
||||||
|
const ALIAS_NOUNS: &[&str] = &[
|
||||||
|
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
|
||||||
|
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
|
||||||
|
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
|
||||||
|
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
|
||||||
|
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Derive a stable human-readable alias from the seed bytes. Same seed →
|
||||||
|
/// same alias forever, different seeds → effectively random aliases.
|
||||||
|
fn derive_alias(seed: &wzp_crypto::Seed) -> String {
|
||||||
|
let adj_idx = (u16::from_le_bytes([seed.0[0], seed.0[1]]) as usize) % ALIAS_ADJECTIVES.len();
|
||||||
|
let noun_idx = (u16::from_le_bytes([seed.0[2], seed.0[3]]) as usize) % ALIAS_NOUNS.len();
|
||||||
|
format!("{} {}", ALIAS_ADJECTIVES[adj_idx], ALIAS_NOUNS[noun_idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct CallEvent {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct Participant {
|
||||||
|
fingerprint: String,
|
||||||
|
alias: Option<String>,
|
||||||
|
relay_label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct CallStatus {
|
||||||
|
active: bool,
|
||||||
|
mic_muted: bool,
|
||||||
|
spk_muted: bool,
|
||||||
|
participants: Vec<Participant>,
|
||||||
|
encode_fps: u64,
|
||||||
|
recv_fps: u64,
|
||||||
|
audio_level: u32,
|
||||||
|
call_duration_secs: f64,
|
||||||
|
fingerprint: String,
|
||||||
|
tx_codec: String,
|
||||||
|
rx_codec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
engine: Mutex<Option<CallEngine>>,
|
||||||
|
signal: Arc<Mutex<SignalState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping result with RTT and server identity hash.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct PingResult {
|
||||||
|
rtt_ms: u32,
|
||||||
|
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
|
||||||
|
server_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping a relay to check if it's online, measure RTT, and get server identity.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
||||||
|
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let conn_result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Always close endpoint to prevent resource leaks
|
||||||
|
endpoint.close(0u32.into(), b"done");
|
||||||
|
|
||||||
|
match conn_result {
|
||||||
|
Ok(Ok(conn)) => {
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u32;
|
||||||
|
|
||||||
|
let server_fingerprint = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||||
|
.and_then(|certs| certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut hasher);
|
||||||
|
let h = hasher.finish();
|
||||||
|
format!("{h:016x}")
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
Ok(PingResult { rtt_ms, server_fingerprint })
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(format!("{e}")),
|
||||||
|
Err(_) => Err("timeout (3s)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the directory where identity/config should live.
|
||||||
|
///
|
||||||
|
/// Resolved at startup from Tauri's `path().app_data_dir()` API which gives
|
||||||
|
/// us the platform-correct app-private location:
|
||||||
|
/// - Android: `/data/data/<package_id>/files/com.wzp.desktop`
|
||||||
|
/// - macOS: `~/Library/Application Support/com.wzp.desktop`
|
||||||
|
/// - Linux: `~/.local/share/com.wzp.desktop`
|
||||||
|
///
|
||||||
|
/// Falls back to `$HOME/.wzp` on the desktop side if the OnceLock hasn't been
|
||||||
|
/// initialised yet (shouldn't happen in normal startup, but keeps the fn
|
||||||
|
/// total).
|
||||||
|
fn identity_dir() -> PathBuf {
|
||||||
|
if let Some(dir) = APP_DATA_DIR.get() {
|
||||||
|
return dir.clone();
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
// Last-resort default. The real path is set in setup() below.
|
||||||
|
std::path::PathBuf::from("/data/data/com.wzp.desktop/files")
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
std::path::PathBuf::from(home).join(".wzp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_path() -> std::path::PathBuf {
|
||||||
|
identity_dir().join("identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the persisted seed, or generate-and-persist a new one if missing.
|
||||||
|
fn load_or_create_seed() -> Result<wzp_crypto::Seed, String> {
|
||||||
|
let path = identity_path();
|
||||||
|
if path.exists() {
|
||||||
|
let hex = std::fs::read_to_string(&path).map_err(|e| format!("read identity: {e}"))?;
|
||||||
|
return wzp_crypto::Seed::from_hex(hex.trim()).map_err(|e| format!("{e}"));
|
||||||
|
}
|
||||||
|
let seed = wzp_crypto::Seed::generate();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("create identity dir: {e}"))?;
|
||||||
|
}
|
||||||
|
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
std::fs::write(&path, hex).map_err(|e| format!("write identity: {e}"))?;
|
||||||
|
Ok(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read fingerprint, generating a fresh identity if none exists yet.
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_identity() -> Result<String, String> {
|
||||||
|
let seed = load_or_create_seed()?;
|
||||||
|
Ok(seed.derive_identity().public_identity().fingerprint.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build/identity info shown on the home screen so the user can prove which
|
||||||
|
/// build is installed and what their stable alias is.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct AppInfo {
|
||||||
|
/// Short git commit hash captured at build time.
|
||||||
|
git_hash: &'static str,
|
||||||
|
/// Stable adjective+noun derived from the seed.
|
||||||
|
alias: String,
|
||||||
|
/// Full fingerprint, e.g. "abcd:ef01:..."
|
||||||
|
fingerprint: String,
|
||||||
|
/// App data dir actually in use — useful for debugging EACCES issues.
|
||||||
|
data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_app_info() -> Result<AppInfo, String> {
|
||||||
|
let seed = load_or_create_seed()?;
|
||||||
|
let pub_id = seed.derive_identity().public_identity();
|
||||||
|
Ok(AppInfo {
|
||||||
|
git_hash: GIT_HASH,
|
||||||
|
alias: derive_alias(&seed),
|
||||||
|
fingerprint: pub_id.fingerprint.to_string(),
|
||||||
|
data_dir: identity_dir().to_string_lossy().into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn connect(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
relay: String,
|
||||||
|
room: String,
|
||||||
|
alias: String,
|
||||||
|
os_aec: bool,
|
||||||
|
quality: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut engine_lock = state.engine.lock().await;
|
||||||
|
if engine_lock.is_some() {
|
||||||
|
return Err("already connected".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we previously opened a quinn::Endpoint for the signaling connection
|
||||||
|
// (direct-call path), reuse it so the media connection shares the same
|
||||||
|
// UDP socket. This side-steps the Android issue where a second
|
||||||
|
// quinn::Endpoint silently hangs in the QUIC handshake.
|
||||||
|
let reuse_endpoint = state.signal.lock().await.endpoint.clone();
|
||||||
|
if reuse_endpoint.is_some() {
|
||||||
|
tracing::info!("connect: reusing existing signal endpoint for media connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, move |event_kind, message| {
|
||||||
|
let _ = app_clone.emit(
|
||||||
|
"call-event",
|
||||||
|
CallEvent {
|
||||||
|
kind: event_kind.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(eng) => {
|
||||||
|
*engine_lock = Some(eng);
|
||||||
|
Ok("connected".into())
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("{e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
|
||||||
|
let mut engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(engine) = engine_lock.take() {
|
||||||
|
engine.stop().await;
|
||||||
|
Ok("disconnected".into())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
Ok(engine.toggle_mic())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
Ok(engine.toggle_speaker())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
let status = engine.status().await;
|
||||||
|
Ok(CallStatus {
|
||||||
|
active: true,
|
||||||
|
mic_muted: status.mic_muted,
|
||||||
|
spk_muted: status.spk_muted,
|
||||||
|
participants: status
|
||||||
|
.participants
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| Participant {
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
alias: p.alias,
|
||||||
|
relay_label: p.relay_label,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
encode_fps: status.frames_sent,
|
||||||
|
recv_fps: status.frames_received,
|
||||||
|
audio_level: status.audio_level,
|
||||||
|
call_duration_secs: status.call_duration_secs,
|
||||||
|
fingerprint: status.fingerprint,
|
||||||
|
tx_codec: status.tx_codec,
|
||||||
|
rx_codec: status.rx_codec,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(CallStatus {
|
||||||
|
active: false,
|
||||||
|
mic_muted: false,
|
||||||
|
spk_muted: false,
|
||||||
|
participants: vec![],
|
||||||
|
encode_fps: 0,
|
||||||
|
recv_fps: 0,
|
||||||
|
audio_level: 0,
|
||||||
|
call_duration_secs: 0.0,
|
||||||
|
fingerprint: String::new(),
|
||||||
|
tx_codec: String::new(),
|
||||||
|
rx_codec: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audio routing (Android-specific, no-op on desktop) ─────────────────────
|
||||||
|
|
||||||
|
/// Switch the call audio between earpiece (`on=false`) and loudspeaker
|
||||||
|
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
|
||||||
|
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
|
||||||
|
/// with the new routing — without the restart, changing the speakerphone
|
||||||
|
/// state mid-call silently tears down the running AAudio streams on some
|
||||||
|
/// OEMs and both capture + playout stop producing data.
|
||||||
|
///
|
||||||
|
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
|
||||||
|
/// window; they just observe empty reads / writes against the
|
||||||
|
/// process-global ring buffers, which is fine because the ring state
|
||||||
|
/// is preserved across stop+start.
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
async fn set_speakerphone(on: bool) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_audio::set_speakerphone(on)?;
|
||||||
|
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
|
||||||
|
tracing::info!(on, "set_speakerphone: restarting Oboe for route change");
|
||||||
|
// Oboe's stop/start are sync C-FFI calls that block for ~400ms
|
||||||
|
// on Nothing-class devices (Pixel is faster). Calling them
|
||||||
|
// directly from an async Tauri command stalls the tokio
|
||||||
|
// executor — the send/recv engine tasks were observed to
|
||||||
|
// freeze for ~20 seconds across a few rapid speaker toggles,
|
||||||
|
// piling up buffered QUIC datagrams and then flooding them
|
||||||
|
// all at once when the runtime finally caught up.
|
||||||
|
//
|
||||||
|
// Fix: run the audio teardown + reopen on a dedicated
|
||||||
|
// blocking thread so the runtime keeps scheduling everything
|
||||||
|
// else. AAudio's requestStop returns only after the stream
|
||||||
|
// is actually in Stopped state, so no explicit inter-call
|
||||||
|
// sleep is needed.
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
wzp_native::audio_stop();
|
||||||
|
wzp_native::audio_start()
|
||||||
|
.map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking join: {e}"))??;
|
||||||
|
tracing::info!("set_speakerphone: Oboe restarted");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query whether the call is currently routed to the loudspeaker.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn is_speakerphone_on() -> Result<bool, String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_audio::is_speakerphone_on()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Call history commands ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_call_history() -> Vec<history::CallHistoryEntry> {
|
||||||
|
history::all()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
|
||||||
|
history::contacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_call_history() -> Result<(), String> {
|
||||||
|
history::clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Signaling commands — platform independent ───────────────────────────────
|
||||||
|
|
||||||
|
struct SignalState {
|
||||||
|
transport: Option<Arc<wzp_transport::QuinnTransport>>,
|
||||||
|
/// The quinn::Endpoint backing the signal connection. Reused for the
|
||||||
|
/// media connection when a direct call is accepted — Android phones
|
||||||
|
/// silently drop packets from a second quinn::Endpoint to the same
|
||||||
|
/// relay, so every call after register_signal MUST share this socket.
|
||||||
|
endpoint: Option<wzp_transport::Endpoint>,
|
||||||
|
fingerprint: String,
|
||||||
|
signal_status: String,
|
||||||
|
incoming_call_id: Option<String>,
|
||||||
|
incoming_caller_fp: Option<String>,
|
||||||
|
incoming_caller_alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn register_signal(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
relay: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use wzp_proto::SignalMessage;
|
||||||
|
|
||||||
|
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
// Load or create seed automatically — no need to "connect to a room first"
|
||||||
|
let seed = load_or_create_seed()?;
|
||||||
|
let pub_id = seed.derive_identity().public_identity();
|
||||||
|
let fp = pub_id.fingerprint.to_string();
|
||||||
|
let identity_pub = *pub_id.signing.as_bytes();
|
||||||
|
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
|
||||||
|
let conn = wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config())
|
||||||
|
.await.map_err(|e| format!("{e}"))?;
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
transport.send_signal(&SignalMessage::RegisterPresence {
|
||||||
|
identity_pub, signature: vec![], alias: None,
|
||||||
|
}).await.map_err(|e| format!("{e}"))?;
|
||||||
|
|
||||||
|
match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
|
||||||
|
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {}
|
||||||
|
_ => return Err("registration failed".into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
{ let mut sig = state.signal.lock().await; sig.transport = Some(transport.clone()); sig.endpoint = Some(endpoint.clone()); sig.fingerprint = fp.clone(); sig.signal_status = "registered".into(); }
|
||||||
|
|
||||||
|
tracing::info!(%fp, "signal registered, spawning recv loop");
|
||||||
|
let signal_state = Arc::clone(&state.signal);
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
||||||
|
tracing::info!(%call_id, "signal: CallRinging");
|
||||||
|
let mut sig = signal_state.lock().await; sig.signal_status = "ringing".into();
|
||||||
|
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"ringing","call_id":call_id}));
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||||
|
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
|
||||||
|
let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into();
|
||||||
|
sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone();
|
||||||
|
// Log as a Missed entry up-front. If the user accepts
|
||||||
|
// the call, answer_call upgrades it to Received via
|
||||||
|
// history::mark_received_if_pending(call_id). If they
|
||||||
|
// reject or ignore, it stays Missed.
|
||||||
|
history::log(
|
||||||
|
call_id.clone(),
|
||||||
|
caller_fingerprint.clone(),
|
||||||
|
caller_alias.clone(),
|
||||||
|
history::CallDirection::Missed,
|
||||||
|
);
|
||||||
|
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
|
||||||
|
let _ = app_clone.emit("history-changed", ());
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
||||||
|
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
||||||
|
tracing::info!(%call_id, %room, %relay_addr, "signal: CallSetup — emitting setup event to JS");
|
||||||
|
let mut sig = signal_state.lock().await; sig.signal_status = "setup".into();
|
||||||
|
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"setup","call_id":call_id,"room":room,"relay_addr":relay_addr}));
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||||
|
tracing::info!(?reason, "signal: Hangup");
|
||||||
|
let mut sig = signal_state.lock().await; sig.signal_status = "registered".into(); sig.incoming_call_id = None;
|
||||||
|
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"hangup"}));
|
||||||
|
}
|
||||||
|
Ok(Some(other)) => {
|
||||||
|
tracing::debug!(?other, "signal: unhandled message");
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!("signal recv returned None — peer closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "signal recv error — breaking loop");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::warn!("signal recv loop exited — signal_status=idle, transport dropped");
|
||||||
|
let mut sig = signal_state.lock().await; sig.signal_status = "idle".into(); sig.transport = None;
|
||||||
|
});
|
||||||
|
Ok(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn place_call(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
target_fp: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use wzp_proto::SignalMessage;
|
||||||
|
let sig = state.signal.lock().await;
|
||||||
|
let transport = sig.transport.as_ref().ok_or("not registered")?;
|
||||||
|
let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||||
|
tracing::info!(%call_id, %target_fp, "place_call: sending DirectCallOffer");
|
||||||
|
transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||||
|
caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(),
|
||||||
|
call_id: call_id.clone(), identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
|
||||||
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
|
}).await.map_err(|e| format!("{e}"))?;
|
||||||
|
history::log(call_id, target_fp, None, history::CallDirection::Placed);
|
||||||
|
let _ = app.emit("history-changed", ());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn answer_call(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
call_id: String,
|
||||||
|
mode: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use wzp_proto::SignalMessage;
|
||||||
|
let sig = state.signal.lock().await;
|
||||||
|
let transport = sig.transport.as_ref().ok_or_else(|| {
|
||||||
|
tracing::warn!("answer_call: not registered (no transport)");
|
||||||
|
"not registered".to_string()
|
||||||
|
})?;
|
||||||
|
let accept_mode = match mode { 0 => wzp_proto::CallAcceptMode::Reject, 1 => wzp_proto::CallAcceptMode::AcceptTrusted, _ => wzp_proto::CallAcceptMode::AcceptGeneric };
|
||||||
|
tracing::info!(%call_id, ?accept_mode, "answer_call: sending DirectCallAnswer");
|
||||||
|
transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||||
|
call_id: call_id.clone(), accept_mode, identity_pub: None, ephemeral_pub: None, signature: None,
|
||||||
|
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||||
|
}).await.map_err(|e| {
|
||||||
|
tracing::error!(%call_id, error = %e, "answer_call: send_signal failed");
|
||||||
|
format!("{e}")
|
||||||
|
})?;
|
||||||
|
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
|
||||||
|
// Upgrade the pending "Missed" entry to "Received" if the user
|
||||||
|
// accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
|
||||||
|
if mode != 0 {
|
||||||
|
if history::mark_received_if_pending(&call_id) {
|
||||||
|
let _ = app.emit("history-changed", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
|
||||||
|
let sig = state.signal.lock().await;
|
||||||
|
Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the signal connection so the user goes back to idle. Called
|
||||||
|
/// when the user clicks "Deregister" on the direct-call screen. The
|
||||||
|
/// spawned recv loop will break out naturally when the transport closes.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||||
|
let mut sig = state.signal.lock().await;
|
||||||
|
if let Some(transport) = sig.transport.take() {
|
||||||
|
tracing::info!("deregister: closing signal transport");
|
||||||
|
transport.close().await.ok();
|
||||||
|
}
|
||||||
|
sig.endpoint = None;
|
||||||
|
sig.signal_status = "idle".into();
|
||||||
|
sig.incoming_call_id = None;
|
||||||
|
sig.incoming_caller_fp = None;
|
||||||
|
sig.incoming_caller_alias = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App entry point ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
|
||||||
|
/// entry point below.
|
||||||
|
pub fn run() {
|
||||||
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
engine: Mutex::new(None),
|
||||||
|
signal: Arc::new(Mutex::new(SignalState {
|
||||||
|
transport: None, endpoint: None, fingerprint: String::new(), signal_status: "idle".into(),
|
||||||
|
incoming_call_id: None, incoming_caller_fp: None, incoming_caller_alias: None,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(state)
|
||||||
|
.setup(|app| {
|
||||||
|
// Resolve the platform-correct app data dir once at startup so
|
||||||
|
// every command can read/write the seed without juggling AppHandle.
|
||||||
|
let data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map(|p| p.join(".wzp"))
|
||||||
|
.unwrap_or_else(|_| identity_dir());
|
||||||
|
// create_dir_all is a no-op if it already exists.
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
|
tracing::warn!("failed to create app data dir {data_dir:?}: {e}");
|
||||||
|
}
|
||||||
|
tracing::info!("app data dir: {data_dir:?}");
|
||||||
|
let _ = APP_DATA_DIR.set(data_dir);
|
||||||
|
|
||||||
|
// Load the standalone wzp-native cdylib (Oboe audio bridge) and
|
||||||
|
// cache its exported function pointers. The library handle is
|
||||||
|
// kept alive in a 'static OnceLock for the lifetime of the
|
||||||
|
// process, so CallEngine::start() can invoke its audio FFI
|
||||||
|
// from anywhere. See src/wzp_native.rs and the incident report
|
||||||
|
// in docs/incident-tauri-android-init-tcb.md.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
match wzp_native::init() {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
"wzp-native loaded: version={} msg=\"{}\"",
|
||||||
|
wzp_native::version(),
|
||||||
|
wzp_native::hello()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("wzp-native init failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
ping_relay, get_identity, get_app_info,
|
||||||
|
connect, disconnect, toggle_mic, toggle_speaker, get_status,
|
||||||
|
register_signal, place_call, answer_call, get_signal_status,
|
||||||
|
deregister,
|
||||||
|
set_speakerphone, is_speakerphone_on,
|
||||||
|
get_call_history, get_recent_contacts, clear_call_history,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running WarzonePhone");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri mobile entry point (Android/iOS). On desktop this is a no-op —
|
||||||
|
/// `main.rs` calls `run()` directly.
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn mobile_entry() {
|
||||||
|
run();
|
||||||
|
}
|
||||||
10
desktop/src-tauri/src/main.rs
Normal file
10
desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Desktop binary entry point. All logic lives in `lib.rs` so the same
|
||||||
|
// code can be built as a cdylib for Android/iOS via `cargo tauri android build`.
|
||||||
|
#![cfg_attr(
|
||||||
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
|
windows_subsystem = "windows"
|
||||||
|
)]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
wzp_desktop_lib::run();
|
||||||
|
}
|
||||||
138
desktop/src-tauri/src/wzp_native.rs
Normal file
138
desktop/src-tauri/src/wzp_native.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! Runtime binding to the standalone `wzp-native` cdylib.
|
||||||
|
//!
|
||||||
|
//! See `docs/incident-tauri-android-init-tcb.md` and the top of
|
||||||
|
//! `crates/wzp-native/src/lib.rs` for the full story on why this split
|
||||||
|
//! exists. Short version: Tauri's desktop cdylib cannot have any C++
|
||||||
|
//! compiled into it (via cc::Build) without landing in rust-lang/rust#104707's
|
||||||
|
//! staticlib symbol leak, which makes bionic's private `pthread_create`
|
||||||
|
//! symbols bind locally and SIGSEGV in `__init_tcb+4` at launch. So all
|
||||||
|
//! the Oboe + audio code lives in a standalone `wzp-native` .so built
|
||||||
|
//! with `cargo-ndk`, and we dlopen it here at runtime.
|
||||||
|
//!
|
||||||
|
//! The Library handle lives in a `'static` `OnceLock` for the lifetime of
|
||||||
|
//! the process; all function pointers cached below borrow from it safely.
|
||||||
|
|
||||||
|
#![cfg(target_os = "android")]
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
// ─── Library handle (kept alive forever) ─────────────────────────────────
|
||||||
|
|
||||||
|
static LIB: OnceLock<libloading::Library> = OnceLock::new();
|
||||||
|
|
||||||
|
// Cached function pointers, resolved once at init(). Each is a raw
|
||||||
|
// `extern "C"` fn pointer with effectively `'static` lifetime because
|
||||||
|
// LIB is a OnceLock that never drops.
|
||||||
|
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||||
|
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
|
||||||
|
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||||
|
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
|
||||||
|
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
|
||||||
|
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
|
||||||
|
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||||
|
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
||||||
|
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Load `libwzp_native.so` and resolve every exported function we use.
|
||||||
|
/// Call this once at app startup (from the Tauri `setup()` callback).
|
||||||
|
/// Subsequent calls are no-ops.
|
||||||
|
pub fn init() -> Result<(), String> {
|
||||||
|
if LIB.get().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the sibling cdylib. The Android dynamic linker searches
|
||||||
|
// /data/app/<pkg>/lib/arm64/ which gradle populates from jniLibs.
|
||||||
|
let lib = unsafe { libloading::Library::new("libwzp_native.so") }
|
||||||
|
.map_err(|e| format!("dlopen libwzp_native.so: {e}"))?;
|
||||||
|
|
||||||
|
// Stash the Library into the OnceLock first so all Symbol lookups
|
||||||
|
// below borrow from the 'static reference rather than a local.
|
||||||
|
LIB.set(lib).map_err(|_| "wzp_native::LIB already set")?;
|
||||||
|
let lib_ref: &'static libloading::Library = LIB.get().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
macro_rules! resolve {
|
||||||
|
($cell:expr, $ty:ty, $name:expr) => {{
|
||||||
|
let sym: libloading::Symbol<$ty> = lib_ref.get($name)
|
||||||
|
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?;
|
||||||
|
// Dereference the Symbol to extract the raw fn pointer;
|
||||||
|
// it stays valid because lib_ref is 'static.
|
||||||
|
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?;
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
|
||||||
|
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
|
||||||
|
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
|
||||||
|
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
|
||||||
|
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
|
||||||
|
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
|
||||||
|
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running");
|
||||||
|
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms");
|
||||||
|
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `init()` done and all symbols cached?
|
||||||
|
pub fn is_loaded() -> bool {
|
||||||
|
AUDIO_START.get().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Smoke-test accessors ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn version() -> i32 {
|
||||||
|
VERSION.get().map(|f| unsafe { f() }).unwrap_or(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hello() -> String {
|
||||||
|
let Some(f) = HELLO.get() else { return String::new(); };
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
|
||||||
|
String::from_utf8_lossy(&buf[..n]).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audio accessors ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Start the Oboe capture + playout streams. Returns `Err(code)` on
|
||||||
|
/// failure. Idempotent on the wzp-native side.
|
||||||
|
pub fn audio_start() -> Result<(), i32> {
|
||||||
|
let f = AUDIO_START.get().ok_or(-100_i32)?;
|
||||||
|
let ret = unsafe { f() };
|
||||||
|
if ret == 0 { Ok(()) } else { Err(ret) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop both streams. Safe to call even if not running.
|
||||||
|
pub fn audio_stop() {
|
||||||
|
if let Some(f) = AUDIO_STOP.get() {
|
||||||
|
unsafe { f() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read captured i16 PCM into `out`. Returns bytes actually copied.
|
||||||
|
pub fn audio_read_capture(out: &mut [i16]) -> usize {
|
||||||
|
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; };
|
||||||
|
unsafe { f(out.as_mut_ptr(), out.len()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write i16 PCM into the playout ring. Returns samples enqueued.
|
||||||
|
pub fn audio_write_playout(input: &[i16]) -> usize {
|
||||||
|
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; };
|
||||||
|
unsafe { f(input.as_ptr(), input.len()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audio_is_running() -> bool {
|
||||||
|
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn audio_capture_latency_ms() -> f32 {
|
||||||
|
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn audio_playout_latency_ms() -> f32 {
|
||||||
|
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
||||||
|
}
|
||||||
36
desktop/src-tauri/tauri.conf.json
Normal file
36
desktop/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"productName": "WarzonePhone",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.wzp.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "WarzonePhone",
|
||||||
|
"width": 400,
|
||||||
|
"height": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"minWidth": 360,
|
||||||
|
"minHeight": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png"
|
||||||
|
],
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
desktop/src/identicon.ts
Normal file
110
desktop/src/identicon.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic identicon generator — creates a unique symmetric pattern
|
||||||
|
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
|
||||||
|
*
|
||||||
|
* Returns an SVG data URL that can be used as an <img> src.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hashBytes(hex: string): number[] {
|
||||||
|
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < clean.length; i += 2) {
|
||||||
|
bytes.push(parseInt(clean.substring(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
// Pad to at least 16 bytes
|
||||||
|
while (bytes.length < 16) bytes.push(0);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const k = (n: number) => (n + h / 30) % 12;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) =>
|
||||||
|
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||||
|
return [
|
||||||
|
Math.round(f(0) * 255),
|
||||||
|
Math.round(f(8) * 255),
|
||||||
|
Math.round(f(4) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIdenticon(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36
|
||||||
|
): string {
|
||||||
|
const bytes = hashBytes(fingerprint);
|
||||||
|
|
||||||
|
// Derive colors from first bytes
|
||||||
|
const hue1 = (bytes[0] * 360) / 256;
|
||||||
|
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
|
||||||
|
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
|
||||||
|
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
|
||||||
|
|
||||||
|
const bg = `rgb(${r1},${g1},${b1})`;
|
||||||
|
const fg = `rgb(${r2},${g2},${b2})`;
|
||||||
|
|
||||||
|
// 5x5 grid, left-right symmetric (only need 3 columns)
|
||||||
|
const grid: boolean[][] = [];
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let x = 0; x < 3; x++) {
|
||||||
|
const byteIdx = 2 + y * 3 + x;
|
||||||
|
row.push(bytes[byteIdx % bytes.length] > 128);
|
||||||
|
}
|
||||||
|
// Mirror: col 3 = col 1, col 4 = col 0
|
||||||
|
grid.push([row[0], row[1], row[2], row[1], row[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render SVG
|
||||||
|
const cellSize = size / 5;
|
||||||
|
const r = size * 0.12; // border radius
|
||||||
|
let rects = "";
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
if (grid[y][x]) {
|
||||||
|
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
|
||||||
|
${rects}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an <img> element with the identicon.
|
||||||
|
* Click copies the fingerprint to clipboard.
|
||||||
|
*/
|
||||||
|
export function createIdenticonEl(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36,
|
||||||
|
clickToCopy: boolean = true
|
||||||
|
): HTMLImageElement {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = generateIdenticon(fingerprint, size);
|
||||||
|
img.width = size;
|
||||||
|
img.height = size;
|
||||||
|
img.style.borderRadius = `${size * 0.12}px`;
|
||||||
|
img.style.cursor = clickToCopy ? "pointer" : "default";
|
||||||
|
img.title = fingerprint;
|
||||||
|
|
||||||
|
if (clickToCopy && fingerprint) {
|
||||||
|
img.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||||
|
img.style.outline = "2px solid #4ade80";
|
||||||
|
setTimeout(() => {
|
||||||
|
img.style.outline = "";
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return img;
|
||||||
|
}
|
||||||
1038
desktop/src/main.ts
Normal file
1038
desktop/src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
1031
desktop/src/style.css
Normal file
1031
desktop/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
15
desktop/tsconfig.json
Normal file
15
desktop/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
desktop/vite.config.ts
Normal file
15
desktop/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
747
docs/ADMINISTRATION.md
Normal file
747
docs/ADMINISTRATION.md
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
# WarzonePhone Relay Administration Guide
|
||||||
|
|
||||||
|
This document covers deploying, configuring, and operating wzp-relay instances, including federation setup, monitoring, and troubleshooting.
|
||||||
|
|
||||||
|
## Relay Deployment
|
||||||
|
|
||||||
|
### Binary
|
||||||
|
|
||||||
|
Build and run the relay directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build release binary
|
||||||
|
cargo build --release --bin wzp-relay
|
||||||
|
|
||||||
|
# Run with defaults (listen on 0.0.0.0:4433, room mode, no auth)
|
||||||
|
./target/release/wzp-relay
|
||||||
|
|
||||||
|
# Run with config file
|
||||||
|
./target/release/wzp-relay --config /etc/wzp/relay.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Build (Linux)
|
||||||
|
|
||||||
|
The included build script provisions a temporary Hetzner Cloud VPS, builds all binaries, and downloads them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires: hcloud CLI authenticated, SSH key "wz" registered
|
||||||
|
./scripts/build-linux.sh
|
||||||
|
# Outputs to: target/linux-x86_64/
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces: `wzp-relay`, `wzp-client`, `wzp-client-audio`, `wzp-web`, `wzp-bench`.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM rust:1.85 AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --bin wzp-relay
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /src/target/release/wzp-relay /usr/local/bin/
|
||||||
|
EXPOSE 4433/udp
|
||||||
|
EXPOSE 9090/tcp
|
||||||
|
VOLUME /data
|
||||||
|
ENV HOME=/data
|
||||||
|
ENTRYPOINT ["wzp-relay"]
|
||||||
|
CMD ["--config", "/data/relay.toml", "--metrics-port", "9090"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t wzp-relay .
|
||||||
|
docker run -d \
|
||||||
|
--name wzp-relay \
|
||||||
|
-p 4433:4433/udp \
|
||||||
|
-p 9090:9090/tcp \
|
||||||
|
-v /opt/wzp:/data \
|
||||||
|
wzp-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/wzp-relay.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=WarzonePhone Relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=wzp
|
||||||
|
Group=wzp
|
||||||
|
ExecStart=/usr/local/bin/wzp-relay --config /etc/wzp/relay.toml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/lib/wzp
|
||||||
|
PrivateTmp=yes
|
||||||
|
|
||||||
|
Environment=HOME=/var/lib/wzp
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create service user
|
||||||
|
useradd --system --home-dir /var/lib/wzp --create-home wzp
|
||||||
|
|
||||||
|
# Install binary and config
|
||||||
|
cp target/release/wzp-relay /usr/local/bin/
|
||||||
|
mkdir -p /etc/wzp
|
||||||
|
cp relay.toml /etc/wzp/
|
||||||
|
|
||||||
|
# Enable and start
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now wzp-relay
|
||||||
|
journalctl -u wzp-relay -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## TOML Configuration Reference
|
||||||
|
|
||||||
|
All fields have defaults. A minimal config file only needs the fields you want to override.
|
||||||
|
|
||||||
|
### Core Settings
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `listen_addr` | string (socket addr) | `"0.0.0.0:4433"` | UDP address to listen on for incoming QUIC connections |
|
||||||
|
| `remote_relay` | string (socket addr) | none | Remote relay address for forward mode. Disables room mode when set |
|
||||||
|
| `max_sessions` | integer | `100` | Maximum concurrent client sessions |
|
||||||
|
| `log_level` | string | `"info"` | Logging level: trace, debug, info, warn, error |
|
||||||
|
|
||||||
|
### Jitter Buffer
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `jitter_target_depth` | integer | `50` | Target buffer depth in packets (50 = 1 second at 20ms frames) |
|
||||||
|
| `jitter_max_depth` | integer | `250` | Maximum buffer depth in packets (250 = 5 seconds) |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `auth_url` | string | none | featherChat auth validation URL. When set, clients must send a bearer token as their first signal message. The relay validates it via `POST <auth_url>` |
|
||||||
|
|
||||||
|
### Metrics and Monitoring
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `metrics_port` | integer | none | Port for the Prometheus HTTP metrics endpoint. Disabled if not set |
|
||||||
|
| `probe_targets` | array of socket addrs | `[]` | Peer relay addresses to probe for health monitoring (1 Ping/s each) |
|
||||||
|
| `probe_mesh` | boolean | `false` | Enable mesh mode for probe targets |
|
||||||
|
|
||||||
|
### Media Processing
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `trunking_enabled` | boolean | `false` | Enable trunk batching for outgoing media. Packs multiple session packets into one QUIC datagram, reducing overhead |
|
||||||
|
|
||||||
|
### WebSocket / Browser Support
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `ws_port` | integer | none | Port for WebSocket listener (browser clients). Disabled if not set |
|
||||||
|
| `static_dir` | string | none | Directory to serve static files (HTML/JS/WASM) |
|
||||||
|
|
||||||
|
### Federation
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `peers` | array of PeerConfig | `[]` | Outbound federation peer relays |
|
||||||
|
| `trusted` | array of TrustedConfig | `[]` | Inbound federation trust list |
|
||||||
|
| `global_rooms` | array of GlobalRoomConfig | `[]` | Room names to bridge across federation |
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `debug_tap` | string | none | Log packet headers for matching rooms. Use `"*"` for all rooms, or a specific room name |
|
||||||
|
|
||||||
|
### PeerConfig Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `url` | string | yes | Address of the peer relay (e.g., `"193.180.213.68:4433"`) |
|
||||||
|
| `fingerprint` | string | yes | Expected TLS certificate fingerprint (hex with colons) |
|
||||||
|
| `label` | string | no | Human-readable label for logging |
|
||||||
|
|
||||||
|
### TrustedConfig Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `fingerprint` | string | yes | Expected TLS certificate fingerprint (hex with colons) |
|
||||||
|
| `label` | string | no | Human-readable label for logging |
|
||||||
|
|
||||||
|
### GlobalRoomConfig Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | yes | Room name to bridge across federation (e.g., `"android"`) |
|
||||||
|
|
||||||
|
## CLI Flags Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>]
|
||||||
|
[--auth-url <url>] [--metrics-port <port>]
|
||||||
|
[--probe <addr>]... [--probe-mesh] [--mesh-status]
|
||||||
|
[--trunking] [--global-room <name>]...
|
||||||
|
[--debug-tap <room>]
|
||||||
|
[--ws-port <port>] [--static-dir <dir>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--config <path>` | Load configuration from TOML file. CLI flags override config file values |
|
||||||
|
| `--listen <addr>` | Listen address (default: `0.0.0.0:4433`) |
|
||||||
|
| `--remote <addr>` | Remote relay for forwarding mode. Disables room mode |
|
||||||
|
| `--auth-url <url>` | featherChat auth endpoint (e.g., `https://chat.example.com/v1/auth/validate`) |
|
||||||
|
| `--metrics-port <port>` | Prometheus metrics HTTP port (e.g., `9090`) |
|
||||||
|
| `--probe <addr>` | Peer relay to probe for health monitoring. Repeatable |
|
||||||
|
| `--probe-mesh` | Enable mesh mode for probes |
|
||||||
|
| `--mesh-status` | Print mesh health table and exit (diagnostic) |
|
||||||
|
| `--trunking` | Enable trunk batching for outgoing media |
|
||||||
|
| `--global-room <name>` | Declare a room as global (bridged across federation). Repeatable |
|
||||||
|
| `--debug-tap <room>` | Log packet headers for a room (`"*"` for all rooms) |
|
||||||
|
| `--event-log <path>` | Write JSONL protocol event log for federation debugging |
|
||||||
|
| `--version`, `-V` | Print build git hash and exit |
|
||||||
|
| `--ws-port <port>` | WebSocket listener port for browser clients |
|
||||||
|
| `--static-dir <dir>` | Directory to serve static files from |
|
||||||
|
| `--help`, `-h` | Print help and exit |
|
||||||
|
|
||||||
|
CLI flags always override config file values when both are specified.
|
||||||
|
|
||||||
|
## Federation Setup
|
||||||
|
|
||||||
|
### Concepts
|
||||||
|
|
||||||
|
- **`[[peers]]`** -- outbound: relays we connect TO. Requires address + fingerprint
|
||||||
|
- **`[[trusted]]`** -- inbound: relays we accept connections FROM. Requires fingerprint only (they connect to us)
|
||||||
|
- **`[[global_rooms]]`** -- rooms bridged across all federated peers. Participants on different relays in the same global room hear each other
|
||||||
|
|
||||||
|
### Getting Your Relay's Fingerprint
|
||||||
|
|
||||||
|
When a relay starts, it logs its TLS fingerprint:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO TLS certificate (deterministic from relay identity) tls_fingerprint="a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
|
||||||
|
INFO federation: to peer with this relay, add to relay.toml:
|
||||||
|
INFO [[peers]]
|
||||||
|
INFO url = "193.180.213.68:4433"
|
||||||
|
INFO fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
|
||||||
|
```
|
||||||
|
|
||||||
|
Share this information with the administrator of the peer relay.
|
||||||
|
|
||||||
|
### Unknown Peer Connections
|
||||||
|
|
||||||
|
When an unknown relay tries to federate, the log shows:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARN unknown relay wants to federate addr=10.0.0.5:12345 fp="7f2a:b391:0c44:..."
|
||||||
|
INFO to accept, add to relay.toml:
|
||||||
|
INFO [[trusted]]
|
||||||
|
INFO fingerprint = "7f2a:b391:0c44:..."
|
||||||
|
INFO label = "Relay at 10.0.0.5:12345"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Configurations
|
||||||
|
|
||||||
|
### Single Relay (Minimal)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# /etc/wzp/relay.toml
|
||||||
|
# Minimal config -- all defaults, just enable metrics
|
||||||
|
metrics_port = 9090
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-relay --config /etc/wzp/relay.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Relay (Full Featured)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# /etc/wzp/relay.toml
|
||||||
|
listen_addr = "0.0.0.0:4433"
|
||||||
|
max_sessions = 200
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
metrics_port = 9090
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth_url = "https://chat.example.com/v1/auth/validate"
|
||||||
|
|
||||||
|
# Browser support
|
||||||
|
ws_port = 8080
|
||||||
|
static_dir = "/opt/wzp/web"
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
trunking_enabled = true
|
||||||
|
|
||||||
|
# Jitter buffer tuning
|
||||||
|
jitter_target_depth = 50
|
||||||
|
jitter_max_depth = 250
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-Relay Federation
|
||||||
|
|
||||||
|
**Relay A** (`relay-a.toml` on 193.180.213.68):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "0.0.0.0:4433"
|
||||||
|
metrics_port = 9090
|
||||||
|
|
||||||
|
# Outbound: connect to Relay B
|
||||||
|
[[peers]]
|
||||||
|
url = "10.0.0.5:4433"
|
||||||
|
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
|
||||||
|
label = "Relay B (US)"
|
||||||
|
|
||||||
|
# Accept inbound from Relay B
|
||||||
|
[[trusted]]
|
||||||
|
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
|
||||||
|
label = "Relay B (US)"
|
||||||
|
|
||||||
|
# Bridge these rooms
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "android"
|
||||||
|
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "general"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Relay B** (`relay-b.toml` on 10.0.0.5):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "0.0.0.0:4433"
|
||||||
|
metrics_port = 9090
|
||||||
|
|
||||||
|
# Outbound: connect to Relay A
|
||||||
|
[[peers]]
|
||||||
|
url = "193.180.213.68:4433"
|
||||||
|
fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
|
||||||
|
label = "Relay A (EU)"
|
||||||
|
|
||||||
|
# Accept inbound from Relay A
|
||||||
|
[[trusted]]
|
||||||
|
fingerprint = "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
|
||||||
|
label = "Relay A (EU)"
|
||||||
|
|
||||||
|
# Same global rooms
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "android"
|
||||||
|
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "general"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three-Relay Chain (Full Mesh)
|
||||||
|
|
||||||
|
For three relays (A, B, C) in full mesh federation, each relay needs peers and trusted entries for the other two:
|
||||||
|
|
||||||
|
**Relay A** (EU):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "0.0.0.0:4433"
|
||||||
|
metrics_port = 9090
|
||||||
|
|
||||||
|
# Probe all peers
|
||||||
|
probe_targets = ["10.0.0.5:4433", "10.0.0.9:4433"]
|
||||||
|
probe_mesh = true
|
||||||
|
|
||||||
|
# Peers
|
||||||
|
[[peers]]
|
||||||
|
url = "10.0.0.5:4433"
|
||||||
|
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
|
||||||
|
label = "Relay B (US)"
|
||||||
|
|
||||||
|
[[peers]]
|
||||||
|
url = "10.0.0.9:4433"
|
||||||
|
fingerprint = "3c8e:d2a1:f7b5:6049:81c3:e9d4:a2f6:5678"
|
||||||
|
label = "Relay C (APAC)"
|
||||||
|
|
||||||
|
# Trust
|
||||||
|
[[trusted]]
|
||||||
|
fingerprint = "7f2a:b391:0c44:9e1d:a8b2:c5d7:e3f0:1234"
|
||||||
|
label = "Relay B (US)"
|
||||||
|
|
||||||
|
[[trusted]]
|
||||||
|
fingerprint = "3c8e:d2a1:f7b5:6049:81c3:e9d4:a2f6:5678"
|
||||||
|
label = "Relay C (APAC)"
|
||||||
|
|
||||||
|
# Global rooms
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "android"
|
||||||
|
|
||||||
|
[[global_rooms]]
|
||||||
|
name = "general"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Relay B** and **Relay C** follow the same pattern, listing the other two relays in their `[[peers]]` and `[[trusted]]` sections.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Prometheus Metrics
|
||||||
|
|
||||||
|
Enable with `--metrics-port <port>` or `metrics_port` in TOML. The relay exposes metrics at `GET /metrics` on the specified HTTP port.
|
||||||
|
|
||||||
|
#### Relay Metrics
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Description |
|
||||||
|
|--------|------|--------|-------------|
|
||||||
|
| `wzp_relay_active_sessions` | Gauge | -- | Current active sessions |
|
||||||
|
| `wzp_relay_active_rooms` | Gauge | -- | Current active rooms |
|
||||||
|
| `wzp_relay_packets_forwarded_total` | Counter | `room` | Total packets forwarded |
|
||||||
|
| `wzp_relay_bytes_forwarded_total` | Counter | `room` | Total bytes forwarded |
|
||||||
|
| `wzp_relay_auth_attempts_total` | Counter | `result` (ok/fail) | Auth validation attempts |
|
||||||
|
| `wzp_relay_handshake_duration_seconds` | Histogram | -- | Crypto handshake time |
|
||||||
|
|
||||||
|
#### Per-Session Metrics
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Description |
|
||||||
|
|--------|------|--------|-------------|
|
||||||
|
| `wzp_relay_session_jitter_buffer_depth` | Gauge | `session_id` | Buffer depth per session |
|
||||||
|
| `wzp_relay_session_loss_pct` | Gauge | `session_id` | Packet loss percentage |
|
||||||
|
| `wzp_relay_session_rtt_ms` | Gauge | `session_id` | Round-trip time |
|
||||||
|
| `wzp_relay_session_underruns_total` | Counter | `session_id` | Jitter buffer underruns |
|
||||||
|
| `wzp_relay_session_overruns_total` | Counter | `session_id` | Jitter buffer overruns |
|
||||||
|
|
||||||
|
#### Inter-Relay Probe Metrics
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Description |
|
||||||
|
|--------|------|--------|-------------|
|
||||||
|
| `wzp_probe_rtt_ms` | Gauge | `target` | RTT to peer relay |
|
||||||
|
| `wzp_probe_loss_pct` | Gauge | `target` | Loss to peer relay |
|
||||||
|
| `wzp_probe_jitter_ms` | Gauge | `target` | Jitter to peer relay |
|
||||||
|
| `wzp_probe_up` | Gauge | `target` | 1 if reachable, 0 if not |
|
||||||
|
|
||||||
|
### Prometheus Scrape Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# prometheus.yml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'wzp-relay'
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'relay-a:9090'
|
||||||
|
- 'relay-b:9090'
|
||||||
|
scrape_interval: 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grafana Dashboard
|
||||||
|
|
||||||
|
A pre-built dashboard is available at `docs/grafana-dashboard.json`. Import it into Grafana for:
|
||||||
|
|
||||||
|
1. **Relay Health** -- active sessions, rooms, packets/s, bytes/s
|
||||||
|
2. **Call Quality** -- per-session jitter depth, loss%, RTT, underruns over time
|
||||||
|
3. **Inter-Relay Mesh** -- latency heatmap, probe status, loss trends
|
||||||
|
4. **Web Bridge** -- active connections, frames bridged, auth failures
|
||||||
|
|
||||||
|
### Event Log (Protocol Analyzer)
|
||||||
|
|
||||||
|
Use `--event-log` to write a JSONL event log that traces every federation media packet through the relay pipeline. Essential for debugging federation audio issues.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-relay --config relay.toml --event-log /tmp/events.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Each media packet emits events at every decision point:
|
||||||
|
- `federation_ingress` — packet arrived from a peer relay
|
||||||
|
- `local_deliver` — packet delivered to local participants
|
||||||
|
- `dedup_drop` — packet dropped as duplicate
|
||||||
|
- `rate_limit_drop` — packet dropped by rate limiter
|
||||||
|
- `room_not_found` — packet for unknown room
|
||||||
|
- `local_deliver_error` — delivery to local client failed
|
||||||
|
|
||||||
|
Analyze with:
|
||||||
|
```bash
|
||||||
|
# Count events by type
|
||||||
|
cat events.jsonl | python3 -c "
|
||||||
|
import json, collections, sys
|
||||||
|
c = collections.Counter()
|
||||||
|
for l in sys.stdin: c[json.loads(l)['event']] += 1
|
||||||
|
for k,v in sorted(c.items(), key=lambda x:-x[1]): print(f' {k}: {v}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Version Check
|
||||||
|
|
||||||
|
Verify a deployed relay's version without SSH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-client --version-check <relay-addr:port>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Tap
|
||||||
|
|
||||||
|
Use `--debug-tap` to log packet headers for debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Log headers for room "android"
|
||||||
|
wzp-relay --debug-tap android
|
||||||
|
|
||||||
|
# Log headers for all rooms
|
||||||
|
wzp-relay --debug-tap '*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in TOML:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
debug_tap = "android"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mesh Status
|
||||||
|
|
||||||
|
Print the current mesh health table (diagnostic):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wzp-relay --mesh-status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### featherChat Token Validation
|
||||||
|
|
||||||
|
When `--auth-url` is set, the relay requires clients to send an `AuthToken` signal message as their first message after QUIC connection. The relay validates the token by calling:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST <auth_url>
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"fingerprint": "a5d6:e3c6:...",
|
||||||
|
"alias": "username"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If validation fails, the client is disconnected.
|
||||||
|
|
||||||
|
### Without Authentication
|
||||||
|
|
||||||
|
When `--auth-url` is not set, any client can connect. The relay logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO auth disabled -- any client can connect (use --auth-url to enable)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Identity Persistence
|
||||||
|
|
||||||
|
### Relay Identity File
|
||||||
|
|
||||||
|
The relay stores its identity seed at `~/.wzp/relay-identity` (a 64-character hex string). This seed:
|
||||||
|
|
||||||
|
- Is generated automatically on first run
|
||||||
|
- Persists across restarts
|
||||||
|
- Derives the relay's Ed25519 signing key and X25519 key agreement key
|
||||||
|
- Derives the TLS certificate deterministically (same seed = same cert = same fingerprint)
|
||||||
|
|
||||||
|
If the identity file is corrupted, the relay generates a new one and logs a warning. This will change the relay's TLS fingerprint, requiring federation peers to update their config.
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
Back up the identity file to preserve the relay's fingerprint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ~/.wzp/relay-identity /secure/backup/relay-identity
|
||||||
|
```
|
||||||
|
|
||||||
|
To restore, copy the file back before starting the relay.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Problem | Cause | Solution |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| "unknown argument" on startup | Unrecognized CLI flag | Check `wzp-relay --help` for valid flags |
|
||||||
|
| "failed to load config" | Invalid TOML syntax | Validate TOML file with `toml-cli` or similar |
|
||||||
|
| "auth failed" for all clients | Wrong `auth_url` or featherChat server down | Verify URL is reachable: `curl -X POST <auth_url>` |
|
||||||
|
| "session rejected" | Max sessions reached | Increase `max_sessions` in config |
|
||||||
|
| Clients cannot connect | Firewall blocking UDP 4433 | Open UDP port 4433 in firewall |
|
||||||
|
| Federation "unknown relay wants to federate" | Peer's fingerprint not in `[[trusted]]` | Add the logged fingerprint to `[[trusted]]` |
|
||||||
|
| Federation "fingerprint mismatch" | Peer relay restarted with new identity | Update the fingerprint in `[[peers]]` config |
|
||||||
|
| Federation audio silent on consecutive connects | Dedup filter or jitter buffer state | Verify relay is running latest build with time-based dedup |
|
||||||
|
| Federation participant shows wrong relay label | Hub relay not propagating original labels | Update relay to latest build (label preservation fix) |
|
||||||
|
| Federation disconnect takes >15 seconds | QUIC idle timeout + stale sweeper | Normal: sweeper runs every 5s with 15s TTL. Use latest client with SIGTERM handler for instant disconnect |
|
||||||
|
| High packet loss between relays | Network congestion or misconfiguration | Check `wzp_probe_loss_pct` metric; consider relay chaining |
|
||||||
|
| Jitter buffer overruns | Packets arriving faster than playout | Increase `jitter_max_depth` |
|
||||||
|
| Jitter buffer underruns | Packets arriving too slowly or lost | Check network quality; increase `jitter_target_depth` |
|
||||||
|
| "probe connection closed" | Peer relay unreachable or crashed | Check peer relay status; will auto-reconnect |
|
||||||
|
| WebSocket clients cannot connect | `ws_port` not set | Add `--ws-port <port>` or `ws_port` in TOML |
|
||||||
|
| Browser mic access denied | Not using HTTPS | Use TLS termination in front of the relay or serve via `wzp-web --tls` |
|
||||||
|
|
||||||
|
### Log Level Tuning
|
||||||
|
|
||||||
|
Set `RUST_LOG` environment variable for fine-grained control:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All relay logs at debug level
|
||||||
|
RUST_LOG=debug wzp-relay
|
||||||
|
|
||||||
|
# Only federation at trace, everything else at info
|
||||||
|
RUST_LOG=info,wzp_relay::federation=trace wzp-relay
|
||||||
|
|
||||||
|
# Quiet mode -- only warnings and errors
|
||||||
|
RUST_LOG=warn wzp-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if relay is listening
|
||||||
|
nc -zu relay-host 4433
|
||||||
|
|
||||||
|
# Check metrics endpoint
|
||||||
|
curl -s http://relay-host:9090/metrics | head -20
|
||||||
|
|
||||||
|
# Check active sessions
|
||||||
|
curl -s http://relay-host:9090/metrics | grep wzp_relay_active_sessions
|
||||||
|
|
||||||
|
# Check federation probe health
|
||||||
|
curl -s http://relay-host:9090/metrics | grep wzp_probe_up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Pipelines
|
||||||
|
|
||||||
|
All production artifacts (Android APK, Linux x86_64 binaries, Windows `.exe`) are built on **SepehrHomeserverdk** using Docker, not on developer workstations. The pipelines are fire-and-forget: a local script invokes a `tmux` session on the remote, the build runs in a Docker container, and the artifact is uploaded to `paste.dk.manko.yoga` (rustypaste) with a notification sent to `ntfy.sh/wzp` on start and completion.
|
||||||
|
|
||||||
|
### Docker images
|
||||||
|
|
||||||
|
Two long-lived images live on the remote:
|
||||||
|
|
||||||
|
| Image | Used by | Base | Key contents |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `wzp-android-builder` | Android APK (Tauri mobile + legacy Kotlin), Linux x86_64 relay/CLI | Debian bookworm | Rust stable with Android targets, cargo-ndk, NDK 26.1, Android SDK (API 34 + 35 + 36), JDK 17, Gradle 8.5, Node.js 20, cmake, ninja, tauri-cli 2.x |
|
||||||
|
| `wzp-windows-builder` | Windows x86_64 `.exe` | Debian bookworm | Rust stable with `x86_64-pc-windows-msvc` target, cargo-xwin (with pre-warmed MSVC CRT + Windows SDK cache), Node.js 20, cmake, ninja, clang, lld, nasm |
|
||||||
|
|
||||||
|
Both images are rebuilt rarely — once the base toolchain is stable, rebuilds are only needed to pick up new dependencies or security patches.
|
||||||
|
|
||||||
|
**Rebuilding an image** (fire-and-forget, ~10 min on a warm base):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
./scripts/build-windows-docker.sh --image-build
|
||||||
|
|
||||||
|
# Android (upload and rebuild handled by the Android build script itself — see
|
||||||
|
# its --image-build flag or equivalent)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--image-build` flag uploads the local Dockerfile to the remote, kicks off `docker build` under `nohup`, and returns immediately. Monitor with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh SepehrHomeserverdk 'tail -f /tmp/wzp-windows-image-build.log'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipeline: Android APK (Tauri Mobile)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-tauri-android.sh # Full: pull + build + upload + notify
|
||||||
|
./scripts/build-tauri-android.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-tauri-android.sh --clean # Force-clean Rust target
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `android-rewrite`
|
||||||
|
- **Image**: `wzp-android-builder`
|
||||||
|
- **Build command**: `cargo tauri android build --release`
|
||||||
|
- **Output**: `wzp-release.apk` → uploaded to rustypaste
|
||||||
|
- **Notifications**: start + completion to `ntfy.sh/wzp`
|
||||||
|
- **Remote artifact path**: `/mnt/storage/manBuilder/data/cache-android/target/…/release/app-release.apk`
|
||||||
|
|
||||||
|
### Pipeline: Linux x86_64 (relay + CLI + bench + web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-linux-docker.sh # Fire-and-forget
|
||||||
|
./scripts/build-linux-docker.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-linux-docker.sh --clean # Force-clean target
|
||||||
|
./scripts/build-linux-docker.sh --install # Wait for completion and download locally
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `feat/android-voip-client` (script default — override by editing the script or passing an env var)
|
||||||
|
- **Image**: `wzp-android-builder` (shared, not a separate Linux-only image)
|
||||||
|
- **Targets built**: `wzp-relay`, `wzp-client`, `wzp-client-audio` (with `--features audio`), `wzp-web`, `wzp-bench`
|
||||||
|
- **Output**: `wzp-linux-x86_64.tar.gz` with all five binaries → uploaded to rustypaste
|
||||||
|
- **Local landing dir** (with `--install`): `target/linux-x86_64/`
|
||||||
|
|
||||||
|
### Pipeline: Windows x86_64 (`wzp-desktop.exe`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-docker.sh # Full: pull + build + download locally
|
||||||
|
./scripts/build-windows-docker.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-windows-docker.sh --rust # Force-clean target-windows cache
|
||||||
|
./scripts/build-windows-docker.sh --image-build # Rebuild the Docker image (fire-and-forget)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `feat/desktop-audio-rewrite`
|
||||||
|
- **Image**: `wzp-windows-builder`
|
||||||
|
- **Build command**: `cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop`
|
||||||
|
- **Output**: `wzp-desktop.exe` (~16 MB) → downloaded to `target/windows-exe/wzp-desktop.exe`, also uploaded to rustypaste
|
||||||
|
- **Target cache volume**: `target-windows` (separate from the Android target cache to avoid triple cross-contamination)
|
||||||
|
- **Shared cache volumes**: `cargo-registry`, `cargo-git` (shared with Android — both pipelines pull the same crates)
|
||||||
|
|
||||||
|
**A/B-preserving workflow** for testing audio backends: rename the prior `.exe` before re-running the build, so both coexist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preserve prior build as the noAEC baseline
|
||||||
|
mv target/windows-exe/wzp-desktop.exe target/windows-exe/wzp-desktop-noAEC.exe
|
||||||
|
./scripts/build-windows-docker.sh
|
||||||
|
ls -la target/windows-exe/
|
||||||
|
# wzp-desktop-noAEC.exe (previous build)
|
||||||
|
# wzp-desktop.exe (new build)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative pipeline: Windows via Hetzner Cloud VPS
|
||||||
|
|
||||||
|
For situations where Docker image rebuilds would be disruptive, or for one-shot debug builds on a clean machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
|
||||||
|
./scripts/build-windows-cloud.sh --prepare # Create VM + install deps, don't build
|
||||||
|
./scripts/build-windows-cloud.sh --build # Build on existing VM
|
||||||
|
./scripts/build-windows-cloud.sh --transfer # Download .exe from existing VM
|
||||||
|
./scripts/build-windows-cloud.sh --destroy # Delete the VM
|
||||||
|
WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Don't auto-destroy after successful build
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Provider**: Hetzner Cloud
|
||||||
|
- **Default server type**: `cx33` (8 GB RAM, 8 vCPU — `cx23` with 4 GB OOMs on the tauri+rustls cross-compile)
|
||||||
|
- **Image**: `ubuntu-24.04`
|
||||||
|
- **SSH key**: must be named `wz` in Hetzner and loaded in the local ssh-agent
|
||||||
|
- **Reminder**: set `WZP_KEEP_VM=1` for multi-build sessions, then **remember to `--destroy` at end of day** so the VM isn't left running overnight. This is tracked in the auto-memory as `feedback_keep_windows_builder_vm.md`.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
All pipelines post to `https://ntfy.sh/wzp`. Subscribe from your phone via the [ntfy.sh app](https://ntfy.sh/) to get push notifications on build start/success/failure. Messages include the short git hash and the rustypaste URL on success:
|
||||||
|
|
||||||
|
```
|
||||||
|
WZP Windows build OK [03a80a3] (16M)
|
||||||
|
https://paste.dk.manko.yoga/<uuid>/wzp-desktop.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rustypaste credentials
|
||||||
|
|
||||||
|
Build pipelines read `rusty_address` and `rusty_auth_token` from the `.env` file at `/mnt/storage/manBuilder/.env` on SepehrHomeserverdk. Local scripts that upload directly (`build-windows-cloud.sh` when run in `--transfer` mode) read from `~/.wzp/rustypaste.env` with the same variable names. Both files must be kept in sync manually if rotated.
|
||||||
File diff suppressed because it is too large
Load Diff
164
docs/BRANCH-desktop-audio-rewrite.md
Normal file
164
docs/BRANCH-desktop-audio-rewrite.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Branch: `feat/desktop-audio-rewrite`
|
||||||
|
|
||||||
|
Home of the Tauri desktop client for macOS, Windows, and Linux. Named "audio-rewrite" because the original driver was replacing a CPAL-only audio pipeline with platform-native backends that support OS-level echo cancellation (VoiceProcessingIO on macOS, WASAPI Communications on Windows), but the branch has grown into the full desktop story — Windows cross-compilation, vendored dependencies, history UI, direct calling, the whole thing.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The desktop client shares 100% of its frontend (`desktop/src/`) and Tauri command layer (`desktop/src-tauri/src/lib.rs`, `engine.rs`, `history.rs`) with the Android build on `android-rewrite`. Differences are limited to:
|
||||||
|
|
||||||
|
- **Audio backends**, which are platform-gated via Cargo target-dep sections in `desktop/src-tauri/Cargo.toml` and feature flags in `crates/wzp-client/Cargo.toml`.
|
||||||
|
- **Identity storage paths**, which resolve via Tauri's `app_data_dir()` (`~/Library/Application Support/…` on macOS, `%APPDATA%\…` on Windows, `~/.local/share/…` on Linux).
|
||||||
|
- **Build toolchains**: native `cargo build` on macOS/Linux, `cargo xwin` cross-compile from Linux for Windows via Docker on SepehrHomeserverdk.
|
||||||
|
|
||||||
|
## Audio backend matrix
|
||||||
|
|
||||||
|
| Target | Capture | Playback | AEC |
|
||||||
|
|---|---|---|---|
|
||||||
|
| macOS | CPAL (WASAPI/CoreAudio via cpal crate) OR VoiceProcessingIO (native Core Audio) | CPAL | VoiceProcessingIO native AEC (when `vpio` feature enabled) |
|
||||||
|
| Windows (default) | CPAL → WASAPI shared mode | CPAL → WASAPI shared mode | None |
|
||||||
|
| Windows (AEC build) | Direct WASAPI with `IAudioClient2::SetClientProperties(AudioCategory_Communications)` | CPAL → WASAPI shared mode | **OS-level**: Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC) |
|
||||||
|
| Linux | CPAL → ALSA/PulseAudio | CPAL → ALSA/PulseAudio | None |
|
||||||
|
|
||||||
|
The macOS VPIO path is gated behind the `vpio` feature in `wzp-client` and the `coreaudio-rs` dep is itself `cfg(target_os = "macos")`, so enabling the feature on Windows or Linux is a no-op.
|
||||||
|
|
||||||
|
The Windows AEC path is gated behind the `windows-aec` feature, also target-gated (the `windows` crate dep is only pulled in on Windows), and re-exports `WasapiAudioCapture as AudioCapture` when enabled so downstream code doesn't need to know which backend is active. The current Windows build at `target/windows-exe/wzp-desktop.exe` has `windows-aec` on; a baseline noAEC build is preserved at `target/windows-exe/wzp-desktop-noAEC.exe` for A/B comparison on real hardware.
|
||||||
|
|
||||||
|
See [`BRANCH-android-rewrite.md`](BRANCH-android-rewrite.md) for Oboe audio on Android, which is its own story.
|
||||||
|
|
||||||
|
## Recent major work
|
||||||
|
|
||||||
|
### 1. Desktop direct calling feature (commit `2fd9465` and neighbors)
|
||||||
|
|
||||||
|
Brought direct 1:1 calls to macOS with full parity to the Android client:
|
||||||
|
|
||||||
|
- **Identity path fix**: the desktop `CallEngine::start` was loading seed from `$HOME/.wzp/identity` while `register_signal` used Tauri's `app_data_dir()`, producing two different fingerprints per run. Both now route through `load_or_create_seed()` which uses `app_data_dir()` everywhere.
|
||||||
|
- **Call history with dedup**: `history.rs` stores a `Vec<CallHistoryEntry>` with a `CallDirection` enum (`Placed | Received | Missed`). The `log` function dedupes by `call_id` so an outgoing call isn't logged twice as "missed" (when the signal loop's `DirectCallOffer` handler fires) and then again as "placed" (when `place_call` returns). Instead the entry is updated in place.
|
||||||
|
- **Recent contacts row**: a horizontal chip UI in the direct-call panel showing the last N peers with friendly aliases, clickable to re-dial.
|
||||||
|
- **Deregister button**: lets a user drop their signal registration without quitting the app, useful when switching identities.
|
||||||
|
- **Random alias derivation**: a new client sees a human-friendly alias like "silent-forest-41" derived deterministically from its seed, so it's identifiable in the UI before manual naming.
|
||||||
|
- **Default room "general"** instead of "android", since the desktop client is not Android.
|
||||||
|
|
||||||
|
### 2. macOS VoiceProcessingIO integration
|
||||||
|
|
||||||
|
`crates/wzp-client/src/audio_vpio.rs` — a native Core Audio implementation using `AUGraph` + `AudioComponentInstance` with the VPIO audio unit. Gives you hardware-accelerated AEC (same AEC Apple ships in FaceTime / iMessage audio / voice memos) at the cost of tight coupling to Apple frameworks. Lock-free ring pattern matches the CPAL path so the upper layers don't notice the difference.
|
||||||
|
|
||||||
|
Enabled by `features = ["audio", "vpio"]` in the macOS target section of `desktop/src-tauri/Cargo.toml`.
|
||||||
|
|
||||||
|
### 3. Windows cross-compilation via cargo-xwin
|
||||||
|
|
||||||
|
Cross-compiling Rust + Tauri to `x86_64-pc-windows-msvc` from Linux using `cargo-xwin`, which downloads the Microsoft CRT + Windows SDK on demand and drives `clang-cl` as the compiler. No Windows machine is needed for the build itself — only for runtime testing.
|
||||||
|
|
||||||
|
**Build infrastructure**:
|
||||||
|
|
||||||
|
- `scripts/Dockerfile.windows-builder` — Debian bookworm + Rust + cargo-xwin + Node 20 + cmake + ninja + llvm + clang + lld + nasm. Pre-warms the xwin MSVC CRT cache at image build time (saves ~4 minutes per cold build).
|
||||||
|
- `scripts/build-windows-docker.sh` — fire-and-forget remote build via Docker on SepehrHomeserverdk. Same pattern as `build-tauri-android.sh`. Uploads the `.exe` to rustypaste and fires an `ntfy.sh/wzp` notification on start and on completion.
|
||||||
|
- `scripts/build-windows-cloud.sh` — alternative pipeline using a temporary Hetzner Cloud VPS. Slower (full VM spin-up), more expensive, but useful when Docker image rebuilds would be disruptive.
|
||||||
|
|
||||||
|
**Two critical blockers resolved** on the way to a working `.exe`:
|
||||||
|
|
||||||
|
1. **libopus SSE4.1 / SSSE3 intrinsic compile failure**. `audiopus_sys` vendors libopus 1.3.1, whose `CMakeLists.txt` gates the per-file `-msse4.1` `COMPILE_FLAGS` behind `if(NOT MSVC)`. Under `clang-cl`, CMake sets `MSVC=1` (because `CMAKE_C_COMPILER_FRONTEND_VARIANT=MSVC` triggers `Platform/Windows-MSVC.cmake` which unconditionally sets the variable), so the per-file flag is never set and the SSE4.1 source files compile without the target feature — then fail with 20+ "always_inline function '_mm_cvtepi16_epi32' requires target feature 'sse4.1'" errors.
|
||||||
|
|
||||||
|
Fixed by **vendoring audiopus_sys into `vendor/audiopus_sys/`** and patching its bundled libopus to introduce an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`). The eight `if(NOT MSVC)` SIMD guards are flipped to `if(NOT MSVC_CL)` and the global `/arch` block at line 445 becomes `if(MSVC_CL)`, so clang-cl gets the GCC-style per-file flags while real cl.exe keeps the `/arch:AVX` / `/arch:SSE2` globals.
|
||||||
|
|
||||||
|
Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root.
|
||||||
|
|
||||||
|
Upstream tracking: [xiph/opus#256](https://github.com/xiph/opus/issues/256), [xiph/opus PR #257](https://github.com/xiph/opus/pull/257) (both stale).
|
||||||
|
|
||||||
|
2. **tauri-build needs `icons/icon.ico` for the Windows PE resource**. The desktop only had `icon.png`. Generated a multi-size ICO (16/24/32/48/64/128/256) from the existing placeholder via Pillow and committed it. Placeholder quality — real branded icons can replace it later.
|
||||||
|
|
||||||
|
### 4. Windows `AudioCategory_Communications` capture path (task #24)
|
||||||
|
|
||||||
|
`crates/wzp-client/src/audio_wasapi.rs` — direct WASAPI capture via `IMMDeviceEnumerator → IAudioClient2 → SetClientProperties` with `AudioCategory_Communications`. This tells Windows "this is a VoIP call" and Windows routes the capture stream through the driver's registered communications APO chain, which on most Win10/11 consumer hardware includes AEC, NS, and AGC.
|
||||||
|
|
||||||
|
**Caveat**: quality is driver-dependent. On a machine with a good communications APO (Intel Smart Sound, Dolby, modern Realtek on Win11 24H2+, anything with Voice Clarity enabled) it's excellent. On generic class-compliant drivers with no communications APO registered, it's a no-op. For a guaranteed AEC regardless of driver, see task #26 which tracks implementing the classic Voice Capture DSP (`CLSID_CWMAudioAEC`) as a fallback.
|
||||||
|
|
||||||
|
Gated behind the `windows-aec` feature in `wzp-client`. Enabled by default in the Windows target section of `desktop/src-tauri/Cargo.toml`.
|
||||||
|
|
||||||
|
## Build pipelines
|
||||||
|
|
||||||
|
### Native macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd src-tauri
|
||||||
|
cargo build --release --bin wzp-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows x86_64 via Docker on SepehrHomeserverdk
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-docker.sh # Full: pull + build + download
|
||||||
|
./scripts/build-windows-docker.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-windows-docker.sh --rust # Force-clean Rust target
|
||||||
|
./scripts/build-windows-docker.sh --image-build # (Re)build the Docker image (fire-and-forget)
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands at `target/windows-exe/wzp-desktop.exe`. Both `wzp-desktop.exe` and `wzp-desktop-noAEC.exe` can coexist in that directory; the script writes `wzp-desktop.exe` so renaming the prior build to `-noAEC.exe` (or any other name) before rebuilding preserves it.
|
||||||
|
|
||||||
|
### Windows x86_64 via Hetzner Cloud (alternative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
|
||||||
|
./scripts/build-windows-cloud.sh --prepare # Create VM and install deps only
|
||||||
|
./scripts/build-windows-cloud.sh --build # Build on existing VM
|
||||||
|
./scripts/build-windows-cloud.sh --destroy # Delete the VM
|
||||||
|
WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Keep VM alive after build for debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember to destroy the VM at end of day with `--destroy`.
|
||||||
|
|
||||||
|
### Linux x86_64 (relay + CLI + bench)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-linux-docker.sh # Fire-and-forget remote Docker build
|
||||||
|
./scripts/build-linux-docker.sh --install # Wait for completion and download
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the same `wzp-android-builder` Docker image as Android (not a separate image), since the deps (Rust + cmake + ring prereqs) are the same.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Direct calling parity
|
||||||
|
|
||||||
|
1. Build on two machines (macOS + Windows, or two macOS, or any combination).
|
||||||
|
2. Both machines register on the same relay.
|
||||||
|
3. Copy one machine's fingerprint into the other's direct-call panel.
|
||||||
|
4. Place the call. Confirm ringing UI on the callee and "calling…" UI on the caller.
|
||||||
|
5. Answer. Confirm audio flows both ways.
|
||||||
|
6. Hang up from either side. Confirm call-history entries are labeled correctly (`Outgoing` on caller, `Incoming` on callee, never `Missed` on a successful call).
|
||||||
|
|
||||||
|
### Windows AEC A/B
|
||||||
|
|
||||||
|
1. Install `wzp-desktop-noAEC.exe` and `wzp-desktop.exe` on the same Windows box.
|
||||||
|
2. Join a call from each (separately) while a second machine plays known audio through the first machine's speakers.
|
||||||
|
3. On the remote (listening) side: the `noAEC` call should have clear audible echo; the AEC call should have minimal or no echo after a 1–2 s convergence period.
|
||||||
|
4. If both builds sound identical (with echo) → the `AudioCategory_Communications` switch isn't triggering the driver's APO chain. Investigate via task #26 (Voice Capture DSP fallback).
|
||||||
|
|
||||||
|
## Known quirks
|
||||||
|
|
||||||
|
1. **libopus vendor path is workspace-relative**. `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` works from any crate in the workspace because Cargo resolves it against the root `Cargo.toml`'s directory. If the workspace is moved or vendored into another workspace, update the path.
|
||||||
|
|
||||||
|
2. **`cargo xwin` overwrites `override.cmake` on every invocation**. Any attempt to patch `~/.cache/cargo-xwin/cmake/clang-cl/override.cmake` at Docker image build time is inert because `src/compiler/clang_cl.rs` line ~444 writes the bundled file fresh on every run. All real fixes must land in the source tree (via the vendored audiopus_sys, as done here), not in the cargo-xwin cache.
|
||||||
|
|
||||||
|
3. **WebView2 runtime is a prerequisite on Windows 10**. Windows 11 ships with it. If the `.exe` launches and immediately exits with no error on a Win10 machine, that's the missing runtime — install it from [Microsoft's Evergreen bootstrapper](https://developer.microsoft.com/en-us/microsoft-edge/webview2/).
|
||||||
|
|
||||||
|
4. **Rust 2024 edition `unsafe_op_in_unsafe_fn` lint**. The WASAPI backend in `audio_wasapi.rs` emits ~18 of these warnings because Rust 2024 requires explicit `unsafe { ... }` blocks inside `unsafe fn` bodies. The warnings don't block the build and don't affect runtime behavior; cleaning them up is tracked informally as tech debt.
|
||||||
|
|
||||||
|
## Files of interest
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `desktop/src/` | Shared frontend (TypeScript + HTML + CSS) |
|
||||||
|
| `desktop/src-tauri/src/lib.rs` | Tauri commands shared with Android |
|
||||||
|
| `desktop/src-tauri/src/engine.rs` | `CallEngine` wrapper |
|
||||||
|
| `desktop/src-tauri/src/history.rs` | Persistent call history store with dedup |
|
||||||
|
| `crates/wzp-client/src/audio_io.rs` | CPAL capture + playback (baseline) |
|
||||||
|
| `crates/wzp-client/src/audio_vpio.rs` | macOS VoiceProcessingIO capture (AEC) |
|
||||||
|
| `crates/wzp-client/src/audio_wasapi.rs` | Windows WASAPI communications capture (AEC) |
|
||||||
|
| `vendor/audiopus_sys/opus/CMakeLists.txt` | Patched libopus for clang-cl SIMD |
|
||||||
|
| `scripts/Dockerfile.windows-builder` | Windows cross-compile Docker image |
|
||||||
|
| `scripts/build-windows-docker.sh` | Remote Docker build pipeline |
|
||||||
|
| `scripts/build-windows-cloud.sh` | Hetzner VPS alternative pipeline |
|
||||||
|
| `scripts/build-linux-docker.sh` | Linux x86_64 relay/CLI build pipeline |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user