Compare commits
22 Commits
experiment
...
android-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bc72a884 | ||
|
|
6aa52accef | ||
|
|
d0c17317ea | ||
|
|
5799d18aee | ||
|
|
46c9ee1be3 | ||
|
|
b53eae9192 | ||
|
|
a3f54566d4 | ||
|
|
76e9fe5e43 | ||
|
|
b0a89d4f39 | ||
|
|
abc96e8887 | ||
|
|
3a6ae61f8d | ||
|
|
4c536d256b | ||
|
|
b0ec9ff4ab | ||
|
|
5855533a39 | ||
|
|
ed09c2e8cc | ||
|
|
f44306cc17 | ||
|
|
0b821585ab | ||
|
|
faec332a8c | ||
|
|
fe9ae276dc | ||
|
|
4fbf6770c4 | ||
|
|
30a893a73f | ||
|
|
d46f3b1deb |
@@ -1,14 +0,0 @@
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Pre-existing historical findings already on fj/main and github/main. The two PASTE_AUTH tokens in scripts/build.sh and scripts/build-linux-notify.sh are real — rotate if those endpoints still authenticate; this allowlist only silences the pre-push hook, it does not remove the exposure."
|
||||
commits = [
|
||||
# wzp-crypto module doc: false positive on "SHA-256(Ed25519 pub)[:16]"
|
||||
"51e893590c1b9fa49e9f6ae5c96c26deb58f353b",
|
||||
# build.sh PASTE_AUTH (paste.tbs.amn.gg)
|
||||
"bd6733b2e5d76b5259020f1c30a5223a9773b6aa",
|
||||
# build-linux-notify Authorization header (paste.dk.manko.yoga)
|
||||
"6d776097c83bc6fbe3f3565e080513d8af93b550",
|
||||
"7751439e2bca9eacf2c30929c8124a4eb6136df2",
|
||||
]
|
||||
4324
Cargo.lock
generated
4324
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@@ -10,9 +10,6 @@ members = [
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-android",
|
||||
"crates/wzp-native",
|
||||
"crates/wzp-video",
|
||||
"desktop/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -33,20 +30,12 @@ serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Transport
|
||||
quinn = "0.11"
|
||||
socket2 = "0.5"
|
||||
|
||||
# FEC
|
||||
raptorq = "2"
|
||||
|
||||
# Codec
|
||||
# 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"
|
||||
audiopus = "0.3.0-rc.0"
|
||||
codec2 = "0.3"
|
||||
|
||||
# Crypto
|
||||
@@ -64,29 +53,3 @@ 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.
|
||||
|
||||
@@ -46,14 +46,6 @@ 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 ===")
|
||||
@@ -66,18 +58,6 @@ 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)
|
||||
}
|
||||
@@ -215,28 +195,4 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.wzp.engine
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Persistent signal connection for direct 1:1 calls.
|
||||
* Separate from WzpEngine — survives across calls.
|
||||
*
|
||||
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
|
||||
*/
|
||||
class SignalManager {
|
||||
|
||||
private var handle: Long = 0L
|
||||
|
||||
val isConnected: Boolean get() = handle != 0L
|
||||
|
||||
/**
|
||||
* Connect to relay and register for direct calls.
|
||||
* MUST be called from a thread with sufficient stack (8MB).
|
||||
* Blocks briefly during QUIC connect + register, then returns.
|
||||
*/
|
||||
fun connect(relay: String, seedHex: String): Boolean {
|
||||
if (handle != 0L) return true // already connected
|
||||
handle = nativeSignalConnect(relay, seedHex)
|
||||
return handle != 0L
|
||||
}
|
||||
|
||||
/** Get current signal state as parsed object. Non-blocking. */
|
||||
fun getState(): SignalState {
|
||||
if (handle == 0L) return SignalState()
|
||||
val json = nativeSignalGetState(handle) ?: return SignalState()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
SignalState(
|
||||
status = obj.optString("status", "idle"),
|
||||
fingerprint = obj.optString("fingerprint", ""),
|
||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
|
||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
|
||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
|
||||
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
|
||||
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
|
||||
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
SignalState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a direct call to a target fingerprint. */
|
||||
fun placeCall(targetFp: String): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalPlaceCall(handle, targetFp)
|
||||
}
|
||||
|
||||
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
|
||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||
if (handle == 0L) return -1
|
||||
return nativeSignalAnswerCall(handle, callId, mode)
|
||||
}
|
||||
|
||||
/** Send hangup signal. */
|
||||
fun hangup() {
|
||||
if (handle != 0L) nativeSignalHangup(handle)
|
||||
}
|
||||
|
||||
/** Destroy the signal manager. */
|
||||
fun destroy() {
|
||||
if (handle != 0L) {
|
||||
nativeSignalDestroy(handle)
|
||||
handle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// JNI native methods
|
||||
private external fun nativeSignalConnect(relay: String, seed: String): Long
|
||||
private external fun nativeSignalGetState(handle: Long): String?
|
||||
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
|
||||
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||
private external fun nativeSignalHangup(handle: Long)
|
||||
private external fun nativeSignalDestroy(handle: Long)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("wzp_android") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Signal connection state. */
|
||||
data class SignalState(
|
||||
val status: String = "idle",
|
||||
val fingerprint: String = "",
|
||||
val incomingCallId: String? = null,
|
||||
val incomingCallerFp: String? = null,
|
||||
val incomingCallerAlias: String? = null,
|
||||
val callSetupRelay: String? = null,
|
||||
val callSetupRoom: String? = null,
|
||||
val callSetupId: String? = null,
|
||||
)
|
||||
@@ -96,17 +96,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal a network transport change (e.g. WiFi → LTE handoff).
|
||||
*
|
||||
* @param networkType matches Rust `NetworkContext` ordinals:
|
||||
* 0=WiFi, 1=LTE, 2=5G, 3=3G, 4=Unknown, 5=None
|
||||
* @param bandwidthKbps reported downstream bandwidth in kbps
|
||||
*/
|
||||
fun onNetworkChanged(networkType: Int, bandwidthKbps: Int) {
|
||||
if (nativeHandle != 0L) nativeOnNetworkChanged(nativeHandle, networkType, bandwidthKbps)
|
||||
}
|
||||
|
||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||
@Synchronized
|
||||
fun destroy() {
|
||||
@@ -170,11 +159,22 @@ 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)
|
||||
|
||||
companion object {
|
||||
init { System.loadLibrary("wzp_android") }
|
||||
|
||||
/** Get the identity fingerprint for a seed hex. No engine needed. */
|
||||
@JvmStatic
|
||||
private external fun nativeGetFingerprint(seedHex: String): String?
|
||||
|
||||
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
|
||||
@JvmStatic
|
||||
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
|
||||
}
|
||||
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
|
||||
private external fun nativeOnNetworkChanged(handle: Long, networkType: Int, bandwidthKbps: Int)
|
||||
|
||||
/**
|
||||
* Ping a relay server. Requires engine to be initialized.
|
||||
@@ -220,11 +220,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("wzp_android")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package com.wzp.net
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Monitors network connectivity changes via [ConnectivityManager.NetworkCallback]
|
||||
* and classifies the active transport (WiFi, LTE, 5G, 3G).
|
||||
*
|
||||
* Callbacks fire on the main looper so callers can safely update UI state or
|
||||
* dispatch to a native engine from any callback.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Set [onNetworkChanged] to receive `(type: Int, downlinkKbps: Int)` events
|
||||
* 2. Optionally set [onIpChanged] for IP address change events (mid-call ICE refresh)
|
||||
* 3. Call [register] when the call starts
|
||||
* 4. Call [unregister] when the call ends
|
||||
*/
|
||||
class NetworkMonitor(context: Context) {
|
||||
|
||||
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Called when the network transport type or bandwidth changes.
|
||||
* `type` constants match the Rust `NetworkContext` enum ordinals.
|
||||
*/
|
||||
var onNetworkChanged: ((type: Int, downlinkKbps: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Called when the device's IP address changes (link properties changed).
|
||||
* Useful for triggering mid-call ICE candidate re-gathering.
|
||||
*/
|
||||
var onIpChanged: (() -> Unit)? = null
|
||||
|
||||
// Track the last emitted type to avoid redundant callbacks
|
||||
@Volatile
|
||||
private var lastEmittedType: Int = TYPE_UNKNOWN
|
||||
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
classifyAndEmit(network)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
classifyFromCaps(caps)
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(
|
||||
network: Network,
|
||||
linkProperties: android.net.LinkProperties
|
||||
) {
|
||||
// IP address may have changed — notify for ICE refresh
|
||||
onIpChanged?.invoke()
|
||||
// Also re-classify in case the transport changed simultaneously
|
||||
classifyAndEmit(network)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
lastEmittedType = TYPE_NONE
|
||||
onNetworkChanged?.invoke(TYPE_NONE, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/** Register the network callback. Call when a call starts. */
|
||||
fun register() {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback, mainHandler)
|
||||
}
|
||||
|
||||
/** Unregister the network callback. Call when the call ends. */
|
||||
fun unregister() {
|
||||
try {
|
||||
cm.unregisterNetworkCallback(callback)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Already unregistered — safe to ignore
|
||||
}
|
||||
}
|
||||
|
||||
// -- Classification -------------------------------------------------------
|
||||
|
||||
private fun classifyAndEmit(network: Network) {
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return
|
||||
classifyFromCaps(caps)
|
||||
}
|
||||
|
||||
private fun classifyFromCaps(caps: NetworkCapabilities) {
|
||||
val type = when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_WIFI // treat as WiFi
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> classifyCellular(caps)
|
||||
else -> TYPE_UNKNOWN
|
||||
}
|
||||
val bw = caps.getLinkDownstreamBandwidthKbps()
|
||||
|
||||
// Deduplicate: only emit when the transport type actually changes
|
||||
if (type != lastEmittedType) {
|
||||
lastEmittedType = type
|
||||
onNetworkChanged?.invoke(type, bw)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate cellular generation from reported downstream bandwidth.
|
||||
* This avoids requiring READ_PHONE_STATE permission (needed for
|
||||
* TelephonyManager.getNetworkType on API 30+).
|
||||
*
|
||||
* Thresholds are conservative — carriers over-report bandwidth, so we
|
||||
* classify based on what's actually usable for VoIP:
|
||||
* - >= 100 Mbps → 5G NR
|
||||
* - >= 10 Mbps → LTE
|
||||
* - < 10 Mbps → 3G or worse
|
||||
*/
|
||||
private fun classifyCellular(caps: NetworkCapabilities): Int {
|
||||
val bw = caps.getLinkDownstreamBandwidthKbps()
|
||||
return when {
|
||||
bw >= 100_000 -> TYPE_CELLULAR_5G
|
||||
bw >= 10_000 -> TYPE_CELLULAR_LTE
|
||||
else -> TYPE_CELLULAR_3G
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Constants matching Rust `NetworkContext` enum ordinals. */
|
||||
const val TYPE_WIFI = 0
|
||||
const val TYPE_CELLULAR_LTE = 1
|
||||
const val TYPE_CELLULAR_5G = 2
|
||||
const val TYPE_CELLULAR_3G = 3
|
||||
const val TYPE_UNKNOWN = 4
|
||||
const val TYPE_NONE = 5
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.audio.AudioRoute
|
||||
import com.wzp.audio.AudioRouteManager
|
||||
import com.wzp.data.SettingsRepository
|
||||
import com.wzp.debug.DebugReporter
|
||||
@@ -13,7 +12,6 @@ import com.wzp.engine.CallStats
|
||||
import com.wzp.service.CallService
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import com.wzp.net.NetworkMonitor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -45,7 +43,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private var engineInitialized = false
|
||||
private var audioPipeline: AudioPipeline? = null
|
||||
private var audioRouteManager: AudioRouteManager? = null
|
||||
private var networkMonitor: NetworkMonitor? = null
|
||||
private var audioStarted = false
|
||||
private var appContext: Context? = null
|
||||
private var settings: SettingsRepository? = null
|
||||
@@ -63,9 +60,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _isSpeaker = MutableStateFlow(false)
|
||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||
|
||||
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
|
||||
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow(CallStats())
|
||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||
|
||||
@@ -147,9 +141,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
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()
|
||||
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
|
||||
private val _signalState = MutableStateFlow("idle")
|
||||
val signalState: StateFlow<String> = _signalState.asStateFlow()
|
||||
|
||||
/** Incoming call info */
|
||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||
@@ -161,34 +155,82 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||
|
||||
/** Separate signal manager (persistent, survives calls) */
|
||||
private var signalManager: com.wzp.engine.SignalManager? = null
|
||||
private var signalPollJob: Job? = null
|
||||
|
||||
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
|
||||
var seed = _seedHex.value
|
||||
// Generate seed if empty (fresh install or cleared storage)
|
||||
if (seed.isEmpty()) {
|
||||
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
|
||||
seed = newSeed.joinToString("") { "%02x".format(it) }
|
||||
_seedHex.value = seed
|
||||
settings?.saveSeedHex(seed)
|
||||
Log.i(TAG, "generated new identity seed")
|
||||
}
|
||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||
|
||||
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"
|
||||
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack.
|
||||
// Dispatchers.IO threads overflow. Use explicit Java Thread.
|
||||
Thread(null, {
|
||||
try {
|
||||
val mgr = com.wzp.engine.SignalManager()
|
||||
val ok = mgr.connect(resolvedRelay, seed)
|
||||
viewModelScope.launch {
|
||||
if (ok) {
|
||||
signalManager = mgr
|
||||
startSignalPolling()
|
||||
} else {
|
||||
_errorMessage.value = "Failed to register on relay"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
viewModelScope.launch {
|
||||
_errorMessage.value = "Register error: ${e.message}"
|
||||
}
|
||||
}
|
||||
}, "wzp-signal-init", 8 * 1024 * 1024).start()
|
||||
}
|
||||
|
||||
/** Poll signal manager state every 500ms */
|
||||
private fun startSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
val mgr = signalManager
|
||||
if (mgr != null && mgr.isConnected) {
|
||||
val state = mgr.getState()
|
||||
_signalState.value = state.status
|
||||
_incomingCallId.value = state.incomingCallId
|
||||
_incomingCallerFp.value = state.incomingCallerFp
|
||||
_incomingCallerAlias.value = state.incomingCallerAlias
|
||||
|
||||
// Auto-connect to media room when call is set up
|
||||
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
|
||||
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
|
||||
startCallInternal(state.callSetupRelay, state.callSetupRoom)
|
||||
}
|
||||
}
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSignalPolling() {
|
||||
signalPollJob?.cancel()
|
||||
signalPollJob = null
|
||||
}
|
||||
|
||||
/** Place a direct call to the target fingerprint */
|
||||
fun placeDirectCall() {
|
||||
val target = _targetFingerprint.value.trim()
|
||||
@@ -196,24 +238,28 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_errorMessage.value = "Enter a fingerprint to call"
|
||||
return
|
||||
}
|
||||
engine?.placeCall(target)
|
||||
_signalState.value = 6 // Ringing
|
||||
signalManager?.placeCall(target)
|
||||
}
|
||||
|
||||
/** Answer an incoming direct call */
|
||||
fun answerIncomingCall(mode: Int = 2) {
|
||||
val callId = _incomingCallId.value ?: return
|
||||
engine?.answerCall(callId, mode)
|
||||
signalManager?.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
|
||||
signalManager?.answerCall(callId, 0)
|
||||
}
|
||||
|
||||
/** Hang up direct call — media ends, signal stays alive */
|
||||
fun hangupDirectCall() {
|
||||
signalManager?.hangup()
|
||||
engine?.stopCall()
|
||||
engine?.destroy()
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -232,19 +278,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioPipeline = AudioPipeline(appCtx)
|
||||
}
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx).also { arm ->
|
||||
arm.onRouteChanged = { route ->
|
||||
_audioRoute.value = route
|
||||
_isSpeaker.value = (route == AudioRoute.SPEAKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (networkMonitor == null) {
|
||||
networkMonitor = NetworkMonitor(appCtx).also { nm ->
|
||||
nm.onNetworkChanged = { type, bw ->
|
||||
engine?.onNetworkChanged(type, bw)
|
||||
}
|
||||
}
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
@@ -625,27 +659,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioRouteManager?.setSpeaker(newSpeaker)
|
||||
}
|
||||
|
||||
/** Cycle audio output: Earpiece → Speaker → Bluetooth (if available) → Earpiece. */
|
||||
fun cycleAudioRoute() {
|
||||
val routes = audioRouteManager?.availableRoutes() ?: return
|
||||
val currentIdx = routes.indexOf(_audioRoute.value)
|
||||
val next = routes[(currentIdx + 1) % routes.size]
|
||||
when (next) {
|
||||
AudioRoute.EARPIECE -> {
|
||||
audioRouteManager?.setBluetoothSco(false)
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
}
|
||||
AudioRoute.SPEAKER -> {
|
||||
audioRouteManager?.setSpeaker(true)
|
||||
}
|
||||
AudioRoute.BLUETOOTH -> {
|
||||
audioRouteManager?.setBluetoothSco(true)
|
||||
}
|
||||
}
|
||||
_audioRoute.value = next
|
||||
_isSpeaker.value = (next == AudioRoute.SPEAKER)
|
||||
}
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun sendDebugReport() {
|
||||
@@ -700,7 +713,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
networkMonitor?.register()
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
@@ -708,10 +720,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
networkMonitor?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
_audioRoute.value = AudioRoute.EARPIECE
|
||||
audioStarted = false
|
||||
}
|
||||
|
||||
@@ -727,30 +737,10 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
val s = CallStats.fromJson(json)
|
||||
lastCallDuration = s.durationSecs
|
||||
_stats.value = s
|
||||
// Only update callState from media engine stats (not signal)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wzp.audio.AudioRoute
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.ui.components.CopyableFingerprint
|
||||
import com.wzp.ui.components.Identicon
|
||||
@@ -75,7 +74,6 @@ fun InCallScreen(
|
||||
val callState by viewModel.callState.collectAsState()
|
||||
val isMuted by viewModel.isMuted.collectAsState()
|
||||
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
||||
val audioRoute by viewModel.audioRoute.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
@@ -167,7 +165,7 @@ fun InCallScreen(
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "ENCRYPTED VOICE",
|
||||
text = "ENCRYPTED VOICE \u2022 direct-call-v1",
|
||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
||||
color = TextDim
|
||||
)
|
||||
@@ -221,7 +219,7 @@ fun InCallScreen(
|
||||
|
||||
// Mode toggle: Room vs Direct Call
|
||||
val callMode by viewModel.callMode.collectAsState()
|
||||
val signalState by viewModel.signalState.collectAsState()
|
||||
val signalState by viewModel.signalState.collectAsState() // "idle"/"registered"/"ringing"/etc
|
||||
val targetFp by viewModel.targetFingerprint.collectAsState()
|
||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||
@@ -311,7 +309,7 @@ fun InCallScreen(
|
||||
}
|
||||
} else {
|
||||
// ── Direct call mode ──
|
||||
if (signalState < 5) {
|
||||
if (signalState == "idle") {
|
||||
// Not registered yet
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
@@ -335,7 +333,7 @@ fun InCallScreen(
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 5) {
|
||||
} else if (signalState == "registered" || signalState == "incoming") {
|
||||
// Registered — show dial pad
|
||||
Text(
|
||||
"\u2705 Registered — waiting for calls",
|
||||
@@ -405,8 +403,7 @@ fun InCallScreen(
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (signalState == 6) {
|
||||
// Ringing
|
||||
} else if (signalState == "ringing") {
|
||||
Text(
|
||||
"\uD83D\uDD14 Ringing...",
|
||||
color = Yellow,
|
||||
@@ -414,11 +411,10 @@ fun InCallScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (signalState == 7) {
|
||||
// Incoming call (state 7 also handled above in registered view)
|
||||
} else if (signalState == "setup") {
|
||||
Text(
|
||||
"\uD83D\uDCDE Incoming call...",
|
||||
color = Green,
|
||||
"Connecting to call...",
|
||||
color = Accent,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -433,14 +429,16 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Identity
|
||||
val fp = if (seedHex.length >= 16) seedHex.take(16) else ""
|
||||
// Identity — compute real fingerprint from seed
|
||||
val fullFp = remember(seedHex) {
|
||||
if (seedHex.length >= 64) com.wzp.engine.WzpEngine.getFingerprint(seedHex) else ""
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (fp.isNotEmpty()) {
|
||||
Identicon(fingerprint = seedHex, size = 28.dp)
|
||||
if (fullFp.isNotEmpty()) {
|
||||
Identicon(fingerprint = fullFp, size = 28.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
CopyableFingerprint(
|
||||
fingerprint = fp.chunked(4).joinToString(":"),
|
||||
fingerprint = fullFp,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = TextDim
|
||||
)
|
||||
@@ -623,12 +621,12 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Controls: Mic / End / Route (Ear/Spk/BT)
|
||||
// Controls: Mic / End / Spk
|
||||
ControlRow(
|
||||
isMuted = isMuted,
|
||||
audioRoute = audioRoute,
|
||||
isSpeaker = isSpeaker,
|
||||
onToggleMute = viewModel::toggleMute,
|
||||
onCycleRoute = viewModel::cycleAudioRoute,
|
||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||
onHangUp = { viewModel.stopCall() }
|
||||
)
|
||||
|
||||
@@ -917,9 +915,9 @@ private fun AudioLevelBar(audioLevel: Int) {
|
||||
@Composable
|
||||
private fun ControlRow(
|
||||
isMuted: Boolean,
|
||||
audioRoute: AudioRoute,
|
||||
isSpeaker: Boolean,
|
||||
onToggleMute: () -> Unit,
|
||||
onCycleRoute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onHangUp: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
@@ -961,28 +959,22 @@ private fun ControlRow(
|
||||
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
||||
// Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
|
||||
// Speaker
|
||||
FilledTonalIconButton(
|
||||
onClick = onCycleRoute,
|
||||
onClick = onToggleSpeaker,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = when (audioRoute) {
|
||||
AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
colors = if (isSpeaker) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0xFF0F3460), contentColor = Color.White
|
||||
)
|
||||
AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0xFF2563EB), contentColor = Color.White
|
||||
)
|
||||
else -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = DarkSurface2, contentColor = Color.White
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = when (audioRoute) {
|
||||
AudioRoute.EARPIECE -> "Ear"
|
||||
AudioRoute.SPEAKER -> "Spk"
|
||||
AudioRoute.BLUETOOTH -> "BT"
|
||||
},
|
||||
text = if (isSpeaker) "Spk\nOn" else "Spk",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
|
||||
@@ -28,7 +28,6 @@ libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
tracing-android = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -65,8 +65,9 @@ fn main() {
|
||||
} else {
|
||||
"aarch64-linux-android"
|
||||
};
|
||||
let lib_dir =
|
||||
format!("{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}");
|
||||
let lib_dir = format!(
|
||||
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||
|
||||
// Copy libc++_shared.so to the jniLibs directory
|
||||
@@ -81,7 +82,9 @@ fn main() {
|
||||
};
|
||||
// Try to copy to the Gradle jniLibs directory
|
||||
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let jni_dir = format!("{manifest}/../../android/app/src/main/jniLibs/{jni_abi}");
|
||||
let jni_dir = format!(
|
||||
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
|
||||
);
|
||||
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
||||
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
||||
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
||||
@@ -124,12 +127,7 @@ 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()
|
||||
{
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
return Some(oboe_dir);
|
||||
}
|
||||
|
||||
@@ -145,12 +143,7 @@ fn fetch_oboe() -> Option<PathBuf> {
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
if oboe_dir
|
||||
.join("include")
|
||||
.join("oboe")
|
||||
.join("Oboe.h")
|
||||
.exists()
|
||||
{
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
Some(oboe_dir)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -326,10 +326,7 @@ pub fn pin_to_big_core() {
|
||||
&set,
|
||||
);
|
||||
if ret != 0 {
|
||||
warn!(
|
||||
"sched_setaffinity failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
||||
} else {
|
||||
info!(start, num_cpus, "pinned to big cores");
|
||||
}
|
||||
|
||||
@@ -77,8 +77,7 @@ impl AudioRing {
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos
|
||||
.store(w.wrapping_add(count), Ordering::Release);
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
@@ -113,8 +112,7 @@ impl AudioRing {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos
|
||||
.store(r.wrapping_add(count), Ordering::Release);
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,13 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use wzp_codec::AdaptiveDecoder;
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::{
|
||||
AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||
MediaHeader, MediaPacket, MediaTransport, MediaType, QualityController, QualityProfile,
|
||||
SignalMessage, default_signal_version,
|
||||
MediaHeader, MediaPacket, MediaTransport, QualityController, QualityProfile, SignalMessage,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
@@ -47,11 +44,7 @@ const PROFILES: [QualityProfile; 6] = [
|
||||
];
|
||||
|
||||
fn profile_to_index(p: &QualityProfile) -> u8 {
|
||||
PROFILES
|
||||
.iter()
|
||||
.position(|pp| pp.codec == p.codec)
|
||||
.map(|i| i as u8)
|
||||
.unwrap_or(3)
|
||||
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
|
||||
}
|
||||
|
||||
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
|
||||
@@ -104,9 +97,6 @@ pub(crate) struct EngineState {
|
||||
/// QUIC transport handle — stored so stop_call() can close it immediately,
|
||||
/// triggering relay-side leave + RoomUpdate broadcast.
|
||||
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
|
||||
/// Network type from Android ConnectivityManager, polled by recv task.
|
||||
/// 0xFF = no change pending; 0-5 = NetworkContext ordinal.
|
||||
pub pending_network_type: AtomicU8,
|
||||
}
|
||||
|
||||
pub struct WzpEngine {
|
||||
@@ -128,7 +118,6 @@ impl WzpEngine {
|
||||
playout_ring: AudioRing::new(),
|
||||
audio_level_rms: AtomicU32::new(0),
|
||||
quic_transport: Mutex::new(None),
|
||||
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
|
||||
});
|
||||
Self {
|
||||
state,
|
||||
@@ -154,10 +143,9 @@ impl WzpEngine {
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let relay_addr: SocketAddr = config
|
||||
.relay_addr
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr))?;
|
||||
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
|
||||
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
|
||||
})?;
|
||||
|
||||
let room = config.room.clone();
|
||||
let identity_seed = config.identity_seed;
|
||||
@@ -171,16 +159,7 @@ impl WzpEngine {
|
||||
|
||||
let state_clone = state.clone();
|
||||
runtime.block_on(async move {
|
||||
if let Err(e) = run_call(
|
||||
relay_addr,
|
||||
&room,
|
||||
&identity_seed,
|
||||
profile,
|
||||
auto_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}");
|
||||
}
|
||||
@@ -222,7 +201,6 @@ impl WzpEngine {
|
||||
/// 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()
|
||||
@@ -248,21 +226,16 @@ impl WzpEngine {
|
||||
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())
|
||||
})
|
||||
})
|
||||
.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
|
||||
))
|
||||
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
|
||||
});
|
||||
|
||||
// Shutdown runtime cleanly with timeout
|
||||
@@ -271,159 +244,7 @@ impl WzpEngine {
|
||||
}
|
||||
|
||||
/// 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 { version: default_signal_version(), token: tok.clone() }).await;
|
||||
}
|
||||
|
||||
// Register presence
|
||||
let _ = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||
version: default_signal_version(),
|
||||
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(())
|
||||
}
|
||||
// Signal methods (start_signaling, place_call, answer_call) moved to signal_mgr.rs
|
||||
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
self.state.muted.store(muted, Ordering::Relaxed);
|
||||
@@ -433,15 +254,6 @@ impl WzpEngine {
|
||||
|
||||
pub fn force_profile(&self, _profile: QualityProfile) {}
|
||||
|
||||
/// Signal a network transport change from Android ConnectivityManager.
|
||||
/// Stores the type atomically; the recv task polls it on each packet.
|
||||
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
|
||||
info!(network_type, bandwidth_kbps, "on_network_changed");
|
||||
self.state
|
||||
.pending_network_type
|
||||
.store(network_type, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
if let Some(start) = self.call_start {
|
||||
@@ -496,7 +308,6 @@ async fn run_call(
|
||||
alias: Option<&str>,
|
||||
state: Arc<EngineState>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
@@ -523,7 +334,6 @@ async fn run_call(
|
||||
let signature = kx.sign(&sign_data);
|
||||
|
||||
let offer = SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
@@ -536,8 +346,6 @@ async fn run_call(
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
transport.send_signal(&offer).await?;
|
||||
info!("CallOffer sent, waiting for CallAnswer...");
|
||||
@@ -548,16 +356,12 @@ async fn run_call(
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||
|
||||
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
||||
SignalMessage::CallAnswer {
|
||||
ephemeral_pub,
|
||||
chosen_profile,
|
||||
..
|
||||
} => (ephemeral_pub, chosen_profile),
|
||||
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
));
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -577,12 +381,9 @@ async fn run_call(
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Initialize codec (Opus or Codec2 based on profile)
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder");
|
||||
let mut decoder = wzp_codec::create_decoder(profile);
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
@@ -608,7 +409,7 @@ async fn run_call(
|
||||
stats.auto_mode = auto_profile;
|
||||
}
|
||||
|
||||
let seq = AtomicU32::new(0);
|
||||
let seq = AtomicU16::new(0);
|
||||
let ts = AtomicU32::new(0);
|
||||
let transport_recv = transport.clone();
|
||||
|
||||
@@ -715,34 +516,23 @@ 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 source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: MediaHeader::VERSION,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: current_profile.codec,
|
||||
stream_id: 0,
|
||||
fec_ratio: hdr_fec_ratio,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
||||
seq: s,
|
||||
timestamp: t,
|
||||
fec_block: ((hdr_fec_symbol as u16) << 8) | (hdr_fec_block as u16),
|
||||
fec_block: block_id,
|
||||
fec_symbol: frame_in_block,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::copy_from_slice(encoded),
|
||||
quality_report: None,
|
||||
@@ -757,7 +547,9 @@ async fn run_call(
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(
|
||||
seq = s,
|
||||
send_errors, frames_dropped, "send_media error (dropping packet): {e}"
|
||||
send_errors,
|
||||
frames_dropped,
|
||||
"send_media error (dropping packet): {e}"
|
||||
);
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
@@ -768,64 +560,63 @@ async fn run_call(
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// 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.
|
||||
// Feed encoded frame to FEC encoder
|
||||
let t0 = Instant::now();
|
||||
if !is_opus {
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
|
||||
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: MediaHeader::VERSION,
|
||||
flags: MediaHeader::FLAG_REPAIR,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: current_profile.codec,
|
||||
stream_id: 0,
|
||||
fec_ratio: MediaHeader::encode_fec_ratio(
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: (sym_idx << 8) | (block_id as u16),
|
||||
},
|
||||
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"
|
||||
);
|
||||
// When block is full, generate repair packets
|
||||
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
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -848,11 +639,7 @@ async fn run_call(
|
||||
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
|
||||
"send stats"
|
||||
);
|
||||
t_agc_us = 0;
|
||||
t_opus_us = 0;
|
||||
t_fec_us = 0;
|
||||
t_send_us = 0;
|
||||
t_frames = 0;
|
||||
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
@@ -872,24 +659,7 @@ async fn run_call(
|
||||
let mut last_stats_log = Instant::now();
|
||||
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<u32> = None;
|
||||
let mut expected_seq: Option<u32> = None;
|
||||
let mut dred_reconstructions: u64 = 0;
|
||||
let mut classical_plc_invocations: u64 = 0;
|
||||
|
||||
info!("recv task started (Opus + DRED + Codec2/RaptorQ)");
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -906,30 +676,11 @@ async fn run_call(
|
||||
warn!(
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
is_repair = pkt.header.is_repair(),
|
||||
is_repair = pkt.header.is_repair,
|
||||
"large recv gap — possible network stall"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for network transport change from ConnectivityManager
|
||||
{
|
||||
let net = state
|
||||
.pending_network_type
|
||||
.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
||||
if net != PROFILE_NO_CHANGE {
|
||||
use wzp_proto::NetworkContext;
|
||||
let ctx = match net {
|
||||
0 => NetworkContext::WiFi,
|
||||
1 => NetworkContext::CellularLte,
|
||||
2 => NetworkContext::Cellular5g,
|
||||
3 => NetworkContext::Cellular3g,
|
||||
_ => NetworkContext::Unknown,
|
||||
};
|
||||
quality_ctrl.signal_network_change(ctx);
|
||||
info!(?ctx, "quality controller: network context updated");
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive quality: ingest quality reports from relay
|
||||
if auto_profile {
|
||||
if let Some(ref qr) = pkt.quality_report {
|
||||
@@ -947,19 +698,17 @@ async fn run_call(
|
||||
}
|
||||
}
|
||||
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let pkt_block = pkt.header.fec_block as u8;
|
||||
let pkt_symbol = pkt.header.fec_block >> 8;
|
||||
let pkt_is_opus = pkt.header.codec_id.is_opus();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Feed every packet (source + repair) to FEC decoder
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
|
||||
// Source packets: decode directly
|
||||
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||
@@ -977,22 +726,11 @@ async fn run_call(
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
..QualityProfile::GOOD
|
||||
},
|
||||
other => QualityProfile {
|
||||
codec: other,
|
||||
..QualityProfile::GOOD
|
||||
},
|
||||
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) {
|
||||
@@ -1001,101 +739,6 @@ async fn run_call(
|
||||
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: u32 = 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]);
|
||||
@@ -1107,44 +750,32 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1152,8 +783,6 @@ 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(),
|
||||
@@ -1166,10 +795,7 @@ async fn run_call(
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered, "relay disconnected (stream ended)"
|
||||
);
|
||||
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1187,10 +813,7 @@ async fn run_call(
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered, recv_errors, "recv task ended"
|
||||
);
|
||||
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
|
||||
};
|
||||
|
||||
// Stats task — polls path quality + quinn RTT every 500ms
|
||||
@@ -1223,11 +846,7 @@ async fn run_call(
|
||||
let signal_task = async {
|
||||
loop {
|
||||
match transport_signal.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RoomUpdate {
|
||||
count,
|
||||
participants,
|
||||
..
|
||||
})) => {
|
||||
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => {
|
||||
info!(count, "RoomUpdate received");
|
||||
let members: Vec<crate::stats::RoomMember> = participants
|
||||
.iter()
|
||||
@@ -1241,19 +860,6 @@ async fn run_call(
|
||||
stats.room_participant_count = count;
|
||||
stats.room_participants = members;
|
||||
}
|
||||
Ok(Some(SignalMessage::QualityDirective {
|
||||
recommended_profile,
|
||||
reason,
|
||||
..
|
||||
})) => {
|
||||
let idx = profile_to_index(&recommended_profile);
|
||||
info!(
|
||||
codec = ?recommended_profile.codec,
|
||||
reason = reason.as_deref().unwrap_or(""),
|
||||
"relay quality directive: switching profile"
|
||||
);
|
||||
pending_profile_recv.store(idx, Ordering::Release);
|
||||
}
|
||||
Ok(Some(msg)) => {
|
||||
info!("signal received: {:?}", std::mem::discriminant(&msg));
|
||||
}
|
||||
@@ -1283,9 +889,7 @@ async fn run_call(
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
transport.connection().closed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
).await {
|
||||
Ok(_) => info!("QUIC connection closed cleanly"),
|
||||
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use std::panic;
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use tracing::{error, info};
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
@@ -26,21 +26,19 @@ const PROFILE_AUTO: jint = 7;
|
||||
|
||||
fn profile_from_int(value: jint) -> QualityProfile {
|
||||
match value {
|
||||
0 => QualityProfile::GOOD, // Opus 24k
|
||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
||||
3 => QualityProfile {
|
||||
// Codec2 3.2k
|
||||
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,
|
||||
..QualityProfile::GOOD
|
||||
},
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,33 +48,25 @@ static INIT_LOGGING: Once = Once::new();
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// Filter: INFO for our crates, WARN for everything else.
|
||||
// The jni crate emits VERBOSE logs for every method lookup
|
||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||
// and causes the system to kill the app.
|
||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(layer)
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// On non-Android targets tracing-android is unavailable.
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
}
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// Filter: INFO for our crates, WARN for everything else.
|
||||
// The jni crate emits VERBOSE logs for every method lookup
|
||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||
// and causes the system to kill the app.
|
||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(layer)
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +77,9 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
||||
) -> jlong {
|
||||
let result = panic::catch_unwind(|| {
|
||||
init_logging();
|
||||
// Install rustls crypto provider ONCE on the main thread.
|
||||
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let handle = Box::new(EngineHandle {
|
||||
engine: WzpEngine::new(),
|
||||
});
|
||||
@@ -111,26 +104,11 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
profile_j: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let relay_addr: String = env
|
||||
.get_string(&relay_addr_j)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let room: String = env
|
||||
.get_string(&room_j)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let seed_hex: String = env
|
||||
.get_string(&seed_hex_j)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let token: String = env
|
||||
.get_string(&token_j)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let alias: String = env
|
||||
.get_string(&alias_j)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
|
||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
||||
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
|
||||
@@ -153,11 +131,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
auto_profile: profile_j == PROFILE_AUTO,
|
||||
relay_addr,
|
||||
room,
|
||||
auth_token: if token.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
token.into_bytes()
|
||||
},
|
||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||
identity_seed,
|
||||
alias: if alias.is_empty() { None } else { Some(alias) },
|
||||
};
|
||||
@@ -251,30 +225,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
||||
}));
|
||||
}
|
||||
|
||||
/// Signal a network transport change from the Android ConnectivityManager.
|
||||
///
|
||||
/// `network_type` matches the Rust `NetworkContext` enum:
|
||||
/// 0=WiFi, 1=CellularLte, 2=Cellular5g, 3=Cellular3g, 4=Unknown, 5=None
|
||||
///
|
||||
/// The engine forwards this to the `AdaptiveQualityController` which:
|
||||
/// - Preemptively downgrades one tier on WiFi→cellular
|
||||
/// - Activates a 10-second FEC boost
|
||||
/// - Uses faster downgrade thresholds on cellular
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
network_type: jint,
|
||||
bandwidth_kbps: jint,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine
|
||||
.on_network_changed(network_type as u8, bandwidth_kbps as u32);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
|
||||
/// pcm is a Java short[] array.
|
||||
#[unsafe(no_mangle)]
|
||||
@@ -337,14 +287,13 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDire
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env
|
||||
.get_direct_buffer_address(&buffer)
|
||||
.unwrap_or(std::ptr::null_mut());
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || sample_count <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples =
|
||||
unsafe { std::slice::from_raw_parts(ptr as *const i16, sample_count as usize) };
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
|
||||
};
|
||||
h.engine.write_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
@@ -363,14 +312,13 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirec
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env
|
||||
.get_direct_buffer_address(&buffer)
|
||||
.unwrap_or(std::ptr::null_mut());
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || max_samples <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples =
|
||||
unsafe { std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize) };
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
|
||||
};
|
||||
h.engine.read_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
@@ -399,10 +347,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||
) -> 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 relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||
match h.engine.ping_relay(&relay) {
|
||||
Ok(json) => Some(json),
|
||||
Err(_) => None,
|
||||
@@ -418,124 +363,149 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
/// Get the identity fingerprint for a seed hex string.
|
||||
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
seed_hex_j: JString,
|
||||
) -> jstring {
|
||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||
let fp = if seed_hex.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
match wzp_crypto::Seed::from_hex(&seed_hex) {
|
||||
Ok(seed) => {
|
||||
let id = seed.derive_identity();
|
||||
id.public_identity().fingerprint.to_string()
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
};
|
||||
env.new_string(&fp)
|
||||
.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.
|
||||
// ── SignalManager JNI functions ──
|
||||
|
||||
/// Opaque handle for SignalManager (separate from EngineHandle).
|
||||
struct SignalHandle {
|
||||
mgr: crate::signal_mgr::SignalManager,
|
||||
}
|
||||
|
||||
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
|
||||
unsafe { &*(handle as *const SignalHandle) }
|
||||
}
|
||||
|
||||
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
|
||||
/// Blocks up to 10s waiting for the internal signal thread to connect.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'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();
|
||||
relay_j: JString,
|
||||
seed_j: JString,
|
||||
) -> jlong {
|
||||
info!("nativeSignalConnect: entered");
|
||||
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
|
||||
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
|
||||
|
||||
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
|
||||
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
|
||||
// Blocks up to 10s waiting for the connect+register to complete.
|
||||
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
|
||||
Ok(mgr) => {
|
||||
let handle = Box::new(SignalHandle { mgr });
|
||||
Box::into_raw(handle) as jlong
|
||||
}
|
||||
Err(_) => {
|
||||
error!("start_signaling panicked");
|
||||
-1
|
||||
Err(e) => {
|
||||
error!("signal connect failed: {e}");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Place a direct call to a target fingerprint.
|
||||
/// Returns 0 on success, -1 on error.
|
||||
/// Get signal state as JSON string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'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)
|
||||
}));
|
||||
) -> jstring {
|
||||
if handle == 0 { return JObject::null().into_raw(); }
|
||||
let h = signal_ref(handle);
|
||||
let json = h.mgr.get_state_json();
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(Ok(())) => 0,
|
||||
Ok(Err(e)) => {
|
||||
error!("place_call failed: {e}");
|
||||
-1
|
||||
}
|
||||
Err(_) => {
|
||||
error!("place_call panicked");
|
||||
-1
|
||||
}
|
||||
/// Place a direct call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
target_j: JString,
|
||||
) -> jint {
|
||||
if handle == 0 { return -1; }
|
||||
let h = signal_ref(handle);
|
||||
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default();
|
||||
match h.mgr.place_call(&target) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => { error!("place_call: {e}"); -1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer an incoming direct call.
|
||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
||||
/// Answer an incoming call.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'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
|
||||
}
|
||||
if handle == 0 { return -1; }
|
||||
let h = signal_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,
|
||||
};
|
||||
match h.mgr.answer_call(&call_id, accept_mode) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => { error!("answer_call: {e}"); -1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Send hangup signal.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle == 0 { return; }
|
||||
let h = signal_ref(handle);
|
||||
h.mgr.hangup();
|
||||
}
|
||||
|
||||
/// Destroy the signal manager and free resources.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle == 0 { return; }
|
||||
let h = signal_ref(handle);
|
||||
h.mgr.stop();
|
||||
// Reclaim the Box
|
||||
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
|
||||
}
|
||||
|
||||
@@ -8,24 +8,12 @@
|
||||
//!
|
||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
||||
//! allowing `cargo check` and unit tests on the host.
|
||||
//!
|
||||
//! ## Status
|
||||
//!
|
||||
//! **Dead code as of the Tauri mobile rewrite.** The legacy Kotlin+JNI
|
||||
//! Android app that consumed this crate was replaced by a Tauri 2.x
|
||||
//! Mobile app (see `desktop/src-tauri/src/engine.rs` for the live
|
||||
//! Android audio recv path and `crates/wzp-native/` for the Oboe
|
||||
//! bridge). We keep this crate in the workspace for reference and to
|
||||
//! preserve the commit history, but it is not built by any shipping
|
||||
//! target. Allow the accumulated leftover warnings so CI/workspace
|
||||
//! checks stay clean — any real cleanup should happen as part of
|
||||
//! removing the crate entirely, not piecemeal.
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_mut)]
|
||||
|
||||
pub mod audio_android;
|
||||
pub mod audio_ring;
|
||||
pub mod commands;
|
||||
pub mod engine;
|
||||
pub mod jni_bridge;
|
||||
pub mod pipeline;
|
||||
pub mod signal_mgr;
|
||||
pub mod stats;
|
||||
pub mod jni_bridge;
|
||||
|
||||
@@ -9,8 +9,8 @@ use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::QualityController;
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::traits::QualityController;
|
||||
use wzp_proto::{MediaPacket, QualityProfile};
|
||||
|
||||
use crate::audio_android::FRAME_SAMPLES;
|
||||
@@ -58,12 +58,14 @@ pub struct Pipeline {
|
||||
impl Pipeline {
|
||||
/// Create a new pipeline configured for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
||||
let encoder =
|
||||
AdaptiveEncoder::new(profile).map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
||||
let decoder =
|
||||
AdaptiveDecoder::new(profile).map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
||||
let fec_encoder = RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
||||
let fec_decoder = RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
||||
let encoder = AdaptiveEncoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
||||
let decoder = AdaptiveDecoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
||||
let fec_encoder =
|
||||
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
||||
let fec_decoder =
|
||||
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
||||
let quality_ctrl = AdaptiveQualityController::new();
|
||||
|
||||
@@ -134,11 +136,11 @@ impl Pipeline {
|
||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
||||
// Feed FEC symbols if present
|
||||
let header = &packet.header;
|
||||
if header.fec_block != 0 {
|
||||
let is_repair = header.is_repair();
|
||||
if header.fec_block != 0 || header.fec_symbol != 0 {
|
||||
let is_repair = header.is_repair;
|
||||
if let Err(e) = self.fec_decoder.add_symbol(
|
||||
header.fec_block as u8,
|
||||
header.fec_block >> 8,
|
||||
header.fec_block,
|
||||
header.fec_symbol,
|
||||
is_repair,
|
||||
&packet.payload,
|
||||
) {
|
||||
@@ -209,7 +211,10 @@ impl Pipeline {
|
||||
///
|
||||
/// Returns a new profile if a tier transition occurred.
|
||||
#[allow(unused)]
|
||||
pub fn observe_quality(&mut self, report: &wzp_proto::QualityReport) -> Option<QualityProfile> {
|
||||
pub fn observe_quality(
|
||||
&mut self,
|
||||
report: &wzp_proto::QualityReport,
|
||||
) -> Option<QualityProfile> {
|
||||
let new_profile = self.quality_ctrl.observe(report);
|
||||
if let Some(ref profile) = new_profile {
|
||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
||||
|
||||
288
crates/wzp-android/src/signal_mgr.rs
Normal file
288
crates/wzp-android/src/signal_mgr.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! Persistent signal connection manager for direct 1:1 calls.
|
||||
//!
|
||||
//! Separate from the media engine — survives across calls.
|
||||
//! Connects to relay via `_signal` SNI, registers presence,
|
||||
//! and handles call signaling (offer/answer/setup/hangup).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
|
||||
/// Signal connection status.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct SignalState {
|
||||
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
|
||||
pub fingerprint: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_fp: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub incoming_caller_alias: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_relay: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_room: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call_setup_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Manages a persistent `_signal` QUIC connection to a relay.
|
||||
pub struct SignalManager {
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
state: Arc<Mutex<SignalState>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl SignalManager {
|
||||
/// Create SignalManager and start connect+register+recv on a background thread.
|
||||
/// Returns immediately. The internal thread runs forever.
|
||||
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
|
||||
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
|
||||
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 state = Arc::new(Mutex::new(SignalState {
|
||||
status: "connecting".into(),
|
||||
fingerprint: fp.clone(),
|
||||
..Default::default()
|
||||
}));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Channel to receive transport after connect succeeds
|
||||
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
|
||||
|
||||
let bg_state = Arc::clone(&state);
|
||||
let bg_running = Arc::clone(&running);
|
||||
let ret_state = Arc::clone(&state);
|
||||
let ret_running = Arc::clone(&running);
|
||||
|
||||
// ONE thread, ONE runtime, NEVER dropped.
|
||||
// Connect + register + recv loop all happen here.
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-signal".into())
|
||||
.stack_size(4 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
|
||||
rt.block_on(async move {
|
||||
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
|
||||
|
||||
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}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
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}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Register
|
||||
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||
identity_pub, signature: vec![], alias: None,
|
||||
}).await {
|
||||
error!("signal register: {e}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
||||
info!(fingerprint = %fp, "signal: registered");
|
||||
bg_state.lock().unwrap().status = "registered".into();
|
||||
// Send transport to caller
|
||||
let _ = transport_tx.send(transport.clone());
|
||||
}
|
||||
other => {
|
||||
error!("signal registration failed: {other:?}");
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recv loop — runs forever
|
||||
loop {
|
||||
if !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 s = state.lock().unwrap();
|
||||
s.status = "ringing".into();
|
||||
}
|
||||
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "incoming".into();
|
||||
s.incoming_call_id = Some(call_id);
|
||||
s.incoming_caller_fp = Some(caller_fingerprint);
|
||||
s.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");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "setup".into();
|
||||
s.call_setup_relay = Some(relay_addr);
|
||||
s.call_setup_room = Some(room);
|
||||
s.call_setup_id = Some(call_id);
|
||||
}
|
||||
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||
info!(reason = ?reason, "signal: hangup");
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "registered".into();
|
||||
s.incoming_call_id = None;
|
||||
s.incoming_caller_fp = None;
|
||||
s.incoming_caller_alias = None;
|
||||
s.call_setup_relay = None;
|
||||
s.call_setup_room = None;
|
||||
s.call_setup_id = None;
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
info!("signal: connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bg_state.lock().unwrap().status = "idle".into();
|
||||
}); // block_on
|
||||
|
||||
// Runtime intentionally NOT dropped — lives until thread exits.
|
||||
// This prevents ring/libcrypto TLS cleanup conflict on Android.
|
||||
// The thread is parked here forever (block_on returned = connection lost).
|
||||
std::thread::park();
|
||||
})?; // thread spawn
|
||||
|
||||
// Wait for transport (up to 10s)
|
||||
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
|
||||
|
||||
Ok(Self { transport, state: ret_state, running: ret_running })
|
||||
}
|
||||
|
||||
/// Get current state (non-blocking).
|
||||
pub fn get_state(&self) -> SignalState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get state as JSON string.
|
||||
pub fn get_state_json(&self) -> String {
|
||||
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
|
||||
}
|
||||
|
||||
/// Place a direct call.
|
||||
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
|
||||
let fp = self.state.lock().unwrap().fingerprint.clone();
|
||||
let target = target_fp.to_string();
|
||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
let transport = self.transport.clone();
|
||||
|
||||
// Send on a small thread (async send needs a runtime)
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-call-send".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: fp,
|
||||
caller_alias: None,
|
||||
target_fingerprint: target,
|
||||
call_id,
|
||||
identity_pub: [0u8; 32],
|
||||
ephemeral_pub: [0u8; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
}).await;
|
||||
});
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer an incoming call.
|
||||
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
||||
let call_id = call_id.to_string();
|
||||
let transport = self.transport.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-answer-send".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode: mode,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||
}).await;
|
||||
});
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send hangup.
|
||||
pub fn hangup(&self) {
|
||||
let transport = self.transport.clone();
|
||||
let state = self.state.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all().build().expect("rt");
|
||||
rt.block_on(async {
|
||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
});
|
||||
let mut s = state.lock().unwrap();
|
||||
s.status = "registered".into();
|
||||
s.incoming_call_id = None;
|
||||
s.incoming_caller_fp = None;
|
||||
s.incoming_caller_alias = None;
|
||||
s.call_setup_relay = None;
|
||||
s.call_setup_room = None;
|
||||
s.call_setup_id = None;
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the signal connection.
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Release);
|
||||
self.transport.connection().close(0u32.into(), b"shutdown");
|
||||
}
|
||||
}
|
||||
@@ -58,16 +58,8 @@ pub struct CallStats {
|
||||
pub frames_decoded: u64,
|
||||
/// Number of playout underruns (buffer empty when audio needed).
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
|
||||
/// RaptorQ per Phase 2).
|
||||
/// Frames recovered by FEC.
|
||||
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).
|
||||
|
||||
@@ -12,7 +12,6 @@ wzp-codec = { workspace = true }
|
||||
wzp-fec = { workspace = true }
|
||||
wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
wzp-video = { path = "../wzp-video" }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
@@ -22,93 +21,17 @@ anyhow = "1"
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
cpal = { version = "0.15", optional = true }
|
||||
libc = "0.2"
|
||||
# Phase 5.5 — LAN host-candidate ICE: enumerate local network
|
||||
# interface addresses for inclusion in DirectCallOffer/Answer so
|
||||
# peers on the same LAN can direct-connect without NAT hairpinning
|
||||
# through the WAN reflex addr (which many consumer NATs, including
|
||||
# MikroTik's default masquerade, don't support).
|
||||
if-addrs = "0.13"
|
||||
rand = { workspace = true }
|
||||
socket2 = "0.5"
|
||||
|
||||
# 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"
|
||||
path = "src/cli.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-analyzer"
|
||||
path = "src/analyzer.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-bench"
|
||||
path = "src/bench_cli.rs"
|
||||
|
||||
@@ -1,973 +0,0 @@
|
||||
//! WarzonePhone Protocol Analyzer — passive call quality observer.
|
||||
//!
|
||||
//! Joins a relay room as a passive participant (no media sent) and displays
|
||||
//! real-time per-participant quality metrics in a terminal UI.
|
||||
//!
|
||||
//! Usage:
|
||||
//! wzp-analyzer 127.0.0.1:4433 --room test
|
||||
//! wzp-analyzer 1.2.3.4:4433 --room test --capture session.wzp
|
||||
//! wzp-analyzer 1.2.3.4:4433 --room test --no-tui --duration 60
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
use wzp_proto::{CodecId, MediaPacket, MediaTransport, default_signal_version};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// WarzonePhone Protocol Analyzer — passive call quality observer
|
||||
#[derive(Parser)]
|
||||
#[command(name = "wzp-analyzer", version)]
|
||||
struct Args {
|
||||
/// Relay address (host:port) — required for live mode, ignored with --replay
|
||||
relay: Option<String>,
|
||||
|
||||
/// Room name to observe — required for live mode, ignored with --replay
|
||||
#[arg(short, long)]
|
||||
room: Option<String>,
|
||||
|
||||
/// Auth token for relay
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
|
||||
/// Identity seed (64-char hex)
|
||||
#[arg(long)]
|
||||
seed: Option<String>,
|
||||
|
||||
/// Capture packets to file
|
||||
#[arg(long)]
|
||||
capture: Option<String>,
|
||||
|
||||
/// Auto-stop after N seconds
|
||||
#[arg(long)]
|
||||
duration: Option<u64>,
|
||||
|
||||
/// Disable TUI (print stats to stdout instead)
|
||||
#[arg(long)]
|
||||
no_tui: bool,
|
||||
|
||||
/// Replay a captured .wzp file (offline analysis)
|
||||
#[arg(long)]
|
||||
replay: Option<String>,
|
||||
|
||||
/// Generate HTML report (from live session or replay)
|
||||
#[arg(long)]
|
||||
html: Option<String>,
|
||||
|
||||
/// Session key hex for decrypting payloads (enables audio decode)
|
||||
// TODO(#17): Audio decode requires session key + nonce context.
|
||||
// In SFU mode, payloads are E2E encrypted. Decoding requires
|
||||
// either: (a) session key from both endpoints, or (b) running
|
||||
// the analyzer as a trusted participant with its own key exchange.
|
||||
// For now, header-only analysis provides loss%, jitter, codec stats.
|
||||
#[arg(long)]
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-participant statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ParticipantStats {
|
||||
/// Stream identifier (index, assigned when we detect a new seq stream)
|
||||
stream_id: usize,
|
||||
/// Display name from RoomUpdate (if available)
|
||||
alias: Option<String>,
|
||||
/// Current codec
|
||||
codec: CodecId,
|
||||
/// Total packets received
|
||||
packets: u64,
|
||||
/// Detected lost packets (sequence gaps)
|
||||
lost: u64,
|
||||
/// Last seen sequence number
|
||||
last_seq: u32,
|
||||
/// Whether we've seen the first packet (for gap detection)
|
||||
seq_initialized: bool,
|
||||
/// EWMA jitter in ms
|
||||
jitter_ms: f64,
|
||||
/// Last packet arrival time
|
||||
last_arrival: Option<Instant>,
|
||||
/// Codec changes observed
|
||||
codec_switches: u32,
|
||||
/// First packet time
|
||||
first_seen: Instant,
|
||||
/// Last packet time
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
impl ParticipantStats {
|
||||
fn new(id: usize, codec: CodecId) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
stream_id: id,
|
||||
alias: None,
|
||||
codec,
|
||||
packets: 0,
|
||||
lost: 0,
|
||||
last_seq: 0,
|
||||
seq_initialized: false,
|
||||
jitter_ms: 0.0,
|
||||
last_arrival: None,
|
||||
codec_switches: 0,
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest(&mut self, pkt: &MediaPacket, now: Instant) {
|
||||
self.packets += 1;
|
||||
self.last_seen = now;
|
||||
|
||||
// Codec switch detection
|
||||
if pkt.header.codec_id != self.codec {
|
||||
self.codec_switches += 1;
|
||||
self.codec = pkt.header.codec_id;
|
||||
}
|
||||
|
||||
// Loss detection from sequence gaps
|
||||
if self.seq_initialized {
|
||||
let expected = self.last_seq.wrapping_add(1);
|
||||
let gap = pkt.header.seq.wrapping_sub(expected);
|
||||
if gap > 0 && gap < 100 {
|
||||
self.lost += gap as u64;
|
||||
}
|
||||
}
|
||||
self.last_seq = pkt.header.seq;
|
||||
self.seq_initialized = true;
|
||||
|
||||
// Jitter (inter-arrival time variance, EWMA)
|
||||
if let Some(last) = self.last_arrival {
|
||||
let interval_ms = now.duration_since(last).as_secs_f64() * 1000.0;
|
||||
let expected_ms = pkt.header.codec_id.frame_duration_ms() as f64;
|
||||
let diff = (interval_ms - expected_ms).abs();
|
||||
self.jitter_ms = 0.1 * diff + 0.9 * self.jitter_ms;
|
||||
}
|
||||
self.last_arrival = Some(now);
|
||||
}
|
||||
|
||||
fn loss_percent(&self) -> f64 {
|
||||
let total = self.packets + self.lost;
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.lost as f64 / total as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
fn duration(&self) -> Duration {
|
||||
self.last_seen.duration_since(self.first_seen)
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.alias
|
||||
.as_deref()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| format!("Stream {}", self.stream_id))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Participant identification by sequence stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Find the participant whose sequence counter is close to `seq`, or create a
|
||||
/// new one. Each sender has an independent wrapping u16 counter, so we can
|
||||
/// distinguish streams by proximity of consecutive sequence numbers.
|
||||
fn find_or_create_participant(
|
||||
participants: &mut Vec<ParticipantStats>,
|
||||
seq: u32,
|
||||
codec: CodecId,
|
||||
) -> usize {
|
||||
for (i, p) in participants.iter().enumerate() {
|
||||
if p.seq_initialized {
|
||||
let delta = seq.wrapping_sub(p.last_seq);
|
||||
if delta > 0 && delta < 50 {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
// New stream detected
|
||||
let id = participants.len();
|
||||
participants.push(ParticipantStats::new(id, codec));
|
||||
id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture writer (binary packet log for later replay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CaptureWriter {
|
||||
file: std::io::BufWriter<std::fs::File>,
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl CaptureWriter {
|
||||
fn new(path: &str, room: &str, relay: &str) -> anyhow::Result<Self> {
|
||||
let file = std::fs::File::create(path)?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
// Magic + version
|
||||
writer.write_all(b"WZP\x01")?;
|
||||
let header = serde_json::json!({
|
||||
"room": room,
|
||||
"relay": relay,
|
||||
"start_time": chrono::Utc::now().to_rfc3339(),
|
||||
"version": 1,
|
||||
});
|
||||
let header_bytes = serde_json::to_vec(&header)?;
|
||||
writer.write_all(&(header_bytes.len() as u32).to_le_bytes())?;
|
||||
writer.write_all(&header_bytes)?;
|
||||
Ok(Self {
|
||||
file: writer,
|
||||
start: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_packet(&mut self, pkt: &MediaPacket, now: Instant) -> anyhow::Result<()> {
|
||||
let elapsed_us = now.duration_since(self.start).as_micros() as u64;
|
||||
self.file.write_all(&elapsed_us.to_le_bytes())?;
|
||||
let raw = pkt.to_bytes();
|
||||
self.file.write_all(&(raw.len() as u32).to_le_bytes())?;
|
||||
self.file.write_all(&raw)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture reader (for replay mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CaptureReader {
|
||||
reader: std::io::BufReader<std::fs::File>,
|
||||
header: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CaptureReader {
|
||||
fn open(path: &str) -> anyhow::Result<Self> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
|
||||
// Read magic
|
||||
let mut magic = [0u8; 4];
|
||||
reader.read_exact(&mut magic)?;
|
||||
anyhow::ensure!(&magic == b"WZP\x01", "not a WZP capture file");
|
||||
|
||||
// Read header
|
||||
let mut len_buf = [0u8; 4];
|
||||
reader.read_exact(&mut len_buf)?;
|
||||
let header_len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut header_bytes = vec![0u8; header_len];
|
||||
reader.read_exact(&mut header_bytes)?;
|
||||
let header: serde_json::Value = serde_json::from_slice(&header_bytes)?;
|
||||
|
||||
Ok(Self { reader, header })
|
||||
}
|
||||
|
||||
fn next_packet(&mut self) -> anyhow::Result<Option<(u64, MediaPacket)>> {
|
||||
use std::io::Read;
|
||||
// Read timestamp
|
||||
let mut ts_buf = [0u8; 8];
|
||||
match self.reader.read_exact(&mut ts_buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let timestamp_us = u64::from_le_bytes(ts_buf);
|
||||
|
||||
// Read packet
|
||||
let mut len_buf = [0u8; 4];
|
||||
self.reader.read_exact(&mut len_buf)?;
|
||||
let pkt_len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut pkt_bytes = vec![0u8; pkt_len];
|
||||
self.reader.read_exact(&mut pkt_bytes)?;
|
||||
|
||||
let pkt = MediaPacket::from_bytes(bytes::Bytes::from(pkt_bytes))
|
||||
.ok_or_else(|| anyhow::anyhow!("malformed packet in capture"))?;
|
||||
|
||||
Ok(Some((timestamp_us, pkt)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline entry (for HTML report generation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TimelineEntry {
|
||||
timestamp_us: u64,
|
||||
stream_id: usize,
|
||||
#[allow(dead_code)]
|
||||
codec: CodecId,
|
||||
#[allow(dead_code)]
|
||||
seq: u32,
|
||||
#[allow(dead_code)]
|
||||
payload_len: usize,
|
||||
loss_pct: f64,
|
||||
jitter_ms: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode (#15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
||||
let mut reader = CaptureReader::open(path)?;
|
||||
eprintln!(
|
||||
"Replaying: {} (room: {})",
|
||||
path,
|
||||
reader
|
||||
.header
|
||||
.get("room")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?")
|
||||
);
|
||||
|
||||
let mut participants: Vec<ParticipantStats> = Vec::new();
|
||||
let mut total_packets: u64 = 0;
|
||||
let start = Instant::now();
|
||||
let mut timeline: Vec<TimelineEntry> = Vec::new();
|
||||
|
||||
// Decrypt session from --key (optional)
|
||||
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> =
|
||||
args.key.as_ref().and_then(|hex| {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let s = std::str::from_utf8(chunk).unwrap_or("00");
|
||||
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
|
||||
}
|
||||
Some(wzp_crypto::ChaChaSession::new(key))
|
||||
});
|
||||
let mut decrypt_ok: u64 = 0;
|
||||
let mut decrypt_fail: u64 = 0;
|
||||
|
||||
while let Some((ts_us, pkt)) = reader.next_packet()? {
|
||||
let now = Instant::now();
|
||||
let idx =
|
||||
find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
|
||||
participants[idx].ingest(&pkt, now);
|
||||
total_packets += 1;
|
||||
|
||||
// Attempt decryption if key provided
|
||||
if let Some(ref mut session) = decrypt_session {
|
||||
use wzp_proto::CryptoSession;
|
||||
let header_bytes = pkt.header.to_bytes();
|
||||
let mut plaintext = Vec::new();
|
||||
match session.decrypt(&header_bytes, &pkt.payload, &mut plaintext) {
|
||||
Ok(()) => {
|
||||
decrypt_ok += 1;
|
||||
if decrypt_ok <= 5 || decrypt_ok % 100 == 0 {
|
||||
eprintln!(
|
||||
" decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B",
|
||||
pkt.header.seq,
|
||||
pkt.header.codec_id,
|
||||
pkt.payload.len(),
|
||||
plaintext.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
decrypt_fail += 1;
|
||||
if decrypt_fail <= 3 {
|
||||
eprintln!(
|
||||
" decrypt FAIL: seq={} (key mismatch, wrong direction, or rekey boundary)",
|
||||
pkt.header.seq
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record for HTML timeline
|
||||
timeline.push(TimelineEntry {
|
||||
timestamp_us: ts_us,
|
||||
stream_id: idx,
|
||||
codec: pkt.header.codec_id,
|
||||
seq: pkt.header.seq,
|
||||
payload_len: pkt.payload.len(),
|
||||
loss_pct: participants[idx].loss_percent(),
|
||||
jitter_ms: participants[idx].jitter_ms,
|
||||
});
|
||||
}
|
||||
|
||||
if decrypt_session.is_some() {
|
||||
eprintln!(
|
||||
"Decrypt stats: {} ok, {} failed (total {})",
|
||||
decrypt_ok, decrypt_fail, total_packets
|
||||
);
|
||||
}
|
||||
|
||||
print_summary(&participants, total_packets, start.elapsed());
|
||||
|
||||
// Generate HTML if requested
|
||||
if let Some(html_path) = &args.html {
|
||||
generate_html_report(
|
||||
html_path,
|
||||
&participants,
|
||||
&timeline,
|
||||
total_packets,
|
||||
&reader.header,
|
||||
)?;
|
||||
eprintln!("HTML report: {}", html_path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML report generation (#16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn generate_html_report(
|
||||
path: &str,
|
||||
participants: &[ParticipantStats],
|
||||
timeline: &[TimelineEntry],
|
||||
total_packets: u64,
|
||||
capture_header: &serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
|
||||
let room = capture_header
|
||||
.get("room")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let start_time = capture_header
|
||||
.get("start_time")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
|
||||
// Build per-stream loss/jitter timeline data for Chart.js
|
||||
// Sample every 1 second (group timeline entries by second)
|
||||
let max_ts = timeline.last().map(|e| e.timestamp_us).unwrap_or(0);
|
||||
let duration_secs = (max_ts / 1_000_000) + 1;
|
||||
|
||||
let mut loss_data: std::collections::HashMap<usize, Vec<f64>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut jitter_data: std::collections::HashMap<usize, Vec<f64>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for stream_id in 0..participants.len() {
|
||||
loss_data.insert(stream_id, vec![0.0; duration_secs as usize]);
|
||||
jitter_data.insert(stream_id, vec![0.0; duration_secs as usize]);
|
||||
}
|
||||
|
||||
for entry in timeline {
|
||||
let sec = (entry.timestamp_us / 1_000_000) as usize;
|
||||
if sec < duration_secs as usize {
|
||||
if let Some(losses) = loss_data.get_mut(&entry.stream_id) {
|
||||
losses[sec] = entry.loss_pct;
|
||||
}
|
||||
if let Some(jitters) = jitter_data.get_mut(&entry.stream_id) {
|
||||
jitters[sec] = entry.jitter_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let colors = [
|
||||
"#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c",
|
||||
];
|
||||
|
||||
// Build dataset JSON for charts
|
||||
let mut loss_datasets = String::new();
|
||||
let mut jitter_datasets = String::new();
|
||||
for (i, p) in participants.iter().enumerate() {
|
||||
let name = p.display_name();
|
||||
let color = colors[i % colors.len()];
|
||||
let loss_vals = loss_data
|
||||
.get(&i)
|
||||
.map(|v| format!("{:?}", v))
|
||||
.unwrap_or_default();
|
||||
let jitter_vals = jitter_data
|
||||
.get(&i)
|
||||
.map(|v| format!("{:?}", v))
|
||||
.unwrap_or_default();
|
||||
|
||||
loss_datasets.push_str(&format!(
|
||||
"{{ label: '{}', data: {}, borderColor: '{}', fill: false }},\n",
|
||||
name, loss_vals, color
|
||||
));
|
||||
jitter_datasets.push_str(&format!(
|
||||
"{{ label: '{}', data: {}, borderColor: '{}', fill: false }},\n",
|
||||
name, jitter_vals, color
|
||||
));
|
||||
}
|
||||
|
||||
let labels: Vec<String> = (0..duration_secs).map(|s| format!("{}s", s)).collect();
|
||||
let labels_json = format!("{:?}", labels);
|
||||
|
||||
// Summary table rows
|
||||
let mut summary_rows = String::new();
|
||||
for p in participants {
|
||||
summary_rows.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{:?}</td><td>{}</td><td>{:.1}%</td><td>{:.0}ms</td><td>{}</td></tr>\n",
|
||||
p.display_name(),
|
||||
p.codec,
|
||||
p.packets,
|
||||
p.loss_percent(),
|
||||
p.jitter_ms,
|
||||
p.codec_switches
|
||||
));
|
||||
}
|
||||
|
||||
write!(
|
||||
f,
|
||||
r#"<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<title>WZP Call Report — {room}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #1a1a2e; color: #e0e0e0; }}
|
||||
h1,h2 {{ color: #4a9eff; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
|
||||
th,td {{ border: 1px solid #333; padding: 8px 12px; text-align: left; }}
|
||||
th {{ background: #16213e; }}
|
||||
tr:nth-child(even) {{ background: #1a1a3e; }}
|
||||
.chart-container {{ background: #16213e; border-radius: 8px; padding: 16px; margin: 20px 0; }}
|
||||
canvas {{ max-height: 300px; }}
|
||||
.meta {{ color: #888; font-size: 0.9em; }}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>WZP Call Quality Report</h1>
|
||||
<p class="meta">Room: <b>{room}</b> | Start: {start_time} | Packets: {total_packets} | Duration: {duration_secs}s</p>
|
||||
|
||||
<h2>Participant Summary</h2>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Codec</th><th>Packets</th><th>Loss</th><th>Jitter</th><th>Codec Switches</th></tr>
|
||||
{summary_rows}
|
||||
</table>
|
||||
|
||||
<h2>Packet Loss Over Time</h2>
|
||||
<div class="chart-container"><canvas id="lossChart"></canvas></div>
|
||||
|
||||
<h2>Jitter Over Time</h2>
|
||||
<div class="chart-container"><canvas id="jitterChart"></canvas></div>
|
||||
|
||||
<script>
|
||||
const labels = {labels_json};
|
||||
new Chart(document.getElementById('lossChart'), {{
|
||||
type: 'line',
|
||||
data: {{ labels, datasets: [{loss_datasets}] }},
|
||||
options: {{ responsive: true, scales: {{ y: {{ beginAtZero: true, title: {{ display: true, text: 'Loss %' }} }} }} }}
|
||||
}});
|
||||
new Chart(document.getElementById('jitterChart'), {{
|
||||
type: 'line',
|
||||
data: {{ labels, datasets: [{jitter_datasets}] }},
|
||||
options: {{ responsive: true, scales: {{ y: {{ beginAtZero: true, title: {{ display: true, text: 'Jitter (ms)' }} }} }} }}
|
||||
}});
|
||||
</script>
|
||||
</body></html>"#
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-TUI mode (print stats to stdout periodically)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_no_tui(
|
||||
transport: &wzp_transport::QuinnTransport,
|
||||
participants: &mut Vec<ParticipantStats>,
|
||||
total_packets: &mut u64,
|
||||
deadline: Option<Instant>,
|
||||
mut capture_writer: Option<&mut CaptureWriter>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut print_timer = Instant::now();
|
||||
loop {
|
||||
if let Some(dl) = deadline {
|
||||
if Instant::now() > dl {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let now = Instant::now();
|
||||
let idx =
|
||||
find_or_create_participant(participants, pkt.header.seq, pkt.header.codec_id);
|
||||
participants[idx].ingest(&pkt, now);
|
||||
*total_packets += 1;
|
||||
if let Some(ref mut w) = capture_writer {
|
||||
w.write_packet(&pkt, now)?;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) => break, // connection closed
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // timeout, loop again
|
||||
}
|
||||
if print_timer.elapsed() >= Duration::from_secs(2) {
|
||||
print_stats(participants, *total_packets);
|
||||
print_timer = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_stats(participants: &[ParticipantStats], total: u64) {
|
||||
eprintln!(
|
||||
"--- {} participants | {} total packets ---",
|
||||
participants.len(),
|
||||
total
|
||||
);
|
||||
for p in participants {
|
||||
eprintln!(
|
||||
" {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s",
|
||||
p.display_name(),
|
||||
p.packets,
|
||||
p.loss_percent(),
|
||||
p.jitter_ms,
|
||||
p.codec,
|
||||
p.duration().as_secs_f64(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TUI mode (ratatui + crossterm)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_tui(
|
||||
transport: &wzp_transport::QuinnTransport,
|
||||
participants: &mut Vec<ParticipantStats>,
|
||||
total_packets: &mut u64,
|
||||
start_time: Instant,
|
||||
deadline: Option<Instant>,
|
||||
mut capture_writer: Option<&mut CaptureWriter>,
|
||||
) -> anyhow::Result<()> {
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?;
|
||||
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
|
||||
let mut redraw_timer = Instant::now();
|
||||
|
||||
let result: anyhow::Result<()> = async {
|
||||
loop {
|
||||
// Check for quit key (q or Ctrl+C)
|
||||
if crossterm::event::poll(Duration::from_millis(0))? {
|
||||
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
if key.code == KeyCode::Char('q')
|
||||
|| (key.code == KeyCode::Char('c')
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dl) = deadline {
|
||||
if Instant::now() > dl {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Receive packets (non-blocking with short timeout)
|
||||
match tokio::time::timeout(Duration::from_millis(20), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let now = Instant::now();
|
||||
let idx = find_or_create_participant(
|
||||
participants,
|
||||
pkt.header.seq,
|
||||
pkt.header.codec_id,
|
||||
);
|
||||
participants[idx].ingest(&pkt, now);
|
||||
*total_packets += 1;
|
||||
if let Some(ref mut w) = capture_writer {
|
||||
w.write_packet(&pkt, now)?;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("recv error: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Redraw TUI at ~10 FPS
|
||||
if redraw_timer.elapsed() >= Duration::from_millis(100) {
|
||||
terminal.draw(|f| draw_ui(f, participants, *total_packets, start_time))?;
|
||||
redraw_timer = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always restore terminal, even on error
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn draw_ui(
|
||||
f: &mut ratatui::Frame,
|
||||
participants: &[ParticipantStats],
|
||||
total_packets: u64,
|
||||
start_time: Instant,
|
||||
) {
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
let elapsed_str = format!(
|
||||
"{:02}:{:02}:{:02}",
|
||||
elapsed.as_secs() / 3600,
|
||||
(elapsed.as_secs() % 3600) / 60,
|
||||
elapsed.as_secs() % 60
|
||||
);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Min(5), // participant table
|
||||
Constraint::Length(3), // footer
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Header
|
||||
let header = Paragraph::new(format!(
|
||||
" WZP Analyzer | {} participants | {} packets | {}",
|
||||
participants.len(),
|
||||
total_packets,
|
||||
elapsed_str
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Protocol Analyzer "),
|
||||
);
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Participant table
|
||||
let header_row = Row::new(vec![
|
||||
"#", "Name", "Codec", "Packets", "Loss%", "Jitter", "Switches", "Duration",
|
||||
])
|
||||
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = participants
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let loss_color = if p.loss_percent() > 5.0 {
|
||||
Color::Red
|
||||
} else if p.loss_percent() > 1.0 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
format!("{}", p.stream_id),
|
||||
p.display_name(),
|
||||
format!("{:?}", p.codec),
|
||||
format!("{}", p.packets),
|
||||
format!("{:.1}%", p.loss_percent()),
|
||||
format!("{:.0}ms", p.jitter_ms),
|
||||
format!("{}", p.codec_switches),
|
||||
format!("{:.0}s", p.duration().as_secs_f64()),
|
||||
])
|
||||
.style(Style::default().fg(loss_color))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(3), // #
|
||||
Constraint::Length(20), // Name
|
||||
Constraint::Length(12), // Codec
|
||||
Constraint::Length(10), // Packets
|
||||
Constraint::Length(8), // Loss%
|
||||
Constraint::Length(10), // Jitter
|
||||
Constraint::Length(10), // Switches
|
||||
Constraint::Length(10), // Duration
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths).header(header_row).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Participants "),
|
||||
);
|
||||
f.render_widget(table, chunks[1]);
|
||||
|
||||
// Footer
|
||||
let footer =
|
||||
Paragraph::new(" Press 'q' to quit ").block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(footer, chunks[2]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary (printed on exit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn print_summary(participants: &[ParticipantStats], total: u64, elapsed: Duration) {
|
||||
eprintln!("\n=== Session Summary ===");
|
||||
eprintln!(
|
||||
"Duration: {:.1}s | Total packets: {} | Participants: {}",
|
||||
elapsed.as_secs_f64(),
|
||||
total,
|
||||
participants.len()
|
||||
);
|
||||
for p in participants {
|
||||
eprintln!(
|
||||
" {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {} codec switches",
|
||||
p.display_name(),
|
||||
p.packets,
|
||||
p.loss_percent(),
|
||||
p.jitter_ms,
|
||||
p.codec,
|
||||
p.codec_switches,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Only init tracing subscriber in no-tui mode (it would corrupt the TUI otherwise)
|
||||
if args.no_tui || args.replay.is_some() {
|
||||
tracing_subscriber::fmt().init();
|
||||
}
|
||||
|
||||
let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> =
|
||||
if let Some(ref key_hex) = args.key {
|
||||
if key_hex.len() != 64 {
|
||||
eprintln!(
|
||||
"Error: --key must be 64 hex characters (32 bytes). Got {} chars.",
|
||||
key_hex.len()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let mut key_bytes = [0u8; 32];
|
||||
for (i, chunk) in key_hex.as_bytes().chunks(2).enumerate() {
|
||||
let hex_str = std::str::from_utf8(chunk).unwrap_or("00");
|
||||
key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0);
|
||||
}
|
||||
eprintln!("Encrypted payload decoding enabled (key loaded).");
|
||||
Some(std::sync::Mutex::new(wzp_crypto::ChaChaSession::new(
|
||||
key_bytes,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Replay mode: offline analysis of a .wzp capture file
|
||||
if let Some(ref replay_path) = args.replay {
|
||||
return run_replay(replay_path, &args).await;
|
||||
}
|
||||
|
||||
// Live mode requires relay and room
|
||||
let relay = args.relay.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("relay address required for live mode (use --replay for offline)")
|
||||
})?;
|
||||
let room = args.room.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("--room required for live mode (use --replay for offline)")
|
||||
})?;
|
||||
|
||||
// TLS crypto provider
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// Identity seed
|
||||
let seed = match &args.seed {
|
||||
Some(hex) => {
|
||||
let s = wzp_crypto::Seed::from_hex(hex).map_err(|e| anyhow::anyhow!(e))?;
|
||||
info!(fingerprint = %s.derive_identity().public_identity().fingerprint, "identity from --seed");
|
||||
s
|
||||
}
|
||||
None => {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
info!(fingerprint = %s.derive_identity().public_identity().fingerprint, "generated ephemeral identity");
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to relay
|
||||
let relay_addr: std::net::SocketAddr = relay.parse()?;
|
||||
let bind_addr: std::net::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 client_config = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&endpoint, relay_addr, room, client_config).await?;
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Crypto handshake
|
||||
let _crypto_session =
|
||||
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some("analyzer")).await?;
|
||||
|
||||
// Auth if token provided
|
||||
if let Some(ref token) = args.token {
|
||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||
version: default_signal_version(),
|
||||
token: token.clone(),
|
||||
};
|
||||
transport.send_signal(&auth).await?;
|
||||
}
|
||||
|
||||
// Capture file (optional)
|
||||
let mut capture_writer = args
|
||||
.capture
|
||||
.as_ref()
|
||||
.map(|path| CaptureWriter::new(path, room, relay))
|
||||
.transpose()?;
|
||||
|
||||
// Duration timeout
|
||||
let deadline = args
|
||||
.duration
|
||||
.map(|s| Instant::now() + Duration::from_secs(s));
|
||||
|
||||
// State
|
||||
let mut participants: Vec<ParticipantStats> = Vec::new();
|
||||
let mut total_packets: u64 = 0;
|
||||
let start_time = Instant::now();
|
||||
|
||||
if args.no_tui {
|
||||
run_no_tui(
|
||||
&transport,
|
||||
&mut participants,
|
||||
&mut total_packets,
|
||||
deadline,
|
||||
capture_writer.as_mut(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
run_tui(
|
||||
&transport,
|
||||
&mut participants,
|
||||
&mut total_packets,
|
||||
start_time,
|
||||
deadline,
|
||||
capture_writer.as_mut(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Print summary
|
||||
print_summary(&participants, total_packets, start_time.elapsed());
|
||||
|
||||
// Clean close
|
||||
transport.close().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,19 +3,19 @@
|
||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use anyhow::{anyhow, Context};
|
||||
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,25 +23,23 @@ pub const FRAME_SAMPLES: usize = 960;
|
||||
// AudioCapture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
|
||||
/// Captures microphone input and yields 960-sample PCM frames.
|
||||
///
|
||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||
pub struct AudioCapture {
|
||||
ring: Arc<AudioRing>,
|
||||
rx: mpsc::Receiver<Vec<i16>>,
|
||||
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 ring = Arc::new(AudioRing::new());
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||
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 (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-capture".into())
|
||||
.spawn(move || {
|
||||
@@ -61,57 +59,53 @@ 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 ring = ring_cb.clone();
|
||||
let buf = buf.clone();
|
||||
let tx = tx.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;
|
||||
}
|
||||
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]);
|
||||
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);
|
||||
}
|
||||
ring.write(&tmp[..n]);
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring_cb.clone();
|
||||
let buf = buf.clone();
|
||||
let tx = tx.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;
|
||||
}
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!(
|
||||
"[audio] capture callback: {} i16 samples",
|
||||
data.len()
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
ring.write(data);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
@@ -120,6 +114,7 @@ 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.
|
||||
@@ -140,12 +135,15 @@ impl AudioCapture {
|
||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { ring, running })
|
||||
Ok(Self { rx, running })
|
||||
}
|
||||
|
||||
/// Get a reference to the capture ring buffer for direct polling.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Stop capturing.
|
||||
@@ -154,35 +152,27 @@ impl AudioCapture {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioCapture {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioPlayback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
|
||||
/// Plays PCM frames through the default output device at 48 kHz mono.
|
||||
///
|
||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||
pub struct AudioPlayback {
|
||||
ring: Arc<AudioRing>,
|
||||
tx: mpsc::SyncSender<Vec<i16>>,
|
||||
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 ring = Arc::new(AudioRing::new());
|
||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||
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 (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-playback".into())
|
||||
.spawn(move || {
|
||||
@@ -202,40 +192,62 @@ 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_cb.clone();
|
||||
let ring = ring.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring_cb.clone();
|
||||
let ring = ring.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let read = ring.read(data);
|
||||
// Fill remainder with silence if ring underran
|
||||
for sample in &mut data[read..] {
|
||||
*sample = 0;
|
||||
let mut lock = ring.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = lock.pop_front().unwrap_or(0);
|
||||
}
|
||||
},
|
||||
err_cb,
|
||||
@@ -245,6 +257,7 @@ 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.
|
||||
@@ -265,12 +278,12 @@ impl AudioPlayback {
|
||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { ring, running })
|
||||
Ok(Self { tx, running })
|
||||
}
|
||||
|
||||
/// Get a reference to the playout ring buffer for direct writing.
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
/// Write a frame of PCM samples for playback.
|
||||
pub fn write_frame(&self, pcm: &[i16]) {
|
||||
let _ = self.tx.try_send(pcm.to_vec());
|
||||
}
|
||||
|
||||
/// Stop playback.
|
||||
@@ -279,16 +292,11 @@ 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()
|
||||
@@ -305,6 +313,7 @@ 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()
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
|
||||
//! Processing Module (AEC3 + noise suppression + high-pass filter).
|
||||
//!
|
||||
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
|
||||
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
|
||||
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
|
||||
//! identically on ALSA / PulseAudio / PipeWire systems.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
|
||||
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
|
||||
//! mono):
|
||||
//!
|
||||
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
|
||||
//! output thread, but wraps each chunk in a call to
|
||||
//! `Processor::process_render_frame` **before** handing it to CPAL. That
|
||||
//! gives APM an authoritative reference of exactly what's going out to
|
||||
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
|
||||
//! what to cancel when it sees echo in the capture stream.
|
||||
//!
|
||||
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
|
||||
//! input thread, and runs `Processor::process_capture_frame` on each
|
||||
//! incoming mic chunk **in place** before pushing it into the ring
|
||||
//! buffer. The AEC subtracts the echo using the render reference it
|
||||
//! saw on the playback side.
|
||||
//!
|
||||
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
|
||||
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
|
||||
//! frame is split into two 480-sample halves, APM is called twice, and the
|
||||
//! halves are stitched back together.
|
||||
//!
|
||||
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
|
||||
//! before the call and f32 → i16 after (with clamping on the return path).
|
||||
//!
|
||||
//! ## Stream delay
|
||||
//!
|
||||
//! AEC needs to know roughly how long it takes between a sample being passed
|
||||
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
|
||||
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
|
||||
//! → CPAL capture. AEC3's internal estimator tracks this within a window
|
||||
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
|
||||
//! starting point for typical Linux audio stacks; the delay estimator does
|
||||
//! the fine-tuning automatically.
|
||||
//!
|
||||
//! ## Thread safety
|
||||
//!
|
||||
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
|
||||
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
|
||||
//! needs a `Mutex` around it for cross-thread sharing. The capture and
|
||||
//! playback threads each acquire the lock briefly (sub-millisecond per
|
||||
//! 10 ms frame) so contention is minimal at our frame rates.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||
use tracing::{info, warn};
|
||||
use webrtc_audio_processing::{
|
||||
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
||||
NUM_SAMPLES_PER_FRAME, NoiseSuppression, NoiseSuppressionLevel, Processor,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
|
||||
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
|
||||
/// Imported from the webrtc-audio-processing crate so we can't drift out
|
||||
/// of sync with whatever sample rate / frame length the C++ lib is using.
|
||||
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
|
||||
const APM_NUM_CHANNELS: usize = 1;
|
||||
/// Round-trip delay hint passed to APM; the estimator refines from here.
|
||||
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
|
||||
#[allow(dead_code)]
|
||||
const STREAM_DELAY_MS: i32 = 60;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared APM instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Module-level lazily-initialized APM. Shared between capture and playback
|
||||
/// so they operate on the same echo-cancellation state — the render frames
|
||||
/// pushed by playback are what the capture path subtracts from the mic input.
|
||||
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
|
||||
/// process_capture_frame and process_render_frame.
|
||||
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
|
||||
|
||||
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
||||
if let Some(p) = PROCESSOR.get() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let init_config = InitializationConfig {
|
||||
num_capture_channels: APM_NUM_CHANNELS as i32,
|
||||
num_render_channels: APM_NUM_CHANNELS as i32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut processor =
|
||||
Processor::new(&init_config).map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
||||
|
||||
let config = Config {
|
||||
echo_cancellation: Some(EchoCancellation {
|
||||
suppression_level: EchoCancellationSuppressionLevel::High,
|
||||
stream_delay_ms: Some(STREAM_DELAY_MS),
|
||||
enable_delay_agnostic: true,
|
||||
enable_extended_filter: true,
|
||||
}),
|
||||
noise_suppression: Some(NoiseSuppression {
|
||||
suppression_level: NoiseSuppressionLevel::High,
|
||||
}),
|
||||
enable_high_pass_filter: true,
|
||||
// AGC left off for now — it can fight the Opus encoder's own gain
|
||||
// staging and the adaptive-quality controller. Add later if users
|
||||
// report low mic levels.
|
||||
..Default::default()
|
||||
};
|
||||
processor.set_config(config);
|
||||
|
||||
let arc = Arc::new(Mutex::new(processor));
|
||||
let _ = PROCESSOR.set(arc.clone());
|
||||
info!(
|
||||
stream_delay_ms = STREAM_DELAY_MS,
|
||||
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
|
||||
);
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: i16 ↔ f32 and APM frame processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[inline]
|
||||
fn i16_to_f32(s: i16) -> f32 {
|
||||
s as f32 / 32768.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn f32_to_i16(s: f32) -> i16 {
|
||||
(s.clamp(-1.0, 1.0) * 32767.0) as i16
|
||||
}
|
||||
|
||||
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
|
||||
/// Splits into two 10 ms halves because APM is strict about frame size.
|
||||
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
|
||||
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
|
||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
||||
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
|
||||
for (i, &s) in half.iter().enumerate() {
|
||||
buf[i] = i16_to_f32(s);
|
||||
}
|
||||
match apm.lock() {
|
||||
Ok(mut p) => {
|
||||
if let Err(e) = p.process_render_frame(&mut buf) {
|
||||
warn!("webrtc APM process_render_frame failed: {e:?}");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("webrtc APM mutex poisoned in render path");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
|
||||
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
|
||||
/// results back into the caller's buffer. Briefly holds the Mutex once
|
||||
/// per 10 ms half.
|
||||
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
|
||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
||||
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
|
||||
for (i, &s) in half.iter().enumerate() {
|
||||
buf[i] = i16_to_f32(s);
|
||||
}
|
||||
match apm.lock() {
|
||||
Ok(mut p) => {
|
||||
if let Err(e) = p.process_capture_frame(&mut buf) {
|
||||
warn!("webrtc APM process_capture_frame failed: {e:?}");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("webrtc APM mutex poisoned in capture path");
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (i, d) in half.iter_mut().enumerate() {
|
||||
*d = f32_to_i16(buf[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Microphone capture with WebRTC AEC3 applied in place before the codec
|
||||
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
|
||||
/// downstream code doesn't change.
|
||||
pub struct LinuxAecCapture {
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LinuxAecCapture {
|
||||
pub fn start() -> Result<Self, anyhow::Error> {
|
||||
// Eagerly init the APM so the playback side can find it already
|
||||
// configured, and so init errors surface on the caller thread
|
||||
// instead of silently failing inside the capture thread.
|
||||
let apm = get_or_init_processor()?;
|
||||
|
||||
let ring = Arc::new(AudioRing::new());
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
let ring_cb = ring.clone();
|
||||
let running_clone = running.clone();
|
||||
let apm_capture = apm.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-capture-linuxaec".into())
|
||||
.spawn(move || {
|
||||
let result = (|| -> Result<(), anyhow::Error> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| anyhow!("no default input audio device found"))?;
|
||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
|
||||
|
||||
let config = StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: SampleRate(48_000),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
let use_f32 = !supports_i16_input(&device)?;
|
||||
|
||||
let err_cb = |e: cpal::StreamError| {
|
||||
warn!("LinuxAEC input stream error: {e}");
|
||||
};
|
||||
|
||||
// Leftover buffer for when CPAL gives us partial frames.
|
||||
// We need exactly 960-sample chunks to feed APM.
|
||||
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
||||
|
||||
let stream = if use_f32 {
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
let apm = apm_capture.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lv = leftover.lock().unwrap();
|
||||
lv.reserve(data.len());
|
||||
for &s in data {
|
||||
lv.push(f32_to_i16(s));
|
||||
}
|
||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring_cb.clone();
|
||||
let running = running_clone.clone();
|
||||
let apm = apm_capture.clone();
|
||||
device.build_input_stream(
|
||||
&config,
|
||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut lv = leftover.lock().unwrap();
|
||||
lv.extend_from_slice(data);
|
||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
};
|
||||
|
||||
stream.play().context("failed to start LinuxAEC input stream")?;
|
||||
let _ = init_tx.send(Ok(()));
|
||||
info!("LinuxAEC capture started (AEC3 active)");
|
||||
|
||||
while running_clone.load(Ordering::Relaxed) {
|
||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
||||
}
|
||||
drop(stream);
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = init_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
|
||||
init_rx
|
||||
.recv()
|
||||
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LinuxAecCapture {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull whole 960-sample frames out of the leftover buffer, run them through
|
||||
/// APM's capture-side processing, and push to the ring. Leaves any partial
|
||||
/// sub-960 remainder in `leftover` for the next callback.
|
||||
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
|
||||
let mut frame = [0i16; FRAME_SAMPLES];
|
||||
while leftover.len() >= FRAME_SAMPLES {
|
||||
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
|
||||
process_capture_frame_20ms(apm, &mut frame);
|
||||
ring.write(&frame);
|
||||
leftover.drain(..FRAME_SAMPLES);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Speaker playback with a render-side tee: each frame written to CPAL is
|
||||
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
|
||||
/// reference signal. This is the "tee the playback ring" approach (Zoom,
|
||||
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
|
||||
/// PipeWire monitor sources.
|
||||
pub struct LinuxAecPlayback {
|
||||
ring: Arc<AudioRing>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LinuxAecPlayback {
|
||||
pub fn start() -> Result<Self, anyhow::Error> {
|
||||
let apm = get_or_init_processor()?;
|
||||
|
||||
let ring = Arc::new(AudioRing::new());
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
|
||||
let ring_cb = ring.clone();
|
||||
let running_clone = running.clone();
|
||||
let apm_render = apm.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("wzp-audio-playback-linuxaec".into())
|
||||
.spawn(move || {
|
||||
let result = (|| -> Result<(), anyhow::Error> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow!("no default output audio device found"))?;
|
||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
|
||||
|
||||
let config = StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: SampleRate(48_000),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
let use_f32 = !supports_i16_output(&device)?;
|
||||
|
||||
let err_cb = |e: cpal::StreamError| {
|
||||
warn!("LinuxAEC output stream error: {e}");
|
||||
};
|
||||
|
||||
// Same 960-sample batching approach as the capture side:
|
||||
// CPAL may ask for N samples in a callback where N doesn't
|
||||
// divide 960. We accumulate partial frames in a Vec and
|
||||
// feed APM as soon as we have a whole 20 ms frame.
|
||||
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
||||
|
||||
let stream = if use_f32 {
|
||||
let ring = ring_cb.clone();
|
||||
let apm = apm_render.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
fill_output_and_tee_f32(data, &ring, &apm, &carry);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
let ring = ring_cb.clone();
|
||||
let apm = apm_render.clone();
|
||||
device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
fill_output_and_tee_i16(data, &ring, &apm, &carry);
|
||||
},
|
||||
err_cb,
|
||||
None,
|
||||
)?
|
||||
};
|
||||
|
||||
stream.play().context("failed to start LinuxAEC output stream")?;
|
||||
let _ = init_tx.send(Ok(()));
|
||||
info!("LinuxAEC playback started (render tee active)");
|
||||
|
||||
while running_clone.load(Ordering::Relaxed) {
|
||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
||||
}
|
||||
drop(stream);
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = init_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
|
||||
init_rx
|
||||
.recv()
|
||||
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
Ok(Self { ring, running })
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||
&self.ring
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LinuxAecPlayback {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_output_and_tee_i16(
|
||||
data: &mut [i16],
|
||||
ring: &AudioRing,
|
||||
apm: &Mutex<Processor>,
|
||||
carry: &std::sync::Mutex<Vec<i16>>,
|
||||
) {
|
||||
let read = ring.read(data);
|
||||
for s in &mut data[read..] {
|
||||
*s = 0;
|
||||
}
|
||||
tee_render_samples(data, apm, carry);
|
||||
}
|
||||
|
||||
fn fill_output_and_tee_f32(
|
||||
data: &mut [f32],
|
||||
ring: &AudioRing,
|
||||
apm: &Mutex<Processor>,
|
||||
carry: &std::sync::Mutex<Vec<i16>>,
|
||||
) {
|
||||
let mut tmp = vec![0i16; data.len()];
|
||||
let read = ring.read(&mut tmp);
|
||||
for s in &mut tmp[read..] {
|
||||
*s = 0;
|
||||
}
|
||||
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
|
||||
*d = i16_to_f32(s);
|
||||
}
|
||||
tee_render_samples(&tmp, apm, carry);
|
||||
}
|
||||
|
||||
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
|
||||
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
|
||||
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
|
||||
let mut lv = carry.lock().unwrap();
|
||||
lv.extend_from_slice(samples);
|
||||
while lv.len() >= FRAME_SAMPLES {
|
||||
let mut frame = [0i16; FRAME_SAMPLES];
|
||||
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
|
||||
push_render_frame_20ms(apm, &frame);
|
||||
lv.drain(..FRAME_SAMPLES);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
|
||||
// independent — each backend file is a self-contained unit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.supported_input_configs()
|
||||
.context("failed to query input configs")?;
|
||||
for cfg in supported {
|
||||
if cfg.sample_format() == SampleFormat::I16
|
||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
||||
&& cfg.channels() >= 1
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||
let supported = device
|
||||
.supported_output_configs()
|
||||
.context("failed to query output configs")?;
|
||||
for cfg in supported {
|
||||
if cfg.sample_format() == SampleFormat::I16
|
||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
||||
&& cfg.channels() >= 1
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||
//!
|
||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||
//!
|
||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||
//! old buffer data. The reader detects the lap via `available() >
|
||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||
//!
|
||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16]>,
|
||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||
write_pos: AtomicUsize,
|
||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||
read_pos: AtomicUsize,
|
||||
/// Incremented by reader when it detects it was lapped (overflow).
|
||||
overflow_count: AtomicU64,
|
||||
/// Incremented by reader when ring is empty (underrun).
|
||||
underrun_count: AtomicU64,
|
||||
}
|
||||
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
unsafe impl Send for AudioRing {}
|
||||
unsafe impl Sync for AudioRing {}
|
||||
|
||||
impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read (clamped to capacity).
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
}
|
||||
|
||||
/// Write samples into the ring. Returns number of samples written.
|
||||
///
|
||||
/// If the ring is full, old data is silently overwritten. The reader
|
||||
/// will detect the lap and self-correct. The writer NEVER touches
|
||||
/// `read_pos`.
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
|
||||
for i in 0..count {
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos
|
||||
.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||
///
|
||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||
/// forward to the oldest valid data.
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos
|
||||
.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of overflow events (reader was lapped by writer).
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of underrun events (reader found empty buffer).
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
//! macOS Voice Processing I/O — uses Apple's VoiceProcessingIO audio unit
|
||||
//! for hardware-accelerated echo cancellation, AGC, and noise suppression.
|
||||
//!
|
||||
//! VoiceProcessingIO is a combined input+output unit that knows what's going
|
||||
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
||||
//! This is the same engine FaceTime and other Apple apps use.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::Context;
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
|
||||
use coreaudio::sys;
|
||||
use tracing::info;
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
|
||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
/// Combined capture + playback via macOS VoiceProcessingIO.
|
||||
///
|
||||
/// The OS handles AEC internally — no manual far-end feeding needed.
|
||||
pub struct VpioAudio {
|
||||
capture_ring: Arc<AudioRing>,
|
||||
playout_ring: Arc<AudioRing>,
|
||||
_audio_unit: AudioUnit,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl VpioAudio {
|
||||
/// Start VoiceProcessingIO with AEC enabled.
|
||||
pub fn start() -> Result<Self, anyhow::Error> {
|
||||
let capture_ring = Arc::new(AudioRing::new());
|
||||
let playout_ring = Arc::new(AudioRing::new());
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
||||
.context("failed to create VoiceProcessingIO audio unit")?;
|
||||
|
||||
// Must uninitialize before configuring properties.
|
||||
au.uninitialize()
|
||||
.context("failed to uninitialize VPIO for configuration")?;
|
||||
|
||||
// Enable input (mic) on Element::Input (bus 1).
|
||||
let enable: u32 = 1;
|
||||
au.set_property(
|
||||
sys::kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Input,
|
||||
Element::Input,
|
||||
Some(&enable),
|
||||
)
|
||||
.context("failed to enable VPIO input")?;
|
||||
|
||||
// Output (speaker) is enabled by default on VPIO, but be explicit.
|
||||
au.set_property(
|
||||
sys::kAudioOutputUnitProperty_EnableIO,
|
||||
Scope::Output,
|
||||
Element::Output,
|
||||
Some(&enable),
|
||||
)
|
||||
.context("failed to enable VPIO output")?;
|
||||
|
||||
// Configure stream format: 48kHz mono f32 non-interleaved
|
||||
let stream_format = StreamFormat {
|
||||
sample_rate: 48_000.0,
|
||||
sample_format: SampleFormat::F32,
|
||||
flags: LinearPcmFlags::IS_FLOAT
|
||||
| LinearPcmFlags::IS_PACKED
|
||||
| LinearPcmFlags::IS_NON_INTERLEAVED,
|
||||
channels: 1,
|
||||
};
|
||||
|
||||
let asbd = stream_format.to_asbd();
|
||||
|
||||
// Input: set format on Output scope of Input element
|
||||
// (= the format the AU delivers to us from the mic)
|
||||
au.set_property(
|
||||
sys::kAudioUnitProperty_StreamFormat,
|
||||
Scope::Output,
|
||||
Element::Input,
|
||||
Some(&asbd),
|
||||
)
|
||||
.context("failed to set input stream format")?;
|
||||
|
||||
// Output: set format on Input scope of Output element
|
||||
// (= the format we feed to the AU for the speaker)
|
||||
au.set_property(
|
||||
sys::kAudioUnitProperty_StreamFormat,
|
||||
Scope::Input,
|
||||
Element::Output,
|
||||
Some(&asbd),
|
||||
)
|
||||
.context("failed to set output stream format")?;
|
||||
|
||||
// Set up input callback (mic capture with AEC applied)
|
||||
let cap_ring = capture_ring.clone();
|
||||
let cap_running = running.clone();
|
||||
let logged = Arc::new(AtomicBool::new(false));
|
||||
au.set_input_callback(
|
||||
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||
if !cap_running.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut buffers = args.data.channels();
|
||||
if let Some(ch) = buffers.next() {
|
||||
if !logged.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
||||
}
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in ch.chunks(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
for i in 0..n {
|
||||
tmp[i] = (chunk[i].clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
}
|
||||
cap_ring.write(&tmp[..n]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.context("failed to set input callback")?;
|
||||
|
||||
// Set up output callback (speaker playback — AEC uses this as reference)
|
||||
let play_ring = playout_ring.clone();
|
||||
au.set_render_callback(
|
||||
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||
let mut buffers = args.data.channels_mut();
|
||||
if let Some(ch) = buffers.next() {
|
||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
||||
let n = chunk.len();
|
||||
let read = play_ring.read(&mut tmp[..n]);
|
||||
for i in 0..read {
|
||||
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
||||
}
|
||||
for i in read..n {
|
||||
chunk[i] = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.context("failed to set render callback")?;
|
||||
|
||||
au.initialize()
|
||||
.context("failed to initialize VoiceProcessingIO")?;
|
||||
au.start().context("failed to start VoiceProcessingIO")?;
|
||||
|
||||
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
||||
|
||||
Ok(Self {
|
||||
capture_ring,
|
||||
playout_ring,
|
||||
_audio_unit: au,
|
||||
running,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capture_ring(&self) -> &Arc<AudioRing> {
|
||||
&self.capture_ring
|
||||
}
|
||||
|
||||
pub fn playout_ring(&self) -> &Arc<AudioRing> {
|
||||
&self.playout_ring
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VpioAudio {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
|
||||
//!
|
||||
//! Bypasses CPAL and opens the default capture endpoint directly via
|
||||
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
|
||||
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
|
||||
//! the switch that tells Windows "this is a VoIP call" — the OS then
|
||||
//! enables its communications audio processing chain (AEC, noise
|
||||
//! suppression, automatic gain control) for the stream. AEC operates at
|
||||
//! the OS level using the currently-playing audio as the reference
|
||||
//! signal, so it cancels echo from our CPAL playback (and any other app's
|
||||
//! audio) without us having to plumb a reference signal ourselves.
|
||||
//!
|
||||
//! Platform: Windows only, compiled only when the `windows-aec` feature
|
||||
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
|
||||
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
||||
//! `AudioCapture`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use tracing::{info, warn};
|
||||
use windows::Win32::Foundation::{BOOL, CloseHandle, WAIT_OBJECT_0};
|
||||
use windows::Win32::Media::Audio::{
|
||||
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
||||
AudioCategory_Communications, AudioClientProperties, IAudioCaptureClient, IAudioClient,
|
||||
IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator, WAVE_FORMAT_PCM, WAVEFORMATEX,
|
||||
eCapture, eCommunications,
|
||||
};
|
||||
use windows::Win32::System::Com::{
|
||||
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
||||
};
|
||||
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
|
||||
use windows::core::{GUID, Interface};
|
||||
|
||||
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()))
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wzp_crypto::ChaChaSession;
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::QualityProfile;
|
||||
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
use crate::call::{CallConfig, CallDecoder, CallEncoder};
|
||||
|
||||
@@ -170,7 +170,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
||||
|
||||
// Collect all symbols: source + repair
|
||||
struct Symbol {
|
||||
index: u16,
|
||||
index: u8,
|
||||
is_repair: bool,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
@@ -180,7 +180,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
||||
// For add_symbol we need to provide the raw data; the decoder pads internally
|
||||
total_source_bytes += sym.len();
|
||||
all_symbols.push(Symbol {
|
||||
index: i as u16,
|
||||
index: i as u8,
|
||||
is_repair: false,
|
||||
data: sym.clone(),
|
||||
});
|
||||
@@ -201,13 +201,9 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
||||
// Deterministic shuffle for reproducibility using a simple seed
|
||||
// We use a basic Fisher-Yates with a fixed-per-block seed
|
||||
let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
|
||||
let mut seed = (block_idx as u64)
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1);
|
||||
let mut seed = (block_idx as u64).wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
for i in (1..indices.len()).rev() {
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let j = (seed >> 33) as usize % (i + 1);
|
||||
indices.swap(i, j);
|
||||
}
|
||||
@@ -263,36 +259,17 @@ pub fn bench_encrypt_decrypt() -> CryptoResult {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build valid v2 MediaHeader bytes — encrypt/decrypt now derive nonces from
|
||||
// header.seq and require a parseable MediaHeader (WIRE_SIZE bytes minimum).
|
||||
use wzp_proto::packet::MediaHeader;
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
let header = b"bench-header";
|
||||
let mut total_bytes: usize = 0;
|
||||
|
||||
let start = Instant::now();
|
||||
for (i, payload) in payloads.iter().enumerate() {
|
||||
let hdr = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: i as u32,
|
||||
timestamp: (i as u32).wrapping_mul(20),
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
||||
hdr.write_to(&mut header_bytes);
|
||||
|
||||
for payload in &payloads {
|
||||
let mut ciphertext = Vec::with_capacity(payload.len() + 16);
|
||||
encryptor
|
||||
.encrypt(&header_bytes, payload, &mut ciphertext)
|
||||
.unwrap();
|
||||
encryptor.encrypt(header, payload, &mut ciphertext).unwrap();
|
||||
|
||||
let mut plaintext = Vec::with_capacity(payload.len());
|
||||
decryptor
|
||||
.decrypt(&header_bytes, &ciphertext, &mut plaintext)
|
||||
.decrypt(header, &ciphertext, &mut plaintext)
|
||||
.unwrap();
|
||||
|
||||
total_bytes += payload.len();
|
||||
|
||||
@@ -24,14 +24,8 @@ fn run_codec() {
|
||||
print_header("Codec Roundtrip (Opus 24kbps)");
|
||||
let r = bench::bench_codec_roundtrip();
|
||||
print_row("Frames", &format!("{}", r.frames));
|
||||
print_row(
|
||||
"Encode total",
|
||||
&format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Decode total",
|
||||
&format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row("Encode total", &format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0));
|
||||
print_row("Decode total", &format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0));
|
||||
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
|
||||
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
|
||||
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
|
||||
@@ -47,10 +41,7 @@ fn run_fec(loss_pct: f32) {
|
||||
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
|
||||
print_row("Source bytes", &format!("{}", r.total_source_bytes));
|
||||
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
|
||||
print_row(
|
||||
"Total time",
|
||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||
print_footer();
|
||||
}
|
||||
|
||||
@@ -58,10 +49,7 @@ fn run_crypto() {
|
||||
print_header("Crypto (ChaCha20-Poly1305)");
|
||||
let r = bench::bench_encrypt_decrypt();
|
||||
print_row("Packets", &format!("{}", r.packets));
|
||||
print_row(
|
||||
"Total time",
|
||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
|
||||
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
|
||||
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
|
||||
@@ -72,18 +60,9 @@ fn run_pipeline() {
|
||||
print_header("Full Pipeline (E2E)");
|
||||
let r = bench::bench_full_pipeline();
|
||||
print_row("Frames", &format!("{}", r.frames));
|
||||
print_row(
|
||||
"Encode pipeline",
|
||||
&format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Decode pipeline",
|
||||
&format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0),
|
||||
);
|
||||
print_row(
|
||||
"Avg E2E latency",
|
||||
&format!("{:.1} us/frame", r.avg_e2e_latency_us),
|
||||
);
|
||||
print_row("Encode pipeline", &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0));
|
||||
print_row("Decode pipeline", &format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0));
|
||||
print_row("Avg E2E latency", &format!("{:.1} us/frame", r.avg_e2e_latency_us));
|
||||
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
|
||||
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
|
||||
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
//! Birthday attack for hard NAT traversal.
|
||||
//!
|
||||
//! When both peers are behind symmetric NATs with random port
|
||||
//! allocation, standard hole-punching fails because neither side
|
||||
//! can predict the other's external port. This module implements
|
||||
//! the birthday-paradox approach:
|
||||
//!
|
||||
//! 1. **Acceptor** opens N sockets, STUN-probes each to learn
|
||||
//! their external ports, reports them to the Dialer.
|
||||
//! 2. **Dialer** sprays QUIC connect attempts to the Acceptor's
|
||||
//! reported ports + random ports on the Acceptor's IP.
|
||||
//! 3. Birthday paradox: with N=64 ports and M=256 probes across
|
||||
//! 65536 ports, collision probability is high.
|
||||
//!
|
||||
//! In practice, the Acceptor's STUN-probed ports are known
|
||||
//! exactly (not random), so the Dialer targets them first —
|
||||
//! making this more like "spray-and-pray with a hit list" than
|
||||
//! a pure birthday attack.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::stun;
|
||||
|
||||
/// Configuration for the birthday attack.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BirthdayConfig {
|
||||
/// Number of sockets the Acceptor opens (default: 32).
|
||||
/// Each socket gets STUN-probed to learn its external port.
|
||||
/// More = higher chance of collision, but more resource usage.
|
||||
pub acceptor_ports: u16,
|
||||
/// Number of QUIC connect attempts the Dialer makes (default: 128).
|
||||
/// Spread across the Acceptor's known ports + random ports.
|
||||
pub dialer_probes: u16,
|
||||
/// Rate limit: ms between consecutive probes (default: 20ms = 50/s).
|
||||
pub probe_interval_ms: u16,
|
||||
/// Overall timeout for the birthday attack phase.
|
||||
pub timeout: Duration,
|
||||
/// STUN config for probing external ports.
|
||||
pub stun_config: stun::StunConfig,
|
||||
}
|
||||
|
||||
impl Default for BirthdayConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
acceptor_ports: 32,
|
||||
dialer_probes: 128,
|
||||
probe_interval_ms: 20,
|
||||
timeout: Duration::from_secs(8),
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec!["stun.l.google.com:19302".into()],
|
||||
timeout: Duration::from_secs(2),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the Acceptor's port-opening phase.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct AcceptorPorts {
|
||||
/// External IP (from STUN).
|
||||
pub external_ip: Option<Ipv4Addr>,
|
||||
/// List of (local_port, external_port) for each opened socket.
|
||||
pub ports: Vec<PortMapping>,
|
||||
/// How many sockets we attempted to open.
|
||||
pub attempted: u16,
|
||||
/// How many STUN probes succeeded.
|
||||
pub succeeded: u16,
|
||||
}
|
||||
|
||||
/// A single socket's local↔external port mapping.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PortMapping {
|
||||
pub local_port: u16,
|
||||
pub external_port: u16,
|
||||
}
|
||||
|
||||
/// Open N sockets and STUN-probe each to discover external ports.
|
||||
///
|
||||
/// Returns the set of known external ports that the Dialer should
|
||||
/// target. Each socket stays open (bound) so the NAT mapping
|
||||
/// remains active until the returned `PortGuard` is dropped.
|
||||
///
|
||||
/// The sockets are returned so the caller can keep them alive
|
||||
/// during the attack. Dropping them closes the NAT pinholes.
|
||||
pub async fn open_acceptor_ports(
|
||||
config: &BirthdayConfig,
|
||||
) -> (AcceptorPorts, Vec<tokio::net::UdpSocket>) {
|
||||
let mut sockets = Vec::new();
|
||||
let mut mappings = Vec::new();
|
||||
let mut external_ip: Option<Ipv4Addr> = None;
|
||||
let mut succeeded: u16 = 0;
|
||||
|
||||
let stun_server = match config.stun_config.servers.first() {
|
||||
Some(s) => match stun::resolve_stun_server(s).await {
|
||||
Ok(a) => Some(a),
|
||||
Err(_) => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
for _ in 0..config.acceptor_ports {
|
||||
// Bind to random port
|
||||
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let local_port = match sock.local_addr() {
|
||||
Ok(a) => a.port(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// STUN probe to learn external port
|
||||
if let Some(stun_addr) = stun_server {
|
||||
match stun::stun_reflect(&sock, stun_addr, config.stun_config.timeout).await {
|
||||
Ok(ext_addr) => {
|
||||
if external_ip.is_none() {
|
||||
if let std::net::IpAddr::V4(ip) = ext_addr.ip() {
|
||||
external_ip = Some(ip);
|
||||
}
|
||||
}
|
||||
mappings.push(PortMapping {
|
||||
local_port,
|
||||
external_port: ext_addr.port(),
|
||||
});
|
||||
succeeded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(local_port, error = %e, "birthday: STUN probe failed for socket");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sockets.push(sock);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
attempted = config.acceptor_ports,
|
||||
succeeded,
|
||||
external_ip = ?external_ip,
|
||||
"birthday: acceptor ports opened"
|
||||
);
|
||||
|
||||
let result = AcceptorPorts {
|
||||
external_ip,
|
||||
ports: mappings,
|
||||
attempted: config.acceptor_ports,
|
||||
succeeded,
|
||||
};
|
||||
|
||||
(result, sockets)
|
||||
}
|
||||
|
||||
/// Generate the list of target addresses for the Dialer to spray.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. Acceptor's known external ports (from STUN probes) — highest hit rate
|
||||
/// 2. Random ports on the Acceptor's IP — birthday paradox fill
|
||||
pub fn generate_dialer_targets(
|
||||
acceptor_ip: Ipv4Addr,
|
||||
known_ports: &[u16],
|
||||
total_probes: u16,
|
||||
) -> Vec<SocketAddr> {
|
||||
let mut targets = Vec::with_capacity(total_probes as usize);
|
||||
|
||||
// First: all known ports (guaranteed targets)
|
||||
for &port in known_ports {
|
||||
targets.push(SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port));
|
||||
}
|
||||
|
||||
// Fill remaining with random ports (birthday attack)
|
||||
let remaining = total_probes.saturating_sub(known_ports.len() as u16);
|
||||
if remaining > 0 {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..remaining {
|
||||
let port = rng.gen_range(1024..=65535u16);
|
||||
let addr = SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port);
|
||||
if !targets.contains(&addr) {
|
||||
targets.push(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
/// Run the Dialer side of the birthday attack.
|
||||
///
|
||||
/// Sprays QUIC connection attempts at the target addresses.
|
||||
/// Returns the first successful connection, or None on timeout.
|
||||
pub async fn spray_dialer(
|
||||
endpoint: &wzp_transport::Endpoint,
|
||||
targets: &[SocketAddr],
|
||||
call_sni: &str,
|
||||
probe_interval: Duration,
|
||||
timeout: Duration,
|
||||
) -> Option<wzp_transport::QuinnTransport> {
|
||||
let start = Instant::now();
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
|
||||
tracing::info!(
|
||||
target_count = targets.len(),
|
||||
interval_ms = probe_interval.as_millis(),
|
||||
timeout_s = timeout.as_secs(),
|
||||
"birthday: dialer starting spray"
|
||||
);
|
||||
|
||||
// Spray connects with rate limiting
|
||||
for (idx, &target) in targets.iter().enumerate() {
|
||||
if start.elapsed() >= timeout {
|
||||
break;
|
||||
}
|
||||
|
||||
let ep = endpoint.clone();
|
||||
let sni = call_sni.to_string();
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
set.spawn(async move {
|
||||
let result = wzp_transport::connect(&ep, target, &sni, client_cfg).await;
|
||||
(idx, target, result)
|
||||
});
|
||||
|
||||
// Rate limit — don't blast the NAT
|
||||
if idx < targets.len() - 1 {
|
||||
tokio::time::sleep(probe_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
spawned = set.len(),
|
||||
elapsed_ms = start.elapsed().as_millis(),
|
||||
"birthday: all probes spawned, waiting for first success"
|
||||
);
|
||||
|
||||
// Wait for first success or all failures
|
||||
let deadline = start + timeout;
|
||||
while let Some(join_res) = tokio::select! {
|
||||
r = set.join_next() => r,
|
||||
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => None,
|
||||
} {
|
||||
match join_res {
|
||||
Ok((idx, target, Ok(conn))) => {
|
||||
tracing::info!(
|
||||
idx,
|
||||
%target,
|
||||
remote = %conn.remote_address(),
|
||||
elapsed_ms = start.elapsed().as_millis(),
|
||||
"birthday: HIT! QUIC handshake succeeded"
|
||||
);
|
||||
set.abort_all();
|
||||
return Some(wzp_transport::QuinnTransport::new(conn));
|
||||
}
|
||||
Ok((idx, target, Err(e))) => {
|
||||
tracing::debug!(
|
||||
idx,
|
||||
%target,
|
||||
error = %e,
|
||||
"birthday: probe failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
elapsed_ms = start.elapsed().as_millis(),
|
||||
"birthday: all probes failed or timed out"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_targets_known_ports_first() {
|
||||
let ip = Ipv4Addr::new(203, 0, 113, 5);
|
||||
let known = vec![10000, 10001, 10002];
|
||||
let targets = generate_dialer_targets(ip, &known, 10);
|
||||
|
||||
// Known ports should be first
|
||||
assert_eq!(targets[0].port(), 10000);
|
||||
assert_eq!(targets[1].port(), 10001);
|
||||
assert_eq!(targets[2].port(), 10002);
|
||||
// Rest are random
|
||||
assert!(targets.len() <= 10);
|
||||
// All target the right IP
|
||||
assert!(targets.iter().all(|a| a.ip() == std::net::IpAddr::V4(ip)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_targets_no_known_all_random() {
|
||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
||||
let targets = generate_dialer_targets(ip, &[], 50);
|
||||
assert!(!targets.is_empty());
|
||||
assert!(targets.len() <= 50);
|
||||
// All ports in valid range
|
||||
assert!(targets.iter().all(|a| a.port() >= 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_targets_more_known_than_total() {
|
||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
||||
let known: Vec<u16> = (10000..10100).collect();
|
||||
let targets = generate_dialer_targets(ip, &known, 50);
|
||||
// All 100 known ports included even though total=50
|
||||
assert_eq!(targets.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_targets_dedup() {
|
||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
||||
let targets = generate_dialer_targets(ip, &[], 100);
|
||||
// No duplicates
|
||||
let mut sorted = targets.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), targets.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config() {
|
||||
let cfg = BirthdayConfig::default();
|
||||
assert_eq!(cfg.acceptor_ports, 32);
|
||||
assert_eq!(cfg.dialer_probes, 128);
|
||||
assert!(cfg.timeout.as_secs() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptor_ports_serializes() {
|
||||
let result = AcceptorPorts {
|
||||
external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)),
|
||||
ports: vec![PortMapping {
|
||||
local_port: 12345,
|
||||
external_port: 54321,
|
||||
}],
|
||||
attempted: 32,
|
||||
succeeded: 1,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("54321"));
|
||||
assert!(json.contains("203.0.113.5"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
|
||||
use wzp_proto::{MediaTransport, default_signal_version};
|
||||
use wzp_proto::MediaTransport;
|
||||
|
||||
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
|
||||
|
||||
@@ -52,8 +52,6 @@ struct CliArgs {
|
||||
signal: bool,
|
||||
/// Place a direct call to a fingerprint (requires --signal).
|
||||
call_target: Option<String>,
|
||||
/// Run network diagnostic (STUN, port mapping, relay latencies).
|
||||
netcheck: bool,
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
@@ -99,7 +97,6 @@ fn parse_args() -> CliArgs {
|
||||
let mut relay_str = None;
|
||||
let mut signal = false;
|
||||
let mut call_target = None;
|
||||
let mut netcheck = false;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
@@ -108,11 +105,7 @@ fn parse_args() -> CliArgs {
|
||||
"--signal" => signal = true,
|
||||
"--call" => {
|
||||
i += 1;
|
||||
call_target = Some(
|
||||
args.get(i)
|
||||
.expect("--call requires a fingerprint")
|
||||
.to_string(),
|
||||
);
|
||||
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
||||
}
|
||||
"--send-tone" => {
|
||||
i += 1;
|
||||
@@ -189,12 +182,7 @@ fn parse_args() -> CliArgs {
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--netcheck" => {
|
||||
netcheck = true;
|
||||
}
|
||||
"--version-check" => {
|
||||
version_check = true;
|
||||
}
|
||||
"--version-check" => { version_check = true; }
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
eprintln!();
|
||||
@@ -205,19 +193,13 @@ fn parse_args() -> CliArgs {
|
||||
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
|
||||
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
||||
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
|
||||
eprintln!(
|
||||
" --sweep Run jitter buffer parameter sweep (local, no network)"
|
||||
);
|
||||
eprintln!(
|
||||
" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"
|
||||
);
|
||||
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
|
||||
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
|
||||
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
||||
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
|
||||
eprintln!(
|
||||
" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"
|
||||
);
|
||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||
eprintln!();
|
||||
eprintln!("Default relay: 127.0.0.1:4433");
|
||||
std::process::exit(0);
|
||||
@@ -256,7 +238,6 @@ fn parse_args() -> CliArgs {
|
||||
version_check,
|
||||
signal,
|
||||
call_target,
|
||||
netcheck,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,28 +256,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// --netcheck: run network diagnostic and exit
|
||||
if cli.netcheck {
|
||||
let config = wzp_client::netcheck::NetcheckConfig {
|
||||
stun_config: wzp_client::stun::StunConfig::default(),
|
||||
relays: vec![("relay".into(), cli.relay_addr)],
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
test_portmap: true,
|
||||
test_ipv6: true,
|
||||
local_port: 0,
|
||||
};
|
||||
let report = wzp_client::netcheck::run_netcheck(&config).await;
|
||||
print!("{}", wzp_client::netcheck::format_report(&report));
|
||||
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?;
|
||||
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();
|
||||
@@ -304,10 +269,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("{} {}", cli.relay_addr, version.trim());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"relay {} does not support version query: {e}",
|
||||
cli.relay_addr
|
||||
);
|
||||
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
|
||||
}
|
||||
}
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
@@ -347,7 +309,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
"0.0.0.0:0".parse()?
|
||||
};
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let connection = wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
|
||||
let connection =
|
||||
wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
|
||||
|
||||
info!("Connected to relay");
|
||||
|
||||
@@ -358,12 +321,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
{
|
||||
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");
|
||||
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..."); }
|
||||
@@ -371,16 +332,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 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");
|
||||
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 {
|
||||
version: default_signal_version(),
|
||||
token: token.clone(),
|
||||
};
|
||||
transport.send_signal(&auth).await?;
|
||||
@@ -388,29 +346,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Crypto handshake — establishes verified identity + session key
|
||||
let session = wzp_client::handshake::perform_handshake(
|
||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
None, // alias — desktop client doesn't set one yet
|
||||
)
|
||||
.await?;
|
||||
).await?;
|
||||
info!("crypto handshake complete");
|
||||
|
||||
// Wrap the transport so all media I/O goes through AEAD encryption.
|
||||
let enc_transport: Arc<dyn wzp_proto::MediaTransport> = Arc::new(
|
||||
wzp_client::encrypted_transport::EncryptingTransport::new(transport.clone(), session),
|
||||
);
|
||||
|
||||
if cli.live {
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
return run_live(enc_transport).await;
|
||||
return run_live(transport).await;
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"--live requires the 'audio' feature (build with: cargo build --features audio)"
|
||||
);
|
||||
anyhow::bail!("--live requires the 'audio' feature (build with: cargo build --features audio)");
|
||||
}
|
||||
} else if let Some(secs) = cli.echo_test_secs {
|
||||
let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?;
|
||||
@@ -427,20 +377,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
transport.close().await?;
|
||||
Ok(())
|
||||
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
|
||||
run_file_mode(
|
||||
enc_transport,
|
||||
cli.send_tone_secs,
|
||||
cli.send_file,
|
||||
cli.record_file,
|
||||
)
|
||||
.await
|
||||
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
|
||||
} else {
|
||||
run_silence(enc_transport).await
|
||||
run_silence(transport).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Send silence frames (connectivity test).
|
||||
async fn run_silence(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Result<()> {
|
||||
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
||||
let config = CallConfig::default();
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
|
||||
@@ -454,7 +398,7 @@ async fn run_silence(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::R
|
||||
for i in 0..250u32 {
|
||||
let packets = encoder.encode_frame(&pcm)?;
|
||||
for pkt in &packets {
|
||||
if pkt.header.is_repair() {
|
||||
if pkt.header.is_repair {
|
||||
total_repair += 1;
|
||||
} else {
|
||||
total_source += 1;
|
||||
@@ -479,9 +423,7 @@ async fn run_silence(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::R
|
||||
|
||||
info!(total_source, total_repair, total_bytes, "done — closing");
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
transport.send_signal(&hangup).await.ok();
|
||||
transport.close().await?;
|
||||
@@ -490,7 +432,7 @@ async fn run_silence(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::R
|
||||
|
||||
/// File/tone mode: send a test tone or audio file, and/or record received audio.
|
||||
async fn run_file_mode(
|
||||
transport: Arc<dyn wzp_proto::MediaTransport>,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
send_tone_secs: Option<u32>,
|
||||
send_file: Option<String>,
|
||||
record_file: Option<String>,
|
||||
@@ -505,28 +447,21 @@ async fn run_file_mode(
|
||||
// Read raw PCM file (48kHz mono s16le)
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("read {path}: {e}");
|
||||
return;
|
||||
}
|
||||
Err(e) => { error!("read {path}: {e}"); return; }
|
||||
};
|
||||
let samples: Vec<i16> = bytes
|
||||
.chunks_exact(2)
|
||||
let samples: Vec<i16> = bytes.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let duration = samples.len() as f64 / 48_000.0;
|
||||
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
|
||||
samples
|
||||
.chunks(FRAME_SAMPLES)
|
||||
samples.chunks(FRAME_SAMPLES)
|
||||
.filter(|c| c.len() == FRAME_SAMPLES)
|
||||
.map(|c| c.to_vec())
|
||||
.collect()
|
||||
} else if let Some(secs) = send_tone_secs {
|
||||
let total = (secs as u64) * 50;
|
||||
info!(seconds = secs, frames = total, "sending 440Hz tone");
|
||||
(0..total)
|
||||
.map(|i| generate_sine_frame(440.0, 48_000, i))
|
||||
.collect()
|
||||
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
|
||||
} else {
|
||||
// No sending, just wait
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
@@ -550,7 +485,7 @@ async fn run_file_mode(
|
||||
}
|
||||
};
|
||||
for pkt in &packets {
|
||||
if pkt.header.is_repair() {
|
||||
if pkt.header.is_repair {
|
||||
total_repair += 1;
|
||||
} else {
|
||||
total_source += 1;
|
||||
@@ -598,7 +533,7 @@ async fn run_file_mode(
|
||||
result = recv_transport.recv_media() => {
|
||||
match result {
|
||||
Ok(Some(pkt)) => {
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -639,9 +574,7 @@ async fn run_file_mode(
|
||||
|
||||
// Send Hangup signal so the relay knows we're done
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
transport.send_signal(&hangup).await.ok();
|
||||
|
||||
@@ -679,7 +612,7 @@ async fn run_file_mode(
|
||||
|
||||
/// Live mode: capture from mic, encode, send; receive, decode, play.
|
||||
#[cfg(feature = "audio")]
|
||||
async fn run_live(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Result<()> {
|
||||
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
||||
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||
|
||||
let capture = AudioCapture::start()?;
|
||||
@@ -693,21 +626,11 @@ async fn run_live(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Resu
|
||||
.spawn(move || {
|
||||
let config = CallConfig::default();
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
let mut frame = vec![0i16; FRAME_SAMPLES];
|
||||
loop {
|
||||
// Pull a full 20 ms frame from the capture ring. The ring
|
||||
// may return a partial read when the CPAL callback hasn't
|
||||
// produced enough samples yet — keep reading until we
|
||||
// accumulate a whole frame, sleeping briefly on empty
|
||||
// returns so we don't hot-spin the CPU.
|
||||
let mut filled = 0usize;
|
||||
while filled < FRAME_SAMPLES {
|
||||
let n = capture.ring().read(&mut frame[filled..]);
|
||||
filled += n;
|
||||
if n == 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
let frame = match capture.read_frame() {
|
||||
Some(f) => f,
|
||||
None => break,
|
||||
};
|
||||
let packets = match encoder.encode_frame(&frame) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -732,19 +655,13 @@ async fn run_live(transport: Arc<dyn wzp_proto::MediaTransport>) -> anyhow::Resu
|
||||
loop {
|
||||
match recv_transport.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
// Only decode for source packets (1 source = 1 audio frame).
|
||||
// Repair packets feed the FEC decoder but don't produce audio.
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
// Push the decoded frame into the playback
|
||||
// ring. The CPAL output callback drains from
|
||||
// here on its own clock; if the ring is full
|
||||
// (rare in CLI live mode) the write returns
|
||||
// a short count and the tail is dropped,
|
||||
// which is the correct real-time behavior.
|
||||
playback.ring().write(&pcm_buf);
|
||||
playback.write_frame(&pcm_buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,7 +694,7 @@ async fn run_signal_mode(
|
||||
token: Option<String>,
|
||||
call_target: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
use wzp_proto::{SignalMessage, default_signal_version};
|
||||
use wzp_proto::SignalMessage;
|
||||
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
@@ -799,34 +716,22 @@ async fn run_signal_mode(
|
||||
|
||||
// Auth if token provided
|
||||
if let Some(ref tok) = token {
|
||||
transport
|
||||
.send_signal(&SignalMessage::AuthToken {
|
||||
version: default_signal_version(),
|
||||
token: tok.clone(),
|
||||
})
|
||||
.await?;
|
||||
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
|
||||
}
|
||||
|
||||
// Register presence (signature not verified in Phase 1)
|
||||
transport
|
||||
.send_signal(&SignalMessage::RegisterPresence {
|
||||
version: default_signal_version(),
|
||||
identity_pub,
|
||||
signature: vec![], // Phase 1: not verified
|
||||
alias: None,
|
||||
})
|
||||
.await?;
|
||||
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,
|
||||
..
|
||||
}) => {
|
||||
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => {
|
||||
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
|
||||
}
|
||||
other => {
|
||||
@@ -837,52 +742,34 @@ async fn run_signal_mode(
|
||||
// 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()
|
||||
);
|
||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||
|
||||
transport
|
||||
.send_signal(&SignalMessage::DirectCallOffer {
|
||||
version: default_signal_version(),
|
||||
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],
|
||||
// CLI client doesn't attempt hole-punching; always
|
||||
// relay-path.
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
caller_build_version: None,
|
||||
})
|
||||
.await?;
|
||||
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, .. } => {
|
||||
SignalMessage::CallRinging { call_id } => {
|
||||
info!(call_id = %call_id, "ringing...");
|
||||
}
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
caller_alias,
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
|
||||
info!(
|
||||
from = %caller_fingerprint,
|
||||
alias = ?caller_alias,
|
||||
@@ -890,40 +777,19 @@ async fn run_signal_mode(
|
||||
"incoming call — auto-accepting (generic)"
|
||||
);
|
||||
// Auto-accept for CLI testing
|
||||
let _ = signal_transport
|
||||
.send_signal(&SignalMessage::DirectCallAnswer {
|
||||
version: default_signal_version(),
|
||||
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),
|
||||
// CLI auto-accept uses generic (privacy) mode,
|
||||
// so callee addr stays hidden from the caller.
|
||||
callee_reflexive_addr: None,
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_mapped_addr: None,
|
||||
callee_build_version: None,
|
||||
})
|
||||
.await;
|
||||
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,
|
||||
..
|
||||
} => {
|
||||
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,
|
||||
peer_direct_addr: _,
|
||||
peer_local_addrs: _,
|
||||
peer_mapped_addr: _,
|
||||
..
|
||||
} => {
|
||||
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
|
||||
@@ -931,28 +797,18 @@ async fn run_signal_mode(
|
||||
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));
|
||||
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
|
||||
{
|
||||
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)"
|
||||
);
|
||||
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 mut encoder = wzp_client::call::CallEncoder::new(&config);
|
||||
let duration = tokio::time::Duration::from_millis(20);
|
||||
loop {
|
||||
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
||||
@@ -960,9 +816,7 @@ async fn run_signal_mode(
|
||||
.collect();
|
||||
if let Ok(pkts) = encoder.encode_frame(&pcm) {
|
||||
for pkt in &pkts {
|
||||
if mt.send_media(pkt).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if mt.send_media(pkt).await.is_err() { return; }
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(duration).await;
|
||||
@@ -985,9 +839,7 @@ async fn run_signal_mode(
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("hanging up...");
|
||||
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
}).await;
|
||||
break;
|
||||
}
|
||||
@@ -1004,7 +856,7 @@ async fn run_signal_mode(
|
||||
Err(e) => error!("media connect failed: {e}"),
|
||||
}
|
||||
}
|
||||
SignalMessage::Hangup { reason, .. } => {
|
||||
SignalMessage::Hangup { reason } => {
|
||||
info!(reason = ?reason, "call ended by remote");
|
||||
}
|
||||
SignalMessage::Pong { .. } => {}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub async fn run_drift_test(
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -180,7 +180,7 @@ pub async fn run_drift_test(
|
||||
while Instant::now() < drain_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -234,10 +234,7 @@ pub fn print_drift_report(result: &DriftResult) {
|
||||
println!();
|
||||
println!("Expected duration: {} ms", result.expected_duration_ms);
|
||||
println!("Actual duration: {} ms", result.actual_duration_ms);
|
||||
println!(
|
||||
"Drift: {} ms ({:+.4}%)",
|
||||
result.drift_ms, result.drift_pct
|
||||
);
|
||||
println!("Drift: {} ms ({:+.4}%)", result.drift_ms, result.drift_pct);
|
||||
println!();
|
||||
|
||||
// Interpretation
|
||||
@@ -249,15 +246,9 @@ pub fn print_drift_report(result: &DriftResult) {
|
||||
} else if abs_drift < 20 {
|
||||
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
|
||||
} else if abs_drift < 100 {
|
||||
println!(
|
||||
"Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.",
|
||||
abs_drift
|
||||
);
|
||||
println!("Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.", abs_drift);
|
||||
} else {
|
||||
println!(
|
||||
"Result: POOR -- significant drift ({} ms). Investigate clock sources.",
|
||||
abs_drift
|
||||
);
|
||||
println!("Result: POOR -- significant drift ({} ms). Investigate clock sources.", abs_drift);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -1,976 +0,0 @@
|
||||
//! Phase 3.5 — dual-path QUIC connect race for P2P hole-punching.
|
||||
//!
|
||||
//! When both peers advertised reflex addrs in the
|
||||
//! DirectCallOffer/Answer flow, the relay cross-wires them into
|
||||
//! `CallSetup.peer_direct_addr`. This module races a direct QUIC
|
||||
//! handshake against the existing relay dial and returns whichever
|
||||
//! completes first — with automatic drop of the loser via
|
||||
//! `tokio::select!`.
|
||||
//!
|
||||
//! Role determination is deterministic and symmetric
|
||||
//! (`wzp_client::reflect::determine_role`): whichever peer has the
|
||||
//! lexicographically smaller reflex addr becomes the **Acceptor**
|
||||
//! (listens on a server-capable endpoint), the other becomes the
|
||||
//! **Dialer** (dials the peer's addr). Because the rule is
|
||||
//! identical on both sides, the Acceptor's inbound QUIC session
|
||||
//! and the Dialer's outbound are the SAME connection — no
|
||||
//! negotiation needed, no two-conns-per-call confusion.
|
||||
//!
|
||||
//! Timeout policy:
|
||||
//! - Direct path: 2s from the start of `race`. Cone-NAT hole-punch
|
||||
//! typically completes in < 500ms on a LAN; 2s gives us tolerance
|
||||
//! for a single QUIC Initial retry on unreliable networks.
|
||||
//! - Relay path: 10s (existing behavior elsewhere in the codebase).
|
||||
//! - Overall: `tokio::select!` returns as soon as either succeeds.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::reflect::Role;
|
||||
use wzp_transport::QuinnTransport;
|
||||
|
||||
/// Which path won the race. Used by the `connect` command for
|
||||
/// logging + (in the future) metrics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WinningPath {
|
||||
Direct,
|
||||
Relay,
|
||||
}
|
||||
|
||||
/// Diagnostic info for a single candidate dial attempt.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CandidateDiag {
|
||||
pub index: usize,
|
||||
pub addr: String,
|
||||
pub result: String, // "ok", "skipped:ipv6", "error:..."
|
||||
pub elapsed_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Phase 6: the race now returns BOTH transports (when available)
|
||||
/// so the connect command can negotiate with the peer before
|
||||
/// committing. The negotiation decides which transport to use
|
||||
/// based on whether BOTH sides report `direct_ok = true`.
|
||||
pub struct RaceResult {
|
||||
/// The direct P2P transport, if the direct path completed.
|
||||
/// `None` if the direct dial/accept failed or timed out.
|
||||
pub direct_transport: Option<Arc<QuinnTransport>>,
|
||||
/// The relay transport, if the relay dial completed.
|
||||
/// `None` if the relay dial failed (shouldn't happen in
|
||||
/// practice since relay is always reachable).
|
||||
pub relay_transport: Option<Arc<QuinnTransport>>,
|
||||
/// Which future completed first in the local race.
|
||||
/// Informational — the actual path used is decided by the
|
||||
/// Phase 6 negotiation after both sides exchange reports.
|
||||
pub local_winner: WinningPath,
|
||||
/// Per-candidate diagnostic info for debugging.
|
||||
pub candidate_diags: Vec<CandidateDiag>,
|
||||
}
|
||||
|
||||
/// Attempt a direct QUIC connection to the peer in parallel with
|
||||
/// the relay dial and return the winning `QuinnTransport`.
|
||||
///
|
||||
/// `role` selects the direction of the direct attempt:
|
||||
/// - `Role::Acceptor` creates a server-capable endpoint and waits
|
||||
/// for the peer to dial in.
|
||||
/// - `Role::Dialer` creates a client-only endpoint and dials
|
||||
/// `peer_direct_addr`.
|
||||
///
|
||||
/// The relay path is always attempted in parallel as a fallback so
|
||||
/// the race ALWAYS produces a working transport unless both paths
|
||||
/// genuinely fail (network partition). Returns
|
||||
/// `Err(anyhow::anyhow!(...))` if both paths fail within the
|
||||
/// timeout.
|
||||
/// Phase 5.5 candidate bundle — full ICE-ish candidate list for
|
||||
/// the peer. The race tries them all in parallel alongside the
|
||||
/// relay path. At minimum this should contain the peer's
|
||||
/// server-reflexive address; `local_addrs` carries LAN host
|
||||
/// candidates gathered from their physical interfaces.
|
||||
///
|
||||
/// Empty is valid: the D-role has nothing to dial and the race
|
||||
/// reduces to "relay only" + (if A-role) accepting on the
|
||||
/// shared endpoint.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PeerCandidates {
|
||||
/// Peer's server-reflexive address (Phase 3). `None` if the
|
||||
/// peer didn't advertise one.
|
||||
pub reflexive: Option<SocketAddr>,
|
||||
/// Peer's LAN host addresses (Phase 5.5). Tried first on
|
||||
/// same-LAN pairs — direct dials to these bypass the NAT
|
||||
/// entirely.
|
||||
pub local: Vec<SocketAddr>,
|
||||
/// Phase 8 (Tailscale-inspired): peer's port-mapped external
|
||||
/// address from NAT-PMP/PCP/UPnP. When the router supports
|
||||
/// port mapping, this gives a stable external address even
|
||||
/// behind symmetric NATs.
|
||||
pub mapped: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl PeerCandidates {
|
||||
/// Flatten into the list of addrs the D-role should dial.
|
||||
/// Order: LAN host candidates first (fastest when they
|
||||
/// work), then port-mapped (stable even behind symmetric
|
||||
/// NATs), then reflexive (covers the non-LAN case).
|
||||
pub fn dial_order(&self) -> Vec<SocketAddr> {
|
||||
let mut out = Vec::with_capacity(self.local.len() + 2);
|
||||
out.extend(self.local.iter().copied());
|
||||
// Port-mapped address goes before reflexive — it's
|
||||
// more reliable on symmetric NATs where the reflexive
|
||||
// addr might not match what the peer actually sees.
|
||||
if let Some(a) = self.mapped {
|
||||
if !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
if let Some(a) = self.reflexive {
|
||||
if !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Smart dial order: filters out candidates that can't possibly
|
||||
/// work given our own reflexive address.
|
||||
///
|
||||
/// - **LAN candidates**: only included if peer's public IP
|
||||
/// matches ours (same network). Private IPs are unreachable
|
||||
/// cross-network.
|
||||
/// - **IPv6 candidates**: stripped entirely (Phase 7 disabled).
|
||||
/// - **Reflexive + mapped**: always included.
|
||||
pub fn smart_dial_order(&self, own_reflexive: Option<&SocketAddr>) -> Vec<SocketAddr> {
|
||||
let own_public_ip = own_reflexive.map(|a| a.ip());
|
||||
let peer_public_ip = self.reflexive.map(|a| a.ip());
|
||||
let same_network = match (own_public_ip, peer_public_ip) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(self.local.len() + 2);
|
||||
|
||||
// LAN candidates only when on the same network.
|
||||
if same_network {
|
||||
for addr in &self.local {
|
||||
if !addr.is_ipv6() {
|
||||
out.push(*addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Port-mapped (always useful — it's a public addr).
|
||||
if let Some(a) = self.mapped {
|
||||
if !a.is_ipv6() && !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
// Reflexive (always useful — it's the peer's public addr).
|
||||
if let Some(a) = self.reflexive {
|
||||
if !a.is_ipv6() && !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Is there anything for the D-role to dial? If not, the
|
||||
/// race reduces to relay-only.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.reflexive.is_none() && self.local.is_empty() && self.mapped.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn race(
|
||||
role: Role,
|
||||
peer_candidates: PeerCandidates,
|
||||
relay_addr: SocketAddr,
|
||||
room_sni: String,
|
||||
call_sni: String,
|
||||
// Our own reflexive address — used to filter LAN candidates
|
||||
// that can't work cross-network.
|
||||
own_reflexive: Option<SocketAddr>,
|
||||
// Phase 5: when `Some`, reuse this endpoint for BOTH the
|
||||
// direct-path branch AND the relay dial. Pass the signal
|
||||
// endpoint. The endpoint MUST be server-capable (created
|
||||
// with a server config) for the A-role accept branch to
|
||||
// work.
|
||||
//
|
||||
// When `None`, falls back to fresh endpoints per role.
|
||||
// Used by tests.
|
||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
||||
// Phase 7: dedicated IPv6 endpoint with IPV6_V6ONLY=1.
|
||||
// When `Some`, A-role accepts on both v4+v6, D-role routes
|
||||
// each candidate to its matching-AF endpoint. When `None`,
|
||||
// IPv6 candidates are skipped (IPv4-only, pre-Phase-7).
|
||||
ipv6_endpoint: Option<wzp_transport::Endpoint>,
|
||||
) -> anyhow::Result<RaceResult> {
|
||||
// Rustls provider must be installed before any quinn endpoint
|
||||
// is created. Install attempt is idempotent.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// Shared diagnostic collector for per-candidate results.
|
||||
let diags_collector: Arc<std::sync::Mutex<Vec<CandidateDiag>>> =
|
||||
Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
// Build the direct-path endpoint + future based on role.
|
||||
//
|
||||
// A-role: one accept future on the shared endpoint. The
|
||||
// first incoming QUIC connection wins — we don't care
|
||||
// which peer candidate the dialer used to reach us.
|
||||
//
|
||||
// D-role: N parallel dial futures, one per peer candidate
|
||||
// (all LAN host addrs + the reflex addr), consolidated
|
||||
// into a single direct_fut via FuturesUnordered-style
|
||||
// "first OK wins" semantics. The first successful dial
|
||||
// becomes the direct path; the losers are dropped (quinn
|
||||
// will abort the in-flight handshakes via the dropped
|
||||
// Connecting futures).
|
||||
//
|
||||
// Either way, direct_fut resolves to a single QuinnTransport
|
||||
// (or an error) and is raced against the relay_fut by the
|
||||
// outer tokio::select!.
|
||||
let direct_ep: wzp_transport::Endpoint;
|
||||
let direct_fut: std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = anyhow::Result<QuinnTransport>> + Send>,
|
||||
>;
|
||||
|
||||
match role {
|
||||
Role::Acceptor => {
|
||||
let ep = match shared_endpoint.clone() {
|
||||
Some(ep) => {
|
||||
tracing::info!(
|
||||
local_addr = ?ep.local_addr().ok(),
|
||||
"dual_path: A-role reusing shared endpoint for accept"
|
||||
);
|
||||
ep
|
||||
}
|
||||
None => {
|
||||
let (sc, _cert_der) = wzp_transport::server_config();
|
||||
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
|
||||
// tried but breaks on Android devices where
|
||||
// IPV6_V6ONLY=1 (default on some kernels) —
|
||||
// IPv4 candidates silently fail. IPv6 host
|
||||
// candidates are skipped for now; they need a
|
||||
// dedicated IPv6 socket alongside the v4 one
|
||||
// (like WebRTC's dual-socket approach).
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let fresh = wzp_transport::create_endpoint(bind, Some(sc))?;
|
||||
tracing::info!(
|
||||
local_addr = ?fresh.local_addr().ok(),
|
||||
"dual_path: A-role fresh endpoint up, awaiting peer dial"
|
||||
);
|
||||
fresh
|
||||
}
|
||||
};
|
||||
let ep_for_fut = ep.clone();
|
||||
// Phase 7: IPv6 accept temporarily disabled (same reason
|
||||
// as dial — IPv6 connections die on datagram send).
|
||||
// Accept on IPv4 shared endpoint only.
|
||||
let _v6_ep_unused = ipv6_endpoint.clone();
|
||||
// Collect peer addrs for NAT tickle (Acceptor-side).
|
||||
let tickle_addrs: Vec<SocketAddr> = peer_candidates
|
||||
.smart_dial_order(own_reflexive.as_ref())
|
||||
.into_iter()
|
||||
.filter(|a| !a.ip().is_loopback() && !a.ip().is_unspecified())
|
||||
.collect();
|
||||
direct_fut = Box::pin(async move {
|
||||
// NAT tickle: send a small UDP packet to each of the
|
||||
// Dialer's candidate addresses FROM our shared endpoint.
|
||||
// This opens our NAT's pinhole for return traffic from
|
||||
// those IPs — critical for address-restricted NATs that
|
||||
// only allow inbound from IPs they've seen outbound
|
||||
// traffic to. Without this, the Dialer's QUIC Initial
|
||||
// gets dropped by our NAT.
|
||||
if !tickle_addrs.is_empty() {
|
||||
if let Ok(local_addr) = ep_for_fut.local_addr() {
|
||||
// Send a tickle to each peer candidate address
|
||||
// to open our NAT for return traffic from that IP.
|
||||
//
|
||||
// We use a socket2 socket with SO_REUSEADDR +
|
||||
// SO_REUSEPORT on the SAME port as the quinn
|
||||
// endpoint. This is necessary because quinn
|
||||
// already holds the port — a plain bind() would
|
||||
// fail with EADDRINUSE.
|
||||
let tickle_result: Result<(), String> = (|| {
|
||||
use std::net::UdpSocket as StdUdpSocket;
|
||||
let sock = socket2::Socket::new(
|
||||
socket2::Domain::IPV4,
|
||||
socket2::Type::DGRAM,
|
||||
Some(socket2::Protocol::UDP),
|
||||
)
|
||||
.map_err(|e| format!("socket: {e}"))?;
|
||||
sock.set_reuse_address(true)
|
||||
.map_err(|e| format!("reuseaddr: {e}"))?;
|
||||
// macOS/BSD/Linux also need SO_REUSEPORT
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "android"
|
||||
))]
|
||||
{
|
||||
// socket2 exposes set_reuse_port on unix
|
||||
unsafe {
|
||||
let optval: libc::c_int = 1;
|
||||
libc::setsockopt(
|
||||
std::os::unix::io::AsRawFd::as_raw_fd(&sock),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_REUSEPORT,
|
||||
&optval as *const _ as *const libc::c_void,
|
||||
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||
);
|
||||
}
|
||||
}
|
||||
sock.set_nonblocking(true)
|
||||
.map_err(|e| format!("nonblock: {e}"))?;
|
||||
let bind_addr: SocketAddr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_addr.port(),
|
||||
);
|
||||
sock.bind(&bind_addr.into())
|
||||
.map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
|
||||
let std_sock: StdUdpSocket = sock.into();
|
||||
for addr in &tickle_addrs {
|
||||
let _ = std_sock.send_to(&[0u8; 1], addr);
|
||||
tracing::info!(
|
||||
%addr,
|
||||
local_port = local_addr.port(),
|
||||
"dual_path: A-role sent NAT tickle"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = tickle_result {
|
||||
tracing::warn!(error = %e, "dual_path: A-role NAT tickle failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accept loop: retry if we get a stale/closed
|
||||
// connection from a previous call. Max 3 retries
|
||||
// to avoid spinning until the race timeout.
|
||||
const MAX_STALE: usize = 3;
|
||||
let mut stale_count: usize = 0;
|
||||
loop {
|
||||
let conn = wzp_transport::accept(&ep_for_fut)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
|
||||
|
||||
if let Some(reason) = conn.close_reason() {
|
||||
// Explicitly close so the peer gets a
|
||||
// close frame instead of idle timeout.
|
||||
conn.close(0u32.into(), b"stale");
|
||||
stale_count += 1;
|
||||
tracing::warn!(
|
||||
remote = %conn.remote_address(),
|
||||
stable_id = conn.stable_id(),
|
||||
stale_count,
|
||||
?reason,
|
||||
"dual_path: A-role skipping stale connection"
|
||||
);
|
||||
if stale_count >= MAX_STALE {
|
||||
return Err(anyhow::anyhow!(
|
||||
"A-role: {stale_count} stale connections, aborting"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_dgram = conn.max_datagram_size().is_some();
|
||||
tracing::info!(
|
||||
remote = %conn.remote_address(),
|
||||
stable_id = conn.stable_id(),
|
||||
has_dgram,
|
||||
"dual_path: A-role accepted direct connection"
|
||||
);
|
||||
|
||||
break Ok(QuinnTransport::new(conn));
|
||||
}
|
||||
});
|
||||
direct_ep = ep;
|
||||
}
|
||||
Role::Dialer => {
|
||||
let ep = match shared_endpoint.clone() {
|
||||
Some(ep) => {
|
||||
tracing::info!(
|
||||
local_addr = ?ep.local_addr().ok(),
|
||||
candidates = ?peer_candidates.dial_order(),
|
||||
"dual_path: D-role reusing shared endpoint to dial peer candidates"
|
||||
);
|
||||
ep
|
||||
}
|
||||
None => {
|
||||
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
|
||||
// tried but breaks on Android devices where
|
||||
// IPV6_V6ONLY=1 (default on some kernels) —
|
||||
// IPv4 candidates silently fail. IPv6 host
|
||||
// candidates are skipped for now; they need a
|
||||
// dedicated IPv6 socket alongside the v4 one
|
||||
// (like WebRTC's dual-socket approach).
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let fresh = wzp_transport::create_endpoint(bind, None)?;
|
||||
tracing::info!(
|
||||
local_addr = ?fresh.local_addr().ok(),
|
||||
candidates = ?peer_candidates.dial_order(),
|
||||
"dual_path: D-role fresh endpoint up, dialing peer candidates"
|
||||
);
|
||||
fresh
|
||||
}
|
||||
};
|
||||
let ep_for_fut = ep.clone();
|
||||
let _v6_ep_for_dial = ipv6_endpoint.clone();
|
||||
let dial_order = peer_candidates.smart_dial_order(own_reflexive.as_ref());
|
||||
let sni = call_sni.clone();
|
||||
let diags = diags_collector.clone();
|
||||
direct_fut = Box::pin(async move {
|
||||
if dial_order.is_empty() {
|
||||
// No candidates — the race reduces to
|
||||
// relay-only. Surface a stable error so the
|
||||
// outer select falls through to relay_fut
|
||||
// without a spurious "direct failed" warning.
|
||||
// Use a pending future that never resolves so
|
||||
// the select's "other side wins" branch is
|
||||
// the natural outcome.
|
||||
std::future::pending::<anyhow::Result<QuinnTransport>>().await
|
||||
} else {
|
||||
// Fan out N parallel dials via JoinSet. First
|
||||
// `Ok` wins; `Err` from a single candidate is
|
||||
// not fatal — we wait for the others. Only
|
||||
// when ALL have failed do we return Err.
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (idx, candidate) in dial_order.iter().enumerate() {
|
||||
// Phase 7: route each candidate to the
|
||||
// endpoint matching its address family.
|
||||
let candidate = *candidate;
|
||||
// Phase 7: IPv6 dials temporarily disabled.
|
||||
// IPv6 QUIC handshakes succeed but the
|
||||
// connection dies immediately on datagram
|
||||
// send ("connection lost"). Root cause is
|
||||
// likely router-level IPv6 UDP filtering.
|
||||
// Re-enable once IPv6 datagram delivery is
|
||||
// verified on target networks.
|
||||
if candidate.is_ipv6() {
|
||||
tracing::info!(
|
||||
%candidate,
|
||||
candidate_idx = idx,
|
||||
"dual_path: skipping IPv6 candidate (disabled)"
|
||||
);
|
||||
if let Ok(mut d) = diags.lock() {
|
||||
d.push(CandidateDiag {
|
||||
index: idx,
|
||||
addr: candidate.to_string(),
|
||||
result: "skipped:ipv6".into(),
|
||||
elapsed_ms: None,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ep = ep_for_fut.clone();
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let sni = sni.clone();
|
||||
let diags_inner = diags.clone();
|
||||
set.spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
tracing::info!(
|
||||
%candidate,
|
||||
candidate_idx = idx,
|
||||
"dual_path: dialing candidate"
|
||||
);
|
||||
let result =
|
||||
wzp_transport::connect(&ep, candidate, &sni, client_cfg).await;
|
||||
let elapsed = start.elapsed().as_millis() as u32;
|
||||
let diag_result = match &result {
|
||||
Ok(_) => "ok".to_string(),
|
||||
Err(e) => format!("error:{e}"),
|
||||
};
|
||||
if let Ok(mut d) = diags_inner.lock() {
|
||||
d.push(CandidateDiag {
|
||||
index: idx,
|
||||
addr: candidate.to_string(),
|
||||
result: diag_result,
|
||||
elapsed_ms: Some(elapsed),
|
||||
});
|
||||
}
|
||||
(idx, candidate, result)
|
||||
});
|
||||
}
|
||||
let mut last_err: Option<String> = None;
|
||||
while let Some(join_res) = set.join_next().await {
|
||||
let (idx, candidate, dial_res) = match join_res {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
last_err = Some(format!("join {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match dial_res {
|
||||
Ok(conn) => {
|
||||
tracing::info!(
|
||||
%candidate,
|
||||
candidate_idx = idx,
|
||||
remote = %conn.remote_address(),
|
||||
stable_id = conn.stable_id(),
|
||||
"dual_path: direct dial succeeded on candidate"
|
||||
);
|
||||
// Abort the remaining in-flight
|
||||
// dials so they don't complete
|
||||
// and leak QUIC sessions.
|
||||
set.abort_all();
|
||||
return Ok(QuinnTransport::new(conn));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
%candidate,
|
||||
candidate_idx = idx,
|
||||
error = %e,
|
||||
"dual_path: direct dial failed, trying others"
|
||||
);
|
||||
last_err = Some(format!("candidate {candidate}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"all {} direct candidates failed; last: {}",
|
||||
dial_order.len(),
|
||||
last_err.unwrap_or_else(|| "n/a".into())
|
||||
))
|
||||
}
|
||||
});
|
||||
direct_ep = ep;
|
||||
}
|
||||
}
|
||||
|
||||
// Relay path: classic dial to the relay's media room. Phase 5:
|
||||
// reuse the shared endpoint here too so MikroTik-style NATs
|
||||
// keep a stable external port across all flows from this
|
||||
// client. Falls back to a fresh endpoint when not shared.
|
||||
let relay_ep = match shared_endpoint.clone() {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
let relay_bind: SocketAddr = "[::]:0".parse().unwrap();
|
||||
wzp_transport::create_endpoint(relay_bind, None)?
|
||||
}
|
||||
};
|
||||
let relay_ep_for_fut = relay_ep.clone();
|
||||
let relay_client_cfg = wzp_transport::client_config();
|
||||
let relay_sni = room_sni.clone();
|
||||
// Phase 5.5 direct-path head-start: hold the relay dial for
|
||||
// 500ms before attempting it. On same-LAN cone-NAT pairs the
|
||||
// direct dial finishes in ~30-100ms, so giving direct a 500ms
|
||||
// head start means direct reliably wins when it's going to
|
||||
// work at all. The worst case adds 500ms to the fall-back-
|
||||
// to-relay scenario, which is imperceptible for users on
|
||||
// setups where direct isn't available anyway.
|
||||
//
|
||||
// Prior behavior (immediate race) caused the relay to win
|
||||
// ~105ms races on a MikroTik LAN because:
|
||||
// - Acceptor role's direct_fut = accept() can only fire
|
||||
// when the peer has completed its outbound LAN dial
|
||||
// - Dialer role's parallel LAN dials need the peer's
|
||||
// CallSetup processed + the race started on the other
|
||||
// side before they can reach us
|
||||
// - Meanwhile relay_fut is a plain dial that completes in
|
||||
// whatever the client→relay RTT is (often <100ms)
|
||||
//
|
||||
// The 500ms head start is the minimum that empirically makes
|
||||
// same-LAN direct reliably beat relay, without penalizing
|
||||
// users who genuinely need the relay path.
|
||||
const DIRECT_HEAD_START: Duration = Duration::from_millis(500);
|
||||
let relay_fut = async move {
|
||||
tokio::time::sleep(DIRECT_HEAD_START).await;
|
||||
let conn =
|
||||
wzp_transport::connect(&relay_ep_for_fut, relay_addr, &relay_sni, relay_client_cfg)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("relay dial: {e}"))?;
|
||||
Ok::<_, anyhow::Error>(QuinnTransport::new(conn))
|
||||
};
|
||||
|
||||
// Phase 6: run both paths concurrently via tokio::spawn and
|
||||
// collect BOTH results. The old tokio::select! approach dropped
|
||||
// the loser, which meant the connect command couldn't negotiate
|
||||
// with the peer — it had to commit to whichever path won locally.
|
||||
//
|
||||
// Now we spawn both as tasks, wait for the first to complete
|
||||
// (that determines `local_winner`), then give the loser a short
|
||||
// grace period to also complete. The connect command gets a
|
||||
// RaceResult with both transports (when available) and uses the
|
||||
// Phase 6 MediaPathReport exchange to decide which one to
|
||||
// actually use for media.
|
||||
let smart_order = peer_candidates.smart_dial_order(own_reflexive.as_ref());
|
||||
tracing::info!(
|
||||
?role,
|
||||
raw_candidates = ?peer_candidates.dial_order(),
|
||||
filtered_candidates = ?smart_order,
|
||||
?own_reflexive,
|
||||
%relay_addr,
|
||||
"dual_path: racing direct vs relay"
|
||||
);
|
||||
|
||||
let mut direct_task = tokio::spawn(tokio::time::timeout(Duration::from_secs(4), direct_fut));
|
||||
let mut relay_task = tokio::spawn(async move {
|
||||
// Keep the 500ms head start so direct has a chance
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), relay_fut).await
|
||||
});
|
||||
|
||||
// Wait for the first one to complete. This tells us the
|
||||
// local_winner — but we DON'T commit to it yet. Phase 6
|
||||
// negotiation decides the actual path.
|
||||
let (mut direct_result, mut relay_result): (
|
||||
Option<anyhow::Result<QuinnTransport>>,
|
||||
Option<anyhow::Result<QuinnTransport>>,
|
||||
) = (None, None);
|
||||
|
||||
let local_winner;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
d = &mut direct_task => {
|
||||
match d {
|
||||
Ok(Ok(Ok(t))) => {
|
||||
tracing::info!("dual_path: direct completed first");
|
||||
direct_result = Some(Ok(t));
|
||||
local_winner = WinningPath::Direct;
|
||||
}
|
||||
Ok(Ok(Err(e))) => {
|
||||
tracing::warn!(error = %e, "dual_path: direct failed");
|
||||
direct_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
local_winner = WinningPath::Relay; // direct failed → relay is our only hope
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
tracing::warn!("dual_path: direct timed out (4s)");
|
||||
direct_result = Some(Err(anyhow::anyhow!("direct timeout")));
|
||||
local_winner = WinningPath::Relay;
|
||||
// Record timeout diag for candidates that were
|
||||
// still in-flight when the timeout fired.
|
||||
if let Ok(mut d) = diags_collector.lock() {
|
||||
let recorded_indices: std::collections::HashSet<usize> =
|
||||
d.iter().map(|diag| diag.index).collect();
|
||||
for (idx, addr) in smart_order.iter().enumerate() {
|
||||
if !recorded_indices.contains(&idx) {
|
||||
d.push(CandidateDiag {
|
||||
index: idx,
|
||||
addr: addr.to_string(),
|
||||
result: "timeout:4s".into(),
|
||||
elapsed_ms: Some(4000),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "dual_path: direct task panicked");
|
||||
direct_result = Some(Err(anyhow::anyhow!("direct task panic")));
|
||||
local_winner = WinningPath::Relay;
|
||||
}
|
||||
}
|
||||
}
|
||||
r = &mut relay_task => {
|
||||
match r {
|
||||
Ok(Ok(Ok(t))) => {
|
||||
tracing::info!("dual_path: relay completed first");
|
||||
relay_result = Some(Ok(t));
|
||||
local_winner = WinningPath::Relay;
|
||||
}
|
||||
Ok(Ok(Err(e))) => {
|
||||
tracing::warn!(error = %e, "dual_path: relay failed");
|
||||
relay_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
local_winner = WinningPath::Direct;
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
tracing::warn!("dual_path: relay timed out");
|
||||
relay_result = Some(Err(anyhow::anyhow!("relay timeout")));
|
||||
local_winner = WinningPath::Direct;
|
||||
}
|
||||
Err(e) => {
|
||||
relay_result = Some(Err(anyhow::anyhow!("relay task panic: {e}")));
|
||||
local_winner = WinningPath::Direct;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give the loser a short grace period (1s) to also complete.
|
||||
// If it does, we have both transports for Phase 6 negotiation.
|
||||
// If it doesn't, we still proceed with just the winner.
|
||||
if direct_result.is_none() {
|
||||
match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
|
||||
Ok(Ok(Ok(Ok(t)))) => {
|
||||
direct_result = Some(Ok(t));
|
||||
}
|
||||
Ok(Ok(Ok(Err(e)))) => {
|
||||
direct_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
}
|
||||
_ => {
|
||||
direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period")));
|
||||
// Fill timeout diags for candidates that never reported.
|
||||
if let Ok(mut d) = diags_collector.lock() {
|
||||
let recorded: std::collections::HashSet<usize> =
|
||||
d.iter().map(|diag| diag.index).collect();
|
||||
for (idx, addr) in smart_order.iter().enumerate() {
|
||||
if !recorded.contains(&idx) {
|
||||
d.push(CandidateDiag {
|
||||
index: idx,
|
||||
addr: addr.to_string(),
|
||||
result: "timeout:grace".into(),
|
||||
elapsed_ms: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if relay_result.is_none() {
|
||||
match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
|
||||
Ok(Ok(Ok(Ok(t)))) => {
|
||||
relay_result = Some(Ok(t));
|
||||
}
|
||||
Ok(Ok(Ok(Err(e)))) => {
|
||||
relay_result = Some(Err(anyhow::anyhow!("{e}")));
|
||||
}
|
||||
_ => {
|
||||
relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let direct_ok = direct_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
|
||||
let relay_ok = relay_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
|
||||
|
||||
tracing::info!(
|
||||
?local_winner,
|
||||
direct_ok,
|
||||
relay_ok,
|
||||
"dual_path: race finished, both results collected for Phase 6 negotiation"
|
||||
);
|
||||
|
||||
if !direct_ok && !relay_ok {
|
||||
return Err(anyhow::anyhow!(
|
||||
"both paths failed: no media transport available"
|
||||
));
|
||||
}
|
||||
|
||||
let _ = (direct_ep, relay_ep, ipv6_endpoint);
|
||||
|
||||
let candidate_diags = diags_collector
|
||||
.lock()
|
||||
.map(|d| d.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(RaceResult {
|
||||
direct_transport: direct_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
||||
relay_transport: relay_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
||||
local_winner,
|
||||
candidate_diags,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_all_types() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
||||
local: vec![
|
||||
"192.168.1.10:4433".parse().unwrap(),
|
||||
"10.0.0.5:4433".parse().unwrap(),
|
||||
],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
// Order: local first, then mapped, then reflexive
|
||||
assert_eq!(order.len(), 4);
|
||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(
|
||||
order[2],
|
||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_no_mapped() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
||||
local: vec!["192.168.1.10:4433".parse().unwrap()],
|
||||
mapped: None,
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 2);
|
||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(order[1], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_only_mapped() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: None,
|
||||
local: vec![],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(
|
||||
order[0],
|
||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_dedup_mapped_equals_reflexive() {
|
||||
let addr: SocketAddr = "203.0.113.5:4433".parse().unwrap();
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some(addr),
|
||||
local: vec![],
|
||||
mapped: Some(addr), // same as reflexive
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
// Should be deduped to 1
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_dial_order_dedup_mapped_in_local() {
|
||||
let addr: SocketAddr = "192.168.1.10:4433".parse().unwrap();
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: None,
|
||||
local: vec![addr],
|
||||
mapped: Some(addr), // same as a local addr
|
||||
};
|
||||
|
||||
let order = candidates.dial_order();
|
||||
assert_eq!(order.len(), 1);
|
||||
assert_eq!(order[0], addr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_is_empty() {
|
||||
let empty = PeerCandidates::default();
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let with_reflexive = PeerCandidates {
|
||||
reflexive: Some("1.2.3.4:5".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_reflexive.is_empty());
|
||||
|
||||
let with_local = PeerCandidates {
|
||||
local: vec!["10.0.0.1:5".parse().unwrap()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_local.is_empty());
|
||||
|
||||
let with_mapped = PeerCandidates {
|
||||
mapped: Some("1.2.3.4:5".parse().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!with_mapped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_candidates_empty_dial_order() {
|
||||
let empty = PeerCandidates::default();
|
||||
assert!(empty.dial_order().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_path_debug() {
|
||||
// Just verify Debug impl doesn't panic
|
||||
let _ = format!("{:?}", WinningPath::Direct);
|
||||
let _ = format!("{:?}", WinningPath::Relay);
|
||||
}
|
||||
|
||||
// ── smart_dial_order tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn smart_dial_order_same_network_includes_lan() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
||||
local: vec![
|
||||
"192.168.1.10:4433".parse().unwrap(),
|
||||
"10.0.0.5:4433".parse().unwrap(),
|
||||
],
|
||||
mapped: None,
|
||||
};
|
||||
let own: SocketAddr = "203.0.113.5:12345".parse().unwrap();
|
||||
let order = candidates.smart_dial_order(Some(&own));
|
||||
// Same public IP → LAN candidates included
|
||||
assert!(order.contains(&"192.168.1.10:4433".parse().unwrap()));
|
||||
assert!(order.contains(&"10.0.0.5:4433".parse().unwrap()));
|
||||
assert!(order.contains(&"203.0.113.5:4433".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_dial_order_different_network_strips_lan() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
||||
local: vec![
|
||||
"172.16.81.126:4433".parse().unwrap(),
|
||||
"10.0.0.5:4433".parse().unwrap(),
|
||||
],
|
||||
mapped: None,
|
||||
};
|
||||
// Different public IP → LAN candidates stripped
|
||||
let own: SocketAddr = "185.115.4.212:12345".parse().unwrap();
|
||||
let order = candidates.smart_dial_order(Some(&own));
|
||||
assert!(!order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
||||
assert!(!order.contains(&"10.0.0.5:4433".parse().unwrap()));
|
||||
// Reflexive still included
|
||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_dial_order_strips_ipv6() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
||||
local: vec![
|
||||
"[2a0d:3344:692c::1]:4433".parse().unwrap(),
|
||||
"172.16.81.126:4433".parse().unwrap(),
|
||||
],
|
||||
mapped: None,
|
||||
};
|
||||
// Same network, but IPv6 should be stripped
|
||||
let own: SocketAddr = "150.228.49.65:5555".parse().unwrap();
|
||||
let order = candidates.smart_dial_order(Some(&own));
|
||||
assert!(!order.iter().any(|a| a.is_ipv6()));
|
||||
assert!(order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_dial_order_no_own_reflexive_strips_lan() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
||||
local: vec!["172.16.81.126:4433".parse().unwrap()],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
// No own reflexive → can't determine same network → strip LAN
|
||||
let order = candidates.smart_dial_order(None);
|
||||
assert!(!order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
||||
assert!(order.contains(&"198.51.100.42:12345".parse().unwrap()));
|
||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_dial_order_mapped_always_included() {
|
||||
let candidates = PeerCandidates {
|
||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
||||
local: vec![],
|
||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
||||
};
|
||||
let own: SocketAddr = "185.115.4.212:12345".parse().unwrap();
|
||||
let order = candidates.smart_dial_order(Some(&own));
|
||||
assert_eq!(order.len(), 2); // mapped + reflexive
|
||||
assert!(order.contains(&"198.51.100.42:12345".parse().unwrap()));
|
||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ pub async fn run_echo_test(
|
||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
total_packets_received += 1;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||
@@ -184,8 +184,7 @@ pub async fn run_echo_test(
|
||||
let time_offset = start.elapsed().as_secs_f64();
|
||||
|
||||
// Compare sent vs received for this window
|
||||
let sent_start =
|
||||
(window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
||||
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
||||
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
|
||||
let sent_window = if sent_end <= sent_pcm.len() {
|
||||
&sent_pcm[sent_start..sent_end]
|
||||
@@ -193,9 +192,7 @@ pub async fn run_echo_test(
|
||||
&sent_pcm[sent_start..]
|
||||
};
|
||||
|
||||
let recv_start = recv_pcm
|
||||
.len()
|
||||
.saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
||||
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
||||
let recv_window = &recv_pcm[recv_start..];
|
||||
|
||||
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
|
||||
@@ -259,7 +256,7 @@ pub async fn run_echo_test(
|
||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
total_packets_received += 1;
|
||||
let is_repair = pkt.header.is_repair();
|
||||
let is_repair = pkt.header.is_repair;
|
||||
decoder.ingest(pkt);
|
||||
if !is_repair {
|
||||
decoder.decode_next(&mut pcm_buf);
|
||||
@@ -313,14 +310,8 @@ pub fn print_report(result: &EchoTestResult) {
|
||||
let status = if w.is_silent { " !" } else { " " };
|
||||
println!(
|
||||
"│ {:>3}{} │ {:>5.1}s │ {:>4} │ {:>4} │ {:>5.1}% │ {:>5.1} │ {:.3} │",
|
||||
w.index,
|
||||
status,
|
||||
w.time_offset_secs,
|
||||
w.frames_sent,
|
||||
w.frames_received,
|
||||
w.loss_pct,
|
||||
w.snr_db,
|
||||
w.correlation
|
||||
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received,
|
||||
w.loss_pct, w.snr_db, w.correlation
|
||||
);
|
||||
}
|
||||
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
|
||||
@@ -330,28 +321,18 @@ pub fn print_report(result: &EchoTestResult) {
|
||||
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
|
||||
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
|
||||
|
||||
let avg_loss_first =
|
||||
first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_loss_second =
|
||||
second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_corr_first =
|
||||
first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_corr_second =
|
||||
second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
||||
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
||||
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
||||
|
||||
println!();
|
||||
if avg_loss_second > avg_loss_first + 5.0 {
|
||||
println!("WARNING: Quality degradation detected!");
|
||||
println!(
|
||||
" Loss increased from {:.1}% to {:.1}% over time",
|
||||
avg_loss_first, avg_loss_second
|
||||
);
|
||||
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second);
|
||||
}
|
||||
if avg_corr_second < avg_corr_first - 0.1 {
|
||||
println!(
|
||||
"WARNING: Signal correlation dropped from {:.3} to {:.3}",
|
||||
avg_corr_first, avg_corr_second
|
||||
);
|
||||
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second);
|
||||
}
|
||||
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
|
||||
println!("Quality is STABLE over the test duration.");
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
//! `EncryptingTransport` — wraps any `MediaTransport` with a `CryptoSession`.
|
||||
//!
|
||||
//! All outbound `send_media` calls encrypt the payload before handing off to
|
||||
//! the inner transport; all inbound `recv_media` calls decrypt after receiving.
|
||||
//! Signal, quality, and close are forwarded unchanged.
|
||||
//!
|
||||
//! The quality report travels in plaintext so the relay can make QoS decisions
|
||||
//! without being able to decrypt media content.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use wzp_proto::{
|
||||
CryptoSession, MediaHeader, MediaPacket, MediaTransport, PathQuality, SignalMessage,
|
||||
TransportError,
|
||||
};
|
||||
|
||||
/// Wraps a `MediaTransport` and applies AEAD encryption/decryption to media payloads.
|
||||
pub struct EncryptingTransport {
|
||||
inner: Arc<dyn MediaTransport>,
|
||||
session: Mutex<Box<dyn CryptoSession>>,
|
||||
}
|
||||
|
||||
impl EncryptingTransport {
|
||||
pub fn new(inner: Arc<dyn MediaTransport>, session: Box<dyn CryptoSession>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
session: Mutex::new(session),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaTransport for EncryptingTransport {
|
||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
||||
packet.header.write_to(&mut header_bytes);
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
self.session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.encrypt(&header_bytes, &packet.payload, &mut ciphertext)
|
||||
.map_err(|e| TransportError::Internal(format!("encrypt: {e}")))?;
|
||||
|
||||
let encrypted = MediaPacket {
|
||||
header: packet.header,
|
||||
payload: Bytes::from(ciphertext),
|
||||
quality_report: packet.quality_report.clone(),
|
||||
};
|
||||
self.inner.send_media(&encrypted).await
|
||||
}
|
||||
|
||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
||||
let packet = match self.inner.recv_media().await? {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
||||
packet.header.write_to(&mut header_bytes);
|
||||
|
||||
let mut plaintext = Vec::new();
|
||||
self.session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.decrypt(&header_bytes, &packet.payload, &mut plaintext)
|
||||
.map_err(|e| TransportError::Internal(format!("decrypt: {e}")))?;
|
||||
|
||||
Ok(Some(MediaPacket {
|
||||
header: packet.header,
|
||||
payload: Bytes::from(plaintext),
|
||||
quality_report: packet.quality_report,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
|
||||
self.inner.send_signal(msg).await
|
||||
}
|
||||
|
||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
||||
self.inner.recv_signal().await
|
||||
}
|
||||
|
||||
fn path_quality(&self) -> PathQuality {
|
||||
self.inner.path_quality()
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TransportError> {
|
||||
self.inner.close().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use wzp_crypto::ChaChaSession;
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
struct LoopbackTransport {
|
||||
sent: StdMutex<Vec<MediaPacket>>,
|
||||
}
|
||||
|
||||
impl LoopbackTransport {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
sent: StdMutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn take_sent(&self) -> Vec<MediaPacket> {
|
||||
self.sent.lock().unwrap().drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaTransport for LoopbackTransport {
|
||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
||||
self.sent.lock().unwrap().push(packet.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn send_signal(&self, _msg: &SignalMessage) -> Result<(), TransportError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
||||
Ok(None)
|
||||
}
|
||||
fn path_quality(&self) -> PathQuality {
|
||||
PathQuality::default()
|
||||
}
|
||||
async fn close(&self) -> Result<(), TransportError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_header(seq: u32) -> MediaHeader {
|
||||
MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq,
|
||||
timestamp: seq * 20,
|
||||
fec_block: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn payload_is_encrypted_on_wire() {
|
||||
let key = [0x42u8; 32];
|
||||
let session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
|
||||
let loopback = LoopbackTransport::new();
|
||||
let enc = EncryptingTransport::new(loopback.clone(), session);
|
||||
|
||||
let header = make_header(1);
|
||||
let plaintext = b"secret audio frame";
|
||||
let pkt = MediaPacket {
|
||||
header,
|
||||
payload: Bytes::from_static(plaintext),
|
||||
quality_report: None,
|
||||
};
|
||||
|
||||
enc.send_media(&pkt).await.unwrap();
|
||||
|
||||
let sent = loopback.take_sent();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].header, header, "header must be preserved");
|
||||
assert_ne!(
|
||||
sent[0].payload.as_ref(),
|
||||
plaintext.as_ref(),
|
||||
"plaintext must not appear on wire"
|
||||
);
|
||||
// Ciphertext is longer by exactly the AEAD tag (16 bytes)
|
||||
assert_eq!(sent[0].payload.len(), plaintext.len() + 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypt_then_decrypt_roundtrip() {
|
||||
let key = [0x42u8; 32];
|
||||
let send_session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
|
||||
let mut recv_session = ChaChaSession::new(key);
|
||||
|
||||
let loopback = LoopbackTransport::new();
|
||||
let enc = EncryptingTransport::new(loopback.clone(), send_session);
|
||||
|
||||
let header = make_header(5);
|
||||
let plaintext = b"hello encrypted world";
|
||||
let pkt = MediaPacket {
|
||||
header,
|
||||
payload: Bytes::from_static(plaintext),
|
||||
quality_report: None,
|
||||
};
|
||||
|
||||
enc.send_media(&pkt).await.unwrap();
|
||||
|
||||
let sent = loopback.take_sent();
|
||||
let wire_pkt = &sent[0];
|
||||
|
||||
let mut header_bytes = Vec::new();
|
||||
header.write_to(&mut header_bytes);
|
||||
let mut decrypted = Vec::new();
|
||||
recv_session
|
||||
.decrypt(&header_bytes, &wire_pkt.payload, &mut decrypted)
|
||||
.expect("decrypt should succeed with matching key");
|
||||
assert_eq!(&decrypted[..], plaintext);
|
||||
}
|
||||
}
|
||||
@@ -96,18 +96,16 @@ 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,
|
||||
SignalMessage::Unhold { .. } => CallSignalType::Unhold,
|
||||
SignalMessage::Mute { .. } => CallSignalType::Mute,
|
||||
SignalMessage::Unmute { .. } => CallSignalType::Unmute,
|
||||
SignalMessage::Hold => CallSignalType::Hold,
|
||||
SignalMessage::Unhold => CallSignalType::Unhold,
|
||||
SignalMessage::Mute => CallSignalType::Mute,
|
||||
SignalMessage::Unmute => CallSignalType::Unmute,
|
||||
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
||||
SignalMessage::TransferAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
||||
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::TransportFeedback { .. } => CallSignalType::Offer, // reuse (BWE)
|
||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
@@ -119,31 +117,8 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
||||
SignalMessage::RegisterPresence { .. } | SignalMessage::RegisterPresenceAck { .. } => {
|
||||
CallSignalType::Offer
|
||||
} // relay-only
|
||||
// NAT reflection is a client↔relay control exchange that
|
||||
// never crosses the featherChat bridge — if it ever reaches
|
||||
// this mapper something is wrong, but we still have to give
|
||||
// an answer. "Offer" is the generic catch-all.
|
||||
SignalMessage::Reflect | SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
|
||||
// Phase 4 cross-relay forwarding envelope — strictly a
|
||||
// relay-to-relay message, never rides the featherChat
|
||||
// bridge. Catch-all mapping for completeness.
|
||||
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
|
||||
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
|
||||
SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather
|
||||
SignalMessage::HardNatProbe { .. } => CallSignalType::IceCandidate, // hard NAT coordination
|
||||
SignalMessage::HardNatBirthdayStart { .. } => CallSignalType::IceCandidate, // birthday attack
|
||||
SignalMessage::UpgradeProposal { .. }
|
||||
| SignalMessage::UpgradeResponse { .. }
|
||||
| SignalMessage::UpgradeConfirm { .. }
|
||||
| SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation
|
||||
SignalMessage::PresenceList { .. } => CallSignalType::Offer, // lobby presence
|
||||
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
|
||||
SignalMessage::Nack { .. }
|
||||
| SignalMessage::PictureLossIndication { .. }
|
||||
| SignalMessage::SetPriorityMode { .. } => CallSignalType::Offer, // relay-initiated (video loss recovery)
|
||||
SignalMessage::RegisterPresence { .. }
|
||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,19 +126,15 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::QualityProfile;
|
||||
use wzp_proto::default_signal_version;
|
||||
|
||||
#[test]
|
||||
fn payload_roundtrip() {
|
||||
let signal = SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [1u8; 32],
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![QualityProfile::GOOD],
|
||||
alias: None,
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
|
||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||
@@ -177,52 +148,28 @@ mod tests {
|
||||
#[test]
|
||||
fn signal_type_mapping() {
|
||||
let offer = SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
alias: None,
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||
|
||||
let hangup = SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&hangup),
|
||||
CallSignalType::Hangup
|
||||
));
|
||||
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
||||
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Hold { version: default_signal_version() }),
|
||||
CallSignalType::Hold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unhold { version: default_signal_version() }),
|
||||
CallSignalType::Unhold
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Mute { version: default_signal_version() }),
|
||||
CallSignalType::Mute
|
||||
));
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&SignalMessage::Unmute { version: default_signal_version() }),
|
||||
CallSignalType::Unmute
|
||||
));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
|
||||
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
|
||||
|
||||
let transfer = SignalMessage::Transfer {
|
||||
version: default_signal_version(),
|
||||
target_fingerprint: "abc".to_string(),
|
||||
relay_addr: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
signal_to_call_type(&transfer),
|
||||
CallSignalType::Transfer
|
||||
));
|
||||
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,53 +4,7 @@
|
||||
//! send `CallOffer` → recv `CallAnswer` → derive shared `CryptoSession`.
|
||||
|
||||
use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_proto::{
|
||||
HangupReason, MediaTransport, QualityProfile, SignalMessage, default_signal_version,
|
||||
};
|
||||
|
||||
/// Errors that can occur during the client-side cryptographic handshake.
|
||||
#[derive(Debug)]
|
||||
pub enum HandshakeError {
|
||||
ConnectionClosed,
|
||||
ProtocolVersionMismatch { server_supported: Vec<u8> },
|
||||
UnexpectedSignal(&'static str),
|
||||
SignatureVerificationFailed,
|
||||
KeyDerivation(String),
|
||||
Transport(wzp_proto::TransportError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HandshakeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ConnectionClosed => write!(f, "connection closed before receiving CallAnswer"),
|
||||
Self::ProtocolVersionMismatch { server_supported } => {
|
||||
write!(
|
||||
f,
|
||||
"protocol version mismatch: server supports {server_supported:?}"
|
||||
)
|
||||
}
|
||||
Self::UnexpectedSignal(expected) => write!(f, "expected CallAnswer, got {expected}"),
|
||||
Self::SignatureVerificationFailed => write!(f, "callee signature verification failed"),
|
||||
Self::KeyDerivation(msg) => write!(f, "key derivation failed: {msg}"),
|
||||
Self::Transport(e) => write!(f, "transport error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HandshakeError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Transport(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<wzp_proto::TransportError> for HandshakeError {
|
||||
fn from(e: wzp_proto::TransportError) -> Self {
|
||||
Self::Transport(e)
|
||||
}
|
||||
}
|
||||
use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||
|
||||
/// Perform the client (caller) side of the cryptographic handshake.
|
||||
///
|
||||
@@ -64,7 +18,7 @@ pub async fn perform_handshake(
|
||||
transport: &dyn MediaTransport,
|
||||
seed: &[u8; 32],
|
||||
alias: Option<&str>,
|
||||
) -> Result<Box<dyn CryptoSession>, HandshakeError> {
|
||||
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||
// 1. Create key exchange from identity seed
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||
let identity_pub = kx.identity_public_key();
|
||||
@@ -80,7 +34,6 @@ pub async fn perform_handshake(
|
||||
|
||||
// 4. Send CallOffer
|
||||
let offer = SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
@@ -93,56 +46,41 @@ pub async fn perform_handshake(
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
transport
|
||||
.send_signal(&offer)
|
||||
.await
|
||||
.map_err(HandshakeError::Transport)?;
|
||||
transport.send_signal(&offer).await?;
|
||||
|
||||
// 5. Wait for CallAnswer — 10s timeout guards against relay not responding.
|
||||
let answer = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
transport.recv_signal(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| HandshakeError::Transport(wzp_proto::TransportError::Timeout { ms: 10_000 }))?
|
||||
.map_err(HandshakeError::Transport)?
|
||||
.ok_or(HandshakeError::ConnectionClosed)?;
|
||||
// 5. Wait for CallAnswer
|
||||
let answer = transport
|
||||
.recv_signal()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?;
|
||||
|
||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
|
||||
match answer {
|
||||
SignalMessage::CallAnswer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
chosen_profile,
|
||||
..
|
||||
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
|
||||
SignalMessage::Hangup {
|
||||
reason: HangupReason::ProtocolVersionMismatch { server_supported },
|
||||
..
|
||||
} => {
|
||||
return Err(HandshakeError::ProtocolVersionMismatch { server_supported });
|
||||
}
|
||||
_ => {
|
||||
return Err(HandshakeError::UnexpectedSignal("CallAnswer"));
|
||||
}
|
||||
};
|
||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) = match answer
|
||||
{
|
||||
SignalMessage::CallAnswer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
chosen_profile,
|
||||
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Verify callee's signature over (ephemeral_pub || "call-answer")
|
||||
let mut verify_data = Vec::with_capacity(32 + 11);
|
||||
verify_data.extend_from_slice(&callee_ephemeral_pub);
|
||||
verify_data.extend_from_slice(b"call-answer");
|
||||
if !WarzoneKeyExchange::verify(&callee_identity_pub, &verify_data, &callee_signature) {
|
||||
return Err(HandshakeError::SignatureVerificationFailed);
|
||||
return Err(anyhow::anyhow!("callee signature verification failed"));
|
||||
}
|
||||
|
||||
// 7. Derive session
|
||||
let session = kx
|
||||
.derive_session(&callee_ephemeral_pub)
|
||||
.map_err(|e| HandshakeError::KeyDerivation(e.to_string()))?;
|
||||
let session = kx.derive_session(&callee_ephemeral_pub)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
//! Phase 8 (Tailscale-inspired): ICE agent for candidate lifecycle
|
||||
//! management and mid-call re-gathering.
|
||||
//!
|
||||
//! The `IceAgent` owns the state of all candidate discovery
|
||||
//! mechanisms (STUN, port mapping, host candidates) and provides:
|
||||
//!
|
||||
//! - `gather()`: initial candidate gathering during call setup
|
||||
//! - `re_gather()`: triggered on network change, produces a
|
||||
//! `CandidateUpdate` to send to the peer
|
||||
//! - `apply_peer_update()`: processes peer's candidate updates
|
||||
//!
|
||||
//! This is NOT a full ICE agent (RFC 8445). It's the Tailscale-style
|
||||
//! "gather all candidates, race them all in parallel, pick the
|
||||
//! winner" approach, adapted for QUIC transport.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_proto::{SignalMessage, default_signal_version};
|
||||
|
||||
use crate::dual_path::PeerCandidates;
|
||||
use crate::portmap;
|
||||
use crate::reflect;
|
||||
use crate::stun;
|
||||
|
||||
/// All candidates gathered for the local side.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandidateSet {
|
||||
/// STUN-discovered server-reflexive address.
|
||||
pub reflexive: Option<SocketAddr>,
|
||||
/// LAN host candidates from local interfaces.
|
||||
pub local: Vec<SocketAddr>,
|
||||
/// Port-mapped address from NAT-PMP/PCP/UPnP.
|
||||
pub mapped: Option<SocketAddr>,
|
||||
/// Generation counter (monotonically increasing per call).
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
/// Configuration for the ICE agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IceAgentConfig {
|
||||
/// STUN servers to use for reflexive discovery.
|
||||
pub stun_config: stun::StunConfig,
|
||||
/// Whether to attempt port mapping.
|
||||
pub enable_portmap: bool,
|
||||
/// Timeout for each discovery mechanism.
|
||||
pub gather_timeout: Duration,
|
||||
/// The QUIC endpoint's local port (for host candidate pairing).
|
||||
pub local_v4_port: u16,
|
||||
/// Optional IPv6 port.
|
||||
pub local_v6_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl Default for IceAgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stun_config: stun::StunConfig::default(),
|
||||
enable_portmap: true,
|
||||
gather_timeout: Duration::from_secs(3),
|
||||
local_v4_port: 0,
|
||||
local_v6_port: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ICE agent managing candidate lifecycle.
|
||||
pub struct IceAgent {
|
||||
config: IceAgentConfig,
|
||||
generation: AtomicU32,
|
||||
call_id: String,
|
||||
/// Last-seen peer generation (to filter stale updates).
|
||||
peer_generation: AtomicU32,
|
||||
}
|
||||
|
||||
impl IceAgent {
|
||||
pub fn new(call_id: String, config: IceAgentConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
generation: AtomicU32::new(0),
|
||||
call_id,
|
||||
peer_generation: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial candidate gathering. Runs all discovery mechanisms
|
||||
/// in parallel and returns the full candidate set.
|
||||
pub async fn gather(&self) -> CandidateSet {
|
||||
let generation = self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Run STUN + port mapping + host candidates in parallel.
|
||||
let stun_fut = stun::discover_reflexive(&self.config.stun_config);
|
||||
let portmap_fut = async {
|
||||
if self.config.enable_portmap && self.config.local_v4_port > 0 {
|
||||
portmap::acquire_port_mapping(self.config.local_v4_port, None)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let (stun_result, portmap_result) = tokio::join!(
|
||||
tokio::time::timeout(self.config.gather_timeout, stun_fut),
|
||||
tokio::time::timeout(self.config.gather_timeout, portmap_fut),
|
||||
);
|
||||
|
||||
let reflexive = stun_result.ok().and_then(|r| r.ok());
|
||||
let mapped = portmap_result.ok().flatten().map(|m| m.external_addr);
|
||||
let local =
|
||||
reflect::local_host_candidates(self.config.local_v4_port, self.config.local_v6_port);
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
reflexive = ?reflexive,
|
||||
mapped = ?mapped,
|
||||
local_count = local.len(),
|
||||
"ice_agent: gathered candidates"
|
||||
);
|
||||
|
||||
CandidateSet {
|
||||
reflexive,
|
||||
local,
|
||||
mapped,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-gather candidates after a network change. Increments the
|
||||
/// generation counter and returns a `CandidateUpdate` signal
|
||||
/// message to send to the peer.
|
||||
pub async fn re_gather(&self) -> (CandidateSet, SignalMessage) {
|
||||
let candidates = self.gather().await;
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: self.call_id.clone(),
|
||||
reflexive_addr: candidates.reflexive.map(|a| a.to_string()),
|
||||
local_addrs: candidates.local.iter().map(|a| a.to_string()).collect(),
|
||||
mapped_addr: candidates.mapped.map(|a| a.to_string()),
|
||||
generation: candidates.generation,
|
||||
};
|
||||
|
||||
(candidates, update)
|
||||
}
|
||||
|
||||
/// Process a peer's candidate update. Returns `Some(PeerCandidates)`
|
||||
/// if the update is newer than the last-seen generation, `None`
|
||||
/// if it's stale.
|
||||
pub fn apply_peer_update(&self, update: &SignalMessage) -> Option<PeerCandidates> {
|
||||
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
|
||||
SignalMessage::CandidateUpdate {
|
||||
reflexive_addr,
|
||||
local_addrs,
|
||||
mapped_addr,
|
||||
generation,
|
||||
..
|
||||
} => (reflexive_addr, local_addrs, mapped_addr, *generation),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Only accept if newer than last-seen generation.
|
||||
let prev = self.peer_generation.fetch_max(generation, Ordering::AcqRel);
|
||||
if generation <= prev {
|
||||
tracing::debug!(
|
||||
generation,
|
||||
prev,
|
||||
"ice_agent: ignoring stale CandidateUpdate"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let reflexive = reflexive_addr.as_deref().and_then(|s| s.parse().ok());
|
||||
let local: Vec<SocketAddr> = local_addrs.iter().filter_map(|s| s.parse().ok()).collect();
|
||||
let mapped = mapped_addr.as_deref().and_then(|s| s.parse().ok());
|
||||
|
||||
tracing::info!(
|
||||
generation,
|
||||
reflexive = ?reflexive,
|
||||
mapped = ?mapped,
|
||||
local_count = local.len(),
|
||||
"ice_agent: applied peer candidate update"
|
||||
);
|
||||
|
||||
Some(PeerCandidates {
|
||||
reflexive,
|
||||
local,
|
||||
mapped,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current generation counter.
|
||||
pub fn generation(&self) -> u32 {
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_rejects_stale() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
|
||||
// First update (gen=1) should succeed.
|
||||
let update1 = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec!["192.168.1.10:4433".into()],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
let result = agent.apply_peer_update(&update1);
|
||||
assert!(result.is_some());
|
||||
let candidates = result.unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("203.0.113.5:4433".parse().unwrap())
|
||||
);
|
||||
assert_eq!(candidates.local.len(), 1);
|
||||
|
||||
// Same generation (gen=1) should be rejected.
|
||||
let update1b = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
assert!(agent.apply_peer_update(&update1b).is_none());
|
||||
|
||||
// Older generation (gen=0) should be rejected.
|
||||
let update0 = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("10.0.0.1:4433".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 0,
|
||||
};
|
||||
assert!(agent.apply_peer_update(&update0).is_none());
|
||||
|
||||
// Newer generation (gen=2) should succeed.
|
||||
let update2 = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("198.51.100.9:5555".into()),
|
||||
local_addrs: vec![],
|
||||
mapped_addr: Some("203.0.113.5:12345".into()),
|
||||
generation: 2,
|
||||
};
|
||||
let result = agent.apply_peer_update(&update2);
|
||||
assert!(result.is_some());
|
||||
let candidates = result.unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("198.51.100.9:5555".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
candidates.mapped,
|
||||
Some("203.0.113.5:12345".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_wrong_signal_returns_none() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
let wrong = SignalMessage::Reflect;
|
||||
assert!(agent.apply_peer_update(&wrong).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_increments() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
assert_eq!(agent.generation(), 0);
|
||||
// Simulate what gather() does internally
|
||||
let g1 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
||||
assert_eq!(g1, 0);
|
||||
assert_eq!(agent.generation(), 1);
|
||||
let g2 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
||||
assert_eq!(g2, 1);
|
||||
assert_eq!(agent.generation(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_parses_all_fields() {
|
||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test-call".into(),
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
|
||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert_eq!(
|
||||
candidates.reflexive,
|
||||
Some("203.0.113.5:4433".parse().unwrap())
|
||||
);
|
||||
assert_eq!(candidates.local.len(), 2);
|
||||
assert_eq!(
|
||||
candidates.local[0],
|
||||
"192.168.1.10:4433".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
candidates.mapped,
|
||||
Some("198.51.100.42:12345".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_handles_empty_fields() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test".into(),
|
||||
reflexive_addr: None,
|
||||
local_addrs: vec![],
|
||||
mapped_addr: None,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert!(candidates.reflexive.is_none());
|
||||
assert!(candidates.local.is_empty());
|
||||
assert!(candidates.mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_peer_update_skips_unparseable_addrs() {
|
||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
||||
|
||||
let update = SignalMessage::CandidateUpdate {
|
||||
version: default_signal_version(),
|
||||
call_id: "test".into(),
|
||||
reflexive_addr: Some("not-an-addr".into()),
|
||||
local_addrs: vec![
|
||||
"192.168.1.10:4433".into(),
|
||||
"garbage".into(),
|
||||
"10.0.0.5:4433".into(),
|
||||
],
|
||||
mapped_addr: Some("also-bad".into()),
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
||||
assert!(candidates.reflexive.is_none()); // unparseable
|
||||
assert_eq!(candidates.local.len(), 2); // garbage filtered
|
||||
assert!(candidates.mapped.is_none()); // unparseable
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_values() {
|
||||
let cfg = IceAgentConfig::default();
|
||||
assert!(cfg.enable_portmap);
|
||||
assert!(cfg.gather_timeout.as_secs() > 0);
|
||||
assert!(!cfg.stun_config.servers.is_empty());
|
||||
assert_eq!(cfg.local_v4_port, 0);
|
||||
assert!(cfg.local_v6_port.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gather_returns_candidates_even_with_no_stun() {
|
||||
// With default config (port 0 = no portmap, STUN will timeout
|
||||
// quickly on loopback), gather should still return host candidates.
|
||||
let agent = IceAgent::new(
|
||||
"test".into(),
|
||||
IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![], // no servers = quick failure
|
||||
timeout: Duration::from_millis(100),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(200),
|
||||
local_v4_port: 12345,
|
||||
local_v6_port: None,
|
||||
},
|
||||
);
|
||||
|
||||
let candidates = agent.gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
// Reflexive should be None (no STUN servers)
|
||||
assert!(candidates.reflexive.is_none());
|
||||
// Mapped should be None (portmap disabled)
|
||||
assert!(candidates.mapped.is_none());
|
||||
// Local candidates depend on the machine's interfaces
|
||||
// but gather() should not panic.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_gather_produces_signal_message() {
|
||||
let agent = IceAgent::new(
|
||||
"call-42".into(),
|
||||
IceAgentConfig {
|
||||
stun_config: stun::StunConfig {
|
||||
servers: vec![],
|
||||
timeout: Duration::from_millis(50),
|
||||
},
|
||||
enable_portmap: false,
|
||||
gather_timeout: Duration::from_millis(100),
|
||||
local_v4_port: 4433,
|
||||
local_v6_port: None,
|
||||
},
|
||||
);
|
||||
|
||||
let (candidates, signal) = agent.re_gather().await;
|
||||
assert_eq!(candidates.generation, 0);
|
||||
|
||||
match signal {
|
||||
SignalMessage::CandidateUpdate {
|
||||
call_id,
|
||||
generation,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "call-42");
|
||||
assert_eq!(generation, 0);
|
||||
}
|
||||
_ => panic!("expected CandidateUpdate"),
|
||||
}
|
||||
|
||||
// Second re_gather increments generation
|
||||
let (candidates2, signal2) = agent.re_gather().await;
|
||||
assert_eq!(candidates2.generation, 1);
|
||||
match signal2 {
|
||||
SignalMessage::CandidateUpdate { generation, .. } => {
|
||||
assert_eq!(generation, 1);
|
||||
}
|
||||
_ => panic!("expected CandidateUpdate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,84 +8,16 @@
|
||||
|
||||
#[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 birthday;
|
||||
pub mod call;
|
||||
pub mod encrypted_transport;
|
||||
pub mod drift_test;
|
||||
pub mod dual_path;
|
||||
pub mod echo_test;
|
||||
pub mod featherchat;
|
||||
pub mod handshake;
|
||||
pub mod ice_agent;
|
||||
pub mod metrics;
|
||||
pub mod netcheck;
|
||||
pub mod portmap;
|
||||
pub mod reflect;
|
||||
pub mod relay_map;
|
||||
pub mod stun;
|
||||
pub mod sweep;
|
||||
|
||||
// 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;
|
||||
#[cfg(feature = "audio")]
|
||||
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||
pub use handshake::perform_handshake;
|
||||
|
||||
@@ -178,10 +178,7 @@ mod tests {
|
||||
|
||||
// Immediate second write should be skipped (60s interval).
|
||||
let second = writer.maybe_write(&snap).unwrap();
|
||||
assert!(
|
||||
!second,
|
||||
"second write should be skipped — interval not elapsed"
|
||||
);
|
||||
assert!(!second, "second write should be skipped — interval not elapsed");
|
||||
|
||||
// Clean up.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
//! Phase 8 (Tailscale-inspired): Comprehensive network diagnostic.
|
||||
//!
|
||||
//! Probes STUN servers, relay infrastructure, port mapping
|
||||
//! capabilities, IPv6 reachability, and NAT hairpinning in parallel
|
||||
//! to produce a `NetcheckReport` that captures the client's network
|
||||
//! environment at a point in time.
|
||||
//!
|
||||
//! Used for:
|
||||
//! - Troubleshooting connectivity issues
|
||||
//! - Automatic relay selection (Phase 5)
|
||||
//! - Pre-call NAT assessment
|
||||
//! - Quality prediction
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::portmap::{self, PortMapProtocol};
|
||||
use crate::reflect::{self, NatType};
|
||||
use crate::stun::{self, StunConfig};
|
||||
|
||||
/// Complete network diagnostic report.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NetcheckReport {
|
||||
/// NAT type classification (from combined STUN + relay probes).
|
||||
pub nat_type: NatType,
|
||||
/// Server-reflexive address (consensus from probes).
|
||||
pub reflexive_addr: Option<String>,
|
||||
/// Whether IPv4 connectivity is available.
|
||||
pub ipv4_reachable: bool,
|
||||
/// Whether IPv6 connectivity is available.
|
||||
pub ipv6_reachable: bool,
|
||||
/// Whether the NAT supports hairpinning (loopback to own
|
||||
/// reflexive address).
|
||||
pub hairpin_works: Option<bool>,
|
||||
/// Which port mapping protocol is available (if any).
|
||||
pub port_mapping: Option<PortMapProtocol>,
|
||||
/// Per-relay latency measurements.
|
||||
pub relay_latencies: Vec<RelayLatency>,
|
||||
/// Preferred relay (lowest latency).
|
||||
pub preferred_relay: Option<String>,
|
||||
/// STUN latency to first responding server (ms).
|
||||
pub stun_latency_ms: Option<u32>,
|
||||
/// Whether UPnP is available on the gateway.
|
||||
pub upnp_available: bool,
|
||||
/// Whether PCP is available on the gateway.
|
||||
pub pcp_available: bool,
|
||||
/// Whether NAT-PMP is available on the gateway.
|
||||
pub nat_pmp_available: bool,
|
||||
/// Default gateway address.
|
||||
pub gateway: Option<String>,
|
||||
/// Total time taken for the diagnostic (ms).
|
||||
pub duration_ms: u32,
|
||||
/// Individual STUN probe results.
|
||||
pub stun_probes: Vec<reflect::NatProbeResult>,
|
||||
/// NAT port allocation pattern (sequential vs random).
|
||||
pub port_allocation: Option<stun::PortAllocation>,
|
||||
}
|
||||
|
||||
/// Latency to a specific relay.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayLatency {
|
||||
pub name: String,
|
||||
pub addr: String,
|
||||
pub rtt_ms: Option<u32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for the netcheck run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetcheckConfig {
|
||||
/// STUN servers to probe.
|
||||
pub stun_config: StunConfig,
|
||||
/// Relay servers to probe (name, address pairs).
|
||||
pub relays: Vec<(String, SocketAddr)>,
|
||||
/// Per-probe timeout.
|
||||
pub timeout: Duration,
|
||||
/// Whether to test port mapping.
|
||||
pub test_portmap: bool,
|
||||
/// Whether to test IPv6.
|
||||
pub test_ipv6: bool,
|
||||
/// Local port for port mapping test (0 = skip).
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
impl Default for NetcheckConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stun_config: StunConfig::default(),
|
||||
relays: Vec::new(),
|
||||
timeout: Duration::from_secs(5),
|
||||
test_portmap: true,
|
||||
test_ipv6: true,
|
||||
local_port: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a comprehensive network diagnostic.
|
||||
///
|
||||
/// Probes run in parallel for speed — the total time is bounded
|
||||
/// by the slowest individual probe, not the sum.
|
||||
pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
||||
let start = Instant::now();
|
||||
|
||||
// Run all probes in parallel.
|
||||
let stun_fut = stun::probe_stun_servers(&config.stun_config);
|
||||
let relay_fut = probe_relays(&config.relays, config.timeout);
|
||||
let portmap_fut = probe_portmap(config.test_portmap, config.local_port);
|
||||
let gateway_fut = portmap::default_gateway();
|
||||
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
|
||||
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
|
||||
|
||||
let (
|
||||
stun_probes,
|
||||
relay_latencies,
|
||||
portmap_result,
|
||||
gateway_result,
|
||||
ipv6_reachable,
|
||||
port_alloc_result,
|
||||
) = tokio::join!(
|
||||
stun_fut,
|
||||
relay_fut,
|
||||
portmap_fut,
|
||||
gateway_result_fut(gateway_fut),
|
||||
ipv6_fut,
|
||||
port_alloc_fut
|
||||
);
|
||||
|
||||
// Classify NAT from STUN probes.
|
||||
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
|
||||
|
||||
// Determine STUN latency (first successful probe).
|
||||
let stun_latency_ms = stun_probes.iter().filter_map(|p| p.latency_ms).min();
|
||||
|
||||
// IPv4 reachable if any STUN probe succeeded.
|
||||
let ipv4_reachable = stun_probes.iter().any(|p| p.observed_addr.is_some());
|
||||
|
||||
// Preferred relay = lowest RTT.
|
||||
let preferred_relay = relay_latencies
|
||||
.iter()
|
||||
.filter_map(|r| r.rtt_ms.map(|rtt| (r.name.clone(), rtt)))
|
||||
.min_by_key(|(_, rtt)| *rtt)
|
||||
.map(|(name, _)| name);
|
||||
|
||||
// Port mapping availability.
|
||||
let (port_mapping, nat_pmp_available, pcp_available, upnp_available) = match portmap_result {
|
||||
Some(mapping) => {
|
||||
let proto = mapping.protocol;
|
||||
(
|
||||
Some(proto),
|
||||
proto == PortMapProtocol::NatPmp,
|
||||
proto == PortMapProtocol::Pcp,
|
||||
proto == PortMapProtocol::UPnP,
|
||||
)
|
||||
}
|
||||
None => (None, false, false, false),
|
||||
};
|
||||
|
||||
let gateway = match gateway_result {
|
||||
Ok(gw) => Some(gw.to_string()),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
NetcheckReport {
|
||||
nat_type,
|
||||
reflexive_addr: consensus_addr,
|
||||
ipv4_reachable,
|
||||
ipv6_reachable,
|
||||
hairpin_works: None, // TODO: implement hairpin test
|
||||
port_mapping,
|
||||
relay_latencies,
|
||||
preferred_relay,
|
||||
stun_latency_ms,
|
||||
upnp_available,
|
||||
pcp_available,
|
||||
nat_pmp_available,
|
||||
gateway,
|
||||
duration_ms: start.elapsed().as_millis() as u32,
|
||||
stun_probes,
|
||||
port_allocation: Some(port_alloc_result.allocation),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe relay latencies via reflect.
|
||||
async fn probe_relays(relays: &[(String, SocketAddr)], timeout: Duration) -> Vec<RelayLatency> {
|
||||
if relays.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let timeout_ms = timeout.as_millis() as u64;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
|
||||
for (name, addr) in relays {
|
||||
let name = name.clone();
|
||||
let addr = *addr;
|
||||
set.spawn(async move {
|
||||
let start = Instant::now();
|
||||
match reflect::probe_reflect_addr(addr, timeout_ms, None).await {
|
||||
Ok((_observed, _latency)) => RelayLatency {
|
||||
name,
|
||||
addr: addr.to_string(),
|
||||
rtt_ms: Some(start.elapsed().as_millis() as u32),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => RelayLatency {
|
||||
name,
|
||||
addr: addr.to_string(),
|
||||
rtt_ms: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(relays.len());
|
||||
while let Some(join_result) = set.join_next().await {
|
||||
match join_result {
|
||||
Ok(r) => results.push(r),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by RTT (lowest first).
|
||||
results.sort_by_key(|r| r.rtt_ms.unwrap_or(u32::MAX));
|
||||
results
|
||||
}
|
||||
|
||||
/// Attempt port mapping and return the mapping if successful.
|
||||
async fn probe_portmap(enabled: bool, local_port: u16) -> Option<portmap::PortMapping> {
|
||||
if !enabled || local_port == 0 {
|
||||
return None;
|
||||
}
|
||||
portmap::acquire_port_mapping(local_port, None).await.ok()
|
||||
}
|
||||
|
||||
/// Wrap the gateway future to handle the Result.
|
||||
async fn gateway_result_fut(
|
||||
fut: impl std::future::Future<Output = Result<std::net::Ipv4Addr, portmap::PortMapError>>,
|
||||
) -> Result<std::net::Ipv4Addr, portmap::PortMapError> {
|
||||
fut.await
|
||||
}
|
||||
|
||||
/// Test IPv6 connectivity by attempting to bind and send on an IPv6 socket.
|
||||
async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
|
||||
if !enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to resolve and connect to an IPv6 STUN server.
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
|
||||
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
|
||||
// and we can send a packet, IPv6 is working.
|
||||
let addr = stun::resolve_stun_server("stun.l.google.com:19302")
|
||||
.await
|
||||
.ok()?;
|
||||
if addr.is_ipv6() {
|
||||
sock.send_to(&[0u8; 1], addr).await.ok()?;
|
||||
Some(true)
|
||||
} else {
|
||||
// Server resolved to IPv4 — try binding to [::] at least
|
||||
Some(false)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(true)) => true,
|
||||
_ => {
|
||||
// Fallback: can we at least bind an IPv6 socket?
|
||||
tokio::net::UdpSocket::bind("[::]:0").await.is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a netcheck report as a human-readable string.
|
||||
pub fn format_report(report: &NetcheckReport) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
|
||||
out.push_str(&format!("NAT Type: {:?}\n", report.nat_type));
|
||||
out.push_str(&format!(
|
||||
"Reflexive Addr: {}\n",
|
||||
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"IPv4: {}\n",
|
||||
if report.ipv4_reachable { "yes" } else { "no" }
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"IPv6: {}\n",
|
||||
if report.ipv6_reachable { "yes" } else { "no" }
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Gateway: {}\n",
|
||||
report.gateway.as_deref().unwrap_or("(unknown)")
|
||||
));
|
||||
|
||||
if let Some(ref alloc) = report.port_allocation {
|
||||
out.push_str(&format!("Port Alloc: {alloc}\n"));
|
||||
}
|
||||
|
||||
out.push_str(&format!("\n--- Port Mapping ---\n"));
|
||||
out.push_str(&format!(
|
||||
"NAT-PMP: {} PCP: {} UPnP: {}\n",
|
||||
if report.nat_pmp_available {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
},
|
||||
if report.pcp_available { "yes" } else { "no" },
|
||||
if report.upnp_available { "yes" } else { "no" },
|
||||
));
|
||||
if let Some(proto) = &report.port_mapping {
|
||||
out.push_str(&format!("Active mapping: {:?}\n", proto));
|
||||
}
|
||||
|
||||
if !report.stun_probes.is_empty() {
|
||||
out.push_str(&format!("\n--- STUN Probes ---\n"));
|
||||
for p in &report.stun_probes {
|
||||
out.push_str(&format!(
|
||||
" {} → {} ({}ms){}\n",
|
||||
p.relay_name,
|
||||
p.observed_addr.as_deref().unwrap_or("failed"),
|
||||
p.latency_ms
|
||||
.map(|ms| ms.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
p.error
|
||||
.as_ref()
|
||||
.map(|e| format!(" [{e}]"))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !report.relay_latencies.is_empty() {
|
||||
out.push_str(&format!("\n--- Relay Latencies ---\n"));
|
||||
for r in &report.relay_latencies {
|
||||
out.push_str(&format!(
|
||||
" {} ({}) → {}ms{}\n",
|
||||
r.name,
|
||||
r.addr,
|
||||
r.rtt_ms
|
||||
.map(|ms| ms.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
r.error
|
||||
.as_ref()
|
||||
.map(|e| format!(" [{e}]"))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if let Some(ref pref) = report.preferred_relay {
|
||||
out.push_str(&format!(" Preferred: {pref}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(&format!("\nCompleted in {}ms\n", report.duration_ms));
|
||||
out
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_stun_servers() {
|
||||
let config = NetcheckConfig::default();
|
||||
assert!(!config.stun_config.servers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_produces_output() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Cone,
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: None,
|
||||
relay_latencies: vec![RelayLatency {
|
||||
name: "relay-1".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(25),
|
||||
error: None,
|
||||
}],
|
||||
preferred_relay: Some("relay-1".into()),
|
||||
stun_latency_ms: Some(15),
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: Some("192.168.1.1".into()),
|
||||
duration_ms: 1500,
|
||||
stun_probes: vec![],
|
||||
port_allocation: None,
|
||||
};
|
||||
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("Cone"));
|
||||
assert!(text.contains("203.0.113.5:4433"));
|
||||
assert!(text.contains("relay-1"));
|
||||
assert!(text.contains("1500ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_serializes_to_json() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Cone,
|
||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: Some(PortMapProtocol::NatPmp),
|
||||
relay_latencies: vec![],
|
||||
preferred_relay: None,
|
||||
stun_latency_ms: Some(25),
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: true,
|
||||
gateway: Some("192.168.1.1".into()),
|
||||
duration_ms: 500,
|
||||
stun_probes: vec![],
|
||||
port_allocation: Some(stun::PortAllocation::Sequential { delta: 1 }),
|
||||
};
|
||||
let json = serde_json::to_string(&report).unwrap();
|
||||
assert!(json.contains("Cone"));
|
||||
assert!(json.contains("203.0.113.5:4433"));
|
||||
assert!(json.contains("NatPmp"));
|
||||
|
||||
// Roundtrip
|
||||
let decoded: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded["ipv4_reachable"], true);
|
||||
assert_eq!(decoded["ipv6_reachable"], false);
|
||||
assert_eq!(decoded["stun_latency_ms"], 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_latency_serializes() {
|
||||
let lat = RelayLatency {
|
||||
name: "eu-west".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(42),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&lat).unwrap();
|
||||
assert!(json.contains("eu-west"));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_empty_relays() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::Unknown,
|
||||
reflexive_addr: None,
|
||||
ipv4_reachable: false,
|
||||
ipv6_reachable: false,
|
||||
hairpin_works: None,
|
||||
port_mapping: None,
|
||||
relay_latencies: vec![],
|
||||
preferred_relay: None,
|
||||
stun_latency_ms: None,
|
||||
upnp_available: false,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: None,
|
||||
duration_ms: 100,
|
||||
stun_probes: vec![],
|
||||
port_allocation: None,
|
||||
};
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("Unknown"));
|
||||
assert!(text.contains("(unknown)")); // reflexive addr
|
||||
assert!(text.contains("100ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_report_with_stun_probes() {
|
||||
let report = NetcheckReport {
|
||||
nat_type: NatType::SymmetricPort,
|
||||
reflexive_addr: None,
|
||||
ipv4_reachable: true,
|
||||
ipv6_reachable: true,
|
||||
hairpin_works: Some(false),
|
||||
port_mapping: Some(PortMapProtocol::UPnP),
|
||||
relay_latencies: vec![
|
||||
RelayLatency {
|
||||
name: "us-east".into(),
|
||||
addr: "10.0.0.1:4433".into(),
|
||||
rtt_ms: Some(15),
|
||||
error: None,
|
||||
},
|
||||
RelayLatency {
|
||||
name: "eu-west".into(),
|
||||
addr: "10.0.0.2:4433".into(),
|
||||
rtt_ms: None,
|
||||
error: Some("timeout".into()),
|
||||
},
|
||||
],
|
||||
preferred_relay: Some("us-east".into()),
|
||||
stun_latency_ms: Some(20),
|
||||
upnp_available: true,
|
||||
pcp_available: false,
|
||||
nat_pmp_available: false,
|
||||
gateway: Some("192.168.0.1".into()),
|
||||
duration_ms: 3000,
|
||||
stun_probes: vec![reflect::NatProbeResult {
|
||||
relay_name: "stun:google".into(),
|
||||
relay_addr: "74.125.250.129:19302".into(),
|
||||
observed_addr: Some("203.0.113.5:12345".into()),
|
||||
latency_ms: Some(20),
|
||||
error: None,
|
||||
}],
|
||||
port_allocation: Some(stun::PortAllocation::Random),
|
||||
};
|
||||
let text = format_report(&report);
|
||||
assert!(text.contains("SymmetricPort"));
|
||||
assert!(text.contains("us-east"));
|
||||
assert!(text.contains("eu-west"));
|
||||
assert!(text.contains("Preferred: us-east"));
|
||||
assert!(text.contains("UPnP: yes"));
|
||||
assert!(text.contains("stun:google"));
|
||||
assert!(text.contains("3000ms"));
|
||||
}
|
||||
|
||||
/// Integration test: run actual netcheck (requires network).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn integration_netcheck() {
|
||||
let config = NetcheckConfig::default();
|
||||
let report = run_netcheck(&config).await;
|
||||
println!("{}", format_report(&report));
|
||||
assert!(report.duration_ms > 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,704 +0,0 @@
|
||||
//! Multi-relay NAT reflection ("STUN for QUIC" — Phase 2).
|
||||
//!
|
||||
//! Phase 1 (`SignalMessage::Reflect` / `ReflectResponse`) lets a
|
||||
//! client ask a single relay "what source address do you see for
|
||||
//! me?". Phase 2 queries N relays in parallel and classifies the
|
||||
//! results into a NAT type so the future P2P hole-punching path
|
||||
//! can decide whether a direct QUIC handshake is viable:
|
||||
//!
|
||||
//! - All relays return the same `(ip, port)` → **Cone NAT**.
|
||||
//! Endpoint-independent mapping, P2P hole-punching viable,
|
||||
//! `consensus_addr` is the one address to advertise.
|
||||
//! - Same ip, different ports → **Symmetric port-dependent NAT**.
|
||||
//! The mapping changes per destination, so the advertised addr
|
||||
//! wouldn't match what a peer actually sees; fall back to
|
||||
//! relay-mediated path.
|
||||
//! - Different ips → multi-homed / anycast / broken DNS, treat as
|
||||
//! `Multiple` and do not attempt P2P.
|
||||
//! - 0 or 1 successful probes → `Unknown`, not enough data.
|
||||
//!
|
||||
//! A probe is a throwaway QUIC signal connection: open endpoint,
|
||||
//! connect, RegisterPresence (with a zero identity — the relay
|
||||
//! accepts this exactly like the main signaling path does), send
|
||||
//! Reflect, read ReflectResponse, close. Each probe gets its own
|
||||
//! ephemeral quinn::Endpoint so the OS assigns a fresh source port
|
||||
//! per relay — if we shared one endpoint across probes, a
|
||||
//! symmetric NAT in front of the client would map every probe to
|
||||
//! the same port and we couldn't detect it.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use wzp_proto::{MediaTransport, SignalMessage, default_signal_version};
|
||||
use wzp_transport::{QuinnTransport, client_config, create_endpoint};
|
||||
|
||||
/// Result of one probe against one relay. Always returned so the
|
||||
/// UI can render per-relay status even when some fail.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NatProbeResult {
|
||||
pub relay_name: String,
|
||||
pub relay_addr: String,
|
||||
/// `Some` on successful probe, `None` on failure.
|
||||
pub observed_addr: Option<String>,
|
||||
/// End-to-end wall-clock from connect start to ReflectResponse
|
||||
/// received, in milliseconds. `Some` only on success.
|
||||
pub latency_ms: Option<u32>,
|
||||
/// Human-readable error on failure.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Aggregated classification over N `NatProbeResult`s.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NatDetection {
|
||||
pub probes: Vec<NatProbeResult>,
|
||||
pub nat_type: NatType,
|
||||
/// When `nat_type == Cone`, the one address all probes agreed
|
||||
/// on. `None` for every other case.
|
||||
pub consensus_addr: Option<String>,
|
||||
}
|
||||
|
||||
/// NAT classification. See module doc for semantics.
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
pub enum NatType {
|
||||
Cone,
|
||||
SymmetricPort,
|
||||
Multiple,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Probe a single relay with a QUIC connection.
|
||||
///
|
||||
/// # Endpoint reuse (Phase 5 — Nebula-style architecture)
|
||||
///
|
||||
/// If `existing_endpoint` is `Some`, the probe uses that socket
|
||||
/// instead of creating a fresh one. This is the desired mode in
|
||||
/// production: a port-preserving NAT (MikroTik masquerade, most
|
||||
/// consumer routers) gives a **stable** external port for the
|
||||
/// one socket, so the reflex addr observed by ANY relay is the
|
||||
/// SAME addr and matches what a peer would see on a direct dial.
|
||||
/// Pass the signal endpoint here.
|
||||
///
|
||||
/// If `None`, creates a fresh one-shot endpoint. Kept for:
|
||||
/// - tests that spin up isolated probes
|
||||
/// - the "I'm not registered yet" case where there's no signal
|
||||
/// endpoint to reuse
|
||||
///
|
||||
/// NOTE on NAT-type detection: the pre-Phase-5 behavior of
|
||||
/// forcing a fresh endpoint per probe was wrong — it made every
|
||||
/// port-preserving NAT look symmetric because the classifier saw
|
||||
/// a different external port for each fresh source port. With
|
||||
/// one shared socket, the classifier reflects the REAL NAT
|
||||
/// behavior.
|
||||
pub async fn probe_reflect_addr(
|
||||
relay: SocketAddr,
|
||||
timeout_ms: u64,
|
||||
existing_endpoint: Option<wzp_transport::Endpoint>,
|
||||
) -> Result<(SocketAddr, u32), String> {
|
||||
// Install rustls provider idempotently — a second install on the
|
||||
// same thread is a no-op.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let endpoint = match existing_endpoint {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))?
|
||||
}
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let probe = async {
|
||||
// Open the signal connection.
|
||||
let conn = wzp_transport::connect(&endpoint, relay, "_signal", client_config())
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
let transport = QuinnTransport::new(conn);
|
||||
|
||||
// The relay signal handler waits for a RegisterPresence
|
||||
// before entering its main dispatch loop (see
|
||||
// wzp-relay/src/main.rs). So a transient probe has to
|
||||
// register with a zero identity first — the relay accepts
|
||||
// the empty-signature form exactly as the main signaling
|
||||
// path does in desktop/src-tauri/src/lib.rs register_signal.
|
||||
transport
|
||||
.send_signal(&SignalMessage::RegisterPresence {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [0u8; 32],
|
||||
signature: vec![],
|
||||
alias: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("send RegisterPresence: {e}"))?;
|
||||
// Drain the RegisterPresenceAck so the response to our
|
||||
// Reflect doesn't land on an unexpected stream order.
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {}
|
||||
Ok(Some(other)) => {
|
||||
return Err(format!(
|
||||
"unexpected pre-reflect signal: {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
));
|
||||
}
|
||||
Ok(None) => return Err("connection closed before RegisterPresenceAck".into()),
|
||||
Err(e) => return Err(format!("recv RegisterPresenceAck: {e}")),
|
||||
}
|
||||
|
||||
// Send Reflect and await response.
|
||||
transport
|
||||
.send_signal(&SignalMessage::Reflect)
|
||||
.await
|
||||
.map_err(|e| format!("send Reflect: {e}"))?;
|
||||
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::ReflectResponse { observed_addr, .. })) => {
|
||||
let parsed: SocketAddr = observed_addr
|
||||
.parse()
|
||||
.map_err(|e| format!("parse observed_addr {observed_addr:?}: {e}"))?;
|
||||
let latency_ms = start.elapsed().as_millis() as u32;
|
||||
|
||||
// Clean close so the relay's per-connection cleanup
|
||||
// runs promptly and we don't leak file descriptors.
|
||||
let _ = transport.close().await;
|
||||
|
||||
Ok((parsed, latency_ms))
|
||||
}
|
||||
Ok(Some(other)) => Err(format!(
|
||||
"expected ReflectResponse, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
)),
|
||||
Ok(None) => Err("connection closed before ReflectResponse".into()),
|
||||
Err(e) => Err(format!("recv ReflectResponse: {e}")),
|
||||
}
|
||||
};
|
||||
|
||||
let out = tokio::time::timeout(Duration::from_millis(timeout_ms), probe)
|
||||
.await
|
||||
.map_err(|_| format!("probe timeout ({timeout_ms}ms)"))??;
|
||||
|
||||
// `endpoint` is a quinn::Endpoint clone — an Arc under the
|
||||
// hood. Letting it drop at end-of-scope is correct whether it
|
||||
// was fresh (last ref → socket closes) or shared (ref count
|
||||
// decrements, socket stays alive for the signal loop).
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Detect the client's NAT type by probing N relays in parallel and
|
||||
/// classifying the returned addresses. Never errors — failing
|
||||
/// probes surface via `NatProbeResult.error`; aggregate is always
|
||||
/// returned.
|
||||
///
|
||||
/// # Endpoint reuse (Phase 5)
|
||||
///
|
||||
/// If `shared_endpoint` is `Some`, every probe reuses it. This is
|
||||
/// the PRODUCTION behavior: all probes source from the same UDP
|
||||
/// port, so port-preserving NATs map them to the same external
|
||||
/// port, and the classifier reflects the real NAT type. Pass the
|
||||
/// signal endpoint.
|
||||
///
|
||||
/// If `None`, each probe creates its own fresh endpoint — useful
|
||||
/// in tests that don't have a signal endpoint, but produces
|
||||
/// spurious `SymmetricPort` classifications against NATs that
|
||||
/// would otherwise look cone-like.
|
||||
pub async fn detect_nat_type(
|
||||
relays: Vec<(String, SocketAddr)>,
|
||||
timeout_ms: u64,
|
||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
||||
) -> NatDetection {
|
||||
// Parallel probes via tokio::task::JoinSet so the wall-clock is
|
||||
// bounded by the slowest probe, not the sum. JoinSet keeps the
|
||||
// dep surface at just tokio — we already depend on it.
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for (name, addr) in relays {
|
||||
let ep = shared_endpoint.clone();
|
||||
set.spawn(async move {
|
||||
let result = probe_reflect_addr(addr, timeout_ms, ep).await;
|
||||
(name, addr, result)
|
||||
});
|
||||
}
|
||||
|
||||
let mut probes = Vec::new();
|
||||
while let Some(join_result) = set.join_next().await {
|
||||
let (name, addr, result) = match join_result {
|
||||
Ok(tuple) => tuple,
|
||||
// Task panicked — surface as a synthetic failed probe so
|
||||
// the aggregate still returns a reasonable shape. This
|
||||
// shouldn't happen but we don't want one bad probe to
|
||||
// poison the whole detection.
|
||||
Err(join_err) => {
|
||||
probes.push(NatProbeResult {
|
||||
relay_name: "<panicked>".into(),
|
||||
relay_addr: "unknown".into(),
|
||||
observed_addr: None,
|
||||
latency_ms: None,
|
||||
error: Some(format!("probe task panicked: {join_err}")),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
probes.push(match result {
|
||||
Ok((observed, latency_ms)) => NatProbeResult {
|
||||
relay_name: name,
|
||||
relay_addr: addr.to_string(),
|
||||
observed_addr: Some(observed.to_string()),
|
||||
latency_ms: Some(latency_ms),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => NatProbeResult {
|
||||
relay_name: name,
|
||||
relay_addr: addr.to_string(),
|
||||
observed_addr: None,
|
||||
latency_ms: None,
|
||||
error: Some(e),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let (nat_type, consensus_addr) = classify_nat(&probes);
|
||||
NatDetection {
|
||||
probes,
|
||||
nat_type,
|
||||
consensus_addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate LAN-local host candidates this client is reachable
|
||||
/// on, paired with the given port (typically the signal
|
||||
/// endpoint's bound port so that incoming dials land on the same
|
||||
/// socket the advertised reflex addr points to).
|
||||
///
|
||||
/// Gathers BOTH IPv4 and IPv6 candidates:
|
||||
///
|
||||
/// - **IPv4**: RFC1918 private ranges (10/8, 172.16/12, 192.168/16)
|
||||
/// and CGNAT shared-transition (100.64/10). Public IPv4 is
|
||||
/// skipped because the reflex-addr path already covers it.
|
||||
/// Loopback and link-local (169.254/16) are skipped.
|
||||
///
|
||||
/// - **IPv6**: ALL global-unicast addresses (2000::/3 — the real
|
||||
/// routable IPv6 space) AND unique-local (fc00::/7). These
|
||||
/// are directly dialable from a peer on the same LAN, and on
|
||||
/// true dual-stack LANs (which most consumer ISPs now provide,
|
||||
/// including Starlink) IPv6 often gives a direct path even
|
||||
/// when IPv4 can't hairpin. Loopback (::1), unspecified (::),
|
||||
/// and link-local (fe80::/10) are skipped — link-local would
|
||||
/// require a scope ID to be useful and is basically never
|
||||
/// reachable across interface boundaries.
|
||||
///
|
||||
/// The port must come from the caller — typically
|
||||
/// `signal_endpoint.local_addr()?.port()`, so that the peer's
|
||||
/// dials to these addresses land on the same socket that's
|
||||
/// already listening (Phase 5 shared-endpoint architecture).
|
||||
///
|
||||
/// Safe to call from any thread; no I/O, no async. The `if-addrs`
|
||||
/// crate reads the kernel's interface table via a single
|
||||
/// getifaddrs(3) syscall.
|
||||
pub fn local_host_candidates(v4_port: u16, v6_port: Option<u16>) -> Vec<SocketAddr> {
|
||||
let Ok(ifaces) = if_addrs::get_if_addrs() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for iface in ifaces {
|
||||
if iface.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
match iface.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
if v4.is_link_local() {
|
||||
continue;
|
||||
}
|
||||
// Keep RFC1918 private ranges and CGNAT — those
|
||||
// are the LAN-dialable addrs we actually want.
|
||||
// Skip public v4 because the reflex addr already
|
||||
// covers that path.
|
||||
if v4.is_private() {
|
||||
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
|
||||
} else if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40 {
|
||||
// 100.64/10 CGNAT — rare but valid if two
|
||||
// phones are on the same CGNAT-hairpinned
|
||||
// carrier LAN (some hotspot setups).
|
||||
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
// Phase 7: IPv6 host candidates via dedicated
|
||||
// IPv6 socket. When v6_port is None, no IPv6
|
||||
// endpoint exists — skip silently.
|
||||
let Some(port) = v6_port else { continue };
|
||||
if v6.is_loopback() || v6.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
// fe80::/10 link-local — needs scope ID, not
|
||||
// routable across interfaces.
|
||||
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
|
||||
continue;
|
||||
}
|
||||
// Accept global unicast (2000::/3) and
|
||||
// unique-local (fc00::/7).
|
||||
let first_seg = v6.segments()[0];
|
||||
let is_global = (first_seg & 0xe000) == 0x2000;
|
||||
let is_ula = (first_seg & 0xfe00) == 0xfc00;
|
||||
if is_global || is_ula {
|
||||
out.push(SocketAddr::new(std::net::IpAddr::V6(v6), port));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Role assignment for the Phase 3.5 dual-path QUIC race.
|
||||
///
|
||||
/// Both peers already know two strings at CallSetup time: their
|
||||
/// own server-reflexive address (queried via Phase 1 Reflect) and
|
||||
/// the peer's (carried in `CallSetup.peer_direct_addr`). To avoid
|
||||
/// a negotiation round-trip, both sides compare the two strings
|
||||
/// lexicographically and agree on a deterministic role:
|
||||
///
|
||||
/// - **Acceptor** — lexicographically smaller addr. Listens for
|
||||
/// an incoming direct connection from the peer. Does NOT dial.
|
||||
/// - **Dialer** — lexicographically larger addr. Dials the
|
||||
/// peer's direct addr. Does NOT listen.
|
||||
///
|
||||
/// Both roles ALSO dial the relay in parallel as a fallback.
|
||||
/// Whichever future (direct or relay) completes first is used as
|
||||
/// the media transport. Because the role is deterministic and
|
||||
/// symmetric, both peers end up holding the same underlying QUIC
|
||||
/// session on the direct path — A's accepted conn and D's dialed
|
||||
/// conn are literally the same connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// This peer listens for the direct incoming connection.
|
||||
Acceptor,
|
||||
/// This peer dials the peer's direct address.
|
||||
Dialer,
|
||||
}
|
||||
|
||||
/// Compute the deterministic role for this peer in the dual-path
|
||||
/// race. Returns `None` when no direct attempt is possible —
|
||||
/// either peer didn't advertise a reflex addr, or the two addrs
|
||||
/// are identical (same host on loopback / mis-advertised).
|
||||
///
|
||||
/// The caller should treat `None` as "skip direct, relay-only".
|
||||
pub fn determine_role(
|
||||
own_reflex_addr: Option<&str>,
|
||||
peer_reflex_addr: Option<&str>,
|
||||
) -> Option<Role> {
|
||||
let (own, peer) = match (own_reflex_addr, peer_reflex_addr) {
|
||||
(Some(o), Some(p)) => (o, p),
|
||||
_ => return None,
|
||||
};
|
||||
match own.cmp(peer) {
|
||||
std::cmp::Ordering::Less => Some(Role::Acceptor),
|
||||
std::cmp::Ordering::Greater => Some(Role::Dialer),
|
||||
// Equal addrs should never happen in production (both
|
||||
// peers behind the same NAT mapping + same port would be
|
||||
// a degenerate case). Guard against it so we don't infinite-
|
||||
// loop waiting for a connection to ourselves.
|
||||
std::cmp::Ordering::Equal => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the address is in an RFC1918 / link-local /
|
||||
/// loopback range and therefore cannot possibly be a post-NAT
|
||||
/// reflex address from the public internet's point of view.
|
||||
///
|
||||
/// A probe against a relay ON THE SAME LAN as the client will
|
||||
/// naturally report the client's LAN IP back (because there's no
|
||||
/// NAT between them) — that observation is real but says nothing
|
||||
/// about the client's public-internet-facing NAT state. Mixing
|
||||
/// LAN reflex addrs with public-internet reflex addrs in
|
||||
/// `classify_nat` would always report `Multiple` (different IPs)
|
||||
/// and falsely warn about symmetric NAT. Filter them out before
|
||||
/// classifying.
|
||||
fn is_private_or_loopback(addr: &SocketAddr) -> bool {
|
||||
match addr.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
v4.is_loopback()
|
||||
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|
||||
|| v4.is_link_local() // 169.254/16
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40) // 100.64/10 CGNAT shared
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-function NAT classifier — split out for unit testing
|
||||
/// without touching the network.
|
||||
///
|
||||
/// Only considers probes whose reflex addr is a **public-internet**
|
||||
/// address. LAN / private / loopback reflex addrs are dropped
|
||||
/// because they reflect the same-network path rather than the
|
||||
/// real NAT state. CGNAT (100.64/10) is also treated as private
|
||||
/// because the post-CGNAT address would be what we actually want
|
||||
/// to classify on — but CGNAT is unreachable from outside the
|
||||
/// carrier, so a relay seeing the CGNAT addr is on the same
|
||||
/// carrier network and again not useful for classification.
|
||||
pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) {
|
||||
// First: parse every successful probe's observed addr.
|
||||
let parsed: Vec<SocketAddr> = probes
|
||||
.iter()
|
||||
.filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok()))
|
||||
.collect();
|
||||
|
||||
// Then: drop LAN / private / loopback reflex addrs. Those are
|
||||
// legitimate observations by same-network relays, but they
|
||||
// don't contribute to NAT-type classification because the
|
||||
// client's real public-facing NAT mapping is not involved on
|
||||
// that path. A relay on the same LAN always sees the client's
|
||||
// LAN IP, regardless of whether the NAT beyond it is cone or
|
||||
// symmetric.
|
||||
let successes: Vec<SocketAddr> = parsed
|
||||
.into_iter()
|
||||
.filter(|a| !is_private_or_loopback(a))
|
||||
.collect();
|
||||
|
||||
if successes.len() < 2 {
|
||||
return (NatType::Unknown, None);
|
||||
}
|
||||
|
||||
let first = successes[0];
|
||||
let same_ip = successes.iter().all(|a| a.ip() == first.ip());
|
||||
if !same_ip {
|
||||
return (NatType::Multiple, None);
|
||||
}
|
||||
|
||||
let same_port = successes.iter().all(|a| a.port() == first.port());
|
||||
if same_port {
|
||||
(NatType::Cone, Some(first.to_string()))
|
||||
} else {
|
||||
(NatType::SymmetricPort, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced NAT detection that combines relay-based reflection with
|
||||
/// public STUN server probes for more robust classification.
|
||||
///
|
||||
/// Runs both probe sets concurrently:
|
||||
/// 1. Relay probes via `detect_nat_type` (existing behavior)
|
||||
/// 2. Public STUN probes via `probe_stun_servers`
|
||||
///
|
||||
/// Merges all results and classifies. More probes = higher confidence
|
||||
/// in the NAT type classification. Falls back gracefully: if STUN
|
||||
/// servers are unreachable, relay probes still work (and vice versa).
|
||||
pub async fn detect_nat_type_with_stun(
|
||||
relays: Vec<(String, SocketAddr)>,
|
||||
timeout_ms: u64,
|
||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
||||
stun_config: &crate::stun::StunConfig,
|
||||
) -> NatDetection {
|
||||
// Run relay probes and STUN probes concurrently.
|
||||
let relay_fut = detect_nat_type(relays, timeout_ms, shared_endpoint);
|
||||
let stun_fut = crate::stun::probe_stun_servers(stun_config);
|
||||
|
||||
let (relay_detection, stun_probes) = tokio::join!(relay_fut, stun_fut);
|
||||
|
||||
// Merge all probes and re-classify.
|
||||
let mut all_probes = relay_detection.probes;
|
||||
all_probes.extend(stun_probes);
|
||||
|
||||
let (nat_type, consensus_addr) = classify_nat(&all_probes);
|
||||
NatDetection {
|
||||
probes: all_probes,
|
||||
nat_type,
|
||||
consensus_addr,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests for the pure classifier ───────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk(addr: Option<&str>) -> NatProbeResult {
|
||||
NatProbeResult {
|
||||
relay_name: "test".into(),
|
||||
relay_addr: "0.0.0.0:0".into(),
|
||||
observed_addr: addr.map(|s| s.to_string()),
|
||||
latency_ms: addr.map(|_| 10),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_empty_is_unknown() {
|
||||
let (nt, addr) = classify_nat(&[]);
|
||||
assert_eq!(nt, NatType::Unknown);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_single_success_is_unknown() {
|
||||
let probes = vec![mk(Some("192.0.2.1:4433"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Unknown);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_two_identical_is_cone() {
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:4433"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Cone);
|
||||
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_same_ip_different_ports_is_symmetric() {
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:51234"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::SymmetricPort);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_different_ips_is_multiple() {
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("198.51.100.9:4433"))];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Multiple);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_drops_private_ip_probes() {
|
||||
// One LAN probe + one public probe should behave like a
|
||||
// single public probe — i.e. Unknown (not enough data to
|
||||
// classify). This is the common real-world case: the user
|
||||
// has a LAN relay + an internet relay configured, the LAN
|
||||
// relay sees the LAN IP, the internet relay sees the WAN
|
||||
// IP, and the old classifier would flag "Multiple" and
|
||||
// falsely warn about symmetric NAT.
|
||||
let probes = vec![
|
||||
mk(Some("192.168.1.100:4433")), // LAN — must be dropped
|
||||
mk(Some("203.0.113.5:4433")), // public (TEST-NET-3)
|
||||
];
|
||||
let (nt, _) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_drops_loopback_probes() {
|
||||
let probes = vec![
|
||||
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:4433")), // public, same addr
|
||||
];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
// Two public probes with identical addrs → Cone.
|
||||
assert_eq!(nt, NatType::Cone);
|
||||
assert_eq!(addr.as_deref(), Some("203.0.113.5:4433"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_drops_cgnat_probes() {
|
||||
// 100.64.0.0/10 is the CGNAT shared-transition range.
|
||||
// Filter treats it like RFC1918 — a relay that sees the
|
||||
// client with a 100.64/10 addr is on the same CGNAT
|
||||
// network and can't contribute to public NAT classification.
|
||||
let probes = vec![
|
||||
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
|
||||
mk(Some("203.0.113.5:4433")), // public
|
||||
mk(Some("203.0.113.5:12345")), // public, different port
|
||||
];
|
||||
let (nt, _) = classify_nat(&probes);
|
||||
// Two public probes same IP different port → SymmetricPort.
|
||||
assert_eq!(nt, NatType::SymmetricPort);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_two_lan_probes_is_unknown_not_cone() {
|
||||
// Even if both probes come back from LAN relays, we can't
|
||||
// say anything useful about the public NAT state. Unknown,
|
||||
// not Cone.
|
||||
let probes = vec![
|
||||
mk(Some("192.168.1.100:4433")),
|
||||
mk(Some("192.168.1.100:4433")),
|
||||
];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Unknown);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mix_of_success_and_failure() {
|
||||
let probes = vec![
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
mk(None), // failed probe
|
||||
mk(Some("192.0.2.1:4433")),
|
||||
];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
// Two successes both agree → Cone, ignore the failure row.
|
||||
assert_eq!(nt, NatType::Cone);
|
||||
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_smaller_is_acceptor() {
|
||||
// Lexicographic: "192.0.2.1:4433" < "198.51.100.9:4433"
|
||||
assert_eq!(
|
||||
determine_role(Some("192.0.2.1:4433"), Some("198.51.100.9:4433")),
|
||||
Some(Role::Acceptor)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_larger_is_dialer() {
|
||||
assert_eq!(
|
||||
determine_role(Some("198.51.100.9:4433"), Some("192.0.2.1:4433")),
|
||||
Some(Role::Dialer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_port_difference_matters() {
|
||||
// Same ip, different ports — string compare still works
|
||||
// because "4433" < "54321".
|
||||
assert_eq!(
|
||||
determine_role(Some("127.0.0.1:4433"), Some("127.0.0.1:54321")),
|
||||
Some(Role::Acceptor)
|
||||
);
|
||||
assert_eq!(
|
||||
determine_role(Some("127.0.0.1:54321"), Some("127.0.0.1:4433")),
|
||||
Some(Role::Dialer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_equal_addrs_is_none() {
|
||||
assert_eq!(
|
||||
determine_role(Some("192.0.2.1:4433"), Some("192.0.2.1:4433")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_missing_side_is_none() {
|
||||
assert_eq!(determine_role(None, Some("192.0.2.1:4433")), None);
|
||||
assert_eq!(determine_role(Some("192.0.2.1:4433"), None), None);
|
||||
assert_eq!(determine_role(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_role_is_symmetric_across_peers() {
|
||||
// Both peers compute roles independently; they must end
|
||||
// up with opposite assignments (one Acceptor, one Dialer)
|
||||
// so that each side ends up talking to the other.
|
||||
let a = "192.0.2.1:4433";
|
||||
let b = "198.51.100.9:4433";
|
||||
let alice_role = determine_role(Some(a), Some(b));
|
||||
let bob_role = determine_role(Some(b), Some(a));
|
||||
assert_eq!(alice_role, Some(Role::Acceptor));
|
||||
assert_eq!(bob_role, Some(Role::Dialer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_one_success_one_failure_is_unknown() {
|
||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(None)];
|
||||
let (nt, addr) = classify_nat(&probes);
|
||||
assert_eq!(nt, NatType::Unknown);
|
||||
assert!(addr.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
//! Phase 8 (Tailscale-inspired): Relay map for automatic relay
|
||||
//! selection based on latency.
|
||||
//!
|
||||
//! Maintains a sorted list of known relays with their measured
|
||||
//! latencies. Used during call setup to pick the lowest-latency
|
||||
//! relay, and by netcheck to report relay health.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// A known relay endpoint with measured latency.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayEntry {
|
||||
/// Human-readable name (e.g., "us-east", "eu-west").
|
||||
pub name: String,
|
||||
/// Relay address.
|
||||
pub addr: SocketAddr,
|
||||
/// Geographic region (from RegisterPresenceAck).
|
||||
pub region: Option<String>,
|
||||
/// Last measured RTT (ms).
|
||||
pub rtt_ms: Option<u32>,
|
||||
/// When the RTT was last measured.
|
||||
#[serde(skip)]
|
||||
pub last_probed: Option<Instant>,
|
||||
/// Whether this relay is currently reachable.
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
/// Sorted relay map. Entries are ordered by RTT (lowest first).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RelayMap {
|
||||
entries: Vec<RelayEntry>,
|
||||
}
|
||||
|
||||
impl RelayMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a relay entry.
|
||||
pub fn upsert(&mut self, name: &str, addr: SocketAddr, region: Option<String>) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.name = name.to_string();
|
||||
if region.is_some() {
|
||||
entry.region = region;
|
||||
}
|
||||
} else {
|
||||
self.entries.push(RelayEntry {
|
||||
name: name.to_string(),
|
||||
addr,
|
||||
region,
|
||||
rtt_ms: None,
|
||||
last_probed: None,
|
||||
reachable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update RTT measurement for a relay.
|
||||
pub fn update_rtt(&mut self, addr: SocketAddr, rtt_ms: u32) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.rtt_ms = Some(rtt_ms);
|
||||
entry.last_probed = Some(Instant::now());
|
||||
entry.reachable = true;
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Mark a relay as unreachable.
|
||||
pub fn mark_unreachable(&mut self, addr: SocketAddr) {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
||||
entry.reachable = false;
|
||||
entry.last_probed = Some(Instant::now());
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Get the preferred (lowest-latency, reachable) relay.
|
||||
pub fn preferred(&self) -> Option<&RelayEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|e| e.reachable && e.rtt_ms.is_some())
|
||||
}
|
||||
|
||||
/// Get all entries, sorted by RTT.
|
||||
pub fn entries(&self) -> &[RelayEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Populate from a `RegisterPresenceAck.available_relays` list.
|
||||
/// Each entry is "name|addr" format.
|
||||
pub fn populate_from_ack(&mut self, relays: &[String], relay_region: Option<&str>) {
|
||||
for entry_str in relays {
|
||||
if let Some((name, addr_str)) = entry_str.split_once('|') {
|
||||
if let Ok(addr) = addr_str.parse::<SocketAddr>() {
|
||||
self.upsert(name, addr, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the ack included a region for the current relay, we
|
||||
// could tag it — but we'd need to know which relay we're
|
||||
// connected to. Left for the caller to handle.
|
||||
let _ = relay_region;
|
||||
}
|
||||
|
||||
/// Check if any entry has a stale probe (older than `max_age`).
|
||||
pub fn needs_reprobe(&self, max_age: Duration) -> bool {
|
||||
self.entries.iter().any(|e| match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get entries that need reprobing.
|
||||
pub fn stale_entries(&self, max_age: Duration) -> Vec<(String, SocketAddr)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| match e.last_probed {
|
||||
None => true,
|
||||
Some(t) => t.elapsed() > max_age,
|
||||
})
|
||||
.map(|e| (e.name.clone(), e.addr))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.entries.sort_by_key(|e| {
|
||||
if e.reachable {
|
||||
e.rtt_ms.unwrap_or(u32::MAX)
|
||||
} else {
|
||||
u32::MAX
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_lowest_rtt() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
let a3: SocketAddr = "10.0.0.3:4433".parse().unwrap();
|
||||
|
||||
map.upsert("slow", a1, None);
|
||||
map.upsert("fast", a2, None);
|
||||
map.upsert("mid", a3, None);
|
||||
|
||||
map.update_rtt(a1, 200);
|
||||
map.update_rtt(a2, 15);
|
||||
map.update_rtt(a3, 80);
|
||||
|
||||
let pref = map.preferred().unwrap();
|
||||
assert_eq!(pref.addr, a2);
|
||||
assert_eq!(pref.rtt_ms, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_not_preferred() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
|
||||
map.upsert("fast-dead", a1, None);
|
||||
map.upsert("slow-alive", a2, None);
|
||||
|
||||
map.update_rtt(a1, 5);
|
||||
map.update_rtt(a2, 200);
|
||||
map.mark_unreachable(a1);
|
||||
|
||||
let pref = map.preferred().unwrap();
|
||||
assert_eq!(pref.addr, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_from_ack() {
|
||||
let mut map = RelayMap::new();
|
||||
map.populate_from_ack(
|
||||
&[
|
||||
"us-east|203.0.113.5:4433".into(),
|
||||
"eu-west|198.51.100.9:4433".into(),
|
||||
],
|
||||
Some("us-east"),
|
||||
);
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
assert_eq!(map.entries()[0].name, "us-east");
|
||||
assert_eq!(map.entries()[1].name, "eu-west");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_existing() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("old-name", addr, None);
|
||||
map.upsert("new-name", addr, Some("us-west".into()));
|
||||
assert_eq!(map.entries().len(), 1);
|
||||
assert_eq!(map.entries()[0].name, "new-name");
|
||||
assert_eq!(map.entries()[0].region, Some("us-west".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_region_when_none() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, Some("eu-west".into()));
|
||||
map.upsert("relay", addr, None); // region is None
|
||||
// Should keep the original region
|
||||
assert_eq!(map.entries()[0].region, Some("eu-west".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_none_on_empty() {
|
||||
let map = RelayMap::new();
|
||||
assert!(map.preferred().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_returns_none_when_all_unreachable() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, None);
|
||||
// Not update_rtt'd, so reachable=false
|
||||
assert!(map.preferred().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_empty_is_false() {
|
||||
let map = RelayMap::new();
|
||||
// No entries → nothing to reprobe
|
||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_never_probed() {
|
||||
let mut map = RelayMap::new();
|
||||
map.upsert("relay", "10.0.0.1:4433".parse().unwrap(), None);
|
||||
assert!(map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reprobe_fresh_is_false() {
|
||||
let mut map = RelayMap::new();
|
||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
map.upsert("relay", addr, None);
|
||||
map.update_rtt(addr, 50);
|
||||
// Just probed, so 60s max_age should not trigger
|
||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_entries_returns_unprobed() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("probed", a1, None);
|
||||
map.upsert("stale", a2, None);
|
||||
map.update_rtt(a1, 50);
|
||||
|
||||
let stale = map.stale_entries(Duration::from_secs(60));
|
||||
assert_eq!(stale.len(), 1);
|
||||
assert_eq!(stale[0].1, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_stability_with_equal_rtt() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("first", a1, None);
|
||||
map.upsert("second", a2, None);
|
||||
map.update_rtt(a1, 50);
|
||||
map.update_rtt(a2, 50);
|
||||
|
||||
// Both have same RTT — sort should be stable (insertion order)
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
// Both are valid preferred relays
|
||||
assert!(map.preferred().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_from_ack_skips_malformed() {
|
||||
let mut map = RelayMap::new();
|
||||
map.populate_from_ack(
|
||||
&[
|
||||
"good|10.0.0.1:4433".into(),
|
||||
"no-pipe-separator".into(),
|
||||
"bad-addr|not-a-socket-addr".into(),
|
||||
"also-good|10.0.0.2:4433".into(),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert_eq!(map.entries().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_unreachable_sorts_to_end() {
|
||||
let mut map = RelayMap::new();
|
||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
||||
map.upsert("fast", a1, None);
|
||||
map.upsert("slow", a2, None);
|
||||
map.update_rtt(a1, 10);
|
||||
map.update_rtt(a2, 200);
|
||||
|
||||
assert_eq!(map.preferred().unwrap().addr, a1);
|
||||
|
||||
map.mark_unreachable(a1);
|
||||
assert_eq!(map.preferred().unwrap().addr, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_entry_serializes() {
|
||||
let entry = RelayEntry {
|
||||
name: "test".into(),
|
||||
addr: "10.0.0.1:4433".parse().unwrap(),
|
||||
region: Some("us-east".into()),
|
||||
rtt_ms: Some(42),
|
||||
last_probed: Some(Instant::now()),
|
||||
reachable: true,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("test"));
|
||||
assert!(json.contains("us-east"));
|
||||
assert!(json.contains("42"));
|
||||
// last_probed is #[serde(skip)]
|
||||
assert!(!json.contains("last_probed"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,8 @@ fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
|
||||
/// decoder, pushes frames through the pipeline, and collects statistics.
|
||||
/// Combinations where `target_depth > max_depth` are skipped.
|
||||
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
|
||||
let frames_per_config = (config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
||||
let frames_per_config =
|
||||
(config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
//! Phase 3.5 integration tests for the dual-path QUIC race.
|
||||
//!
|
||||
//! The race takes a role (Acceptor or Dialer), a peer_direct_addr,
|
||||
//! a relay_addr, and two SNI strings, then returns whichever QUIC
|
||||
//! handshake completes first wrapped in a `QuinnTransport`. These
|
||||
//! tests validate that:
|
||||
//!
|
||||
//! 1. On loopback with two real clients playing A + D roles, the
|
||||
//! direct path wins (fewer hops than relay).
|
||||
//! 2. When the direct peer is dead (nothing listening) but the
|
||||
//! relay is up, the relay wins within the fallback window.
|
||||
//! 3. When both paths are dead, the race errors cleanly rather
|
||||
//! than hanging forever.
|
||||
//!
|
||||
//! The "relay" in these tests is a minimal mock that just accepts
|
||||
//! an incoming QUIC connection and drops it — we don't need any
|
||||
//! protocol handling, just a TCP-ish listen-and-accept.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
|
||||
use wzp_client::reflect::Role;
|
||||
use wzp_transport::{create_endpoint, server_config};
|
||||
|
||||
/// Spin up a "relay-ish" mock server on loopback that accepts
|
||||
/// incoming QUIC connections and does nothing with them. Used to
|
||||
/// give the relay branch of the race a real target to dial.
|
||||
/// Returns the bound address + a join handle (kept alive to keep
|
||||
/// the endpoint up).
|
||||
async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let (sc, _cert_der) = server_config();
|
||||
let bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
||||
let ep = create_endpoint(bind, Some(sc)).expect("relay endpoint");
|
||||
let addr = ep.local_addr().expect("local_addr");
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// Accept loop — hold the connection alive for a short
|
||||
// while so the race result isn't killed by the peer
|
||||
// closing before the winning transport is returned.
|
||||
while let Some(incoming) = ep.accept().await {
|
||||
if let Ok(_conn) = incoming.await {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: direct path wins when both sides are up
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// Spawn a mock relay, then set up a two-client test where one
|
||||
// client plays the Acceptor role and the other plays the Dialer
|
||||
// role. The Dialer's `peer_direct_addr` is the Acceptor's listen
|
||||
// address. Because the direct path is a single loopback hop and
|
||||
// the relay dial also terminates on loopback, both complete
|
||||
// essentially instantly — the `biased` tokio::select in race()
|
||||
// should pick direct.
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dual_path_direct_wins_on_loopback() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let (relay_addr, _relay_handle) = spawn_mock_relay().await;
|
||||
|
||||
// Acceptor task: run race(Role::Acceptor, peer_addr_placeholder, ...).
|
||||
// Since the acceptor doesn't dial, the peer_direct_addr arg is
|
||||
// unused on the direct branch but we still pass a placeholder
|
||||
// because the API takes one. Use a stub addr that would error
|
||||
// if it were ever dialed — proving the Acceptor really doesn't
|
||||
// reach it.
|
||||
let unused_addr: SocketAddr = "127.0.0.1:2".parse().unwrap();
|
||||
|
||||
// We can't race both sides in the same task because each race
|
||||
// call has its own direct endpoint that needs to talk to the
|
||||
// OTHER side's endpoint. So spawn the Acceptor in a task and
|
||||
// let it expose its listen addr via a oneshot back to the test,
|
||||
// then run the Dialer in the test's main task.
|
||||
//
|
||||
// There's a chicken-and-egg issue: the Acceptor's listen addr
|
||||
// is only known after race() creates its endpoint. To avoid
|
||||
// reaching into race()'s internals, we instead play a slight
|
||||
// trick: create the Acceptor's endpoint ourselves (outside
|
||||
// race()) to learn its addr, spin up an accept loop on it
|
||||
// ourselves, and pass THAT addr as the Dialer's peer addr.
|
||||
// This tests the Dialer->Acceptor handshake end-to-end without
|
||||
// running the full race() on both sides.
|
||||
|
||||
let (sc, _cert_der) = server_config();
|
||||
let acceptor_bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
||||
let acceptor_ep = create_endpoint(acceptor_bind, Some(sc)).expect("acceptor ep");
|
||||
let acceptor_listen_addr = acceptor_ep.local_addr().expect("acceptor addr");
|
||||
|
||||
// Drop the external acceptor after the test finishes, not
|
||||
// before — spawn a dedicated accept task.
|
||||
let acceptor_accept_task = tokio::spawn(async move {
|
||||
// Accept one connection and hold it for a while so the
|
||||
// Dialer side can complete its QUIC handshake.
|
||||
if let Some(incoming) = acceptor_ep.accept().await {
|
||||
if let Ok(_conn) = incoming.await {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now run the Dialer in the race — peer_direct_addr = acceptor's
|
||||
// listen addr. The relay is the mock from above. Direct path
|
||||
// should win.
|
||||
let result = race(
|
||||
Role::Dialer,
|
||||
PeerCandidates {
|
||||
reflexive: Some(acceptor_listen_addr),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // own_reflexive: not needed in tests
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
None, // Phase 7: no IPv6 endpoint in tests
|
||||
)
|
||||
.await
|
||||
.expect("race must succeed");
|
||||
|
||||
assert!(
|
||||
result.direct_transport.is_some(),
|
||||
"direct transport should be available"
|
||||
);
|
||||
assert_eq!(
|
||||
result.local_winner,
|
||||
WinningPath::Direct,
|
||||
"direct should win on loopback"
|
||||
);
|
||||
|
||||
// Cancel the acceptor accept task so the test finishes.
|
||||
acceptor_accept_task.abort();
|
||||
// Suppress unused-var warning for the placeholder.
|
||||
let _ = unused_addr;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: relay wins when the direct peer is dead
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// Dialer role, peer_direct_addr = a port nothing is listening on,
|
||||
// relay is the working mock. Direct dial will sit waiting for a
|
||||
// QUIC handshake that never comes; the 2s direct timeout kicks in
|
||||
// and the relay path wins the fallback.
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dual_path_relay_wins_when_direct_is_dead() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let (relay_addr, _relay_handle) = spawn_mock_relay().await;
|
||||
|
||||
// A port that nothing is listening on — dead direct target.
|
||||
// Port 1 on loopback is almost never bound and UDP packets to
|
||||
// it will be dropped silently, so the QUIC handshake times out.
|
||||
let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
|
||||
let result = race(
|
||||
Role::Dialer,
|
||||
PeerCandidates {
|
||||
reflexive: Some(dead_peer),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // own_reflexive: not needed in tests
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
None, // Phase 7: no IPv6 endpoint in tests
|
||||
)
|
||||
.await
|
||||
.expect("race must succeed via relay fallback");
|
||||
|
||||
assert!(
|
||||
result.relay_transport.is_some(),
|
||||
"relay transport should be available"
|
||||
);
|
||||
assert_eq!(
|
||||
result.local_winner,
|
||||
WinningPath::Relay,
|
||||
"relay should win when direct dial has nowhere to land"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: race errors cleanly when both paths are dead
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// Dialer role, peer_direct_addr = dead, relay_addr = dead.
|
||||
// Expected: race returns an Err within ~7s (2s direct timeout +
|
||||
// 5s relay timeout fallback).
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dual_path_errors_cleanly_when_both_paths_dead() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
let dead_relay: SocketAddr = "127.0.0.1:2".parse().unwrap();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = race(
|
||||
Role::Dialer,
|
||||
PeerCandidates {
|
||||
reflexive: Some(dead_peer),
|
||||
local: Vec::new(),
|
||||
mapped: None,
|
||||
},
|
||||
dead_relay,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // own_reflexive: not needed in tests
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
None, // Phase 7: no IPv6 endpoint in tests
|
||||
)
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(result.is_err(), "both-dead must return Err");
|
||||
// Upper bound: direct 2s timeout + relay 5s fallback + small
|
||||
// slack for scheduling. If this blows, something is looping.
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(10),
|
||||
"race took too long to give up: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use wzp_proto::packet::MediaPacket;
|
||||
use wzp_proto::traits::{MediaTransport, PathQuality};
|
||||
use wzp_proto::{SignalMessage, TransportError, default_signal_version};
|
||||
use wzp_proto::{SignalMessage, TransportError};
|
||||
|
||||
/// A mock transport backed by two mpsc channels (one per direction).
|
||||
///
|
||||
@@ -83,68 +83,43 @@ async fn full_handshake_both_sides_derive_same_session() {
|
||||
|
||||
// Run client and relay handshakes concurrently.
|
||||
let (client_result, relay_result) = tokio::join!(
|
||||
wzp_client::handshake::perform_handshake(
|
||||
client_transport_clone.as_ref(),
|
||||
&client_seed,
|
||||
None
|
||||
),
|
||||
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed),
|
||||
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
|
||||
);
|
||||
|
||||
let mut client_session = client_result.expect("client handshake should succeed");
|
||||
let (mut relay_session, chosen_profile, _caller_fp, _caller_alias) =
|
||||
let (mut relay_session, chosen_profile) =
|
||||
relay_result.expect("relay handshake should succeed");
|
||||
|
||||
// Verify a profile was chosen.
|
||||
assert_eq!(chosen_profile, wzp_proto::QualityProfile::GOOD);
|
||||
|
||||
// Verify both sides can communicate: client encrypts, relay decrypts.
|
||||
// encrypt/decrypt derive nonces from MediaHeader.seq, so we need valid headers.
|
||||
use wzp_proto::packet::MediaHeader;
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
let make_hdr = |seq: u32| {
|
||||
let h = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq,
|
||||
timestamp: seq.wrapping_mul(20),
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut b = Vec::new();
|
||||
h.write_to(&mut b);
|
||||
b
|
||||
};
|
||||
|
||||
let header = make_hdr(0);
|
||||
let header = b"test-header";
|
||||
let plaintext = b"hello from client to relay";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
client_session
|
||||
.encrypt(&header, plaintext, &mut ciphertext)
|
||||
.encrypt(header, plaintext, &mut ciphertext)
|
||||
.expect("client encrypt should succeed");
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
relay_session
|
||||
.decrypt(&header, &ciphertext, &mut decrypted)
|
||||
.decrypt(header, &ciphertext, &mut decrypted)
|
||||
.expect("relay decrypt should succeed");
|
||||
|
||||
assert_eq!(&decrypted[..], plaintext);
|
||||
|
||||
// Verify reverse direction: relay encrypts, client decrypts.
|
||||
let header2 = make_hdr(0); // relay's send_seq starts at 0
|
||||
let plaintext2 = b"hello from relay to client";
|
||||
let mut ciphertext2 = Vec::new();
|
||||
relay_session
|
||||
.encrypt(&header2, plaintext2, &mut ciphertext2)
|
||||
.encrypt(header, plaintext2, &mut ciphertext2)
|
||||
.expect("relay encrypt should succeed");
|
||||
|
||||
let mut decrypted2 = Vec::new();
|
||||
client_session
|
||||
.decrypt(&header2, &ciphertext2, &mut decrypted2)
|
||||
.decrypt(header, &ciphertext2, &mut decrypted2)
|
||||
.expect("client decrypt should succeed");
|
||||
|
||||
assert_eq!(&decrypted2[..], plaintext2);
|
||||
@@ -172,14 +147,10 @@ async fn handshake_rejects_tampered_signature() {
|
||||
let bad_signature = kx.sign(b"wrong-data-intentionally");
|
||||
|
||||
let offer = SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature: bad_signature,
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
alias: None,
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
client_transport_clone
|
||||
.send_signal(&offer)
|
||||
@@ -203,42 +174,3 @@ async fn handshake_rejects_tampered_signature() {
|
||||
Ok(_) => panic!("relay should reject tampered signature"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_receives_protocol_version_mismatch() {
|
||||
let (client_transport, relay_transport) = MockTransport::pair();
|
||||
|
||||
let client_seed = [0xAA_u8; 32];
|
||||
|
||||
// Spawn a fake relay that sends ProtocolVersionMismatch.
|
||||
let relay_clone = Arc::clone(&relay_transport);
|
||||
tokio::spawn(async move {
|
||||
// Wait for the client's CallOffer.
|
||||
let offer = relay_clone.recv_signal().await.unwrap().unwrap();
|
||||
assert!(matches!(offer, SignalMessage::CallOffer { .. }));
|
||||
|
||||
// Respond with ProtocolVersionMismatch.
|
||||
let mismatch = SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::ProtocolVersionMismatch {
|
||||
server_supported: vec![3],
|
||||
},
|
||||
call_id: None,
|
||||
};
|
||||
relay_clone.send_signal(&mismatch).await.unwrap();
|
||||
});
|
||||
|
||||
let result =
|
||||
wzp_client::handshake::perform_handshake(client_transport.as_ref(), &client_seed, None)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(wzp_client::handshake::HandshakeError::ProtocolVersionMismatch {
|
||||
server_supported,
|
||||
}) => {
|
||||
assert_eq!(server_supported, vec![3]);
|
||||
}
|
||||
Err(other) => panic!("expected ProtocolVersionMismatch, got: {other:?}"),
|
||||
Ok(_) => panic!("expected handshake to fail with ProtocolVersionMismatch"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,8 @@ fn long_session_no_drift() {
|
||||
println!(
|
||||
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
||||
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
||||
stats.underruns,
|
||||
stats.overruns,
|
||||
stats.current_depth,
|
||||
stats.max_depth_seen,
|
||||
stats.packets_late,
|
||||
stats.packets_lost,
|
||||
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
|
||||
stats.packets_late, stats.packets_lost,
|
||||
);
|
||||
|
||||
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
|
||||
@@ -127,7 +123,7 @@ fn long_session_with_simulated_loss() {
|
||||
|
||||
for (j, pkt) in batch.into_iter().enumerate() {
|
||||
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
|
||||
if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
|
||||
if !pkt.header.is_repair && i % 20 == 0 && j == 0 {
|
||||
continue; // drop this packet
|
||||
}
|
||||
decoder.ingest(pkt);
|
||||
@@ -143,12 +139,8 @@ fn long_session_with_simulated_loss() {
|
||||
println!(
|
||||
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
||||
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
||||
stats.underruns,
|
||||
stats.overruns,
|
||||
stats.current_depth,
|
||||
stats.max_depth_seen,
|
||||
stats.packets_late,
|
||||
stats.packets_lost,
|
||||
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
|
||||
stats.packets_late, stats.packets_lost,
|
||||
);
|
||||
|
||||
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.
|
||||
@@ -158,65 +150,6 @@ fn long_session_with_simulated_loss() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `MediaHeader::timestamp` continues monotonically across
|
||||
/// rekey boundaries. Rekey is a crypto-layer operation (key material
|
||||
/// rotation) and must not reset or interfere with framing state.
|
||||
///
|
||||
/// We simulate a 3000-frame session with two conceptual rekeys at frames
|
||||
/// 1000 and 2000. The encoder's timestamp counter must advance
|
||||
/// monotonically throughout.
|
||||
#[test]
|
||||
fn rekey_timestamp_monotonic() {
|
||||
let config = test_config();
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
|
||||
let mut timestamps = Vec::new();
|
||||
|
||||
// Phase 1: before first rekey
|
||||
for i in 0..1000 {
|
||||
let pcm = sine_frame(i);
|
||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
||||
for pkt in packets {
|
||||
timestamps.push(pkt.header.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: between first and second rekey
|
||||
for i in 1000..2000 {
|
||||
let pcm = sine_frame(i);
|
||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
||||
for pkt in packets {
|
||||
timestamps.push(pkt.header.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: after second rekey
|
||||
for i in 2000..3000 {
|
||||
let pcm = sine_frame(i);
|
||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
||||
for pkt in packets {
|
||||
timestamps.push(pkt.header.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Assert strict monotonicity (non-decreasing) across all three phases.
|
||||
for window in timestamps.windows(2) {
|
||||
assert!(
|
||||
window[1] >= window[0],
|
||||
"timestamp not monotonic across rekey boundary: {} -> {}",
|
||||
window[0],
|
||||
window[1]
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity: we should have collected at least 3000 timestamps.
|
||||
assert!(
|
||||
timestamps.len() >= 3000,
|
||||
"expected >= 3000 timestamps, got {}",
|
||||
timestamps.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the jitter buffer's decoded-frame count is consistent with its
|
||||
/// own internal statistics over a long session.
|
||||
#[test]
|
||||
|
||||
@@ -10,17 +10,8 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
|
||||
wzp-proto = { workspace = true }
|
||||
tracing = { 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 }
|
||||
# Opus bindings
|
||||
audiopus = { workspace = true }
|
||||
|
||||
# Pure-Rust Codec2 implementation
|
||||
codec2 = { workspace = true }
|
||||
|
||||
@@ -116,14 +116,6 @@ impl AudioEncoder for AdaptiveEncoder {
|
||||
fn set_dtx(&mut self, enabled: bool) {
|
||||
self.opus.set_dtx(enabled);
|
||||
}
|
||||
|
||||
fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
self.opus.set_expected_loss(loss_pct);
|
||||
}
|
||||
|
||||
fn set_dred_duration(&mut self, frames: u8) {
|
||||
self.opus.set_dred_duration(frames);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AdaptiveDecoder ─────────────────────────────────────────────────────────
|
||||
@@ -207,27 +199,6 @@ 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,127 +1,53 @@
|
||||
//! 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.
|
||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
||||
|
||||
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
|
||||
/// 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.
|
||||
pub struct EchoCanceller {
|
||||
// --- Adaptive filter ---
|
||||
filter: Vec<f32>,
|
||||
filter_coeffs: Vec<f32>,
|
||||
filter_len: usize,
|
||||
/// Circular buffer of far-end reference samples (after delay).
|
||||
far_buf: Vec<f32>,
|
||||
far_pos: usize,
|
||||
/// NLMS step size.
|
||||
far_end_buf: Vec<f32>,
|
||||
far_end_pos: usize,
|
||||
mu: f32,
|
||||
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
||||
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
||||
leak: f32,
|
||||
enabled: bool,
|
||||
|
||||
// --- Delay buffer ---
|
||||
/// Raw far-end samples before delay compensation.
|
||||
delay_ring: Vec<f32>,
|
||||
delay_write: usize,
|
||||
delay_read: usize,
|
||||
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
||||
delay_samples: usize,
|
||||
/// Capacity of the delay ring.
|
||||
delay_cap: usize,
|
||||
|
||||
// --- Double-talk detection (Geigel) ---
|
||||
/// Peak far-end level over the last filter_len samples.
|
||||
far_peak: f32,
|
||||
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
||||
geigel_threshold: f32,
|
||||
/// Holdover counter: keep DTD active for a few frames after detection.
|
||||
dtd_holdover: u32,
|
||||
dtd_hold_frames: u32,
|
||||
}
|
||||
|
||||
impl EchoCanceller {
|
||||
/// Create a new echo canceller.
|
||||
///
|
||||
/// * `sample_rate` — typically 48000
|
||||
/// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
|
||||
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
|
||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||
Self::with_delay(sample_rate, filter_ms, 40)
|
||||
}
|
||||
|
||||
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
||||
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
||||
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
||||
Self {
|
||||
filter: vec![0.0; filter_len],
|
||||
filter_coeffs: vec![0.0f32; filter_len],
|
||||
filter_len,
|
||||
far_buf: vec![0.0; filter_len],
|
||||
far_pos: 0,
|
||||
far_end_buf: vec![0.0f32; filter_len],
|
||||
far_end_pos: 0,
|
||||
mu: 0.01,
|
||||
leak: 0.0001,
|
||||
enabled: true,
|
||||
|
||||
delay_ring: vec![0.0; delay_cap],
|
||||
delay_write: 0,
|
||||
delay_read: 0,
|
||||
delay_samples,
|
||||
delay_cap,
|
||||
|
||||
far_peak: 0.0,
|
||||
geigel_threshold: 0.7,
|
||||
dtd_holdover: 0,
|
||||
dtd_hold_frames: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
||||
/// once enough samples have accumulated, they are released to the filter's
|
||||
/// circular buffer with the correct delay offset.
|
||||
/// 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.
|
||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||
// Write raw samples into the delay ring.
|
||||
for &s in farend {
|
||||
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
||||
self.delay_write += 1;
|
||||
}
|
||||
|
||||
// Release delayed samples to the filter's far-end buffer.
|
||||
while self.delay_available() >= 1 {
|
||||
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
||||
self.delay_read += 1;
|
||||
|
||||
self.far_buf[self.far_pos] = sample;
|
||||
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
||||
|
||||
// Track peak far-end level for Geigel DTD.
|
||||
let abs_s = sample.abs();
|
||||
if abs_s > self.far_peak {
|
||||
self.far_peak = abs_s;
|
||||
}
|
||||
}
|
||||
|
||||
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
||||
self.far_peak *= 0.9995;
|
||||
}
|
||||
|
||||
/// Number of delayed samples available to release.
|
||||
fn delay_available(&self) -> usize {
|
||||
let buffered = self.delay_write - self.delay_read;
|
||||
if buffered > self.delay_samples {
|
||||
buffered - self.delay_samples
|
||||
} else {
|
||||
0
|
||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -130,96 +56,85 @@ 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;
|
||||
|
||||
// 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) ---
|
||||
// --- 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.
|
||||
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_buf[fe_idx];
|
||||
echo_est += self.filter[k] * fe;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
echo_est += self.filter_coeffs[k] * fe;
|
||||
power += fe * fe;
|
||||
}
|
||||
|
||||
let error = near_f - echo_est;
|
||||
|
||||
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
||||
if far_active && !is_doubletalk && power > 10.0 {
|
||||
let step = self.mu * error / (power + 1.0);
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
self.filter[k] += step * self.far_buf[fe_idx];
|
||||
}
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
let out = error.clamp(-32768.0, 32767.0);
|
||||
// Clamp output
|
||||
let out = error.max(-32768.0).min(32767.0);
|
||||
nearend[i] = out as i16;
|
||||
|
||||
sum_near_sq += (near_f as f64).powi(2);
|
||||
sum_err_sq += (out as f64).powi(2);
|
||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
||||
sum_err_sq += (out as f64) * (out as f64);
|
||||
}
|
||||
|
||||
// ERLE ratio
|
||||
if sum_err_sq < 1.0 {
|
||||
100.0
|
||||
} else {
|
||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||
return 100.0; // near-perfect cancellation
|
||||
}
|
||||
(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.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,40 +143,50 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creates_with_correct_sizes() {
|
||||
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
||||
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
||||
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 60);
|
||||
fn aec_passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 100);
|
||||
aec.set_enabled(false);
|
||||
assert!(!aec.is_enabled());
|
||||
|
||||
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
|
||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
||||
let mut frame = original.clone();
|
||||
aec.process_frame(&mut frame);
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert_eq!(erle, 1.0);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_passthrough() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||
aec.feed_farend(&vec![0i16; 960]);
|
||||
let mut frame = vec![0i16; 960];
|
||||
aec.process_frame(&mut frame);
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduces_echo_with_no_delay() {
|
||||
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
||||
// (realistic — speaker to mic on laptop loses volume).
|
||||
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
||||
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);
|
||||
|
||||
let frame_len = 480;
|
||||
let make_tone = |offset: usize| -> Vec<i16> {
|
||||
// Generate a simple repeating pattern.
|
||||
let frame_len = 480usize;
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48000.0;
|
||||
@@ -270,16 +195,18 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Warm up the adaptive filter with several frames.
|
||||
let mut last_erle = 1.0f32;
|
||||
for frame_idx in 0..100 {
|
||||
let farend = make_tone(frame_idx * frame_len);
|
||||
for frame_idx in 0..40 {
|
||||
let farend = make_frame(frame_idx * frame_len);
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
||||
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
||||
// Near-end = exact copy of far-end (pure echo).
|
||||
let mut nearend = farend.clone();
|
||||
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}"
|
||||
@@ -287,52 +214,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_nearend_during_doubletalk() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||
|
||||
let frame_len = 960;
|
||||
let nearend: Vec<i16> = (0..frame_len)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Feed silence as far-end (no echo source).
|
||||
aec.feed_farend(&vec![0i16; frame_len]);
|
||||
|
||||
let mut frame = nearend.clone();
|
||||
aec.process_frame(&mut frame);
|
||||
|
||||
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||
let ratio = output_energy / input_energy;
|
||||
|
||||
assert!(
|
||||
ratio > 0.8,
|
||||
"near-end speech should be preserved, energy ratio = {ratio:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_buffer_holds_samples() {
|
||||
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
|
||||
// 20ms delay = 960 samples @ 48kHz.
|
||||
// After feeding, feed_farend auto-drains available samples to far_buf.
|
||||
// So delay_available() is always 0 after feed_farend returns.
|
||||
// Instead, verify far_pos advances only after the delay is filled.
|
||||
|
||||
// Feed 960 samples (= delay amount). No samples released yet.
|
||||
aec.feed_farend(&vec![1i16; 960]);
|
||||
// far_buf should still be all zeros (nothing released).
|
||||
assert!(
|
||||
aec.far_buf.iter().all(|&s| s == 0.0),
|
||||
"nothing should be released yet"
|
||||
);
|
||||
|
||||
// Feed 480 more. 480 should be released to far_buf.
|
||||
aec.feed_farend(&vec![2i16; 480]);
|
||||
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
|
||||
assert!(non_zero > 0, "samples should have been released to far_buf");
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ impl AutoGainControl {
|
||||
/// Create a new AGC with sensible VoIP defaults.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
||||
current_gain: 1.0,
|
||||
min_gain: 0.5,
|
||||
max_gain: 32.0,
|
||||
attack_alpha: 0.3, // fast attack
|
||||
release_alpha: 0.02, // slow release
|
||||
attack_alpha: 0.3, // fast attack
|
||||
release_alpha: 0.02, // slow release
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,9 @@ mod tests {
|
||||
fn agc_gain_db_at_unity() {
|
||||
let agc = AutoGainControl::new();
|
||||
let db = agc.current_gain_db();
|
||||
assert!(db.abs() < 0.01, "expected ~0 dB at unity gain, got {db}");
|
||||
assert!(
|
||||
db.abs() < 0.01,
|
||||
"expected ~0 dB at unity gain, got {db}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +99,7 @@ mod tests {
|
||||
}
|
||||
let original_len = pcm.len();
|
||||
ns.process(&mut pcm);
|
||||
assert_eq!(
|
||||
pcm.len(),
|
||||
original_len,
|
||||
"output length must match input length"
|
||||
);
|
||||
assert_eq!(pcm.len(), original_len, "output length must match input length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
//! 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,7 +15,6 @@ 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;
|
||||
@@ -28,32 +27,15 @@ pub use denoise::NoiseSupressor;
|
||||
pub use silence::{ComfortNoise, SilenceDetector};
|
||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Global verbose-logging flag for DRED. Off by default — when enabled
|
||||
/// (via the GUI debug toggle wired through Tauri), the encoder logs its
|
||||
/// DRED config + libopus version, and the recv path logs every DRED
|
||||
/// reconstruction, classical PLC fill, and parse heartbeat. Off in
|
||||
/// "normal" mode keeps logcat clean.
|
||||
static DRED_VERBOSE_LOGS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Returns whether DRED verbose logging is currently enabled.
|
||||
#[inline]
|
||||
pub fn dred_verbose_logs() -> bool {
|
||||
DRED_VERBOSE_LOGS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Enable/disable DRED verbose logging at runtime.
|
||||
pub fn set_dred_verbose_logs(enabled: bool) {
|
||||
DRED_VERBOSE_LOGS.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Create an adaptive encoder starting at the given quality profile.
|
||||
///
|
||||
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
||||
/// codec; resampling is handled internally when Codec2 is selected.
|
||||
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
||||
Box::new(AdaptiveEncoder::new(profile).expect("failed to create adaptive encoder"))
|
||||
Box::new(
|
||||
AdaptiveEncoder::new(profile)
|
||||
.expect("failed to create adaptive encoder"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an adaptive decoder starting at the given quality profile.
|
||||
@@ -61,7 +43,10 @@ pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
||||
/// The returned decoder always produces 48 kHz mono PCM; upsampling from
|
||||
/// Codec2's native 8 kHz is handled internally.
|
||||
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
|
||||
Box::new(AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder"))
|
||||
Box::new(
|
||||
AdaptiveDecoder::new(profile)
|
||||
.expect("failed to create adaptive decoder"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -76,10 +61,6 @@ mod codec2_tests {
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
priority_mode: wzp_proto::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,10 +189,7 @@ mod codec2_tests {
|
||||
|
||||
let mut pcm_out_c2 = vec![0i16; 1920];
|
||||
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
|
||||
assert_eq!(
|
||||
samples_c2, 1920,
|
||||
"should get 1920 samples at 48kHz after upsample"
|
||||
);
|
||||
assert_eq!(samples_c2, 1920, "should get 1920 samples at 48kHz after upsample");
|
||||
|
||||
// Step 3: Switch back to Opus.
|
||||
enc.set_profile(QualityProfile::GOOD).unwrap();
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
//! 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.
|
||||
//! Opus decoder wrapping the `audiopus` crate.
|
||||
|
||||
use crate::dred_ffi::{DecoderHandle, DredState};
|
||||
use audiopus::coder::Decoder;
|
||||
use audiopus::{Channels, MutSignals, SampleRate};
|
||||
use audiopus::packet::Packet;
|
||||
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||
|
||||
/// Opus decoder implementing [`AudioDecoder`].
|
||||
/// Opus decoder implementing `AudioDecoder`.
|
||||
///
|
||||
/// 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.
|
||||
/// Operates at 48 kHz mono output.
|
||||
pub struct OpusDecoder {
|
||||
inner: DecoderHandle,
|
||||
inner: Decoder,
|
||||
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 inner = DecoderHandle::new()?;
|
||||
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
inner,
|
||||
inner: decoder,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
})
|
||||
@@ -36,24 +34,6 @@ 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 {
|
||||
@@ -65,7 +45,15 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
self.inner.decode(encoded, pcm)
|
||||
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)
|
||||
}
|
||||
|
||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
@@ -76,7 +64,13 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
self.inner.decode_lost(pcm)
|
||||
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)
|
||||
}
|
||||
|
||||
fn codec_id(&self) -> CodecId {
|
||||
|
||||
@@ -1,230 +1,58 @@
|
||||
//! 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): 1040 ms — users on 6k are by definition on a
|
||||
//! bad link; the maximum libopus DRED window buys the best burst resilience
|
||||
//! where it matters. The RDO-VAE naturally degrades quality at longer offsets.
|
||||
//!
|
||||
//! # 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.
|
||||
//! Opus encoder wrapping the `audiopus` crate.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
|
||||
use tracing::{debug, info, warn};
|
||||
use audiopus::coder::Encoder;
|
||||
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||
use tracing::debug;
|
||||
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||
|
||||
/// Logged exactly once per process the first time an OpusEncoder is built.
|
||||
/// Confirms that libopus 1.5.2 (the version with DRED) is actually linked
|
||||
/// at runtime — invaluable when chasing "is the new codec loaded?"
|
||||
/// regressions on Android, where the only debug surface is logcat.
|
||||
static LIBOPUS_VERSION_LOGGED: OnceLock<()> = OnceLock::new();
|
||||
|
||||
/// 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. 104 × 10 ms = 1040 ms,
|
||||
// the highest value libopus 1.5 supports. Users on 6k are on a bad
|
||||
// link by definition; the RDO-VAE naturally degrades quality at longer
|
||||
// offsets, so the extra window costs only ~1-2 kbps additional overhead
|
||||
// while buying substantially better burst resilience (up from 500 ms).
|
||||
CodecId::Opus6k => 104,
|
||||
// Non-Opus (Codec2 / CN / video): DRED is N/A.
|
||||
CodecId::Codec2_1200
|
||||
| CodecId::Codec2_3200
|
||||
| CodecId::ComfortNoise
|
||||
| CodecId::H264Baseline
|
||||
| CodecId::H265Main
|
||||
| CodecId::Av1Main => 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 20 ms and 40 ms frames via the active
|
||||
/// `QualityProfile`.
|
||||
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||
/// and 40 ms (1920 samples).
|
||||
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
|
||||
// opusic-c Encoder wraps a non-null pointer that is !Sync by default,
|
||||
// but we never share it across threads without exclusive access.
|
||||
// audiopus Encoder contains a raw pointer that is !Sync, 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> {
|
||||
// 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 encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||
|
||||
let mut enc = Self {
|
||||
inner: encoder,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
legacy_fec_mode,
|
||||
};
|
||||
|
||||
// Common setup — bitrate, DTX, signal hint, complexity. These are
|
||||
// identical regardless of the protection mode below.
|
||||
enc.apply_bitrate(profile.codec)?;
|
||||
enc.set_inband_fec(true);
|
||||
enc.set_dtx(true);
|
||||
|
||||
// Voice signal type hint for better compression
|
||||
enc.inner
|
||||
.set_signal(Signal::Voice)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?;
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||
|
||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||
enc.inner
|
||||
.set_complexity(7)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?;
|
||||
|
||||
// Protection mode: DRED (Phase 1 default) or legacy inband FEC.
|
||||
enc.apply_protection_mode(profile.codec)?;
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// 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_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:?}")))?;
|
||||
|
||||
// Both of these are gated behind the GUI debug toggle so logcat
|
||||
// stays clean in normal mode. Flip "DRED verbose logs" in the
|
||||
// settings panel to see the per-encoder config + libopus version.
|
||||
if crate::dred_verbose_logs() {
|
||||
info!(
|
||||
codec = ?codec,
|
||||
dred_frames,
|
||||
dred_ms = dred_frames as u32 * 10,
|
||||
loss_floor_pct = DRED_LOSS_FLOOR_PCT,
|
||||
"opus encoder: DRED enabled"
|
||||
);
|
||||
|
||||
// One-shot logging of the linked libopus version so we can
|
||||
// confirm at a glance that opusic-c (libopus 1.5.2) is loaded.
|
||||
// Pre-Phase-0 audiopus shipped libopus 1.3 which has no DRED;
|
||||
// if this log says "libopus 1.3" something is very wrong.
|
||||
LIBOPUS_VERSION_LOGGED.get_or_init(|| {
|
||||
info!(libopus_version = %opusic_c::version(), "linked libopus version");
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
let bps = codec.bitrate_bps();
|
||||
let bps = codec.bitrate_bps() as i32;
|
||||
self.inner
|
||||
.set_bitrate(Bitrate::Value(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?;
|
||||
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||
Ok(())
|
||||
}
|
||||
@@ -243,36 +71,10 @@ impl OpusEncoder {
|
||||
|
||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||
///
|
||||
/// 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).
|
||||
/// Higher values cause the encoder to use more redundancy to survive
|
||||
/// packet loss, at the expense of slightly higher bitrate.
|
||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
let 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
|
||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +87,10 @@ 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_to_slice(pcm_u16, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?;
|
||||
.encode(pcm, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -306,9 +104,6 @@ impl AudioEncoder for OpusEncoder {
|
||||
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 {
|
||||
@@ -325,202 +120,10 @@ impl AudioEncoder for OpusEncoder {
|
||||
}
|
||||
|
||||
fn set_inband_fec(&mut self, enabled: bool) {
|
||||
// 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);
|
||||
let _ = self.inner.set_inband_fec(enabled);
|
||||
}
|
||||
|
||||
fn set_dtx(&mut self, enabled: bool) {
|
||||
let _ = self.inner.set_dtx(enabled);
|
||||
}
|
||||
|
||||
fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
OpusEncoder::set_expected_loss(self, loss_pct);
|
||||
}
|
||||
|
||||
fn set_dred_duration(&mut self, frames: u8) {
|
||||
OpusEncoder::set_dred_duration(self, frames);
|
||||
}
|
||||
}
|
||||
|
||||
#[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_1040ms() {
|
||||
assert_eq!(dred_duration_for(CodecId::Opus6k), 104);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ impl Downsampler48to8 {
|
||||
|
||||
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
||||
if work.len() >= hist_len {
|
||||
self.history.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
} else {
|
||||
// Input was shorter than history — shift.
|
||||
let shift = hist_len - work.len();
|
||||
@@ -208,7 +209,8 @@ impl Upsampler8to48 {
|
||||
|
||||
// Update history.
|
||||
if work.len() >= hist_len {
|
||||
self.history.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
} else {
|
||||
let shift = hist_len - work.len();
|
||||
self.history.copy_within(shift.., 0);
|
||||
|
||||
@@ -151,10 +151,7 @@ mod tests {
|
||||
for _ in 0..4 {
|
||||
det.is_silent(&silence);
|
||||
}
|
||||
assert!(
|
||||
det.is_silent(&silence),
|
||||
"should be suppressing after hangover"
|
||||
);
|
||||
assert!(det.is_silent(&silence), "should be suppressing after hangover");
|
||||
|
||||
// Speech arrives — should immediately stop suppressing.
|
||||
assert!(!det.is_silent(&speech));
|
||||
@@ -168,16 +165,10 @@ mod tests {
|
||||
cn.generate(&mut pcm);
|
||||
|
||||
// At least some samples should be non-zero.
|
||||
assert!(
|
||||
pcm.iter().any(|&s| s != 0),
|
||||
"CN output should not be all zeros"
|
||||
);
|
||||
assert!(pcm.iter().any(|&s| s != 0), "CN output should not be all zeros");
|
||||
|
||||
// All samples should be within [-50, 50].
|
||||
assert!(
|
||||
pcm.iter().all(|&s| s.abs() <= 50),
|
||||
"CN samples out of range"
|
||||
);
|
||||
assert!(pcm.iter().all(|&s| s.abs() <= 50), "CN samples out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,17 +179,11 @@ mod tests {
|
||||
// Constant value: RMS of [v, v, v, ...] = |v|.
|
||||
let pcm = vec![100i16; 100];
|
||||
let rms = SilenceDetector::rms(&pcm);
|
||||
assert!(
|
||||
(rms - 100.0).abs() < 0.01,
|
||||
"RMS of constant 100 should be 100, got {rms}"
|
||||
);
|
||||
assert!((rms - 100.0).abs() < 0.01, "RMS of constant 100 should be 100, got {rms}");
|
||||
|
||||
// Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355
|
||||
let rms2 = SilenceDetector::rms(&[3, 4]);
|
||||
assert!(
|
||||
(rms2 - 3.5355).abs() < 0.01,
|
||||
"RMS of [3,4] should be ~3.5355, got {rms2}"
|
||||
);
|
||||
assert!((rms2 - 3.5355).abs() < 0.01, "RMS of [3,4] should be ~3.5355, got {rms2}");
|
||||
|
||||
// Empty buffer → 0.
|
||||
assert_eq!(SilenceDetector::rms(&[]), 0.0);
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
//! Sliding window replay protection.
|
||||
//!
|
||||
//! Tracks seen sequence numbers using a bitmap. Window size is configurable
|
||||
//! at construction time. Sequence numbers that are too old (more than
|
||||
//! `window_size` behind the highest seen) are rejected.
|
||||
//! Tracks seen sequence numbers using a bitmap. Window size is 1024 packets.
|
||||
//! Sequence numbers that are too old (more than WINDOW_SIZE behind the highest
|
||||
//! seen) are rejected.
|
||||
|
||||
use wzp_proto::CryptoError;
|
||||
|
||||
/// Window size in packets.
|
||||
const WINDOW_SIZE: u16 = 1024;
|
||||
|
||||
/// Sliding window anti-replay detector.
|
||||
///
|
||||
/// Uses a bitmap to track which sequence numbers have been seen within
|
||||
/// the current window. Handles `u32` wrapping correctly.
|
||||
/// the current window. Handles u16 wrapping correctly.
|
||||
pub struct AntiReplayWindow {
|
||||
/// Window size in packets.
|
||||
window_size: u32,
|
||||
/// Highest sequence number seen so far.
|
||||
highest: u32,
|
||||
highest: u16,
|
||||
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
||||
bitmap: Vec<u64>,
|
||||
/// Whether any packet has been received yet.
|
||||
@@ -22,26 +23,21 @@ pub struct AntiReplayWindow {
|
||||
}
|
||||
|
||||
impl AntiReplayWindow {
|
||||
/// Create a new anti-replay window with the default size of 1024 packets.
|
||||
pub fn new() -> Self {
|
||||
Self::with_window(1024)
|
||||
}
|
||||
/// Number of u64 words needed for the bitmap.
|
||||
const BITMAP_WORDS: usize = (WINDOW_SIZE as usize + 63) / 64;
|
||||
|
||||
/// Create a new anti-replay window with a custom size.
|
||||
pub fn with_window(size: usize) -> Self {
|
||||
let window_size = size as u32;
|
||||
let bitmap_words = (size + 63) / 64;
|
||||
/// Create a new anti-replay window.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
window_size,
|
||||
highest: 0,
|
||||
bitmap: vec![0u64; bitmap_words],
|
||||
bitmap: vec![0u64; Self::BITMAP_WORDS],
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sequence number is valid (not a replay, not too old).
|
||||
/// If valid, marks it as seen.
|
||||
pub fn check_and_update(&mut self, seq: u32) -> Result<(), CryptoError> {
|
||||
pub fn check_and_update(&mut self, seq: u16) -> Result<(), CryptoError> {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
self.highest = seq;
|
||||
@@ -56,17 +52,17 @@ impl AntiReplayWindow {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
|
||||
if diff < 0x8000_0000 {
|
||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF_FFFF])
|
||||
if diff < 0x8000 {
|
||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF])
|
||||
let shift = diff as usize;
|
||||
self.advance_window(shift);
|
||||
self.highest = seq;
|
||||
self.set_bit(0);
|
||||
Ok(())
|
||||
} else {
|
||||
// seq is behind highest (wrapping-aware: diff in [0x8000_0000, 0xFFFF_FFFF])
|
||||
// seq is behind highest (wrapping-aware: diff in [0x8000, 0xFFFF])
|
||||
let behind = self.highest.wrapping_sub(seq) as usize;
|
||||
if behind >= self.window_size as usize {
|
||||
if behind >= WINDOW_SIZE as usize {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
if self.get_bit(behind) {
|
||||
@@ -79,8 +75,7 @@ impl AntiReplayWindow {
|
||||
|
||||
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
||||
fn advance_window(&mut self, shift: usize) {
|
||||
let window_size = self.window_size as usize;
|
||||
if shift >= window_size {
|
||||
if shift >= WINDOW_SIZE as usize {
|
||||
for word in &mut self.bitmap {
|
||||
*word = 0;
|
||||
}
|
||||
@@ -161,11 +156,7 @@ mod tests {
|
||||
fn sequential_accepted() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
for i in 0..200 {
|
||||
assert!(
|
||||
w.check_and_update(i).is_ok(),
|
||||
"seq {} should be accepted",
|
||||
i
|
||||
);
|
||||
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +183,11 @@ mod tests {
|
||||
#[test]
|
||||
fn wrapping_works() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(0xFFFF_FFF0).is_ok());
|
||||
assert!(w.check_and_update(0xFFFF_FFFF).is_ok());
|
||||
assert!(w.check_and_update(65530).is_ok());
|
||||
assert!(w.check_and_update(65535).is_ok());
|
||||
assert!(w.check_and_update(0).is_ok()); // wrapped
|
||||
assert!(w.check_and_update(1).is_ok());
|
||||
assert!(w.check_and_update(0xFFFF_FFFF).is_err()); // duplicate
|
||||
assert!(w.check_and_update(65535).is_err()); // duplicate
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -210,53 +201,4 @@ mod tests {
|
||||
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
||||
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_window_size() {
|
||||
let mut w = AntiReplayWindow::with_window(64);
|
||||
for i in 0..64 {
|
||||
assert!(w.check_and_update(i).is_ok());
|
||||
}
|
||||
// seq 0 is now exactly at the boundary (64 behind 64)
|
||||
assert!(w.check_and_update(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_burst_200_with_one_reorder() {
|
||||
let mut w = AntiReplayWindow::with_window(1024);
|
||||
// Simulate a 200-packet burst
|
||||
for i in 0..200 {
|
||||
assert!(
|
||||
w.check_and_update(i).is_ok(),
|
||||
"seq {} should be accepted",
|
||||
i
|
||||
);
|
||||
}
|
||||
// One packet reordered (arrives late)
|
||||
assert!(w.check_and_update(50).is_err(), "seq 50 is a duplicate");
|
||||
// But a packet just behind the window should still be ok
|
||||
assert!(w.check_and_update(199).is_err(), "seq 199 is a duplicate");
|
||||
// Continue the burst
|
||||
for i in 200..400 {
|
||||
assert!(
|
||||
w.check_and_update(i).is_ok(),
|
||||
"seq {} should be accepted",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_high_range_works() {
|
||||
let mut w = AntiReplayWindow::with_window(64);
|
||||
let base = 1000u32;
|
||||
assert!(w.check_and_update(base).is_ok());
|
||||
assert!(w.check_and_update(base + 1).is_ok());
|
||||
// 65 behind highest (base+1) is outside the 64-packet window
|
||||
assert!(w.check_and_update(base.wrapping_sub(64)).is_err());
|
||||
// 63 behind is inside
|
||||
assert!(w.check_and_update(base.wrapping_sub(62)).is_ok());
|
||||
// base itself is now a duplicate
|
||||
assert!(w.check_and_update(base).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use hkdf::Hkdf;
|
||||
use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||
|
||||
use crate::session::ChaChaSession;
|
||||
|
||||
@@ -18,14 +18,10 @@ use crate::session::ChaChaSession;
|
||||
pub struct WarzoneKeyExchange {
|
||||
/// Ed25519 signing key (identity).
|
||||
signing_key: SigningKey,
|
||||
/// X25519 static secret derived from identity seed. Reserved for future
|
||||
/// use in static-key federation authentication (not used in current
|
||||
/// ephemeral-only handshake protocol).
|
||||
/// X25519 static secret (derived from seed, used for identity encryption).
|
||||
#[allow(dead_code)]
|
||||
x25519_static_secret: StaticSecret,
|
||||
/// X25519 static public key derived from identity seed. Reserved for
|
||||
/// future use in static-key federation authentication (not used in
|
||||
/// current ephemeral-only handshake protocol).
|
||||
/// X25519 static public key.
|
||||
#[allow(dead_code)]
|
||||
x25519_static_public: X25519PublicKey,
|
||||
/// Ephemeral X25519 secret for the current call (set by generate_ephemeral).
|
||||
@@ -95,11 +91,12 @@ impl KeyExchange for WarzoneKeyExchange {
|
||||
&self,
|
||||
peer_ephemeral_pub: &[u8; 32],
|
||||
) -> Result<Box<dyn CryptoSession>, CryptoError> {
|
||||
let secret = self.ephemeral_secret.as_ref().ok_or_else(|| {
|
||||
CryptoError::Internal(
|
||||
"no ephemeral key generated; call generate_ephemeral first".into(),
|
||||
)
|
||||
})?;
|
||||
let secret = self
|
||||
.ephemeral_secret
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into())
|
||||
})?;
|
||||
|
||||
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
|
||||
// Use diffie_hellman with a clone of the StaticSecret
|
||||
@@ -209,34 +206,18 @@ mod tests {
|
||||
let mut alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||
let mut bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||
|
||||
// Verify they can communicate: Alice encrypts, Bob decrypts.
|
||||
// Use a valid v2 MediaHeader — encrypt/decrypt now derive the nonce from
|
||||
// header.seq and will reject raw byte slices shorter than WIRE_SIZE.
|
||||
use wzp_proto::{CodecId, MediaHeader, MediaType};
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut header_bytes = Vec::new();
|
||||
header.write_to(&mut header_bytes);
|
||||
|
||||
// Verify they can communicate: Alice encrypts, Bob decrypts
|
||||
let header = b"call-header";
|
||||
let plaintext = b"hello from alice";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice_session
|
||||
.encrypt(&header_bytes, plaintext, &mut ciphertext)
|
||||
.encrypt(header, plaintext, &mut ciphertext)
|
||||
.unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
bob_session
|
||||
.decrypt(&header_bytes, &ciphertext, &mut decrypted)
|
||||
.decrypt(header, &ciphertext, &mut decrypted)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
|
||||
@@ -79,9 +79,7 @@ impl Seed {
|
||||
///
|
||||
/// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed`
|
||||
pub fn from_mnemonic(words: &str) -> Result<Self, String> {
|
||||
let mnemonic: bip39::Mnemonic = words
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid mnemonic: {e}"))?;
|
||||
let mnemonic: bip39::Mnemonic = words.parse().map_err(|e| format!("invalid mnemonic: {e}"))?;
|
||||
let entropy = mnemonic.to_entropy();
|
||||
if entropy.len() != 32 {
|
||||
return Err(format!("expected 32 bytes entropy, got {}", entropy.len()));
|
||||
|
||||
@@ -16,8 +16,8 @@ pub mod session;
|
||||
|
||||
pub use anti_replay::AntiReplayWindow;
|
||||
pub use handshake::WarzoneKeyExchange;
|
||||
pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed, hash_room_name};
|
||||
pub use nonce::{Direction, build_nonce};
|
||||
pub use identity::{hash_room_name, Fingerprint, IdentityKeyPair, PublicIdentity, Seed};
|
||||
pub use nonce::{build_nonce, Direction};
|
||||
pub use rekey::RekeyManager;
|
||||
pub use session::ChaChaSession;
|
||||
|
||||
|
||||
@@ -36,10 +36,6 @@ impl RekeyManager {
|
||||
///
|
||||
/// The old key is zeroized after the new key is derived.
|
||||
/// Returns the new 32-byte symmetric key.
|
||||
///
|
||||
/// NOTE: Rekeying changes **only** the symmetric key material. Sequence
|
||||
/// numbers and timestamps in the media framing layer (e.g. `MediaHeader`)
|
||||
/// are untouched — they continue monotonically across the rekey boundary.
|
||||
pub fn perform_rekey(
|
||||
&mut self,
|
||||
new_peer_pub: &[u8; 32],
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
//! Implements the `CryptoSession` trait for per-call media encryption.
|
||||
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chacha20poly1305::aead::Aead;
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
||||
use rand::rngs::OsRng;
|
||||
use wzp_proto::{CryptoError, CryptoSession, MediaHeader, MediaType};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
use rand::rngs::OsRng;
|
||||
use wzp_proto::{CryptoError, CryptoSession};
|
||||
|
||||
use crate::anti_replay::AntiReplayWindow;
|
||||
use crate::nonce::{self, Direction};
|
||||
use crate::rekey::RekeyManager;
|
||||
|
||||
@@ -31,10 +28,6 @@ pub struct ChaChaSession {
|
||||
pending_rekey_secret: Option<StaticSecret>,
|
||||
/// Short Authentication String (4-digit code for verbal verification).
|
||||
sas_code: Option<u32>,
|
||||
/// Per-stream anti-replay windows, keyed by (stream_id, media_type).
|
||||
anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>,
|
||||
/// Last timestamp seen in encrypt() — used to assert monotonicity across rekeys.
|
||||
last_encrypt_timestamp: Option<u32>,
|
||||
}
|
||||
|
||||
impl ChaChaSession {
|
||||
@@ -56,8 +49,6 @@ impl ChaChaSession {
|
||||
rekey_mgr: RekeyManager::new(shared_secret),
|
||||
pending_rekey_secret: None,
|
||||
sas_code: None,
|
||||
anti_replay: HashMap::new(),
|
||||
last_encrypt_timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,27 +67,6 @@ impl ChaChaSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a v2 `MediaHeader` from raw bytes.
|
||||
/// Returns `None` if the buffer is too short or not a valid v2 header.
|
||||
fn parse_header(header_bytes: &[u8]) -> Option<MediaHeader> {
|
||||
if header_bytes.len() < MediaHeader::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(header_bytes);
|
||||
MediaHeader::read_from(&mut cursor)
|
||||
}
|
||||
|
||||
/// Return the default anti-replay window size for a given media type.
|
||||
fn default_window_for_media_type(media_type: MediaType) -> AntiReplayWindow {
|
||||
let size = match media_type {
|
||||
MediaType::Audio => 64,
|
||||
MediaType::Video => 1024,
|
||||
MediaType::Data => 256,
|
||||
MediaType::Control => 32,
|
||||
};
|
||||
AntiReplayWindow::with_window(size)
|
||||
}
|
||||
|
||||
impl CryptoSession for ChaChaSession {
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
@@ -104,14 +74,10 @@ impl CryptoSession for ChaChaSession {
|
||||
plaintext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError> {
|
||||
// Derive nonce from the wire-level seq in the header, not from an
|
||||
// internal counter. This ensures the receiver can reconstruct the
|
||||
// same nonce using the header it receives, regardless of delivery order.
|
||||
let header = parse_header(header_bytes)
|
||||
.ok_or_else(|| CryptoError::Internal("header too short to derive nonce".into()))?;
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, header.seq, Direction::Send);
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, self.send_seq, Direction::Send);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Encrypt with AAD
|
||||
use chacha20poly1305::aead::Payload;
|
||||
let payload = Payload {
|
||||
msg: plaintext,
|
||||
@@ -124,19 +90,7 @@ impl CryptoSession for ChaChaSession {
|
||||
.map_err(|_| CryptoError::Internal("encryption failed".into()))?;
|
||||
|
||||
out.extend_from_slice(&ciphertext);
|
||||
self.send_seq = self.send_seq.wrapping_add(1); // packet counter for rekey trigger only
|
||||
|
||||
// M5: assert timestamp_ms is non-decreasing across calls (including post-rekey).
|
||||
// Timestamps are u32 and wrap at 2^32 ms (~49 days); allow wrapping.
|
||||
debug_assert!(
|
||||
self.last_encrypt_timestamp
|
||||
.map_or(true, |last| header.timestamp.wrapping_sub(last) < u32::MAX / 2),
|
||||
"encrypt: timestamp must not decrease (last={:?}, now={})",
|
||||
self.last_encrypt_timestamp,
|
||||
header.timestamp,
|
||||
);
|
||||
self.last_encrypt_timestamp = Some(header.timestamp);
|
||||
|
||||
self.send_seq = self.send_seq.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,14 +100,9 @@ impl CryptoSession for ChaChaSession {
|
||||
ciphertext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError> {
|
||||
// Parse header before decryption — needed for nonce derivation.
|
||||
// Using header.seq (not recv_seq) means the nonce is always derived
|
||||
// from the same wire field as the sender, surviving out-of-order delivery.
|
||||
// A recv_seq counter diverges from the sender's send_seq on any reorder,
|
||||
// causing every subsequent decryption to fail for the rest of the session.
|
||||
let header = parse_header(header_bytes)
|
||||
.ok_or_else(|| CryptoError::Internal("header too short to derive nonce".into()))?;
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, header.seq, Direction::Send);
|
||||
// Use Direction::Send to match the sender's nonce construction.
|
||||
// The recv_seq counter tracks which packet from the peer we're decrypting.
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, self.recv_seq, Direction::Send);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
use chacha20poly1305::aead::Payload;
|
||||
@@ -167,21 +116,8 @@ impl CryptoSession for ChaChaSession {
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
|
||||
let plaintext_len = plaintext.len();
|
||||
out.extend_from_slice(&plaintext);
|
||||
self.recv_seq = self.recv_seq.wrapping_add(1); // packet counter for rekey trigger only
|
||||
|
||||
// Anti-replay check: header already parsed above.
|
||||
let window = self
|
||||
.anti_replay
|
||||
.entry((header.stream_id, header.media_type))
|
||||
.or_insert_with(|| default_window_for_media_type(header.media_type));
|
||||
if let Err(e) = window.check_and_update(header.seq) {
|
||||
// Roll back the plaintext we just appended.
|
||||
out.truncate(out.len() - plaintext_len);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -199,14 +135,10 @@ impl CryptoSession for ChaChaSession {
|
||||
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
|
||||
|
||||
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
|
||||
let new_key = self
|
||||
.rekey_mgr
|
||||
.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
||||
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
||||
self.install_key(new_key);
|
||||
|
||||
// Reset sequence counters after rekey for nonce uniqueness.
|
||||
// last_encrypt_timestamp is intentionally NOT reset — spec requires
|
||||
// timestamp_ms to be monotonic across rekeys.
|
||||
// Reset sequence counters after rekey for nonce uniqueness
|
||||
self.send_seq = 0;
|
||||
self.recv_seq = 0;
|
||||
|
||||
@@ -221,42 +153,24 @@ impl CryptoSession for ChaChaSession {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
fn make_session_pair() -> (ChaChaSession, ChaChaSession) {
|
||||
let key = [0x42u8; 32];
|
||||
(ChaChaSession::new(key), ChaChaSession::new(key))
|
||||
}
|
||||
|
||||
/// Build a minimal valid v2 MediaHeader serialised to bytes.
|
||||
fn make_header_bytes(seq: u32) -> Vec<u8> {
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq,
|
||||
timestamp: seq.wrapping_mul(20),
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
header.write_to(&mut bytes);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = make_header_bytes(0);
|
||||
let header = b"test-header";
|
||||
let plaintext = b"hello warzone";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice.encrypt(&header, plaintext, &mut ciphertext).unwrap();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
// Bob decrypts (his recv matches Alice's send)
|
||||
let mut decrypted = Vec::new();
|
||||
bob.decrypt(&header, &ciphertext, &mut decrypted).unwrap();
|
||||
bob.decrypt(header, &ciphertext, &mut decrypted).unwrap();
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
}
|
||||
@@ -264,18 +178,14 @@ mod tests {
|
||||
#[test]
|
||||
fn decrypt_wrong_aad_fails() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let correct_header = make_header_bytes(0);
|
||||
// Different seq → different nonce AND different AAD bytes: decryption must fail.
|
||||
let wrong_header = make_header_bytes(1);
|
||||
let header = b"correct-header";
|
||||
let plaintext = b"secret data";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice
|
||||
.encrypt(&correct_header, plaintext, &mut ciphertext)
|
||||
.unwrap();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
let result = bob.decrypt(&wrong_header, &ciphertext, &mut decrypted);
|
||||
let result = bob.decrypt(b"wrong-header", &ciphertext, &mut decrypted);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -284,29 +194,29 @@ mod tests {
|
||||
let mut alice = ChaChaSession::new([0xAA; 32]);
|
||||
let mut eve = ChaChaSession::new([0xBB; 32]);
|
||||
|
||||
let header = make_header_bytes(0);
|
||||
let header = b"hdr";
|
||||
let plaintext = b"secret";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice.encrypt(&header, plaintext, &mut ciphertext).unwrap();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
let result = eve.decrypt(&header, &ciphertext, &mut decrypted);
|
||||
let result = eve.decrypt(header, &ciphertext, &mut decrypted);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_packets_roundtrip() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = b"hdr";
|
||||
|
||||
for i in 0..100u32 {
|
||||
let header = make_header_bytes(i);
|
||||
for i in 0..100 {
|
||||
let msg = format!("message {}", i);
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header, msg.as_bytes(), &mut ct).unwrap();
|
||||
alice.encrypt(header, msg.as_bytes(), &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(&header, &ct, &mut pt).unwrap();
|
||||
bob.decrypt(header, &ct, &mut pt).unwrap();
|
||||
assert_eq!(pt, msg.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -325,140 +235,4 @@ mod tests {
|
||||
// Session is now rekeyed - counters reset
|
||||
assert_eq!(alice.send_seq, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_survives_out_of_order_delivery() {
|
||||
// Regression test for nonce derivation using recv_seq instead of
|
||||
// MediaHeader.seq. If nonces are tied to a local counter, any reorder
|
||||
// causes the counter to diverge from the sender's seq and every
|
||||
// subsequent packet fails decryption permanently.
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
let key = [0x55u8; 32];
|
||||
let mut alice = ChaChaSession::new(key);
|
||||
let mut bob = ChaChaSession::new(key);
|
||||
|
||||
let plaintext = b"audio payload";
|
||||
|
||||
// Encrypt 5 packets in order (seqs 10, 11, 12, 13, 14).
|
||||
let seqs = [10u32, 11, 12, 13, 14];
|
||||
let mut ciphertexts: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
for &seq in &seqs {
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq,
|
||||
timestamp: seq * 20,
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut header_bytes = Vec::new();
|
||||
header.write_to(&mut header_bytes);
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
ciphertexts.push((header_bytes, ct));
|
||||
}
|
||||
|
||||
// Bob receives them out of order: 0, 2, 1, 4, 3
|
||||
let delivery_order = [0usize, 2, 1, 4, 3];
|
||||
for &idx in &delivery_order {
|
||||
let (ref hdr, ref ct) = ciphertexts[idx];
|
||||
let mut pt = Vec::new();
|
||||
let result = bob.decrypt(hdr, ct, &mut pt);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"out-of-order packet (original idx={idx}, seq={}) must decrypt successfully",
|
||||
seqs[idx]
|
||||
);
|
||||
assert_eq!(&pt, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_stream_anti_replay_rejects_duplicate() {
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 10,
|
||||
seq: 42,
|
||||
timestamp: 1000,
|
||||
fec_block: 0,
|
||||
};
|
||||
let mut header_bytes = Vec::new();
|
||||
header.write_to(&mut header_bytes);
|
||||
|
||||
let plaintext = b"audio frame";
|
||||
|
||||
// First packet decrypts successfully
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
||||
assert_eq!(&pt, plaintext);
|
||||
|
||||
// Exact duplicate is rejected by anti-replay
|
||||
let mut pt2 = Vec::new();
|
||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt2);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"duplicate packet with same seq must be rejected"
|
||||
);
|
||||
assert!(pt2.is_empty(), "plaintext must be rolled back on replay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_stream_anti_replay_video_burst_200_with_reorder() {
|
||||
use wzp_proto::{CodecId, MediaType};
|
||||
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Video,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 1,
|
||||
fec_ratio: 10,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
};
|
||||
|
||||
let plaintext = b"video frame";
|
||||
|
||||
// Send 200 packets in order
|
||||
for i in 0..200 {
|
||||
let mut h = header;
|
||||
h.seq = i;
|
||||
let mut header_bytes = Vec::new();
|
||||
h.write_to(&mut header_bytes);
|
||||
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
||||
}
|
||||
|
||||
// Re-send packet 50 — should be rejected as replay
|
||||
let mut h = header;
|
||||
h.seq = 50;
|
||||
let mut header_bytes = Vec::new();
|
||||
h.write_to(&mut header_bytes);
|
||||
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt);
|
||||
assert!(result.is_err(), "reordered duplicate must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! 3. Auth: WZP auth module request/response matches FC's /v1/auth/validate contract
|
||||
//! 4. Mnemonic: BIP39 interop between both implementations
|
||||
|
||||
use wzp_proto::{KeyExchange, default_signal_version};
|
||||
use wzp_proto::KeyExchange;
|
||||
|
||||
// ─── Identity Compatibility (WZP-FC-8) ──────────────────────────────────────
|
||||
|
||||
@@ -52,10 +52,7 @@ fn wzp_identity_module_matches_featherchat() {
|
||||
assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes());
|
||||
assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes());
|
||||
assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0);
|
||||
assert_eq!(
|
||||
wzp_pub.fingerprint.to_string(),
|
||||
fc_pub.fingerprint.to_string()
|
||||
);
|
||||
assert_eq!(wzp_pub.fingerprint.to_string(), fc_pub.fingerprint.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,14 +111,10 @@ fn mnemonic_strings_identical() {
|
||||
fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
||||
// WZP creates a CallOffer SignalMessage
|
||||
let offer = wzp_proto::SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [1u8; 32],
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
alias: None,
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
};
|
||||
|
||||
// Encode as featherChat CallSignal payload
|
||||
@@ -154,25 +147,16 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
||||
// And deserializes back
|
||||
let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap();
|
||||
if let warzone_protocol::message::WireMessage::CallSignal {
|
||||
id,
|
||||
payload: p,
|
||||
signal_type,
|
||||
..
|
||||
id, payload: p, signal_type, ..
|
||||
} = decoded
|
||||
{
|
||||
assert_eq!(id, "call-123");
|
||||
assert!(matches!(
|
||||
signal_type,
|
||||
warzone_protocol::message::CallSignalType::Offer
|
||||
));
|
||||
assert!(matches!(signal_type, warzone_protocol::message::CallSignalType::Offer));
|
||||
|
||||
// Decode the WZP payload back
|
||||
let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap();
|
||||
assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433");
|
||||
assert!(matches!(
|
||||
wzp_payload.signal,
|
||||
wzp_proto::SignalMessage::CallOffer { .. }
|
||||
));
|
||||
assert!(matches!(wzp_payload.signal, wzp_proto::SignalMessage::CallOffer { .. }));
|
||||
} else {
|
||||
panic!("expected CallSignal");
|
||||
}
|
||||
@@ -181,7 +165,6 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
||||
#[test]
|
||||
fn wzp_answer_round_trips_through_fc_callsignal() {
|
||||
let answer = wzp_proto::SignalMessage::CallAnswer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [10u8; 32],
|
||||
ephemeral_pub: [20u8; 32],
|
||||
signature: vec![30u8; 64],
|
||||
@@ -214,17 +197,12 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
|
||||
#[test]
|
||||
fn wzp_hangup_round_trips_through_fc_callsignal() {
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
|
||||
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
|
||||
let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup);
|
||||
assert!(matches!(
|
||||
signal_type,
|
||||
wzp_client::featherchat::CallSignalType::Hangup
|
||||
));
|
||||
assert!(matches!(signal_type, wzp_client::featherchat::CallSignalType::Hangup));
|
||||
|
||||
let fc_msg = warzone_protocol::message::WireMessage::CallSignal {
|
||||
id: "call-789".to_string(),
|
||||
@@ -239,10 +217,7 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
|
||||
|
||||
if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded {
|
||||
let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap();
|
||||
assert!(matches!(
|
||||
wzp.signal,
|
||||
wzp_proto::SignalMessage::Hangup { .. }
|
||||
));
|
||||
assert!(matches!(wzp.signal, wzp_proto::SignalMessage::Hangup { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +250,8 @@ fn auth_validate_response_matches_wzp_expectations() {
|
||||
"eth_address": null
|
||||
});
|
||||
|
||||
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
|
||||
let wzp_resp: wzp_relay::auth::ValidateResponse =
|
||||
serde_json::from_value(fc_response).unwrap();
|
||||
assert!(wzp_resp.valid);
|
||||
assert_eq!(
|
||||
wzp_resp.fingerprint.unwrap(),
|
||||
@@ -287,7 +263,8 @@ fn auth_validate_response_matches_wzp_expectations() {
|
||||
#[test]
|
||||
fn auth_invalid_response_matches() {
|
||||
let fc_response = serde_json::json!({ "valid": false });
|
||||
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
|
||||
let wzp_resp: wzp_relay::auth::ValidateResponse =
|
||||
serde_json::from_value(fc_response).unwrap();
|
||||
assert!(!wzp_resp.valid);
|
||||
assert!(wzp_resp.fingerprint.is_none());
|
||||
}
|
||||
@@ -296,27 +273,19 @@ fn auth_invalid_response_matches() {
|
||||
|
||||
#[test]
|
||||
fn all_signal_types_map_correctly() {
|
||||
use wzp_client::featherchat::signal_to_call_type;
|
||||
use wzp_client::featherchat::{signal_to_call_type, CallSignalType};
|
||||
|
||||
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
||||
(
|
||||
wzp_proto::SignalMessage::CallOffer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
alias: None,
|
||||
protocol_version: 2,
|
||||
supported_versions: vec![2],
|
||||
identity_pub: [0; 32], ephemeral_pub: [0; 32],
|
||||
signature: vec![], supported_profiles: vec![],
|
||||
},
|
||||
"Offer",
|
||||
),
|
||||
(
|
||||
wzp_proto::SignalMessage::CallAnswer {
|
||||
version: default_signal_version(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
identity_pub: [0; 32], ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
chosen_profile: wzp_proto::QualityProfile::GOOD,
|
||||
},
|
||||
@@ -324,16 +293,13 @@ fn all_signal_types_map_correctly() {
|
||||
),
|
||||
(
|
||||
wzp_proto::SignalMessage::IceCandidate {
|
||||
version: default_signal_version(),
|
||||
candidate: "candidate:1".to_string(),
|
||||
},
|
||||
"IceCandidate",
|
||||
),
|
||||
(
|
||||
wzp_proto::SignalMessage::Hangup {
|
||||
version: default_signal_version(),
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
},
|
||||
"Hangup",
|
||||
),
|
||||
@@ -342,10 +308,7 @@ fn all_signal_types_map_correctly() {
|
||||
for (signal, expected_name) in cases {
|
||||
let ct = signal_to_call_type(&signal);
|
||||
let name = format!("{ct:?}");
|
||||
assert_eq!(
|
||||
name, expected_name,
|
||||
"signal type mapping for {expected_name}"
|
||||
);
|
||||
assert_eq!(name, expected_name, "signal type mapping for {expected_name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +422,8 @@ fn auth_response_with_eth_address() {
|
||||
"alias": "vitalik",
|
||||
"eth_address": "0x1234567890abcdef1234567890abcdef12345678"
|
||||
});
|
||||
let resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_eth).unwrap();
|
||||
let resp: wzp_relay::auth::ValidateResponse =
|
||||
serde_json::from_value(with_eth).unwrap();
|
||||
assert!(resp.valid);
|
||||
assert_eq!(
|
||||
resp.fingerprint.unwrap(),
|
||||
@@ -474,7 +438,8 @@ fn auth_response_with_eth_address() {
|
||||
"alias": "anon",
|
||||
"eth_address": null
|
||||
});
|
||||
let resp2: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_null_eth).unwrap();
|
||||
let resp2: wzp_relay::auth::ValidateResponse =
|
||||
serde_json::from_value(with_null_eth).unwrap();
|
||||
assert!(resp2.valid);
|
||||
assert_eq!(
|
||||
resp2.fingerprint.unwrap(),
|
||||
@@ -485,15 +450,15 @@ fn auth_response_with_eth_address() {
|
||||
let without_eth = serde_json::json!({
|
||||
"valid": false
|
||||
});
|
||||
let resp3: wzp_relay::auth::ValidateResponse = serde_json::from_value(without_eth).unwrap();
|
||||
let resp3: wzp_relay::auth::ValidateResponse =
|
||||
serde_json::from_value(without_eth).unwrap();
|
||||
assert!(!resp3.valid);
|
||||
}
|
||||
|
||||
/// WZP-S-7: SignalMessage::AuthToken { version: default_signal_version(), token } exists and round-trips via serde.
|
||||
/// WZP-S-7: SignalMessage::AuthToken { token } exists and round-trips via serde.
|
||||
#[test]
|
||||
fn wzp_proto_has_auth_token_variant() {
|
||||
let msg = wzp_proto::SignalMessage::AuthToken {
|
||||
version: default_signal_version(),
|
||||
token: "fc-bearer-token-xyz".to_string(),
|
||||
};
|
||||
|
||||
@@ -504,7 +469,7 @@ fn wzp_proto_has_auth_token_variant() {
|
||||
|
||||
// Deserialize back
|
||||
let decoded: wzp_proto::SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
if let wzp_proto::SignalMessage::AuthToken { token, .. } = decoded {
|
||||
if let wzp_proto::SignalMessage::AuthToken { token } = decoded {
|
||||
assert_eq!(token, "fc-bearer-token-xyz");
|
||||
} else {
|
||||
panic!("expected AuthToken variant, got: {decoded:?}");
|
||||
@@ -527,11 +492,7 @@ fn all_fc_call_signal_types_representable() {
|
||||
(CallSignalType::Busy, "Busy"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
variants.len(),
|
||||
7,
|
||||
"featherChat defines exactly 7 call signal types"
|
||||
);
|
||||
assert_eq!(variants.len(), 7, "featherChat defines exactly 7 call signal types");
|
||||
|
||||
for (variant, expected_name) in &variants {
|
||||
let name = format!("{variant:?}");
|
||||
@@ -585,7 +546,10 @@ fn hash_room_name_used_as_sni_is_valid() {
|
||||
#[test]
|
||||
fn wzp_proto_cargo_toml_is_standalone() {
|
||||
// Try both paths (run from workspace root or from crate directory)
|
||||
let candidates = ["crates/wzp-proto/Cargo.toml", "../wzp-proto/Cargo.toml"];
|
||||
let candidates = [
|
||||
"crates/wzp-proto/Cargo.toml",
|
||||
"../wzp-proto/Cargo.toml",
|
||||
];
|
||||
|
||||
let contents = candidates
|
||||
.iter()
|
||||
|
||||
@@ -13,17 +13,11 @@ pub struct AdaptiveFec {
|
||||
pub repair_ratio: f32,
|
||||
/// Symbol size in bytes.
|
||||
pub symbol_size: u16,
|
||||
/// Repair ratio to use when the block contains a keyframe.
|
||||
/// Default 0.5 (50% overhead) — keyframes are critical and worth
|
||||
/// the extra bandwidth.
|
||||
pub keyframe_repair_ratio: f32,
|
||||
}
|
||||
|
||||
impl AdaptiveFec {
|
||||
/// Default symbol size for adaptive configuration.
|
||||
const DEFAULT_SYMBOL_SIZE: u16 = 256;
|
||||
/// Default keyframe repair ratio (PRD-video-v1 T4.5).
|
||||
const DEFAULT_KEYFRAME_REPAIR_RATIO: f32 = 0.5;
|
||||
|
||||
/// Create an adaptive FEC configuration from a quality profile.
|
||||
///
|
||||
@@ -36,15 +30,12 @@ impl AdaptiveFec {
|
||||
frames_per_block: profile.frames_per_block as usize,
|
||||
repair_ratio: profile.fec_ratio,
|
||||
symbol_size: Self::DEFAULT_SYMBOL_SIZE,
|
||||
keyframe_repair_ratio: Self::DEFAULT_KEYFRAME_REPAIR_RATIO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a configured FEC encoder from this adaptive configuration.
|
||||
pub fn build_encoder(&self) -> RaptorQFecEncoder {
|
||||
let mut enc = RaptorQFecEncoder::new(self.frames_per_block, self.symbol_size);
|
||||
enc.set_keyframe_ratio(self.keyframe_repair_ratio);
|
||||
enc
|
||||
RaptorQFecEncoder::new(self.frames_per_block, self.symbol_size)
|
||||
}
|
||||
|
||||
/// Get the repair ratio for use with `FecEncoder::generate_repair()`.
|
||||
@@ -68,7 +59,6 @@ mod tests {
|
||||
let cfg = AdaptiveFec::from_profile(&QualityProfile::GOOD);
|
||||
assert_eq!(cfg.frames_per_block, 5);
|
||||
assert!((cfg.repair_ratio - 0.2).abs() < f32::EPSILON);
|
||||
assert!((cfg.keyframe_repair_ratio - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||
use wzp_proto::FecDecoder;
|
||||
use wzp_proto::error::FecError;
|
||||
use wzp_proto::FecDecoder;
|
||||
|
||||
/// Length prefix size (u16 little-endian), must match encoder.
|
||||
const LEN_PREFIX: usize = 2;
|
||||
@@ -73,7 +73,7 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
fn add_symbol(
|
||||
&mut self,
|
||||
block_id: u8,
|
||||
symbol_index: u16,
|
||||
symbol_index: u8,
|
||||
_is_repair: bool,
|
||||
data: &[u8],
|
||||
) -> Result<(), FecError> {
|
||||
@@ -140,7 +140,10 @@ impl FecDecoder for RaptorQFecDecoder {
|
||||
frames.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
let payload_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
|
||||
let payload_len = u16::from_le_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
]) as usize;
|
||||
let payload_start = offset + LEN_PREFIX;
|
||||
let payload_end = (payload_start + payload_len).min(data.len());
|
||||
frames.push(data[payload_start..payload_end].to_vec());
|
||||
@@ -195,7 +198,9 @@ mod tests {
|
||||
|
||||
// Feed all source symbols (using the length-prefixed padded data).
|
||||
for (i, pkt) in source_pkts.iter().enumerate() {
|
||||
decoder.add_symbol(0, i as u16, false, pkt.data()).unwrap();
|
||||
decoder
|
||||
.add_symbol(0, i as u8, false, pkt.data())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = decoder.try_decode(0).unwrap();
|
||||
@@ -228,11 +233,7 @@ mod tests {
|
||||
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
|
||||
let mut dec = SourceBlockDecoder::new(0, &config, block_len);
|
||||
let decoded = dec.decode(all);
|
||||
assert!(
|
||||
decoded.is_some(),
|
||||
"Should recover with {:.0}% loss",
|
||||
drop_fraction * 100.0
|
||||
);
|
||||
assert!(decoded.is_some(), "Should recover with {:.0}% loss", drop_fraction * 100.0);
|
||||
|
||||
let data = decoded.unwrap();
|
||||
let ss = SYMBOL_SIZE as usize;
|
||||
@@ -244,19 +245,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_with_30pct_loss() {
|
||||
run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3);
|
||||
}
|
||||
fn decode_with_30pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3); }
|
||||
|
||||
#[test]
|
||||
fn decode_with_50pct_loss() {
|
||||
run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5);
|
||||
}
|
||||
fn decode_with_50pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5); }
|
||||
|
||||
#[test]
|
||||
fn decode_with_70pct_source_loss_heavy_repair() {
|
||||
run_loss_test(8, 2.0, 0.5);
|
||||
}
|
||||
fn decode_with_70pct_source_loss_heavy_repair() { run_loss_test(8, 2.0, 0.5); }
|
||||
|
||||
#[test]
|
||||
fn expire_removes_old_blocks() {
|
||||
@@ -293,10 +288,10 @@ mod tests {
|
||||
// Interleave symbols from block 0 and block 1
|
||||
for i in 0..FRAMES_PER_BLOCK {
|
||||
decoder
|
||||
.add_symbol(0, i as u16, false, pkts_a[i].data())
|
||||
.add_symbol(0, i as u8, false, pkts_a[i].data())
|
||||
.unwrap();
|
||||
decoder
|
||||
.add_symbol(1, i as u16, false, pkts_b[i].data())
|
||||
.add_symbol(1, i as u8, false, pkts_b[i].data())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
|
||||
|
||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
|
||||
use wzp_proto::FecEncoder;
|
||||
use wzp_proto::error::FecError;
|
||||
use wzp_proto::FecEncoder;
|
||||
|
||||
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
|
||||
/// but we pad to a uniform size within a block.
|
||||
@@ -23,11 +23,6 @@ pub struct RaptorQFecEncoder {
|
||||
source_symbols: Vec<Vec<u8>>,
|
||||
/// Symbol size used for encoding (all symbols padded to this size).
|
||||
symbol_size: u16,
|
||||
/// True if at least one source symbol in the current block is a keyframe.
|
||||
has_keyframe: bool,
|
||||
/// Repair ratio to use when the block contains a keyframe.
|
||||
/// If zero, the nominal ratio passed to [`generate_repair`] is used.
|
||||
keyframe_ratio: f32,
|
||||
}
|
||||
|
||||
impl RaptorQFecEncoder {
|
||||
@@ -41,26 +36,9 @@ impl RaptorQFecEncoder {
|
||||
frames_per_block,
|
||||
source_symbols: Vec::with_capacity(frames_per_block),
|
||||
symbol_size,
|
||||
has_keyframe: false,
|
||||
keyframe_ratio: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the repair ratio to use for blocks that contain at least one
|
||||
/// keyframe source symbol.
|
||||
///
|
||||
/// When `keyframe_ratio > 0.0` and [`has_keyframe`](Self::has_keyframe)
|
||||
/// is true, [`generate_repair`](FecEncoder::generate_repair) uses this
|
||||
/// ratio instead of the nominal ratio passed by the caller.
|
||||
pub fn set_keyframe_ratio(&mut self, ratio: f32) {
|
||||
self.keyframe_ratio = ratio.max(0.0);
|
||||
}
|
||||
|
||||
/// Returns true if the current block contains a keyframe source symbol.
|
||||
pub fn has_keyframe(&self) -> bool {
|
||||
self.has_keyframe
|
||||
}
|
||||
|
||||
/// Create with default symbol size (256 bytes).
|
||||
pub fn with_defaults(frames_per_block: usize) -> Self {
|
||||
Self::new(frames_per_block, DEFAULT_MAX_SYMBOL_SIZE)
|
||||
@@ -76,7 +54,8 @@ impl RaptorQFecEncoder {
|
||||
let payload_len = sym.len().min(max_payload);
|
||||
let offset = i * ss;
|
||||
// Write 2-byte little-endian length prefix.
|
||||
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||
data[offset..offset + LEN_PREFIX]
|
||||
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||
// Write payload after prefix.
|
||||
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||
.copy_from_slice(&sym[..payload_len]);
|
||||
@@ -96,36 +75,17 @@ impl FecEncoder for RaptorQFecEncoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_source_symbol_with_keyframe(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
is_keyframe: bool,
|
||||
) -> Result<(), FecError> {
|
||||
self.add_source_symbol(data)?;
|
||||
if is_keyframe {
|
||||
self.has_keyframe = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u16, Vec<u8>)>, FecError> {
|
||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError> {
|
||||
if self.source_symbols.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let effective_ratio = if self.has_keyframe && self.keyframe_ratio > 0.0 {
|
||||
self.keyframe_ratio
|
||||
} else {
|
||||
ratio
|
||||
};
|
||||
|
||||
let block_data = self.build_block_data();
|
||||
let config =
|
||||
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
|
||||
let config = ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
|
||||
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
|
||||
|
||||
let num_source = self.source_symbols.len() as u32;
|
||||
let num_repair = ((num_source as f32) * effective_ratio).ceil() as u32;
|
||||
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
||||
if num_repair == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
@@ -133,11 +93,11 @@ impl FecEncoder for RaptorQFecEncoder {
|
||||
// Generate repair packets starting from offset 0 (ESIs begin at num_source).
|
||||
let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair);
|
||||
|
||||
let result: Vec<(u16, Vec<u8>)> = repair_packets
|
||||
let result: Vec<(u8, Vec<u8>)> = repair_packets
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, pkt): (usize, EncodingPacket)| {
|
||||
let idx = (num_source as u16).wrapping_add(i as u16);
|
||||
let idx = (num_source as u8).wrapping_add(i as u8);
|
||||
(idx, pkt.data().to_vec())
|
||||
})
|
||||
.collect();
|
||||
@@ -149,7 +109,6 @@ impl FecEncoder for RaptorQFecEncoder {
|
||||
let completed = self.block_id;
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.source_symbols.clear();
|
||||
self.has_keyframe = false;
|
||||
Ok(completed)
|
||||
}
|
||||
|
||||
@@ -171,7 +130,8 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
|
||||
let max_payload = ss - LEN_PREFIX;
|
||||
let payload_len = sym.len().min(max_payload);
|
||||
let offset = i * ss;
|
||||
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||
data[offset..offset + LEN_PREFIX]
|
||||
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||
.copy_from_slice(&sym[..payload_len]);
|
||||
}
|
||||
@@ -251,54 +211,4 @@ mod tests {
|
||||
// After 256 blocks, wraps back to 0
|
||||
assert_eq!(enc.current_block_id(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_boost_uses_higher_ratio() {
|
||||
// Non-keyframe block with nominal ratio 0.2 → ceil(5 * 0.2) = 1 repair.
|
||||
let mut enc_normal = RaptorQFecEncoder::with_defaults(5);
|
||||
enc_normal.set_keyframe_ratio(0.8);
|
||||
for i in 0..5 {
|
||||
enc_normal
|
||||
.add_source_symbol_with_keyframe(&[i as u8; 100], false)
|
||||
.unwrap();
|
||||
}
|
||||
let normal_repair = enc_normal.generate_repair(0.2).unwrap();
|
||||
assert_eq!(normal_repair.len(), 1);
|
||||
|
||||
// Keyframe block with same nominal ratio but boost to 0.8 → ceil(5 * 0.8) = 4 repairs.
|
||||
let mut enc_key = RaptorQFecEncoder::with_defaults(5);
|
||||
enc_key.set_keyframe_ratio(0.8);
|
||||
for i in 0..5 {
|
||||
enc_key
|
||||
.add_source_symbol_with_keyframe(&[i as u8; 100], i == 2)
|
||||
.unwrap();
|
||||
}
|
||||
let keyframe_repair = enc_key.generate_repair(0.2).unwrap();
|
||||
assert_eq!(keyframe_repair.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_keyframe_block_uses_nominal_ratio() {
|
||||
let mut enc = RaptorQFecEncoder::with_defaults(5);
|
||||
enc.set_keyframe_ratio(0.8);
|
||||
|
||||
for i in 0..5 {
|
||||
enc.add_source_symbol_with_keyframe(&[i as u8; 100], false)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let repair = enc.generate_repair(0.2).unwrap();
|
||||
assert_eq!(repair.len(), 1); // ceil(5 * 0.2) = 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_clears_keyframe_flag() {
|
||||
let mut enc = RaptorQFecEncoder::with_defaults(2);
|
||||
enc.add_source_symbol_with_keyframe(&[0u8; 10], true)
|
||||
.unwrap();
|
||||
assert!(enc.has_keyframe());
|
||||
|
||||
enc.finalize_block().unwrap();
|
||||
assert!(!enc.has_keyframe());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,7 @@ mod tests {
|
||||
|
||||
// Each block should lose exactly 2 (6 losses / 3 blocks)
|
||||
for &loss in &losses_per_block {
|
||||
assert_eq!(
|
||||
loss, 2,
|
||||
"Each block should lose at most 2 symbols from a burst of 6"
|
||||
);
|
||||
assert_eq!(loss, 2, "Each block should lose at most 2 symbols from a burst of 6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ pub mod encoder;
|
||||
pub mod interleave;
|
||||
|
||||
pub use adaptive::AdaptiveFec;
|
||||
pub use block_manager::{
|
||||
DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState,
|
||||
};
|
||||
pub use block_manager::{DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState};
|
||||
pub use decoder::RaptorQFecDecoder;
|
||||
pub use encoder::RaptorQFecEncoder;
|
||||
pub use interleave::Interleaver;
|
||||
@@ -26,7 +24,9 @@ pub use interleave::Interleaver;
|
||||
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
|
||||
|
||||
/// Create an encoder/decoder pair configured for the given quality profile.
|
||||
pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
|
||||
pub fn create_fec_pair(
|
||||
profile: &QualityProfile,
|
||||
) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
|
||||
let cfg = AdaptiveFec::from_profile(profile);
|
||||
let encoder = cfg.build_encoder();
|
||||
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[package]
|
||||
name = "wzp-native"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
|
||||
|
||||
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
|
||||
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
|
||||
# standalone .so, which is the same path the legacy wzp-android crate uses
|
||||
# successfully on the same phone / same NDK. Keeping the crate-type single
|
||||
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
|
||||
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
|
||||
# archive pulled bionic's internal pthread_create into the final .so.
|
||||
[lib]
|
||||
name = "wzp_native"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[build-dependencies]
|
||||
# cc is SAFE to use here because this crate is a single-cdylib: no
|
||||
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
|
||||
# legacy wzp-android crate uses the same setup and works.
|
||||
cc = "1"
|
||||
|
||||
[dependencies]
|
||||
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
|
||||
# audio pipeline via extern "C" into the bundled C++ and expose our own
|
||||
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
|
||||
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
|
||||
# instead of calling back into wzp-desktop via callbacks.
|
||||
@@ -1,134 +0,0 @@
|
||||
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
|
||||
//!
|
||||
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
|
||||
//! work). The crucial distinction: this crate is a single-cdylib (no
|
||||
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
|
||||
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
|
||||
//! UND and resolve against libc.so at runtime, as they should.
|
||||
//!
|
||||
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
|
||||
//! `cargo check --target <host>` still works for IDEs and CI.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
|
||||
if target.contains("android") {
|
||||
// getauxval_fix: override compiler-rt's broken static getauxval
|
||||
// stub that SIGSEGVs in shared libraries.
|
||||
cc::Build::new()
|
||||
.file("cpp/getauxval_fix.c")
|
||||
.compile("wzp_native_getauxval_fix");
|
||||
|
||||
let oboe_dir = fetch_oboe();
|
||||
match oboe_dir {
|
||||
Some(oboe_path) => {
|
||||
println!(
|
||||
"cargo:warning=wzp-native: building with Oboe from {:?}",
|
||||
oboe_path
|
||||
);
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
// Shared libc++ — matches legacy wzp-android setup.
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.include("cpp")
|
||||
.include(oboe_path.join("include"))
|
||||
.include(oboe_path.join("src"))
|
||||
.define("WZP_HAS_OBOE", None)
|
||||
.file("cpp/oboe_bridge.cpp");
|
||||
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
|
||||
build.compile("wzp_native_oboe_bridge");
|
||||
}
|
||||
None => {
|
||||
println!("cargo:warning=wzp-native: Oboe not found, building stub");
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("wzp_native_oboe_bridge");
|
||||
}
|
||||
}
|
||||
|
||||
// Oboe needs log + OpenSLES backends at runtime.
|
||||
println!("cargo:rustc-link-lib=log");
|
||||
println!("cargo:rustc-link-lib=OpenSLES");
|
||||
|
||||
// Re-run if any cpp file changes
|
||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
|
||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
|
||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
||||
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
||||
} else {
|
||||
// Non-Android hosts: compile the empty stub so lib.rs's extern
|
||||
// declarations resolve when someone runs `cargo check` on macOS
|
||||
// or Linux without an NDK.
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("wzp_native_oboe_bridge");
|
||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively add all `.cpp` files from a directory to a cc::Build.
|
||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
||||
if !dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_cpp_files_recursive(build, &path);
|
||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
||||
build.file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
|
||||
/// legacy wzp-android crate's build.rs.
|
||||
fn fetch_oboe() -> Option<PathBuf> {
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let oboe_dir = out_dir.join("oboe");
|
||||
|
||||
if oboe_dir
|
||||
.join("include")
|
||||
.join("oboe")
|
||||
.join("Oboe.h")
|
||||
.exists()
|
||||
{
|
||||
return Some(oboe_dir);
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"--branch=1.8.1",
|
||||
"https://github.com/google/oboe.git",
|
||||
oboe_dir.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s)
|
||||
if s.success()
|
||||
&& oboe_dir
|
||||
.join("include")
|
||||
.join("oboe")
|
||||
.join("Oboe.h")
|
||||
.exists() =>
|
||||
{
|
||||
Some(oboe_dir)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Override the broken static getauxval from compiler-rt/CRT.
|
||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
||||
// This version calls the real bionic getauxval via dlsym.
|
||||
#ifdef __ANDROID__
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
||||
|
||||
unsigned long getauxval(unsigned long type) {
|
||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
||||
if (!real_getauxval) {
|
||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
||||
if (!real_getauxval) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return real_getauxval(type);
|
||||
}
|
||||
#endif
|
||||
@@ -1,491 +0,0 @@
|
||||
// Full Oboe implementation for Android
|
||||
// This file is compiled only when targeting Android
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <oboe/Oboe.h>
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
#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::Shared)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
|
||||
->setDataCallback(&g_capture_cb);
|
||||
|
||||
if (config->bt_active) {
|
||||
// BT SCO mode: do NOT set sample rate or input preset.
|
||||
// Requesting 48kHz against a BT SCO device fails with
|
||||
// "getInputProfile could not find profile". Letting the system
|
||||
// choose the native rate (8/16kHz) and relying on Oboe's
|
||||
// resampler (SampleRateConversionQuality::Best) to bridge
|
||||
// to our 48kHz ring buffer is the only path that works.
|
||||
// InputPreset::VoiceCommunication can also prevent BT SCO
|
||||
// routing on some devices — skip it for BT.
|
||||
LOGI("capture: BT mode — no sample rate or input preset set");
|
||||
} else {
|
||||
captureBuilder.setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setInputPreset(oboe::InputPreset::VoiceCommunication);
|
||||
}
|
||||
|
||||
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::Shared)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
|
||||
->setDataCallback(&g_playout_cb);
|
||||
|
||||
if (config->bt_active) {
|
||||
LOGI("playout: BT mode — no sample rate set, using Usage::Media");
|
||||
// Usage::Media instead of VoiceCommunication for BT output
|
||||
// to avoid conflicts with the communication device routing.
|
||||
playoutBuilder.setUsage(oboe::Usage::Media);
|
||||
} else {
|
||||
playoutBuilder.setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setUsage(oboe::Usage::VoiceCommunication);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Log initial stream states right after requestStart() returns.
|
||||
// On well-behaved HALs both will already be Started; on others
|
||||
// (Nothing A059) they may still be in Starting state.
|
||||
LOGI("requestStart returned: capture_state=%d playout_state=%d",
|
||||
(int)g_capture_stream->getState(),
|
||||
(int)g_playout_stream->getState());
|
||||
|
||||
// Poll until both streams report Started state, up to 2s timeout.
|
||||
// Some Android HALs (Nothing A059) delay transitioning from Starting
|
||||
// to Started; proceeding before the transition completes causes the
|
||||
// first capture/playout callbacks to be dropped silently.
|
||||
{
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
|
||||
int poll_count = 0;
|
||||
bool streams_started = false;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto cap_state = g_capture_stream->getState();
|
||||
auto play_state = g_playout_stream->getState();
|
||||
if (cap_state == oboe::StreamState::Started &&
|
||||
play_state == oboe::StreamState::Started) {
|
||||
LOGI("both streams Started after %d polls", poll_count);
|
||||
streams_started = true;
|
||||
break;
|
||||
}
|
||||
poll_count++;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
// Log final state even on timeout (helps diagnose HAL quirks)
|
||||
LOGI("stream states after poll: capture=%d playout=%d (polls=%d)",
|
||||
(int)g_capture_stream->getState(),
|
||||
(int)g_playout_stream->getState(),
|
||||
poll_count);
|
||||
if (!streams_started) {
|
||||
LOGE("Timed out waiting for Oboe streams to reach Started state");
|
||||
g_running.store(false, std::memory_order_release);
|
||||
g_rings_valid.store(false, std::memory_order_release);
|
||||
g_capture_stream->requestStop();
|
||||
g_playout_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_playout_stream->close();
|
||||
g_capture_stream.reset();
|
||||
g_playout_stream.reset();
|
||||
return -6;
|
||||
}
|
||||
}
|
||||
|
||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
g_running.store(false, std::memory_order_release);
|
||||
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
|
||||
// the streams, so any in-flight callback returns Stop instead of reading
|
||||
// stale pointers.
|
||||
g_rings_valid.store(false, std::memory_order_release);
|
||||
|
||||
if (g_capture_stream) {
|
||||
g_capture_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_capture_stream.reset();
|
||||
}
|
||||
if (g_playout_stream) {
|
||||
g_playout_stream->requestStop();
|
||||
g_playout_stream->close();
|
||||
g_playout_stream.reset();
|
||||
}
|
||||
|
||||
LOGI("Oboe stopped");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
#else
|
||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
||||
// Provide empty implementations just in case.
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config; (void)rings;
|
||||
return -99;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {}
|
||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
||||
int wzp_oboe_is_running(void) { return 0; }
|
||||
|
||||
#endif // __ANDROID__
|
||||
@@ -1,44 +0,0 @@
|
||||
#ifndef WZP_OBOE_BRIDGE_H
|
||||
#define WZP_OBOE_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
||||
extern "C" {
|
||||
#else
|
||||
#include <stdatomic.h>
|
||||
typedef atomic_int wzp_atomic_int;
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int32_t sample_rate;
|
||||
int32_t frames_per_burst;
|
||||
int32_t channel_count;
|
||||
int32_t bt_active; /* nonzero = BT SCO mode: skip sample rate + input preset */
|
||||
} WzpOboeConfig;
|
||||
|
||||
typedef struct {
|
||||
int16_t* capture_buf;
|
||||
int32_t capture_capacity;
|
||||
wzp_atomic_int* capture_write_idx;
|
||||
wzp_atomic_int* capture_read_idx;
|
||||
|
||||
int16_t* playout_buf;
|
||||
int32_t playout_capacity;
|
||||
wzp_atomic_int* playout_write_idx;
|
||||
wzp_atomic_int* playout_read_idx;
|
||||
} WzpOboeRings;
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
||||
void wzp_oboe_stop(void);
|
||||
float wzp_oboe_capture_latency_ms(void);
|
||||
float wzp_oboe_playout_latency_ms(void);
|
||||
int wzp_oboe_is_running(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // WZP_OBOE_BRIDGE_H
|
||||
@@ -1,27 +0,0 @@
|
||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
#include <stdio.h>
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config;
|
||||
(void)rings;
|
||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return 0;
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
//! wzp-native — standalone Android cdylib for all the C++ audio code.
|
||||
//!
|
||||
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
|
||||
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
|
||||
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
|
||||
//!
|
||||
//! Phase 2: real Oboe audio backend.
|
||||
//!
|
||||
//! Architecture: Oboe runs capture + playout streams on its own high-
|
||||
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
|
||||
//! buffers (capture and playout) are shared between the C++ callbacks
|
||||
//! and the Rust side via atomic indices — no locks on the hot path.
|
||||
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
|
||||
//! the playout ring with decoded PCM.
|
||||
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
|
||||
|
||||
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
|
||||
/// work before any audio code runs.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn wzp_native_version() -> i32 {
|
||||
42
|
||||
}
|
||||
|
||||
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
|
||||
/// returns bytes written excluding the NUL.
|
||||
///
|
||||
/// # Safety
|
||||
/// `out` must be a valid pointer to at least `cap` contiguous bytes of
|
||||
/// writable memory. Passing a null pointer or zero capacity is safe
|
||||
/// (returns 0), but a dangling non-null pointer is undefined behaviour.
|
||||
#[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,
|
||||
/// When nonzero, capture stream skips setSampleRate and setInputPreset
|
||||
/// so the system can route to BT SCO at its native rate (8/16kHz).
|
||||
/// Oboe's SampleRateConversionQuality::Best resamples to 48kHz.
|
||||
bt_active: 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,
|
||||
/// Fix A (task #35): the playout ring's read_idx at the last
|
||||
/// check. If audio_write_playout observes read_idx hasn't
|
||||
/// advanced after N writes, the Oboe playout callback has
|
||||
/// stopped firing → restart the streams.
|
||||
playout_last_read_idx: std::sync::atomic::AtomicI32,
|
||||
/// Number of writes since the last read_idx advance.
|
||||
playout_stall_writes: std::sync::atomic::AtomicU32,
|
||||
}
|
||||
|
||||
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),
|
||||
playout_last_read_idx: std::sync::atomic::AtomicI32::new(0),
|
||||
playout_stall_writes: std::sync::atomic::AtomicU32::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 {
|
||||
audio_start_inner(false)
|
||||
}
|
||||
|
||||
/// Start Oboe in Bluetooth SCO mode — skips sample rate and input preset
|
||||
/// on capture so the system can route to the BT SCO device natively.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn wzp_native_audio_start_bt() -> i32 {
|
||||
audio_start_inner(true)
|
||||
}
|
||||
|
||||
fn audio_start_inner(bt: bool) -> 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,
|
||||
bt_active: if bt { 1 } else { 0 },
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of capture samples available to read without blocking.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn wzp_native_audio_capture_available() -> usize {
|
||||
backend().capture.available_read()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// # Safety
|
||||
/// `out` must be a valid pointer to `out_len` contiguous `i16` values.
|
||||
/// The caller must ensure no other thread writes to the same buffer
|
||||
/// concurrently. Passing a null pointer or zero length is safe (returns 0).
|
||||
#[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).
|
||||
///
|
||||
/// # Safety
|
||||
/// `input` must be a valid pointer to `in_len` contiguous `i16` values
|
||||
/// that remain valid for the duration of the call. Passing a null pointer
|
||||
/// or zero length is safe (returns 0). The caller must not free or mutate
|
||||
/// the buffer while this function is executing.
|
||||
#[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();
|
||||
|
||||
// Fix A (task #35): detect playout callback stall. If the
|
||||
// playout ring's read_idx hasn't advanced in 50+ writes
|
||||
// (~1 second at 50 writes/sec), the Oboe playout callback
|
||||
// has stopped firing → restart the streams. This is the
|
||||
// self-healing behavior that makes rejoin work: teardown +
|
||||
// rebuild clears whatever HAL state locked up the callback.
|
||||
let current_read_idx = b
|
||||
.playout
|
||||
.read_idx
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let last_read_idx = b
|
||||
.playout_last_read_idx
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if current_read_idx == last_read_idx {
|
||||
let stall = b
|
||||
.playout_stall_writes
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if stall >= 50 {
|
||||
// Callback hasn't drained anything in ~1 second.
|
||||
// Force a stream restart.
|
||||
unsafe {
|
||||
android_log(
|
||||
"playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams",
|
||||
);
|
||||
}
|
||||
b.playout_stall_writes
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
// Release the started lock, stop, re-start.
|
||||
// This is the same logic as the Rust-side
|
||||
// audio_stop() + audio_start() but done inline
|
||||
// because we can't call the extern "C" fns
|
||||
// recursively. Just call the C++ side directly.
|
||||
{
|
||||
if let Ok(mut started) = b.started.lock() {
|
||||
if *started {
|
||||
unsafe { wzp_oboe_stop() };
|
||||
*started = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear the rings so the restart doesn't read stale data
|
||||
b.playout
|
||||
.write_idx
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
b.playout
|
||||
.read_idx
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
b.capture
|
||||
.write_idx
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
b.capture
|
||||
.read_idx
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
// Re-start (stall detector — always non-BT mode)
|
||||
let config = WzpOboeConfig {
|
||||
sample_rate: 48_000,
|
||||
frames_per_burst: FRAME_SAMPLES as i32,
|
||||
channel_count: 1,
|
||||
bt_active: 0,
|
||||
};
|
||||
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 {
|
||||
if let Ok(mut started) = b.started.lock() {
|
||||
*started = true;
|
||||
}
|
||||
unsafe {
|
||||
android_log("playout restart OK — Oboe streams rebuilt");
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
android_log(&format!("playout restart FAILED: {ret}"));
|
||||
}
|
||||
}
|
||||
b.playout_last_read_idx
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
return 0; // caller will retry on next frame
|
||||
}
|
||||
} else {
|
||||
// read_idx advanced — callback is alive, reset counter
|
||||
b.playout_stall_writes
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
b.playout_last_read_idx
|
||||
.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
@@ -20,4 +20,3 @@ tracing = "0.1"
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
//! Control (GCC).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::QualityProfile;
|
||||
use crate::packet::QualityReport;
|
||||
use crate::QualityProfile;
|
||||
|
||||
/// Network congestion state derived from delay and loss signals.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -159,16 +158,6 @@ pub struct BandwidthEstimator {
|
||||
loss_detector: LossBasedDetector,
|
||||
/// Last update timestamp.
|
||||
last_update: Option<Instant>,
|
||||
|
||||
// ── Transport-feedback BWE (T2.2) ──
|
||||
/// Congestion-window-derived bandwidth estimate in bits per second.
|
||||
cwnd_bps: AtomicU64,
|
||||
/// Peer REMB (Receiver Estimated Maximum Bitrate) in bits per second.
|
||||
peer_remb_bps: AtomicU64,
|
||||
/// EWMA-smoothed bandwidth estimate in bits per second.
|
||||
smoothed_bps: AtomicU64,
|
||||
/// Last time `smoothed_bps` was updated (UNIX epoch millis).
|
||||
last_smoothed_ms: AtomicU64,
|
||||
}
|
||||
|
||||
/// Multiplicative decrease factor applied on congestion (15% reduction).
|
||||
@@ -190,10 +179,6 @@ impl BandwidthEstimator {
|
||||
delay_detector: DelayBasedDetector::new(),
|
||||
loss_detector: LossBasedDetector::new(),
|
||||
last_update: None,
|
||||
cwnd_bps: AtomicU64::new(0),
|
||||
peer_remb_bps: AtomicU64::new(u64::MAX),
|
||||
smoothed_bps: AtomicU64::new(0),
|
||||
last_smoothed_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,64 +250,6 @@ impl BandwidthEstimator {
|
||||
QualityProfile::CATASTROPHIC
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transport-feedback BWE (T2.2) ──
|
||||
|
||||
/// Update from QUIC path stats.
|
||||
///
|
||||
/// Computes `cwnd_bps = cwnd_bytes * 8 / rtt_s` and feeds it into the
|
||||
/// smoothed estimate.
|
||||
pub fn update_from_path(&self, cwnd_bytes: u64, _bytes_in_flight: u64, rtt_ms: u32) {
|
||||
let rtt_s = rtt_ms.max(1) as f64 / 1000.0;
|
||||
let cwnd_bps = ((cwnd_bytes * 8) as f64 / rtt_s) as u64;
|
||||
self.cwnd_bps.store(cwnd_bps, Relaxed);
|
||||
self.update_smoothed(cwnd_bps);
|
||||
}
|
||||
|
||||
/// Update from a peer's `TransportFeedback` REMB value.
|
||||
pub fn update_from_peer(&self, fb_remb_bps: u32) {
|
||||
let remb = fb_remb_bps as u64;
|
||||
self.peer_remb_bps.store(remb, Relaxed);
|
||||
self.update_smoothed(remb);
|
||||
}
|
||||
|
||||
/// Target sending bitrate in bits per second.
|
||||
///
|
||||
/// Returns 90% of the minimum between the congestion-window estimate
|
||||
/// and the peer REMB estimate.
|
||||
pub fn target_send_bps(&self) -> u64 {
|
||||
let cwnd = self.cwnd_bps.load(Relaxed);
|
||||
let remb = self.peer_remb_bps.load(Relaxed);
|
||||
let m = cwnd.min(remb);
|
||||
(m as f64 * 0.9) as u64
|
||||
}
|
||||
|
||||
/// EWMA-smoothed bandwidth estimate in bits per second.
|
||||
pub fn smoothed_bps(&self) -> u64 {
|
||||
self.smoothed_bps.load(Relaxed)
|
||||
}
|
||||
|
||||
/// Apply EWMA smoothing with a 2-second half-life.
|
||||
fn update_smoothed(&self, new_bps: u64) {
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let last_ms = self.last_smoothed_ms.load(Relaxed);
|
||||
let dt_ms = now_ms.saturating_sub(last_ms);
|
||||
|
||||
let current = self.smoothed_bps.load(Relaxed);
|
||||
let updated = if current == 0 || dt_ms == 0 {
|
||||
new_bps
|
||||
} else {
|
||||
let alpha = 1.0 - 0.5_f64.powf(dt_ms as f64 / 2000.0);
|
||||
let s = current as f64 * (1.0 - alpha) + new_bps as f64 * alpha;
|
||||
s as u64
|
||||
};
|
||||
|
||||
self.smoothed_bps.store(updated, Relaxed);
|
||||
self.last_smoothed_ms.store(now_ms, Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -469,7 +396,10 @@ mod tests {
|
||||
|
||||
// Below 8 => CATASTROPHIC
|
||||
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
|
||||
assert_eq!(bwe_cat.recommended_profile(), QualityProfile::CATASTROPHIC);
|
||||
assert_eq!(
|
||||
bwe_cat.recommended_profile(),
|
||||
QualityProfile::CATASTROPHIC
|
||||
);
|
||||
|
||||
// High bandwidth
|
||||
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
|
||||
@@ -483,7 +413,7 @@ mod tests {
|
||||
// Build a QualityReport with moderate loss and RTT.
|
||||
let report = QualityReport {
|
||||
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
|
||||
rtt_4ms: 25, // 100ms RTT
|
||||
rtt_4ms: 25, // 100ms RTT
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 200,
|
||||
};
|
||||
@@ -521,46 +451,4 @@ mod tests {
|
||||
}
|
||||
assert!(det.is_congested());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_send_bps_uses_min_of_cwnd_and_remb() {
|
||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
||||
// cwnd_bps = 100_000, remb = 200_000 → min = 100_000 → 90%
|
||||
bwe.update_from_path(1250, 0, 100); // 1250*8 / 0.1 = 100_000
|
||||
bwe.update_from_peer(200_000);
|
||||
assert_eq!(bwe.target_send_bps(), 90_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_send_bps_with_zero_cwnd_uses_remb() {
|
||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
||||
// Default cwnd is 0, remb is u64::MAX (default).
|
||||
// 0.min(u64::MAX) = 0 → 90% = 0
|
||||
assert_eq!(bwe.target_send_bps(), 0);
|
||||
|
||||
bwe.update_from_peer(100_000);
|
||||
// cwnd still 0
|
||||
assert_eq!(bwe.target_send_bps(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothed_bps_ewma_converges() {
|
||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
||||
bwe.update_from_path(1250, 0, 100); // 100_000 bps
|
||||
let s1 = bwe.smoothed_bps();
|
||||
assert_eq!(s1, 100_000);
|
||||
|
||||
// Immediately update with same value — dt ≈ 0, so should stay at 100_000
|
||||
bwe.update_from_path(1250, 0, 100);
|
||||
let s2 = bwe.smoothed_bps();
|
||||
assert_eq!(s2, 100_000);
|
||||
|
||||
// Sleep a bit so dt is non-zero, then update with a much higher value.
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
bwe.update_from_path(12500, 0, 100); // 1_000_000 bps
|
||||
let s3 = bwe.smoothed_bps();
|
||||
assert!(s3 > 100_000, "smoothed should increase toward 1M: {s3}");
|
||||
// With 100ms dt, alpha ≈ 0.03, so smoothed should be ~100k * 0.97 + 1M * 0.03 ≈ 127k
|
||||
assert!(s3 < 500_000, "smoothed should not jump too far: {s3}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identifies the audio codec and bitrate configuration.
|
||||
///
|
||||
/// Encoded as 4 bits in the v1 media packet header, and as a full 8-bit
|
||||
/// value in the v2 [`MediaHeaderV2`](crate::MediaHeaderV2).
|
||||
/// Encoded as 4 bits in the media packet header.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum CodecId {
|
||||
@@ -25,16 +24,6 @@ pub enum CodecId {
|
||||
Opus48k = 7,
|
||||
/// Opus at 64kbps (studio high)
|
||||
Opus64k = 8,
|
||||
/// H.264 baseline profile (video).
|
||||
H264Baseline = 9,
|
||||
// Reserved for video codecs; implementations land in PRD-video-multicodec.
|
||||
// 10 => H264 main
|
||||
// 11 => H265 main
|
||||
// 13 => VP9
|
||||
/// AV1 main profile (video).
|
||||
Av1Main = 12,
|
||||
/// H.265 main profile (video).
|
||||
H265Main = 11,
|
||||
}
|
||||
|
||||
impl CodecId {
|
||||
@@ -50,7 +39,6 @@ impl CodecId {
|
||||
Self::Codec2_3200 => 3_200,
|
||||
Self::Codec2_1200 => 1_200,
|
||||
Self::ComfortNoise => 0,
|
||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 2_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,22 +50,16 @@ impl CodecId {
|
||||
Self::Codec2_3200 => 20,
|
||||
Self::Codec2_1200 => 40,
|
||||
Self::ComfortNoise => 20,
|
||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 33,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample rate expected by this codec.
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus24k
|
||||
| Self::Opus16k
|
||||
| Self::Opus6k
|
||||
| Self::Opus32k
|
||||
| Self::Opus48k
|
||||
| Self::Opus64k => 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,
|
||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 48_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,9 +75,6 @@ impl CodecId {
|
||||
6 => Some(Self::Opus32k),
|
||||
7 => Some(Self::Opus48k),
|
||||
8 => Some(Self::Opus64k),
|
||||
9 => Some(Self::H264Baseline),
|
||||
11 => Some(Self::H265Main),
|
||||
12 => Some(Self::Av1Main),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -105,22 +84,10 @@ impl CodecId {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Returns true if this is a video codec variant.
|
||||
pub const fn is_video(self) -> bool {
|
||||
matches!(self, Self::H264Baseline | Self::H265Main | Self::Av1Main)
|
||||
}
|
||||
|
||||
/// 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
|
||||
)
|
||||
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,18 +102,6 @@ pub struct QualityProfile {
|
||||
pub frame_duration_ms: u8,
|
||||
/// Number of source frames per FEC block.
|
||||
pub frames_per_block: u8,
|
||||
/// Bandwidth-allocation priority between audio and video.
|
||||
#[serde(default)]
|
||||
pub priority_mode: crate::PriorityMode,
|
||||
/// Target video bitrate in kbps (set by quality controller, not handshake).
|
||||
#[serde(default)]
|
||||
pub video_bitrate_kbps: Option<u32>,
|
||||
/// Target video resolution as (width, height).
|
||||
#[serde(default)]
|
||||
pub video_resolution: Option<(u16, u16)>,
|
||||
/// Target video frame rate.
|
||||
#[serde(default)]
|
||||
pub video_fps: Option<u8>,
|
||||
}
|
||||
|
||||
impl QualityProfile {
|
||||
@@ -156,10 +111,6 @@ impl QualityProfile {
|
||||
fec_ratio: 0.2,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Degraded conditions: Opus 6kbps, moderate FEC.
|
||||
@@ -168,10 +119,6 @@ impl QualityProfile {
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 40,
|
||||
frames_per_block: 10,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Catastrophic conditions: Codec2 1.2kbps, heavy FEC.
|
||||
@@ -180,10 +127,6 @@ impl QualityProfile {
|
||||
fec_ratio: 1.0,
|
||||
frame_duration_ms: 40,
|
||||
frames_per_block: 8,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Studio low: Opus 32kbps, minimal FEC.
|
||||
@@ -192,10 +135,6 @@ impl QualityProfile {
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Studio: Opus 48kbps, minimal FEC.
|
||||
@@ -204,10 +143,6 @@ impl QualityProfile {
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Studio high: Opus 64kbps, minimal FEC.
|
||||
@@ -216,10 +151,6 @@ impl QualityProfile {
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
priority_mode: crate::PriorityMode::AudioFirst,
|
||||
video_bitrate_kbps: None,
|
||||
video_resolution: None,
|
||||
video_fps: None,
|
||||
};
|
||||
|
||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||
@@ -228,46 +159,3 @@ impl QualityProfile {
|
||||
base * (1.0 + self.fec_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CodecId, QualityProfile};
|
||||
use crate::PriorityMode;
|
||||
|
||||
#[test]
|
||||
fn codec_id_unknown_values_rejected() {
|
||||
for v in [10u8, 13].iter().copied().chain(14u8..=255) {
|
||||
assert!(CodecId::from_wire(v).is_none(), "v={v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h265_main_roundtrips() {
|
||||
assert_eq!(CodecId::H265Main.to_wire(), 11);
|
||||
assert_eq!(CodecId::from_wire(11), Some(CodecId::H265Main));
|
||||
assert!(CodecId::H265Main.is_video());
|
||||
assert_eq!(CodecId::H265Main.bitrate_bps(), 2_000_000);
|
||||
assert_eq!(CodecId::H265Main.frame_duration_ms(), 33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_main_roundtrips() {
|
||||
assert_eq!(CodecId::Av1Main.to_wire(), 12);
|
||||
assert_eq!(CodecId::from_wire(12), Some(CodecId::Av1Main));
|
||||
assert!(CodecId::Av1Main.is_video());
|
||||
assert_eq!(CodecId::Av1Main.bitrate_bps(), 2_000_000);
|
||||
assert_eq!(CodecId::Av1Main.frame_duration_ms(), 33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_profile_backward_compat_old_json() {
|
||||
// Old JSON emitted before T5.1 has no priority_mode or video fields.
|
||||
let old_json =
|
||||
r#"{"codec":"Opus24k","fec_ratio":0.2,"frame_duration_ms":20,"frames_per_block":5}"#;
|
||||
let parsed: QualityProfile = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.priority_mode, PriorityMode::AudioFirst);
|
||||
assert_eq!(parsed.video_bitrate_kbps, None);
|
||||
assert_eq!(parsed.video_resolution, None);
|
||||
assert_eq!(parsed.video_fps, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
//! Continuous DRED tuning from real-time network metrics.
|
||||
//!
|
||||
//! Instead of locking DRED duration to 3 discrete quality tiers (100/200/500 ms),
|
||||
//! `DredTuner` maps live path quality metrics to a continuous DRED duration and
|
||||
//! expected-loss hint, updated every N packets. This makes DRED reactive within
|
||||
//! ~200 ms instead of waiting for 3+ consecutive bad quality reports to trigger
|
||||
//! a full tier transition.
|
||||
//!
|
||||
//! The tuner also implements pre-emptive jitter-spike detection ("sawtooth"
|
||||
//! prediction): when jitter variance spikes >30% over a 200 ms window — typical
|
||||
//! of Starlink satellite handovers — it temporarily boosts DRED to the maximum
|
||||
//! allowed for the current codec before packets actually start dropping.
|
||||
//!
|
||||
//! See also: [`crate::quality`] for discrete tier classification that drives
|
||||
//! codec switching. DredTuner operates within a tier, adjusting DRED
|
||||
//! parameters continuously based on live network metrics.
|
||||
|
||||
use crate::CodecId;
|
||||
|
||||
/// Output of a single tuning cycle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DredTuning {
|
||||
/// DRED duration in 10 ms frame units (0–104). Passed directly to
|
||||
/// `OpusEncoder::set_dred_duration()`.
|
||||
pub dred_frames: u8,
|
||||
/// Expected packet loss percentage (0–100). Passed to
|
||||
/// `OpusEncoder::set_expected_loss()`. Floored at 15% by the encoder
|
||||
/// itself, but we pass the real value so the encoder can override upward.
|
||||
pub expected_loss_pct: u8,
|
||||
}
|
||||
|
||||
/// Minimum DRED frames for any Opus codec (matches DRED_LOSS_FLOOR_PCT logic:
|
||||
/// at 15% loss, libopus 1.5 emits ~95 ms of DRED, which needs at least 10
|
||||
/// frames configured to be useful).
|
||||
const MIN_DRED_FRAMES: u8 = 5;
|
||||
|
||||
/// Maximum DRED frames libopus supports (104 × 10 ms = 1040 ms).
|
||||
const MAX_DRED_FRAMES: u8 = 104;
|
||||
|
||||
/// Jitter variance spike ratio that triggers pre-emptive DRED boost.
|
||||
const JITTER_SPIKE_RATIO: f32 = 1.3;
|
||||
|
||||
/// How many tuning cycles a jitter-spike boost persists (at 25 packets/cycle
|
||||
/// and 20 ms/packet, 10 cycles ≈ 5 seconds).
|
||||
const SPIKE_BOOST_COOLDOWN_CYCLES: u32 = 10;
|
||||
|
||||
/// Maps codec tier to its baseline DRED frames (used when network is healthy).
|
||||
fn baseline_dred_frames(codec: CodecId) -> u8 {
|
||||
match codec {
|
||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
|
||||
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
|
||||
CodecId::Opus6k => 50, // 500 ms
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps codec tier to its maximum allowed DRED frames under spike/bad conditions.
|
||||
fn max_dred_frames_for(codec: CodecId) -> u8 {
|
||||
match codec {
|
||||
// Studio: cap at 300 ms (don't waste bitrate on good links)
|
||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 30,
|
||||
// Normal: cap at 500 ms
|
||||
CodecId::Opus16k | CodecId::Opus24k => 50,
|
||||
// Degraded: allow full 1040 ms
|
||||
CodecId::Opus6k => MAX_DRED_FRAMES,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Continuous DRED tuner driven by network path metrics.
|
||||
pub struct DredTuner {
|
||||
/// Current codec (determines baseline and ceiling).
|
||||
codec: CodecId,
|
||||
/// Last computed tuning output.
|
||||
last_tuning: DredTuning,
|
||||
/// EWMA-smoothed jitter for spike detection (in ms).
|
||||
jitter_ewma: f32,
|
||||
/// Remaining cooldown cycles for a jitter-spike boost.
|
||||
spike_cooldown: u32,
|
||||
/// Whether the tuner has received at least one observation.
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl DredTuner {
|
||||
/// Create a new tuner for the given codec.
|
||||
pub fn new(codec: CodecId) -> Self {
|
||||
let baseline = baseline_dred_frames(codec);
|
||||
Self {
|
||||
codec,
|
||||
last_tuning: DredTuning {
|
||||
dred_frames: baseline,
|
||||
expected_loss_pct: 15, // match DRED_LOSS_FLOOR_PCT
|
||||
},
|
||||
jitter_ewma: 0.0,
|
||||
spike_cooldown: 0,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the active codec (e.g. on tier transition). Resets spike state.
|
||||
pub fn set_codec(&mut self, codec: CodecId) {
|
||||
self.codec = codec;
|
||||
self.spike_cooldown = 0;
|
||||
}
|
||||
|
||||
/// Feed network metrics and compute new DRED parameters.
|
||||
///
|
||||
/// Call this every tuning cycle (e.g. every 25 packets ≈ 500 ms at 20 ms
|
||||
/// frame duration).
|
||||
///
|
||||
/// - `loss_pct`: observed packet loss (0.0–100.0)
|
||||
/// - `rtt_ms`: smoothed round-trip time
|
||||
/// - `jitter_ms`: current jitter estimate (RTT variance)
|
||||
///
|
||||
/// Returns `Some(tuning)` if the output changed, `None` if unchanged.
|
||||
pub fn update(&mut self, loss_pct: f32, rtt_ms: u32, jitter_ms: u32) -> Option<DredTuning> {
|
||||
if !self.codec.is_opus() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let baseline = baseline_dred_frames(self.codec);
|
||||
let ceiling = max_dred_frames_for(self.codec);
|
||||
|
||||
// --- Jitter spike detection ---
|
||||
let jitter_f = jitter_ms as f32;
|
||||
if !self.initialized {
|
||||
self.jitter_ewma = jitter_f;
|
||||
self.initialized = true;
|
||||
} else {
|
||||
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
|
||||
let alpha = if jitter_f > self.jitter_ewma {
|
||||
0.3
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
|
||||
}
|
||||
|
||||
// Detect spike: instantaneous jitter > EWMA × 1.3
|
||||
if self.jitter_ewma > 1.0 && jitter_f > self.jitter_ewma * JITTER_SPIKE_RATIO {
|
||||
self.spike_cooldown = SPIKE_BOOST_COOLDOWN_CYCLES;
|
||||
}
|
||||
|
||||
// Decrement cooldown
|
||||
if self.spike_cooldown > 0 {
|
||||
self.spike_cooldown -= 1;
|
||||
}
|
||||
|
||||
// --- Compute DRED frames ---
|
||||
let dred_frames = if self.spike_cooldown > 0 {
|
||||
// During spike boost: jump to ceiling
|
||||
ceiling
|
||||
} else {
|
||||
// Continuous mapping: scale linearly between baseline and ceiling
|
||||
// based on loss percentage.
|
||||
// 0% loss → baseline
|
||||
// 40% loss → ceiling
|
||||
let loss_clamped = loss_pct.clamp(0.0, 40.0);
|
||||
let t = loss_clamped / 40.0;
|
||||
let raw = baseline as f32 + t * (ceiling - baseline) as f32;
|
||||
(raw as u8).clamp(MIN_DRED_FRAMES, ceiling)
|
||||
};
|
||||
|
||||
// --- Compute expected loss hint ---
|
||||
// Pass the real loss so the encoder can clamp at its own floor (15%).
|
||||
// For RTT-driven boost: high RTT suggests impending loss, so add a
|
||||
// phantom loss contribution to keep DRED emitting generously.
|
||||
let rtt_loss_phantom = if rtt_ms > 200 {
|
||||
((rtt_ms - 200) as f32 / 40.0).min(15.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let expected_loss = (loss_pct + rtt_loss_phantom).clamp(0.0, 100.0) as u8;
|
||||
|
||||
let tuning = DredTuning {
|
||||
dred_frames,
|
||||
expected_loss_pct: expected_loss,
|
||||
};
|
||||
|
||||
if tuning != self.last_tuning {
|
||||
self.last_tuning = tuning;
|
||||
Some(tuning)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the last computed tuning without updating.
|
||||
pub fn current(&self) -> DredTuning {
|
||||
self.last_tuning
|
||||
}
|
||||
|
||||
/// Whether a jitter-spike boost is currently active.
|
||||
pub fn spike_boost_active(&self) -> bool {
|
||||
self.spike_cooldown > 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn baseline_for_opus24k() {
|
||||
let tuner = DredTuner::new(CodecId::Opus24k);
|
||||
assert_eq!(tuner.current().dred_frames, 20); // 200 ms
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_for_opus6k() {
|
||||
let tuner = DredTuner::new(CodecId::Opus6k);
|
||||
assert_eq!(tuner.current().dred_frames, 50); // 500 ms
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec2_returns_none() {
|
||||
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
|
||||
assert!(tuner.update(10.0, 100, 20).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_with_loss() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// 0% loss → baseline (20 frames)
|
||||
tuner.update(0.0, 50, 5);
|
||||
assert_eq!(tuner.current().dred_frames, 20);
|
||||
|
||||
// 20% loss → midpoint between 20 and 50 = 35
|
||||
tuner.update(20.0, 50, 5);
|
||||
assert_eq!(tuner.current().dred_frames, 35);
|
||||
|
||||
// 40%+ loss → ceiling (50 frames)
|
||||
tuner.update(40.0, 50, 5);
|
||||
assert_eq!(tuner.current().dred_frames, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_spike_triggers_boost() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// Establish baseline jitter
|
||||
for _ in 0..20 {
|
||||
tuner.update(0.0, 50, 10);
|
||||
}
|
||||
assert!(!tuner.spike_boost_active());
|
||||
|
||||
// Spike: jitter jumps to 50 ms (5x the EWMA of ~10)
|
||||
tuner.update(0.0, 50, 50);
|
||||
assert!(tuner.spike_boost_active());
|
||||
// Should be at ceiling (50 frames = 500 ms for Opus24k)
|
||||
assert_eq!(tuner.current().dred_frames, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_cooldown_decays() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// Establish baseline then spike
|
||||
for _ in 0..20 {
|
||||
tuner.update(0.0, 50, 10);
|
||||
}
|
||||
tuner.update(0.0, 50, 50);
|
||||
assert!(tuner.spike_boost_active());
|
||||
|
||||
// Run through cooldown
|
||||
for _ in 0..SPIKE_BOOST_COOLDOWN_CYCLES {
|
||||
tuner.update(0.0, 50, 10);
|
||||
}
|
||||
assert!(!tuner.spike_boost_active());
|
||||
// Should return to baseline
|
||||
assert_eq!(tuner.current().dred_frames, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtt_phantom_loss() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// High RTT (400ms) with 0% real loss
|
||||
tuner.update(0.0, 400, 10);
|
||||
// Phantom loss = (400-200)/40 = 5
|
||||
assert_eq!(tuner.current().expected_loss_pct, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_codec_resets_spike() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// Trigger spike
|
||||
for _ in 0..20 {
|
||||
tuner.update(0.0, 50, 10);
|
||||
}
|
||||
tuner.update(0.0, 50, 50);
|
||||
assert!(tuner.spike_boost_active());
|
||||
|
||||
// Switch codec — spike should reset
|
||||
tuner.set_codec(CodecId::Opus6k);
|
||||
assert!(!tuner.spike_boost_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus6k_reaches_max_1040ms() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus6k);
|
||||
|
||||
// High loss → should reach 104 frames (1040 ms)
|
||||
tuner.update(40.0, 50, 5);
|
||||
assert_eq!(tuner.current().dred_frames, MAX_DRED_FRAMES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_unchanged() {
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// First update always returns Some (initial → computed)
|
||||
let first = tuner.update(0.0, 50, 5);
|
||||
// Same inputs → None
|
||||
let second = tuner.update(0.0, 50, 5);
|
||||
assert!(first.is_some() || second.is_none());
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub enum CryptoError {
|
||||
#[error("rekey failed: {0}")]
|
||||
RekeyFailed(String),
|
||||
#[error("anti-replay: duplicate or old packet (seq={seq})")]
|
||||
ReplayDetected { seq: u32 },
|
||||
ReplayDetected { seq: u16 },
|
||||
#[error("internal crypto error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
@@ -53,15 +53,6 @@ pub enum TransportError {
|
||||
Timeout { ms: u64 },
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// Parsed wire bytes successfully but the payload didn't
|
||||
/// deserialize into a known `SignalMessage` variant. Usually
|
||||
/// means the peer is running a newer build with a variant we
|
||||
/// don't know yet. Callers should **log and continue** rather
|
||||
/// than tearing down the connection, so that forward-compat
|
||||
/// additions to `SignalMessage` don't silently kill old
|
||||
/// clients/relays.
|
||||
#[error("signal deserialize: {0}")]
|
||||
Deserialize(String),
|
||||
#[error("internal transport error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ impl AdaptivePlayoutDelay {
|
||||
let jitter = (actual_delta - expected_delta).abs();
|
||||
|
||||
// Spike detection: check before EMA update
|
||||
if self.jitter_ema > 0.0 && jitter > self.jitter_ema * self.spike_threshold_multiplier {
|
||||
if self.jitter_ema > 0.0
|
||||
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
||||
{
|
||||
self.spike_detected_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
@@ -105,8 +107,10 @@ impl AdaptivePlayoutDelay {
|
||||
self.target_delay = self.max_delay;
|
||||
} else {
|
||||
// Convert jitter estimate to target delay in packets
|
||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
||||
self.target_delay = (raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
let raw_target =
|
||||
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
||||
self.target_delay =
|
||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,9 +162,9 @@ impl AdaptivePlayoutDelay {
|
||||
/// Manages packet reordering, gap detection, and signals when PLC is needed.
|
||||
pub struct JitterBuffer {
|
||||
/// Packets waiting to be consumed, ordered by sequence number.
|
||||
buffer: BTreeMap<u32, MediaPacket>,
|
||||
buffer: BTreeMap<u16, MediaPacket>,
|
||||
/// Next sequence number expected for playout.
|
||||
next_playout_seq: u32,
|
||||
next_playout_seq: u16,
|
||||
/// Maximum buffer depth in number of packets.
|
||||
max_depth: usize,
|
||||
/// Target buffer depth (adaptive, based on jitter).
|
||||
@@ -200,7 +204,7 @@ pub enum PlayoutResult {
|
||||
/// A packet is available for playout.
|
||||
Packet(MediaPacket),
|
||||
/// The expected packet is missing — decoder should generate PLC.
|
||||
Missing { seq: u32 },
|
||||
Missing { seq: u16 },
|
||||
/// Buffer is empty or not yet filled to target depth.
|
||||
NotReady,
|
||||
}
|
||||
@@ -274,18 +278,9 @@ impl JitterBuffer {
|
||||
// federation room — reset instead of dropping.
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
tracing::warn!(
|
||||
seq,
|
||||
next = self.next_playout_seq,
|
||||
backward_distance,
|
||||
"jitter: backward seq detected"
|
||||
);
|
||||
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"
|
||||
);
|
||||
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;
|
||||
@@ -433,18 +428,9 @@ impl JitterBuffer {
|
||||
// federation room — reset instead of dropping.
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
||||
tracing::warn!(
|
||||
seq,
|
||||
next = self.next_playout_seq,
|
||||
backward_distance,
|
||||
"jitter: backward seq detected"
|
||||
);
|
||||
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"
|
||||
);
|
||||
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;
|
||||
@@ -503,7 +489,7 @@ impl JitterBuffer {
|
||||
|
||||
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
|
||||
/// Returns true if `a` comes before `b` in sequence space.
|
||||
fn seq_before(a: u32, b: u32) -> bool {
|
||||
fn seq_before(a: u16, b: u16) -> bool {
|
||||
let diff = b.wrapping_sub(a);
|
||||
diff > 0 && diff < 0x8000
|
||||
}
|
||||
@@ -511,23 +497,24 @@ fn seq_before(a: u32, b: u32) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CodecId;
|
||||
use crate::MediaType;
|
||||
use crate::packet::{MediaHeader, MediaPacket};
|
||||
use bytes::Bytes;
|
||||
use crate::CodecId;
|
||||
|
||||
fn make_packet(seq: u32) -> MediaPacket {
|
||||
fn make_packet(seq: u16) -> MediaPacket {
|
||||
MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq,
|
||||
timestamp: seq * 20,
|
||||
timestamp: seq as u32 * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(vec![0u8; 60]),
|
||||
quality_report: None,
|
||||
@@ -611,7 +598,7 @@ mod tests {
|
||||
fn seq_before_wrapping() {
|
||||
assert!(seq_before(0, 1));
|
||||
assert!(seq_before(65534, 65535));
|
||||
assert!(seq_before(u32::MAX, 0)); // wrap
|
||||
assert!(seq_before(65535, 0)); // wrap
|
||||
assert!(!seq_before(1, 0));
|
||||
assert!(!seq_before(5, 5)); // equal
|
||||
}
|
||||
@@ -813,7 +800,7 @@ mod tests {
|
||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||
|
||||
// Push packets with consistent timing
|
||||
for i in 0u32..20 {
|
||||
for i in 0u16..20 {
|
||||
let pkt = make_packet(i);
|
||||
let arrival_ms = i as u64 * 20;
|
||||
jb.push_with_arrival(pkt, arrival_ms);
|
||||
|
||||
@@ -14,28 +14,22 @@
|
||||
|
||||
pub mod bandwidth;
|
||||
pub mod codec_id;
|
||||
pub mod dred_tuner;
|
||||
pub mod error;
|
||||
pub mod jitter;
|
||||
pub mod media_type;
|
||||
pub mod packet;
|
||||
pub mod priority_mode;
|
||||
pub mod quality;
|
||||
pub mod session;
|
||||
pub mod traits;
|
||||
|
||||
// Re-export key types at crate root for convenience.
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use codec_id::{CodecId, QualityProfile};
|
||||
pub use dred_tuner::{DredTuner, DredTuning};
|
||||
pub use error::*;
|
||||
pub use media_type::MediaType;
|
||||
pub use packet::{
|
||||
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV2,
|
||||
MediaPacket, MiniFrameContext, MiniFrameContextV2, MiniHeader, MiniHeaderV2, PresenceUser,
|
||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, default_signal_version,
|
||||
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
||||
FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use priority_mode::PriorityMode;
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Media stream type carried in a v2 [`MediaHeaderV2`](crate::MediaHeaderV2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum MediaType {
|
||||
/// Encoded speech / music (Opus, Codec2, ComfortNoise).
|
||||
Audio = 0,
|
||||
/// Encoded video access unit (H.264, H.265, AV1; PRD-video-multicodec).
|
||||
Video = 1,
|
||||
/// Opaque payload not interpreted by the relay (reserved).
|
||||
Data = 2,
|
||||
/// In-band control message carried on the media plane (reserved).
|
||||
Control = 3,
|
||||
}
|
||||
|
||||
impl MediaType {
|
||||
/// Encode to the wire byte representation (`self as u8`).
|
||||
pub const fn to_wire(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Decode from a wire byte. Returns `None` for values outside 0..=3.
|
||||
pub const fn from_wire(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Audio),
|
||||
1 => Some(Self::Video),
|
||||
2 => Some(Self::Data),
|
||||
3 => Some(Self::Control),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_type_roundtrip() {
|
||||
for mt in [
|
||||
MediaType::Audio,
|
||||
MediaType::Video,
|
||||
MediaType::Data,
|
||||
MediaType::Control,
|
||||
] {
|
||||
assert_eq!(MediaType::from_wire(mt.to_wire()), Some(mt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_type_unknown_rejected() {
|
||||
for v in 4u8..=255 {
|
||||
assert!(MediaType::from_wire(v).is_none(), "v={v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
//! Priority mode for bandwidth allocation between audio and video.
|
||||
//!
|
||||
//! See `docs/PRD/PRD-video-quality-priority.md` for the full design.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bandwidth-allocation policy between audio and video.
|
||||
///
|
||||
/// Carried on [`QualityProfile`](crate::QualityProfile) and mutable at
|
||||
/// runtime via [`SignalMessage::SetPriorityMode`](crate::SignalMessage).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum PriorityMode {
|
||||
/// Audio gets its floor first; video gets the remainder.
|
||||
/// Default for voice/video calls.
|
||||
#[default]
|
||||
AudioFirst,
|
||||
/// Video gets its floor first; audio degrades to Opus 16k floor.
|
||||
VideoFirst,
|
||||
/// Audio clamped to 16 kbps (intelligible speech); video gets remainder.
|
||||
/// Falls back to slide mode when bandwidth drops below SD floor.
|
||||
ScreenShare,
|
||||
/// Proportional split (~15 % audio, ~85 % video).
|
||||
Balanced,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn priority_mode_default_is_audio_first() {
|
||||
assert_eq!(PriorityMode::default(), PriorityMode::AudioFirst);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,24 @@
|
||||
//! See also: [`crate::dred_tuner`] for continuous DRED tuning within a tier.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::BandwidthEstimator;
|
||||
use crate::QualityProfile;
|
||||
use crate::packet::QualityReport;
|
||||
use crate::traits::QualityController;
|
||||
use crate::QualityProfile;
|
||||
|
||||
/// Network quality tier — drives codec and FEC selection.
|
||||
///
|
||||
/// 5-tier range from studio quality down to catastrophic:
|
||||
/// Studio64k > Studio48k > Studio32k > Good > Degraded > Catastrophic
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
/// loss >= 15% OR RTT >= 200ms — Codec2 1.2k
|
||||
Catastrophic = 0,
|
||||
/// loss < 15% AND RTT < 200ms — Opus 6k
|
||||
Degraded = 1,
|
||||
/// loss < 5% AND RTT < 100ms — Opus 24k
|
||||
Good = 2,
|
||||
/// loss < 2% AND RTT < 80ms — Opus 32k
|
||||
Studio32k = 3,
|
||||
/// loss < 1% AND RTT < 50ms — Opus 48k
|
||||
Studio48k = 4,
|
||||
/// loss < 1% AND RTT < 30ms — Opus 64k
|
||||
Studio64k = 5,
|
||||
/// loss < 10%, RTT < 400ms
|
||||
Good,
|
||||
/// loss 10-40% OR RTT 400-600ms
|
||||
Degraded,
|
||||
/// loss > 40% OR RTT > 600ms
|
||||
Catastrophic,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn profile(self) -> QualityProfile {
|
||||
match self {
|
||||
Self::Studio64k => QualityProfile::STUDIO_64K,
|
||||
Self::Studio48k => QualityProfile::STUDIO_48K,
|
||||
Self::Studio32k => QualityProfile::STUDIO_32K,
|
||||
Self::Good => QualityProfile::GOOD,
|
||||
Self::Degraded => QualityProfile::DEGRADED,
|
||||
Self::Catastrophic => QualityProfile::CATASTROPHIC,
|
||||
@@ -55,7 +39,7 @@ impl Tier {
|
||||
NetworkContext::CellularLte
|
||||
| NetworkContext::Cellular5g
|
||||
| NetworkContext::Cellular3g => {
|
||||
// Tighter thresholds for cellular — no studio tiers
|
||||
// Tighter thresholds for cellular networks
|
||||
if loss > 25.0 || rtt > 500 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 8.0 || rtt > 300 {
|
||||
@@ -65,18 +49,13 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
NetworkContext::WiFi | NetworkContext::Unknown => {
|
||||
if loss >= 15.0 || rtt >= 200 {
|
||||
// Original thresholds
|
||||
if loss > 40.0 || rtt > 600 {
|
||||
Self::Catastrophic
|
||||
} else if loss >= 5.0 || rtt >= 100 {
|
||||
} else if loss > 10.0 || rtt > 400 {
|
||||
Self::Degraded
|
||||
} else if loss >= 2.0 || rtt >= 80 {
|
||||
Self::Good
|
||||
} else if loss >= 1.0 || rtt >= 50 {
|
||||
Self::Studio32k
|
||||
} else if rtt >= 30 {
|
||||
Self::Studio48k
|
||||
} else {
|
||||
Self::Studio64k
|
||||
Self::Good
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,32 +64,29 @@ impl Tier {
|
||||
/// Return the next lower (worse) tier, or None if already at the worst.
|
||||
pub fn downgrade(self) -> Option<Tier> {
|
||||
match self {
|
||||
Self::Studio64k => Some(Self::Studio48k),
|
||||
Self::Studio48k => Some(Self::Studio32k),
|
||||
Self::Studio32k => Some(Self::Good),
|
||||
Self::Good => Some(Self::Degraded),
|
||||
Self::Degraded => Some(Self::Catastrophic),
|
||||
Self::Catastrophic => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a studio tier (above Good).
|
||||
pub fn is_studio(self) -> bool {
|
||||
matches!(self, Self::Studio64k | Self::Studio48k | Self::Studio32k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the network transport type for context-aware quality decisions.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkContext {
|
||||
WiFi,
|
||||
CellularLte,
|
||||
Cellular5g,
|
||||
Cellular3g,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for NetworkContext {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||
///
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||
@@ -132,50 +108,20 @@ pub struct AdaptiveQualityController {
|
||||
fec_boost_until: Option<Instant>,
|
||||
/// FEC boost amount to add during handoff recovery window.
|
||||
fec_boost_amount: f32,
|
||||
/// Probing state: when Some, we're actively testing a higher tier.
|
||||
probe: Option<ProbeState>,
|
||||
/// Time spent stable at the current tier (for probe trigger).
|
||||
stable_since: Option<Instant>,
|
||||
/// Optional bandwidth estimator for BWE-guarded upgrades.
|
||||
bwe: Option<Arc<BandwidthEstimator>>,
|
||||
}
|
||||
|
||||
/// Threshold for downgrading (fast reaction to degradation).
|
||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||
/// Threshold for downgrading on cellular networks (even faster).
|
||||
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
||||
/// Threshold for upgrading from Catastrophic/Degraded to Good.
|
||||
const UPGRADE_THRESHOLD: u32 = 5;
|
||||
/// Threshold for upgrading into studio tiers (very conservative).
|
||||
const STUDIO_UPGRADE_THRESHOLD: u32 = 10;
|
||||
/// Threshold for upgrading (slow, cautious improvement).
|
||||
const UPGRADE_THRESHOLD: u32 = 10;
|
||||
/// Maximum history window size.
|
||||
const HISTORY_SIZE: usize = 20;
|
||||
/// Default FEC boost amount during handoff recovery.
|
||||
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
||||
/// Duration of FEC boost after a network handoff.
|
||||
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
||||
/// Minimum time stable at current tier before probing upward (30 seconds).
|
||||
const PROBE_STABLE_SECS: u64 = 30;
|
||||
/// Duration of a probe window (5 seconds — ~25 quality reports at 1/s).
|
||||
const PROBE_DURATION_SECS: u64 = 5;
|
||||
/// Maximum bad reports during probe before aborting (1 out of ~5 = 20%).
|
||||
const PROBE_MAX_BAD: u32 = 1;
|
||||
/// Cooldown after a failed probe before trying again (60 seconds).
|
||||
const PROBE_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
/// Active bandwidth probe state.
|
||||
struct ProbeState {
|
||||
/// The tier we're probing (one step above current).
|
||||
target_tier: Tier,
|
||||
/// Profile to apply during probe.
|
||||
target_profile: QualityProfile,
|
||||
/// When the probe started.
|
||||
started: Instant,
|
||||
/// Reports observed during probe.
|
||||
probe_reports: u32,
|
||||
/// Bad reports during probe (loss/RTT exceeded target tier thresholds).
|
||||
bad_reports: u32,
|
||||
}
|
||||
|
||||
impl AdaptiveQualityController {
|
||||
pub fn new() -> Self {
|
||||
@@ -189,9 +135,6 @@ impl AdaptiveQualityController {
|
||||
network_context: NetworkContext::default(),
|
||||
fec_boost_until: None,
|
||||
fec_boost_amount: DEFAULT_FEC_BOOST,
|
||||
probe: None,
|
||||
stable_since: None,
|
||||
bwe: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,10 +174,6 @@ impl AdaptiveQualityController {
|
||||
self.forced = false;
|
||||
}
|
||||
|
||||
// Cancel any active probe
|
||||
self.probe = None;
|
||||
self.stable_since = None;
|
||||
|
||||
// Activate FEC boost for any network change
|
||||
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
||||
}
|
||||
@@ -255,19 +194,6 @@ impl AdaptiveQualityController {
|
||||
pub fn reset_counters(&mut self) {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
self.probe = None;
|
||||
self.stable_since = None;
|
||||
}
|
||||
|
||||
/// Attach a bandwidth estimator for BWE-guarded tier transitions.
|
||||
pub fn set_bandwidth_estimator(&mut self, bwe: Arc<BandwidthEstimator>) {
|
||||
self.bwe = Some(bwe);
|
||||
}
|
||||
|
||||
/// Return the bitrate ceiling (in bps) for a given tier, including FEC overhead.
|
||||
fn tier_ceiling_bps(tier: Tier) -> u64 {
|
||||
let kbps = tier.profile().total_bitrate_kbps();
|
||||
(kbps * 1000.0) as u64
|
||||
}
|
||||
|
||||
/// Get the effective downgrade threshold based on network context.
|
||||
@@ -287,13 +213,16 @@ impl AdaptiveQualityController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_worse = observed_tier < self.current_tier;
|
||||
let is_worse = match (self.current_tier, observed_tier) {
|
||||
(Tier::Good, Tier::Degraded | Tier::Catastrophic) => true,
|
||||
(Tier::Degraded, Tier::Catastrophic) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_worse {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down += 1;
|
||||
if self.consecutive_down >= self.downgrade_threshold() {
|
||||
// Jump directly to the observed tier (don't step one-at-a-time on downgrade)
|
||||
self.current_tier = observed_tier;
|
||||
self.current_profile = observed_tier.profile();
|
||||
self.consecutive_down = 0;
|
||||
@@ -303,123 +232,22 @@ impl AdaptiveQualityController {
|
||||
// Better conditions
|
||||
self.consecutive_down = 0;
|
||||
self.consecutive_up += 1;
|
||||
// Studio tiers require more consecutive good reports
|
||||
let threshold = if self.current_tier >= Tier::Good {
|
||||
STUDIO_UPGRADE_THRESHOLD
|
||||
} else {
|
||||
UPGRADE_THRESHOLD
|
||||
};
|
||||
if self.consecutive_up >= threshold {
|
||||
if self.consecutive_up >= UPGRADE_THRESHOLD {
|
||||
// Only upgrade one step at a time
|
||||
if let Some(next_tier) = self.upgrade_one_step() {
|
||||
// BWE guard: require 130% headroom over target tier bitrate
|
||||
if let Some(ref bwe) = self.bwe {
|
||||
let required = (Self::tier_ceiling_bps(next_tier) * 130) / 100;
|
||||
if bwe.target_send_bps() < required {
|
||||
// Insufficient bandwidth — reset counter to prevent flapping
|
||||
self.consecutive_up = 0;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.current_tier = next_tier;
|
||||
self.current_profile = next_tier.profile();
|
||||
self.consecutive_up = 0;
|
||||
return Some(self.current_profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether to start, continue, or conclude a bandwidth probe.
|
||||
///
|
||||
/// Called from `observe()` when no hysteresis transition fired.
|
||||
fn check_probe(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||
// Don't probe if forced, or if already at highest tier, or on cellular
|
||||
if self.forced || self.current_tier == Tier::Studio64k {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
self.network_context,
|
||||
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If we have an active probe, evaluate it
|
||||
if let Some(ref mut probe) = self.probe {
|
||||
probe.probe_reports += 1;
|
||||
|
||||
// Check if the observed tier meets the probe target
|
||||
if observed_tier < probe.target_tier {
|
||||
probe.bad_reports += 1;
|
||||
}
|
||||
|
||||
// Probe failed: too many bad reports
|
||||
if probe.bad_reports > PROBE_MAX_BAD {
|
||||
let _failed_probe = self.probe.take();
|
||||
// Reset stable_since to trigger cooldown
|
||||
self.stable_since = Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
|
||||
return None; // stay at current tier
|
||||
}
|
||||
|
||||
// Probe succeeded: enough good reports within the window
|
||||
if probe.started.elapsed() >= Duration::from_secs(PROBE_DURATION_SECS) {
|
||||
let target = probe.target_tier;
|
||||
let profile = probe.target_profile;
|
||||
self.probe.take();
|
||||
self.current_tier = target;
|
||||
self.current_profile = profile;
|
||||
let next_tier = match self.current_tier {
|
||||
Tier::Catastrophic => Tier::Degraded,
|
||||
Tier::Degraded => Tier::Good,
|
||||
Tier::Good => return None,
|
||||
};
|
||||
self.current_tier = next_tier;
|
||||
self.current_profile = next_tier.profile();
|
||||
self.consecutive_up = 0;
|
||||
self.stable_since = Some(Instant::now());
|
||||
return Some(profile);
|
||||
return Some(self.current_profile);
|
||||
}
|
||||
|
||||
return None; // probe still running
|
||||
}
|
||||
|
||||
// No active probe — check if we should start one
|
||||
if observed_tier >= self.current_tier {
|
||||
// Track stability
|
||||
if self.stable_since.is_none() {
|
||||
self.stable_since = Some(Instant::now());
|
||||
}
|
||||
|
||||
if let Some(stable_since) = self.stable_since {
|
||||
if stable_since.elapsed() >= Duration::from_secs(PROBE_STABLE_SECS) {
|
||||
// Stable long enough — start probing
|
||||
if let Some(next) = self.upgrade_one_step() {
|
||||
self.probe = Some(ProbeState {
|
||||
target_tier: next,
|
||||
target_profile: next.profile(),
|
||||
started: Instant::now(),
|
||||
probe_reports: 0,
|
||||
bad_reports: 0,
|
||||
});
|
||||
// Return the probe profile so the encoder switches
|
||||
return Some(next.profile());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Conditions degraded — reset stability timer
|
||||
self.stable_since = None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn upgrade_one_step(&self) -> Option<Tier> {
|
||||
match self.current_tier {
|
||||
Tier::Catastrophic => Some(Tier::Degraded),
|
||||
Tier::Degraded => Some(Tier::Good),
|
||||
Tier::Good => Some(Tier::Studio32k),
|
||||
Tier::Studio32k => Some(Tier::Studio48k),
|
||||
Tier::Studio48k => Some(Tier::Studio64k),
|
||||
Tier::Studio64k => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdaptiveQualityController {
|
||||
@@ -441,17 +269,7 @@ impl QualityController for AdaptiveQualityController {
|
||||
}
|
||||
|
||||
let observed = Tier::classify_with_context(report, self.network_context);
|
||||
|
||||
// First check for downgrades/upgrades via hysteresis
|
||||
if let Some(profile) = self.try_transition(observed) {
|
||||
// Cancel any active probe on tier change
|
||||
self.probe.take();
|
||||
self.stable_since = None;
|
||||
return Some(profile);
|
||||
}
|
||||
|
||||
// Then check probing
|
||||
self.check_probe(observed)
|
||||
self.try_transition(observed)
|
||||
}
|
||||
|
||||
fn force_profile(&mut self, profile: QualityProfile) {
|
||||
@@ -513,33 +331,25 @@ mod tests {
|
||||
}
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
|
||||
// 4 good reports — not enough (threshold is 5)
|
||||
let good = make_report(0.5, 20); // studio-quality report
|
||||
for _ in 0..4 {
|
||||
// 9 good reports — not enough
|
||||
let good = make_report(2.0, 100);
|
||||
for _ in 0..9 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
|
||||
// 5th good report triggers upgrade (one step: Catastrophic → Degraded)
|
||||
// 10th good report triggers upgrade (one step: Catastrophic → Degraded)
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||
|
||||
// Another 5 to go from Degraded → Good
|
||||
for _ in 0..4 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
|
||||
// Studio upgrades need 10 consecutive — Good → Studio32k
|
||||
// Need another 10 to go from Degraded → Good
|
||||
for _ in 0..9 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(ctrl.tier(), Tier::Studio32k);
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -554,78 +364,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwe_guard_blocks_upgrade_when_bandwidth_insufficient() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
|
||||
// Force to catastrophic
|
||||
let bad = make_report(50.0, 300);
|
||||
for _ in 0..3 {
|
||||
ctrl.observe(&bad);
|
||||
}
|
||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||
|
||||
// Attach a BWE with very low headroom.
|
||||
// Degraded tier needs 6kbps * 1.5 FEC = 9kbps → 130% = 11.7kbps.
|
||||
// Set target_send_bps ≈ 9_000 (below 11_700 threshold).
|
||||
let bwe = Arc::new(BandwidthEstimator::new(1000.0, 1.0, 100_000.0));
|
||||
bwe.update_from_path(1_000_000, 0, 10); // high cwnd
|
||||
bwe.update_from_peer(10_000); // low remb → target = 9_000
|
||||
ctrl.set_bandwidth_estimator(bwe.clone());
|
||||
|
||||
let good = make_report(0.5, 20);
|
||||
for _ in 0..5 {
|
||||
assert!(
|
||||
ctrl.observe(&good).is_none(),
|
||||
"upgrade should be blocked by low BWE"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ctrl.tier(),
|
||||
Tier::Catastrophic,
|
||||
"should remain at Catastrophic"
|
||||
);
|
||||
|
||||
// Raise BWE well above the 130% threshold
|
||||
bwe.update_from_peer(100_000); // target ≈ 90_000 bps
|
||||
|
||||
// Counter was reset, need another 5 good reports
|
||||
for _ in 0..4 {
|
||||
assert!(ctrl.observe(&good).is_none());
|
||||
}
|
||||
let result = ctrl.observe(&good);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"upgrade should proceed with sufficient BWE"
|
||||
);
|
||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_classification() {
|
||||
// Studio tiers
|
||||
assert_eq!(Tier::classify(&make_report(0.5, 20)), Tier::Studio64k);
|
||||
assert_eq!(Tier::classify(&make_report(0.5, 40)), Tier::Studio48k);
|
||||
assert_eq!(Tier::classify(&make_report(1.5, 60)), Tier::Studio32k);
|
||||
// Good/Degraded/Catastrophic
|
||||
assert_eq!(Tier::classify(&make_report(3.0, 90)), Tier::Good);
|
||||
assert_eq!(Tier::classify(&make_report(6.0, 120)), Tier::Degraded);
|
||||
assert_eq!(Tier::classify(&make_report(16.0, 120)), Tier::Catastrophic);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 200)), Tier::Catastrophic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio_tier_boundaries() {
|
||||
// loss < 1% AND RTT < 30ms → Studio64k
|
||||
assert_eq!(Tier::classify(&make_report(0.9, 28)), Tier::Studio64k);
|
||||
// loss < 1% AND RTT 30-49ms → Studio48k
|
||||
assert_eq!(Tier::classify(&make_report(0.9, 32)), Tier::Studio48k);
|
||||
// loss < 2% AND RTT < 80ms → Studio32k (but loss >= 1%)
|
||||
assert_eq!(Tier::classify(&make_report(1.5, 40)), Tier::Studio32k);
|
||||
// loss >= 2% → Good (use 2.5 to survive u8 quantization)
|
||||
assert_eq!(Tier::classify(&make_report(2.5, 40)), Tier::Good);
|
||||
// RTT 80ms → Good
|
||||
assert_eq!(Tier::classify(&make_report(0.5, 80)), Tier::Good);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 200)), Tier::Good);
|
||||
assert_eq!(Tier::classify(&make_report(15.0, 200)), Tier::Degraded);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 500)), Tier::Degraded);
|
||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -634,8 +379,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cellular_tighter_thresholds() {
|
||||
// 9% loss: Degraded on both WiFi (>=5%) and cellular (>=8%)
|
||||
let report = make_report(9.0, 80);
|
||||
// 12% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(12.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
@@ -645,22 +390,22 @@ mod tests {
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 6% loss, low RTT: Degraded on WiFi (>=5%), Good on cellular (<8%)
|
||||
let report = make_report(6.0, 80);
|
||||
// 9% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(9.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 30% loss: Catastrophic on WiFi (>=15%), Catastrophic on cellular (>=25%)
|
||||
let report = make_report(30.0, 80);
|
||||
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
||||
let report = make_report(30.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Catastrophic
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
||||
@@ -670,30 +415,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cellular_rtt_thresholds() {
|
||||
// RTT 150ms: Degraded on WiFi (>=100ms), Good on cellular (<300ms and loss<8%)
|
||||
let report = make_report(2.0, 148);
|
||||
// RTT 350ms: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Good
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_no_studio_tiers() {
|
||||
// Even with perfect network, cellular stays at Good (no studio)
|
||||
let report = make_report(0.0, 10);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Studio64k
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -738,9 +469,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tier_downgrade() {
|
||||
assert_eq!(Tier::Studio64k.downgrade(), Some(Tier::Studio48k));
|
||||
assert_eq!(Tier::Studio48k.downgrade(), Some(Tier::Studio32k));
|
||||
assert_eq!(Tier::Studio32k.downgrade(), Some(Tier::Good));
|
||||
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
||||
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
||||
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
||||
@@ -750,103 +478,4 @@ mod tests {
|
||||
fn network_context_default() {
|
||||
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bandwidth probing tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn probe_triggers_after_stable_period() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
let excellent = make_report(0.3, 20); // would classify as Studio64k
|
||||
|
||||
// Starts at Good. Fast-forward stability by setting stable_since directly.
|
||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
||||
|
||||
// One excellent report should trigger a probe (Good → Studio32k)
|
||||
let result = ctrl.observe(&excellent);
|
||||
assert!(result.is_some(), "should start probe after 30s stable");
|
||||
assert!(ctrl.probe.is_some(), "probe should be active");
|
||||
assert_eq!(ctrl.probe.as_ref().unwrap().target_tier, Tier::Studio32k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_succeeds_after_window() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
||||
|
||||
let excellent = make_report(0.3, 20);
|
||||
|
||||
// Trigger probe start
|
||||
let result = ctrl.observe(&excellent);
|
||||
assert!(result.is_some());
|
||||
|
||||
// Simulate probe window elapsed by backdating started
|
||||
ctrl.probe.as_mut().unwrap().started =
|
||||
Instant::now() - Duration::from_secs(PROBE_DURATION_SECS);
|
||||
|
||||
// Next good report should finalize the probe
|
||||
let result = ctrl.observe(&excellent);
|
||||
assert!(result.is_some(), "probe should succeed");
|
||||
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
||||
assert!(ctrl.probe.is_none(), "probe should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_fails_on_bad_reports() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
// Put controller at Studio32k, pretend we've been stable
|
||||
ctrl.current_tier = Tier::Studio32k;
|
||||
ctrl.current_profile = Tier::Studio32k.profile();
|
||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
||||
|
||||
// Start a probe to Studio48k
|
||||
let excellent = make_report(0.3, 20);
|
||||
let result = ctrl.observe(&excellent);
|
||||
assert!(result.is_some()); // probe started
|
||||
assert_eq!(ctrl.probe.as_ref().unwrap().target_tier, Tier::Studio48k);
|
||||
|
||||
// Feed bad reports (loss too high for Studio48k)
|
||||
let degraded = make_report(3.0, 100);
|
||||
ctrl.observe(°raded); // first bad
|
||||
ctrl.observe(°raded); // second bad — exceeds PROBE_MAX_BAD (1)
|
||||
|
||||
// Probe should be cancelled
|
||||
assert!(
|
||||
ctrl.probe.is_none(),
|
||||
"probe should be cancelled after bad reports"
|
||||
);
|
||||
// Should still be at Studio32k (not upgraded)
|
||||
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_probe_on_cellular() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
ctrl.current_tier = Tier::Good;
|
||||
ctrl.current_profile = Tier::Good.profile();
|
||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(60));
|
||||
|
||||
let good = make_report(0.5, 40);
|
||||
let result = ctrl.observe(&good);
|
||||
// Should NOT probe on cellular
|
||||
assert!(ctrl.probe.is_none(), "should not probe on cellular");
|
||||
assert!(result.is_none() || ctrl.current_tier == Tier::Good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_probe_at_highest_tier() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.current_tier = Tier::Studio64k;
|
||||
ctrl.current_profile = Tier::Studio64k.profile();
|
||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(60));
|
||||
|
||||
let excellent = make_report(0.1, 10);
|
||||
let result = ctrl.observe(&excellent);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"should not probe when already at Studio64k"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,6 @@ pub trait AudioEncoder: Send + Sync {
|
||||
|
||||
/// Enable/disable DTX (discontinuous transmission). No-op for Codec2.
|
||||
fn set_dtx(&mut self, _enabled: bool) {}
|
||||
|
||||
/// Hint the encoder about expected packet loss (0–100). In DRED mode the
|
||||
/// encoder floors this at 15% internally. No-op for Codec2.
|
||||
fn set_expected_loss(&mut self, _loss_pct: u8) {}
|
||||
|
||||
/// Set DRED duration in 10 ms frame units (0–104). No-op for Codec2.
|
||||
fn set_dred_duration(&mut self, _frames: u8) {}
|
||||
}
|
||||
|
||||
/// Decodes compressed frames back to PCM audio.
|
||||
@@ -61,27 +54,11 @@ pub trait FecEncoder: Send + Sync {
|
||||
/// Add a source symbol (one audio frame) to the current block.
|
||||
fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
|
||||
|
||||
/// Add a source symbol and mark whether it belongs to a keyframe.
|
||||
///
|
||||
/// When the block contains at least one keyframe source symbol,
|
||||
/// [`generate_repair`] uses the configured keyframe ratio instead of the
|
||||
/// nominal ratio.
|
||||
///
|
||||
/// Default implementation delegates to [`add_source_symbol`] and ignores
|
||||
/// the keyframe flag.
|
||||
fn add_source_symbol_with_keyframe(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
_is_keyframe: bool,
|
||||
) -> Result<(), FecError> {
|
||||
self.add_source_symbol(data)
|
||||
}
|
||||
|
||||
/// Generate repair symbols for the current block.
|
||||
///
|
||||
/// `ratio` is the repair overhead (e.g., 0.5 = 50% more symbols than source).
|
||||
/// Returns `(fec_symbol_index, repair_data)` pairs.
|
||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u16, Vec<u8>)>, FecError>;
|
||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError>;
|
||||
|
||||
/// Finalize the current block and start a new one.
|
||||
/// Returns the block ID of the finalized block.
|
||||
@@ -100,7 +77,7 @@ pub trait FecDecoder: Send + Sync {
|
||||
fn add_symbol(
|
||||
&mut self,
|
||||
block_id: u8,
|
||||
symbol_index: u16,
|
||||
symbol_index: u8,
|
||||
is_repair: bool,
|
||||
data: &[u8],
|
||||
) -> Result<(), FecError>;
|
||||
|
||||
@@ -20,7 +20,6 @@ bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = "0.8"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde_json = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
@@ -29,7 +28,6 @@ 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"
|
||||
dashmap = "6"
|
||||
dirs = "6"
|
||||
sha2 = { workspace = true }
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -7,7 +7,9 @@ fn main() {
|
||||
.output();
|
||||
|
||||
let hash = match output {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Ok(o) if o.status.success() => {
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
//! Tier F audio scorer — behavioural entropy detection for abuse mitigation.
|
||||
//!
|
||||
//! Computes a `legitimacy ∈ [0, 1]` score over a 10–30 s observation window.
|
||||
//! Features: IAT CoV, payload-size bimodality, silence fraction, bitrate
|
||||
//! deviation, and Q-flag cadence.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wzp_proto::{CodecId, MediaHeader, MediaType};
|
||||
|
||||
use crate::verdict::Verdict;
|
||||
|
||||
/// Maximum samples kept in rolling windows.
|
||||
const MAX_IAT_SAMPLES: usize = 200;
|
||||
const MAX_SIZE_SAMPLES: usize = 200;
|
||||
const MAX_Q_INTERVALS: usize = 32;
|
||||
|
||||
/// Silence threshold: payload below this many bytes is treated as silence / CN.
|
||||
const SILENCE_SIZE_THRESHOLD: usize = 16;
|
||||
|
||||
/// Observation window for bitrate tracking.
|
||||
const BITRATE_WINDOW_SECS: u64 = 30;
|
||||
|
||||
// Number of payload-size histogram bins.
|
||||
// (SIZE_BINS reserved for future histogram-based bimodality)
|
||||
|
||||
/// Audio-specific behavioural scorer (Tier F).
|
||||
pub struct AudioScorer {
|
||||
/// Rolling inter-arrival times.
|
||||
iat_samples: VecDeque<Duration>,
|
||||
last_arrival: Option<Instant>,
|
||||
|
||||
/// Rolling payload sizes.
|
||||
size_samples: VecDeque<usize>,
|
||||
|
||||
/// Count of packets below silence threshold.
|
||||
silence_packets: u32,
|
||||
/// Total packets observed in current window.
|
||||
total_packets: u32,
|
||||
|
||||
/// Bitrate window.
|
||||
window_start: Instant,
|
||||
window_bytes: u64,
|
||||
|
||||
/// Q-flag arrival intervals.
|
||||
q_intervals: VecDeque<Duration>,
|
||||
last_q_flag: Option<Instant>,
|
||||
|
||||
/// Codec declared at first packet (used for nominal bitrate baseline).
|
||||
declared_codec: Option<CodecId>,
|
||||
}
|
||||
|
||||
impl AudioScorer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
iat_samples: VecDeque::with_capacity(MAX_IAT_SAMPLES),
|
||||
last_arrival: None,
|
||||
size_samples: VecDeque::with_capacity(MAX_SIZE_SAMPLES),
|
||||
silence_packets: 0,
|
||||
total_packets: 0,
|
||||
window_start: Instant::now(),
|
||||
window_bytes: 0,
|
||||
q_intervals: VecDeque::with_capacity(MAX_Q_INTERVALS),
|
||||
last_q_flag: None,
|
||||
declared_codec: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one packet into the scorer.
|
||||
pub fn observe(&mut self, header: &MediaHeader, payload_len: usize, now: Instant) {
|
||||
// Ignore non-audio traffic.
|
||||
if header.media_type != MediaType::Audio {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.declared_codec.is_none() {
|
||||
self.declared_codec = Some(header.codec_id);
|
||||
}
|
||||
|
||||
// IAT
|
||||
if let Some(last) = self.last_arrival {
|
||||
let iat = now.saturating_duration_since(last);
|
||||
self.iat_samples.push_back(iat);
|
||||
if self.iat_samples.len() > MAX_IAT_SAMPLES {
|
||||
self.iat_samples.pop_front();
|
||||
}
|
||||
}
|
||||
self.last_arrival = Some(now);
|
||||
|
||||
// Payload size
|
||||
self.size_samples.push_back(payload_len);
|
||||
if self.size_samples.len() > MAX_SIZE_SAMPLES {
|
||||
self.size_samples.pop_front();
|
||||
}
|
||||
|
||||
// Silence fraction
|
||||
self.total_packets += 1;
|
||||
if payload_len <= SILENCE_SIZE_THRESHOLD {
|
||||
self.silence_packets += 1;
|
||||
}
|
||||
|
||||
// Bitrate window
|
||||
if now.duration_since(self.window_start) >= Duration::from_secs(BITRATE_WINDOW_SECS) {
|
||||
self.window_start = now;
|
||||
self.window_bytes = 0;
|
||||
}
|
||||
self.window_bytes += (MediaHeader::WIRE_SIZE + payload_len) as u64;
|
||||
|
||||
// Q-flag cadence
|
||||
if header.has_quality() {
|
||||
if let Some(last) = self.last_q_flag {
|
||||
let interval = now.saturating_duration_since(last);
|
||||
self.q_intervals.push_back(interval);
|
||||
if self.q_intervals.len() > MAX_Q_INTERVALS {
|
||||
self.q_intervals.pop_front();
|
||||
}
|
||||
}
|
||||
self.last_q_flag = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute legitimacy score ∈ [0, 1].
|
||||
///
|
||||
/// Higher = more legitimate. Returns `None` when insufficient samples
|
||||
/// have been collected (< 20 packets).
|
||||
pub fn legitimacy(&self) -> Option<f32> {
|
||||
if self.total_packets < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut score = 1.0f32;
|
||||
|
||||
// 1. IAT CoV penalty
|
||||
if let Some(cov) = self.iat_cov() {
|
||||
if cov > 0.4 {
|
||||
let penalty = ((cov - 0.4) / 0.6).min(1.0) * 0.25;
|
||||
score -= penalty as f32;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Silence fraction penalty
|
||||
let silence_fraction = self.silence_fraction();
|
||||
if silence_fraction < 0.02 {
|
||||
let penalty = ((0.02 - silence_fraction) / 0.02).min(1.0) * 0.25;
|
||||
score -= penalty as f32;
|
||||
} else if silence_fraction > 0.60 {
|
||||
// Too much silence can also be suspicious (stuffed payloads)
|
||||
let penalty = ((silence_fraction - 0.60) / 0.40).min(1.0) * 0.15;
|
||||
score -= penalty as f32;
|
||||
}
|
||||
|
||||
// 3. Bitrate deviation penalty
|
||||
if let Some(ratio) = self.bitrate_ratio() {
|
||||
if ratio > 1.20 {
|
||||
let penalty = ((ratio - 1.20) / 0.80).min(1.0) * 0.25;
|
||||
score -= penalty as f32;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Q-flag cadence penalty
|
||||
if let Some(cv) = self.q_flag_cv() {
|
||||
// High variability in Q-flag spacing = suspicious
|
||||
if cv > 0.5 {
|
||||
let penalty = ((cv - 0.5) / 0.5).min(1.0) * 0.15;
|
||||
score -= penalty as f32;
|
||||
}
|
||||
} else {
|
||||
// No Q flags seen at all — mildly suspicious after many packets
|
||||
if self.total_packets > 100 {
|
||||
score -= 0.10;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Payload-size bimodality bonus/penalty
|
||||
if let Some(bimodality) = self.size_bimodality() {
|
||||
// Bimodality score: 0 = unimodal, 1 = strongly bimodal
|
||||
// Legitimate audio is bimodal (speech + silence)
|
||||
if bimodality < 0.2 {
|
||||
score -= 0.10;
|
||||
}
|
||||
}
|
||||
|
||||
Some(score.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Map legitimacy score to a [`Verdict`].
|
||||
pub fn verdict(&self) -> Option<Verdict> {
|
||||
self.legitimacy().map(|s| {
|
||||
if s >= 0.7 {
|
||||
Verdict::Legitimate
|
||||
} else if s >= 0.3 {
|
||||
Verdict::Suspect
|
||||
} else {
|
||||
Verdict::Abusive
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Feature extractors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Coefficient of variation of inter-arrival times.
|
||||
fn iat_cov(&self) -> Option<f64> {
|
||||
if self.iat_samples.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let mean = self
|
||||
.iat_samples
|
||||
.iter()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.sum::<f64>()
|
||||
/ self.iat_samples.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let variance = self
|
||||
.iat_samples
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let diff = d.as_secs_f64() - mean;
|
||||
diff * diff
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ self.iat_samples.len() as f64;
|
||||
let std = variance.sqrt();
|
||||
Some(std / mean)
|
||||
}
|
||||
|
||||
/// Fraction of packets that are silence / comfort-noise sized.
|
||||
fn silence_fraction(&self) -> f64 {
|
||||
if self.total_packets == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.silence_packets as f64 / self.total_packets as f64
|
||||
}
|
||||
|
||||
/// Ratio of observed bitrate to nominal bitrate over the 30 s window.
|
||||
fn bitrate_ratio(&self) -> Option<f64> {
|
||||
let codec = self.declared_codec?;
|
||||
let nominal_bps = codec.bitrate_bps() as f64;
|
||||
if nominal_bps == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let observed_bps = self.window_bytes as f64 * 8.0 / BITRATE_WINDOW_SECS as f64;
|
||||
Some(observed_bps / nominal_bps)
|
||||
}
|
||||
|
||||
/// Coefficient of variation of Q-flag intervals.
|
||||
fn q_flag_cv(&self) -> Option<f64> {
|
||||
if self.q_intervals.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mean = self
|
||||
.q_intervals
|
||||
.iter()
|
||||
.map(|d| d.as_secs_f64())
|
||||
.sum::<f64>()
|
||||
/ self.q_intervals.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let variance = self
|
||||
.q_intervals
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let diff = d.as_secs_f64() - mean;
|
||||
diff * diff
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ self.q_intervals.len() as f64;
|
||||
let std = variance.sqrt();
|
||||
Some(std / mean)
|
||||
}
|
||||
|
||||
/// Simple bimodality score based on a 2-bin histogram.
|
||||
///
|
||||
/// Splits payload sizes into "small" (≤ threshold) and "large" bins.
|
||||
/// Returns a score in [0, 1] where 1 = strongly bimodal.
|
||||
fn size_bimodality(&self) -> Option<f64> {
|
||||
if self.size_samples.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
let small = self
|
||||
.size_samples
|
||||
.iter()
|
||||
.filter(|&&s| s <= SILENCE_SIZE_THRESHOLD)
|
||||
.count();
|
||||
let large = self.size_samples.len() - small;
|
||||
let total = self.size_samples.len() as f64;
|
||||
let p_small = small as f64 / total;
|
||||
let _p_large = large as f64 / total;
|
||||
// Max bimodality when both bins are equally populated (~0.5 each)
|
||||
let bimodality = 1.0 - (p_small - 0.5).abs() * 2.0;
|
||||
Some(bimodality)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioScorer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn audio_header(payload_len: usize, has_quality: bool) -> MediaHeader {
|
||||
MediaHeader {
|
||||
version: 2,
|
||||
flags: if has_quality { 0x40 } else { 0 },
|
||||
media_type: MediaType::Audio,
|
||||
codec_id: CodecId::Opus24k,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_ignores_video() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let mut h = audio_header(100, false);
|
||||
h.media_type = MediaType::Video;
|
||||
scorer.observe(&h, 100, Instant::now());
|
||||
assert_eq!(scorer.total_packets, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_counts_packets() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
for i in 0..25 {
|
||||
let h = audio_header(100, false);
|
||||
scorer.observe(&h, 100, Instant::now() + Duration::from_millis(i * 20));
|
||||
}
|
||||
assert_eq!(scorer.total_packets, 25);
|
||||
assert!(scorer.legitimacy().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_legitimate_traffic() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
// Simulate 200 packets of legitimate audio:
|
||||
// ~20 ms IAT, mixed speech (100 B) and silence (8 B), periodic Q flags.
|
||||
for i in 0..200 {
|
||||
let payload = if i % 3 == 0 { 8 } else { 100 };
|
||||
let has_q = i % 10 == 0;
|
||||
let h = audio_header(payload, has_q);
|
||||
scorer.observe(&h, payload, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let leg = scorer.legitimacy().unwrap();
|
||||
assert!(
|
||||
leg >= 0.7,
|
||||
"legitimate traffic should score ≥ 0.7, got {leg}"
|
||||
);
|
||||
assert_eq!(scorer.verdict(), Some(Verdict::Legitimate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_abusive_uniform_iat() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
// Uniform IAT (no jitter), all same size, no Q flags — tunnel-like
|
||||
for i in 0..200 {
|
||||
let h = audio_header(200, false);
|
||||
scorer.observe(&h, 200, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let leg = scorer.legitimacy().unwrap();
|
||||
assert!(
|
||||
leg < 0.6,
|
||||
"uniform tunnel-like traffic should score < 0.6, got {leg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_abusive_no_silence() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
// No silence packets at all, very regular IAT
|
||||
for i in 0..200 {
|
||||
let h = audio_header(150, false);
|
||||
scorer.observe(&h, 150, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let leg = scorer.legitimacy().unwrap();
|
||||
assert!(
|
||||
leg < 0.6,
|
||||
"no-silence traffic should score < 0.6, got {leg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_scorer_insufficient_samples() {
|
||||
let scorer = AudioScorer::new();
|
||||
assert_eq!(scorer.legitimacy(), None);
|
||||
assert_eq!(scorer.verdict(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_fraction_computed_correctly() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
for i in 0..100 {
|
||||
let payload = if i < 30 { 8 } else { 100 };
|
||||
let h = audio_header(payload, false);
|
||||
scorer.observe(&h, payload, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
assert!((scorer.silence_fraction() - 0.30).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_ratio_saturates_when_no_codec() {
|
||||
let scorer = AudioScorer::new();
|
||||
assert_eq!(scorer.bitrate_ratio(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_flag_cv_regular_spacing() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
for i in 0..50 {
|
||||
let has_q = i % 5 == 0;
|
||||
let h = audio_header(100, has_q);
|
||||
scorer.observe(&h, 100, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let cv = scorer.q_flag_cv().unwrap();
|
||||
assert!(
|
||||
cv < 0.1,
|
||||
"regular Q-flag spacing should have CV < 0.1, got {cv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_bimodality_for_mixed_traffic() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
for i in 0..100 {
|
||||
let payload = if i % 2 == 0 { 8 } else { 120 };
|
||||
let h = audio_header(payload, false);
|
||||
scorer.observe(&h, payload, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let bim = scorer.size_bimodality().unwrap();
|
||||
assert!(
|
||||
bim > 0.8,
|
||||
"perfectly mixed small/large should be highly bimodal, got {bim}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_bimodality_for_uniform_traffic() {
|
||||
let mut scorer = AudioScorer::new();
|
||||
let base = Instant::now();
|
||||
for i in 0..100 {
|
||||
let h = audio_header(100, false);
|
||||
scorer.observe(&h, 100, base + Duration::from_millis(i * 20));
|
||||
}
|
||||
let bim = scorer.size_bimodality().unwrap();
|
||||
assert!(
|
||||
bim < 0.3,
|
||||
"uniform size traffic should be unimodal, got {bim}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@ pub struct AuthenticatedClient {
|
||||
///
|
||||
/// Calls `POST {auth_url}` with `{ "token": "..." }`.
|
||||
/// Returns the client identity if valid, or an error string.
|
||||
pub async fn validate_token(auth_url: &str, token: &str) -> Result<AuthenticatedClient, String> {
|
||||
pub async fn validate_token(
|
||||
auth_url: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedClient, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
|
||||
@@ -31,43 +31,6 @@ pub struct DirectCall {
|
||||
pub created_at: Instant,
|
||||
pub answered_at: Option<Instant>,
|
||||
pub ended_at: Option<Instant>,
|
||||
/// Phase 3 (hole-punching): caller's server-reflexive address
|
||||
/// as carried in the `DirectCallOffer`. The relay stashes it
|
||||
/// here when the offer arrives so it can later inject it as
|
||||
/// `peer_direct_addr` into the callee's `CallSetup`.
|
||||
pub caller_reflexive_addr: Option<String>,
|
||||
/// Phase 3 (hole-punching): callee's server-reflexive address
|
||||
/// as carried in the `DirectCallAnswer`. Only populated for
|
||||
/// `AcceptTrusted` answers — privacy-mode answers leave this
|
||||
/// `None`. Fed into the caller's `CallSetup.peer_direct_addr`.
|
||||
pub callee_reflexive_addr: Option<String>,
|
||||
/// Phase 4 (cross-relay): federation TLS fingerprint of the
|
||||
/// PEER RELAY that forwarded the offer/answer for this call.
|
||||
/// `None` for local calls — caller and callee both
|
||||
/// registered on this relay. `Some(fp)` when one side of
|
||||
/// the call is on a remote relay reached through the
|
||||
/// federation link identified by `fp`. The
|
||||
/// `DirectCallAnswer` handling uses this to route the reply
|
||||
/// back through the SAME link instead of broadcasting again.
|
||||
pub peer_relay_fp: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): caller's LAN-local
|
||||
/// interface addresses from the `DirectCallOffer`. Cross-
|
||||
/// wired into the callee's `CallSetup.peer_local_addrs` so
|
||||
/// the callee can direct-dial the caller over the same LAN
|
||||
/// without going through the WAN reflex addr (NAT
|
||||
/// hairpinning often doesn't work for same-LAN peers).
|
||||
pub caller_local_addrs: Vec<String>,
|
||||
/// Phase 5.5 (ICE host candidates): callee's LAN-local
|
||||
/// interface addresses from the `DirectCallAnswer`. Cross-
|
||||
/// wired into the caller's `CallSetup.peer_local_addrs`.
|
||||
pub callee_local_addrs: Vec<String>,
|
||||
/// Phase 8 (Tailscale-inspired): caller's port-mapped
|
||||
/// external address from NAT-PMP/PCP/UPnP. Cross-wired
|
||||
/// into callee's `CallSetup.peer_mapped_addr`.
|
||||
pub caller_mapped_addr: Option<String>,
|
||||
/// Phase 8: callee's port-mapped external address.
|
||||
/// Cross-wired into caller's `CallSetup.peer_mapped_addr`.
|
||||
pub callee_mapped_addr: Option<String>,
|
||||
}
|
||||
|
||||
/// Registry of active direct calls.
|
||||
@@ -83,12 +46,7 @@ impl CallRegistry {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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,
|
||||
@@ -99,79 +57,11 @@ impl CallRegistry {
|
||||
created_at: Instant::now(),
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
caller_reflexive_addr: None,
|
||||
callee_reflexive_addr: None,
|
||||
peer_relay_fp: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
callee_local_addrs: Vec::new(),
|
||||
caller_mapped_addr: None,
|
||||
callee_mapped_addr: None,
|
||||
};
|
||||
self.calls.insert(call_id.clone(), call);
|
||||
self.calls.get(&call_id).unwrap()
|
||||
}
|
||||
|
||||
/// Phase 5.5: stash the caller's LAN host candidates from
|
||||
/// the `DirectCallOffer`. Empty Vec is a valid value meaning
|
||||
/// "caller has no LAN candidates" (e.g. old client).
|
||||
pub fn set_caller_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.caller_local_addrs = addrs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 5.5: stash the callee's LAN host candidates from
|
||||
/// the `DirectCallAnswer`.
|
||||
pub fn set_callee_local_addrs(&mut self, call_id: &str, addrs: Vec<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.callee_local_addrs = addrs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: stash the federation TLS fingerprint of the peer
|
||||
/// relay that originated (or will receive) the cross-relay
|
||||
/// forward for this call. Safe to call with `None` to clear
|
||||
/// a previously-set value.
|
||||
pub fn set_peer_relay_fp(&mut self, call_id: &str, fp: Option<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.peer_relay_fp = fp;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 3: stash the caller's server-reflexive address read
|
||||
/// off a `DirectCallOffer`. Safe to call on any call state;
|
||||
/// a no-op if the call doesn't exist.
|
||||
pub fn set_caller_reflexive_addr(&mut self, call_id: &str, addr: Option<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.caller_reflexive_addr = addr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 3: stash the callee's server-reflexive address read
|
||||
/// off a `DirectCallAnswer`. Safe to call on any call state;
|
||||
/// a no-op if the call doesn't exist.
|
||||
pub fn set_callee_reflexive_addr(&mut self, call_id: &str, addr: Option<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.callee_reflexive_addr = addr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 8: stash the caller's port-mapped address from
|
||||
/// the `DirectCallOffer`.
|
||||
pub fn set_caller_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.caller_mapped_addr = addr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 8: stash the callee's port-mapped address from
|
||||
/// the `DirectCallAnswer`.
|
||||
pub fn set_callee_mapped_addr(&mut self, call_id: &str, addr: Option<String>) {
|
||||
if let Some(call) = self.calls.get_mut(call_id) {
|
||||
call.callee_mapped_addr = addr;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a call by ID.
|
||||
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
||||
self.calls.get(call_id)
|
||||
@@ -194,12 +84,7 @@ impl CallRegistry {
|
||||
}
|
||||
|
||||
/// Transition to Active state.
|
||||
pub fn set_active(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
mode: wzp_proto::CallAcceptMode,
|
||||
room: String,
|
||||
) -> bool {
|
||||
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;
|
||||
@@ -223,8 +108,7 @@ impl CallRegistry {
|
||||
|
||||
/// Find active/pending calls involving a fingerprint.
|
||||
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
|
||||
self.calls
|
||||
.values()
|
||||
self.calls.values()
|
||||
.filter(|c| {
|
||||
c.state != DirectCallState::Ended
|
||||
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
|
||||
@@ -247,25 +131,22 @@ impl CallRegistry {
|
||||
/// 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()
|
||||
let expired: Vec<String> = self.calls.iter()
|
||||
.filter(|(_, c)| {
|
||||
c.state == DirectCallState::Pending && now.duration_since(c.created_at) > timeout
|
||||
c.state == DirectCallState::Pending
|
||||
&& now.duration_since(c.created_at) > timeout
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
expired
|
||||
.into_iter()
|
||||
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()
|
||||
self.calls.values()
|
||||
.filter(|c| c.state != DirectCallState::Ended)
|
||||
.count()
|
||||
}
|
||||
@@ -284,16 +165,9 @@ mod tests {
|
||||
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!(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")
|
||||
);
|
||||
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);
|
||||
@@ -322,119 +196,4 @@ mod tests {
|
||||
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
|
||||
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_registry_stores_reflexive_addrs() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
|
||||
// Default: both addrs are None.
|
||||
let c = reg.get("c1").unwrap();
|
||||
assert!(c.caller_reflexive_addr.is_none());
|
||||
assert!(c.callee_reflexive_addr.is_none());
|
||||
|
||||
// Caller advertises its reflex addr via DirectCallOffer.
|
||||
reg.set_caller_reflexive_addr("c1", Some("192.0.2.1:4433".into()));
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().caller_reflexive_addr.as_deref(),
|
||||
Some("192.0.2.1:4433")
|
||||
);
|
||||
|
||||
// Callee responds with AcceptTrusted + its own reflex addr.
|
||||
reg.set_callee_reflexive_addr("c1", Some("198.51.100.9:4433".into()));
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().callee_reflexive_addr.as_deref(),
|
||||
Some("198.51.100.9:4433")
|
||||
);
|
||||
|
||||
// Both addrs are independently readable — the relay uses
|
||||
// them to cross-wire peer_direct_addr in CallSetup.
|
||||
let c = reg.get("c1").unwrap();
|
||||
assert_eq!(c.caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
assert_eq!(
|
||||
c.callee_reflexive_addr.as_deref(),
|
||||
Some("198.51.100.9:4433")
|
||||
);
|
||||
|
||||
// Setter on an unknown call is a no-op, not a panic.
|
||||
reg.set_caller_reflexive_addr("does-not-exist", Some("x".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_registry_stores_peer_relay_fp() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
|
||||
// Default: no peer relay.
|
||||
assert!(reg.get("c1").unwrap().peer_relay_fp.is_none());
|
||||
|
||||
// Cross-relay call: origin relay's fp is stashed.
|
||||
reg.set_peer_relay_fp("c1", Some("relay-a-tls-fp".into()));
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().peer_relay_fp.as_deref(),
|
||||
Some("relay-a-tls-fp")
|
||||
);
|
||||
|
||||
// Clearing with None is a valid no-op and empties the field.
|
||||
reg.set_peer_relay_fp("c1", None);
|
||||
assert!(reg.get("c1").unwrap().peer_relay_fp.is_none());
|
||||
|
||||
// Unknown call is a no-op, not a panic.
|
||||
reg.set_peer_relay_fp("does-not-exist", Some("x".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_registry_stores_mapped_addrs() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
|
||||
// Default: both mapped addrs are None.
|
||||
let c = reg.get("c1").unwrap();
|
||||
assert!(c.caller_mapped_addr.is_none());
|
||||
assert!(c.callee_mapped_addr.is_none());
|
||||
|
||||
// Caller advertises its port-mapped addr via DirectCallOffer.
|
||||
reg.set_caller_mapped_addr("c1", Some("203.0.113.5:12345".into()));
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().caller_mapped_addr.as_deref(),
|
||||
Some("203.0.113.5:12345")
|
||||
);
|
||||
|
||||
// Callee responds with its mapped addr.
|
||||
reg.set_callee_mapped_addr("c1", Some("198.51.100.9:54321".into()));
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().callee_mapped_addr.as_deref(),
|
||||
Some("198.51.100.9:54321")
|
||||
);
|
||||
|
||||
// Both addrs readable — relay uses them to cross-wire
|
||||
// peer_mapped_addr in CallSetup.
|
||||
let c = reg.get("c1").unwrap();
|
||||
assert_eq!(c.caller_mapped_addr.as_deref(), Some("203.0.113.5:12345"));
|
||||
assert_eq!(c.callee_mapped_addr.as_deref(), Some("198.51.100.9:54321"));
|
||||
|
||||
// Setter on unknown call is a no-op.
|
||||
reg.set_caller_mapped_addr("nope", Some("x".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_registry_clearing_mapped_addr_works() {
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
reg.set_caller_mapped_addr("c1", Some("1.2.3.4:5".into()));
|
||||
reg.set_caller_mapped_addr("c1", None);
|
||||
assert!(reg.get("c1").unwrap().caller_mapped_addr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_registry_clearing_reflex_addr_works() {
|
||||
// Passing None to the setter must clear a previously-set value
|
||||
// so callers that downgrade to privacy mode mid-flow don't
|
||||
// leak a stale addr into CallSetup.
|
||||
let mut reg = CallRegistry::new();
|
||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
||||
reg.set_caller_reflexive_addr("c1", Some("192.0.2.1:4433".into()));
|
||||
reg.set_caller_reflexive_addr("c1", None);
|
||||
assert!(reg.get("c1").unwrap().caller_reflexive_addr.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +87,6 @@ pub struct RelayConfig {
|
||||
/// Unlike [[peers]], no url is needed — the peer connects to us.
|
||||
#[serde(default)]
|
||||
pub trusted: Vec<TrustedConfig>,
|
||||
/// Phase 8: geographic region identifier (e.g., "us-east", "eu-west").
|
||||
/// Sent to clients in `RegisterPresenceAck.relay_region` so they can
|
||||
/// build a relay map for automatic selection.
|
||||
pub region: Option<String>,
|
||||
/// Phase 8: externally-advertised address for this relay. Used to
|
||||
/// populate `available_relays` in `RegisterPresenceAck`. If not set,
|
||||
/// `listen_addr` is used.
|
||||
pub advertised_addr: Option<SocketAddr>,
|
||||
/// 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>,
|
||||
@@ -122,8 +114,6 @@ impl Default for RelayConfig {
|
||||
peers: Vec::new(),
|
||||
global_rooms: Vec::new(),
|
||||
trusted: Vec::new(),
|
||||
region: None,
|
||||
advertised_addr: None,
|
||||
debug_tap: None,
|
||||
event_log: None,
|
||||
}
|
||||
@@ -145,10 +135,7 @@ pub struct RelayInfo {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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);
|
||||
@@ -167,9 +154,7 @@ pub fn load_or_create_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 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!(
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
//! Relay conformance metering — Tier A/B/C/D/E enforcement.
|
||||
//!
|
||||
//! Each participant gets a [`ConformanceMeter`] that tracks per-second
|
||||
//! traffic against the declared codec's nominal bitrate ceiling.
|
||||
//! Violations are logged and counted but do **not** drop packets
|
||||
//! (observe-only mode).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
use wzp_proto::{CodecId, MediaHeader};
|
||||
|
||||
/// Rolling window size for timestamp-drift detection (Tier C).
|
||||
const DRIFT_WINDOW_SIZE: usize = 200;
|
||||
|
||||
/// Kinds of conformance violation detected by the relay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
/// Cumulative bitrate in the current 1 s window exceeds the Tier A ceiling.
|
||||
BitrateExceeded,
|
||||
/// Packet rate exceeds the per-codec safety limit (Tier B).
|
||||
PacketRateExceeded,
|
||||
/// Timestamp jumped backwards or forwards suspiciously (Tier C).
|
||||
TimestampDrift,
|
||||
/// Sustained payload size exceeds 2× the typical bound for the declared codec (Tier D).
|
||||
PayloadSizeExceeded,
|
||||
/// Per-session token-bucket rate cap exceeded (Tier E).
|
||||
RateCapExceeded,
|
||||
}
|
||||
|
||||
/// Error type returned when a [`TokenBucket`] does not hold enough tokens.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TokenExhausted;
|
||||
|
||||
/// Simple token bucket for per-session rate capping (Tier E).
|
||||
///
|
||||
/// Tokens represent bytes. The bucket refills at `refill_per_sec` bytes per
|
||||
/// second, up to `capacity`. A packet is allowed only if the bucket holds
|
||||
/// enough tokens for its size.
|
||||
pub struct TokenBucket {
|
||||
capacity: u64,
|
||||
tokens: f64,
|
||||
refill_per_sec: u64,
|
||||
last_refill: Instant,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
/// Create a new bucket with the given byte capacity and refill rate.
|
||||
pub fn new(capacity: u64, refill_per_sec: u64) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
tokens: capacity as f64,
|
||||
refill_per_sec,
|
||||
last_refill: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session audio cap: 256 kbps with 30 s @ 2× burst.
|
||||
/// Capacity = 30 s × 64 KB/s = 1_920_000 bytes.
|
||||
pub fn for_audio_session() -> Self {
|
||||
let refill_per_sec = 256_000 / 8; // 32_000 bytes/sec
|
||||
let capacity = refill_per_sec * 30 * 2; // 1_920_000 bytes
|
||||
Self::new(capacity, refill_per_sec)
|
||||
}
|
||||
|
||||
/// Attempt to consume `bytes` from the bucket.
|
||||
///
|
||||
/// Refills based on elapsed time since the last call, then deducts the
|
||||
/// cost. Returns `Ok(())` if enough tokens were available,
|
||||
/// `Err(TokenExhausted)` otherwise.
|
||||
pub fn try_consume(&mut self, bytes: u64, now: Instant) -> Result<(), TokenExhausted> {
|
||||
let elapsed = now.duration_since(self.last_refill);
|
||||
self.last_refill = now;
|
||||
self.tokens += elapsed.as_secs_f64() * self.refill_per_sec as f64;
|
||||
if self.tokens > self.capacity as f64 {
|
||||
self.tokens = self.capacity as f64;
|
||||
}
|
||||
if self.tokens >= bytes as f64 {
|
||||
self.tokens -= bytes as f64;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TokenExhausted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-participant traffic conformance meter.
|
||||
pub struct ConformanceMeter {
|
||||
window_start: Instant,
|
||||
bytes_in_window: u64,
|
||||
packets_in_window: u64,
|
||||
/// Rolling (seq, timestamp) pairs for drift detection.
|
||||
drift_window: VecDeque<(u32, u32)>,
|
||||
/// EWMA of payload size for Tier D sanity checks.
|
||||
ewma_payload_size: f64,
|
||||
/// Optional token bucket for Tier E per-session rate cap.
|
||||
token_bucket: Option<TokenBucket>,
|
||||
}
|
||||
|
||||
impl ConformanceMeter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
window_start: Instant::now(),
|
||||
bytes_in_window: 0,
|
||||
packets_in_window: 0,
|
||||
drift_window: VecDeque::with_capacity(DRIFT_WINDOW_SIZE),
|
||||
ewma_payload_size: 0.0,
|
||||
token_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a meter with a Tier E token bucket for per-session rate capping.
|
||||
pub fn with_token_bucket(bucket: TokenBucket) -> Self {
|
||||
let mut meter = Self::new();
|
||||
meter.token_bucket = Some(bucket);
|
||||
meter
|
||||
}
|
||||
|
||||
/// Inspect an incoming media packet and accumulate it against the
|
||||
/// current 1-second window. Returns [`Err(Violation)`] when a limit
|
||||
/// is crossed.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
header: &MediaHeader,
|
||||
payload_len: usize,
|
||||
now: Instant,
|
||||
) -> Result<(), Violation> {
|
||||
// Roll the window forward if a second has elapsed.
|
||||
if now.duration_since(self.window_start) >= Duration::from_secs(1) {
|
||||
self.window_start = now;
|
||||
self.bytes_in_window = 0;
|
||||
self.packets_in_window = 0;
|
||||
}
|
||||
|
||||
let packet_size = (MediaHeader::WIRE_SIZE + payload_len) as u64;
|
||||
self.bytes_in_window += packet_size;
|
||||
self.packets_in_window += 1;
|
||||
|
||||
// Tier A — bitrate ceiling.
|
||||
let ceiling = ceiling_bps(header.codec_id);
|
||||
let max_bytes_per_sec = ceiling / 8;
|
||||
if self.bytes_in_window > max_bytes_per_sec {
|
||||
return Err(Violation::BitrateExceeded);
|
||||
}
|
||||
|
||||
// Tier B — packet-rate ceiling.
|
||||
let max_pps = max_pps(header.codec_id);
|
||||
let pps_threshold = (max_pps as f32 * 1.5) as u64;
|
||||
if self.packets_in_window > pps_threshold {
|
||||
return Err(Violation::PacketRateExceeded);
|
||||
}
|
||||
|
||||
// Tier C — timestamp drift.
|
||||
self.drift_window.push_back((header.seq, header.timestamp));
|
||||
if self.drift_window.len() > DRIFT_WINDOW_SIZE {
|
||||
self.drift_window.pop_front();
|
||||
}
|
||||
if self.drift_window.len() >= 2 {
|
||||
let (first_seq, first_ts) = self.drift_window.front().copied().unwrap();
|
||||
let (last_seq, last_ts) = self.drift_window.back().copied().unwrap();
|
||||
|
||||
let ds = last_seq.wrapping_sub(first_seq) as f64;
|
||||
let dt = last_ts.wrapping_sub(first_ts) as f64;
|
||||
|
||||
if ds > 0.0 {
|
||||
let avg_ms_per_packet = dt / ds;
|
||||
let frame_ms = header.codec_id.frame_duration_ms() as f64;
|
||||
let min_ratio = frame_ms * 0.5;
|
||||
let max_ratio = frame_ms * 2.0;
|
||||
if avg_ms_per_packet < min_ratio || avg_ms_per_packet > max_ratio {
|
||||
return Err(Violation::TimestampDrift);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier D — payload-size sanity (EWMA).
|
||||
let alpha = 0.05; // ~20-packet smoothing
|
||||
self.ewma_payload_size =
|
||||
alpha * payload_len as f64 + (1.0 - alpha) * self.ewma_payload_size;
|
||||
let bound = payload_size_bound(header.codec_id);
|
||||
if self.ewma_payload_size > (bound * 2) as f64 {
|
||||
return Err(Violation::PayloadSizeExceeded);
|
||||
}
|
||||
|
||||
// Tier E — per-session token-bucket rate cap.
|
||||
if let Some(ref mut bucket) = self.token_bucket {
|
||||
let packet_size = (MediaHeader::WIRE_SIZE + payload_len) as u64;
|
||||
if bucket.try_consume(packet_size, now).is_err() {
|
||||
return Err(Violation::RateCapExceeded);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConformanceMeter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Tier A bitrate ceiling for a given codec.
|
||||
///
|
||||
/// Formula:
|
||||
/// nominal_bitrate * 3 (FEC 2.0 overhead) * 115 / 100 (15% safety margin)
|
||||
/// with a floor of 2 kbps.
|
||||
pub fn ceiling_bps(codec: CodecId) -> u64 {
|
||||
let nominal = codec.bitrate_bps() as u64;
|
||||
(nominal * 3 * 115 / 100).max(2_000)
|
||||
}
|
||||
|
||||
/// Compute the Tier B packet-rate ceiling for a given codec.
|
||||
///
|
||||
/// Formula:
|
||||
/// 1000 / frame_duration_ms * 3 (FEC overhead factor)
|
||||
pub fn max_pps(codec: CodecId) -> u32 {
|
||||
let fd = codec.frame_duration_ms() as u32;
|
||||
if fd == 0 {
|
||||
return 0;
|
||||
}
|
||||
(1000 / fd) * 3
|
||||
}
|
||||
|
||||
/// Typical per-codec payload size bound in bytes (Tier D).
|
||||
///
|
||||
/// These are empirical upper bounds for a single audio frame at the codec's
|
||||
/// nominal configuration. The EWMA must not exceed 2× this value.
|
||||
pub fn payload_size_bound(codec: CodecId) -> usize {
|
||||
match codec {
|
||||
CodecId::Opus64k => 320,
|
||||
CodecId::Opus48k => 240,
|
||||
CodecId::Opus32k => 200,
|
||||
CodecId::Opus24k => 160,
|
||||
CodecId::Opus16k => 100,
|
||||
CodecId::Opus6k => 90,
|
||||
CodecId::Codec2_3200 => 30,
|
||||
CodecId::Codec2_1200 => 30,
|
||||
CodecId::ComfortNoise => 16,
|
||||
CodecId::H264Baseline | CodecId::H265Main | CodecId::Av1Main => 1400,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::MediaType;
|
||||
|
||||
fn make_header(codec_id: CodecId) -> MediaHeader {
|
||||
MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id,
|
||||
seq: 0,
|
||||
timestamp: 0,
|
||||
fec_block: 0,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_header_with_seq_ts(codec_id: CodecId, seq: u32, timestamp: u32) -> MediaHeader {
|
||||
MediaHeader {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
media_type: MediaType::Audio,
|
||||
codec_id,
|
||||
seq,
|
||||
timestamp,
|
||||
fec_block: 0,
|
||||
stream_id: 0,
|
||||
fec_ratio: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_exceeded_for_opus24k() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let header = make_header(CodecId::Opus24k);
|
||||
|
||||
// Ceiling for Opus24k = 24_000 * 3 * 115 / 100 = 82_800 bps
|
||||
// = 10_350 bytes/sec. 1 MB/s = 125_000 bytes/packet will blow past
|
||||
// that in a single packet.
|
||||
let now = Instant::now();
|
||||
let result = meter.observe(&header, 1_000_000, now);
|
||||
assert_eq!(result, Err(Violation::BitrateExceeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_packets_stay_within_ceiling() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let header = make_header(CodecId::Opus24k);
|
||||
|
||||
// Ceiling = 82_800 bps = 10_350 bytes/sec.
|
||||
// Each packet = 16-byte header + 80 bytes = 96 bytes.
|
||||
// 100 packets = 9_600 bytes < 10_350.
|
||||
let now = Instant::now();
|
||||
for _ in 0..100 {
|
||||
assert!(meter.observe(&header, 80, now).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_resets_after_one_second() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let header = make_header(CodecId::Opus24k);
|
||||
|
||||
// Fill the window to just under the limit.
|
||||
// Use 300-byte payloads (under Tier D 2× bound of 320 for Opus24k).
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..32 {
|
||||
assert!(meter.observe(&header, 300, t0).is_ok());
|
||||
}
|
||||
// 32 * (header wire size + 300) ≈ 32 * 316 = 10_112 bytes < 10_350
|
||||
|
||||
// Same packets 1.1 seconds later should be fine because the window
|
||||
// rolls over.
|
||||
let t1 = t0 + Duration::from_millis(1_100);
|
||||
for _ in 0..32 {
|
||||
assert!(meter.observe(&header, 300, t1).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceiling_bps_floor() {
|
||||
// ComfortNoise has 0 nominal bitrate, so the floor kicks in.
|
||||
assert_eq!(ceiling_bps(CodecId::ComfortNoise), 2_000);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tier B — packet rate
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn packet_rate_exceeded() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
// Opus24k: max_pps = 1000/20 * 3 = 150. Threshold = 150 * 1.5 = 225.
|
||||
let header = make_header(CodecId::Opus24k);
|
||||
let now = Instant::now();
|
||||
for _ in 0..225 {
|
||||
assert!(meter.observe(&header, 10, now).is_ok());
|
||||
}
|
||||
// 226th packet should trip the limit.
|
||||
assert_eq!(
|
||||
meter.observe(&header, 10, now),
|
||||
Err(Violation::PacketRateExceeded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_rate_within_limit() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
// Opus6k: max_pps = 1000/40 * 3 = 75. Threshold = 75 * 1.5 = 112.
|
||||
// Use 0-byte payload so bitrate ceiling (2_587 bytes/sec) is not the
|
||||
// limiting factor. 112 packets × 16 bytes = 1_792 bytes < 2_587.
|
||||
let header = make_header(CodecId::Opus6k);
|
||||
let now = Instant::now();
|
||||
for _ in 0..112 {
|
||||
assert!(meter.observe(&header, 0, now).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tier C — timestamp drift
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn timestamp_drift_detected_when_too_fast() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
// Opus24k frame_duration = 20 ms.
|
||||
// Acceptable range: [10, 40] ms per packet.
|
||||
// Send packets with timestamp advancing by 5 ms each (too fast).
|
||||
let now = Instant::now();
|
||||
let mut drift_seen = false;
|
||||
for i in 0..200 {
|
||||
let header = make_header_with_seq_ts(CodecId::Opus24k, i, i * 5);
|
||||
match meter.observe(&header, 10, now) {
|
||||
Ok(()) => {}
|
||||
Err(Violation::TimestampDrift) => drift_seen = true,
|
||||
Err(other) => panic!("unexpected violation: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(drift_seen, "expected TimestampDrift to be detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_drift_detected_when_too_slow() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
// Opus24k frame_duration = 20 ms.
|
||||
// Acceptable range: [10, 40] ms per packet.
|
||||
// Send packets with timestamp advancing by 50 ms each (too slow).
|
||||
let now = Instant::now();
|
||||
let mut drift_seen = false;
|
||||
for i in 0..200 {
|
||||
let header = make_header_with_seq_ts(CodecId::Opus24k, i, i * 50);
|
||||
match meter.observe(&header, 10, now) {
|
||||
Ok(()) => {}
|
||||
Err(Violation::TimestampDrift) => drift_seen = true,
|
||||
Err(other) => panic!("unexpected violation: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(drift_seen, "expected TimestampDrift to be detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_normal_no_drift() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
// Opus24k frame_duration = 20 ms.
|
||||
// Send 200 packets with timestamp advancing by exactly 20 ms each.
|
||||
let now = Instant::now();
|
||||
for i in 0..200 {
|
||||
let header = make_header_with_seq_ts(CodecId::Opus24k, i, i * 20);
|
||||
assert!(meter.observe(&header, 10, now).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_drift_not_checked_before_two_packets() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let now = Instant::now();
|
||||
// Single packet with wild timestamp — should not trigger drift.
|
||||
let header = make_header_with_seq_ts(CodecId::Opus24k, 0, 999_999);
|
||||
assert!(meter.observe(&header, 10, now).is_ok());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tier D — payload-size sanity
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn conformance_tier_d() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let header = make_header(CodecId::Codec2_1200);
|
||||
let now = Instant::now();
|
||||
|
||||
// Codec2_1200 bound = 30 bytes. 2× bound = 60 bytes.
|
||||
// Feed 1400-byte payloads — EWMA should cross 60 within a few packets.
|
||||
let mut flagged = false;
|
||||
for _ in 0..200 {
|
||||
if meter.observe(&header, 1400, now).is_err() {
|
||||
flagged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
flagged,
|
||||
"expected PayloadSizeExceeded for 1400-byte Codec2_1200 payloads"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_size_normal_stays_within_bound() {
|
||||
let mut meter = ConformanceMeter::new();
|
||||
let header = make_header(CodecId::Opus24k);
|
||||
let now = Instant::now();
|
||||
|
||||
// Opus24k bound = 160 bytes. 2× bound = 320 bytes.
|
||||
// Feed 150-byte payloads — well within the 2× limit.
|
||||
// Limit to 10 packets so the 1-second bitrate window (10_350 bytes)
|
||||
// is not exhausted: 10 * (16 + 150) = 1_660 < 10_350.
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
meter.observe(&header, 150, now).is_ok(),
|
||||
"150-byte Opus24k payloads should stay within Tier D limit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tier E — token-bucket rate cap
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn token_bucket_small_burst_ok() {
|
||||
let mut bucket = TokenBucket::new(100_000, 32_000);
|
||||
let now = Instant::now();
|
||||
// 50 KB burst fits inside 100 KB capacity.
|
||||
assert!(bucket.try_consume(50_000, now).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_large_burst_fails() {
|
||||
let mut bucket = TokenBucket::new(100_000, 32_000);
|
||||
let now = Instant::now();
|
||||
// 1 MB exceeds 100 KB capacity.
|
||||
assert!(bucket.try_consume(1_000_000, now).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_refills_over_time() {
|
||||
let mut bucket = TokenBucket::new(100_000, 32_000);
|
||||
let t0 = Instant::now();
|
||||
// Drain the bucket.
|
||||
assert!(bucket.try_consume(100_000, t0).is_ok());
|
||||
// Immediately try again — should fail.
|
||||
assert!(bucket.try_consume(10_000, t0).is_err());
|
||||
// Wait 1 second — bucket refills 32_000 bytes.
|
||||
let t1 = t0 + Duration::from_secs(1);
|
||||
assert!(bucket.try_consume(30_000, t1).is_ok());
|
||||
// 40_000 is more than the 32_000 refilled.
|
||||
assert!(bucket.try_consume(40_000, t1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_sustained_rate_balanced() {
|
||||
let mut bucket = TokenBucket::new(1_000_000, 32_000);
|
||||
let t0 = Instant::now();
|
||||
// Send 32 KB every second for 5 seconds — exactly at refill rate.
|
||||
// The bucket should never empty because each second it refills
|
||||
// exactly what was consumed.
|
||||
for i in 0..5 {
|
||||
let t = t0 + Duration::from_secs(i);
|
||||
assert!(
|
||||
bucket.try_consume(32_000, t).is_ok(),
|
||||
"32 KB/s sustained should stay within bucket limit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conformance_tier_e_integration() {
|
||||
// Use Opus64k (high bitrate ceiling + high payload bound) so Tiers
|
||||
// A/B/D never fire on the small bursts used here. Only Tier E.
|
||||
let mut meter = ConformanceMeter::with_token_bucket(TokenBucket::new(1_000, 500));
|
||||
let header = make_header(CodecId::Opus64k);
|
||||
let now = Instant::now();
|
||||
|
||||
// Two 500-byte (wire) packets = 1_000 bytes — exactly the bucket cap.
|
||||
assert!(
|
||||
meter
|
||||
.observe(&header, 500 - MediaHeader::WIRE_SIZE, now)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
meter
|
||||
.observe(&header, 500 - MediaHeader::WIRE_SIZE, now)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Third packet exceeds the 1_000-byte cap.
|
||||
let result = meter.observe(&header, 10, now);
|
||||
assert_eq!(result, Err(Violation::RateCapExceeded));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -25,13 +26,16 @@ pub struct Event {
|
||||
pub src: Option<String>,
|
||||
/// Packet sequence number.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<u32>,
|
||||
pub seq: Option<u16>,
|
||||
/// Codec identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
/// FEC block ID (low byte) and symbol index (high byte).
|
||||
/// FEC block ID.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fec_block: Option<u16>,
|
||||
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>,
|
||||
@@ -57,9 +61,7 @@ pub struct Event {
|
||||
|
||||
impl Event {
|
||||
fn now() -> String {
|
||||
chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.6fZ")
|
||||
.to_string()
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
||||
}
|
||||
|
||||
/// Create a minimal event with just type and timestamp.
|
||||
@@ -72,6 +74,7 @@ impl Event {
|
||||
seq: None,
|
||||
codec: None,
|
||||
fec_block: None,
|
||||
fec_sym: None,
|
||||
repair: None,
|
||||
len: None,
|
||||
to_count: None,
|
||||
@@ -83,59 +86,33 @@ impl Event {
|
||||
}
|
||||
|
||||
/// Set room.
|
||||
pub fn room(mut self, room: &str) -> Self {
|
||||
self.room = Some(room.to_string());
|
||||
self
|
||||
}
|
||||
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
|
||||
}
|
||||
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.repair = Some(pkt.header.is_repair());
|
||||
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: u32) -> Self {
|
||||
self.seq = Some(seq);
|
||||
self
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
|
||||
}
|
||||
|
||||
/// Handle for emitting events. Cheap to clone.
|
||||
@@ -205,12 +182,8 @@ async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user