Compare commits
188 Commits
build/last
...
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 | ||
|
|
8c9befb15d | ||
|
|
3f869a4cd7 | ||
|
|
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
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,3 +4,28 @@
|
||||
*.swp
|
||||
*.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-web",
|
||||
"crates/wzp-android",
|
||||
"crates/wzp-native",
|
||||
"desktop/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -40,7 +42,7 @@ codec2 = "0.3"
|
||||
|
||||
# Crypto
|
||||
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"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
@@ -53,3 +55,37 @@ wzp-fec = { path = "crates/wzp-fec" }
|
||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||
wzp-transport = { path = "crates/wzp-transport" }
|
||||
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,7 +57,7 @@ class AudioPipeline(private val context: Context) {
|
||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||
var aecEnabled: Boolean = true
|
||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||
var debugRecording: Boolean = true
|
||||
var debugRecording: Boolean = false
|
||||
private var captureThread: Thread? = null
|
||||
private var playoutThread: Thread? = null
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class SettingsRepository(context: Context) {
|
||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||
private const val TOFU_PREFIX = "tofu_"
|
||||
}
|
||||
@@ -120,6 +121,16 @@ class SettingsRepository(context: Context) {
|
||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||
|
||||
// --- Debug recording ---
|
||||
|
||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
||||
|
||||
// --- Codec choice ---
|
||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
||||
|
||||
// --- Identity seed ---
|
||||
|
||||
/**
|
||||
@@ -179,4 +190,14 @@ class SettingsRepository(context: Context) {
|
||||
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,
|
||||
/** Current mic audio level (RMS, 0-32767). */
|
||||
val audioLevel: Int = 0,
|
||||
/** Our current outgoing codec (e.g. "Opus24k"). */
|
||||
val currentCodec: String = "",
|
||||
/** Last seen incoming codec from peers. */
|
||||
val peerCodec: String = "",
|
||||
/** Whether auto quality mode is active. */
|
||||
val autoMode: Boolean = false,
|
||||
/** Number of participants in the room. */
|
||||
val roomParticipantCount: Int = 0,
|
||||
/** Participants in the room (fingerprint + optional alias). */
|
||||
val roomParticipants: List<RoomMember> = emptyList(),
|
||||
/** SAS verification code (4-digit, null if not in a call). */
|
||||
val sasCode: Int? = null,
|
||||
/** Incoming call ID (or "relay|room" for CallSetup). */
|
||||
val incomingCallId: String? = null,
|
||||
/** Incoming caller's fingerprint. */
|
||||
val incomingCallerFp: String? = null,
|
||||
/** Incoming caller's alias. */
|
||||
val incomingCallerAlias: String? = null,
|
||||
) {
|
||||
/** Human-readable quality label. */
|
||||
val qualityLabel: String
|
||||
@@ -54,7 +68,8 @@ data class CallStats(
|
||||
val o = arr.getJSONObject(i)
|
||||
RoomMember(
|
||||
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),
|
||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||
audioLevel = obj.optInt("audio_level", 0),
|
||||
currentCodec = obj.optString("current_codec", ""),
|
||||
peerCodec = obj.optString("peer_codec", ""),
|
||||
autoMode = obj.optBoolean("auto_mode", false),
|
||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
|
||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
||||
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
CallStats()
|
||||
@@ -88,7 +110,8 @@ data class CallStats(
|
||||
|
||||
data class RoomMember(
|
||||
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. */
|
||||
val displayName: String
|
||||
|
||||
@@ -38,9 +38,12 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
* @param alias display name sent to relay for room participant list
|
||||
* @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" }
|
||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias)
|
||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
||||
if (result == 0) {
|
||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||
} else {
|
||||
@@ -50,6 +53,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
}
|
||||
|
||||
/** Stop the active call. Safe to call when no call is active. */
|
||||
@Synchronized
|
||||
fun stopCall() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeStopCall(nativeHandle)
|
||||
@@ -73,6 +77,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
*
|
||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getStats(): String {
|
||||
if (nativeHandle == 0L) return "{}"
|
||||
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. */
|
||||
@Synchronized
|
||||
fun destroy() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeDestroy(nativeHandle)
|
||||
@@ -141,7 +147,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
|
||||
private external fun nativeInit(): Long
|
||||
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
|
||||
private external fun nativeStopCall(handle: Long)
|
||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||
@@ -153,20 +159,59 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
||||
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
||||
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
||||
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||
|
||||
/**
|
||||
* Ping a relay server. Requires engine to be initialized.
|
||||
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
||||
*/
|
||||
fun pingRelay(address: String): String? {
|
||||
if (nativeHandle == 0L) return null
|
||||
return nativePingRelay(nativeHandle, address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start persistent signaling connection for direct 1:1 calls.
|
||||
* The engine registers on the relay and listens for incoming calls.
|
||||
* Call state updates are available via [getStats].
|
||||
*
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a direct call to a peer by fingerprint.
|
||||
* Requires [startSignaling] to have been called first.
|
||||
*
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun placeCall(targetFingerprint: String): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativePlaceCall(nativeHandle, targetFingerprint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an incoming direct call.
|
||||
*
|
||||
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
|
||||
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
|
||||
* @return 0 on success, -1 on error
|
||||
*/
|
||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("wzp_android")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
|
||||
* or null if unreachable. Does not require an engine instance.
|
||||
*/
|
||||
fun pingRelay(address: String): String? = nativePingRelay(address)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativePingRelay(relay: String): String?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 = "",
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,8 @@ data class ServerEntry(val address: String, val label: String)
|
||||
|
||||
data class PingResult(
|
||||
val rttMs: Int,
|
||||
val serverFingerprint: String,
|
||||
val serverFingerprint: String = "",
|
||||
val reachable: Boolean = rttMs > 0,
|
||||
)
|
||||
|
||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||
@@ -105,6 +106,18 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _aecEnabled = MutableStateFlow(true)
|
||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||
|
||||
private val _debugRecording = MutableStateFlow(false)
|
||||
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
||||
|
||||
// Quality profile index (matches JNI bridge profile_from_int)
|
||||
private val _codecChoice = MutableStateFlow(0)
|
||||
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
||||
|
||||
/** Key-change warning dialog state. */
|
||||
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
||||
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
||||
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
||||
|
||||
/** True when a call just ended and debug report can be sent. */
|
||||
private val _debugReportAvailable = MutableStateFlow(false)
|
||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||
@@ -119,13 +132,91 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
// ── Direct calling state ──
|
||||
/** 0=room mode, 1=direct call mode */
|
||||
private val _callMode = MutableStateFlow(0)
|
||||
val callMode: StateFlow<Int> = _callMode.asStateFlow()
|
||||
|
||||
/** Target fingerprint for direct call */
|
||||
private val _targetFingerprint = MutableStateFlow("")
|
||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||
|
||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
||||
private val _signalState = MutableStateFlow(0)
|
||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
||||
|
||||
/** Incoming call info */
|
||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
|
||||
|
||||
private val _incomingCallerFp = MutableStateFlow<String?>(null)
|
||||
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
|
||||
|
||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||
|
||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||
|
||||
/** Register on relay for direct calls */
|
||||
fun registerForCalls() {
|
||||
if (engine == null) {
|
||||
engine = WzpEngine(this).also { it.init() }
|
||||
}
|
||||
val serverIdx = _selectedServer.value
|
||||
val serverList = _servers.value
|
||||
if (serverIdx >= serverList.size) return
|
||||
|
||||
val relay = serverList[serverIdx].address
|
||||
val seed = _seedHex.value
|
||||
val alias = _alias.value
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
||||
if (result == 0) {
|
||||
_signalState.value = 5 // Registered
|
||||
startStatsPolling()
|
||||
} else {
|
||||
_errorMessage.value = "Failed to register on relay"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a direct call to the target fingerprint */
|
||||
fun placeDirectCall() {
|
||||
val target = _targetFingerprint.value.trim()
|
||||
if (target.isEmpty()) {
|
||||
_errorMessage.value = "Enter a fingerprint to call"
|
||||
return
|
||||
}
|
||||
engine?.placeCall(target)
|
||||
_signalState.value = 6 // Ringing
|
||||
}
|
||||
|
||||
/** Answer an incoming direct call */
|
||||
fun answerIncomingCall(mode: Int = 2) {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
engine?.answerCall(callId, mode)
|
||||
}
|
||||
|
||||
/** Reject an incoming direct call */
|
||||
fun rejectIncomingCall() {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
engine?.answerCall(callId, 0) // 0 = Reject
|
||||
_signalState.value = 5 // Back to registered
|
||||
_incomingCallId.value = null
|
||||
_incomingCallerFp.value = null
|
||||
_incomingCallerAlias.value = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WzpCall"
|
||||
val DEFAULT_SERVERS = listOf(
|
||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||
)
|
||||
const val DEFAULT_ROOM = "android"
|
||||
const val DEFAULT_ROOM = "general"
|
||||
}
|
||||
|
||||
fun setContext(context: Context) {
|
||||
@@ -159,6 +250,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_captureGainDb.value = s.loadCaptureGain()
|
||||
_seedHex.value = s.getOrCreateSeedHex()
|
||||
_aecEnabled.value = s.loadAecEnabled()
|
||||
_debugRecording.value = s.loadDebugRecording()
|
||||
_codecChoice.value = s.loadCodecChoice()
|
||||
_recentRooms.value = s.loadRecentRooms()
|
||||
}
|
||||
|
||||
@@ -203,35 +296,43 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
settings?.saveSelectedServer(_selectedServer.value)
|
||||
}
|
||||
|
||||
/** Ping all servers in background, update results. */
|
||||
/**
|
||||
* 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 pr = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val json = WzpEngine.pingRelay(server.address) ?: return@withContext null
|
||||
val obj = JSONObject(json)
|
||||
PingResult(
|
||||
rttMs = obj.getInt("rtt_ms"),
|
||||
serverFingerprint = obj.optString("server_fingerprint", ""),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "ping ${server.address} failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
val json = withContext(Dispatchers.IO) {
|
||||
eng.pingRelay(server.address)
|
||||
}
|
||||
if (pr != null) {
|
||||
results[server.address] = pr
|
||||
// TOFU: save fingerprint on first contact
|
||||
if (pr.serverFingerprint.isNotEmpty()) {
|
||||
val saved = settings?.loadServerFingerprint(server.address)
|
||||
if (saved == null) {
|
||||
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
|
||||
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
|
||||
}
|
||||
known[server.address] = saved ?: pr.serverFingerprint
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
_pingResults.value = results
|
||||
@@ -239,12 +340,23 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
val known = _knownFingerprints.value[address]
|
||||
if (!pr.reachable) return LockStatus.OFFLINE
|
||||
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
||||
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||
if (known == null) return LockStatus.NEW
|
||||
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||
}
|
||||
|
||||
@@ -280,6 +392,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
settings?.saveAecEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setDebugRecording(enabled: Boolean) {
|
||||
_debugRecording.value = enabled
|
||||
settings?.saveDebugRecording(enabled)
|
||||
}
|
||||
|
||||
fun setCodecChoice(choice: Int) {
|
||||
_codecChoice.value = choice
|
||||
settings?.saveCodecChoice(choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||
* since Rust's DNS resolution may not work on Android.
|
||||
@@ -346,7 +468,74 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
/** Accept the new server key and proceed with the call. */
|
||||
fun acceptNewFingerprint() {
|
||||
val info = _keyWarning.value ?: return
|
||||
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
||||
it[info.address] = info.newFp
|
||||
}
|
||||
settings?.saveServerFingerprint(info.address, info.newFp)
|
||||
_keyWarning.value = null
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
fun dismissKeyWarning() {
|
||||
_keyWarning.value = null
|
||||
}
|
||||
|
||||
fun startCall() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
// Check for key change before connecting
|
||||
val ls = lockStatus(serverEntry.address)
|
||||
if (ls == LockStatus.CHANGED) {
|
||||
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
||||
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
||||
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
||||
return
|
||||
}
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
/** Start a call to a specific relay + room (used by direct call setup). */
|
||||
private fun startCallInternal(relay: String, room: String) {
|
||||
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
|
||||
try {
|
||||
// Don't teardown — keep the signal connection alive
|
||||
engine = WzpEngine(this)
|
||||
engine!!.init()
|
||||
engineInitialized = true
|
||||
_callState.value = 1
|
||||
_errorMessage.value = null
|
||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
||||
Log.w(TAG, "service start err: $e")
|
||||
}
|
||||
startStatsPolling()
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val seed = _seedHex.value
|
||||
val name = _alias.value
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||
CallService.onStopFromNotification = { stopCall() }
|
||||
if (result != 0) {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Failed to connect to call room (code $result)"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCallDirect error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCallDirect error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCallInternal() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
val room = _roomName.value
|
||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||
@@ -377,7 +566,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
val seed = _seedHex.value
|
||||
val name = _alias.value
|
||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||
Log.i(TAG, "startCall: engine returned $result")
|
||||
// Only wire up notification callback after engine is running
|
||||
CallService.onStopFromNotification = { stopCall() }
|
||||
@@ -468,6 +657,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
it.playoutGainDb = _playoutGainDb.value
|
||||
it.captureGainDb = _captureGainDb.value
|
||||
it.aecEnabled = _aecEnabled.value
|
||||
it.debugRecording = _debugRecording.value
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
@@ -498,6 +688,27 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
}
|
||||
// Track signal state changes for direct calling
|
||||
if (s.state in 5..7) {
|
||||
_signalState.value = s.state
|
||||
}
|
||||
// Incoming call detection
|
||||
if (s.state == 7) { // IncomingCall
|
||||
_incomingCallId.value = s.incomingCallId
|
||||
_incomingCallerFp.value = s.incomingCallerFp
|
||||
_incomingCallerAlias.value = s.incomingCallerAlias
|
||||
}
|
||||
// CallSetup: auto-connect to media room
|
||||
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
||||
// Format: "relay_addr|room_name"
|
||||
val parts = s.incomingCallId.split("|", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val mediaRelay = parts[0]
|
||||
val mediaRoom = parts[1]
|
||||
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
||||
startCallInternal(mediaRelay, mediaRoom)
|
||||
}
|
||||
}
|
||||
if (s.state == 2 && !audioStarted) {
|
||||
startAudio()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
package com.wzp.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
@@ -241,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))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -12,4 +12,13 @@ pub enum EngineCommand {
|
||||
ForceProfile(QualityProfile),
|
||||
/// Stop the call and shut down the engine.
|
||||
Stop,
|
||||
/// Place a direct call to a fingerprint (requires signal connection).
|
||||
PlaceCall { target_fingerprint: String },
|
||||
/// Answer an incoming direct call.
|
||||
AnswerCall {
|
||||
call_id: String,
|
||||
accept_mode: wzp_proto::CallAcceptMode,
|
||||
},
|
||||
/// Reject an incoming direct call.
|
||||
RejectCall { call_id: String },
|
||||
}
|
||||
|
||||
@@ -9,32 +9,58 @@
|
||||
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
||||
|
||||
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::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_codec::opus_dec::OpusDecoder;
|
||||
use wzp_codec::opus_enc::OpusEncoder;
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::{
|
||||
AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||
MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
|
||||
AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||
MediaHeader, MediaPacket, MediaTransport, QualityController, QualityProfile, SignalMessage,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
use crate::commands::EngineCommand;
|
||||
use crate::stats::{CallState, CallStats};
|
||||
|
||||
/// Opus frame size at 48kHz mono, 20ms = 960 samples.
|
||||
const FRAME_SAMPLES: usize = 960;
|
||||
/// Max frame size at 48kHz mono (40ms = 1920 samples, for Codec2/Opus6k).
|
||||
const MAX_FRAME_SAMPLES: usize = 1920;
|
||||
|
||||
/// Sentinel value: no profile change pending.
|
||||
const PROFILE_NO_CHANGE: u8 = 0xFF;
|
||||
|
||||
/// All quality profiles in index order, for AtomicU8-based signaling.
|
||||
const PROFILES: [QualityProfile; 6] = [
|
||||
QualityProfile::STUDIO_64K, // 0
|
||||
QualityProfile::STUDIO_48K, // 1
|
||||
QualityProfile::STUDIO_32K, // 2
|
||||
QualityProfile::GOOD, // 3
|
||||
QualityProfile::DEGRADED, // 4
|
||||
QualityProfile::CATASTROPHIC, // 5
|
||||
];
|
||||
|
||||
fn profile_to_index(p: &QualityProfile) -> u8 {
|
||||
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
|
||||
}
|
||||
|
||||
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
|
||||
PROFILES.get(idx as usize).copied()
|
||||
}
|
||||
|
||||
/// Compute frame samples at 48kHz for a given profile.
|
||||
fn frame_samples_for(profile: &QualityProfile) -> usize {
|
||||
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
||||
}
|
||||
|
||||
/// Configuration to start a call.
|
||||
pub struct CallStartConfig {
|
||||
pub profile: QualityProfile,
|
||||
/// When true, use the relay's chosen_profile from CallAnswer instead of local profile.
|
||||
pub auto_profile: bool,
|
||||
pub relay_addr: String,
|
||||
pub room: String,
|
||||
pub auth_token: Vec<u8>,
|
||||
@@ -46,6 +72,7 @@ impl Default for CallStartConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: QualityProfile::GOOD,
|
||||
auto_profile: false,
|
||||
relay_addr: String::new(),
|
||||
room: String::new(),
|
||||
auth_token: Vec::new(),
|
||||
@@ -123,6 +150,7 @@ impl WzpEngine {
|
||||
let room = config.room.clone();
|
||||
let identity_seed = config.identity_seed;
|
||||
let profile = config.profile;
|
||||
let auto_profile = config.auto_profile;
|
||||
let alias = config.alias.clone();
|
||||
let state = self.state.clone();
|
||||
|
||||
@@ -131,7 +159,7 @@ impl WzpEngine {
|
||||
|
||||
let state_clone = state.clone();
|
||||
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}");
|
||||
}
|
||||
@@ -169,6 +197,203 @@ impl WzpEngine {
|
||||
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) {
|
||||
self.state.muted.store(muted, Ordering::Relaxed);
|
||||
}
|
||||
@@ -227,6 +452,7 @@ async fn run_call(
|
||||
room: &str,
|
||||
identity_seed: &[u8; 32],
|
||||
profile: QualityProfile,
|
||||
auto_profile: bool,
|
||||
alias: Option<&str>,
|
||||
state: Arc<EngineState>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
@@ -261,6 +487,9 @@ async fn run_call(
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
@@ -275,8 +504,8 @@ async fn run_call(
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||
|
||||
let relay_ephemeral_pub = match answer {
|
||||
SignalMessage::CallAnswer { ephemeral_pub, .. } => ephemeral_pub,
|
||||
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
||||
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
@@ -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)?;
|
||||
info!("handshake complete, call active");
|
||||
info!(codec = ?profile.codec, "handshake complete, call active");
|
||||
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// Initialize Opus codec
|
||||
let mut encoder =
|
||||
OpusEncoder::new(profile).map_err(|e| anyhow::anyhow!("opus encoder init: {e}"))?;
|
||||
let mut decoder =
|
||||
OpusDecoder::new(profile).map_err(|e| anyhow::anyhow!("opus decoder init: {e}"))?;
|
||||
// Initialize codec (Opus or Codec2 based on profile)
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = wzp_codec::create_decoder(profile);
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
@@ -307,21 +542,37 @@ async fn run_call(
|
||||
let mut capture_agc = AutoGainControl::new();
|
||||
let mut playout_agc = AutoGainControl::new();
|
||||
|
||||
let mut frame_samples = frame_samples_for(&profile);
|
||||
info!(
|
||||
codec = ?profile.codec,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
frames_per_block = profile.frames_per_block,
|
||||
"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 ts = AtomicU32::new(0);
|
||||
let transport_recv = transport.clone();
|
||||
|
||||
// Pre-allocate buffers
|
||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||
// Adaptive quality: shared AtomicU8 between recv task (writer) and send task (reader).
|
||||
// 0xFF = no change pending, 0-5 = index into PROFILES array.
|
||||
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
|
||||
let pending_profile_recv = pending_profile.clone();
|
||||
|
||||
// Pre-allocate buffers (sized for current profile)
|
||||
let mut capture_buf = vec![0i16; frame_samples];
|
||||
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
||||
let mut frame_in_block: u8 = 0;
|
||||
let mut block_id: u8 = 0;
|
||||
let mut current_profile = profile;
|
||||
|
||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||
//
|
||||
@@ -347,14 +598,47 @@ async fn run_call(
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for adaptive profile switch from recv task
|
||||
if auto_profile {
|
||||
let p = pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
||||
if p != PROFILE_NO_CHANGE {
|
||||
if let Some(new_profile) = index_to_profile(p) {
|
||||
info!(
|
||||
from = ?current_profile.codec,
|
||||
to = ?new_profile.codec,
|
||||
"auto: switching encoder profile"
|
||||
);
|
||||
if let Err(e) = encoder.set_profile(new_profile) {
|
||||
warn!("encoder set_profile failed: {e}");
|
||||
} else {
|
||||
fec_enc = wzp_fec::create_encoder(&new_profile);
|
||||
current_profile = new_profile;
|
||||
let new_frame_samples = frame_samples_for(&new_profile);
|
||||
if new_frame_samples != frame_samples {
|
||||
frame_samples = new_frame_samples;
|
||||
capture_buf.resize(frame_samples, 0);
|
||||
}
|
||||
encode_buf.resize(encoder.max_frame_bytes(), 0);
|
||||
// Reset FEC block state for clean switch
|
||||
frame_in_block = 0;
|
||||
block_id = block_id.wrapping_add(1);
|
||||
// Update stats with new codec
|
||||
if let Ok(mut stats) = state.stats.lock() {
|
||||
stats.current_codec = format!("{:?}", new_profile.codec);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let avail = state.capture_ring.available();
|
||||
if avail < FRAME_SAMPLES {
|
||||
if avail < frame_samples {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let read = state.capture_ring.read(&mut capture_buf);
|
||||
if read < FRAME_SAMPLES {
|
||||
if read < frame_samples {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -383,15 +667,15 @@ async fn run_call(
|
||||
|
||||
// Build source packet
|
||||
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 {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: profile.codec,
|
||||
codec_id: current_profile.codec,
|
||||
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,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
@@ -433,8 +717,8 @@ async fn run_call(
|
||||
frame_in_block += 1;
|
||||
|
||||
// When block is full, generate repair packets
|
||||
if frame_in_block >= profile.frames_per_block {
|
||||
match fec_enc.generate_repair(profile.fec_ratio) {
|
||||
if frame_in_block >= current_profile.frames_per_block {
|
||||
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
@@ -443,10 +727,10 @@ async fn run_call(
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: profile.codec,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
profile.fec_ratio,
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
@@ -469,7 +753,7 @@ async fn run_call(
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
fec_ratio = current_profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
}
|
||||
@@ -511,8 +795,8 @@ async fn run_call(
|
||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||
};
|
||||
|
||||
// Pre-allocate decode buffer
|
||||
let mut decode_buf = vec![0i16; FRAME_SAMPLES];
|
||||
// Pre-allocate decode buffer (max size to handle any incoming codec)
|
||||
let mut decode_buf = vec![0i16; MAX_FRAME_SAMPLES];
|
||||
|
||||
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
||||
let recv_task = async {
|
||||
@@ -522,6 +806,8 @@ async fn run_call(
|
||||
let mut last_recv_instant = Instant::now();
|
||||
let mut max_recv_gap_ms: u64 = 0;
|
||||
let mut last_stats_log = Instant::now();
|
||||
let mut quality_ctrl = AdaptiveQualityController::new();
|
||||
let mut last_peer_codec: Option<CodecId> = None;
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
@@ -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 pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
@@ -557,7 +860,34 @@ async fn run_call(
|
||||
);
|
||||
|
||||
// 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) {
|
||||
Ok(samples) => {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
@@ -672,6 +1002,7 @@ async fn run_call(
|
||||
.map(|p| crate::stats::RoomMember {
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
alias: p.alias.clone(),
|
||||
relay_label: p.relay_label.clone(),
|
||||
})
|
||||
.collect();
|
||||
let mut stats = state_signal.stats.lock().unwrap();
|
||||
|
||||
@@ -21,11 +21,24 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||
}
|
||||
|
||||
/// 7 = auto (use relay's chosen profile)
|
||||
const PROFILE_AUTO: jint = 7;
|
||||
|
||||
fn profile_from_int(value: jint) -> QualityProfile {
|
||||
match value {
|
||||
1 => QualityProfile::DEGRADED,
|
||||
2 => QualityProfile::CATASTROPHIC,
|
||||
_ => QualityProfile::GOOD,
|
||||
0 => QualityProfile::GOOD, // Opus 24k
|
||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
||||
3 => QualityProfile { // Codec2 3.2k
|
||||
codec: wzp_proto::CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
||||
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
||||
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
||||
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +98,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
seed_hex_j: JString,
|
||||
token_j: JString,
|
||||
alias_j: JString,
|
||||
profile_j: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||
@@ -110,7 +124,8 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
}
|
||||
|
||||
let config = CallStartConfig {
|
||||
profile: QualityProfile::GOOD,
|
||||
profile: profile_from_int(profile_j),
|
||||
auto_profile: profile_j == PROFILE_AUTO,
|
||||
relay_addr,
|
||||
room,
|
||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||
@@ -318,71 +333,22 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||
}));
|
||||
}
|
||||
|
||||
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||
/// Does NOT require an engine handle — creates a temporary QUIC connection.
|
||||
/// 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();
|
||||
let addr: std::net::SocketAddr = match relay.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(conn)) => {
|
||||
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");
|
||||
Some(format!(
|
||||
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
|
||||
rtt_ms, server_fp
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
match h.engine.ping_relay(&relay) {
|
||||
Ok(json) => Some(json),
|
||||
Err(_) => None,
|
||||
}
|
||||
}));
|
||||
|
||||
let json = match result {
|
||||
@@ -393,3 +359,89 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||
.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,
|
||||
Reconnecting,
|
||||
Closed,
|
||||
/// Connected to relay signal channel, registered for direct calls.
|
||||
Registered,
|
||||
/// Outgoing call ringing on callee's side.
|
||||
Ringing,
|
||||
/// Incoming call received, waiting for user to accept/reject.
|
||||
IncomingCall,
|
||||
}
|
||||
|
||||
impl serde::Serialize for CallState {
|
||||
@@ -21,6 +27,9 @@ impl serde::Serialize for CallState {
|
||||
CallState::Active => 2,
|
||||
CallState::Reconnecting => 3,
|
||||
CallState::Closed => 4,
|
||||
CallState::Registered => 5,
|
||||
CallState::Ringing => 6,
|
||||
CallState::IncomingCall => 7,
|
||||
};
|
||||
serializer.serialize_u8(n)
|
||||
}
|
||||
@@ -59,10 +68,28 @@ pub struct CallStats {
|
||||
pub capture_overflows: u64,
|
||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||
pub audio_level: u32,
|
||||
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
||||
pub current_codec: String,
|
||||
/// Last seen incoming codec from other participants.
|
||||
pub peer_codec: String,
|
||||
/// Whether auto quality mode is active.
|
||||
pub auto_mode: bool,
|
||||
/// Number of participants in the room (from last RoomUpdate).
|
||||
pub room_participant_count: u32,
|
||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||
pub room_participants: Vec<RoomMember>,
|
||||
/// SAS code for verbal verification (None if not in a call).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sas_code: Option<u32>,
|
||||
/// Incoming call info (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_call_id: Option<String>,
|
||||
/// Fingerprint of the caller (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_fp: Option<String>,
|
||||
/// Alias of the caller (present when state == IncomingCall).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_alias: Option<String>,
|
||||
}
|
||||
|
||||
/// A room member entry, serialized into the stats JSON.
|
||||
@@ -70,4 +97,5 @@ pub struct CallStats {
|
||||
pub struct RoomMember {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
|
||||
@@ -23,10 +23,71 @@ serde_json = "1"
|
||||
chrono = "0.4"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
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]
|
||||
default = []
|
||||
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]]
|
||||
name = "wzp-client"
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||
//!
|
||||
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
||||
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
||||
//! channel handles.
|
||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
|
||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
|
||||
// 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`.
|
||||
pub struct AudioCapture {
|
||||
rx: mpsc::Receiver<Vec<i16>>,
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioCapture {
|
||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||
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_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()
|
||||
.name("wzp-audio-capture".into())
|
||||
@@ -59,53 +61,51 @@ impl AudioCapture {
|
||||
|
||||
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| {
|
||||
warn!("input stream error: {e}");
|
||||
};
|
||||
|
||||
let logged_cb_size = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let stream = if use_f32 {
|
||||
let buf = buf.clone();
|
||||
let tx = tx.clone();
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
let logged = logged_cb_size.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lock = buf.lock().unwrap();
|
||||
for &s in data {
|
||||
lock.push(f32_to_i16(s));
|
||||
if lock.len() == FRAME_SAMPLES {
|
||||
let frame = lock.drain(..).collect();
|
||||
let _ = tx.try_send(frame);
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
||||
}
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
for i in 0..n {
|
||||
tmp[i] = f32_to_i16(chunk[i]);
|
||||
}
|
||||
ring.write(&tmp[..n]);
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let buf = buf.clone();
|
||||
let tx = tx.clone();
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
let logged = logged_cb_size.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lock = buf.lock().unwrap();
|
||||
for &s in data {
|
||||
lock.push(s);
|
||||
if lock.len() == FRAME_SAMPLES {
|
||||
let frame = lock.drain(..).collect();
|
||||
let _ = tx.try_send(frame);
|
||||
}
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
||||
}
|
||||
ring.write(data);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
@@ -114,7 +114,6 @@ impl AudioCapture {
|
||||
|
||||
stream.play().context("failed to start input stream")?;
|
||||
|
||||
// Signal success to the caller before parking.
|
||||
let _ = init_tx.send(Ok(()));
|
||||
|
||||
// Keep stream alive until stopped.
|
||||
@@ -135,15 +134,12 @@ impl AudioCapture {
|
||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { rx, running })
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
/// Read the next frame of 960 PCM samples (blocking until available).
|
||||
///
|
||||
/// Returns `None` when the stream has been stopped or the channel is
|
||||
/// disconnected.
|
||||
pub fn read_frame(&self) -> Option<Vec<i16>> {
|
||||
self.rx.recv().ok()
|
||||
/// Get a reference to the capture ring buffer for direct polling.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
/// Stop capturing.
|
||||
@@ -152,26 +148,34 @@ impl AudioCapture {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioCapture {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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`.
|
||||
pub struct AudioPlayback {
|
||||
tx: mpsc::SyncSender<Vec<i16>>,
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Create and start playback on the default output device at 48 kHz mono.
|
||||
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_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()
|
||||
.name("wzp-audio-playback".into())
|
||||
@@ -192,62 +196,40 @@ impl AudioPlayback {
|
||||
|
||||
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| {
|
||||
warn!("output stream error: {e}");
|
||||
};
|
||||
|
||||
let stream = if use_f32 {
|
||||
let ring = ring.clone();
|
||||
let ring = ring_cb.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut lock = ring.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = match lock.pop_front() {
|
||||
Some(s) => i16_to_f32(s),
|
||||
None => 0.0,
|
||||
};
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
let read = ring.read(&mut tmp[..n]);
|
||||
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,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring.clone();
|
||||
let ring = ring_cb.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut lock = ring.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = lock.pop_front().unwrap_or(0);
|
||||
let read = ring.read(data);
|
||||
// Fill remainder with silence if ring underran
|
||||
for sample in &mut data[read..] {
|
||||
*sample = 0;
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
@@ -257,7 +239,6 @@ impl AudioPlayback {
|
||||
|
||||
stream.play().context("failed to start output stream")?;
|
||||
|
||||
// Signal success to the caller before parking.
|
||||
let _ = init_tx.send(Ok(()));
|
||||
|
||||
// Keep stream alive until stopped.
|
||||
@@ -278,12 +259,12 @@ impl AudioPlayback {
|
||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { tx, running })
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
/// Write a frame of PCM samples for playback.
|
||||
pub fn write_frame(&self, pcm: &[i16]) {
|
||||
let _ = self.tx.try_send(pcm.to_vec());
|
||||
/// Get a reference to the playout ring buffer for direct writing.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
/// Stop playback.
|
||||
@@ -292,11 +273,16 @@ impl AudioPlayback {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayback {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if the input device supports i16 at 48 kHz mono.
|
||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.supported_input_configs()
|
||||
@@ -313,7 +299,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if the output device supports i16 at 48 kHz mono.
|
||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.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;
|
||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||
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).
|
||||
///
|
||||
/// When true, the jitter buffer target depth is automatically adjusted
|
||||
@@ -63,6 +66,7 @@ impl Default for CallConfig {
|
||||
noise_suppression: true,
|
||||
mini_frames_enabled: true,
|
||||
adaptive_jitter: true,
|
||||
aec_delay_ms: 40,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +245,7 @@ impl CallEncoder {
|
||||
block_id: 0,
|
||||
frame_in_block: 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(),
|
||||
silence_detector: SilenceDetector::new(
|
||||
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.
|
||||
///
|
||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||
@@ -510,6 +560,9 @@ impl CallDecoder {
|
||||
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;
|
||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||
Ok(n) => Some(n),
|
||||
|
||||
@@ -47,6 +47,11 @@ struct CliArgs {
|
||||
room: Option<String>,
|
||||
token: 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 {
|
||||
@@ -88,12 +93,20 @@ fn parse_args() -> CliArgs {
|
||||
let mut room = None;
|
||||
let mut token = None;
|
||||
let mut metrics_file = None;
|
||||
let mut version_check = false;
|
||||
let mut relay_str = None;
|
||||
let mut signal = false;
|
||||
let mut call_target = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--live" => live = true,
|
||||
"--signal" => signal = true,
|
||||
"--call" => {
|
||||
i += 1;
|
||||
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||
}
|
||||
"--send-tone" => {
|
||||
i += 1;
|
||||
send_tone_secs = Some(
|
||||
@@ -169,6 +182,7 @@ fn parse_args() -> CliArgs {
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--version-check" => { version_check = true; }
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
eprintln!();
|
||||
@@ -221,6 +235,9 @@ fn parse_args() -> CliArgs {
|
||||
room,
|
||||
token,
|
||||
_metrics_file: metrics_file,
|
||||
version_check,
|
||||
signal,
|
||||
call_target,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +256,32 @@ async fn main() -> anyhow::Result<()> {
|
||||
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();
|
||||
|
||||
info!(
|
||||
@@ -250,12 +293,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
"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 {
|
||||
Some(name) => {
|
||||
let hashed = wzp_crypto::hash_room_name(name);
|
||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
||||
hashed
|
||||
info!(room = %name, "using room name as SNI");
|
||||
name.clone()
|
||||
}
|
||||
None => "default".to_string(),
|
||||
};
|
||||
@@ -274,6 +316,26 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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)
|
||||
if let Some(ref token) = cli.token {
|
||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||
@@ -624,3 +686,195 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
||||
info!("done");
|
||||
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::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::FederationHello { .. }
|
||||
| SignalMessage::GlobalRoomActive { .. }
|
||||
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
||||
SignalMessage::RegisterPresence { .. }
|
||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ pub async fn perform_handshake(
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
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 call;
|
||||
pub mod drift_test;
|
||||
@@ -17,7 +35,48 @@ pub mod handshake;
|
||||
pub mod metrics;
|
||||
pub mod sweep;
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||
// AudioPlayback: three possible backends depending on feature flags.
|
||||
// 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 handshake::perform_handshake;
|
||||
|
||||
@@ -1,53 +1,127 @@
|
||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
||||
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
|
||||
//! Geigel double-talk detection.
|
||||
//!
|
||||
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
|
||||
//! → air → mic → capture) is 30–50ms. The far-end reference must be delayed
|
||||
//! by this amount so the adaptive filter models the *echo path*, not the
|
||||
//! *system delay + echo path*.
|
||||
//!
|
||||
//! The leaky coefficient decay prevents the filter from diverging when the
|
||||
//! echo path changes (e.g. hand near laptop) or when the delay estimate
|
||||
//! is slightly off.
|
||||
|
||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
||||
///
|
||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
||||
/// the estimated echo from the near-end in real time.
|
||||
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
|
||||
pub struct EchoCanceller {
|
||||
filter_coeffs: Vec<f32>,
|
||||
// --- Adaptive filter ---
|
||||
filter: Vec<f32>,
|
||||
filter_len: usize,
|
||||
far_end_buf: Vec<f32>,
|
||||
far_end_pos: usize,
|
||||
/// Circular buffer of far-end reference samples (after delay).
|
||||
far_buf: Vec<f32>,
|
||||
far_pos: usize,
|
||||
/// NLMS step size.
|
||||
mu: f32,
|
||||
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
||||
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
||||
leak: f32,
|
||||
enabled: bool,
|
||||
|
||||
// --- Delay buffer ---
|
||||
/// Raw far-end samples before delay compensation.
|
||||
delay_ring: Vec<f32>,
|
||||
delay_write: usize,
|
||||
delay_read: usize,
|
||||
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
||||
delay_samples: usize,
|
||||
/// Capacity of the delay ring.
|
||||
delay_cap: usize,
|
||||
|
||||
// --- Double-talk detection (Geigel) ---
|
||||
/// Peak far-end level over the last filter_len samples.
|
||||
far_peak: f32,
|
||||
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
||||
geigel_threshold: f32,
|
||||
/// Holdover counter: keep DTD active for a few frames after detection.
|
||||
dtd_holdover: u32,
|
||||
dtd_hold_frames: u32,
|
||||
}
|
||||
|
||||
impl EchoCanceller {
|
||||
/// Create a new echo canceller.
|
||||
///
|
||||
/// * `sample_rate` — typically 48000
|
||||
/// * `filter_ms` — echo-tail length in milliseconds (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 {
|
||||
Self::with_delay(sample_rate, filter_ms, 40)
|
||||
}
|
||||
|
||||
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
||||
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
||||
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
||||
Self {
|
||||
filter_coeffs: vec![0.0f32; filter_len],
|
||||
filter: vec![0.0; filter_len],
|
||||
filter_len,
|
||||
far_end_buf: vec![0.0f32; filter_len],
|
||||
far_end_pos: 0,
|
||||
far_buf: vec![0.0; filter_len],
|
||||
far_pos: 0,
|
||||
mu: 0.01,
|
||||
leak: 0.0001,
|
||||
enabled: true,
|
||||
|
||||
delay_ring: vec![0.0; delay_cap],
|
||||
delay_write: 0,
|
||||
delay_read: 0,
|
||||
delay_samples,
|
||||
delay_cap,
|
||||
|
||||
far_peak: 0.0,
|
||||
geigel_threshold: 0.7,
|
||||
dtd_holdover: 0,
|
||||
dtd_hold_frames: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
||||
///
|
||||
/// Must be called with the audio that was played out through the speaker
|
||||
/// *before* the corresponding near-end frame is processed.
|
||||
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
||||
/// once enough samples have accumulated, they are released to the filter's
|
||||
/// circular buffer with the correct delay offset.
|
||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||
// Write raw samples into the delay ring.
|
||||
for &s in farend {
|
||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
||||
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
||||
self.delay_write += 1;
|
||||
}
|
||||
|
||||
// Release delayed samples to the filter's far-end buffer.
|
||||
while self.delay_available() >= 1 {
|
||||
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
||||
self.delay_read += 1;
|
||||
|
||||
self.far_buf[self.far_pos] = sample;
|
||||
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
||||
|
||||
// Track peak far-end level for Geigel DTD.
|
||||
let abs_s = sample.abs();
|
||||
if abs_s > self.far_peak {
|
||||
self.far_peak = abs_s;
|
||||
}
|
||||
}
|
||||
|
||||
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
||||
self.far_peak *= 0.9995;
|
||||
}
|
||||
|
||||
/// Number of delayed samples available to release.
|
||||
fn delay_available(&self) -> usize {
|
||||
let buffered = self.delay_write - self.delay_read;
|
||||
if buffered > self.delay_samples {
|
||||
buffered - self.delay_samples
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||
///
|
||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
||||
/// mean echo was reduced.
|
||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||
if !self.enabled {
|
||||
return 1.0;
|
||||
@@ -56,85 +130,96 @@ impl EchoCanceller {
|
||||
let n = nearend.len();
|
||||
let fl = self.filter_len;
|
||||
|
||||
// --- Geigel double-talk detection ---
|
||||
// If any near-end sample exceeds threshold * far_peak, assume
|
||||
// the local speaker is active and freeze adaptation.
|
||||
let mut is_doubletalk = self.dtd_holdover > 0;
|
||||
if !is_doubletalk {
|
||||
let threshold_level = self.geigel_threshold * self.far_peak;
|
||||
for &s in nearend.iter() {
|
||||
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
|
||||
is_doubletalk = true;
|
||||
self.dtd_holdover = self.dtd_hold_frames;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dtd_holdover > 0 {
|
||||
self.dtd_holdover -= 1;
|
||||
}
|
||||
|
||||
// Check if far-end is active (otherwise nothing to cancel).
|
||||
let far_active = self.far_peak > 100.0;
|
||||
|
||||
// --- Leaky coefficient decay ---
|
||||
// Applied once per frame for efficiency.
|
||||
let decay = 1.0 - self.leak;
|
||||
for c in self.filter.iter_mut() {
|
||||
*c *= decay;
|
||||
}
|
||||
|
||||
let mut sum_near_sq: f64 = 0.0;
|
||||
let mut sum_err_sq: f64 = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let near_f = nearend[i] as f32;
|
||||
|
||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
||||
// The far-end window for this sample starts at
|
||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
||||
// and goes back filter_len samples.
|
||||
// Position of far-end "now" for this near-end sample.
|
||||
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||
|
||||
// --- Echo estimation: dot(filter, far_end_window) ---
|
||||
let mut echo_est: f32 = 0.0;
|
||||
let mut power: f32 = 0.0;
|
||||
|
||||
// Position of the most-recent far-end sample for this near-end sample.
|
||||
// far_end_pos points to the *next write* position, so the most-recent
|
||||
// sample written is at far_end_pos - 1. We have already called
|
||||
// feed_farend for this block, so the relevant samples are the last
|
||||
// filter_len entries ending just before the current write position,
|
||||
// offset by how far we are into this near-end frame.
|
||||
//
|
||||
// For sample i of the near-end frame, the corresponding far-end
|
||||
// "now" is far_end_pos - n + i (wrapping).
|
||||
// far_end_pos points to next-write, so most recent sample is at
|
||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
||||
// repeatedly to avoid underflow on the usize subtraction.
|
||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
echo_est += self.filter_coeffs[k] * fe;
|
||||
let fe = self.far_buf[fe_idx];
|
||||
echo_est += self.filter[k] * fe;
|
||||
power += fe * fe;
|
||||
}
|
||||
|
||||
let error = near_f - echo_est;
|
||||
|
||||
// --- NLMS coefficient update ---
|
||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
||||
let step = self.mu * error / norm;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
self.filter_coeffs[k] += step * fe;
|
||||
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
||||
if far_active && !is_doubletalk && power > 10.0 {
|
||||
let step = self.mu * error / (power + 1.0);
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
self.filter[k] += step * self.far_buf[fe_idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp output
|
||||
let out = error.max(-32768.0).min(32767.0);
|
||||
let out = error.clamp(-32768.0, 32767.0);
|
||||
nearend[i] = out as i16;
|
||||
|
||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
||||
sum_err_sq += (out as f64) * (out as f64);
|
||||
sum_near_sq += (near_f as f64).powi(2);
|
||||
sum_err_sq += (out as f64).powi(2);
|
||||
}
|
||||
|
||||
// ERLE ratio
|
||||
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) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns whether echo cancellation is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Reset the adaptive filter to its initial state.
|
||||
///
|
||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
||||
pub fn reset(&mut self) {
|
||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||
self.far_end_pos = 0;
|
||||
self.filter.iter_mut().for_each(|c| *c = 0.0);
|
||||
self.far_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||
self.far_pos = 0;
|
||||
self.far_peak = 0.0;
|
||||
self.delay_ring.iter_mut().for_each(|s| *s = 0.0);
|
||||
self.delay_write = 0;
|
||||
self.delay_read = 0;
|
||||
self.dtd_holdover = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,50 +228,40 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aec_creates_with_correct_filter_len() {
|
||||
let aec = EchoCanceller::new(48000, 100);
|
||||
assert_eq!(aec.filter_len, 4800);
|
||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
||||
fn creates_with_correct_sizes() {
|
||||
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
||||
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
||||
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 100);
|
||||
fn passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 60);
|
||||
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 erle = aec.process_frame(&mut frame);
|
||||
assert_eq!(erle, 1.0);
|
||||
aec.process_frame(&mut frame);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reset_zeroes_state() {
|
||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
aec.reset();
|
||||
|
||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
||||
assert_eq!(aec.far_end_pos, 0);
|
||||
fn silence_passthrough() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||
aec.feed_farend(&vec![0i16; 960]);
|
||||
let mut frame = vec![0i16; 960];
|
||||
aec.process_frame(&mut frame);
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reduces_echo_of_known_signal() {
|
||||
// Use a small filter for speed. Feed a known far-end signal, then
|
||||
// present the *same* signal as near-end (perfect echo, no room).
|
||||
// After adaptation the output energy should drop.
|
||||
let filter_ms = 5; // 240 taps at 48 kHz
|
||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
||||
fn reduces_echo_with_no_delay() {
|
||||
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
||||
// (realistic — speaker to mic on laptop loses volume).
|
||||
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
||||
|
||||
// Generate a simple repeating pattern.
|
||||
let frame_len = 480usize;
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
let frame_len = 480;
|
||||
let make_tone = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48000.0;
|
||||
@@ -195,18 +270,16 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Warm up the adaptive filter with several frames.
|
||||
let mut last_erle = 1.0f32;
|
||||
for frame_idx in 0..40 {
|
||||
let farend = make_frame(frame_idx * frame_len);
|
||||
for frame_idx in 0..100 {
|
||||
let farend = make_tone(frame_idx * frame_len);
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
// Near-end = exact copy of far-end (pure echo).
|
||||
let mut nearend = farend.clone();
|
||||
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
||||
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
||||
last_erle = aec.process_frame(&mut nearend);
|
||||
}
|
||||
|
||||
// After 40 frames the ERLE should be meaningfully > 1.
|
||||
assert!(
|
||||
last_erle > 1.0,
|
||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||
@@ -214,15 +287,49 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_silence_passthrough() {
|
||||
let mut aec = EchoCanceller::new(48000, 10);
|
||||
// Feed silence far-end
|
||||
aec.feed_farend(&vec![0i16; 480]);
|
||||
// Near-end is silence too
|
||||
let mut frame = vec![0i16; 480];
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert!(erle >= 1.0);
|
||||
// Output should still be silence
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
fn preserves_nearend_during_doubletalk() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||
|
||||
let frame_len = 960;
|
||||
let nearend: Vec<i16> = (0..frame_len)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Feed silence as far-end (no echo source).
|
||||
aec.feed_farend(&vec![0i16; frame_len]);
|
||||
|
||||
let mut frame = nearend.clone();
|
||||
aec.process_frame(&mut frame);
|
||||
|
||||
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||
let ratio = output_energy / input_energy;
|
||||
|
||||
assert!(
|
||||
ratio > 0.8,
|
||||
"near-end speech should be preserved, energy ratio = {ratio:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_buffer_holds_samples() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
|
||||
// 20ms delay = 960 samples @ 48kHz.
|
||||
// After feeding, feed_farend auto-drains available samples to far_buf.
|
||||
// So delay_available() is always 0 after feed_farend returns.
|
||||
// Instead, verify far_pos advances only after the delay is filled.
|
||||
|
||||
// Feed 960 samples (= delay amount). No samples released yet.
|
||||
aec.feed_farend(&vec![1i16; 960]);
|
||||
// far_buf should still be all zeros (nothing released).
|
||||
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
|
||||
|
||||
// Feed 480 more. 480 should be released to far_buf.
|
||||
aec.feed_farend(&vec![2i16; 480]);
|
||||
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
|
||||
assert!(non_zero > 0, "samples should have been released to far_buf");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
c if c.is_opus() => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
Ok(())
|
||||
|
||||
@@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
c if c.is_opus() => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
self.apply_bitrate(profile.codec)?;
|
||||
|
||||
@@ -110,7 +110,18 @@ impl KeyExchange for WarzoneKeyExchange {
|
||||
hk.expand(b"warzone-session-key", &mut session_key)
|
||||
.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);
|
||||
}
|
||||
|
||||
#[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,
|
||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||
pending_rekey_secret: Option<StaticSecret>,
|
||||
/// Short Authentication String (4-digit code for verbal verification).
|
||||
sas_code: Option<u32>,
|
||||
}
|
||||
|
||||
impl ChaChaSession {
|
||||
@@ -46,9 +48,15 @@ impl ChaChaSession {
|
||||
recv_seq: 0,
|
||||
rekey_mgr: RekeyManager::new(shared_secret),
|
||||
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).
|
||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||
use sha2::Digest;
|
||||
@@ -136,6 +144,10 @@ impl CryptoSession for ChaChaSession {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sas_code(&self) -> Option<u32> {
|
||||
self.sas_code
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||
use wzp_proto::error::FecError;
|
||||
@@ -9,6 +10,9 @@ use wzp_proto::FecDecoder;
|
||||
/// Length prefix size (u16 little-endian), must match encoder.
|
||||
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.
|
||||
struct BlockState {
|
||||
/// Number of source symbols expected.
|
||||
@@ -21,6 +25,8 @@ struct BlockState {
|
||||
decoded: bool,
|
||||
/// Cached decoded result.
|
||||
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.
|
||||
@@ -58,6 +64,7 @@ impl RaptorQFecDecoder {
|
||||
symbol_size: self.symbol_size,
|
||||
decoded: false,
|
||||
result: None,
|
||||
decoded_at: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,8 +81,20 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
let block = self.get_or_create_block(block_id);
|
||||
|
||||
if block.decoded {
|
||||
// Already decoded, ignore additional symbols.
|
||||
return Ok(());
|
||||
// If the block was decoded recently, skip (normal duplicate).
|
||||
// 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).
|
||||
@@ -132,6 +151,7 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
|
||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||
block.decoded = true;
|
||||
block.decoded_at = Some(Instant::now());
|
||||
block.result = Some(frames.clone());
|
||||
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,
|
||||
/// Comfort noise descriptor (silence suppression)
|
||||
ComfortNoise = 5,
|
||||
/// Opus at 32kbps (studio low)
|
||||
Opus32k = 6,
|
||||
/// Opus at 48kbps (studio)
|
||||
Opus48k = 7,
|
||||
/// Opus at 64kbps (studio high)
|
||||
Opus64k = 8,
|
||||
}
|
||||
|
||||
impl CodecId {
|
||||
@@ -27,6 +33,9 @@ impl CodecId {
|
||||
Self::Opus24k => 24_000,
|
||||
Self::Opus16k => 16_000,
|
||||
Self::Opus6k => 6_000,
|
||||
Self::Opus32k => 32_000,
|
||||
Self::Opus48k => 48_000,
|
||||
Self::Opus64k => 64_000,
|
||||
Self::Codec2_3200 => 3_200,
|
||||
Self::Codec2_1200 => 1_200,
|
||||
Self::ComfortNoise => 0,
|
||||
@@ -36,8 +45,7 @@ impl CodecId {
|
||||
/// Preferred frame duration in milliseconds.
|
||||
pub const fn frame_duration_ms(self) -> u8 {
|
||||
match self {
|
||||
Self::Opus24k => 20,
|
||||
Self::Opus16k => 20,
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||
Self::Opus6k => 40,
|
||||
Self::Codec2_3200 => 20,
|
||||
Self::Codec2_1200 => 40,
|
||||
@@ -48,7 +56,8 @@ impl CodecId {
|
||||
/// Sample rate expected by this codec.
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
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::ComfortNoise => 48_000,
|
||||
}
|
||||
@@ -63,6 +72,9 @@ impl CodecId {
|
||||
3 => Some(Self::Codec2_3200),
|
||||
4 => Some(Self::Codec2_1200),
|
||||
5 => Some(Self::ComfortNoise),
|
||||
6 => Some(Self::Opus32k),
|
||||
7 => Some(Self::Opus48k),
|
||||
8 => Some(Self::Opus64k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -71,6 +83,12 @@ impl CodecId {
|
||||
pub const fn to_wire(self) -> 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.
|
||||
@@ -111,6 +129,30 @@ impl QualityProfile {
|
||||
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.
|
||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||
|
||||
@@ -273,10 +273,21 @@ impl JitterBuffer {
|
||||
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) {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
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
|
||||
@@ -412,10 +423,21 @@ impl JitterBuffer {
|
||||
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) {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
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
|
||||
|
||||
@@ -25,8 +25,9 @@ pub mod traits;
|
||||
pub use codec_id::{CodecId, QualityProfile};
|
||||
pub use error::*;
|
||||
pub use packet::{
|
||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
||||
FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||
|
||||
@@ -656,6 +656,112 @@ pub enum SignalMessage {
|
||||
/// List of participants currently in the room.
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
// ── Federation signals (relay-to-relay) ──
|
||||
|
||||
/// Federation: initial handshake — the connecting relay identifies itself.
|
||||
FederationHello {
|
||||
/// TLS certificate fingerprint of the connecting relay.
|
||||
tls_fingerprint: String,
|
||||
},
|
||||
|
||||
/// Federation: this relay now has local participants in a global room.
|
||||
GlobalRoomActive {
|
||||
room: String,
|
||||
/// Participants on the announcing relay (for federated presence).
|
||||
#[serde(default)]
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
/// Federation: this relay's last local participant left a global room.
|
||||
GlobalRoomInactive {
|
||||
room: String,
|
||||
},
|
||||
|
||||
// ── Direct calling signals (client ↔ relay signaling) ──
|
||||
|
||||
/// Register on relay for direct calls. Sent on `_signal` connections
|
||||
/// after optional AuthToken.
|
||||
RegisterPresence {
|
||||
/// Client's Ed25519 identity public key.
|
||||
identity_pub: [u8; 32],
|
||||
/// Signature over ("register-presence" || identity_pub).
|
||||
signature: Vec<u8>,
|
||||
/// Optional display name.
|
||||
alias: Option<String>,
|
||||
},
|
||||
|
||||
/// Relay confirms presence registration.
|
||||
RegisterPresenceAck {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Direct call offer routed through the relay to a specific peer.
|
||||
DirectCallOffer {
|
||||
/// Caller's fingerprint.
|
||||
caller_fingerprint: String,
|
||||
/// Caller's display name.
|
||||
caller_alias: Option<String>,
|
||||
/// Target's fingerprint.
|
||||
target_fingerprint: String,
|
||||
/// Unique call session ID (UUID).
|
||||
call_id: String,
|
||||
/// Caller's Ed25519 identity pub.
|
||||
identity_pub: [u8; 32],
|
||||
/// Caller's ephemeral X25519 pub (for key exchange on media connect).
|
||||
ephemeral_pub: [u8; 32],
|
||||
/// Signature over (ephemeral_pub || target_fingerprint || call_id).
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
},
|
||||
|
||||
/// Callee's response to a direct call.
|
||||
DirectCallAnswer {
|
||||
call_id: String,
|
||||
/// How the callee accepts (or rejects).
|
||||
accept_mode: CallAcceptMode,
|
||||
/// Callee's identity pub (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
identity_pub: Option<[u8; 32]>,
|
||||
/// Callee's ephemeral pub (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ephemeral_pub: Option<[u8; 32]>,
|
||||
/// Signature (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<Vec<u8>>,
|
||||
/// Chosen quality profile (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chosen_profile: Option<crate::QualityProfile>,
|
||||
},
|
||||
|
||||
/// Relay tells both parties: media room is ready.
|
||||
CallSetup {
|
||||
call_id: String,
|
||||
/// Room name on the relay for the media session (e.g., "_call:a1b2c3d4").
|
||||
room: String,
|
||||
/// Relay address for the QUIC media connection.
|
||||
relay_addr: String,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
CallRinging {
|
||||
call_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the callee responds to a direct call.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CallAcceptMode {
|
||||
/// Reject the call.
|
||||
Reject,
|
||||
/// Accept with trust — in Phase 2, this enables P2P (reveals IP).
|
||||
/// In Phase 1, behaves the same as AcceptGeneric.
|
||||
AcceptTrusted,
|
||||
/// Accept with privacy — relay always mediates media.
|
||||
AcceptGeneric,
|
||||
}
|
||||
|
||||
/// A participant entry in a RoomUpdate message.
|
||||
@@ -665,6 +771,10 @@ pub struct RoomParticipant {
|
||||
pub fingerprint: String,
|
||||
/// Optional display name set by the client.
|
||||
pub alias: Option<String>,
|
||||
/// Relay label — identifies which relay this participant is connected to.
|
||||
/// None for local participants, Some("Relay B") for federated.
|
||||
#[serde(default)]
|
||||
pub relay_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
|
||||
@@ -132,6 +132,14 @@ pub trait CryptoSession: Send + Sync {
|
||||
fn overhead(&self) -> usize {
|
||||
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.
|
||||
|
||||
@@ -28,6 +28,9 @@ prometheus = "0.13"
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "6"
|
||||
sha2 = { workspace = true }
|
||||
chrono = "0.4"
|
||||
|
||||
[[bin]]
|
||||
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 std::net::SocketAddr;
|
||||
|
||||
/// Configuration for the relay daemon.
|
||||
/// A federated peer relay.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
/// Address of the peer relay (e.g., "193.180.213.68:4433").
|
||||
pub url: String,
|
||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||
pub fingerprint: String,
|
||||
/// Optional human-readable label.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// A trusted relay — accepts inbound federation without needing the peer's address.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TrustedConfig {
|
||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||
pub fingerprint: String,
|
||||
/// Optional human-readable label.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// A room declared global — bridged across all federated peers.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GlobalRoomConfig {
|
||||
/// Room name to bridge (e.g., "android").
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Configuration for the relay daemon.
|
||||
///
|
||||
/// 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 {
|
||||
/// Address to listen on for incoming connections (client-facing).
|
||||
pub listen_addr: SocketAddr,
|
||||
@@ -44,6 +77,22 @@ pub struct RelayConfig {
|
||||
pub ws_port: Option<u16>,
|
||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||
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 {
|
||||
@@ -62,6 +111,100 @@ impl Default for RelayConfig {
|
||||
trunking_enabled: false,
|
||||
ws_port: 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?;
|
||||
|
||||
// Derive caller fingerprint from their identity public key (first 8 bytes as hex)
|
||||
let caller_fp = caller_identity_pub[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
|
||||
// Must match the format used in signal registration and presence.
|
||||
let caller_fp = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(&caller_identity_pub);
|
||||
let fp = wzp_crypto::Fingerprint([
|
||||
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
||||
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
||||
]);
|
||||
fp.to_string()
|
||||
};
|
||||
|
||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||
}
|
||||
|
||||
/// Select the best quality profile from those the caller supports.
|
||||
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||
// Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
|
||||
if supported.is_empty() {
|
||||
return QualityProfile::GOOD;
|
||||
}
|
||||
// Pick the profile with the highest bitrate.
|
||||
supported
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
a.total_bitrate_kbps()
|
||||
.partial_cmp(&b.total_bitrate_kbps())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(QualityProfile::GOOD)
|
||||
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested
|
||||
// for federation reliability (large packets may exceed path MTU).
|
||||
QualityProfile::GOOD
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
//! quality transitions.
|
||||
|
||||
pub mod auth;
|
||||
pub mod call_registry;
|
||||
pub mod config;
|
||||
pub mod event_log;
|
||||
pub mod federation;
|
||||
pub mod signal_hub;
|
||||
pub mod handshake;
|
||||
pub mod metrics;
|
||||
pub mod pipeline;
|
||||
|
||||
@@ -13,9 +13,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
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::metrics::RelayMetrics;
|
||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||
@@ -23,12 +23,54 @@ use wzp_relay::presence::PresenceRegistry;
|
||||
use wzp_relay::room::{self, RoomManager};
|
||||
use wzp_relay::session_mgr::SessionManager;
|
||||
|
||||
fn parse_args() -> RelayConfig {
|
||||
let mut config = RelayConfig::default();
|
||||
/// Parsed CLI result — config + identity path.
|
||||
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();
|
||||
|
||||
// First pass: extract --config and --identity
|
||||
let mut config_file = None;
|
||||
let mut identity_path = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
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" => {
|
||||
i += 1;
|
||||
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(),
|
||||
);
|
||||
}
|
||||
"--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" => {
|
||||
// Print mesh table from a fresh registry and exit.
|
||||
// In practice this is useful after the relay has been running;
|
||||
@@ -90,9 +154,11 @@ fn parse_args() -> RelayConfig {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--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!("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!(" --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)");
|
||||
@@ -102,6 +168,8 @@ fn parse_args() -> RelayConfig {
|
||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||
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!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||
eprintln!();
|
||||
@@ -116,7 +184,7 @@ fn parse_args() -> RelayConfig {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
config
|
||||
CliResult { config, identity_path, config_file, config_needs_create }
|
||||
}
|
||||
|
||||
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]
|
||||
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();
|
||||
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.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));
|
||||
}
|
||||
|
||||
// Generate ephemeral relay identity for crypto handshake
|
||||
let relay_seed = wzp_crypto::Seed::generate();
|
||||
// Load or generate relay identity
|
||||
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;
|
||||
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))?;
|
||||
|
||||
// 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
|
||||
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
||||
if let Some(remote_addr) = config.remote_relay {
|
||||
@@ -230,9 +418,41 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Room manager (room mode only)
|
||||
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
|
||||
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
|
||||
if !config.probe_targets.is_empty() {
|
||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||
@@ -267,13 +487,32 @@ async fn main() -> anyhow::Result<()> {
|
||||
} else {
|
||||
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...");
|
||||
|
||||
loop {
|
||||
let connection = match wzp_transport::accept(&endpoint).await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => { error!("accept: {e}"); continue; }
|
||||
// Pull the next Incoming off the queue. Deliberately do NOT await
|
||||
// the QUIC handshake here — move that into the per-connection
|
||||
// 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();
|
||||
@@ -283,10 +522,28 @@ async fn main() -> anyhow::Result<()> {
|
||||
let relay_seed_bytes = relay_seed.0;
|
||||
let metrics = metrics.clone();
|
||||
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 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 {
|
||||
// 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 room_name = connection
|
||||
@@ -299,6 +556,23 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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.
|
||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||
if room_name == "_probe" {
|
||||
@@ -385,6 +659,290 @@ async fn main() -> anyhow::Result<()> {
|
||||
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: if --auth-url is set, expect AuthToken as first signal
|
||||
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
|
||||
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
|
||||
{
|
||||
let mut reg = presence.lock().await;
|
||||
@@ -503,6 +1083,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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 mut mgr = room_mgr.lock().await;
|
||||
match mgr.join(
|
||||
@@ -515,7 +1109,25 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok((id, update, senders)) => {
|
||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
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
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -533,6 +1145,25 @@ async fn main() -> anyhow::Result<()> {
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.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_mgr.clone(),
|
||||
room_name,
|
||||
@@ -541,6 +1172,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
metrics.clone(),
|
||||
&session_id_str,
|
||||
trunking_enabled,
|
||||
debug_tap,
|
||||
federation_tx,
|
||||
federation_room_hash,
|
||||
).await;
|
||||
|
||||
// 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 auth_attempts: IntCounterVec,
|
||||
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
|
||||
pub session_buffer_depth: IntGaugeVec,
|
||||
pub session_loss_pct: GaugeVec,
|
||||
@@ -60,6 +67,28 @@ impl RelayMetrics {
|
||||
)
|
||||
.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(
|
||||
Opts::new(
|
||||
"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(auth_attempts.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_loss_pct.clone())).expect("register");
|
||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||
@@ -120,6 +155,12 @@ impl RelayMetrics {
|
||||
bytes_forwarded,
|
||||
auth_attempts,
|
||||
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_loss_pct,
|
||||
session_rtt_ms,
|
||||
|
||||
@@ -18,6 +18,38 @@ use wzp_proto::MediaTransport;
|
||||
use crate::metrics::RelayMetrics;
|
||||
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.
|
||||
pub type ParticipantId = u64;
|
||||
|
||||
@@ -27,6 +59,22 @@ fn next_id() -> ParticipantId {
|
||||
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.
|
||||
#[derive(Clone)]
|
||||
pub enum ParticipantSender {
|
||||
@@ -132,6 +180,7 @@ impl Room {
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
relay_label: None, // local participant
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -157,24 +206,35 @@ pub struct RoomManager {
|
||||
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
||||
/// fingerprints can join the corresponding room.
|
||||
acl: Option<HashMap<String, HashSet<String>>>,
|
||||
/// Channel for room lifecycle events (federation subscribes).
|
||||
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
pub fn new() -> Self {
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
acl: None,
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a room manager with ACL enforcement enabled.
|
||||
pub fn with_acl() -> Self {
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
Self {
|
||||
rooms: 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.
|
||||
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
||||
if let Some(ref mut acl) = self.acl {
|
||||
@@ -213,8 +273,13 @@ impl RoomManager {
|
||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||
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 id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||
if was_empty {
|
||||
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
|
||||
}
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
@@ -235,12 +300,34 @@ impl RoomManager {
|
||||
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.
|
||||
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) {
|
||||
room.remove(participant_id);
|
||||
if room.is_empty() {
|
||||
self.rooms.remove(room_name);
|
||||
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
||||
info!(room = room_name, "room closed (empty)");
|
||||
return None;
|
||||
}
|
||||
@@ -350,6 +437,9 @@ pub async fn run_participant(
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
trunking_enabled: bool,
|
||||
debug_tap: Option<DebugTap>,
|
||||
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
||||
federation_room_hash: Option<[u8; 8]>,
|
||||
) {
|
||||
if trunking_enabled {
|
||||
run_participant_trunked(
|
||||
@@ -358,7 +448,7 @@ pub async fn run_participant(
|
||||
.await;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
@@ -372,6 +462,9 @@ async fn run_participant_plain(
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
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 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
|
||||
let fwd_start = std::time::Instant::now();
|
||||
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;
|
||||
if fwd_ms > max_forward_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"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
rcgen = "0.13"
|
||||
ed25519-dalek = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
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::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:
|
||||
/// - 30s idle timeout
|
||||
/// - 5s keep-alive interval
|
||||
/// - DATAGRAM extension enabled
|
||||
/// - Conservative flow control for bandwidth-constrained links
|
||||
/// The certificate changes on every call. Use `server_config_from_seed` for
|
||||
/// a deterministic certificate that survives relay restarts.
|
||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||
.expect("failed to generate self-signed cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||
let key_der =
|
||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).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()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
|
||||
@@ -22,8 +22,13 @@ pub mod path_monitor;
|
||||
pub mod quic;
|
||||
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 path_monitor::PathMonitor;
|
||||
pub use quic::QuinnTransport;
|
||||
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
|
||||
}
|
||||
|
||||
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
||||
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||
self.connection
|
||||
.send_datagram(bytes::Bytes::copy_from_slice(data))
|
||||
.map_err(|e| TransportError::Internal(format!("datagram: {e}")))
|
||||
}
|
||||
|
||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||
/// The relay will detect the close and remove this participant from the room.
|
||||
pub fn close_now(&self) {
|
||||
@@ -136,7 +143,7 @@ impl MediaTransport for QuinnTransport {
|
||||
}
|
||||
};
|
||||
|
||||
match datagram::deserialize_media(data) {
|
||||
match datagram::deserialize_media(data.clone()) {
|
||||
Some(packet) => {
|
||||
// Record receive observation
|
||||
{
|
||||
@@ -149,8 +156,10 @@ impl MediaTransport for QuinnTransport {
|
||||
Ok(Some(packet))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("received malformed media datagram");
|
||||
Ok(None)
|
||||
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
|
||||
// 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 |
|
||||
665
docs/DESIGN.md
665
docs/DESIGN.md
@@ -1,168 +1,591 @@
|
||||
# WarzonePhone Detailed Design Decisions
|
||||
# WarzonePhone Design Document
|
||||
|
||||
## Why Opus + Codec2 (Not Just One)
|
||||
> Custom encrypted VoIP protocol built in Rust. Designed for hostile network conditions: 5-70% packet loss, 100-500 kbps throughput, 300-800 ms RTT. Multi-platform: Desktop (Tauri), Android, CLI, Web.
|
||||
|
||||
The dual-codec architecture is driven by the extreme range of network conditions WarzonePhone targets:
|
||||
## System Overview
|
||||
|
||||
**Opus** (24/16/6 kbps) is the clear choice for normal to degraded conditions. It offers excellent quality at moderate bitrates, has built-in inband FEC and DTX (discontinuous transmission), and the `audiopus` crate provides mature Rust bindings to libopus. Opus operates at 48 kHz natively.
|
||||
WarzonePhone is a voice-over-IP system built from scratch in Rust, targeting reliable encrypted voice communication over severely degraded networks. The protocol uses adaptive codecs (Opus + Codec2), fountain-code FEC (RaptorQ), and end-to-end ChaCha20-Poly1305 encryption over a QUIC transport layer.
|
||||
|
||||
**Codec2** (3200/1200 bps) is a narrowband vocoder designed specifically for HF radio links with extreme bandwidth constraints. At 1200 bps (1.2 kbps), it produces intelligible speech in only 6 bytes per 40ms frame -- roughly 20x lower bitrate than Opus at its minimum. The pure-Rust `codec2` crate means no C dependencies for this codec. Codec2 operates at 8 kHz, so the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently.
|
||||
The system comprises three categories of components:
|
||||
|
||||
The `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions.
|
||||
1. **Protocol crates** -- a Rust workspace of 7 library crates with a star dependency graph enabling parallel development
|
||||
2. **Client applications** -- Desktop (Tauri), Android (Kotlin + JNI), CLI, and Web (browser bridge)
|
||||
3. **Relay infrastructure** -- SFU relay daemons with federation, health probing, and Prometheus metrics
|
||||
|
||||
**Bandwidth comparison with FEC overhead:**
|
||||
### Design Principles
|
||||
|
||||
| Tier | Codec Bitrate | FEC Ratio | Total Bandwidth |
|
||||
|------|--------------|-----------|----------------|
|
||||
| GOOD | 24 kbps | 20% | ~28.8 kbps |
|
||||
| DEGRADED | 6 kbps | 50% | ~9.0 kbps |
|
||||
| CATASTROPHIC | 1.2 kbps | 100% | ~2.4 kbps |
|
||||
- **User sovereignty** -- client-driven route selection, BIP39 identity backup, no central authority
|
||||
- **End-to-end encryption** -- relays never see plaintext audio; SFU forwarding preserves E2E encryption
|
||||
- **Adaptive resilience** -- automatic codec and FEC switching based on observed network quality
|
||||
- **Parallel development** -- star dependency graph allows 5 agents/developers to work simultaneously with zero merge conflicts
|
||||
|
||||
At the catastrophic tier, the entire call (audio + FEC + headers) fits within approximately 3 kbps, which is viable even over severely degraded links.
|
||||
## Architecture
|
||||
|
||||
## Why RaptorQ Over Reed-Solomon
|
||||
### Crate Overview
|
||||
|
||||
**Reed-Solomon** is a classical block erasure code. It works well but has fixed-rate overhead: you must decide in advance how many repair symbols to generate, and decoding requires receiving exactly K of any K+R symbols.
|
||||
The workspace contains 7 core crates plus integration binaries:
|
||||
|
||||
**RaptorQ** (RFC 6330) is a fountain code with several advantages for VoIP:
|
||||
| Crate | Purpose | Key Dependencies |
|
||||
|-------|---------|-----------------|
|
||||
| `wzp-proto` | Protocol types, traits, wire format | serde, bytes |
|
||||
| `wzp-codec` | Audio codecs (Opus, Codec2, RNNoise) | audiopus, codec2, nnnoiseless |
|
||||
| `wzp-fec` | Forward error correction | raptorq |
|
||||
| `wzp-crypto` | Cryptography and identity | ed25519-dalek, x25519-dalek, chacha20poly1305, bip39 |
|
||||
| `wzp-transport` | QUIC transport layer | quinn, rustls |
|
||||
| `wzp-relay` | Relay daemon (SFU, federation, metrics) | tokio, prometheus |
|
||||
| `wzp-client` | Call engine and CLI | All above |
|
||||
|
||||
1. **Rateless**: You can generate an arbitrary number of repair symbols on the fly. If conditions worsen mid-block, you can generate additional repair without re-encoding.
|
||||
Additional integration targets: `wzp-web` (browser bridge via WebSocket), Android native library (JNI), Desktop (Tauri).
|
||||
|
||||
2. **Efficient decoding**: RaptorQ can decode from any K symbols with high probability (typically K + 1 or K + 2 suffice), compared to Reed-Solomon which requires exactly K.
|
||||
### Dependency Graph
|
||||
|
||||
3. **Lower computational complexity**: O(K) encoding and decoding time, compared to O(K^2) for Reed-Solomon. This matters for real-time audio at 50 frames/second.
|
||||
```mermaid
|
||||
graph TD
|
||||
PROTO["wzp-proto<br/>(Types, Traits, Wire Format)"]
|
||||
|
||||
4. **Variable block sizes**: The encoder handles 1-56403 source symbols per block (the WZP implementation uses 5-10, but the flexibility is there).
|
||||
CODEC["wzp-codec<br/>(Opus + Codec2 + RNNoise)"]
|
||||
FEC["wzp-fec<br/>(RaptorQ FEC)"]
|
||||
CRYPTO["wzp-crypto<br/>(ChaCha20 + Identity)"]
|
||||
TRANSPORT["wzp-transport<br/>(QUIC / Quinn)"]
|
||||
|
||||
The `raptorq` crate (v2) provides a well-tested pure-Rust implementation. The WZP FEC layer adds length-prefixed padding (2-byte LE prefix + zero-pad to 256 bytes) so that variable-length audio frames can be recovered exactly.
|
||||
RELAY["wzp-relay<br/>(Relay Daemon)"]
|
||||
CLIENT["wzp-client<br/>(CLI + Call Engine)"]
|
||||
WEB["wzp-web<br/>(Browser Bridge)"]
|
||||
DESKTOP["Desktop<br/>(Tauri + CPAL)"]
|
||||
ANDROID["Android<br/>(Kotlin + JNI)"]
|
||||
|
||||
**FEC bandwidth math at different loss rates:**
|
||||
PROTO --> CODEC
|
||||
PROTO --> FEC
|
||||
PROTO --> CRYPTO
|
||||
PROTO --> TRANSPORT
|
||||
|
||||
CODEC --> CLIENT
|
||||
FEC --> CLIENT
|
||||
CRYPTO --> CLIENT
|
||||
TRANSPORT --> CLIENT
|
||||
|
||||
CODEC --> RELAY
|
||||
FEC --> RELAY
|
||||
CRYPTO --> RELAY
|
||||
TRANSPORT --> RELAY
|
||||
|
||||
CLIENT --> WEB
|
||||
CLIENT --> DESKTOP
|
||||
CLIENT --> ANDROID
|
||||
TRANSPORT --> WEB
|
||||
|
||||
FC["warzone-protocol<br/>(featherChat Identity)"] -.->|path dep| CRYPTO
|
||||
|
||||
style PROTO fill:#6c5ce7,color:#fff
|
||||
style RELAY fill:#ff9f43,color:#fff
|
||||
style CLIENT fill:#00b894,color:#fff
|
||||
style WEB fill:#0984e3,color:#fff
|
||||
style DESKTOP fill:#0984e3,color:#fff
|
||||
style ANDROID fill:#0984e3,color:#fff
|
||||
style FC fill:#fd79a8,color:#fff
|
||||
```
|
||||
|
||||
The star pattern ensures each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`) depends only on `wzp-proto` and never on each other. This enables:
|
||||
|
||||
- **Parallel development** -- 5 agents work on 5 crates with no merge conflicts
|
||||
- **Independent testing** -- each crate has self-contained tests
|
||||
- **Pluggability** -- any implementation can be swapped by implementing the same trait
|
||||
- **Fast compilation** -- changing one leaf only recompiles that leaf and integration crates
|
||||
|
||||
## Audio Pipeline
|
||||
|
||||
### Encode Pipeline (Mic to Network)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Mic as Microphone
|
||||
participant RNN as RNNoise Denoise
|
||||
participant VAD as Silence Detector
|
||||
participant ENC as Opus/Codec2 Encode
|
||||
participant FEC as RaptorQ FEC Encode
|
||||
participant INT as Interleaver
|
||||
participant HDR as Header Assembly
|
||||
participant CRYPT as ChaCha20-Poly1305
|
||||
participant QUIC as QUIC Datagram
|
||||
|
||||
Mic->>RNN: PCM i16 x 960 (20ms @ 48kHz)
|
||||
RNN->>VAD: Denoised samples (2 x 480)
|
||||
alt Silence detected (>100ms)
|
||||
VAD->>ENC: ComfortNoise packet (every 200ms)
|
||||
else Active speech or hangover
|
||||
VAD->>ENC: Active audio frame
|
||||
end
|
||||
ENC->>FEC: Compressed frame (padded to 256 bytes)
|
||||
FEC->>FEC: Accumulate block (5-10 frames)
|
||||
FEC->>INT: Source + repair symbols
|
||||
INT->>HDR: Interleaved packets (depth=3)
|
||||
HDR->>CRYPT: MediaHeader (12B) or MiniHeader (4B)
|
||||
CRYPT->>QUIC: Header=AAD, Payload=encrypted
|
||||
```
|
||||
|
||||
### Decode Pipeline (Network to Speaker)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant QUIC as QUIC Datagram
|
||||
participant CRYPT as ChaCha20-Poly1305
|
||||
participant HDR as Header Parse
|
||||
participant DEINT as De-interleaver
|
||||
participant FEC as RaptorQ FEC Decode
|
||||
participant JIT as Jitter Buffer
|
||||
participant DEC as Opus/Codec2 Decode
|
||||
participant SPK as Speaker
|
||||
|
||||
QUIC->>CRYPT: Encrypted packet
|
||||
CRYPT->>HDR: Decrypt (header=AAD)
|
||||
HDR->>DEINT: Parsed MediaHeader + payload
|
||||
DEINT->>FEC: Reordered symbols
|
||||
FEC->>FEC: Reconstruct from any K of K+R symbols
|
||||
FEC->>JIT: Recovered audio frames
|
||||
JIT->>JIT: Sequence-ordered BTreeMap
|
||||
JIT->>DEC: Pop when depth >= target
|
||||
DEC->>SPK: PCM i16 x 960
|
||||
```
|
||||
|
||||
## Codec System
|
||||
|
||||
WarzonePhone uses a dual-codec architecture to cover the full range of network conditions:
|
||||
|
||||
### Opus (Primary)
|
||||
|
||||
Opus is the primary codec for normal to degraded conditions. It operates at 48 kHz natively with built-in inband FEC and DTX (discontinuous transmission). The `audiopus` crate provides mature Rust bindings to libopus.
|
||||
|
||||
| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
|
||||
|---------|---------|---------------|-----------|----------------|----------|
|
||||
| Studio 64k | 64 kbps | 20ms | 10% | 70.4 kbps | LAN, excellent WiFi |
|
||||
| Studio 48k | 48 kbps | 20ms | 10% | 52.8 kbps | Good WiFi, wired |
|
||||
| Studio 32k | 32 kbps | 20ms | 10% | 35.2 kbps | WiFi, LTE |
|
||||
| Good (24k) | 24 kbps | 20ms | 20% | 28.8 kbps | WiFi, LTE, decent links |
|
||||
| Opus 16k | 16 kbps | 20ms | 20% | 19.2 kbps | 3G, moderate congestion |
|
||||
| Degraded (6k) | 6 kbps | 40ms | 50% | 9.0 kbps | 3G, congested WiFi |
|
||||
|
||||
### Codec2 (Fallback)
|
||||
|
||||
Codec2 is a narrowband vocoder designed for HF radio links with extreme bandwidth constraints. It operates at 8 kHz, and the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently. The pure-Rust `codec2` crate means no C dependencies.
|
||||
|
||||
| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
|
||||
|---------|---------|---------------|-----------|----------------|----------|
|
||||
| Codec2 3200 | 3.2 kbps | 20ms | 50% | 4.8 kbps | Poor conditions |
|
||||
| Catastrophic (1200) | 1.2 kbps | 40ms | 100% | 2.4 kbps | Satellite, extreme loss |
|
||||
|
||||
### ComfortNoise
|
||||
|
||||
When the silence detector identifies no speech activity for over 100ms, the encoder switches to emitting a ComfortNoise packet every 200ms instead of encoding silence. This provides approximately 50% bandwidth savings in typical conversations.
|
||||
|
||||
### Adaptive Switching
|
||||
|
||||
The `AdaptiveEncoder`/`AdaptiveDecoder` in `wzp-codec` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions. The `AdaptiveQualityController` in `wzp-proto` manages tier transitions with hysteresis:
|
||||
|
||||
- **Downgrade**: 3 consecutive bad reports (2 on cellular networks)
|
||||
- **Upgrade**: 10 consecutive good reports (one tier at a time)
|
||||
- **Network handoff**: WiFi-to-cellular switch triggers preemptive one-tier downgrade plus a temporary 10-second FEC boost (+20%)
|
||||
|
||||
Quality tier classification thresholds:
|
||||
|
||||
| Tier | WiFi/Unknown | Cellular |
|
||||
|------|-------------|----------|
|
||||
| Good | loss < 10%, RTT < 400ms | loss < 8%, RTT < 300ms |
|
||||
| Degraded | loss 10-40%, RTT 400-600ms | loss 8-25%, RTT 300-500ms |
|
||||
| Catastrophic | loss > 40%, RTT > 600ms | loss > 25%, RTT > 500ms |
|
||||
|
||||
## Forward Error Correction (FEC)
|
||||
|
||||
### Why RaptorQ Over Reed-Solomon
|
||||
|
||||
WarzonePhone uses RaptorQ (RFC 6330) fountain codes via the `raptorq` crate:
|
||||
|
||||
1. **Rateless** -- generate arbitrary repair symbols on the fly; if conditions worsen mid-block, generate additional repair without re-encoding
|
||||
2. **Efficient decoding** -- decode from any K symbols with high probability (typically K + 1 or K + 2 suffice)
|
||||
3. **Lower complexity** -- O(K) encoding/decoding time vs O(K^2) for Reed-Solomon
|
||||
4. **Variable block sizes** -- 1-56,403 source symbols per block (WZP uses 5-10)
|
||||
|
||||
### FEC Block Structure
|
||||
|
||||
Each FEC block consists of 5-10 audio frames padded to 256-byte symbols with a 2-byte LE length prefix:
|
||||
|
||||
```
|
||||
[len:u16 LE][audio_frame][zero_padding_to_256_bytes]
|
||||
```
|
||||
|
||||
### Loss Survival by FEC Ratio
|
||||
|
||||
With 5 source frames per block:
|
||||
- 20% repair (GOOD): 1 repair symbol. Survives loss of 1 out of 6 packets (16.7% loss).
|
||||
- 50% repair (DEGRADED): 3 repair symbols. Survives loss of 3 out of 8 packets (37.5% loss).
|
||||
- 100% repair (CATASTROPHIC): 5 repair symbols. Survives loss of 5 out of 10 packets (50% loss).
|
||||
|
||||
The benchmark (`wzp-bench --fec --loss 30`) dynamically scales the FEC ratio to survive the requested loss percentage.
|
||||
| FEC Ratio | Repair Symbols | Survives Loss | Profile |
|
||||
|-----------|---------------|---------------|---------|
|
||||
| 10% | 1 | 1 of 6 (16.7%) | Studio |
|
||||
| 20% | 1 | 1 of 6 (16.7%) | Good |
|
||||
| 50% | 3 | 3 of 8 (37.5%) | Degraded |
|
||||
| 100% | 5 | 5 of 10 (50.0%) | Catastrophic |
|
||||
|
||||
## Why QUIC Over Raw UDP
|
||||
### Interleaving
|
||||
|
||||
Raw UDP would be simpler and lower-latency, but QUIC (via the `quinn` crate) provides:
|
||||
Burst loss protection via depth-3 interleaving: packets from 3 consecutive FEC blocks are interleaved before transmission. A burst of 3 consecutive lost packets affects 3 different blocks (1 loss each) rather than destroying 1 block entirely.
|
||||
|
||||
1. **DATAGRAM frames**: Unreliable delivery without head-of-line blocking (RFC 9221). Media packets use this path, so they behave like UDP datagrams but benefit from QUIC's connection management.
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "FEC Encoder"
|
||||
F1[Frame 1] --> BLK[Source Block<br/>5-10 frames]
|
||||
F2[Frame 2] --> BLK
|
||||
F3[Frame 3] --> BLK
|
||||
F4[Frame 4] --> BLK
|
||||
F5[Frame 5] --> BLK
|
||||
BLK --> SRC[Source Symbols]
|
||||
BLK --> REP[Repair Symbols<br/>ratio-dependent]
|
||||
SRC --> INT[Interleaver<br/>depth=3]
|
||||
REP --> INT
|
||||
end
|
||||
|
||||
2. **Reliable streams**: Signaling messages (CallOffer, CallAnswer, Rekey, Hangup) require reliable delivery. QUIC provides multiplexed streams without needing a separate TCP connection.
|
||||
subgraph "Network"
|
||||
INT --> LOSS{Packet Loss}
|
||||
LOSS -->|some lost| RCV[Received Symbols]
|
||||
end
|
||||
|
||||
3. **Built-in congestion control**: QUIC's congestion control prevents overwhelming degraded links, which is important when chaining relays.
|
||||
subgraph "FEC Decoder"
|
||||
RCV --> DEINT[De-interleaver]
|
||||
DEINT --> RAPTORQ[RaptorQ Decode<br/>Any K of K+R]
|
||||
RAPTORQ --> OUT[Original Frames]
|
||||
end
|
||||
|
||||
4. **Connection migration**: QUIC connections survive IP address changes (e.g., WiFi to cellular handoff), which is valuable for mobile clients.
|
||||
|
||||
5. **TLS 1.3 built-in**: The QUIC handshake provides encryption at the transport level. While WZP has its own end-to-end ChaCha20 layer, the QUIC TLS protects the header and signaling from eavesdroppers.
|
||||
|
||||
6. **NAT keepalive**: QUIC's built-in keep-alive (configured at 5-second intervals) maintains NAT bindings without application-level pings.
|
||||
|
||||
7. **Firewall traversal**: QUIC runs on UDP port 443 by default, which is commonly allowed through firewalls. The `wzp` ALPN protocol identifier distinguishes WZP traffic.
|
||||
|
||||
The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP (QUIC short header + DATAGRAM frame overhead).
|
||||
|
||||
## Why ChaCha20-Poly1305 Over AES-GCM
|
||||
|
||||
1. **Software performance**: ChaCha20-Poly1305 is faster than AES-GCM on hardware without AES-NI instructions. This matters for ARM devices (Android phones, Raspberry Pi relays, embedded systems) where AES hardware acceleration may be absent.
|
||||
|
||||
2. **Constant-time by design**: ChaCha20 uses only add-rotate-XOR operations, making it inherently resistant to timing side-channel attacks. AES-GCM implementations without hardware support often require careful constant-time implementation.
|
||||
|
||||
3. **Warzone messenger compatibility**: The existing Warzone messenger uses ChaCha20-Poly1305 for message encryption. Reusing the same primitive simplifies the security audit and allows key material to be shared across messaging and calling.
|
||||
|
||||
4. **16-byte overhead**: Both ChaCha20-Poly1305 and AES-128-GCM produce a 16-byte authentication tag. There is no size advantage to AES-GCM.
|
||||
|
||||
5. **AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data (AAD), ensuring the header is authenticated but not encrypted. This allows relays to read routing information (block ID, sequence number) without decrypting the payload.
|
||||
|
||||
## Why Star Dependency Graph (Parallel Development)
|
||||
|
||||
The workspace follows a strict star dependency pattern:
|
||||
|
||||
```
|
||||
wzp-proto (hub)
|
||||
/ | \ \
|
||||
wzp-codec wzp-fec wzp-crypto wzp-transport
|
||||
\ | / /
|
||||
wzp-relay
|
||||
wzp-client
|
||||
wzp-web
|
||||
style LOSS fill:#e17055,color:#fff
|
||||
style RAPTORQ fill:#00b894,color:#fff
|
||||
```
|
||||
|
||||
- `wzp-proto` defines all trait interfaces and wire format types
|
||||
- Each "leaf" crate (codec, fec, crypto, transport) depends only on `wzp-proto`
|
||||
- No leaf crate depends on another leaf crate
|
||||
- Integration crates (relay, client, web) depend on all leaves
|
||||
## Transport Layer
|
||||
|
||||
This enables:
|
||||
1. **Parallel development**: 5 agents/developers can work on 5 crates simultaneously with zero merge conflicts
|
||||
2. **Independent testing**: Each crate has comprehensive tests that run without requiring other implementations
|
||||
3. **Pluggability**: Any implementation can be swapped (e.g., replace RaptorQ with Reed-Solomon) by implementing the same trait
|
||||
4. **Fast compilation**: Changes to one leaf only recompile that leaf and the integration crates, not other leaves
|
||||
### Why QUIC Over Raw UDP
|
||||
|
||||
## Jitter Buffer Trade-offs
|
||||
WarzonePhone uses QUIC (via the `quinn` crate) rather than raw UDP for several reasons:
|
||||
|
||||
The jitter buffer must balance two competing goals:
|
||||
| Feature | Benefit |
|
||||
|---------|---------|
|
||||
| DATAGRAM frames (RFC 9221) | Unreliable delivery without head-of-line blocking -- behaves like UDP for media |
|
||||
| Reliable streams | Multiplexed signaling (CallOffer, Hangup, Rekey) without a separate TCP connection |
|
||||
| Congestion control | Prevents overwhelming degraded links, important when chaining relays |
|
||||
| Connection migration | Connections survive IP address changes (WiFi to cellular handoff) |
|
||||
| TLS 1.3 built-in | Transport-level encryption protects headers and signaling |
|
||||
| NAT keepalive | 5-second interval maintains NAT bindings without application-level pings |
|
||||
| Firewall traversal | Runs on UDP port 443 with `wzp` ALPN identifier |
|
||||
|
||||
**Lower latency** (smaller buffer):
|
||||
- Better conversational interactivity
|
||||
- Less memory usage
|
||||
- But more vulnerable to jitter and reordering
|
||||
The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP.
|
||||
|
||||
**Higher quality** (larger buffer):
|
||||
- More time to receive out-of-order packets
|
||||
- More time for FEC recovery (repair packets may arrive after source packets)
|
||||
- But adds perceptible delay to the conversation
|
||||
### Wire Formats
|
||||
|
||||
The default configuration:
|
||||
- Target: 10 packets (200ms) for the client, 50 packets (1s) for the relay
|
||||
- Minimum: 3 packets (60ms) before playout begins (client), 25 packets (500ms) for relay
|
||||
- Maximum: 250 packets (5s) absolute cap
|
||||
#### MediaHeader (12 bytes)
|
||||
|
||||
The relay uses a deeper buffer because it needs to absorb jitter from the lossy inter-relay link. The client uses a shallower buffer for lower latency since it is on the last hop.
|
||||
```
|
||||
Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
|
||||
Byte 1: [FecRatioLo:6][unused:2]
|
||||
Bytes 2-3: sequence (u16 BE)
|
||||
Bytes 4-7: timestamp_ms (u32 BE)
|
||||
Byte 8: fec_block_id (u8)
|
||||
Byte 9: fec_symbol_idx (u8)
|
||||
Byte 10: reserved
|
||||
Byte 11: csrc_count
|
||||
|
||||
**Known issue**: The current jitter buffer does not adapt its depth based on observed jitter. It uses sequence-number ordering only, without timestamp-based playout scheduling. This can lead to drift during long calls, as observed in echo tests.
|
||||
V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
|
||||
```
|
||||
|
||||
## Browser Audio: AudioWorklet vs ScriptProcessorNode
|
||||
#### MiniHeader (4 bytes, compressed)
|
||||
|
||||
The web bridge (`crates/wzp-web/static/`) uses AudioWorklet as the primary audio I/O mechanism, with ScriptProcessorNode as a fallback.
|
||||
```
|
||||
Bytes 0-1: timestamp_delta_ms (u16 BE)
|
||||
Bytes 2-3: payload_len (u16 BE)
|
||||
|
||||
**AudioWorklet** (preferred):
|
||||
- Runs on a dedicated audio rendering thread
|
||||
- Lower latency (no main-thread round-trip)
|
||||
- Consistent 128-sample callback timing
|
||||
- Supported in Chrome 66+, Firefox 76+, Safari 14.1+
|
||||
Preceded by FRAME_TYPE_MINI (0x01). Full header every 50 frames (~1s).
|
||||
Saves 8 bytes/packet (67% header reduction).
|
||||
```
|
||||
|
||||
**ScriptProcessorNode** (fallback):
|
||||
- Runs on the main thread via `onaudioprocess` callback
|
||||
- Higher latency, potential glitches from main-thread GC pauses
|
||||
- Deprecated by the Web Audio specification
|
||||
- Used when AudioWorklet is not available
|
||||
#### TrunkFrame (batched datagrams)
|
||||
|
||||
Both paths accumulate Float32 samples into 960-sample (20ms) Int16 frames before sending via WebSocket, matching the WZP codec frame size.
|
||||
```
|
||||
[count:u16]
|
||||
[session_id:2][len:u16][payload:len] x count
|
||||
|
||||
**Playback** uses an AudioWorklet with a ring buffer capped at 200ms (9600 samples at 48 kHz). When the buffer exceeds this limit, old samples are dropped to prevent unbounded drift. The fallback path uses scheduled `AudioBufferSourceNode` instances.
|
||||
Packs multiple session packets into one QUIC datagram.
|
||||
Max 10 entries or 1200 bytes, flushed every 5ms.
|
||||
```
|
||||
|
||||
## Room Mode: SFU vs MCU Trade-offs
|
||||
#### QualityReport (4 bytes, optional trailer)
|
||||
|
||||
WarzonePhone implements an **SFU** (Selective Forwarding Unit) architecture:
|
||||
```
|
||||
Byte 0: loss_pct (0-255 maps to 0-100%)
|
||||
Byte 1: rtt_4ms (0-255 maps to 0-1020ms)
|
||||
Byte 2: jitter_ms
|
||||
Byte 3: bitrate_cap_kbps
|
||||
```
|
||||
|
||||
**SFU** (implemented):
|
||||
- Relay forwards each participant's packets to all other participants unchanged
|
||||
- No transcoding -- the relay never decodes or re-encodes audio
|
||||
- O(N) bandwidth at the relay for N participants (each packet is sent N-1 times)
|
||||
- Each client receives separate streams from each other participant
|
||||
- Client must mix/decode multiple streams locally
|
||||
- Lower relay CPU usage (no transcoding)
|
||||
- End-to-end encryption is preserved (relay never sees plaintext)
|
||||
### Bandwidth Summary
|
||||
|
||||
**MCU** (not implemented, for comparison):
|
||||
- Relay would decode all streams, mix them, and re-encode a single combined stream
|
||||
- O(1) bandwidth to each client (receives one mixed stream)
|
||||
- Requires the relay to have codec keys (breaks E2E encryption)
|
||||
- Higher relay CPU (decoding N streams + mixing + re-encoding)
|
||||
- Audio quality loss from re-encoding
|
||||
| Profile | Audio | FEC Overhead | Total | Silence Savings |
|
||||
|---------|-------|-------------|-------|----------------|
|
||||
| Studio 64k | 64 kbps | 10% = 6.4 kbps | **70.4 kbps** | ~50% with DTX |
|
||||
| Studio 48k | 48 kbps | 10% = 4.8 kbps | **52.8 kbps** | ~50% with DTX |
|
||||
| Studio 32k | 32 kbps | 10% = 3.2 kbps | **35.2 kbps** | ~50% with DTX |
|
||||
| Good (24k) | 24 kbps | 20% = 4.8 kbps | **28.8 kbps** | ~50% with DTX |
|
||||
| Degraded (6k) | 6 kbps | 50% = 3.0 kbps | **9.0 kbps** | ~50% with DTX |
|
||||
| Catastrophic (1.2k) | 1.2 kbps | 100% = 1.2 kbps | **2.4 kbps** | ~50% with DTX |
|
||||
|
||||
The SFU choice is driven by the E2E encryption requirement: since relays never have access to the audio codec keys, they cannot decode, mix, or re-encode. The current room implementation in `crates/wzp-relay/src/room.rs` forwards received datagrams to all other participants in the room with best-effort delivery -- if one send fails, the relay continues to the next participant.
|
||||
Additional savings: MiniHeaders save 8 bytes/packet (67% header reduction). Trunking shares QUIC overhead across multiplexed sessions.
|
||||
|
||||
## Security
|
||||
|
||||
### Identity Model
|
||||
|
||||
Every user has a persistent identity derived from a 32-byte seed:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
SEED["32-byte Seed<br/>(BIP39 Mnemonic: 24 words)"] --> HKDF1["HKDF<br/>info='warzone-ed25519'"]
|
||||
SEED --> HKDF2["HKDF<br/>info='warzone-x25519'"]
|
||||
|
||||
HKDF1 --> ED["Ed25519 SigningKey<br/>(Digital Signatures)"]
|
||||
HKDF2 --> X25519["X25519 StaticSecret<br/>(Key Agreement)"]
|
||||
|
||||
ED --> VKEY["Ed25519 VerifyingKey<br/>(Public)"]
|
||||
X25519 --> XPUB["X25519 PublicKey<br/>(Public)"]
|
||||
|
||||
VKEY --> FP["Fingerprint<br/>SHA-256(pubkey), truncated 16 bytes<br/>xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"]
|
||||
|
||||
style SEED fill:#6c5ce7,color:#fff
|
||||
style FP fill:#fd79a8,color:#fff
|
||||
style ED fill:#ee5a24,color:#fff
|
||||
style X25519 fill:#00b894,color:#fff
|
||||
```
|
||||
|
||||
**BIP39 Mnemonic Backup**: The 32-byte seed can be encoded as a 24-word BIP39 mnemonic for human-readable backup. The same seed produces the same identity on any platform.
|
||||
|
||||
**featherChat Compatibility**: The identity derivation is compatible with the Warzone messenger (featherChat), allowing a shared identity across messaging and calling.
|
||||
|
||||
### Cryptographic Handshake
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant R as Relay / Callee
|
||||
|
||||
Note over C: Derive identity from seed<br/>Ed25519 + X25519 via HKDF
|
||||
|
||||
C->>C: Generate ephemeral X25519 keypair
|
||||
C->>C: Sign(ephemeral_pub || "call-offer")
|
||||
C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
|
||||
|
||||
R->>R: Verify Ed25519 signature
|
||||
R->>R: Generate ephemeral X25519 keypair
|
||||
R->>R: shared_secret = DH(eph_b, eph_a)
|
||||
R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
|
||||
R->>R: Sign(ephemeral_pub || "call-answer")
|
||||
R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, profile }
|
||||
|
||||
C->>C: Verify signature
|
||||
C->>C: shared_secret = DH(eph_a, eph_b)
|
||||
C->>C: session_key = HKDF(shared_secret)
|
||||
|
||||
Note over C,R: Both have identical ChaCha20-Poly1305 session key
|
||||
C->>R: Encrypted media (QUIC datagrams)
|
||||
R->>C: Encrypted media (QUIC datagrams)
|
||||
|
||||
Note over C,R: Rekey every 65,536 packets<br/>New ephemeral DH + HKDF mix
|
||||
```
|
||||
|
||||
### Encryption Details
|
||||
|
||||
| Component | Algorithm | Purpose |
|
||||
|-----------|-----------|---------|
|
||||
| Identity signing | Ed25519 | Authenticate handshake messages |
|
||||
| Key agreement | X25519 (ephemeral) | Derive shared secret |
|
||||
| Key derivation | HKDF-SHA256 | Derive session key from shared secret |
|
||||
| Media encryption | ChaCha20-Poly1305 | Encrypt audio payloads (16-byte tag) |
|
||||
| Nonce construction | Deterministic from sequence number | No nonce reuse, no state sync needed |
|
||||
| Anti-replay | Sliding window (64-packet) | Reject duplicate/old packets |
|
||||
| Forward secrecy | Rekey every 65,536 packets | New ephemeral DH + HKDF mix |
|
||||
|
||||
**Why ChaCha20-Poly1305 over AES-GCM**:
|
||||
- Faster on hardware without AES-NI (ARM phones, Raspberry Pi relays)
|
||||
- Inherently constant-time (add-rotate-XOR only)
|
||||
- Compatible with Warzone messenger (featherChat)
|
||||
- Same 16-byte authentication tag overhead as AES-GCM
|
||||
|
||||
**AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data. The header is authenticated but not encrypted, allowing relays to read routing information (block ID, sequence number) without decrypting the payload.
|
||||
|
||||
### Trust on First Use (TOFU)
|
||||
|
||||
Clients remember the relay's TLS certificate fingerprint after first connection. If the fingerprint changes on a subsequent connection, the desktop client shows a "Server Key Changed" warning dialog. The relay derives its TLS certificate deterministically from its persisted identity seed, so the fingerprint is stable across restarts.
|
||||
|
||||
## Relay Architecture
|
||||
|
||||
### Room Mode (Default SFU)
|
||||
|
||||
In room mode, the relay acts as a Selective Forwarding Unit. Clients join named rooms via the QUIC SNI (Server Name Indication) field. The relay forwards each participant's encrypted packets to all other participants in the room without decoding or re-encoding.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Room Mode (SFU)"
|
||||
C1[Client 1] -->|"QUIC SNI=room-hash"| RM[Room Manager]
|
||||
C2[Client 2] -->|"QUIC SNI=room-hash"| RM
|
||||
C3[Client 3] -->|"QUIC SNI=room-hash"| RM
|
||||
RM --> R1[Room 'podcast']
|
||||
R1 -->|fan-out| C1
|
||||
R1 -->|fan-out| C2
|
||||
R1 -->|fan-out| C3
|
||||
end
|
||||
|
||||
style RM fill:#ff9f43,color:#fff
|
||||
style R1 fill:#fdcb6e
|
||||
```
|
||||
|
||||
**SFU vs MCU trade-off**: SFU was chosen because it preserves end-to-end encryption (the relay never sees plaintext audio). An MCU would need to decode, mix, and re-encode, breaking E2E encryption. The trade-off is O(N) bandwidth at the relay for N participants.
|
||||
|
||||
### Forward Mode
|
||||
|
||||
With `--remote`, the relay forwards all traffic to a remote relay. Used for chaining relays across lossy or censored links:
|
||||
|
||||
```
|
||||
Client --> Relay A (--remote B) --> Relay B --> Destination Client
|
||||
```
|
||||
|
||||
The relay pipeline in forward mode: FEC decode, jitter buffer, then FEC re-encode for the next hop.
|
||||
|
||||
## Federation
|
||||
|
||||
### Overview
|
||||
|
||||
Two or more relays form a federation mesh. Each relay is an independent SFU. When configured to trust each other, they bridge **global rooms** -- participants on relay A in a global room hear participants on relay B in the same room.
|
||||
|
||||
### Configuration
|
||||
|
||||
Federation uses three TOML configuration sections:
|
||||
|
||||
- `[[peers]]` -- outbound connections to peer relays (url + TLS fingerprint)
|
||||
- `[[trusted]]` -- inbound connections accepted from relays (TLS fingerprint only)
|
||||
- `[[global_rooms]]` -- room names to bridge across all federated peers
|
||||
|
||||
### Federation Topology
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Relay A (EU)"
|
||||
A_RM[Room Manager]
|
||||
A_FM[Federation Manager]
|
||||
A1[Alice - local]
|
||||
A2[Bob - local]
|
||||
A_RM --> A_FM
|
||||
end
|
||||
|
||||
subgraph "Relay B (US)"
|
||||
B_RM[Room Manager]
|
||||
B_FM[Federation Manager]
|
||||
B1[Charlie - local]
|
||||
B_RM --> B_FM
|
||||
end
|
||||
|
||||
A_FM <-->|"QUIC SNI='_federation'<br/>GlobalRoomActive/Inactive<br/>Media forwarding"| B_FM
|
||||
|
||||
A1 -->|media| A_RM
|
||||
A2 -->|media| A_RM
|
||||
B1 -->|media| B_RM
|
||||
|
||||
A_RM -->|"federated fan-out"| A1
|
||||
A_RM -->|"federated fan-out"| A2
|
||||
B_RM -->|"federated fan-out"| B1
|
||||
|
||||
style A_FM fill:#6c5ce7,color:#fff
|
||||
style B_FM fill:#6c5ce7,color:#fff
|
||||
style A_RM fill:#ff9f43,color:#fff
|
||||
style B_RM fill:#ff9f43,color:#fff
|
||||
```
|
||||
|
||||
### Protocol
|
||||
|
||||
1. On startup, each relay connects to all configured `[[peers]]` via QUIC with SNI `"_federation"`
|
||||
2. After QUIC handshake, sends `FederationHello { tls_fingerprint }` for identity verification
|
||||
3. Peer verifies the fingerprint against its `[[trusted]]` or `[[peers]]` list
|
||||
4. When a local participant joins a global room, sends `GlobalRoomActive { room }` to all peers
|
||||
5. When the last local participant leaves, sends `GlobalRoomInactive { room }`
|
||||
6. Media is forwarded as `[room_hash:8][original_media_packet]` -- the relay does not decrypt
|
||||
|
||||
### What Relays Do NOT Do
|
||||
|
||||
- **No transcoding** -- media passes through as-is
|
||||
- **No re-encryption** -- packets are already encrypted E2E
|
||||
- **No central coordinator** -- each relay independently connects to configured peers
|
||||
- **No automatic peer discovery** -- peers must be explicitly configured
|
||||
|
||||
### Failure Handling
|
||||
|
||||
- If a peer goes down, local rooms continue working; federated participants disappear from presence
|
||||
- Reconnection: every 30 seconds with exponential backoff up to 5 minutes
|
||||
- If a peer restarts with a different identity, the fingerprint check fails with a clear log message
|
||||
|
||||
## Jitter Buffer
|
||||
|
||||
The jitter buffer balances latency vs quality:
|
||||
|
||||
| Setting | Client | Relay |
|
||||
|---------|--------|-------|
|
||||
| Target depth | 10 packets (200ms) | 50 packets (1s) |
|
||||
| Minimum before playout | 3 packets (60ms) | 25 packets (500ms) |
|
||||
| Maximum cap | 250 packets (5s) | 250 packets (5s) |
|
||||
|
||||
The relay uses a deeper buffer to absorb jitter from lossy inter-relay links. The client uses a shallower buffer for lower latency.
|
||||
|
||||
The adaptive playout delay tracks jitter via exponential moving average and adjusts the target depth:
|
||||
|
||||
```
|
||||
target_delay = ceil(jitter_ema / 20ms) + 2
|
||||
```
|
||||
|
||||
**Known limitation**: The current jitter buffer does not use timestamp-based playout scheduling. It relies on sequence-number ordering only, which can lead to drift during long calls.
|
||||
|
||||
## Signal Messages
|
||||
|
||||
Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
|
||||
|
||||
```
|
||||
[4-byte length prefix][serde_json payload]
|
||||
```
|
||||
|
||||
| Message | Purpose |
|
||||
|---------|---------|
|
||||
| `CallOffer` | Identity, ephemeral key, signature, supported profiles |
|
||||
| `CallAnswer` | Identity, ephemeral key, signature, chosen profile |
|
||||
| `AuthToken` | featherChat bearer token for relay authentication |
|
||||
| `Hangup` | Reason: Normal, Busy, Declined, Timeout, Error |
|
||||
| `Hold` / `Unhold` | Call hold state |
|
||||
| `Mute` / `Unmute` | Mic mute state |
|
||||
| `Transfer` | Call transfer to another relay/fingerprint |
|
||||
| `Rekey` | New ephemeral key for forward secrecy |
|
||||
| `QualityUpdate` | Quality report + recommended profile |
|
||||
| `Ping` / `Pong` | Latency measurement (timestamp_ms) |
|
||||
| `RoomUpdate` | Participant list changes |
|
||||
| `PresenceUpdate` | Federation presence gossip |
|
||||
| `RouteQuery` / `RouteResponse` | Presence discovery for routing |
|
||||
| `FederationHello` | Relay identity during federation setup |
|
||||
| `GlobalRoomActive` / `GlobalRoomInactive` | Federation room bridging |
|
||||
|
||||
## Test Coverage
|
||||
|
||||
272 tests across all crates, 0 failures:
|
||||
|
||||
| Crate | Tests | Key Coverage |
|
||||
|-------|-------|-------------|
|
||||
| wzp-proto | 41 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
|
||||
| wzp-codec | 31 | Opus/Codec2 roundtrip, silence detection, noise suppression |
|
||||
| wzp-fec | 22 | RaptorQ encode/decode, loss recovery, interleaving |
|
||||
| wzp-crypto | 34 + 28 compat | Encrypt/decrypt, handshake, anti-replay, featherChat identity |
|
||||
| wzp-transport | 2 | QUIC connection setup |
|
||||
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
|
||||
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
|
||||
| wzp-web | 2 | Metrics |
|
||||
|
||||
## Build Requirements
|
||||
|
||||
- **Rust** 1.85+ (2024 edition)
|
||||
- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
|
||||
- **macOS**: Xcode command line tools (CoreAudio included)
|
||||
- **Android**: NDK r27c, cmake 3.28+ (from pip)
|
||||
|
||||
201
docs/PRD-adaptive-quality.md
Normal file
201
docs/PRD-adaptive-quality.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# PRD: Adaptive Quality Control (Auto Codec)
|
||||
|
||||
## Problem
|
||||
|
||||
When a user selects "Auto" quality, the system currently just starts at Opus 24k (GOOD) and never changes. There is no runtime adaptation — if the network degrades mid-call, audio breaks up instead of gracefully stepping down to a lower bitrate codec. Conversely, if the network is excellent, the user stays on 24k when they could have studio-quality 64k.
|
||||
|
||||
The relay already sends `QualityReport` messages with loss % and RTT, and a `QualityAdapter` exists in `call.rs` that classifies network conditions into GOOD/DEGRADED/CATASTROPHIC — but none of this is wired into the Android or desktop engines.
|
||||
|
||||
## Solution
|
||||
|
||||
Wire the existing `QualityAdapter` into both engines so that "Auto" mode continuously monitors network quality and switches codecs mid-call. The full quality range should be used:
|
||||
|
||||
```
|
||||
Excellent network → Studio 64k (best quality)
|
||||
Good network → Opus 24k (default)
|
||||
Degraded network → Opus 6k (lower bitrate, more FEC)
|
||||
Poor network → Codec2 3.2k (vocoder, heavy FEC)
|
||||
Catastrophic → Codec2 1.2k (minimum viable voice)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
Relay ──────────► │ QualityReport │ loss %, RTT, jitter
|
||||
│ (every ~1s) │
|
||||
└────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ QualityAdapter │ classify + hysteresis
|
||||
│ (3-report window) │
|
||||
└────────┬────────────┘
|
||||
│ recommend new profile
|
||||
▼
|
||||
┌──────────────┴──────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ Encoder │ │ Decoder │
|
||||
│ set_profile() │ │ (auto-switch │
|
||||
│ + FEC update │ │ already works)│
|
||||
└────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
## Existing Infrastructure
|
||||
|
||||
### What already exists (in `crates/wzp-client/src/call.rs`)
|
||||
|
||||
1. **`QualityAdapter`** (lines 97-196):
|
||||
- Sliding window of `QualityReport` messages
|
||||
- `classify()`: loss > 15% or RTT > 200ms → CATASTROPHIC, loss > 5% or RTT > 100ms → DEGRADED, else → GOOD
|
||||
- `should_switch()`: hysteresis — requires 3 consecutive reports recommending the same profile before switching
|
||||
- Prevents oscillation between profiles
|
||||
|
||||
2. **`QualityReport`** (in `wzp-proto/src/packet.rs`):
|
||||
- Sent by relay piggy-backed on media packets
|
||||
- Fields: `loss_pct` (u8, 0-255 scaled), `rtt_4ms` (u8, RTT in 4ms units), `jitter_ms`, `bitrate_cap_kbps`
|
||||
|
||||
3. **`CallEncoder::set_profile()`** / **`CallDecoder` auto-switch**:
|
||||
- Encoder can switch codec mid-stream
|
||||
- Decoder already auto-detects incoming codec from packet headers
|
||||
|
||||
### What's missing
|
||||
|
||||
1. **QualityReport ingestion** — neither Android engine nor desktop engine reads quality reports from the relay
|
||||
2. **Profile switch loop** — no periodic check that feeds reports to `QualityAdapter` and applies recommended switches
|
||||
3. **Upward adaptation** — `QualityAdapter` only classifies into 3 tiers (GOOD/DEGRADED/CATASTROPHIC). Needs extension to recommend studio tiers when conditions are excellent (loss < 1%, RTT < 50ms)
|
||||
4. **Notification to UI** — when quality changes, the UI should show the current active codec
|
||||
|
||||
## Requirements
|
||||
|
||||
### Phase 1: Basic Adaptive (3-tier)
|
||||
|
||||
**Both Android and Desktop:**
|
||||
|
||||
1. **Ingest QualityReports**: In the recv loop, extract `quality_report` from incoming `MediaPacket`s when present. Feed to `QualityAdapter`.
|
||||
|
||||
2. **Periodic quality check**: Every 1 second (or on each QualityReport), call `adapter.should_switch(¤t_profile)`. If it returns `Some(new_profile)`:
|
||||
- Switch the encoder: `encoder.set_profile(new_profile)`
|
||||
- Update FEC encoder: `fec_enc = create_encoder(&new_profile)`
|
||||
- Update frame size if changed (e.g., 20ms → 40ms)
|
||||
- Log the switch
|
||||
|
||||
3. **Frame size adaptation on switch**: When switching from 20ms to 40ms frames (or vice versa):
|
||||
- Android: update `frame_samples` variable, resize `capture_buf`
|
||||
- Desktop: same — the send loop reads `frame_samples` dynamically
|
||||
|
||||
4. **UI indicator**: Show current active codec in the call screen stats line.
|
||||
- Android: add to `CallStats` and display in stats text
|
||||
- Desktop: add to `get_status` response and display in stats div
|
||||
|
||||
5. **Only in Auto mode**: Adaptive switching should only happen when the user selected "Auto". If they manually selected a profile, respect their choice.
|
||||
|
||||
### Phase 2: Extended Range (5-tier)
|
||||
|
||||
Extend `QualityAdapter::classify()` to use the full codec range:
|
||||
|
||||
| Condition | Profile | Codec |
|
||||
|-----------|---------|-------|
|
||||
| loss < 1% AND RTT < 30ms | STUDIO_64K | Opus 64k |
|
||||
| loss < 1% AND RTT < 50ms | STUDIO_48K | Opus 48k |
|
||||
| loss < 2% AND RTT < 80ms | STUDIO_32K | Opus 32k |
|
||||
| loss < 5% AND RTT < 100ms | GOOD | Opus 24k |
|
||||
| loss < 15% AND RTT < 200ms | DEGRADED | Opus 6k |
|
||||
| loss >= 15% OR RTT >= 200ms | CATASTROPHIC | Codec2 1.2k |
|
||||
|
||||
With hysteresis:
|
||||
- **Downgrade**: 3 consecutive reports (fast reaction to degradation)
|
||||
- **Upgrade**: 5 consecutive reports (slow, cautious improvement)
|
||||
- **Studio upgrade**: 10 consecutive reports (very conservative — avoid bouncing to 64k on brief good patches)
|
||||
|
||||
### Phase 3: Bandwidth Probing
|
||||
|
||||
Rather than relying solely on loss/RTT:
|
||||
1. Start at GOOD
|
||||
2. After 10 seconds of stable call, probe upward by switching to STUDIO_32K
|
||||
3. If no quality degradation after 5 seconds, probe to STUDIO_48K
|
||||
4. If degradation detected, immediately fall back
|
||||
5. This discovers the true available bandwidth rather than guessing from loss stats
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Android (`crates/wzp-android/src/engine.rs`)
|
||||
|
||||
```rust
|
||||
// In the recv loop, after decoding:
|
||||
if let Some(ref qr) = pkt.quality_report {
|
||||
quality_adapter.ingest(qr);
|
||||
}
|
||||
|
||||
// Periodic check (every 50 frames ≈ 1 second):
|
||||
if auto_profile && frames_decoded % 50 == 0 {
|
||||
if let Some(new_profile) = quality_adapter.should_switch(¤t_profile) {
|
||||
info!(from = ?current_profile.codec, to = ?new_profile.codec, "auto: switching quality");
|
||||
let _ = encoder_ref.lock().set_profile(new_profile);
|
||||
fec_enc_ref.lock() = create_encoder(&new_profile);
|
||||
current_profile = new_profile;
|
||||
frame_samples = frame_samples_for(&new_profile);
|
||||
// Resize capture buffer if needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Challenge**: The encoder is in the send task and the quality reports arrive in the recv task. Need shared state (AtomicU8 for profile index, or a channel).
|
||||
|
||||
**Recommended approach**: Use an `AtomicU8` that the recv task writes and the send task reads:
|
||||
```rust
|
||||
let pending_profile = Arc::new(AtomicU8::new(0xFF)); // 0xFF = no change
|
||||
|
||||
// Recv task: when adapter recommends switch
|
||||
pending_profile.store(new_profile_index, Ordering::Release);
|
||||
|
||||
// Send task: check at frame boundary
|
||||
let p = pending_profile.swap(0xFF, Ordering::Acquire);
|
||||
if p != 0xFF { /* apply switch */ }
|
||||
```
|
||||
|
||||
### Desktop (`desktop/src-tauri/src/engine.rs`)
|
||||
|
||||
Same pattern. The desktop engine already has separate send/recv tasks with shared atomics for mic_muted, etc. Add a `pending_profile: Arc<AtomicU8>` following the same pattern.
|
||||
|
||||
### Desktop CLI (`crates/wzp-client/src/call.rs`)
|
||||
|
||||
The `CallEncoder` already has `set_profile()`. The `CallDecoder` already auto-switches. Just need to:
|
||||
1. Add `QualityAdapter` to `CallDecoder`
|
||||
2. Feed quality reports in `ingest()`
|
||||
3. Check `should_switch()` in `decode_next()`
|
||||
4. Emit the recommendation via a callback or return value
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Local test with tc/netem**: Use Linux traffic control to simulate loss/latency:
|
||||
```bash
|
||||
# Simulate 10% loss, 150ms RTT
|
||||
tc qdisc add dev lo root netem loss 10% delay 75ms
|
||||
# Run 2 clients in auto mode, verify they switch to DEGRADED
|
||||
```
|
||||
|
||||
2. **CLI test**: Run `wzp-client --profile auto` between two instances with simulated network conditions
|
||||
|
||||
3. **Relay quality reports**: Verify the relay actually sends QualityReport messages. If it doesn't yet, that needs to be implemented first (check relay code).
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Does the relay currently send QualityReports?** If not, Phase 1 is blocked until the relay implements per-client loss/RTT tracking and report generation. The relay sees all packets and can compute loss % per sender.
|
||||
|
||||
2. **Codec2 3.2k placement**: Should auto mode use Codec2 3.2k between DEGRADED and CATASTROPHIC? It's 20ms frames (lower latency than Opus 6k's 40ms) but speech-only quality.
|
||||
|
||||
3. **Cross-client adaptation**: If client A is on GOOD and client B auto-adapts to CATASTROPHIC, client A still sends Opus 24k. Client B can decode it fine (auto-switch on recv). But should A also be told to lower quality to save B's bandwidth? This requires signaling between clients.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Phase | Scope | Effort | Dependency |
|
||||
|-------|-------|--------|------------|
|
||||
| 0 | Verify relay sends QualityReports | 0.5 day | None |
|
||||
| 1a | Wire QualityAdapter in Android engine | 1 day | Phase 0 |
|
||||
| 1b | Wire QualityAdapter in desktop engine | 1 day | Phase 0 |
|
||||
| 1c | UI indicator (current codec) | 0.5 day | Phase 1a/1b |
|
||||
| 2 | Extended 5-tier classification | 0.5 day | Phase 1 |
|
||||
| 3 | Bandwidth probing | 2 days | Phase 2 |
|
||||
198
docs/PRD-coordinated-codec.md
Normal file
198
docs/PRD-coordinated-codec.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# PRD: Coordinated Codec Switching (Relay-Judged Quality)
|
||||
|
||||
## Problem
|
||||
|
||||
The current adaptive quality system (`QualityAdapter` in call.rs) exists but isn't wired into either engine. Clients encode at a fixed quality chosen at call start. When network conditions change mid-call, audio degrades instead of gracefully stepping down. When conditions improve, clients stay on low quality unnecessarily.
|
||||
|
||||
Additionally, in SFU mode with multiple participants, uncoordinated codec switching creates asymmetry: if client A upgrades to 64k while B stays on 24k, bandwidth is wasted. Participants should switch together.
|
||||
|
||||
## Solution
|
||||
|
||||
The **relay acts as the quality judge** since it sees both sides of every connection. It monitors packet loss, jitter, and RTT per participant, then signals quality recommendations. Clients react to these signals with coordinated codec switches.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Client A │◄──────►│ Relay │◄──────►│ Client B │
|
||||
│ │ │ (judge) │ │ │
|
||||
│ Encoder │ │ │ │ Encoder │
|
||||
│ Decoder │ │ Monitor │ │ Decoder │
|
||||
└─────────┘ │ per-peer│ └─────────┘
|
||||
│ quality │
|
||||
└────┬────┘
|
||||
│
|
||||
Quality Signals:
|
||||
- StableSignal (conditions good)
|
||||
- DegradeSignal (conditions bad)
|
||||
- UpgradeProposal (try higher quality?)
|
||||
- UpgradeConfirm (all agreed, switch at T)
|
||||
```
|
||||
|
||||
## Quality Classification (Relay-Side)
|
||||
|
||||
The relay monitors each participant's connection quality:
|
||||
|
||||
| Condition | Classification | Action |
|
||||
|-----------|---------------|--------|
|
||||
| loss >= 15% OR RTT >= 200ms | Critical | Immediate downgrade signal |
|
||||
| loss >= 5% OR RTT >= 100ms | Degraded | Downgrade signal after 3 reports |
|
||||
| loss < 2% AND RTT < 80ms | Good | Stable signal |
|
||||
| loss < 1% AND RTT < 50ms for 30s | Excellent | Upgrade proposal |
|
||||
| loss < 0.5% AND RTT < 30ms for 60s | Studio | Studio upgrade proposal |
|
||||
|
||||
## Coordinated Switching Protocol
|
||||
|
||||
### Downgrade (fast, safety-first)
|
||||
|
||||
1. Relay detects degradation for ANY participant
|
||||
2. Relay sends `QualityUpdate { recommended_profile: DEGRADED }` to ALL participants
|
||||
3. ALL participants immediately switch encoder to the recommended profile
|
||||
4. No negotiation — downgrade is mandatory and instant
|
||||
|
||||
### Upgrade (slow, consensual)
|
||||
|
||||
1. Relay detects sustained good conditions for ALL participants (threshold: 30s stable)
|
||||
2. Relay sends `UpgradeProposal { target_profile, switch_timestamp }` to all
|
||||
3. Each client responds: `UpgradeAccept` or `UpgradeReject`
|
||||
4. If ALL accept within 5s → Relay sends `UpgradeConfirm { profile, switch_at_ms }`
|
||||
5. All clients switch encoder at the agreed timestamp (relative to session clock)
|
||||
6. If ANY rejects or times out → upgrade cancelled, stay on current profile
|
||||
|
||||
### Asymmetric Encoding (SFU optimization)
|
||||
|
||||
In SFU mode, each client encodes independently. The relay could allow:
|
||||
- Client A (strong connection): encode at 64k
|
||||
- Client B (weak connection): encode at 6k
|
||||
- Relay forwards A's 64k to B's decoder (auto-switch handles it)
|
||||
- B benefits from A's quality without needing to send at 64k
|
||||
|
||||
This requires NO protocol changes — just each client independently following the relay's recommendation for their own encoding quality. The decoder already handles any codec.
|
||||
|
||||
### Split Network Consideration
|
||||
|
||||
If participant A has great quality but participant C has terrible quality:
|
||||
- Option 1: **Match weakest link** — everyone encodes at C's level (current approach, simple)
|
||||
- Option 2: **Per-participant recommendations** — A encodes at 64k, C encodes at 6k. B (good connection) receives and decodes both. Works because decoders auto-switch per packet.
|
||||
- Option 3: **Relay transcoding** — relay re-encodes A's 64k as 6k for C. Adds CPU on relay, but saves bandwidth for C. Future feature.
|
||||
|
||||
Recommended: start with Option 1 (match weakest), add Option 2 later.
|
||||
|
||||
## Signal Messages (New/Modified)
|
||||
|
||||
```rust
|
||||
/// Quality signal from relay to client
|
||||
QualityDirective {
|
||||
/// Recommended profile to use for encoding
|
||||
recommended_profile: QualityProfile,
|
||||
/// Reason for the recommendation
|
||||
reason: QualityReason,
|
||||
}
|
||||
|
||||
enum QualityReason {
|
||||
/// Network conditions require this quality level
|
||||
NetworkCondition,
|
||||
/// Coordinated upgrade — all participants agreed
|
||||
CoordinatedUpgrade,
|
||||
/// Coordinated downgrade — weakest link determines level
|
||||
CoordinatedDowngrade,
|
||||
}
|
||||
|
||||
/// Upgrade proposal from relay
|
||||
UpgradeProposal {
|
||||
target_profile: QualityProfile,
|
||||
/// Milliseconds from now when the switch would happen
|
||||
switch_delay_ms: u32,
|
||||
}
|
||||
|
||||
/// Client response to upgrade proposal
|
||||
UpgradeResponse {
|
||||
accepted: bool,
|
||||
}
|
||||
|
||||
/// Confirmed upgrade — all clients switch at this time
|
||||
UpgradeConfirm {
|
||||
profile: QualityProfile,
|
||||
/// Session-relative timestamp to switch (ms since call start)
|
||||
switch_at_session_ms: u64,
|
||||
}
|
||||
```
|
||||
|
||||
## Relay-Side Implementation
|
||||
|
||||
### Per-Participant Quality Tracking
|
||||
|
||||
```rust
|
||||
struct ParticipantQuality {
|
||||
/// Sliding window of recent observations
|
||||
loss_samples: VecDeque<f32>, // last 30 seconds
|
||||
rtt_samples: VecDeque<u32>, // last 30 seconds
|
||||
jitter_samples: VecDeque<u32>,
|
||||
/// Current classification
|
||||
classification: QualityClass,
|
||||
/// How long current classification has been stable
|
||||
stable_since: Instant,
|
||||
}
|
||||
```
|
||||
|
||||
### Quality Monitor Task (on relay)
|
||||
|
||||
Runs alongside the SFU forwarding loop:
|
||||
1. Every 1 second, compute per-participant quality from QUIC connection stats
|
||||
2. Classify each participant
|
||||
3. If ANY participant degrades → send downgrade to ALL
|
||||
4. If ALL participants stable for threshold → propose upgrade
|
||||
5. Track upgrade negotiation state
|
||||
|
||||
### Integration with Existing Code
|
||||
|
||||
The relay already has access to:
|
||||
- `QuinnTransport::path_quality()` → loss, RTT, jitter, bandwidth estimates
|
||||
- `QualityReport` embedded in media packet headers
|
||||
- Per-session metrics in `RelayMetrics`
|
||||
|
||||
The quality monitor just needs to read these existing metrics and produce signals.
|
||||
|
||||
## Client-Side Implementation
|
||||
|
||||
### Handling Quality Signals
|
||||
|
||||
In the recv loop (both Android engine and desktop engine):
|
||||
```rust
|
||||
SignalMessage::QualityDirective { recommended_profile, .. } => {
|
||||
// Immediate: switch encoder to recommended profile
|
||||
encoder.set_profile(recommended_profile)?;
|
||||
fec_enc = create_encoder(&recommended_profile);
|
||||
frame_samples = frame_samples_for(&recommended_profile);
|
||||
info!(codec = ?recommended_profile.codec, "quality directive: switched");
|
||||
}
|
||||
```
|
||||
|
||||
### P2P Quality (simpler case)
|
||||
|
||||
For P2P calls (no relay), both clients directly observe quality:
|
||||
1. Each client runs its own `QualityAdapter` on the direct connection
|
||||
2. When quality changes, client proposes to peer via signal
|
||||
3. Simpler negotiation: only 2 parties, no relay middleman
|
||||
4. Same coordinated switching logic, just peer-to-peer signals
|
||||
|
||||
## Backporting P2P → Relay
|
||||
|
||||
The quality monitoring and codec switching logic is identical:
|
||||
- **P2P**: client observes quality directly → proposes switch to peer
|
||||
- **Relay**: relay observes quality → proposes switch to all clients
|
||||
|
||||
The only difference is WHO makes the decision (client vs relay) and HOW many participants need to agree (2 vs N).
|
||||
|
||||
Implementation strategy: build for P2P first (simpler, 2 parties), then wrap the same logic with relay-mediated signals for SFU mode.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|-------|-------|--------|
|
||||
| 1 | Relay-side quality monitor (per-participant tracking) | 1 day |
|
||||
| 2 | Downgrade signal (immediate, match weakest) | 1 day |
|
||||
| 3 | Client handling of QualityDirective | 1 day (both engines) |
|
||||
| 4 | Upgrade proposal + negotiation protocol | 2 days |
|
||||
| 5 | P2P quality adaptation (direct observation) | 1 day |
|
||||
| 6 | Per-participant asymmetric encoding (Option 2) | 1 day |
|
||||
170
docs/PRD-delegated-trust.md
Normal file
170
docs/PRD-delegated-trust.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# PRD: Delegated Trust for Relay Federation
|
||||
|
||||
## Problem
|
||||
|
||||
In the current federation model, when Relay 1 trusts Relay 2, and Relay 2 forwards media from Relay 3, Relay 1 has no way to know or control that Relay 3's traffic is reaching it. This is a trust gap — any relay in the chain can introduce untrusted traffic.
|
||||
|
||||
**Example:** Relay 1 (trusted zone) ←→ Relay 2 (hub) ←→ Relay 3 (unknown)
|
||||
|
||||
Relay 1 explicitly trusts Relay 2. But Relay 2 forwards Relay 3's media to Relay 1 without Relay 1's consent. Relay 1 receives media that originated from an entity it never approved.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a `delegate` flag to `[[trusted]]` entries. When `delegate = true`, the relay accepts media forwarded through the trusted peer from relays that the trusted peer vouches for. When `delegate = false` (default), only media originating from explicitly trusted/peered relays is accepted.
|
||||
|
||||
## Trust Levels
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| `[[peers]]` | "I connect to you and trust your identity" |
|
||||
| `[[trusted]]` | "I accept connections from you" |
|
||||
| `[[trusted]] delegate = true` | "I accept connections from you AND from relays you vouch for" |
|
||||
| No entry | "I reject your connections and drop your forwarded media" |
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml
|
||||
# Relay 1: trusts Relay 2 and delegates trust
|
||||
[[trusted]]
|
||||
fingerprint = "relay-2-tls-fingerprint"
|
||||
label = "Relay 2 (Hub)"
|
||||
delegate = true # Accept relays that Relay 2 forwards from
|
||||
|
||||
# Without delegate (default = false):
|
||||
[[trusted]]
|
||||
fingerprint = "relay-4-tls-fingerprint"
|
||||
label = "Relay 4"
|
||||
# delegate = false (implicit default)
|
||||
# Only direct media from Relay 4 is accepted
|
||||
```
|
||||
|
||||
## Protocol Changes
|
||||
|
||||
### Relay-to-Relay Media Authorization
|
||||
|
||||
When Relay 2 forwards media from Relay 3 to Relay 1, the datagram needs to carry origin information so Relay 1 can decide whether to accept it.
|
||||
|
||||
**Option A: Origin tag in datagram** (recommended)
|
||||
|
||||
Extend the federation datagram format:
|
||||
```
|
||||
[room_hash: 8 bytes][origin_relay_fp: 8 bytes][media_packet]
|
||||
```
|
||||
|
||||
The 8-byte origin fingerprint identifies which relay originally produced the media. The forwarding relay (Relay 2) sets this to the source relay's fingerprint. Relay 1 checks:
|
||||
1. Is the origin relay directly trusted? → accept
|
||||
2. Is the forwarding relay trusted with `delegate = true`? → accept
|
||||
3. Otherwise → drop
|
||||
|
||||
**Option B: Trust announcement signal**
|
||||
|
||||
When Relay 2 connects to Relay 1, it sends a `FederationTrustChain` signal listing which relays it will forward from:
|
||||
```rust
|
||||
FederationTrustChain {
|
||||
/// Fingerprints of relays this peer may forward media from
|
||||
vouched_relays: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Relay 1 checks each fingerprint against its policy:
|
||||
- If Relay 2 has `delegate = true` in Relay 1's config → accept all listed relays
|
||||
- If Relay 2 has `delegate = false` → reject, only accept direct media from Relay 2
|
||||
|
||||
Option B is simpler to implement (no datagram format change) but less granular.
|
||||
|
||||
### Recommended: Option B for v1, Option A for v2
|
||||
|
||||
Option B is simpler — the trust chain is established at connection time, not per-datagram. The forwarding relay announces what it will forward, and the receiving relay approves or rejects upfront.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Config Changes
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TrustedConfig {
|
||||
pub fingerprint: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
/// When true, also accept media forwarded through this relay from
|
||||
/// relays it vouches for. Default: false.
|
||||
#[serde(default)]
|
||||
pub delegate: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### Federation Signal
|
||||
|
||||
```rust
|
||||
/// Sent after FederationHello — lists relays this peer will forward from.
|
||||
FederationTrustChain {
|
||||
/// TLS fingerprints of relays whose media may be forwarded through us.
|
||||
vouched_relays: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Forwarding Authorization
|
||||
|
||||
In `handle_datagram`, before forwarding media to local participants:
|
||||
|
||||
```rust
|
||||
// Check if we should accept this forwarded media
|
||||
let is_authorized = if source_is_direct_peer {
|
||||
true // Direct peer, always accepted
|
||||
} else {
|
||||
// Check if the forwarding peer has delegate=true
|
||||
let forwarding_peer = fm.find_trusted_by_fingerprint(forwarding_peer_fp);
|
||||
forwarding_peer.map(|t| t.delegate).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !is_authorized {
|
||||
warn!("dropping forwarded media from unauthorized relay chain");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Relay 2 (Hub) Behavior
|
||||
|
||||
When Relay 2 receives `FederationTrustChain` queries from peers:
|
||||
1. Collect all directly connected peer fingerprints
|
||||
2. Send `FederationTrustChain { vouched_relays }` to each peer
|
||||
3. When a new relay connects, update all peers' trust chains
|
||||
|
||||
### Anti-Spam Properties
|
||||
|
||||
| Attack | Mitigation |
|
||||
|--------|-----------|
|
||||
| Unknown relay connects to hub | Hub rejects (not in `[[trusted]]`) |
|
||||
| Hub forwards spam relay's media | Receiving relay checks delegate flag, drops if false |
|
||||
| Relay spoofs origin fingerprint | Origin tag is set by the forwarding relay, not the source. The forwarding relay is trusted, so if it lies about origin, the trust is misplaced at the config level. |
|
||||
| Chain amplification (A→B→C→D→...) | TTL on forwarded datagrams (decrement at each hop, drop at 0). Default TTL=2 (one intermediate relay). |
|
||||
|
||||
## TTL for Chain Length
|
||||
|
||||
Add a TTL byte to the federation datagram to limit chain depth:
|
||||
|
||||
```
|
||||
[room_hash: 8 bytes][ttl: 1 byte][media_packet]
|
||||
```
|
||||
|
||||
- Default TTL = 2 (allows one intermediate relay: A→B→C)
|
||||
- Each forwarding relay decrements TTL
|
||||
- When TTL = 0, don't forward further (only deliver to local participants)
|
||||
- Configurable per-relay: `max_federation_hops = 2`
|
||||
|
||||
## Milestones
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|-------|-------|--------|
|
||||
| 1 | Add `delegate` field to `TrustedConfig` | 0.5 day |
|
||||
| 2 | `FederationTrustChain` signal + announcement | 1 day |
|
||||
| 3 | Authorization check in `handle_datagram` | 0.5 day |
|
||||
| 4 | TTL in federation datagrams | 0.5 day |
|
||||
| 5 | Testing: authorized vs unauthorized forwarding | 0.5 day |
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Per-room trust policies (trust Relay X only for room "android")
|
||||
- Dynamic trust negotiation (relays negotiate trust level at runtime)
|
||||
- Revocation (removing a relay from trust chain requires config edit + restart)
|
||||
- Cryptographic proof of origin (signed datagrams from source relay)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user