Compare commits
197 Commits
build/last
...
1f607281fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f607281fd | ||
|
|
7515417202 | ||
|
|
505a834c5b | ||
|
|
27bc264738 | ||
|
|
c27b39d553 | ||
|
|
6db5c25b54 | ||
|
|
54cbebd34e | ||
|
|
86526a7ad4 | ||
|
|
56e3417063 | ||
|
|
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
|
||||
|
||||
3786
Cargo.lock
generated
3786
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@@ -10,6 +10,8 @@ members = [
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-android",
|
||||
"crates/wzp-native",
|
||||
"desktop/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -35,12 +37,19 @@ quinn = "0.11"
|
||||
raptorq = "2"
|
||||
|
||||
# Codec
|
||||
audiopus = "0.3.0-rc.0"
|
||||
# opusic-c: high-level safe bindings over libopus 1.5.2 (encoder side).
|
||||
# opusic-sys: raw FFI for the decoder side — we build our own DecoderHandle
|
||||
# because opusic-c::Decoder.inner is pub(crate) and cannot be reached for the
|
||||
# Phase 3 DRED reconstruction path. See docs/PRD-dred-integration.md.
|
||||
# Pinned exactly (no caret) for reproducible libopus 1.5.2 across the fleet.
|
||||
opusic-c = { version = "=1.5.5", default-features = false, features = ["bundled", "dred"] }
|
||||
opusic-sys = { version = "=0.6.0", default-features = false, features = ["bundled"] }
|
||||
bytemuck = "1"
|
||||
codec2 = "0.3"
|
||||
|
||||
# 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 +62,29 @@ 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.opusic-sys]
|
||||
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
|
||||
|
||||
# Phase 0 (opus-DRED): removed the [patch.crates-io] audiopus_sys = { path =
|
||||
# "vendor/audiopus_sys" } block. That patch existed to fix a Windows clang-cl
|
||||
# SIMD compile bug in libopus 1.3.1. With the swap to opusic-sys (libopus
|
||||
# 1.5.2), the upstream SIMD gating was fixed and the vendor patch is
|
||||
# obsolete. The vendor/audiopus_sys directory itself should be deleted as
|
||||
# part of the same cleanup — see the commit that follows this Phase 0.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ class DebugReporter(private val context: Context) {
|
||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
// Phase 4: extract DRED / classical PLC counters from the
|
||||
// stats JSON so they're visible in the meta preamble at a
|
||||
// glance, not buried in the trailing JSON dump.
|
||||
val dredReconstructions = extractLongField(finalStatsJson, "dred_reconstructions")
|
||||
val classicalPlc = extractLongField(finalStatsJson, "classical_plc_invocations")
|
||||
val framesDecoded = extractLongField(finalStatsJson, "frames_decoded")
|
||||
val fecRecovered = extractLongField(finalStatsJson, "fec_recovered")
|
||||
|
||||
// 1. Call metadata
|
||||
val meta = buildString {
|
||||
appendLine("=== WZ Phone Debug Report ===")
|
||||
@@ -58,6 +66,18 @@ class DebugReporter(private val context: Context) {
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||
appendLine()
|
||||
appendLine("=== Loss Recovery ===")
|
||||
appendLine("Frames decoded: $framesDecoded")
|
||||
appendLine("DRED reconstructions: $dredReconstructions (Opus neural recovery)")
|
||||
appendLine("Classical PLC: $classicalPlc (fallback)")
|
||||
appendLine("RaptorQ FEC recovered: $fecRecovered (Codec2 only)")
|
||||
if (framesDecoded > 0) {
|
||||
val dredPct = 100.0 * dredReconstructions / framesDecoded
|
||||
val plcPct = 100.0 * classicalPlc / framesDecoded
|
||||
appendLine("DRED rate: ${"%.2f".format(dredPct)}%")
|
||||
appendLine("Classical PLC rate: ${"%.2f".format(plcPct)}%")
|
||||
}
|
||||
appendLine()
|
||||
appendLine("=== Final Stats ===")
|
||||
appendLine(finalStatsJson)
|
||||
}
|
||||
@@ -195,4 +215,28 @@ class DebugReporter(private val context: Context) {
|
||||
FileInputStream(file).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny JSON field extractor — pulls an integer value for a top-level
|
||||
* field like `"dred_reconstructions":42`. We don't want to pull in a
|
||||
* full JSON parser just for the debug preamble, and the CallStats
|
||||
* output is a flat record with well-known field names.
|
||||
*
|
||||
* Returns 0 if the field is missing or unparseable.
|
||||
*/
|
||||
private fun extractLongField(json: String, field: String): Long {
|
||||
val key = "\"$field\":"
|
||||
val idx = json.indexOf(key)
|
||||
if (idx < 0) return 0
|
||||
var i = idx + key.length
|
||||
// Skip whitespace
|
||||
while (i < json.length && json[i].isWhitespace()) i++
|
||||
val start = i
|
||||
while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
|
||||
return try {
|
||||
json.substring(start, i).toLong()
|
||||
} catch (_: NumberFormatException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wzp.ui.call
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -89,9 +90,60 @@ fun InCallScreen(
|
||||
val pingResults by viewModel.pingResults.collectAsState()
|
||||
|
||||
var showManageRelays by remember { mutableStateOf(false) }
|
||||
val keyWarning by viewModel.keyWarning.collectAsState()
|
||||
|
||||
// Auto-ping on first display
|
||||
LaunchedEffect(Unit) { viewModel.pingAllServers() }
|
||||
// Key-change warning dialog
|
||||
keyWarning?.let { info ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissKeyWarning() },
|
||||
title = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("\u26A0\uFE0F", fontSize = 40.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Server Key Changed", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"The relay's identity has changed since you last connected. " +
|
||||
"This usually happens when the server was restarted.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("Previously known", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(info.oldFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("New key", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(info.newFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.acceptNewFingerprint() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFACC15))
|
||||
) {
|
||||
Text("Accept New Key", color = Color.Black, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissKeyWarning() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Ping once on launch, then every 5 minutes
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSavedFingerprints()
|
||||
viewModel.pingAllServers()
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(300_000) // 5 minutes
|
||||
viewModel.pingAllServers()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -166,65 +218,211 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Room
|
||||
SectionLabel("ROOM")
|
||||
OutlinedTextField(
|
||||
value = roomName,
|
||||
onValueChange = { viewModel.setRoomName(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// Mode toggle: Room vs Direct Call
|
||||
val callMode by viewModel.callMode.collectAsState()
|
||||
val signalState by viewModel.signalState.collectAsState()
|
||||
val targetFp by viewModel.targetFingerprint.collectAsState()
|
||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||
val incomingCallerAlias by viewModel.incomingCallerAlias.collectAsState()
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Alias
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { viewModel.setAlias(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// AEC + Settings
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = aecEnabled,
|
||||
onCheckedChange = { viewModel.setAecEnabled(it) }
|
||||
)
|
||||
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Surface(
|
||||
onClick = onOpenSettings,
|
||||
Button(
|
||||
onClick = { viewModel.setCallMode(0) },
|
||||
modifier = Modifier.weight(1f).height(36.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u2699", fontSize = 18.sp, color = TextDim)
|
||||
}
|
||||
}
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (callMode == 0) Accent else Color(0xFF333333)
|
||||
)
|
||||
) { Text("Room", color = Color.White, fontSize = 13.sp) }
|
||||
Button(
|
||||
onClick = { viewModel.setCallMode(1) },
|
||||
modifier = Modifier.weight(1f).height(36.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (callMode == 1) Accent else Color(0xFF333333)
|
||||
)
|
||||
) { Text("Direct Call", color = Color.White, fontSize = 13.sp) }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Connect button
|
||||
Button(
|
||||
onClick = { viewModel.startCall() },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
||||
) {
|
||||
Text(
|
||||
"Connect",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
if (callMode == 0) {
|
||||
// ── Room mode ──
|
||||
SectionLabel("ROOM")
|
||||
OutlinedTextField(
|
||||
value = roomName,
|
||||
onValueChange = { viewModel.setRoomName(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { viewModel.setAlias(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = aecEnabled,
|
||||
onCheckedChange = { viewModel.setAecEnabled(it) }
|
||||
)
|
||||
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Surface(
|
||||
onClick = onOpenSettings,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u2699", fontSize = 18.sp, color = TextDim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.startCall() },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
||||
) {
|
||||
Text(
|
||||
"Connect",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// ── Direct call mode ──
|
||||
if (signalState < 5) {
|
||||
// Not registered yet
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { viewModel.setAlias(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.registerForCalls() },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2196F3))
|
||||
) {
|
||||
Text(
|
||||
"Register on Relay",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 5) {
|
||||
// Registered — show dial pad
|
||||
Text(
|
||||
"\u2705 Registered — waiting for calls",
|
||||
color = Green,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Incoming call notification
|
||||
if (incomingCallId != null && incomingCallerFp != null) {
|
||||
Surface(
|
||||
color = Color(0xFF1B5E20),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Incoming Call",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
Text(
|
||||
"From: ${incomingCallerAlias ?: incomingCallerFp?.take(16) ?: "unknown"}",
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.answerIncomingCall(2) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Green),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Accept", color = Color.White) }
|
||||
Button(
|
||||
onClick = { viewModel.rejectIncomingCall() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Red),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Reject", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
SectionLabel("CALL BY FINGERPRINT")
|
||||
OutlinedTextField(
|
||||
value = targetFp,
|
||||
onValueChange = { viewModel.setTargetFingerprint(it) },
|
||||
singleLine = true,
|
||||
placeholder = { Text("Paste fingerprint (xxxx:xxxx:...)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.placeDirectCall() },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent),
|
||||
enabled = targetFp.isNotBlank()
|
||||
) {
|
||||
Text(
|
||||
"Call",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 6) {
|
||||
// Ringing
|
||||
Text(
|
||||
"\uD83D\uDD14 Ringing...",
|
||||
color = Yellow,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (signalState == 7) {
|
||||
// Incoming call (state 7 also handled above in registered view)
|
||||
Text(
|
||||
"\uD83D\uDCDE Incoming call...",
|
||||
color = Green,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { err ->
|
||||
@@ -360,31 +558,54 @@ fun InCallScreen(
|
||||
if (stats.roomParticipantCount > 0) {
|
||||
val unique = stats.roomParticipants
|
||||
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
|
||||
unique.forEach { member ->
|
||||
// Group by relay
|
||||
val grouped = unique.groupBy { it.relayLabel ?: "This Relay" }
|
||||
grouped.forEach { (relay, members) ->
|
||||
// Relay header
|
||||
val isLocal = relay == "This Relay"
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp)
|
||||
) {
|
||||
Identicon(
|
||||
fingerprint = member.fingerprint.ifEmpty { member.displayName },
|
||||
size = 40.dp,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isLocal) Green else Color(0xFF60A5FA))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = member.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
color = Color.White
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = relay.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 0.5.sp),
|
||||
color = TextDim
|
||||
)
|
||||
}
|
||||
members.forEach { member ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
Identicon(
|
||||
fingerprint = member.fingerprint.ifEmpty { member.displayName },
|
||||
size = 40.dp,
|
||||
)
|
||||
if (member.fingerprint.isNotEmpty()) {
|
||||
CopyableFingerprint(
|
||||
fingerprint = member.fingerprint.take(16),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = TextDim,
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = member.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
color = Color.White
|
||||
)
|
||||
if (member.fingerprint.isNotEmpty()) {
|
||||
CopyableFingerprint(
|
||||
fingerprint = member.fingerprint.take(16),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = TextDim,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +633,51 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Stats
|
||||
// Codec + Stats
|
||||
if (stats.currentCodec.isNotEmpty()) {
|
||||
val codecLabel = formatCodecName(stats.currentCodec)
|
||||
val peerLabel = if (stats.peerCodec.isNotEmpty()) formatCodecName(stats.peerCodec) else null
|
||||
val autoTag = if (stats.autoMode) " [Auto]" else ""
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Our codec badge
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = codecColor(stats.currentCodec)
|
||||
) {
|
||||
Text(
|
||||
text = "TX $codecLabel$autoTag",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
if (peerLabel != null) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = codecColor(stats.peerCodec)
|
||||
) {
|
||||
Text(
|
||||
text = "RX $peerLabel",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = "TX: ${stats.framesEncoded} | RX: ${stats.framesDecoded}",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||
@@ -434,6 +699,7 @@ fun InCallScreen(
|
||||
onSelect = { idx -> viewModel.selectServer(idx) },
|
||||
onDelete = { idx -> viewModel.removeServer(idx) },
|
||||
onAdd = { addr, label -> viewModel.addServer(addr, label) },
|
||||
onRefresh = { viewModel.pingAllServers() },
|
||||
onDismiss = { showManageRelays = false }
|
||||
)
|
||||
}
|
||||
@@ -462,6 +728,7 @@ private fun ManageRelaysDialog(
|
||||
onSelect: (Int) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onAdd: (String, String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var addName by remember { mutableStateOf("") }
|
||||
@@ -477,14 +744,26 @@ private fun ManageRelaysDialog(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Manage Relays", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Surface(
|
||||
onClick = onDismiss,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface2,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Surface(
|
||||
onClick = onRefresh,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface2,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u21BB", color = TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
onClick = onDismiss,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface2,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,13 +818,17 @@ private fun ManageRelaysDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"\u00D7",
|
||||
color = TextDim,
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.clickable { onDelete(idx) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Surface(
|
||||
onClick = { onDelete(idx) },
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,3 +1039,25 @@ private fun DebugReportCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map Rust CodecId debug name to a human-readable label. */
|
||||
private fun formatCodecName(codecId: String): String = when (codecId) {
|
||||
"Opus64k" -> "Opus 64k"
|
||||
"Opus48k" -> "Opus 48k"
|
||||
"Opus32k" -> "Opus 32k"
|
||||
"Opus24k" -> "Opus 24k"
|
||||
"Opus16k" -> "Opus 16k"
|
||||
"Opus6k" -> "Opus 6k"
|
||||
"Codec2_3200" -> "C2 3.2k"
|
||||
"Codec2_1200" -> "C2 1.2k"
|
||||
else -> codecId
|
||||
}
|
||||
|
||||
/** Color-code codec badges by quality tier. */
|
||||
private fun codecColor(codecId: String): Color = when (codecId) {
|
||||
"Opus64k", "Opus48k", "Opus32k" -> Color(0xFF0D6EFD) // blue — studio
|
||||
"Opus24k", "Opus16k" -> Color(0xFF198754) // green — good
|
||||
"Opus6k" -> Color(0xFFCC8800) // amber — degraded
|
||||
"Codec2_3200", "Codec2_1200" -> Color(0xFFDC3545) // red — catastrophic
|
||||
else -> Color(0xFF6C757D) // gray
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -17,6 +17,7 @@ wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
@@ -27,9 +28,7 @@ libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
android_logger = "0.14"
|
||||
log = "0.4"
|
||||
tracing-log = "0.2"
|
||||
tracing-android = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
@@ -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,60 @@
|
||||
//! 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 tracing::{debug, error, info, warn};
|
||||
use wzp_codec::AdaptiveDecoder;
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_codec::opus_dec::OpusDecoder;
|
||||
use wzp_codec::opus_enc::OpusEncoder;
|
||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
||||
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 +74,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 +152,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 +161,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 +199,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 +454,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 +489,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 +506,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 +516,28 @@ 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).
|
||||
// Phase 3c: decoder is a concrete AdaptiveDecoder (not Box<dyn
|
||||
// AudioDecoder>) so the recv task can call reconstruct_from_dred on
|
||||
// gaps detected via sequence tracking.
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder");
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
@@ -307,21 +547,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 +603,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;
|
||||
}
|
||||
|
||||
@@ -381,21 +670,34 @@ async fn run_call(
|
||||
t_opus_us += t0.elapsed().as_micros() as u64;
|
||||
let encoded = &encode_buf[..encoded_len];
|
||||
|
||||
// Phase 2: Opus tiers bypass RaptorQ (DRED handles loss recovery
|
||||
// at the codec layer). Codec2 tiers keep RaptorQ unchanged.
|
||||
let is_opus = current_profile.codec.is_opus();
|
||||
let (hdr_fec_block, hdr_fec_symbol, hdr_fec_ratio) = if is_opus {
|
||||
(0u8, 0u8, 0u8)
|
||||
} else {
|
||||
(
|
||||
block_id,
|
||||
frame_in_block,
|
||||
MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
||||
)
|
||||
};
|
||||
|
||||
// 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: hdr_fec_ratio,
|
||||
seq: s,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: frame_in_block,
|
||||
fec_block: hdr_fec_block,
|
||||
fec_symbol: hdr_fec_symbol,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
@@ -425,63 +727,66 @@ async fn run_call(
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// Feed encoded frame to FEC encoder
|
||||
// Codec2-only: feed RaptorQ and emit repair packets when the
|
||||
// block is full. Opus tiers skip this entire block — DRED
|
||||
// (enabled in Phase 1) provides codec-layer loss recovery.
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
if !is_opus {
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
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) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
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 {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = current_profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
}
|
||||
}
|
||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||
t_frames += 1;
|
||||
@@ -511,8 +816,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,7 +827,29 @@ 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();
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
let mut quality_ctrl = AdaptiveQualityController::new();
|
||||
let mut last_peer_codec: Option<CodecId> = None;
|
||||
|
||||
// Phase 3c: DRED reconstruction state. Unlike the desktop
|
||||
// CallDecoder (which sits behind a jitter buffer that emits
|
||||
// Missing signals), engine.rs reads packets directly from the
|
||||
// transport and decodes straight into the playout ring. Gap
|
||||
// detection is therefore done via sequence-number tracking:
|
||||
// when a packet arrives with seq > expected_seq, the frames in
|
||||
// between are missing and we attempt to reconstruct them via
|
||||
// DRED before decoding the newly-arrived packet.
|
||||
let mut dred_decoder =
|
||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
|
||||
let mut dred_parse_scratch =
|
||||
DredState::new().expect("opus_dred_alloc failed (scratch)");
|
||||
let mut last_good_dred =
|
||||
DredState::new().expect("opus_dred_alloc failed (good state)");
|
||||
let mut last_good_dred_seq: Option<u16> = None;
|
||||
let mut expected_seq: Option<u16> = None;
|
||||
let mut dred_reconstructions: u64 = 0;
|
||||
let mut classical_plc_invocations: u64 = 0;
|
||||
|
||||
info!("recv task started (Opus + DRED + Codec2/RaptorQ)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -544,20 +871,181 @@ 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;
|
||||
let pkt_is_opus = pkt.header.codec_id.is_opus();
|
||||
|
||||
// Feed every packet (source + repair) to FEC decoder
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
// Phase 2: Opus packets bypass RaptorQ entirely — DRED
|
||||
// (enabled Phase 1) handles codec-layer loss recovery,
|
||||
// and feeding these symbols into the RaptorQ decoder
|
||||
// would accumulate block_id=0 duplicates that never
|
||||
// decode. Codec2 packets still feed RaptorQ.
|
||||
if !pkt_is_opus {
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Profile switch invalidates the cached DRED
|
||||
// state because samples_available is measured
|
||||
// in the old profile's sample rate. Reset the
|
||||
// tracking so we don't try to reconstruct with
|
||||
// stale offsets.
|
||||
last_good_dred_seq = None;
|
||||
expected_seq = None;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3c: Opus path — parse DRED state out of
|
||||
// the current packet FIRST so last_good_dred
|
||||
// reflects the freshest available reconstruction
|
||||
// source, then attempt gap recovery against it
|
||||
// BEFORE decoding this packet's audio. Ordering
|
||||
// matters because the playout ring is FIFO — gap
|
||||
// samples must be written before this packet's
|
||||
// samples, which come next.
|
||||
if pkt_is_opus {
|
||||
// Update DRED state from the current packet.
|
||||
match dred_decoder.parse_into(&mut dred_parse_scratch, &pkt.payload) {
|
||||
Ok(available) if available > 0 => {
|
||||
std::mem::swap(
|
||||
&mut dred_parse_scratch,
|
||||
&mut last_good_dred,
|
||||
);
|
||||
last_good_dred_seq = Some(pkt.header.seq);
|
||||
}
|
||||
Ok(_) => {
|
||||
// Packet carried no DRED — keep cached state.
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("DRED parse error (ignored): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Detect and fill gap from last-expected to this packet.
|
||||
const MAX_GAP_FRAMES: u16 = 16;
|
||||
if let Some(expected) = expected_seq {
|
||||
let gap = pkt.header.seq.wrapping_sub(expected);
|
||||
if gap > 0 && gap <= MAX_GAP_FRAMES {
|
||||
let current_profile_frame_samples =
|
||||
(48_000 * profile.frame_duration_ms as i32) / 1000;
|
||||
let available = last_good_dred.samples_available();
|
||||
let pcm_slice_len =
|
||||
current_profile_frame_samples as usize;
|
||||
|
||||
for gap_idx in 0..gap {
|
||||
let missing_seq = expected.wrapping_add(gap_idx);
|
||||
// Offset from the DRED anchor (last_good_dred_seq)
|
||||
// back to the missing seq, in samples. Skip if
|
||||
// the anchor is not ahead of missing (defensive).
|
||||
let offset_samples = match last_good_dred_seq {
|
||||
Some(anchor) => {
|
||||
let delta = anchor.wrapping_sub(missing_seq);
|
||||
if delta == 0 || delta > MAX_GAP_FRAMES {
|
||||
-1 // skip DRED, use PLC
|
||||
} else {
|
||||
delta as i32 * current_profile_frame_samples
|
||||
}
|
||||
}
|
||||
None => -1,
|
||||
};
|
||||
|
||||
let reconstructed = if offset_samples > 0
|
||||
&& offset_samples <= available
|
||||
{
|
||||
decoder
|
||||
.reconstruct_from_dred(
|
||||
&last_good_dred,
|
||||
offset_samples,
|
||||
&mut decode_buf[..pcm_slice_len],
|
||||
)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match reconstructed {
|
||||
Some(samples) => {
|
||||
playout_agc.process_frame(
|
||||
&mut decode_buf[..samples],
|
||||
);
|
||||
state
|
||||
.playout_ring
|
||||
.write(&decode_buf[..samples]);
|
||||
dred_reconstructions += 1;
|
||||
frames_decoded += 1;
|
||||
}
|
||||
None => {
|
||||
// Fall through to classical PLC.
|
||||
if let Ok(samples) =
|
||||
decoder.decode_lost(&mut decode_buf)
|
||||
{
|
||||
playout_agc
|
||||
.process_frame(&mut decode_buf[..samples]);
|
||||
state
|
||||
.playout_ring
|
||||
.write(&decode_buf[..samples]);
|
||||
classical_plc_invocations += 1;
|
||||
frames_decoded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the expected-seq tracker for the next arrival.
|
||||
expected_seq = Some(pkt.header.seq.wrapping_add(1));
|
||||
}
|
||||
|
||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||
Ok(samples) => {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
@@ -569,32 +1057,44 @@ async fn run_call(
|
||||
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
// This is a decode-error fallback (not a
|
||||
// detected gap), so count it as PLC.
|
||||
classical_plc_invocations += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try FEC recovery
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
// Codec2-only: try FEC recovery and expire old blocks.
|
||||
// Opus packets skip both — the Phase 2 Opus path has no
|
||||
// RaptorQ state to query or clean up. The `fec_recovered`
|
||||
// counter is now effectively Codec2-only, which is
|
||||
// correct because DRED reconstructions will be counted
|
||||
// separately once Phase 3 lands (new telemetry field).
|
||||
if !pkt_is_opus {
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
}
|
||||
}
|
||||
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_decoded = frames_decoded;
|
||||
stats.fec_recovered = fec_recovered;
|
||||
stats.dred_reconstructions = dred_reconstructions;
|
||||
stats.classical_plc_invocations = classical_plc_invocations;
|
||||
drop(stats);
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
@@ -602,6 +1102,8 @@ async fn run_call(
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
dred_reconstructions,
|
||||
classical_plc_invocations,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
@@ -672,6 +1174,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +48,24 @@ static INIT_LOGGING: Once = Once::new();
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
// Use android_logger directly — tracing_subscriber::registry() allocates
|
||||
// a sharded_slab which causes SIGSEGV on Android 16 MTE devices.
|
||||
// android_logger is lightweight and doesn't trigger scudo crashes.
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("wzp"),
|
||||
);
|
||||
// Bridge tracing → log so our tracing::info! macros work
|
||||
let _ = tracing_log::LogTracer::init();
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// Filter: INFO for our crates, WARN for everything else.
|
||||
// The jni crate emits VERBOSE logs for every method lookup
|
||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||
// and causes the system to kill the app.
|
||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(layer)
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -78,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();
|
||||
@@ -103,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() },
|
||||
@@ -311,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 {
|
||||
@@ -386,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)
|
||||
}
|
||||
@@ -49,8 +58,16 @@ pub struct CallStats {
|
||||
pub frames_decoded: u64,
|
||||
/// Number of playout underruns (buffer empty when audio needed).
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by FEC.
|
||||
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
|
||||
/// RaptorQ per Phase 2).
|
||||
pub fec_recovered: u64,
|
||||
/// Phase 3c: Opus frames reconstructed via DRED side-channel data.
|
||||
/// Only increments on the Opus tiers; always zero for Codec2.
|
||||
pub dred_reconstructions: u64,
|
||||
/// Phase 3c: Opus frames filled via classical Opus PLC because no DRED
|
||||
/// state covered the gap, plus any decode-error fallbacks. Codec2 loss
|
||||
/// also increments this counter via the Codec2 PLC path.
|
||||
pub classical_plc_invocations: u64,
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
@@ -59,10 +76,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 +105,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()))
|
||||
}
|
||||
@@ -7,14 +7,15 @@ use std::time::{Duration, Instant};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
||||
use wzp_codec::{
|
||||
AdaptiveDecoder, AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector,
|
||||
};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{
|
||||
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
|
||||
};
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::packet::QualityReport;
|
||||
use wzp_proto::{CodecId, QualityProfile};
|
||||
|
||||
@@ -42,6 +43,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 +67,7 @@ impl Default for CallConfig {
|
||||
noise_suppression: true,
|
||||
mini_frames_enabled: true,
|
||||
adaptive_jitter: true,
|
||||
aec_delay_ms: 40,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +246,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,
|
||||
@@ -340,6 +345,22 @@ impl CallEncoder {
|
||||
let enc_len = self.audio_enc.encode(pcm, &mut encoded)?;
|
||||
encoded.truncate(enc_len);
|
||||
|
||||
// Phase 2: Opus tiers bypass RaptorQ entirely (DRED handles loss
|
||||
// recovery at the codec layer). Codec2 tiers keep RaptorQ unchanged.
|
||||
// On Opus packets, zero the FEC header fields so old receivers
|
||||
// can cleanly identify "no RaptorQ block to assemble" and new
|
||||
// receivers can short-circuit their FEC ingest path.
|
||||
let is_opus = self.profile.codec.is_opus();
|
||||
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
|
||||
(0u8, 0u8, 0u8)
|
||||
} else {
|
||||
(
|
||||
self.block_id,
|
||||
self.frame_in_block,
|
||||
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
)
|
||||
};
|
||||
|
||||
// Build source media packet
|
||||
let source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
@@ -347,11 +368,11 @@ impl CallEncoder {
|
||||
is_repair: false,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
fec_ratio_encoded,
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: self.frame_in_block,
|
||||
fec_block,
|
||||
fec_symbol,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
@@ -366,39 +387,42 @@ impl CallEncoder {
|
||||
|
||||
let mut output = vec![source_pkt];
|
||||
|
||||
// Add to FEC encoder
|
||||
self.fec_enc.add_source_symbol(&encoded)?;
|
||||
self.frame_in_block += 1;
|
||||
// Codec2-only: feed RaptorQ and generate repair packets when the
|
||||
// block is full. Opus tiers skip this entire block — DRED (active
|
||||
// in Phase 1) provides codec-layer loss recovery.
|
||||
if !is_opus {
|
||||
self.fec_enc.add_source_symbol(&encoded)?;
|
||||
self.frame_in_block += 1;
|
||||
|
||||
// If block is full, generate repair and finalize
|
||||
if self.frame_in_block >= self.profile.frames_per_block {
|
||||
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
output.push(MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
self.profile.fec_ratio,
|
||||
),
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
});
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
if self.frame_in_block >= self.profile.frames_per_block {
|
||||
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
output.push(MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
self.profile.fec_ratio,
|
||||
),
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
});
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
let _ = self.fec_enc.finalize_block();
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.frame_in_block = 0;
|
||||
}
|
||||
let _ = self.fec_enc.finalize_block();
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.frame_in_block = 0;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
@@ -434,9 +458,12 @@ impl CallEncoder {
|
||||
|
||||
/// Manages the recv/decode side of a call.
|
||||
pub struct CallDecoder {
|
||||
/// Audio decoder.
|
||||
audio_dec: Box<dyn AudioDecoder>,
|
||||
/// FEC decoder.
|
||||
/// Audio decoder. Concrete `AdaptiveDecoder` (not `Box<dyn AudioDecoder>`)
|
||||
/// because Phase 3b calls the inherent `reconstruct_from_dred` method,
|
||||
/// which cannot live on the `AudioDecoder` trait without dragging libopus
|
||||
/// types into `wzp-proto`.
|
||||
audio_dec: AdaptiveDecoder,
|
||||
/// FEC decoder (Codec2 tiers only; Opus bypasses RaptorQ per Phase 2).
|
||||
fec_dec: RaptorQFecDecoder,
|
||||
/// Jitter buffer.
|
||||
jitter: JitterBuffer,
|
||||
@@ -450,6 +477,24 @@ pub struct CallDecoder {
|
||||
last_was_cn: bool,
|
||||
/// Mini-frame decompression context (tracks last full header baseline).
|
||||
mini_context: MiniFrameContext,
|
||||
// ─── Phase 3b: DRED reconstruction state ──────────────────────────────
|
||||
/// DRED side-channel parser (a separate libopus object from the decoder).
|
||||
dred_decoder: DredDecoderHandle,
|
||||
/// Scratch buffer used by `dred_decoder.parse_into` on every arriving
|
||||
/// Opus packet. Reused across calls to avoid 10 KB alloc churn per packet.
|
||||
dred_parse_scratch: DredState,
|
||||
/// Cached "most recently parsed valid" DRED state, swapped with
|
||||
/// `dred_parse_scratch` on successful parse. Used by `decode_next` when
|
||||
/// the jitter buffer reports a gap.
|
||||
last_good_dred: DredState,
|
||||
/// Sequence number of the packet that produced `last_good_dred`. `None`
|
||||
/// if no packet has yielded DRED state yet (cold start or legacy sender).
|
||||
last_good_dred_seq: Option<u16>,
|
||||
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
|
||||
pub dred_reconstructions: u64,
|
||||
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
|
||||
/// (because no DRED state covered the gap, or the active codec is Codec2).
|
||||
pub classical_plc_invocations: u64,
|
||||
}
|
||||
|
||||
impl CallDecoder {
|
||||
@@ -459,8 +504,19 @@ impl CallDecoder {
|
||||
} else {
|
||||
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
|
||||
};
|
||||
// Phase 3b: build the DRED parser + state buffers. These allocate
|
||||
// libopus state (~10 KB each) once per call, not per packet — the
|
||||
// scratch and last-good buffers are reused via std::mem::swap on
|
||||
// every successful parse.
|
||||
let dred_decoder =
|
||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed at call setup");
|
||||
let dred_parse_scratch =
|
||||
DredState::new().expect("opus_dred_alloc failed at call setup (scratch)");
|
||||
let last_good_dred =
|
||||
DredState::new().expect("opus_dred_alloc failed at call setup (good state)");
|
||||
Self {
|
||||
audio_dec: wzp_codec::create_decoder(config.profile),
|
||||
audio_dec: AdaptiveDecoder::new(config.profile)
|
||||
.expect("failed to create adaptive decoder"),
|
||||
fec_dec: wzp_fec::create_decoder(&config.profile),
|
||||
jitter,
|
||||
quality: AdaptiveQualityController::new(),
|
||||
@@ -468,6 +524,12 @@ impl CallDecoder {
|
||||
comfort_noise: ComfortNoise::new(50),
|
||||
last_was_cn: false,
|
||||
mini_context: MiniFrameContext::default(),
|
||||
dred_decoder,
|
||||
dred_parse_scratch,
|
||||
last_good_dred,
|
||||
last_good_dred_seq: None,
|
||||
dred_reconstructions: 0,
|
||||
classical_plc_invocations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,20 +544,105 @@ impl CallDecoder {
|
||||
|
||||
/// Feed a received media packet into the decode pipeline.
|
||||
pub fn ingest(&mut self, packet: MediaPacket) {
|
||||
// Feed to FEC decoder
|
||||
let _ = self.fec_dec.add_symbol(
|
||||
packet.header.fec_block,
|
||||
packet.header.fec_symbol,
|
||||
packet.header.is_repair,
|
||||
&packet.payload,
|
||||
);
|
||||
// Phase 2: Opus packets bypass RaptorQ. Codec2 packets still feed
|
||||
// the FEC decoder for recovery. This also cleanly drops any stray
|
||||
// Opus repair packets from an old sender (we don't push repair
|
||||
// packets to the jitter buffer either, so they're effectively
|
||||
// ignored — a graceful mixed-version degradation).
|
||||
if !packet.header.codec_id.is_opus() {
|
||||
let _ = self.fec_dec.add_symbol(
|
||||
packet.header.fec_block,
|
||||
packet.header.fec_symbol,
|
||||
packet.header.is_repair,
|
||||
&packet.payload,
|
||||
);
|
||||
}
|
||||
|
||||
// If not a repair packet, also feed directly to jitter buffer
|
||||
// Phase 3b: Opus source packets carry DRED side-channel data in
|
||||
// libopus 1.5. Parse it into the scratch state and, on success,
|
||||
// swap with the cached `last_good_dred` so later gap reconstruction
|
||||
// has fresh neural redundancy to draw from. Parsing happens before
|
||||
// the jitter push because the jitter buffer consumes the packet.
|
||||
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
|
||||
match self
|
||||
.dred_decoder
|
||||
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
|
||||
{
|
||||
Ok(available) if available > 0 => {
|
||||
// Swap the freshly parsed state into `last_good_dred`.
|
||||
// The old good state (now in scratch) is about to be
|
||||
// overwritten on the next parse — its contents are
|
||||
// not needed after this swap.
|
||||
std::mem::swap(&mut self.dred_parse_scratch, &mut self.last_good_dred);
|
||||
self.last_good_dred_seq = Some(packet.header.seq);
|
||||
}
|
||||
Ok(_) => {
|
||||
// Packet had no DRED data (return 0). Leave the cached
|
||||
// state untouched — it may still cover upcoming gaps
|
||||
// from a warm-up period where the encoder was producing
|
||||
// DRED bytes. The scratch buffer was potentially written
|
||||
// but its `samples_available` is 0 so it's harmless.
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("DRED parse error (ignored): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
|
||||
// Repair packets never reach the jitter buffer; for Codec2 they're
|
||||
// used by the FEC decoder above, for Opus they're dropped here.
|
||||
if !packet.header.is_repair {
|
||||
self.jitter.push(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +657,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),
|
||||
@@ -524,19 +674,72 @@ impl CallDecoder {
|
||||
result
|
||||
}
|
||||
PlayoutResult::Missing { seq } => {
|
||||
// Only generate PLC if there are still packets buffered ahead.
|
||||
// Only attempt recovery if there are still packets buffered ahead.
|
||||
// Otherwise we've drained everything — return None to stop.
|
||||
if self.jitter.depth() > 0 {
|
||||
debug!(seq, "packet loss, generating PLC");
|
||||
let result = self.audio_dec.decode_lost(pcm).ok();
|
||||
if result.is_some() {
|
||||
self.jitter.record_decode();
|
||||
}
|
||||
result
|
||||
} else {
|
||||
if self.jitter.depth() == 0 {
|
||||
self.jitter.record_underrun();
|
||||
None
|
||||
return None;
|
||||
}
|
||||
|
||||
// Phase 3b: try DRED reconstruction first. If we have a
|
||||
// recent DRED state from a packet whose seq > missing seq,
|
||||
// and the seq delta (in samples) fits within the state's
|
||||
// available window, libopus can synthesize a plausible
|
||||
// replacement for the lost frame. Fall back to classical
|
||||
// PLC when no state covers the gap, when the active codec
|
||||
// is Codec2, or when the reconstruction itself errors.
|
||||
if self.profile.codec.is_opus() {
|
||||
if let Some(last_seq) = self.last_good_dred_seq {
|
||||
// How many frames ahead of the missing seq is the
|
||||
// last-good packet? Use wrapping arithmetic for the
|
||||
// u16 seq space.
|
||||
let seq_delta = last_seq.wrapping_sub(seq);
|
||||
// Reject stale or backward state. u16 wraparound
|
||||
// would make a "seq went backward" delta very large;
|
||||
// cap at a sane forward-looking window.
|
||||
const MAX_SEQ_DELTA: u16 = 128;
|
||||
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
|
||||
let frame_samples =
|
||||
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
|
||||
let offset_samples = seq_delta as i32 * frame_samples;
|
||||
let available = self.last_good_dred.samples_available();
|
||||
if offset_samples > 0 && offset_samples <= available {
|
||||
match self.audio_dec.reconstruct_from_dred(
|
||||
&self.last_good_dred,
|
||||
offset_samples,
|
||||
pcm,
|
||||
) {
|
||||
Ok(n) => {
|
||||
self.dred_reconstructions += 1;
|
||||
self.jitter.record_decode();
|
||||
debug!(
|
||||
seq,
|
||||
last_seq,
|
||||
offset_samples,
|
||||
available,
|
||||
"DRED reconstruction for gap"
|
||||
);
|
||||
return Some(n);
|
||||
}
|
||||
Err(e) => {
|
||||
// Reconstruction failed — fall
|
||||
// through to classical PLC below.
|
||||
debug!(seq, "DRED reconstruct error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classical PLC fallback (also the Codec2 path).
|
||||
debug!(seq, "packet loss, generating classical PLC");
|
||||
self.classical_plc_invocations += 1;
|
||||
let result = self.audio_dec.decode_lost(pcm).ok();
|
||||
if result.is_some() {
|
||||
self.jitter.record_decode();
|
||||
}
|
||||
result
|
||||
}
|
||||
PlayoutResult::NotReady => {
|
||||
self.jitter.record_underrun();
|
||||
@@ -559,6 +762,19 @@ impl CallDecoder {
|
||||
pub fn reset_stats(&mut self) {
|
||||
self.jitter.reset_stats();
|
||||
}
|
||||
|
||||
/// Phase 3b introspection: sequence number of the most recently parsed
|
||||
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
|
||||
/// yet. Used by tests to debug reconstruction eligibility.
|
||||
pub fn last_good_dred_seq(&self) -> Option<u16> {
|
||||
self.last_good_dred_seq
|
||||
}
|
||||
|
||||
/// Phase 3b introspection: samples of audio history currently available
|
||||
/// in the cached DRED state.
|
||||
pub fn last_good_dred_samples_available(&self) -> i32 {
|
||||
self.last_good_dred.samples_available()
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic telemetry logger for jitter buffer statistics.
|
||||
@@ -620,18 +836,83 @@ mod tests {
|
||||
assert!(!packets[0].header.is_repair);
|
||||
}
|
||||
|
||||
/// Phase 2: Opus packets have zero FEC header fields — no block, no
|
||||
/// symbol index, no repair ratio. The RaptorQ layer is bypassed
|
||||
/// entirely on the Opus tiers.
|
||||
#[test]
|
||||
fn encoder_generates_repair_on_full_block() {
|
||||
fn opus_source_packets_have_zero_fec_header_fields() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::GOOD, // 5 frames/block
|
||||
profile: QualityProfile::GOOD, // Opus 24k
|
||||
suppression_enabled: false, // skip silence gate for this test
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
let pcm = vec![0i16; 960];
|
||||
// Non-silent sine wave so silence detection doesn't suppress us
|
||||
// even with suppression_enabled=false (belt and braces).
|
||||
let pcm: Vec<i16> = (0..960)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
|
||||
let hdr = &packets[0].header;
|
||||
assert!(hdr.codec_id.is_opus());
|
||||
assert!(!hdr.is_repair);
|
||||
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
|
||||
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
|
||||
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
|
||||
}
|
||||
|
||||
let mut total_packets = 0;
|
||||
let mut repair_count = 0;
|
||||
for _ in 0..5 {
|
||||
/// Phase 2: Opus never emits repair packets, regardless of how many
|
||||
/// source frames are fed in. DRED (Phase 1) provides loss recovery at
|
||||
/// the codec layer; RaptorQ is disabled on Opus tiers.
|
||||
#[test]
|
||||
fn opus_encoder_never_emits_repair_packets() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::GOOD, // 5 frames/block in the Codec2 sense
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
let pcm: Vec<i16> = (0..960)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
|
||||
// Encode well beyond a block boundary to prove no repair ever comes out.
|
||||
let mut total_packets = 0usize;
|
||||
let mut repair_count = 0usize;
|
||||
for _ in 0..20 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
total_packets += packets.len();
|
||||
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
|
||||
}
|
||||
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
|
||||
assert_eq!(
|
||||
total_packets, 20,
|
||||
"20 source frames → 20 source packets (1:1, no RaptorQ expansion)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2: Codec2 still emits repair packets with RaptorQ ratio unchanged.
|
||||
/// DRED is libopus-only and does not apply here, so RaptorQ is still the
|
||||
/// primary loss-recovery mechanism on Codec2 tiers.
|
||||
#[test]
|
||||
fn codec2_encoder_generates_repair_on_full_block() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::CATASTROPHIC, // Codec2 1200, 8 frames/block, ratio 1.0
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
// Codec2 takes 48 kHz samples and downsamples internally.
|
||||
// CATASTROPHIC uses 40 ms frames → 1920 samples.
|
||||
let pcm: Vec<i16> = (0..1920)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
|
||||
let mut total_packets = 0usize;
|
||||
let mut repair_count = 0usize;
|
||||
// Run long enough to cross the 8-frame block boundary and see repairs.
|
||||
for _ in 0..16 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for p in &packets {
|
||||
if p.header.is_repair {
|
||||
@@ -640,8 +921,10 @@ mod tests {
|
||||
}
|
||||
total_packets += packets.len();
|
||||
}
|
||||
assert!(repair_count > 0, "should have repair packets after full block");
|
||||
assert!(total_packets > 5, "total {total_packets} should exceed 5 source");
|
||||
assert!(
|
||||
repair_count > 0,
|
||||
"Codec2 must still emit repair packets (got {repair_count} repairs, {total_packets} total)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -672,6 +955,219 @@ mod tests {
|
||||
assert!(dec.decode_next(&mut pcm).is_none());
|
||||
}
|
||||
|
||||
// ─── Phase 3b — DRED reconstruction on packet loss ────────────────────
|
||||
|
||||
/// Helper: create a CallEncoder/CallDecoder pair with the given profile
|
||||
/// and silence suppression disabled so silence-detection doesn't drop
|
||||
/// our synthetic test frames.
|
||||
fn encoder_decoder_pair(profile: QualityProfile) -> (CallEncoder, CallDecoder) {
|
||||
let config = CallConfig {
|
||||
profile,
|
||||
suppression_enabled: false,
|
||||
// Small jitter buffer so decode_next drains quickly in tests.
|
||||
jitter_min: 2,
|
||||
jitter_target: 3,
|
||||
jitter_max: 20,
|
||||
adaptive_jitter: false,
|
||||
..Default::default()
|
||||
};
|
||||
(CallEncoder::new(&config), CallDecoder::new(&config))
|
||||
}
|
||||
|
||||
/// Helper: generate a non-silent 20 ms frame of 300 Hz sine at the
|
||||
/// given sample offset so consecutive frames form a continuous tone.
|
||||
fn voice_frame_20ms(sample_offset: usize) -> Vec<i16> {
|
||||
(0..960)
|
||||
.map(|i| {
|
||||
let t = (sample_offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Phase 3b probe: sweep packet_loss_perc values to find the minimum
|
||||
/// that produces a samples_available ≥ 960 (enough to reconstruct a
|
||||
/// single 20 ms Opus frame). This guides the production loss floor.
|
||||
#[test]
|
||||
#[ignore] // diagnostic only — run with `cargo test ... -- --ignored --nocapture`
|
||||
fn probe_dred_samples_available_by_loss_floor() {
|
||||
use wzp_codec::opus_enc::OpusEncoder;
|
||||
use wzp_proto::traits::AudioEncoder;
|
||||
|
||||
for loss_pct in [5u8, 10, 15, 20, 25, 40, 60, 80].iter().copied() {
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
enc.set_expected_loss(loss_pct);
|
||||
let (_drop_enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
for i in 0..60u16 {
|
||||
let pcm = voice_frame_20ms(i as usize * 960);
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
||||
encoded.truncate(n);
|
||||
let pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq: i,
|
||||
timestamp: (i as u32) * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(encoded),
|
||||
quality_report: None,
|
||||
};
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
eprintln!(
|
||||
"[phase3b probe] loss_pct={loss_pct} samples_available={}",
|
||||
dec.last_good_dred_samples_available()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 3b: simulated single-packet loss on an Opus call triggers a
|
||||
/// DRED reconstruction rather than a classical PLC fill. Runs the full
|
||||
/// encode → ingest → decode_next pipeline.
|
||||
#[test]
|
||||
fn opus_single_packet_loss_is_recovered_via_dred() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
// Warm-up: encode and ingest 60 frames (1.2 s) so the DRED emitter
|
||||
// has had time to fill its 200 ms window and at least one
|
||||
// successful DRED parse has happened on the decoder side.
|
||||
let warmup_frames = 60;
|
||||
for i in 0..warmup_frames {
|
||||
let pcm = voice_frame_20ms(i * 960);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the warm-up frames through the decoder to advance the
|
||||
// jitter buffer cursor past them.
|
||||
let mut out = vec![0i16; 960];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
// Encode the next three frames but skip ingesting the middle one.
|
||||
let base_offset = warmup_frames * 960;
|
||||
let pcm_a = voice_frame_20ms(base_offset);
|
||||
let pcm_b = voice_frame_20ms(base_offset + 960);
|
||||
let pcm_c = voice_frame_20ms(base_offset + 1920);
|
||||
|
||||
let pkts_a = enc.encode_frame(&pcm_a).unwrap();
|
||||
let pkts_b = enc.encode_frame(&pcm_b).unwrap(); // DROP THIS ONE
|
||||
let pkts_c = enc.encode_frame(&pcm_c).unwrap();
|
||||
|
||||
for pkt in pkts_a {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
// Skip pkts_b entirely — this is the "packet loss".
|
||||
drop(pkts_b);
|
||||
for pkt in pkts_c {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
|
||||
// Drain again. Somewhere in here decode_next will hit Missing()
|
||||
// for the dropped packet and attempt DRED reconstruction.
|
||||
let baseline_dred = dec.dred_reconstructions;
|
||||
let baseline_plc = dec.classical_plc_invocations;
|
||||
eprintln!(
|
||||
"[phase3b probe] pre-drain: last_good_seq={:?} samples_available={}",
|
||||
dec.last_good_dred_seq(),
|
||||
dec.last_good_dred_samples_available()
|
||||
);
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
let dred_delta = dec.dred_reconstructions - baseline_dred;
|
||||
let plc_delta = dec.classical_plc_invocations - baseline_plc;
|
||||
eprintln!(
|
||||
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
|
||||
);
|
||||
assert!(
|
||||
dred_delta >= 1,
|
||||
"expected ≥1 DRED reconstruction on single-packet loss, \
|
||||
got dred_delta={dred_delta} plc_delta={plc_delta}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3b: lossless stream never triggers DRED reconstruction or PLC.
|
||||
/// Baseline behavior — verifies the Missing() branch is not spuriously taken.
|
||||
#[test]
|
||||
fn opus_lossless_ingest_never_triggers_dred_or_plc() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
// Encode + ingest 40 frames with no drops.
|
||||
for i in 0..40 {
|
||||
let pcm = voice_frame_20ms(i * 960);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = vec![0i16; 960];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
assert_eq!(
|
||||
dec.dred_reconstructions, 0,
|
||||
"lossless stream should not reconstruct"
|
||||
);
|
||||
assert_eq!(
|
||||
dec.classical_plc_invocations, 0,
|
||||
"lossless stream should not PLC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3b: Codec2 calls fall through to classical PLC on loss.
|
||||
/// DRED is libopus-only, so even if the decoder's DRED state were
|
||||
/// populated (it won't be — Codec2 packets don't carry DRED bytes),
|
||||
/// `reconstruct_from_dred` rejects Codec2 at the AdaptiveDecoder
|
||||
/// level. This test guards the Codec2 side of the protection split.
|
||||
#[test]
|
||||
fn codec2_loss_falls_through_to_classical_plc() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::CATASTROPHIC);
|
||||
|
||||
// Codec2 1200 uses 40 ms frames → 1920 samples at 48 kHz (before
|
||||
// the downsample inside the codec). Encode 20 frames (~0.8 s).
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..1920)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for i in 0..20 {
|
||||
let pcm = make_frame(i * 1920);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
// Drop every 5th source packet to simulate loss.
|
||||
if !pkt.header.is_repair && i % 5 == 3 {
|
||||
continue;
|
||||
}
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = vec![0i16; 1920];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
assert_eq!(
|
||||
dec.dred_reconstructions, 0,
|
||||
"Codec2 must never reconstruct via DRED"
|
||||
);
|
||||
// classical_plc_invocations may or may not trigger depending on
|
||||
// whether the jitter buffer sees Missing before draining — the key
|
||||
// assertion is that DRED is not used. PLC count is advisory.
|
||||
}
|
||||
|
||||
// ---- QualityAdapter tests ----
|
||||
|
||||
/// Helper: build a QualityReport from human-readable loss% and RTT ms.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
||||
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
||||
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||
SignalMessage::Hold => CallSignalType::Hold,
|
||||
@@ -110,6 +111,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;
|
||||
|
||||
@@ -10,8 +10,17 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
|
||||
wzp-proto = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Opus bindings
|
||||
audiopus = { workspace = true }
|
||||
# Opus bindings — libopus 1.5.2.
|
||||
# opusic-c for the encoder (set_dred_duration lives here in Phase 1).
|
||||
# opusic-sys for the decoder — we wrap the raw *mut OpusDecoder ourselves
|
||||
# because opusic-c::Decoder.inner is pub(crate), blocking the unified
|
||||
# decoder + DRED path we need in Phase 3.
|
||||
opusic-c = { workspace = true }
|
||||
opusic-sys = { workspace = true }
|
||||
|
||||
# Zero-cost slice reinterpretation for the i16 ↔ u16 boundary between
|
||||
# our PCM buffers and opusic-c's encode API.
|
||||
bytemuck = { workspace = true }
|
||||
|
||||
# Pure-Rust Codec2 implementation
|
||||
codec2 = { workspace = true }
|
||||
|
||||
@@ -199,6 +199,27 @@ impl AdaptiveDecoder {
|
||||
fn codec2_frame_samples(&self) -> usize {
|
||||
self.codec2.frame_samples()
|
||||
}
|
||||
|
||||
/// Reconstruct a lost frame from a previously parsed DRED state.
|
||||
///
|
||||
/// Phase 3b entry point for gap reconstruction. Dispatches to the
|
||||
/// inner Opus decoder when active. Returns an error if the active
|
||||
/// codec is Codec2 — DRED is libopus-only and has no Codec2 equivalent,
|
||||
/// so callers must fall back to classical PLC on Codec2 tiers.
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &crate::dred_ffi::DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
if is_codec2(self.active) {
|
||||
return Err(CodecError::DecodeFailed(
|
||||
"DRED reconstruction is Opus-only; Codec2 must use classical PLC".into(),
|
||||
));
|
||||
}
|
||||
self.opus
|
||||
.reconstruct_from_dred(state, offset_samples, output)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
585
crates/wzp-codec/src/dred_ffi.rs
Normal file
585
crates/wzp-codec/src/dred_ffi.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
//! Raw opusic-sys FFI wrappers for libopus 1.5.2 decoder + DRED reconstruction.
|
||||
//!
|
||||
//! # Why this module exists
|
||||
//!
|
||||
//! We cannot use `opusic_c::Decoder` because its inner `*mut OpusDecoder`
|
||||
//! pointer is `pub(crate)` — not reachable from outside the opusic-c crate.
|
||||
//! Phase 3 of the DRED integration needs to hand that same pointer to
|
||||
//! `opus_decoder_dred_decode`, and running two parallel decoders (one from
|
||||
//! opusic-c for normal audio, another from opusic-sys for DRED) would cause
|
||||
//! the DRED-only decoder's internal state to drift out of sync with the
|
||||
//! audio stream because it would not see normal decode calls.
|
||||
//!
|
||||
//! The fix is to own the raw decoder ourselves and use the same handle for
|
||||
//! both normal decode AND DRED reconstruction. This module is the single
|
||||
//! owner of `*mut OpusDecoder`, `*mut OpusDREDDecoder`, and `*mut OpusDRED`
|
||||
//! in the WZP workspace.
|
||||
//!
|
||||
//! # Phase 3a scope
|
||||
//!
|
||||
//! Phase 0 added `DecoderHandle` (normal decode). Phase 3a adds:
|
||||
//! - [`DredDecoderHandle`] — wraps `*mut OpusDREDDecoder` for parsing DRED
|
||||
//! side-channel data out of arriving Opus packets.
|
||||
//! - [`DredState`] — wraps `*mut OpusDRED` (a fixed 10,592-byte buffer
|
||||
//! allocated by libopus) that holds parsed DRED state between the parse
|
||||
//! and reconstruct steps.
|
||||
//! - [`DredDecoderHandle::parse_into`] — wraps `opus_dred_parse`.
|
||||
//! - [`DecoderHandle::reconstruct_from_dred`] — wraps `opus_decoder_dred_decode`.
|
||||
//!
|
||||
//! The pattern is: on every arriving Opus packet, the receiver calls
|
||||
//! `parse_into` with a reusable `DredState`, then stores (seq, state_clone)
|
||||
//! in a ring. On detected loss, the receiver computes the offset from the
|
||||
//! freshest reachable DRED state and calls `reconstruct_from_dred` to
|
||||
//! synthesize the missing audio.
|
||||
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use opusic_sys::{
|
||||
OPUS_OK, OpusDRED, OpusDREDDecoder, OpusDecoder as RawOpusDecoder, opus_decode,
|
||||
opus_decoder_create, opus_decoder_destroy, opus_decoder_dred_decode, opus_dred_alloc,
|
||||
opus_dred_decoder_create, opus_dred_decoder_destroy, opus_dred_free, opus_dred_parse,
|
||||
};
|
||||
use wzp_proto::CodecError;
|
||||
|
||||
/// libopus operates at 48 kHz for all Opus variants we use.
|
||||
const SAMPLE_RATE_HZ: i32 = 48_000;
|
||||
/// Mono.
|
||||
const CHANNELS: i32 = 1;
|
||||
|
||||
/// Safe owner of a `*mut OpusDecoder` allocated via `opus_decoder_create`.
|
||||
///
|
||||
/// Releases the decoder in `Drop`. All FFI access goes through `&mut self`
|
||||
/// methods, so there is no aliasing or race. The raw pointer is exposed via
|
||||
/// [`Self::as_raw_ptr`] at a crate-internal visibility for the future Phase 3
|
||||
/// DRED reconstruction path — external crates cannot reach it.
|
||||
pub struct DecoderHandle {
|
||||
inner: NonNull<RawOpusDecoder>,
|
||||
}
|
||||
|
||||
impl DecoderHandle {
|
||||
/// Allocate a new Opus decoder at 48 kHz mono.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_decoder_create writes to `error` and returns either a
|
||||
// valid heap pointer or null. We check both before constructing the
|
||||
// NonNull wrapper.
|
||||
let ptr = unsafe { opus_decoder_create(SAMPLE_RATE_HZ, CHANNELS, &mut error) };
|
||||
if error != OPUS_OK {
|
||||
// Even if ptr is non-null on error, libopus contracts guarantee
|
||||
// it is unusable — do not attempt to free it.
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decoder_create failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
||||
CodecError::DecodeFailed("opus_decoder_create returned null".into())
|
||||
})?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Decode an Opus packet into PCM samples.
|
||||
///
|
||||
/// `pcm` must have enough capacity for the frame (960 for 20 ms, 1920
|
||||
/// for 40 ms at 48 kHz mono). Returns the number of decoded samples
|
||||
/// per channel — for mono streams this equals the total sample count.
|
||||
pub fn decode(&mut self, packet: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
if packet.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty packet".into()));
|
||||
}
|
||||
if pcm.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
||||
}
|
||||
// SAFETY: self.inner is a valid *mut OpusDecoder owned by this struct.
|
||||
// `data` / `pcm` are live Rust slices, so their pointers and lengths
|
||||
// are valid for the duration of the call. libopus reads len bytes
|
||||
// from data and writes up to frame_size samples (per channel) to pcm.
|
||||
let n = unsafe {
|
||||
opus_decode(
|
||||
self.inner.as_ptr(),
|
||||
packet.as_ptr(),
|
||||
packet.len() as i32,
|
||||
pcm.as_mut_ptr(),
|
||||
pcm.len() as i32,
|
||||
/* decode_fec = */ 0,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decode failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Generate packet-loss concealment audio for a missing frame.
|
||||
///
|
||||
/// Implemented via `opus_decode` with a null data pointer, per the
|
||||
/// libopus API contract. `pcm` should be sized for the expected frame.
|
||||
pub fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
if pcm.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
||||
}
|
||||
// SAFETY: same invariants as decode(). libopus documents that passing
|
||||
// a null data pointer with len=0 triggers PLC synthesis into pcm.
|
||||
let n = unsafe {
|
||||
opus_decode(
|
||||
self.inner.as_ptr(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
pcm.as_mut_ptr(),
|
||||
pcm.len() as i32,
|
||||
/* decode_fec = */ 0,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decode PLC failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Reconstruct audio from a `DredState` into the `output` buffer.
|
||||
///
|
||||
/// `offset_samples` is the sample position (positive, measured backward
|
||||
/// from the packet anchor that produced `state`) where reconstruction
|
||||
/// begins. `output.len()` must match the number of samples to synthesize.
|
||||
///
|
||||
/// The libopus API: `opus_decoder_dred_decode(st, dred, dred_offset, pcm,
|
||||
/// frame_size)` where `dred_offset` is "position of the redundancy to
|
||||
/// decode, in samples before the beginning of the real audio data in the
|
||||
/// packet." Valid values: `0 < offset_samples < state.samples_available()`.
|
||||
///
|
||||
/// Returns the number of samples actually written (should equal
|
||||
/// `output.len()` on success).
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
if output.is_empty() {
|
||||
return Err(CodecError::DecodeFailed(
|
||||
"empty reconstruction output buffer".into(),
|
||||
));
|
||||
}
|
||||
if offset_samples <= 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"DRED offset must be positive (got {offset_samples})"
|
||||
)));
|
||||
}
|
||||
if offset_samples > state.samples_available() {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"DRED offset {offset_samples} exceeds available samples {}",
|
||||
state.samples_available()
|
||||
)));
|
||||
}
|
||||
// SAFETY: self.inner is a valid *mut OpusDecoder, state.inner is a
|
||||
// valid *const OpusDRED populated by a prior parse_into call, and
|
||||
// output is a live mutable slice. libopus reads from dred and writes
|
||||
// exactly frame_size samples (the output.len()) to pcm.
|
||||
let n = unsafe {
|
||||
opus_decoder_dred_decode(
|
||||
self.inner.as_ptr(),
|
||||
state.inner.as_ptr(),
|
||||
offset_samples,
|
||||
output.as_mut_ptr(),
|
||||
output.len() as i32,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decoder_dred_decode failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DecoderHandle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_decoder_destroy(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: The underlying OpusDecoder is a plain heap allocation with no
|
||||
// thread-local or lock-free state. It is safe to move between threads
|
||||
// (Send), and all method access is gated by &mut self so Rust's borrow
|
||||
// checker prevents simultaneous access from multiple threads (Sync).
|
||||
unsafe impl Send for DecoderHandle {}
|
||||
unsafe impl Sync for DecoderHandle {}
|
||||
|
||||
// ─── DRED decoder (parser) ──────────────────────────────────────────────────
|
||||
|
||||
/// Safe owner of a `*mut OpusDREDDecoder` allocated via
|
||||
/// `opus_dred_decoder_create`.
|
||||
///
|
||||
/// The DRED decoder is a **separate** libopus object from the regular
|
||||
/// `OpusDecoder`. It's used exclusively for parsing DRED side-channel data
|
||||
/// out of arriving Opus packets via [`Self::parse_into`]. Actual audio
|
||||
/// reconstruction from the parsed state uses the regular `DecoderHandle`
|
||||
/// via [`DecoderHandle::reconstruct_from_dred`].
|
||||
pub struct DredDecoderHandle {
|
||||
inner: NonNull<OpusDREDDecoder>,
|
||||
}
|
||||
|
||||
impl DredDecoderHandle {
|
||||
/// Allocate a new DRED decoder.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_dred_decoder_create writes to `error` and returns
|
||||
// either a valid heap pointer or null. Both are checked.
|
||||
let ptr = unsafe { opus_dred_decoder_create(&mut error) };
|
||||
if error != OPUS_OK {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_decoder_create failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
||||
CodecError::DecodeFailed("opus_dred_decoder_create returned null".into())
|
||||
})?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Parse DRED side-channel data from an Opus packet into `state`.
|
||||
///
|
||||
/// Returns the number of samples of audio history available for
|
||||
/// reconstruction, or 0 if the packet carries no DRED data. Subsequent
|
||||
/// `DecoderHandle::reconstruct_from_dred` calls using this `state` can
|
||||
/// reconstruct any sample position in `(0, samples_available]`.
|
||||
///
|
||||
/// libopus API: `opus_dred_parse(dred_dec, dred, data, len,
|
||||
/// max_dred_samples, sampling_rate, dred_end, defer_processing)`. We
|
||||
/// pass `max_dred_samples = 48000` (1 s at 48 kHz, the DRED maximum),
|
||||
/// `sampling_rate = 48000`, `defer_processing = 0` (process immediately).
|
||||
/// The `dred_end` output is the silence gap at the tail of the DRED
|
||||
/// window; we subtract it from the total offset to give callers the
|
||||
/// truly usable sample count.
|
||||
pub fn parse_into(
|
||||
&mut self,
|
||||
state: &mut DredState,
|
||||
packet: &[u8],
|
||||
) -> Result<i32, CodecError> {
|
||||
if packet.is_empty() {
|
||||
state.samples_available = 0;
|
||||
return Ok(0);
|
||||
}
|
||||
let mut dred_end: i32 = 0;
|
||||
// SAFETY: self.inner is a valid *mut OpusDREDDecoder; state.inner is
|
||||
// a valid *mut OpusDRED allocated via opus_dred_alloc; packet is a
|
||||
// live slice; dred_end is a stack int. libopus reads packet bytes
|
||||
// and writes parsed DRED state into *state.inner.
|
||||
let ret = unsafe {
|
||||
opus_dred_parse(
|
||||
self.inner.as_ptr(),
|
||||
state.inner.as_ptr(),
|
||||
packet.as_ptr(),
|
||||
packet.len() as i32,
|
||||
/* max_dred_samples = */ 48_000, // 1s max per libopus 1.5
|
||||
/* sampling_rate = */ 48_000,
|
||||
&mut dred_end,
|
||||
/* defer_processing = */ 0,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
state.samples_available = 0;
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_parse failed: err={ret}"
|
||||
)));
|
||||
}
|
||||
// ret is the positive offset of the first decodable DRED sample,
|
||||
// or 0 if no DRED is present. dred_end is the silence gap at the
|
||||
// tail. The usable sample range is (dred_end, ret], so the count
|
||||
// of usable samples is ret - dred_end. We store `ret` as the max
|
||||
// usable offset — callers should pass dred_offset values in the
|
||||
// range (dred_end, ret] to reconstruct_from_dred. For simplicity
|
||||
// we expose just samples_available = ret and let callers treat
|
||||
// the full window as valid (the silence gap is small and libopus
|
||||
// handles minor boundary cases gracefully).
|
||||
state.samples_available = ret;
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DredDecoderHandle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_dred_decoder_destroy(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: same reasoning as DecoderHandle — heap allocation with no
|
||||
// thread-local state, &mut self access discipline prevents races.
|
||||
unsafe impl Send for DredDecoderHandle {}
|
||||
unsafe impl Sync for DredDecoderHandle {}
|
||||
|
||||
// ─── DRED state buffer ──────────────────────────────────────────────────────
|
||||
|
||||
/// Safe owner of a `*mut OpusDRED` allocated via `opus_dred_alloc`.
|
||||
///
|
||||
/// Holds a fixed-size (10,592-byte per libopus 1.5) buffer that
|
||||
/// `DredDecoderHandle::parse_into` populates from an Opus packet. The state
|
||||
/// is reusable — the caller can call `parse_into` again on the same
|
||||
/// `DredState` to overwrite it with a fresh packet's data.
|
||||
///
|
||||
/// `samples_available` tracks the last-parsed result so reconstruction
|
||||
/// callers don't need to thread the return value separately. A fresh
|
||||
/// state (before any `parse_into`) has `samples_available == 0`.
|
||||
pub struct DredState {
|
||||
inner: NonNull<OpusDRED>,
|
||||
samples_available: i32,
|
||||
}
|
||||
|
||||
impl DredState {
|
||||
/// Allocate a new DRED state buffer.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_dred_alloc writes to `error` and returns either a
|
||||
// valid heap pointer or null.
|
||||
let ptr = unsafe { opus_dred_alloc(&mut error) };
|
||||
if error != OPUS_OK {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_alloc failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr)
|
||||
.ok_or_else(|| CodecError::DecodeFailed("opus_dred_alloc returned null".into()))?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_available: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many samples of audio history this state currently covers.
|
||||
///
|
||||
/// Returns 0 if the state is fresh or the last parse found no DRED
|
||||
/// data. Otherwise returns the positive offset set by the most recent
|
||||
/// `DredDecoderHandle::parse_into` call — the maximum valid
|
||||
/// `offset_samples` value for `DecoderHandle::reconstruct_from_dred`.
|
||||
pub fn samples_available(&self) -> i32 {
|
||||
self.samples_available
|
||||
}
|
||||
|
||||
/// Reset the state to "fresh" without freeing the underlying buffer.
|
||||
/// The next `parse_into` will overwrite the contents.
|
||||
pub fn reset(&mut self) {
|
||||
self.samples_available = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DredState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_dred_free(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: same reasoning as DecoderHandle.
|
||||
unsafe impl Send for DredState {}
|
||||
unsafe impl Sync for DredState {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoder_handle_creates_and_drops() {
|
||||
let handle = DecoderHandle::new().expect("decoder create");
|
||||
// Dropping the handle must not panic or leak — validated by miri
|
||||
// and the absence of sanitizer complaints in CI.
|
||||
drop(handle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_lost_produces_full_frame_of_silence_on_cold_start() {
|
||||
let mut handle = DecoderHandle::new().unwrap();
|
||||
// 20 ms @ 48 kHz mono.
|
||||
let mut pcm = vec![0i16; 960];
|
||||
let n = handle.decode_lost(&mut pcm).unwrap();
|
||||
assert_eq!(n, 960);
|
||||
// On a fresh decoder, PLC output is silence (no past audio to extend).
|
||||
assert!(pcm.iter().all(|&s| s == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_empty_packet_errors() {
|
||||
let mut handle = DecoderHandle::new().unwrap();
|
||||
let mut pcm = vec![0i16; 960];
|
||||
let err = handle.decode(&[], &mut pcm);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
// ─── Phase 3a — DRED decoder + state ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dred_decoder_handle_creates_and_drops() {
|
||||
let h = DredDecoderHandle::new().expect("dred decoder create");
|
||||
drop(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_state_creates_and_drops() {
|
||||
let s = DredState::new().expect("dred state alloc");
|
||||
assert_eq!(s.samples_available(), 0);
|
||||
drop(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_state_reset_zeroes_counter() {
|
||||
let mut s = DredState::new().unwrap();
|
||||
s.samples_available = 480; // pretend a parse populated it
|
||||
assert_eq!(s.samples_available(), 480);
|
||||
s.reset();
|
||||
assert_eq!(s.samples_available(), 0);
|
||||
}
|
||||
|
||||
/// Phase 3a end-to-end: encode a DRED-enabled stream, parse state out
|
||||
/// of packets, and reconstruct audio at a past offset. Validates the
|
||||
/// full parse → reconstruct pipeline against a real libopus 1.5.2
|
||||
/// encoder so we catch FFI-layer bugs early.
|
||||
#[test]
|
||||
fn dred_parse_and_reconstruct_roundtrip() {
|
||||
use crate::opus_enc::OpusEncoder;
|
||||
use wzp_proto::{AudioEncoder, QualityProfile};
|
||||
|
||||
// Encoder with DRED at Opus 24k / 200 ms duration (Phase 1 default
|
||||
// for GOOD profile). The loss floor is 5% per Phase 1.
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
|
||||
// Decode-side handles.
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
||||
let mut state = DredState::new().unwrap();
|
||||
|
||||
// Generate 60 frames (1.2 s) of a voice-like 300 Hz sine wave so
|
||||
// the encoder's DRED emitter has real content to encode rather
|
||||
// than compressing silence.
|
||||
let frame_len = 960usize; // 20 ms @ 48 kHz
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Track the freshest packet that carried non-zero DRED state.
|
||||
let mut best_samples_available = 0;
|
||||
let mut best_packet: Option<Vec<u8>> = None;
|
||||
|
||||
for frame_idx in 0..60 {
|
||||
let pcm = make_frame(frame_idx * frame_len);
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
||||
encoded.truncate(n);
|
||||
|
||||
// Run the packet through the normal decode path so dec's
|
||||
// internal state mirrors the full stream — this is necessary
|
||||
// for DRED reconstruction to produce meaningful output.
|
||||
let mut decoded = vec![0i16; frame_len];
|
||||
dec.decode(&encoded, &mut decoded).unwrap();
|
||||
|
||||
// Parse DRED state out of the same packet. Early packets may
|
||||
// have samples_available == 0 while the DRED encoder warms up;
|
||||
// later packets should carry the full window.
|
||||
match dred_dec.parse_into(&mut state, &encoded) {
|
||||
Ok(available) => {
|
||||
if available > best_samples_available {
|
||||
best_samples_available = available;
|
||||
best_packet = Some(encoded.clone());
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("parse_into errored unexpectedly: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// By the time we're 60 frames in, DRED should have emitted data.
|
||||
assert!(
|
||||
best_samples_available > 0,
|
||||
"DRED emitted zero samples across 60 frames — the encoder isn't \
|
||||
producing DRED bytes (check set_dred_duration and packet_loss floor)"
|
||||
);
|
||||
|
||||
// Parse the best packet into a fresh state and reconstruct some
|
||||
// audio from somewhere inside its DRED window. We use frame_len/2
|
||||
// as the offset to pick a point squarely inside the reconstructable
|
||||
// range rather than at an edge.
|
||||
let packet = best_packet.expect("at least one packet had DRED state");
|
||||
let mut fresh_state = DredState::new().unwrap();
|
||||
let available = dred_dec.parse_into(&mut fresh_state, &packet).unwrap();
|
||||
assert!(available > 0, "re-parse of known-good packet returned 0");
|
||||
|
||||
// Need a decoder that's in the right state to reconstruct — rewind
|
||||
// by creating a fresh one and feeding it the same stream up to the
|
||||
// point of the best packet. Simpler: just use a fresh decoder and
|
||||
// accept that the reconstructed samples may not be phase-matched.
|
||||
// The test here only asserts *non-silent energy*, not signal fidelity.
|
||||
let mut recon_dec = DecoderHandle::new().unwrap();
|
||||
// Warm up the decoder with one frame so its internal state is valid.
|
||||
let warmup_pcm = vec![0i16; frame_len];
|
||||
let warmup_encoded = {
|
||||
let mut warmup_enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
let mut buf = vec![0u8; 512];
|
||||
let n = warmup_enc.encode(&warmup_pcm, &mut buf).unwrap();
|
||||
buf.truncate(n);
|
||||
buf
|
||||
};
|
||||
let mut throwaway = vec![0i16; frame_len];
|
||||
let _ = recon_dec.decode(&warmup_encoded, &mut throwaway);
|
||||
|
||||
// Reconstruct 20 ms from some position inside the DRED window.
|
||||
let offset = (available / 2).max(480).min(available);
|
||||
let mut recon_pcm = vec![0i16; frame_len];
|
||||
let n = recon_dec
|
||||
.reconstruct_from_dred(&fresh_state, offset, &mut recon_pcm)
|
||||
.expect("reconstruct_from_dred failed");
|
||||
assert_eq!(n, frame_len);
|
||||
|
||||
// Energy check: reconstructed audio should not be all zeros. A
|
||||
// loose threshold — the DRED reconstruction won't be phase-matched
|
||||
// to our sine wave because we fed a cold decoder only one warmup
|
||||
// frame, but it should still produce non-silent speech-like output
|
||||
// since the DRED state was parsed from real speech content.
|
||||
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum();
|
||||
assert!(
|
||||
energy > 0,
|
||||
"reconstructed audio has zero total energy — DRED reconstruction produced silence"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second roundtrip variant: offset too large errors cleanly rather
|
||||
/// than crashing the FFI.
|
||||
#[test]
|
||||
fn reconstruct_with_out_of_range_offset_errors() {
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let state = DredState::new().unwrap();
|
||||
// state has samples_available == 0 (fresh), so any positive offset
|
||||
// should be out of range.
|
||||
let mut out = vec![0i16; 960];
|
||||
let err = dec.reconstruct_from_dred(&state, 480, &mut out);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstruct_with_zero_offset_errors() {
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let state = DredState::new().unwrap();
|
||||
let mut out = vec![0i16; 960];
|
||||
let err = dec.reconstruct_from_dred(&state, 0, &mut out);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_parse_empty_packet_returns_zero() {
|
||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
||||
let mut state = DredState::new().unwrap();
|
||||
let result = dred_dec.parse_into(&mut state, &[]).unwrap();
|
||||
assert_eq!(result, 0);
|
||||
assert_eq!(state.samples_available(), 0);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod agc;
|
||||
pub mod codec2_dec;
|
||||
pub mod codec2_enc;
|
||||
pub mod denoise;
|
||||
pub mod dred_ffi;
|
||||
pub mod opus_dec;
|
||||
pub mod opus_enc;
|
||||
pub mod resample;
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
//! Opus decoder wrapping the `audiopus` crate.
|
||||
//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`.
|
||||
//!
|
||||
//! Phase 0 of the DRED integration: we went straight to a custom
|
||||
//! `DecoderHandle` instead of `opusic_c::Decoder` because the latter's
|
||||
//! inner pointer is `pub(crate)` and we need to reach it in Phase 3 for
|
||||
//! `opus_decoder_dred_decode`. See `dred_ffi.rs` for the rationale and
|
||||
//! `docs/PRD-dred-integration.md` for the full plan.
|
||||
|
||||
use audiopus::coder::Decoder;
|
||||
use audiopus::{Channels, MutSignals, SampleRate};
|
||||
use audiopus::packet::Packet;
|
||||
use crate::dred_ffi::{DecoderHandle, DredState};
|
||||
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||
|
||||
/// Opus decoder implementing `AudioDecoder`.
|
||||
/// Opus decoder implementing [`AudioDecoder`].
|
||||
///
|
||||
/// Operates at 48 kHz mono output.
|
||||
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via
|
||||
/// the active `QualityProfile`. Behavior is intentionally identical to
|
||||
/// the pre-swap audiopus-based decoder at this phase — DRED reconstruction
|
||||
/// lands in Phase 3.
|
||||
pub struct OpusDecoder {
|
||||
inner: Decoder,
|
||||
inner: DecoderHandle,
|
||||
codec_id: CodecId,
|
||||
frame_duration_ms: u8,
|
||||
}
|
||||
|
||||
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
|
||||
unsafe impl Sync for OpusDecoder {}
|
||||
|
||||
impl OpusDecoder {
|
||||
/// Create a new Opus decoder for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||
|
||||
let inner = DecoderHandle::new()?;
|
||||
Ok(Self {
|
||||
inner: decoder,
|
||||
inner,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
})
|
||||
@@ -34,6 +36,24 @@ impl OpusDecoder {
|
||||
pub fn frame_samples(&self) -> usize {
|
||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||
}
|
||||
|
||||
/// Reconstruct a lost frame from a previously parsed `DredState`.
|
||||
///
|
||||
/// Phase 3b entry point: callers (CallDecoder / engine.rs) use this to
|
||||
/// synthesize audio for gaps detected by the jitter buffer when DRED
|
||||
/// side-channel state from a later-arriving packet covers the gap's
|
||||
/// sample offset. `offset_samples` is measured backward from the anchor
|
||||
/// packet that produced `state`. See `DecoderHandle::reconstruct_from_dred`
|
||||
/// for the full semantics.
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
self.inner
|
||||
.reconstruct_from_dred(state, offset_samples, output)
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioDecoder for OpusDecoder {
|
||||
@@ -45,15 +65,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
let packet = Packet::try_from(encoded)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
|
||||
let signals = MutSignals::try_from(pcm)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||
let n = self
|
||||
.inner
|
||||
.decode(Some(packet), signals, false)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
|
||||
Ok(n)
|
||||
self.inner.decode(encoded, pcm)
|
||||
}
|
||||
|
||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
@@ -64,13 +76,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
let signals = MutSignals::try_from(pcm)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||
let n = self
|
||||
.inner
|
||||
.decode(None, signals, false)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
|
||||
Ok(n)
|
||||
self.inner.decode_lost(pcm)
|
||||
}
|
||||
|
||||
fn codec_id(&self) -> CodecId {
|
||||
@@ -79,7 +85,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(())
|
||||
|
||||
@@ -1,58 +1,199 @@
|
||||
//! Opus encoder wrapping the `audiopus` crate.
|
||||
//! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2).
|
||||
//!
|
||||
//! Phase 1 of the DRED integration: encoder-side DRED is enabled on every
|
||||
//! Opus profile with a tiered duration (studio 100 ms / normal 200 ms /
|
||||
//! degraded 500 ms), and Opus inband FEC (LBRR) is disabled because DRED
|
||||
//! is the stronger mechanism for the same failure mode. The legacy behavior
|
||||
//! is preserved behind the `AUDIO_USE_LEGACY_FEC` environment variable as a
|
||||
//! runtime escape hatch for rollout. See `docs/PRD-dred-integration.md`.
|
||||
//!
|
||||
//! # DRED duration policy
|
||||
//!
|
||||
//! Rationale from the PRD:
|
||||
//! - Studio tiers (Opus 32k/48k/64k): 100 ms — loss is rare on high-quality
|
||||
//! networks; short window keeps decoder CPU modest.
|
||||
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
|
||||
//! VoIP loss patterns (20–150 ms bursts from wifi roam, transient congestion).
|
||||
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a
|
||||
//! bad link; longer DRED buys maximum burst resilience where it matters.
|
||||
//!
|
||||
//! # Why the 15% packet loss floor
|
||||
//!
|
||||
//! libopus 1.5's DRED emitter is gated on `OPUS_SET_PACKET_LOSS_PERC` and
|
||||
//! scales the emitted window proportionally to the assumed loss:
|
||||
//!
|
||||
//! ```text
|
||||
//! loss_pct samples_available effective_ms
|
||||
//! 5% 720 15
|
||||
//! 10% 2640 55
|
||||
//! 15% 4560 95
|
||||
//! 20% 6480 135
|
||||
//! 25%+ 8400 (capped) 175 (≈ 87% of the 200ms configured max)
|
||||
//! ```
|
||||
//!
|
||||
//! Measured empirically against libopus 1.5.2 on Opus 24k / 200 ms DRED
|
||||
//! duration during Phase 3b. At 5% loss the window is only 15 ms — too
|
||||
//! small to even reconstruct a single 20 ms Opus frame. 15% gives 95 ms
|
||||
//! (enough for single-frame recovery plus modest burst margin) while
|
||||
//! keeping the bitrate overhead modest compared to 25%. Real measurements
|
||||
//! from the quality adapter override upward when loss exceeds the floor.
|
||||
|
||||
use audiopus::coder::Encoder;
|
||||
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||
use tracing::debug;
|
||||
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
|
||||
use tracing::{debug, warn};
|
||||
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||
|
||||
/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus
|
||||
/// scales the DRED emission window with the assumed loss percentage:
|
||||
/// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15%
|
||||
/// gives 95 ms, and 25%+ saturates the configured max (~175 ms at 200 ms
|
||||
/// duration). 15% is the minimum value that produces a DRED window larger
|
||||
/// than a single 20 ms frame, making it the minimum floor that actually
|
||||
/// gives DRED something useful to reconstruct. Real loss measurements from
|
||||
/// the quality adapter override this upward.
|
||||
const DRED_LOSS_FLOOR_PCT: u8 = 15;
|
||||
|
||||
/// Environment variable that reverts Phase 1 behavior to Phase 0 (inband FEC
|
||||
/// on, DRED off, no loss floor). Read once per encoder construction.
|
||||
const LEGACY_FEC_ENV: &str = "AUDIO_USE_LEGACY_FEC";
|
||||
|
||||
/// Returns the DRED duration in 10 ms frame units for a given Opus codec.
|
||||
///
|
||||
/// Unit: each frame is 10 ms, so the max value of 104 corresponds to 1040 ms
|
||||
/// of reconstructable history. Returns 0 for non-Opus codecs (DRED is not
|
||||
/// emitted by the libopus encoder in that case anyway, but we avoid a
|
||||
/// pointless FFI call).
|
||||
///
|
||||
/// See the DRED duration policy in the module docs for per-tier rationale.
|
||||
pub fn dred_duration_for(codec: CodecId) -> u8 {
|
||||
match codec {
|
||||
// Studio tiers — loss is rare, short window.
|
||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
|
||||
// Normal tiers — balanced baseline.
|
||||
CodecId::Opus16k | CodecId::Opus24k => 20,
|
||||
// Degraded tier — maximum burst resilience.
|
||||
CodecId::Opus6k => 50,
|
||||
// Non-Opus (Codec2 / CN): DRED is N/A.
|
||||
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the legacy-FEC escape hatch is active.
|
||||
///
|
||||
/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy
|
||||
/// mode; unset or empty leaves DRED enabled.
|
||||
fn read_legacy_fec_env() -> bool {
|
||||
match std::env::var(LEGACY_FEC_ENV) {
|
||||
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opus encoder implementing `AudioEncoder`.
|
||||
///
|
||||
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||
/// and 40 ms (1920 samples).
|
||||
/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active
|
||||
/// `QualityProfile`.
|
||||
pub struct OpusEncoder {
|
||||
inner: Encoder,
|
||||
codec_id: CodecId,
|
||||
frame_duration_ms: u8,
|
||||
/// When `true`, revert to the Phase 0 behavior: inband FEC Mode1, DRED
|
||||
/// disabled, no loss floor. Captured at construction time and not
|
||||
/// re-read mid-call.
|
||||
legacy_fec_mode: bool,
|
||||
}
|
||||
|
||||
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
||||
// audiopus Encoder contains a raw pointer that is !Sync, but we never
|
||||
// share it across threads without exclusive access.
|
||||
// opusic-c Encoder wraps a non-null pointer that is !Sync by default,
|
||||
// but we never share it across threads without exclusive access.
|
||||
unsafe impl Sync for OpusEncoder {}
|
||||
|
||||
impl OpusEncoder {
|
||||
/// Create a new Opus encoder for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||
// opusic-c argument order: (Channels, SampleRate, Application)
|
||||
// — different from audiopus's (SampleRate, Channels, Application).
|
||||
let encoder = Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e:?}")))?;
|
||||
|
||||
let legacy_fec_mode = read_legacy_fec_env();
|
||||
if legacy_fec_mode {
|
||||
warn!(
|
||||
"AUDIO_USE_LEGACY_FEC active — reverting Opus encoder to Phase 0 \
|
||||
behavior (inband FEC Mode1, no DRED)"
|
||||
);
|
||||
}
|
||||
|
||||
let mut enc = Self {
|
||||
inner: encoder,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
legacy_fec_mode,
|
||||
};
|
||||
enc.apply_bitrate(profile.codec)?;
|
||||
enc.set_inband_fec(true);
|
||||
enc.set_dtx(true);
|
||||
|
||||
// Voice signal type hint for better compression
|
||||
// Common setup — bitrate, DTX, signal hint, complexity. These are
|
||||
// identical regardless of the protection mode below.
|
||||
enc.apply_bitrate(profile.codec)?;
|
||||
enc.set_dtx(true);
|
||||
enc.inner
|
||||
.set_signal(Signal::Voice)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||
|
||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?;
|
||||
enc.inner
|
||||
.set_complexity(7)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?;
|
||||
|
||||
// Protection mode: DRED (Phase 1 default) or legacy inband FEC.
|
||||
enc.apply_protection_mode(profile.codec)?;
|
||||
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
let bps = codec.bitrate_bps() as i32;
|
||||
/// Configure the protection mode for the active codec.
|
||||
///
|
||||
/// In DRED mode (default): disable inband FEC, set DRED duration for the
|
||||
/// codec tier, clamp packet_loss to the 5% floor so DRED stays active.
|
||||
///
|
||||
/// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave
|
||||
/// DRED and packet_loss at libopus defaults.
|
||||
fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
if self.legacy_fec_mode {
|
||||
self.inner
|
||||
.set_inband_fec(InbandFec::Mode1)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?;
|
||||
// Leave DRED at 0 and packet_loss at default — matches Phase 0.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// DRED path: disable the overlapping inband FEC, enable DRED with
|
||||
// per-profile duration, floor packet_loss so DRED emits.
|
||||
self.inner
|
||||
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||
.set_inband_fec(InbandFec::Off)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?;
|
||||
|
||||
let dred_frames = dred_duration_for(codec);
|
||||
self.inner
|
||||
.set_dred_duration(dred_frames)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?;
|
||||
|
||||
self.inner
|
||||
.set_packet_loss(DRED_LOSS_FLOOR_PCT)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
|
||||
|
||||
debug!(
|
||||
codec = ?codec,
|
||||
dred_frames,
|
||||
dred_ms = dred_frames as u32 * 10,
|
||||
loss_floor_pct = DRED_LOSS_FLOOR_PCT,
|
||||
"opus encoder: DRED enabled"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
let bps = codec.bitrate_bps();
|
||||
self.inner
|
||||
.set_bitrate(Bitrate::Value(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?;
|
||||
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||
Ok(())
|
||||
}
|
||||
@@ -71,10 +212,36 @@ impl OpusEncoder {
|
||||
|
||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||
///
|
||||
/// Higher values cause the encoder to use more redundancy to survive
|
||||
/// packet loss, at the expense of slightly higher bitrate.
|
||||
/// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the
|
||||
/// encoder never drops DRED emission even on a perfect network. Real
|
||||
/// loss measurements from the quality adapter override upward.
|
||||
///
|
||||
/// In legacy mode, the value is passed through unchanged (min 0, max 100).
|
||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||
let clamped = if self.legacy_fec_mode {
|
||||
loss_pct.min(100)
|
||||
} else {
|
||||
loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100)
|
||||
};
|
||||
let _ = self.inner.set_packet_loss(clamped);
|
||||
}
|
||||
|
||||
/// Set the DRED duration in 10 ms frame units (0 disables, max 104).
|
||||
///
|
||||
/// No-op in legacy mode. Normally driven automatically by the active
|
||||
/// quality profile via `apply_protection_mode`; this setter exists for
|
||||
/// tests and for the rare case where a caller needs to override the
|
||||
/// per-profile default.
|
||||
pub fn set_dred_duration(&mut self, frames: u8) {
|
||||
if self.legacy_fec_mode {
|
||||
return;
|
||||
}
|
||||
let _ = self.inner.set_dred_duration(frames.min(104));
|
||||
}
|
||||
|
||||
/// Test/introspection accessor: whether legacy FEC mode is active.
|
||||
pub fn is_legacy_fec_mode(&self) -> bool {
|
||||
self.legacy_fec_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +254,14 @@ impl AudioEncoder for OpusEncoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
// opusic-c takes &[u16] for the sample input. Bit pattern is
|
||||
// identical to i16 — the cast is zero-cost and the encoder
|
||||
// interprets the bytes the same way as libopus internally.
|
||||
let pcm_u16: &[u16] = bytemuck::cast_slice(pcm);
|
||||
let n = self
|
||||
.inner
|
||||
.encode(pcm, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||
.encode_to_slice(pcm_u16, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -100,10 +271,13 @@ 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)?;
|
||||
// Refresh DRED duration for the new tier. apply_protection_mode
|
||||
// is idempotent and handles the legacy-vs-DRED branch correctly.
|
||||
self.apply_protection_mode(profile.codec)?;
|
||||
Ok(())
|
||||
}
|
||||
other => Err(CodecError::UnsupportedTransition {
|
||||
@@ -120,10 +294,190 @@ impl AudioEncoder for OpusEncoder {
|
||||
}
|
||||
|
||||
fn set_inband_fec(&mut self, enabled: bool) {
|
||||
let _ = self.inner.set_inband_fec(enabled);
|
||||
// In DRED mode, ignore external requests to re-enable inband FEC —
|
||||
// running both mechanisms wastes bitrate on overlapping protection
|
||||
// and opusic-c's own docs recommend disabling inband FEC when DRED
|
||||
// is on. Trait callers that genuinely want classical FEC should set
|
||||
// `AUDIO_USE_LEGACY_FEC=1` and re-create the encoder.
|
||||
if !self.legacy_fec_mode {
|
||||
debug!(
|
||||
enabled,
|
||||
"set_inband_fec ignored: DRED mode is active (set AUDIO_USE_LEGACY_FEC to revert)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off };
|
||||
let _ = self.inner.set_inband_fec(mode);
|
||||
}
|
||||
|
||||
fn set_dtx(&mut self, enabled: bool) {
|
||||
let _ = self.inner.set_dtx(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::AudioDecoder;
|
||||
|
||||
/// Phase 0 acceptance gate: fail loudly if the linked libopus is not 1.5.x.
|
||||
/// DRED (Phase 1+) only exists in libopus ≥ 1.5, so running against an
|
||||
/// older version would silently regress the entire DRED integration.
|
||||
#[test]
|
||||
fn linked_libopus_is_1_5() {
|
||||
let version = opusic_c::version();
|
||||
assert!(
|
||||
version.contains("1.5"),
|
||||
"expected libopus 1.5.x, got: {version}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_creates_at_good_profile() {
|
||||
let enc = OpusEncoder::new(QualityProfile::GOOD).expect("opus encoder init");
|
||||
assert_eq!(enc.codec_id, CodecId::Opus24k);
|
||||
assert_eq!(enc.frame_samples(), 960); // 20 ms @ 48 kHz
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_roundtrip_silence() {
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
|
||||
let pcm_in = vec![0i16; 960]; // 20 ms silence
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
||||
assert!(n > 0);
|
||||
let mut pcm_out = vec![0i16; 960];
|
||||
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
|
||||
assert_eq!(samples, 960);
|
||||
}
|
||||
|
||||
// ─── Phase 1 — DRED duration policy ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dred_duration_for_studio_tiers_is_100ms() {
|
||||
assert_eq!(dred_duration_for(CodecId::Opus32k), 10);
|
||||
assert_eq!(dred_duration_for(CodecId::Opus48k), 10);
|
||||
assert_eq!(dred_duration_for(CodecId::Opus64k), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_duration_for_normal_tiers_is_200ms() {
|
||||
assert_eq!(dred_duration_for(CodecId::Opus16k), 20);
|
||||
assert_eq!(dred_duration_for(CodecId::Opus24k), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_duration_for_degraded_tier_is_500ms() {
|
||||
assert_eq!(dred_duration_for(CodecId::Opus6k), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_duration_for_codec2_is_zero() {
|
||||
assert_eq!(dred_duration_for(CodecId::Codec2_3200), 0);
|
||||
assert_eq!(dred_duration_for(CodecId::Codec2_1200), 0);
|
||||
assert_eq!(dred_duration_for(CodecId::ComfortNoise), 0);
|
||||
}
|
||||
|
||||
// ─── Phase 1 — Legacy escape hatch ──────────────────────────────────────
|
||||
|
||||
/// By default (env var unset), legacy mode is off.
|
||||
///
|
||||
/// This test does NOT manipulate the environment to avoid flakiness
|
||||
/// when the full suite runs in parallel. It only asserts on a freshly
|
||||
/// created encoder in the ambient environment.
|
||||
#[test]
|
||||
fn default_mode_is_dred_not_legacy() {
|
||||
// SAFETY: only run if the ambient env hasn't set the var externally.
|
||||
if std::env::var(LEGACY_FEC_ENV).is_ok() {
|
||||
return; // don't assert — someone set the env for a reason.
|
||||
}
|
||||
let enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
assert!(!enc.is_legacy_fec_mode());
|
||||
}
|
||||
|
||||
// ─── Phase 1 — Behavioral regression: roundtrip still works ─────────────
|
||||
|
||||
#[test]
|
||||
fn dred_mode_roundtrip_voice_pattern() {
|
||||
// Use a realistic voice-like input (sine wave at speech frequencies)
|
||||
// so the encoder emits meaningful DRED data rather than trivially
|
||||
// compressible silence.
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
|
||||
|
||||
let mut total_encoded_bytes = 0usize;
|
||||
// Run 50 frames (1 second) so DRED fills up and starts emitting.
|
||||
for frame_idx in 0..50 {
|
||||
let pcm_in: Vec<i16> = (0..960)
|
||||
.map(|i| {
|
||||
let t = (frame_idx * 960 + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
||||
assert!(n > 0);
|
||||
total_encoded_bytes += n;
|
||||
|
||||
let mut pcm_out = vec![0i16; 960];
|
||||
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
|
||||
assert_eq!(samples, 960);
|
||||
}
|
||||
|
||||
// Effective bitrate after 1 second of encoding.
|
||||
// Opus 24k base + ~1 kbps DRED ≈ 25 kbps ≈ 3125 bytes/sec.
|
||||
// Allow generous headroom (2000 lower bound, 8000 upper bound) —
|
||||
// this is a behavioral regression check, not a tight bitrate assertion.
|
||||
// The exact value is printed with --nocapture for diagnostic use.
|
||||
eprintln!(
|
||||
"[phase1 bitrate probe] legacy_fec_mode={} total_encoded={} bytes/sec",
|
||||
enc.is_legacy_fec_mode(),
|
||||
total_encoded_bytes
|
||||
);
|
||||
assert!(
|
||||
total_encoded_bytes > 2000,
|
||||
"encoder output too small: {total_encoded_bytes} bytes/sec (DRED likely not emitting)"
|
||||
);
|
||||
assert!(
|
||||
total_encoded_bytes < 8000,
|
||||
"encoder output too large: {total_encoded_bytes} bytes/sec"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Phase 1 — set_profile updates DRED duration on tier switch ─────────
|
||||
|
||||
#[test]
|
||||
fn profile_switch_refreshes_dred_duration() {
|
||||
// Start on GOOD (Opus 24k, DRED 20 frames), switch to DEGRADED
|
||||
// (Opus 6k, DRED 50 frames). The encoder should accept both profile
|
||||
// changes without error. We can't directly observe the DRED duration
|
||||
// inside libopus, but apply_protection_mode returns Ok for both.
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
assert_eq!(enc.codec_id, CodecId::Opus24k);
|
||||
|
||||
enc.set_profile(QualityProfile::DEGRADED).unwrap();
|
||||
assert_eq!(enc.codec_id, CodecId::Opus6k);
|
||||
|
||||
enc.set_profile(QualityProfile::STUDIO_64K).unwrap();
|
||||
assert_eq!(enc.codec_id, CodecId::Opus64k);
|
||||
}
|
||||
|
||||
// ─── Phase 1 — Trait set_inband_fec is a no-op in DRED mode ─────────────
|
||||
|
||||
#[test]
|
||||
fn set_inband_fec_noop_in_dred_mode() {
|
||||
if std::env::var(LEGACY_FEC_ENV).is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
// Should not error, should not re-enable inband FEC internally.
|
||||
enc.set_inband_fec(true);
|
||||
// We can't directly query libopus's inband FEC state through opusic-c,
|
||||
// but the call must not panic and the encoder must still work.
|
||||
let pcm_in = vec![0i16; 960];
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
||||
assert!(n > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -584,6 +584,26 @@ pub enum SignalMessage {
|
||||
recommended_profile: crate::QualityProfile,
|
||||
},
|
||||
|
||||
/// Phase 4 telemetry: loss-recovery counts for the current session.
|
||||
/// Sent periodically from receivers to the relay so Prometheus metrics
|
||||
/// can distinguish DRED reconstructions from classical PLC invocations.
|
||||
/// Fields default to 0 on old receivers (`#[serde(default)]`), so
|
||||
/// introducing this variant is backward-compatible with pre-Phase-4
|
||||
/// relays — they'll just log "unknown signal variant" on receipt.
|
||||
LossRecoveryUpdate {
|
||||
/// Total frames reconstructed via DRED since call start (monotonic).
|
||||
#[serde(default)]
|
||||
dred_reconstructions: u64,
|
||||
/// Total frames filled via classical Opus/Codec2 PLC since call
|
||||
/// start (monotonic).
|
||||
#[serde(default)]
|
||||
classical_plc_invocations: u64,
|
||||
/// Total frames decoded since call start. Used by the relay to
|
||||
/// compute recovery rates as a fraction of total frames.
|
||||
#[serde(default)]
|
||||
frames_decoded: u64,
|
||||
},
|
||||
|
||||
/// Connection keepalive / RTT measurement.
|
||||
Ping { timestamp_ms: u64 },
|
||||
Pong { timestamp_ms: u64 },
|
||||
@@ -656,6 +676,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 +791,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,12 +16,22 @@ 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,
|
||||
pub session_rtt_ms: GaugeVec,
|
||||
pub session_underruns: IntCounterVec,
|
||||
pub session_overruns: IntCounterVec,
|
||||
// Phase 4: loss-recovery breakdown per session.
|
||||
pub session_dred_reconstructions: IntCounterVec,
|
||||
pub session_classical_plc: IntCounterVec,
|
||||
registry: Registry,
|
||||
}
|
||||
|
||||
@@ -60,6 +70,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",
|
||||
@@ -101,17 +133,42 @@ impl RelayMetrics {
|
||||
)
|
||||
.expect("metric");
|
||||
|
||||
let session_dred_reconstructions = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"wzp_relay_session_dred_reconstructions_total",
|
||||
"Frames reconstructed via DRED (Deep REDundancy) per session",
|
||||
),
|
||||
&["session_id"],
|
||||
)
|
||||
.expect("metric");
|
||||
let session_classical_plc = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"wzp_relay_session_classical_plc_total",
|
||||
"Frames filled via classical Opus/Codec2 PLC per session",
|
||||
),
|
||||
&["session_id"],
|
||||
)
|
||||
.expect("metric");
|
||||
|
||||
registry.register(Box::new(active_sessions.clone())).expect("register");
|
||||
registry.register(Box::new(active_rooms.clone())).expect("register");
|
||||
registry.register(Box::new(packets_forwarded.clone())).expect("register");
|
||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||
registry.register(Box::new(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");
|
||||
registry.register(Box::new(session_underruns.clone())).expect("register");
|
||||
registry.register(Box::new(session_overruns.clone())).expect("register");
|
||||
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register");
|
||||
registry.register(Box::new(session_classical_plc.clone())).expect("register");
|
||||
|
||||
Self {
|
||||
active_sessions,
|
||||
@@ -120,11 +177,19 @@ 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,
|
||||
session_underruns,
|
||||
session_overruns,
|
||||
session_dred_reconstructions,
|
||||
session_classical_plc,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
@@ -176,6 +241,39 @@ impl RelayMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: update per-session loss-recovery counters from a client's
|
||||
/// `LossRecoveryUpdate` signal message. The client sends monotonic
|
||||
/// totals (frames reconstructed since call start); we compute the
|
||||
/// delta against the current Prometheus counter and increment by it.
|
||||
/// IntCounterVec only increases, so a client restart that resets the
|
||||
/// counter to 0 simply produces no delta until the new totals exceed
|
||||
/// the Prometheus state.
|
||||
pub fn update_session_loss_recovery(
|
||||
&self,
|
||||
session_id: &str,
|
||||
dred_reconstructions: u64,
|
||||
classical_plc: u64,
|
||||
) {
|
||||
let cur_dred = self
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[session_id])
|
||||
.get();
|
||||
if dred_reconstructions > cur_dred {
|
||||
self.session_dred_reconstructions
|
||||
.with_label_values(&[session_id])
|
||||
.inc_by(dred_reconstructions - cur_dred);
|
||||
}
|
||||
let cur_plc = self
|
||||
.session_classical_plc
|
||||
.with_label_values(&[session_id])
|
||||
.get();
|
||||
if classical_plc > cur_plc {
|
||||
self.session_classical_plc
|
||||
.with_label_values(&[session_id])
|
||||
.inc_by(classical_plc - cur_plc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all per-session label values for a disconnected session.
|
||||
pub fn remove_session_metrics(&self, session_id: &str) {
|
||||
let _ = self.session_buffer_depth.remove_label_values(&[session_id]);
|
||||
@@ -183,6 +281,10 @@ impl RelayMetrics {
|
||||
let _ = self.session_rtt_ms.remove_label_values(&[session_id]);
|
||||
let _ = self.session_underruns.remove_label_values(&[session_id]);
|
||||
let _ = self.session_overruns.remove_label_values(&[session_id]);
|
||||
let _ = self
|
||||
.session_dred_reconstructions
|
||||
.remove_label_values(&[session_id]);
|
||||
let _ = self.session_classical_plc.remove_label_values(&[session_id]);
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying Prometheus registry.
|
||||
@@ -377,10 +479,13 @@ mod tests {
|
||||
};
|
||||
m.update_session_quality("sess-cleanup", &report);
|
||||
m.update_session_buffer("sess-cleanup", 42, 3, 1);
|
||||
m.update_session_loss_recovery("sess-cleanup", 17, 4);
|
||||
|
||||
// Verify they appear
|
||||
let output = m.metrics_handler();
|
||||
assert!(output.contains("sess-cleanup"));
|
||||
assert!(output.contains("wzp_relay_session_dred_reconstructions_total"));
|
||||
assert!(output.contains("wzp_relay_session_classical_plc_total"));
|
||||
|
||||
// Remove and verify they are gone
|
||||
m.remove_session_metrics("sess-cleanup");
|
||||
@@ -388,6 +493,55 @@ mod tests {
|
||||
assert!(!output.contains("sess-cleanup"));
|
||||
}
|
||||
|
||||
/// Phase 4: LossRecoveryUpdate → per-session counters, monotonic delta
|
||||
/// application.
|
||||
#[test]
|
||||
fn session_loss_recovery_monotonic_delta() {
|
||||
let m = RelayMetrics::new();
|
||||
let sess = "sess-dred";
|
||||
|
||||
// First update: 10 DRED, 2 PLC
|
||||
m.update_session_loss_recovery(sess, 10, 2);
|
||||
let dred1 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc1 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred1, 10);
|
||||
assert_eq!(plc1, 2);
|
||||
|
||||
// Second update: 25 DRED, 5 PLC — counter advances by (15, 3)
|
||||
m.update_session_loss_recovery(sess, 25, 5);
|
||||
let dred2 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc2 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred2, 25);
|
||||
assert_eq!(plc2, 5);
|
||||
|
||||
// Third update with LOWER values (e.g., client reset) — counters
|
||||
// hold steady, no decrement.
|
||||
m.update_session_loss_recovery(sess, 5, 1);
|
||||
let dred3 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc3 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred3, 25, "counter must not decrease");
|
||||
assert_eq!(plc3, 5, "counter must not decrease");
|
||||
|
||||
// Fourth update: client caught up and exceeded the old max.
|
||||
m.update_session_loss_recovery(sess, 30, 8);
|
||||
let dred4 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc4 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred4, 30);
|
||||
assert_eq!(plc4, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_increment() {
|
||||
let m = RelayMetrics::new();
|
||||
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user