Compare commits
84 Commits
feat/deskt
...
opus-DRED-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d249b32ee5 | ||
|
|
22045bc5e6 | ||
|
|
766c9df442 | ||
|
|
24cc74d93c | ||
|
|
300ea66d13 | ||
|
|
114d69e488 | ||
|
|
15c237ceea | ||
|
|
a37c8b30fe | ||
|
|
137fe5f084 | ||
|
|
5dfb5b3581 | ||
|
|
fd0ccf8e99 | ||
|
|
2d4948a7b3 | ||
|
|
19703ff66c | ||
|
|
7e8dc400dc | ||
|
|
a798634b3d | ||
|
|
d89376016a | ||
|
|
678695776e | ||
|
|
4c1ad841e1 | ||
|
|
29cd23fe39 | ||
|
|
4d66d3769d | ||
|
|
002df15c5e | ||
|
|
1eb82d77b8 | ||
|
|
f843a934fe | ||
|
|
b79073c649 | ||
|
|
82b439595c | ||
|
|
1904b19d05 | ||
|
|
40955bd11c | ||
|
|
7554959baa | ||
|
|
0b62d3e22f | ||
|
|
4cfcd5117f | ||
|
|
bd6733b2e5 | ||
|
|
7d1b8f1fdc | ||
|
|
c2d298beb5 | ||
|
|
aee41a638d | ||
|
|
9fb92967eb | ||
|
|
9f2ff6a6ec | ||
|
|
134ee3a77f | ||
|
|
e61397ca85 | ||
|
|
f5542ef822 | ||
|
|
de007ec2fd | ||
|
|
0a973b234b | ||
|
|
026940d492 | ||
|
|
0ccf4ed6b5 | ||
|
|
847699bf66 | ||
|
|
6cd61fc63b | ||
|
|
50e6a50de4 | ||
|
|
0cb8d34b21 | ||
|
|
2427630472 | ||
|
|
16793be36f | ||
|
|
fa038df057 | ||
|
|
8990514417 | ||
|
|
1618ff6c9d | ||
|
|
05ec926317 | ||
|
|
b7a48bf13b | ||
|
|
e75b045470 | ||
|
|
20375eceb9 | ||
|
|
00deb97a5d | ||
|
|
da08723fe7 | ||
|
|
8cdf8d486a | ||
|
|
59ce52f8e8 | ||
|
|
39277bf3a0 | ||
|
|
8d903f16c6 | ||
|
|
921856eba9 | ||
|
|
7e7968b2f9 | ||
|
|
578ff8cff4 | ||
|
|
16890576fb | ||
|
|
daf7bcd9ba | ||
|
|
df1a45a5f5 | ||
|
|
dd0c714caa | ||
|
|
a7b2f850f1 | ||
|
|
575a39d07a | ||
|
|
d63d50cdc0 | ||
|
|
d269600aa7 | ||
|
|
dfbe21fe6e | ||
|
|
b83c31b5d1 | ||
|
|
1f607281fd | ||
|
|
7515417202 | ||
|
|
505a834c5b | ||
|
|
27bc264738 | ||
|
|
c27b39d553 | ||
|
|
6db5c25b54 | ||
|
|
54cbebd34e | ||
|
|
86526a7ad4 | ||
|
|
56e3417063 |
867
Cargo.lock
generated
867
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
32
Cargo.toml
32
Cargo.toml
@@ -32,12 +32,20 @@ serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Transport
|
||||
quinn = "0.11"
|
||||
socket2 = "0.5"
|
||||
|
||||
# FEC
|
||||
raptorq = "2"
|
||||
|
||||
# Codec
|
||||
audiopus = "0.3.0-rc.0"
|
||||
# opusic-c: high-level safe bindings over libopus 1.5.2 (encoder side).
|
||||
# opusic-sys: raw FFI for the decoder side — we build our own DecoderHandle
|
||||
# because opusic-c::Decoder.inner is pub(crate) and cannot be reached for the
|
||||
# Phase 3 DRED reconstruction path. See docs/PRD-dred-integration.md.
|
||||
# Pinned exactly (no caret) for reproducible libopus 1.5.2 across the fleet.
|
||||
opusic-c = { version = "=1.5.5", default-features = false, features = ["bundled", "dred"] }
|
||||
opusic-sys = { version = "=0.6.0", default-features = false, features = ["bundled"] }
|
||||
bytemuck = "1"
|
||||
codec2 = "0.3"
|
||||
|
||||
# Crypto
|
||||
@@ -66,9 +74,7 @@ opt-level = 2
|
||||
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
||||
[profile.dev.package.nnnoiseless]
|
||||
opt-level = 3
|
||||
[profile.dev.package.audiopus_sys]
|
||||
opt-level = 3
|
||||
[profile.dev.package.audiopus]
|
||||
[profile.dev.package.opusic-sys]
|
||||
opt-level = 3
|
||||
[profile.dev.package.raptorq]
|
||||
opt-level = 3
|
||||
@@ -77,15 +83,9 @@ opt-level = 3
|
||||
[profile.dev.package.wzp-fec]
|
||||
opt-level = 3
|
||||
|
||||
# Vendored audiopus_sys with a patched opus/CMakeLists.txt that distinguishes
|
||||
# real cl.exe (MSVC) from clang-cl (used by cargo-xwin for Windows cross-
|
||||
# compiles). Upstream libopus 1.3.1 gates its `-msse4.1` per-file compile
|
||||
# flags on `if(NOT MSVC)`, which is false under clang-cl because CMake sets
|
||||
# MSVC=1 for both compilers — resulting in SSE4.1 source files compiled
|
||||
# without the required target feature and hard failures in silk/NSQ_sse4_1.c.
|
||||
# The vendored copy introduces an `MSVC_CL` var (true only for real cl.exe)
|
||||
# and flips the SIMD guards to use it, restoring per-file SIMD flags for
|
||||
# clang-cl. See vendor/audiopus_sys/opus/CMakeLists.txt for the full diff
|
||||
# and rationale, plus xiph/opus#256 / xiph/opus PR #257 upstream.
|
||||
[patch.crates-io]
|
||||
audiopus_sys = { path = "vendor/audiopus_sys" }
|
||||
# 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,6 +46,14 @@ class DebugReporter(private val context: Context) {
|
||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
// Phase 4: extract DRED / classical PLC counters from the
|
||||
// stats JSON so they're visible in the meta preamble at a
|
||||
// glance, not buried in the trailing JSON dump.
|
||||
val dredReconstructions = extractLongField(finalStatsJson, "dred_reconstructions")
|
||||
val classicalPlc = extractLongField(finalStatsJson, "classical_plc_invocations")
|
||||
val framesDecoded = extractLongField(finalStatsJson, "frames_decoded")
|
||||
val fecRecovered = extractLongField(finalStatsJson, "fec_recovered")
|
||||
|
||||
// 1. Call metadata
|
||||
val meta = buildString {
|
||||
appendLine("=== WZ Phone Debug Report ===")
|
||||
@@ -58,6 +66,18 @@ class DebugReporter(private val context: Context) {
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||
appendLine()
|
||||
appendLine("=== Loss Recovery ===")
|
||||
appendLine("Frames decoded: $framesDecoded")
|
||||
appendLine("DRED reconstructions: $dredReconstructions (Opus neural recovery)")
|
||||
appendLine("Classical PLC: $classicalPlc (fallback)")
|
||||
appendLine("RaptorQ FEC recovered: $fecRecovered (Codec2 only)")
|
||||
if (framesDecoded > 0) {
|
||||
val dredPct = 100.0 * dredReconstructions / framesDecoded
|
||||
val plcPct = 100.0 * classicalPlc / framesDecoded
|
||||
appendLine("DRED rate: ${"%.2f".format(dredPct)}%")
|
||||
appendLine("Classical PLC rate: ${"%.2f".format(plcPct)}%")
|
||||
}
|
||||
appendLine()
|
||||
appendLine("=== Final Stats ===")
|
||||
appendLine(finalStatsJson)
|
||||
}
|
||||
@@ -195,4 +215,28 @@ class DebugReporter(private val context: Context) {
|
||||
FileInputStream(file).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny JSON field extractor — pulls an integer value for a top-level
|
||||
* field like `"dred_reconstructions":42`. We don't want to pull in a
|
||||
* full JSON parser just for the debug preamble, and the CallStats
|
||||
* output is a flat record with well-known field names.
|
||||
*
|
||||
* Returns 0 if the field is missing or unparseable.
|
||||
*/
|
||||
private fun extractLongField(json: String, field: String): Long {
|
||||
val key = "\"$field\":"
|
||||
val idx = json.indexOf(key)
|
||||
if (idx < 0) return 0
|
||||
var i = idx + key.length
|
||||
// Skip whitespace
|
||||
while (i < json.length && json[i].isWhitespace()) i++
|
||||
val start = i
|
||||
while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
|
||||
return try {
|
||||
json.substring(start, i).toLong()
|
||||
} catch (_: NumberFormatException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ 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() {
|
||||
@@ -163,6 +174,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
||||
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.
|
||||
|
||||
141
android/app/src/main/java/com/wzp/net/NetworkMonitor.kt
Normal file
141
android/app/src/main/java/com/wzp/net/NetworkMonitor.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
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,6 +5,7 @@ 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
|
||||
@@ -12,6 +13,7 @@ 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
|
||||
@@ -43,6 +45,7 @@ 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
|
||||
@@ -60,6 +63,9 @@ 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()
|
||||
|
||||
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
audioPipeline = AudioPipeline(appCtx)
|
||||
}
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
@@ -607,6 +625,27 @@ 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() {
|
||||
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
networkMonitor?.register()
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
@@ -668,8 +708,10 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.wzp.ui.call
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -50,6 +49,7 @@ 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,6 +75,7 @@ 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()
|
||||
@@ -622,12 +623,12 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Controls: Mic / End / Spk
|
||||
// Controls: Mic / End / Route (Ear/Spk/BT)
|
||||
ControlRow(
|
||||
isMuted = isMuted,
|
||||
isSpeaker = isSpeaker,
|
||||
audioRoute = audioRoute,
|
||||
onToggleMute = viewModel::toggleMute,
|
||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||
onCycleRoute = viewModel::cycleAudioRoute,
|
||||
onHangUp = { viewModel.stopCall() }
|
||||
)
|
||||
|
||||
@@ -916,9 +917,9 @@ private fun AudioLevelBar(audioLevel: Int) {
|
||||
@Composable
|
||||
private fun ControlRow(
|
||||
isMuted: Boolean,
|
||||
isSpeaker: Boolean,
|
||||
audioRoute: AudioRoute,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onCycleRoute: () -> Unit,
|
||||
onHangUp: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
@@ -960,22 +961,28 @@ private fun ControlRow(
|
||||
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
||||
// Speaker
|
||||
// Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleSpeaker,
|
||||
onClick = onCycleRoute,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = if (isSpeaker) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
colors = when (audioRoute) {
|
||||
AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0xFF0F3460), contentColor = Color.White
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0xFF2563EB), contentColor = Color.White
|
||||
)
|
||||
else -> IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = DarkSurface2, contentColor = Color.White
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (isSpeaker) "Spk\nOn" else "Spk",
|
||||
text = when (audioRoute) {
|
||||
AudioRoute.EARPIECE -> "Ear"
|
||||
AudioRoute.SPEAKER -> "Spk"
|
||||
AudioRoute.BLUETOOTH -> "BT"
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
|
||||
@@ -14,8 +14,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use wzp_codec::AdaptiveDecoder;
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::{
|
||||
@@ -97,6 +99,9 @@ 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 {
|
||||
@@ -118,6 +123,7 @@ 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,
|
||||
@@ -340,7 +346,7 @@ impl WzpEngine {
|
||||
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 })) => {
|
||||
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
|
||||
@@ -349,7 +355,7 @@ impl WzpEngine {
|
||||
// Store call setup info for Kotlin to pick up
|
||||
stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
|
||||
}
|
||||
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||
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;
|
||||
@@ -402,6 +408,13 @@ 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 {
|
||||
@@ -530,9 +543,12 @@ async fn run_call(
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// Initialize codec (Opus or Codec2 based on profile)
|
||||
// Initialize codec (Opus or Codec2 based on profile).
|
||||
// Phase 3c: decoder is a concrete AdaptiveDecoder (not Box<dyn
|
||||
// AudioDecoder>) so the recv task can call reconstruct_from_dred on
|
||||
// gaps detected via sequence tracking.
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = wzp_codec::create_decoder(profile);
|
||||
let mut decoder = AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder");
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
@@ -665,6 +681,19 @@ 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);
|
||||
@@ -675,11 +704,11 @@ async fn run_call(
|
||||
is_repair: false,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
||||
fec_ratio_encoded: hdr_fec_ratio,
|
||||
seq: s,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: frame_in_block,
|
||||
fec_block: hdr_fec_block,
|
||||
fec_symbol: hdr_fec_symbol,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
@@ -709,63 +738,66 @@ async fn run_call(
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// Feed encoded frame to FEC encoder
|
||||
// Codec2-only: feed RaptorQ and emit repair packets when the
|
||||
// block is full. Opus tiers skip this entire block — DRED
|
||||
// (enabled in Phase 1) provides codec-layer loss recovery.
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
if !is_opus {
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
|
||||
// When block is full, generate repair packets
|
||||
if frame_in_block >= current_profile.frames_per_block {
|
||||
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
if frame_in_block >= current_profile.frames_per_block {
|
||||
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: current_profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
current_profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = current_profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = current_profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
}
|
||||
}
|
||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||
t_frames += 1;
|
||||
@@ -808,7 +840,27 @@ 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;
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
|
||||
// Phase 3c: DRED reconstruction state. Unlike the desktop
|
||||
// CallDecoder (which sits behind a jitter buffer that emits
|
||||
// Missing signals), engine.rs reads packets directly from the
|
||||
// transport and decodes straight into the playout ring. Gap
|
||||
// detection is therefore done via sequence-number tracking:
|
||||
// when a packet arrives with seq > expected_seq, the frames in
|
||||
// between are missing and we attempt to reconstruct them via
|
||||
// DRED before decoding the newly-arrived packet.
|
||||
let mut dred_decoder =
|
||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
|
||||
let mut dred_parse_scratch =
|
||||
DredState::new().expect("opus_dred_alloc failed (scratch)");
|
||||
let mut last_good_dred =
|
||||
DredState::new().expect("opus_dred_alloc failed (good state)");
|
||||
let mut last_good_dred_seq: Option<u16> = None;
|
||||
let mut expected_seq: Option<u16> = None;
|
||||
let mut dred_reconstructions: u64 = 0;
|
||||
let mut classical_plc_invocations: u64 = 0;
|
||||
|
||||
info!("recv task started (Opus + DRED + Codec2/RaptorQ)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -830,6 +882,23 @@ async fn run_call(
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -850,14 +919,21 @@ async fn run_call(
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
let pkt_is_opus = pkt.header.codec_id.is_opus();
|
||||
|
||||
// Feed every packet (source + repair) to FEC decoder
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
// Phase 2: Opus packets bypass RaptorQ entirely — DRED
|
||||
// (enabled Phase 1) handles codec-layer loss recovery,
|
||||
// and feeding these symbols into the RaptorQ decoder
|
||||
// would accumulate block_id=0 duplicates that never
|
||||
// decode. Codec2 packets still feed RaptorQ.
|
||||
if !pkt_is_opus {
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
}
|
||||
|
||||
// Source packets: decode directly
|
||||
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||
@@ -880,6 +956,13 @@ async fn run_call(
|
||||
};
|
||||
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) {
|
||||
@@ -888,6 +971,109 @@ 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: u16 = 16;
|
||||
if let Some(expected) = expected_seq {
|
||||
let gap = pkt.header.seq.wrapping_sub(expected);
|
||||
if gap > 0 && gap <= MAX_GAP_FRAMES {
|
||||
let current_profile_frame_samples =
|
||||
(48_000 * profile.frame_duration_ms as i32) / 1000;
|
||||
let available = last_good_dred.samples_available();
|
||||
let pcm_slice_len =
|
||||
current_profile_frame_samples as usize;
|
||||
|
||||
for gap_idx in 0..gap {
|
||||
let missing_seq = expected.wrapping_add(gap_idx);
|
||||
// Offset from the DRED anchor (last_good_dred_seq)
|
||||
// back to the missing seq, in samples. Skip if
|
||||
// the anchor is not ahead of missing (defensive).
|
||||
let offset_samples = match last_good_dred_seq {
|
||||
Some(anchor) => {
|
||||
let delta = anchor.wrapping_sub(missing_seq);
|
||||
if delta == 0 || delta > MAX_GAP_FRAMES {
|
||||
-1 // skip DRED, use PLC
|
||||
} else {
|
||||
delta as i32 * current_profile_frame_samples
|
||||
}
|
||||
}
|
||||
None => -1,
|
||||
};
|
||||
|
||||
let reconstructed = if offset_samples > 0
|
||||
&& offset_samples <= available
|
||||
{
|
||||
decoder
|
||||
.reconstruct_from_dred(
|
||||
&last_good_dred,
|
||||
offset_samples,
|
||||
&mut decode_buf[..pcm_slice_len],
|
||||
)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match reconstructed {
|
||||
Some(samples) => {
|
||||
playout_agc.process_frame(
|
||||
&mut decode_buf[..samples],
|
||||
);
|
||||
state
|
||||
.playout_ring
|
||||
.write(&decode_buf[..samples]);
|
||||
dred_reconstructions += 1;
|
||||
frames_decoded += 1;
|
||||
}
|
||||
None => {
|
||||
// Fall through to classical PLC.
|
||||
if let Ok(samples) =
|
||||
decoder.decode_lost(&mut decode_buf)
|
||||
{
|
||||
playout_agc
|
||||
.process_frame(&mut decode_buf[..samples]);
|
||||
state
|
||||
.playout_ring
|
||||
.write(&decode_buf[..samples]);
|
||||
classical_plc_invocations += 1;
|
||||
frames_decoded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the expected-seq tracker for the next arrival.
|
||||
expected_seq = Some(pkt.header.seq.wrapping_add(1));
|
||||
}
|
||||
|
||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||
Ok(samples) => {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
@@ -899,32 +1085,44 @@ async fn run_call(
|
||||
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
// This is a decode-error fallback (not a
|
||||
// detected gap), so count it as PLC.
|
||||
classical_plc_invocations += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try FEC recovery
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
// Codec2-only: try FEC recovery and expire old blocks.
|
||||
// Opus packets skip both — the Phase 2 Opus path has no
|
||||
// RaptorQ state to query or clean up. The `fec_recovered`
|
||||
// counter is now effectively Codec2-only, which is
|
||||
// correct because DRED reconstructions will be counted
|
||||
// separately once Phase 3 lands (new telemetry field).
|
||||
if !pkt_is_opus {
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
}
|
||||
}
|
||||
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_decoded = frames_decoded;
|
||||
stats.fec_recovered = fec_recovered;
|
||||
stats.dred_reconstructions = dred_reconstructions;
|
||||
stats.classical_plc_invocations = classical_plc_invocations;
|
||||
drop(stats);
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
@@ -932,6 +1130,8 @@ async fn run_call(
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
dred_reconstructions,
|
||||
classical_plc_invocations,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
|
||||
@@ -222,6 +222,29 @@ 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)]
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
@@ -58,8 +58,16 @@ pub struct CallStats {
|
||||
pub frames_decoded: u64,
|
||||
/// Number of playout underruns (buffer empty when audio needed).
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by FEC.
|
||||
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
|
||||
/// RaptorQ per Phase 2).
|
||||
pub fec_recovered: u64,
|
||||
/// Phase 3c: Opus frames reconstructed via DRED side-channel data.
|
||||
/// Only increments on the Opus tiers; always zero for Codec2.
|
||||
pub dred_reconstructions: u64,
|
||||
/// Phase 3c: Opus frames filled via classical Opus PLC because no DRED
|
||||
/// state covered the gap, plus any decode-error fallbacks. Codec2 loss
|
||||
/// also increments this counter via the Codec2 PLC path.
|
||||
pub classical_plc_invocations: u64,
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
|
||||
@@ -24,6 +24,12 @@ chrono = "0.4"
|
||||
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"
|
||||
|
||||
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
||||
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
||||
|
||||
@@ -7,14 +7,15 @@ use std::time::{Duration, Instant};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
||||
use wzp_codec::{
|
||||
AdaptiveDecoder, AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector,
|
||||
};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{
|
||||
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
|
||||
};
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::packet::QualityReport;
|
||||
use wzp_proto::{CodecId, QualityProfile};
|
||||
|
||||
@@ -344,6 +345,22 @@ impl CallEncoder {
|
||||
let enc_len = self.audio_enc.encode(pcm, &mut encoded)?;
|
||||
encoded.truncate(enc_len);
|
||||
|
||||
// Phase 2: Opus tiers bypass RaptorQ entirely (DRED handles loss
|
||||
// recovery at the codec layer). Codec2 tiers keep RaptorQ unchanged.
|
||||
// On Opus packets, zero the FEC header fields so old receivers
|
||||
// can cleanly identify "no RaptorQ block to assemble" and new
|
||||
// receivers can short-circuit their FEC ingest path.
|
||||
let is_opus = self.profile.codec.is_opus();
|
||||
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
|
||||
(0u8, 0u8, 0u8)
|
||||
} else {
|
||||
(
|
||||
self.block_id,
|
||||
self.frame_in_block,
|
||||
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
)
|
||||
};
|
||||
|
||||
// Build source media packet
|
||||
let source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
@@ -351,11 +368,11 @@ impl CallEncoder {
|
||||
is_repair: false,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||
fec_ratio_encoded,
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: self.frame_in_block,
|
||||
fec_block,
|
||||
fec_symbol,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
@@ -370,39 +387,42 @@ impl CallEncoder {
|
||||
|
||||
let mut output = vec![source_pkt];
|
||||
|
||||
// Add to FEC encoder
|
||||
self.fec_enc.add_source_symbol(&encoded)?;
|
||||
self.frame_in_block += 1;
|
||||
// Codec2-only: feed RaptorQ and generate repair packets when the
|
||||
// block is full. Opus tiers skip this entire block — DRED (active
|
||||
// in Phase 1) provides codec-layer loss recovery.
|
||||
if !is_opus {
|
||||
self.fec_enc.add_source_symbol(&encoded)?;
|
||||
self.frame_in_block += 1;
|
||||
|
||||
// If block is full, generate repair and finalize
|
||||
if self.frame_in_block >= self.profile.frames_per_block {
|
||||
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
output.push(MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
self.profile.fec_ratio,
|
||||
),
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
});
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
if self.frame_in_block >= self.profile.frames_per_block {
|
||||
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
output.push(MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: self.profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
self.profile.fec_ratio,
|
||||
),
|
||||
seq: self.seq,
|
||||
timestamp: self.timestamp_ms,
|
||||
fec_block: self.block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
});
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
let _ = self.fec_enc.finalize_block();
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.frame_in_block = 0;
|
||||
}
|
||||
let _ = self.fec_enc.finalize_block();
|
||||
self.block_id = self.block_id.wrapping_add(1);
|
||||
self.frame_in_block = 0;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
@@ -425,6 +445,15 @@ impl CallEncoder {
|
||||
self.aec.feed_farend(farend);
|
||||
}
|
||||
|
||||
/// Apply DRED tuning output to the encoder.
|
||||
///
|
||||
/// Called by the send loop after `DredTuner::update()` returns `Some`.
|
||||
/// No-op when the active codec is Codec2 (DRED is Opus-only).
|
||||
pub fn apply_dred_tuning(&mut self, tuning: wzp_proto::DredTuning) {
|
||||
self.audio_enc.set_dred_duration(tuning.dred_frames);
|
||||
self.audio_enc.set_expected_loss(tuning.expected_loss_pct);
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||
self.aec.set_enabled(enabled);
|
||||
@@ -438,9 +467,12 @@ impl CallEncoder {
|
||||
|
||||
/// Manages the recv/decode side of a call.
|
||||
pub struct CallDecoder {
|
||||
/// Audio decoder.
|
||||
audio_dec: Box<dyn AudioDecoder>,
|
||||
/// FEC decoder.
|
||||
/// Audio decoder. Concrete `AdaptiveDecoder` (not `Box<dyn AudioDecoder>`)
|
||||
/// because Phase 3b calls the inherent `reconstruct_from_dred` method,
|
||||
/// which cannot live on the `AudioDecoder` trait without dragging libopus
|
||||
/// types into `wzp-proto`.
|
||||
audio_dec: AdaptiveDecoder,
|
||||
/// FEC decoder (Codec2 tiers only; Opus bypasses RaptorQ per Phase 2).
|
||||
fec_dec: RaptorQFecDecoder,
|
||||
/// Jitter buffer.
|
||||
jitter: JitterBuffer,
|
||||
@@ -454,6 +486,24 @@ pub struct CallDecoder {
|
||||
last_was_cn: bool,
|
||||
/// Mini-frame decompression context (tracks last full header baseline).
|
||||
mini_context: MiniFrameContext,
|
||||
// ─── Phase 3b: DRED reconstruction state ──────────────────────────────
|
||||
/// DRED side-channel parser (a separate libopus object from the decoder).
|
||||
dred_decoder: DredDecoderHandle,
|
||||
/// Scratch buffer used by `dred_decoder.parse_into` on every arriving
|
||||
/// Opus packet. Reused across calls to avoid 10 KB alloc churn per packet.
|
||||
dred_parse_scratch: DredState,
|
||||
/// Cached "most recently parsed valid" DRED state, swapped with
|
||||
/// `dred_parse_scratch` on successful parse. Used by `decode_next` when
|
||||
/// the jitter buffer reports a gap.
|
||||
last_good_dred: DredState,
|
||||
/// Sequence number of the packet that produced `last_good_dred`. `None`
|
||||
/// if no packet has yielded DRED state yet (cold start or legacy sender).
|
||||
last_good_dred_seq: Option<u16>,
|
||||
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
|
||||
pub dred_reconstructions: u64,
|
||||
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
|
||||
/// (because no DRED state covered the gap, or the active codec is Codec2).
|
||||
pub classical_plc_invocations: u64,
|
||||
}
|
||||
|
||||
impl CallDecoder {
|
||||
@@ -463,8 +513,19 @@ impl CallDecoder {
|
||||
} else {
|
||||
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
|
||||
};
|
||||
// Phase 3b: build the DRED parser + state buffers. These allocate
|
||||
// libopus state (~10 KB each) once per call, not per packet — the
|
||||
// scratch and last-good buffers are reused via std::mem::swap on
|
||||
// every successful parse.
|
||||
let dred_decoder =
|
||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed at call setup");
|
||||
let dred_parse_scratch =
|
||||
DredState::new().expect("opus_dred_alloc failed at call setup (scratch)");
|
||||
let last_good_dred =
|
||||
DredState::new().expect("opus_dred_alloc failed at call setup (good state)");
|
||||
Self {
|
||||
audio_dec: wzp_codec::create_decoder(config.profile),
|
||||
audio_dec: AdaptiveDecoder::new(config.profile)
|
||||
.expect("failed to create adaptive decoder"),
|
||||
fec_dec: wzp_fec::create_decoder(&config.profile),
|
||||
jitter,
|
||||
quality: AdaptiveQualityController::new(),
|
||||
@@ -472,6 +533,12 @@ impl CallDecoder {
|
||||
comfort_noise: ComfortNoise::new(50),
|
||||
last_was_cn: false,
|
||||
mini_context: MiniFrameContext::default(),
|
||||
dred_decoder,
|
||||
dred_parse_scratch,
|
||||
last_good_dred,
|
||||
last_good_dred_seq: None,
|
||||
dred_reconstructions: 0,
|
||||
classical_plc_invocations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,15 +553,54 @@ impl CallDecoder {
|
||||
|
||||
/// Feed a received media packet into the decode pipeline.
|
||||
pub fn ingest(&mut self, packet: MediaPacket) {
|
||||
// Feed to FEC decoder
|
||||
let _ = self.fec_dec.add_symbol(
|
||||
packet.header.fec_block,
|
||||
packet.header.fec_symbol,
|
||||
packet.header.is_repair,
|
||||
&packet.payload,
|
||||
);
|
||||
// Phase 2: Opus packets bypass RaptorQ. Codec2 packets still feed
|
||||
// the FEC decoder for recovery. This also cleanly drops any stray
|
||||
// Opus repair packets from an old sender (we don't push repair
|
||||
// packets to the jitter buffer either, so they're effectively
|
||||
// ignored — a graceful mixed-version degradation).
|
||||
if !packet.header.codec_id.is_opus() {
|
||||
let _ = self.fec_dec.add_symbol(
|
||||
packet.header.fec_block,
|
||||
packet.header.fec_symbol,
|
||||
packet.header.is_repair,
|
||||
&packet.payload,
|
||||
);
|
||||
}
|
||||
|
||||
// If not a repair packet, also feed directly to jitter buffer
|
||||
// Phase 3b: Opus source packets carry DRED side-channel data in
|
||||
// libopus 1.5. Parse it into the scratch state and, on success,
|
||||
// swap with the cached `last_good_dred` so later gap reconstruction
|
||||
// has fresh neural redundancy to draw from. Parsing happens before
|
||||
// the jitter push because the jitter buffer consumes the packet.
|
||||
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
|
||||
match self
|
||||
.dred_decoder
|
||||
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
|
||||
{
|
||||
Ok(available) if available > 0 => {
|
||||
// Swap the freshly parsed state into `last_good_dred`.
|
||||
// The old good state (now in scratch) is about to be
|
||||
// overwritten on the next parse — its contents are
|
||||
// not needed after this swap.
|
||||
std::mem::swap(&mut self.dred_parse_scratch, &mut self.last_good_dred);
|
||||
self.last_good_dred_seq = Some(packet.header.seq);
|
||||
}
|
||||
Ok(_) => {
|
||||
// Packet had no DRED data (return 0). Leave the cached
|
||||
// state untouched — it may still cover upcoming gaps
|
||||
// from a warm-up period where the encoder was producing
|
||||
// DRED bytes. The scratch buffer was potentially written
|
||||
// but its `samples_available` is 0 so it's harmless.
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("DRED parse error (ignored): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
|
||||
// Repair packets never reach the jitter buffer; for Codec2 they're
|
||||
// used by the FEC decoder above, for Opus they're dropped here.
|
||||
if !packet.header.is_repair {
|
||||
self.jitter.push(packet);
|
||||
}
|
||||
@@ -577,19 +683,72 @@ impl CallDecoder {
|
||||
result
|
||||
}
|
||||
PlayoutResult::Missing { seq } => {
|
||||
// Only generate PLC if there are still packets buffered ahead.
|
||||
// Only attempt recovery if there are still packets buffered ahead.
|
||||
// Otherwise we've drained everything — return None to stop.
|
||||
if self.jitter.depth() > 0 {
|
||||
debug!(seq, "packet loss, generating PLC");
|
||||
let result = self.audio_dec.decode_lost(pcm).ok();
|
||||
if result.is_some() {
|
||||
self.jitter.record_decode();
|
||||
}
|
||||
result
|
||||
} else {
|
||||
if self.jitter.depth() == 0 {
|
||||
self.jitter.record_underrun();
|
||||
None
|
||||
return None;
|
||||
}
|
||||
|
||||
// Phase 3b: try DRED reconstruction first. If we have a
|
||||
// recent DRED state from a packet whose seq > missing seq,
|
||||
// and the seq delta (in samples) fits within the state's
|
||||
// available window, libopus can synthesize a plausible
|
||||
// replacement for the lost frame. Fall back to classical
|
||||
// PLC when no state covers the gap, when the active codec
|
||||
// is Codec2, or when the reconstruction itself errors.
|
||||
if self.profile.codec.is_opus() {
|
||||
if let Some(last_seq) = self.last_good_dred_seq {
|
||||
// How many frames ahead of the missing seq is the
|
||||
// last-good packet? Use wrapping arithmetic for the
|
||||
// u16 seq space.
|
||||
let seq_delta = last_seq.wrapping_sub(seq);
|
||||
// Reject stale or backward state. u16 wraparound
|
||||
// would make a "seq went backward" delta very large;
|
||||
// cap at a sane forward-looking window.
|
||||
const MAX_SEQ_DELTA: u16 = 128;
|
||||
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
|
||||
let frame_samples =
|
||||
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
|
||||
let offset_samples = seq_delta as i32 * frame_samples;
|
||||
let available = self.last_good_dred.samples_available();
|
||||
if offset_samples > 0 && offset_samples <= available {
|
||||
match self.audio_dec.reconstruct_from_dred(
|
||||
&self.last_good_dred,
|
||||
offset_samples,
|
||||
pcm,
|
||||
) {
|
||||
Ok(n) => {
|
||||
self.dred_reconstructions += 1;
|
||||
self.jitter.record_decode();
|
||||
debug!(
|
||||
seq,
|
||||
last_seq,
|
||||
offset_samples,
|
||||
available,
|
||||
"DRED reconstruction for gap"
|
||||
);
|
||||
return Some(n);
|
||||
}
|
||||
Err(e) => {
|
||||
// Reconstruction failed — fall
|
||||
// through to classical PLC below.
|
||||
debug!(seq, "DRED reconstruct error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classical PLC fallback (also the Codec2 path).
|
||||
debug!(seq, "packet loss, generating classical PLC");
|
||||
self.classical_plc_invocations += 1;
|
||||
let result = self.audio_dec.decode_lost(pcm).ok();
|
||||
if result.is_some() {
|
||||
self.jitter.record_decode();
|
||||
}
|
||||
result
|
||||
}
|
||||
PlayoutResult::NotReady => {
|
||||
self.jitter.record_underrun();
|
||||
@@ -612,6 +771,19 @@ impl CallDecoder {
|
||||
pub fn reset_stats(&mut self) {
|
||||
self.jitter.reset_stats();
|
||||
}
|
||||
|
||||
/// Phase 3b introspection: sequence number of the most recently parsed
|
||||
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
|
||||
/// yet. Used by tests to debug reconstruction eligibility.
|
||||
pub fn last_good_dred_seq(&self) -> Option<u16> {
|
||||
self.last_good_dred_seq
|
||||
}
|
||||
|
||||
/// Phase 3b introspection: samples of audio history currently available
|
||||
/// in the cached DRED state.
|
||||
pub fn last_good_dred_samples_available(&self) -> i32 {
|
||||
self.last_good_dred.samples_available()
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic telemetry logger for jitter buffer statistics.
|
||||
@@ -673,18 +845,83 @@ mod tests {
|
||||
assert!(!packets[0].header.is_repair);
|
||||
}
|
||||
|
||||
/// Phase 2: Opus packets have zero FEC header fields — no block, no
|
||||
/// symbol index, no repair ratio. The RaptorQ layer is bypassed
|
||||
/// entirely on the Opus tiers.
|
||||
#[test]
|
||||
fn encoder_generates_repair_on_full_block() {
|
||||
fn opus_source_packets_have_zero_fec_header_fields() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::GOOD, // 5 frames/block
|
||||
profile: QualityProfile::GOOD, // Opus 24k
|
||||
suppression_enabled: false, // skip silence gate for this test
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
let pcm = vec![0i16; 960];
|
||||
// Non-silent sine wave so silence detection doesn't suppress us
|
||||
// even with suppression_enabled=false (belt and braces).
|
||||
let pcm: Vec<i16> = (0..960)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
|
||||
let hdr = &packets[0].header;
|
||||
assert!(hdr.codec_id.is_opus());
|
||||
assert!(!hdr.is_repair);
|
||||
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
|
||||
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
|
||||
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
|
||||
}
|
||||
|
||||
let mut total_packets = 0;
|
||||
let mut repair_count = 0;
|
||||
for _ in 0..5 {
|
||||
/// Phase 2: Opus never emits repair packets, regardless of how many
|
||||
/// source frames are fed in. DRED (Phase 1) provides loss recovery at
|
||||
/// the codec layer; RaptorQ is disabled on Opus tiers.
|
||||
#[test]
|
||||
fn opus_encoder_never_emits_repair_packets() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::GOOD, // 5 frames/block in the Codec2 sense
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
let pcm: Vec<i16> = (0..960)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
|
||||
// Encode well beyond a block boundary to prove no repair ever comes out.
|
||||
let mut total_packets = 0usize;
|
||||
let mut repair_count = 0usize;
|
||||
for _ in 0..20 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
total_packets += packets.len();
|
||||
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
|
||||
}
|
||||
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
|
||||
assert_eq!(
|
||||
total_packets, 20,
|
||||
"20 source frames → 20 source packets (1:1, no RaptorQ expansion)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2: Codec2 still emits repair packets with RaptorQ ratio unchanged.
|
||||
/// DRED is libopus-only and does not apply here, so RaptorQ is still the
|
||||
/// primary loss-recovery mechanism on Codec2 tiers.
|
||||
#[test]
|
||||
fn codec2_encoder_generates_repair_on_full_block() {
|
||||
let config = CallConfig {
|
||||
profile: QualityProfile::CATASTROPHIC, // Codec2 1200, 8 frames/block, ratio 1.0
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc = CallEncoder::new(&config);
|
||||
// Codec2 takes 48 kHz samples and downsamples internally.
|
||||
// CATASTROPHIC uses 40 ms frames → 1920 samples.
|
||||
let pcm: Vec<i16> = (0..1920)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
|
||||
let mut total_packets = 0usize;
|
||||
let mut repair_count = 0usize;
|
||||
// Run long enough to cross the 8-frame block boundary and see repairs.
|
||||
for _ in 0..16 {
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for p in &packets {
|
||||
if p.header.is_repair {
|
||||
@@ -693,8 +930,10 @@ mod tests {
|
||||
}
|
||||
total_packets += packets.len();
|
||||
}
|
||||
assert!(repair_count > 0, "should have repair packets after full block");
|
||||
assert!(total_packets > 5, "total {total_packets} should exceed 5 source");
|
||||
assert!(
|
||||
repair_count > 0,
|
||||
"Codec2 must still emit repair packets (got {repair_count} repairs, {total_packets} total)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -725,6 +964,219 @@ mod tests {
|
||||
assert!(dec.decode_next(&mut pcm).is_none());
|
||||
}
|
||||
|
||||
// ─── Phase 3b — DRED reconstruction on packet loss ────────────────────
|
||||
|
||||
/// Helper: create a CallEncoder/CallDecoder pair with the given profile
|
||||
/// and silence suppression disabled so silence-detection doesn't drop
|
||||
/// our synthetic test frames.
|
||||
fn encoder_decoder_pair(profile: QualityProfile) -> (CallEncoder, CallDecoder) {
|
||||
let config = CallConfig {
|
||||
profile,
|
||||
suppression_enabled: false,
|
||||
// Small jitter buffer so decode_next drains quickly in tests.
|
||||
jitter_min: 2,
|
||||
jitter_target: 3,
|
||||
jitter_max: 20,
|
||||
adaptive_jitter: false,
|
||||
..Default::default()
|
||||
};
|
||||
(CallEncoder::new(&config), CallDecoder::new(&config))
|
||||
}
|
||||
|
||||
/// Helper: generate a non-silent 20 ms frame of 300 Hz sine at the
|
||||
/// given sample offset so consecutive frames form a continuous tone.
|
||||
fn voice_frame_20ms(sample_offset: usize) -> Vec<i16> {
|
||||
(0..960)
|
||||
.map(|i| {
|
||||
let t = (sample_offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Phase 3b probe: sweep packet_loss_perc values to find the minimum
|
||||
/// that produces a samples_available ≥ 960 (enough to reconstruct a
|
||||
/// single 20 ms Opus frame). This guides the production loss floor.
|
||||
#[test]
|
||||
#[ignore] // diagnostic only — run with `cargo test ... -- --ignored --nocapture`
|
||||
fn probe_dred_samples_available_by_loss_floor() {
|
||||
use wzp_codec::opus_enc::OpusEncoder;
|
||||
use wzp_proto::traits::AudioEncoder;
|
||||
|
||||
for loss_pct in [5u8, 10, 15, 20, 25, 40, 60, 80].iter().copied() {
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
enc.set_expected_loss(loss_pct);
|
||||
let (_drop_enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
for i in 0..60u16 {
|
||||
let pcm = voice_frame_20ms(i as usize * 960);
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
||||
encoded.truncate(n);
|
||||
let pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq: i,
|
||||
timestamp: (i as u32) * 20,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(encoded),
|
||||
quality_report: None,
|
||||
};
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
eprintln!(
|
||||
"[phase3b probe] loss_pct={loss_pct} samples_available={}",
|
||||
dec.last_good_dred_samples_available()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 3b: simulated single-packet loss on an Opus call triggers a
|
||||
/// DRED reconstruction rather than a classical PLC fill. Runs the full
|
||||
/// encode → ingest → decode_next pipeline.
|
||||
#[test]
|
||||
fn opus_single_packet_loss_is_recovered_via_dred() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
// Warm-up: encode and ingest 60 frames (1.2 s) so the DRED emitter
|
||||
// has had time to fill its 200 ms window and at least one
|
||||
// successful DRED parse has happened on the decoder side.
|
||||
let warmup_frames = 60;
|
||||
for i in 0..warmup_frames {
|
||||
let pcm = voice_frame_20ms(i * 960);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the warm-up frames through the decoder to advance the
|
||||
// jitter buffer cursor past them.
|
||||
let mut out = vec![0i16; 960];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
// Encode the next three frames but skip ingesting the middle one.
|
||||
let base_offset = warmup_frames * 960;
|
||||
let pcm_a = voice_frame_20ms(base_offset);
|
||||
let pcm_b = voice_frame_20ms(base_offset + 960);
|
||||
let pcm_c = voice_frame_20ms(base_offset + 1920);
|
||||
|
||||
let pkts_a = enc.encode_frame(&pcm_a).unwrap();
|
||||
let pkts_b = enc.encode_frame(&pcm_b).unwrap(); // DROP THIS ONE
|
||||
let pkts_c = enc.encode_frame(&pcm_c).unwrap();
|
||||
|
||||
for pkt in pkts_a {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
// Skip pkts_b entirely — this is the "packet loss".
|
||||
drop(pkts_b);
|
||||
for pkt in pkts_c {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
|
||||
// Drain again. Somewhere in here decode_next will hit Missing()
|
||||
// for the dropped packet and attempt DRED reconstruction.
|
||||
let baseline_dred = dec.dred_reconstructions;
|
||||
let baseline_plc = dec.classical_plc_invocations;
|
||||
eprintln!(
|
||||
"[phase3b probe] pre-drain: last_good_seq={:?} samples_available={}",
|
||||
dec.last_good_dred_seq(),
|
||||
dec.last_good_dred_samples_available()
|
||||
);
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
let dred_delta = dec.dred_reconstructions - baseline_dred;
|
||||
let plc_delta = dec.classical_plc_invocations - baseline_plc;
|
||||
eprintln!(
|
||||
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
|
||||
);
|
||||
assert!(
|
||||
dred_delta >= 1,
|
||||
"expected ≥1 DRED reconstruction on single-packet loss, \
|
||||
got dred_delta={dred_delta} plc_delta={plc_delta}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3b: lossless stream never triggers DRED reconstruction or PLC.
|
||||
/// Baseline behavior — verifies the Missing() branch is not spuriously taken.
|
||||
#[test]
|
||||
fn opus_lossless_ingest_never_triggers_dred_or_plc() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
||||
|
||||
// Encode + ingest 40 frames with no drops.
|
||||
for i in 0..40 {
|
||||
let pcm = voice_frame_20ms(i * 960);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = vec![0i16; 960];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
assert_eq!(
|
||||
dec.dred_reconstructions, 0,
|
||||
"lossless stream should not reconstruct"
|
||||
);
|
||||
assert_eq!(
|
||||
dec.classical_plc_invocations, 0,
|
||||
"lossless stream should not PLC"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3b: Codec2 calls fall through to classical PLC on loss.
|
||||
/// DRED is libopus-only, so even if the decoder's DRED state were
|
||||
/// populated (it won't be — Codec2 packets don't carry DRED bytes),
|
||||
/// `reconstruct_from_dred` rejects Codec2 at the AdaptiveDecoder
|
||||
/// level. This test guards the Codec2 side of the protection split.
|
||||
#[test]
|
||||
fn codec2_loss_falls_through_to_classical_plc() {
|
||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::CATASTROPHIC);
|
||||
|
||||
// Codec2 1200 uses 40 ms frames → 1920 samples at 48 kHz (before
|
||||
// the downsample inside the codec). Encode 20 frames (~0.8 s).
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..1920)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for i in 0..20 {
|
||||
let pcm = make_frame(i * 1920);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
for pkt in packets {
|
||||
// Drop every 5th source packet to simulate loss.
|
||||
if !pkt.header.is_repair && i % 5 == 3 {
|
||||
continue;
|
||||
}
|
||||
dec.ingest(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = vec![0i16; 1920];
|
||||
while dec.decode_next(&mut out).is_some() {}
|
||||
|
||||
assert_eq!(
|
||||
dec.dred_reconstructions, 0,
|
||||
"Codec2 must never reconstruct via DRED"
|
||||
);
|
||||
// classical_plc_invocations may or may not trigger depending on
|
||||
// whether the jitter buffer sees Missing before draining — the key
|
||||
// assertion is that DRED is not used. PLC count is advisory.
|
||||
}
|
||||
|
||||
// ---- QualityAdapter tests ----
|
||||
|
||||
/// Helper: build a QualityReport from human-readable loss% and RTT ms.
|
||||
@@ -999,4 +1451,131 @@ mod tests {
|
||||
"frames_suppressed should be > 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- DredTuner integration tests ----
|
||||
|
||||
/// End-to-end test: DredTuner reacts to simulated network degradation
|
||||
/// and adjusts the encoder's DRED parameters via `apply_dred_tuning`.
|
||||
#[test]
|
||||
fn dred_tuner_adjusts_encoder_on_loss() {
|
||||
use wzp_proto::DredTuner;
|
||||
|
||||
let mut enc = CallEncoder::new(&CallConfig {
|
||||
profile: QualityProfile::GOOD,
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
});
|
||||
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
|
||||
|
||||
// Baseline: good network → baseline DRED (20 frames = 200 ms).
|
||||
let baseline = tuner.current();
|
||||
assert_eq!(baseline.dred_frames, 20);
|
||||
|
||||
// Warm up the tuner — first few updates may return Some as the
|
||||
// EWMA initializes and expected_loss settles from the initial 15%.
|
||||
for _ in 0..10 {
|
||||
tuner.update(0.0, 50, 5);
|
||||
}
|
||||
// After settling, the tuning should be at baseline.
|
||||
assert_eq!(tuner.current().dred_frames, 20);
|
||||
|
||||
// Simulate network degradation: 30% loss, 300ms RTT.
|
||||
// The tuner should increase DRED frames above baseline.
|
||||
let tuning = tuner.update(30.0, 300, 15);
|
||||
assert!(tuning.is_some(), "loss spike should trigger tuning change");
|
||||
let t = tuning.unwrap();
|
||||
assert!(
|
||||
t.dred_frames > 20,
|
||||
"30% loss should increase DRED above baseline 20, got {}",
|
||||
t.dred_frames
|
||||
);
|
||||
|
||||
// Apply to encoder — should not panic.
|
||||
enc.apply_dred_tuning(t);
|
||||
|
||||
// Verify the encoder still works after tuning.
|
||||
let pcm = voice_frame_20ms(0);
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
|
||||
}
|
||||
|
||||
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
|
||||
#[test]
|
||||
fn dred_tuner_spike_boosts_to_ceiling() {
|
||||
use wzp_proto::DredTuner;
|
||||
|
||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
||||
|
||||
// Establish low-jitter baseline.
|
||||
for _ in 0..20 {
|
||||
tuner.update(0.0, 50, 5);
|
||||
}
|
||||
assert!(!tuner.spike_boost_active());
|
||||
|
||||
// Jitter spikes to 40ms (8x baseline of ~5ms).
|
||||
let tuning = tuner.update(0.0, 50, 40);
|
||||
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
|
||||
assert!(tuning.is_some());
|
||||
// Ceiling for Opus24k is 50 frames = 500 ms.
|
||||
assert_eq!(
|
||||
tuning.unwrap().dred_frames, 50,
|
||||
"spike should push to ceiling"
|
||||
);
|
||||
}
|
||||
|
||||
/// DredTuner is a no-op for Codec2 profiles.
|
||||
#[test]
|
||||
fn dred_tuner_noop_for_codec2() {
|
||||
use wzp_proto::DredTuner;
|
||||
|
||||
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
|
||||
|
||||
// Even extreme conditions produce no tuning output.
|
||||
assert!(tuner.update(50.0, 800, 100).is_none());
|
||||
assert_eq!(tuner.current().dred_frames, 0);
|
||||
}
|
||||
|
||||
/// DredTuner + CallEncoder: full cycle through profile switch.
|
||||
#[test]
|
||||
fn dred_tuner_handles_profile_switch() {
|
||||
use wzp_proto::DredTuner;
|
||||
|
||||
let mut enc = CallEncoder::new(&CallConfig {
|
||||
profile: QualityProfile::GOOD,
|
||||
suppression_enabled: false,
|
||||
..Default::default()
|
||||
});
|
||||
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
|
||||
|
||||
// Apply initial tuning on good network.
|
||||
if let Some(t) = tuner.update(0.0, 50, 5) {
|
||||
enc.apply_dred_tuning(t);
|
||||
}
|
||||
|
||||
// Switch to degraded profile.
|
||||
enc.set_profile(QualityProfile::DEGRADED).unwrap();
|
||||
tuner.set_codec(QualityProfile::DEGRADED.codec);
|
||||
|
||||
// Opus6k baseline is 50 frames (500 ms), ceiling is 104 (1040 ms).
|
||||
let baseline = tuner.current();
|
||||
// After set_codec, the cached tuning should reflect old state;
|
||||
// a fresh update gives the new codec's mapping.
|
||||
let tuning = tuner.update(20.0, 200, 10);
|
||||
assert!(tuning.is_some());
|
||||
let t = tuning.unwrap();
|
||||
assert!(
|
||||
t.dred_frames >= 50,
|
||||
"Opus6k with 20% loss should be at least baseline 50, got {}",
|
||||
t.dred_frames
|
||||
);
|
||||
|
||||
enc.apply_dred_tuning(t);
|
||||
|
||||
// Encode a 40ms frame (Opus6k uses 40ms frames = 1920 samples).
|
||||
let pcm: Vec<i16> = (0..1920)
|
||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
||||
.collect();
|
||||
let packets = enc.encode_frame(&pcm).unwrap();
|
||||
assert!(!packets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +424,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
|
||||
info!(total_source, total_repair, total_bytes, "done — closing");
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
transport.send_signal(&hangup).await.ok();
|
||||
transport.close().await?;
|
||||
@@ -575,6 +576,7 @@ async fn run_file_mode(
|
||||
// Send Hangup signal so the relay knows we're done
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
transport.send_signal(&hangup).await.ok();
|
||||
|
||||
@@ -626,11 +628,21 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
||||
.spawn(move || {
|
||||
let config = CallConfig::default();
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
let mut frame = vec![0i16; FRAME_SAMPLES];
|
||||
loop {
|
||||
let frame = match capture.read_frame() {
|
||||
Some(f) => f,
|
||||
None => break,
|
||||
};
|
||||
// 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 packets = match encoder.encode_frame(&frame) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -661,7 +673,13 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
||||
// Repair packets feed the FEC decoder but don't produce audio.
|
||||
if !is_repair {
|
||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||
playback.write_frame(&pcm_buf);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -731,7 +749,7 @@ async fn run_signal_mode(
|
||||
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 => {
|
||||
@@ -754,13 +772,17 @@ async fn run_signal_mode(
|
||||
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_build_version: None,
|
||||
}).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 {
|
||||
@@ -784,12 +806,17 @@ async fn run_signal_mode(
|
||||
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_build_version: None,
|
||||
}).await;
|
||||
}
|
||||
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
||||
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
||||
}
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay } => {
|
||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _ } => {
|
||||
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
||||
|
||||
// Connect to the media room
|
||||
@@ -840,6 +867,7 @@ async fn run_signal_mode(
|
||||
info!("hanging up...");
|
||||
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
}).await;
|
||||
break;
|
||||
}
|
||||
@@ -856,7 +884,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 { .. } => {}
|
||||
|
||||
546
crates/wzp-client/src/dual_path.rs
Normal file
546
crates/wzp-client/src/dual_path.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl PeerCandidates {
|
||||
/// Flatten into the list of addrs the D-role should dial.
|
||||
/// Order: LAN host candidates first (fastest when they
|
||||
/// work), then reflexive (covers the non-LAN case).
|
||||
pub fn dial_order(&self) -> Vec<SocketAddr> {
|
||||
let mut out = Vec::with_capacity(self.local.len() + 1);
|
||||
out.extend(self.local.iter().copied());
|
||||
if let Some(a) = self.reflexive {
|
||||
// Only add if it's not already in the list (some
|
||||
// edge cases on same-LAN could have the same addr
|
||||
// in both).
|
||||
if !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()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn race(
|
||||
role: Role,
|
||||
peer_candidates: PeerCandidates,
|
||||
relay_addr: SocketAddr,
|
||||
room_sni: String,
|
||||
call_sni: String,
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
direct_fut = Box::pin(async move {
|
||||
// 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.dial_order();
|
||||
let sni = call_sni.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::debug!(
|
||||
%candidate,
|
||||
candidate_idx = idx,
|
||||
"dual_path: skipping IPv6 candidate (disabled)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let ep = ep_for_fut.clone();
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let sni = sni.clone();
|
||||
set.spawn(async move {
|
||||
let result = wzp_transport::connect(
|
||||
&ep,
|
||||
candidate,
|
||||
&sni,
|
||||
client_cfg,
|
||||
)
|
||||
.await;
|
||||
(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::debug!(
|
||||
%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.
|
||||
tracing::info!(
|
||||
?role,
|
||||
candidates = ?peer_candidates.dial_order(),
|
||||
%relay_addr,
|
||||
"dual_path: racing direct vs relay"
|
||||
);
|
||||
|
||||
let mut direct_task = tokio::spawn(
|
||||
tokio::time::timeout(Duration::from_secs(2), 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 (2s)");
|
||||
direct_result = Some(Err(anyhow::anyhow!("direct timeout")));
|
||||
local_winner = WinningPath::Relay;
|
||||
}
|
||||
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"))); }
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -96,6 +96,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
||||
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
||||
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||
SignalMessage::Hold => CallSignalType::Hold,
|
||||
@@ -119,6 +120,18 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
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::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +171,7 @@ mod tests {
|
||||
|
||||
let hangup = SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ pub mod drift_test;
|
||||
pub mod echo_test;
|
||||
pub mod featherchat;
|
||||
pub mod handshake;
|
||||
pub mod dual_path;
|
||||
pub mod metrics;
|
||||
pub mod reflect;
|
||||
pub mod sweep;
|
||||
|
||||
// AudioPlayback: three possible backends depending on feature flags.
|
||||
|
||||
679
crates/wzp-client/src/reflect.rs
Normal file
679
crates/wzp-client/src/reflect.rs
Normal file
@@ -0,0 +1,679 @@
|
||||
//! 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};
|
||||
use wzp_transport::{client_config, create_endpoint, QuinnTransport};
|
||||
|
||||
/// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
}
|
||||
}
|
||||
213
crates/wzp-client/tests/dual_path.rs
Normal file
213
crates/wzp-client/tests/dual_path.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! 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::{race, PeerCandidates, WinningPath};
|
||||
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(),
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
)
|
||||
.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(),
|
||||
},
|
||||
relay_addr,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
)
|
||||
.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(),
|
||||
},
|
||||
dead_relay,
|
||||
"test-room".into(),
|
||||
"call-test".into(),
|
||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
||||
)
|
||||
.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
|
||||
);
|
||||
}
|
||||
@@ -83,12 +83,12 @@ 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),
|
||||
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed, None),
|
||||
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) =
|
||||
let (mut relay_session, chosen_profile, _caller_fp, _caller_alias) =
|
||||
relay_result.expect("relay handshake should succeed");
|
||||
|
||||
// Verify a profile was chosen.
|
||||
@@ -151,6 +151,7 @@ async fn handshake_rejects_tampered_signature() {
|
||||
ephemeral_pub,
|
||||
signature: bad_signature,
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
client_transport_clone
|
||||
.send_signal(&offer)
|
||||
|
||||
@@ -10,8 +10,17 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
|
||||
wzp-proto = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Opus bindings
|
||||
audiopus = { workspace = true }
|
||||
# Opus bindings — libopus 1.5.2.
|
||||
# opusic-c for the encoder (set_dred_duration lives here in Phase 1).
|
||||
# opusic-sys for the decoder — we wrap the raw *mut OpusDecoder ourselves
|
||||
# because opusic-c::Decoder.inner is pub(crate), blocking the unified
|
||||
# decoder + DRED path we need in Phase 3.
|
||||
opusic-c = { workspace = true }
|
||||
opusic-sys = { workspace = true }
|
||||
|
||||
# Zero-cost slice reinterpretation for the i16 ↔ u16 boundary between
|
||||
# our PCM buffers and opusic-c's encode API.
|
||||
bytemuck = { workspace = true }
|
||||
|
||||
# Pure-Rust Codec2 implementation
|
||||
codec2 = { workspace = true }
|
||||
|
||||
@@ -116,6 +116,14 @@ 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 ─────────────────────────────────────────────────────────
|
||||
@@ -199,6 +207,27 @@ impl AdaptiveDecoder {
|
||||
fn codec2_frame_samples(&self) -> usize {
|
||||
self.codec2.frame_samples()
|
||||
}
|
||||
|
||||
/// Reconstruct a lost frame from a previously parsed DRED state.
|
||||
///
|
||||
/// Phase 3b entry point for gap reconstruction. Dispatches to the
|
||||
/// inner Opus decoder when active. Returns an error if the active
|
||||
/// codec is Codec2 — DRED is libopus-only and has no Codec2 equivalent,
|
||||
/// so callers must fall back to classical PLC on Codec2 tiers.
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &crate::dred_ffi::DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
if is_codec2(self.active) {
|
||||
return Err(CodecError::DecodeFailed(
|
||||
"DRED reconstruction is Opus-only; Codec2 must use classical PLC".into(),
|
||||
));
|
||||
}
|
||||
self.opus
|
||||
.reconstruct_from_dred(state, offset_samples, output)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
585
crates/wzp-codec/src/dred_ffi.rs
Normal file
585
crates/wzp-codec/src/dred_ffi.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
//! Raw opusic-sys FFI wrappers for libopus 1.5.2 decoder + DRED reconstruction.
|
||||
//!
|
||||
//! # Why this module exists
|
||||
//!
|
||||
//! We cannot use `opusic_c::Decoder` because its inner `*mut OpusDecoder`
|
||||
//! pointer is `pub(crate)` — not reachable from outside the opusic-c crate.
|
||||
//! Phase 3 of the DRED integration needs to hand that same pointer to
|
||||
//! `opus_decoder_dred_decode`, and running two parallel decoders (one from
|
||||
//! opusic-c for normal audio, another from opusic-sys for DRED) would cause
|
||||
//! the DRED-only decoder's internal state to drift out of sync with the
|
||||
//! audio stream because it would not see normal decode calls.
|
||||
//!
|
||||
//! The fix is to own the raw decoder ourselves and use the same handle for
|
||||
//! both normal decode AND DRED reconstruction. This module is the single
|
||||
//! owner of `*mut OpusDecoder`, `*mut OpusDREDDecoder`, and `*mut OpusDRED`
|
||||
//! in the WZP workspace.
|
||||
//!
|
||||
//! # Phase 3a scope
|
||||
//!
|
||||
//! Phase 0 added `DecoderHandle` (normal decode). Phase 3a adds:
|
||||
//! - [`DredDecoderHandle`] — wraps `*mut OpusDREDDecoder` for parsing DRED
|
||||
//! side-channel data out of arriving Opus packets.
|
||||
//! - [`DredState`] — wraps `*mut OpusDRED` (a fixed 10,592-byte buffer
|
||||
//! allocated by libopus) that holds parsed DRED state between the parse
|
||||
//! and reconstruct steps.
|
||||
//! - [`DredDecoderHandle::parse_into`] — wraps `opus_dred_parse`.
|
||||
//! - [`DecoderHandle::reconstruct_from_dred`] — wraps `opus_decoder_dred_decode`.
|
||||
//!
|
||||
//! The pattern is: on every arriving Opus packet, the receiver calls
|
||||
//! `parse_into` with a reusable `DredState`, then stores (seq, state_clone)
|
||||
//! in a ring. On detected loss, the receiver computes the offset from the
|
||||
//! freshest reachable DRED state and calls `reconstruct_from_dred` to
|
||||
//! synthesize the missing audio.
|
||||
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use opusic_sys::{
|
||||
OPUS_OK, OpusDRED, OpusDREDDecoder, OpusDecoder as RawOpusDecoder, opus_decode,
|
||||
opus_decoder_create, opus_decoder_destroy, opus_decoder_dred_decode, opus_dred_alloc,
|
||||
opus_dred_decoder_create, opus_dred_decoder_destroy, opus_dred_free, opus_dred_parse,
|
||||
};
|
||||
use wzp_proto::CodecError;
|
||||
|
||||
/// libopus operates at 48 kHz for all Opus variants we use.
|
||||
const SAMPLE_RATE_HZ: i32 = 48_000;
|
||||
/// Mono.
|
||||
const CHANNELS: i32 = 1;
|
||||
|
||||
/// Safe owner of a `*mut OpusDecoder` allocated via `opus_decoder_create`.
|
||||
///
|
||||
/// Releases the decoder in `Drop`. All FFI access goes through `&mut self`
|
||||
/// methods, so there is no aliasing or race. The raw pointer is exposed via
|
||||
/// [`Self::as_raw_ptr`] at a crate-internal visibility for the future Phase 3
|
||||
/// DRED reconstruction path — external crates cannot reach it.
|
||||
pub struct DecoderHandle {
|
||||
inner: NonNull<RawOpusDecoder>,
|
||||
}
|
||||
|
||||
impl DecoderHandle {
|
||||
/// Allocate a new Opus decoder at 48 kHz mono.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_decoder_create writes to `error` and returns either a
|
||||
// valid heap pointer or null. We check both before constructing the
|
||||
// NonNull wrapper.
|
||||
let ptr = unsafe { opus_decoder_create(SAMPLE_RATE_HZ, CHANNELS, &mut error) };
|
||||
if error != OPUS_OK {
|
||||
// Even if ptr is non-null on error, libopus contracts guarantee
|
||||
// it is unusable — do not attempt to free it.
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decoder_create failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
||||
CodecError::DecodeFailed("opus_decoder_create returned null".into())
|
||||
})?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Decode an Opus packet into PCM samples.
|
||||
///
|
||||
/// `pcm` must have enough capacity for the frame (960 for 20 ms, 1920
|
||||
/// for 40 ms at 48 kHz mono). Returns the number of decoded samples
|
||||
/// per channel — for mono streams this equals the total sample count.
|
||||
pub fn decode(&mut self, packet: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
if packet.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty packet".into()));
|
||||
}
|
||||
if pcm.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
||||
}
|
||||
// SAFETY: self.inner is a valid *mut OpusDecoder owned by this struct.
|
||||
// `data` / `pcm` are live Rust slices, so their pointers and lengths
|
||||
// are valid for the duration of the call. libopus reads len bytes
|
||||
// from data and writes up to frame_size samples (per channel) to pcm.
|
||||
let n = unsafe {
|
||||
opus_decode(
|
||||
self.inner.as_ptr(),
|
||||
packet.as_ptr(),
|
||||
packet.len() as i32,
|
||||
pcm.as_mut_ptr(),
|
||||
pcm.len() as i32,
|
||||
/* decode_fec = */ 0,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decode failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Generate packet-loss concealment audio for a missing frame.
|
||||
///
|
||||
/// Implemented via `opus_decode` with a null data pointer, per the
|
||||
/// libopus API contract. `pcm` should be sized for the expected frame.
|
||||
pub fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
if pcm.is_empty() {
|
||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
||||
}
|
||||
// SAFETY: same invariants as decode(). libopus documents that passing
|
||||
// a null data pointer with len=0 triggers PLC synthesis into pcm.
|
||||
let n = unsafe {
|
||||
opus_decode(
|
||||
self.inner.as_ptr(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
pcm.as_mut_ptr(),
|
||||
pcm.len() as i32,
|
||||
/* decode_fec = */ 0,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decode PLC failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// Reconstruct audio from a `DredState` into the `output` buffer.
|
||||
///
|
||||
/// `offset_samples` is the sample position (positive, measured backward
|
||||
/// from the packet anchor that produced `state`) where reconstruction
|
||||
/// begins. `output.len()` must match the number of samples to synthesize.
|
||||
///
|
||||
/// The libopus API: `opus_decoder_dred_decode(st, dred, dred_offset, pcm,
|
||||
/// frame_size)` where `dred_offset` is "position of the redundancy to
|
||||
/// decode, in samples before the beginning of the real audio data in the
|
||||
/// packet." Valid values: `0 < offset_samples < state.samples_available()`.
|
||||
///
|
||||
/// Returns the number of samples actually written (should equal
|
||||
/// `output.len()` on success).
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
if output.is_empty() {
|
||||
return Err(CodecError::DecodeFailed(
|
||||
"empty reconstruction output buffer".into(),
|
||||
));
|
||||
}
|
||||
if offset_samples <= 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"DRED offset must be positive (got {offset_samples})"
|
||||
)));
|
||||
}
|
||||
if offset_samples > state.samples_available() {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"DRED offset {offset_samples} exceeds available samples {}",
|
||||
state.samples_available()
|
||||
)));
|
||||
}
|
||||
// SAFETY: self.inner is a valid *mut OpusDecoder, state.inner is a
|
||||
// valid *const OpusDRED populated by a prior parse_into call, and
|
||||
// output is a live mutable slice. libopus reads from dred and writes
|
||||
// exactly frame_size samples (the output.len()) to pcm.
|
||||
let n = unsafe {
|
||||
opus_decoder_dred_decode(
|
||||
self.inner.as_ptr(),
|
||||
state.inner.as_ptr(),
|
||||
offset_samples,
|
||||
output.as_mut_ptr(),
|
||||
output.len() as i32,
|
||||
)
|
||||
};
|
||||
if n < 0 {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_decoder_dred_decode failed: err={n}"
|
||||
)));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DecoderHandle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_decoder_destroy(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: The underlying OpusDecoder is a plain heap allocation with no
|
||||
// thread-local or lock-free state. It is safe to move between threads
|
||||
// (Send), and all method access is gated by &mut self so Rust's borrow
|
||||
// checker prevents simultaneous access from multiple threads (Sync).
|
||||
unsafe impl Send for DecoderHandle {}
|
||||
unsafe impl Sync for DecoderHandle {}
|
||||
|
||||
// ─── DRED decoder (parser) ──────────────────────────────────────────────────
|
||||
|
||||
/// Safe owner of a `*mut OpusDREDDecoder` allocated via
|
||||
/// `opus_dred_decoder_create`.
|
||||
///
|
||||
/// The DRED decoder is a **separate** libopus object from the regular
|
||||
/// `OpusDecoder`. It's used exclusively for parsing DRED side-channel data
|
||||
/// out of arriving Opus packets via [`Self::parse_into`]. Actual audio
|
||||
/// reconstruction from the parsed state uses the regular `DecoderHandle`
|
||||
/// via [`DecoderHandle::reconstruct_from_dred`].
|
||||
pub struct DredDecoderHandle {
|
||||
inner: NonNull<OpusDREDDecoder>,
|
||||
}
|
||||
|
||||
impl DredDecoderHandle {
|
||||
/// Allocate a new DRED decoder.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_dred_decoder_create writes to `error` and returns
|
||||
// either a valid heap pointer or null. Both are checked.
|
||||
let ptr = unsafe { opus_dred_decoder_create(&mut error) };
|
||||
if error != OPUS_OK {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_decoder_create failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
||||
CodecError::DecodeFailed("opus_dred_decoder_create returned null".into())
|
||||
})?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Parse DRED side-channel data from an Opus packet into `state`.
|
||||
///
|
||||
/// Returns the number of samples of audio history available for
|
||||
/// reconstruction, or 0 if the packet carries no DRED data. Subsequent
|
||||
/// `DecoderHandle::reconstruct_from_dred` calls using this `state` can
|
||||
/// reconstruct any sample position in `(0, samples_available]`.
|
||||
///
|
||||
/// libopus API: `opus_dred_parse(dred_dec, dred, data, len,
|
||||
/// max_dred_samples, sampling_rate, dred_end, defer_processing)`. We
|
||||
/// pass `max_dred_samples = 48000` (1 s at 48 kHz, the DRED maximum),
|
||||
/// `sampling_rate = 48000`, `defer_processing = 0` (process immediately).
|
||||
/// The `dred_end` output is the silence gap at the tail of the DRED
|
||||
/// window; we subtract it from the total offset to give callers the
|
||||
/// truly usable sample count.
|
||||
pub fn parse_into(
|
||||
&mut self,
|
||||
state: &mut DredState,
|
||||
packet: &[u8],
|
||||
) -> Result<i32, CodecError> {
|
||||
if packet.is_empty() {
|
||||
state.samples_available = 0;
|
||||
return Ok(0);
|
||||
}
|
||||
let mut dred_end: i32 = 0;
|
||||
// SAFETY: self.inner is a valid *mut OpusDREDDecoder; state.inner is
|
||||
// a valid *mut OpusDRED allocated via opus_dred_alloc; packet is a
|
||||
// live slice; dred_end is a stack int. libopus reads packet bytes
|
||||
// and writes parsed DRED state into *state.inner.
|
||||
let ret = unsafe {
|
||||
opus_dred_parse(
|
||||
self.inner.as_ptr(),
|
||||
state.inner.as_ptr(),
|
||||
packet.as_ptr(),
|
||||
packet.len() as i32,
|
||||
/* max_dred_samples = */ 48_000, // 1s max per libopus 1.5
|
||||
/* sampling_rate = */ 48_000,
|
||||
&mut dred_end,
|
||||
/* defer_processing = */ 0,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
state.samples_available = 0;
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_parse failed: err={ret}"
|
||||
)));
|
||||
}
|
||||
// ret is the positive offset of the first decodable DRED sample,
|
||||
// or 0 if no DRED is present. dred_end is the silence gap at the
|
||||
// tail. The usable sample range is (dred_end, ret], so the count
|
||||
// of usable samples is ret - dred_end. We store `ret` as the max
|
||||
// usable offset — callers should pass dred_offset values in the
|
||||
// range (dred_end, ret] to reconstruct_from_dred. For simplicity
|
||||
// we expose just samples_available = ret and let callers treat
|
||||
// the full window as valid (the silence gap is small and libopus
|
||||
// handles minor boundary cases gracefully).
|
||||
state.samples_available = ret;
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DredDecoderHandle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_dred_decoder_destroy(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: same reasoning as DecoderHandle — heap allocation with no
|
||||
// thread-local state, &mut self access discipline prevents races.
|
||||
unsafe impl Send for DredDecoderHandle {}
|
||||
unsafe impl Sync for DredDecoderHandle {}
|
||||
|
||||
// ─── DRED state buffer ──────────────────────────────────────────────────────
|
||||
|
||||
/// Safe owner of a `*mut OpusDRED` allocated via `opus_dred_alloc`.
|
||||
///
|
||||
/// Holds a fixed-size (10,592-byte per libopus 1.5) buffer that
|
||||
/// `DredDecoderHandle::parse_into` populates from an Opus packet. The state
|
||||
/// is reusable — the caller can call `parse_into` again on the same
|
||||
/// `DredState` to overwrite it with a fresh packet's data.
|
||||
///
|
||||
/// `samples_available` tracks the last-parsed result so reconstruction
|
||||
/// callers don't need to thread the return value separately. A fresh
|
||||
/// state (before any `parse_into`) has `samples_available == 0`.
|
||||
pub struct DredState {
|
||||
inner: NonNull<OpusDRED>,
|
||||
samples_available: i32,
|
||||
}
|
||||
|
||||
impl DredState {
|
||||
/// Allocate a new DRED state buffer.
|
||||
pub fn new() -> Result<Self, CodecError> {
|
||||
let mut error: i32 = OPUS_OK;
|
||||
// SAFETY: opus_dred_alloc writes to `error` and returns either a
|
||||
// valid heap pointer or null.
|
||||
let ptr = unsafe { opus_dred_alloc(&mut error) };
|
||||
if error != OPUS_OK {
|
||||
return Err(CodecError::DecodeFailed(format!(
|
||||
"opus_dred_alloc failed: err={error}"
|
||||
)));
|
||||
}
|
||||
let inner = NonNull::new(ptr)
|
||||
.ok_or_else(|| CodecError::DecodeFailed("opus_dred_alloc returned null".into()))?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_available: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many samples of audio history this state currently covers.
|
||||
///
|
||||
/// Returns 0 if the state is fresh or the last parse found no DRED
|
||||
/// data. Otherwise returns the positive offset set by the most recent
|
||||
/// `DredDecoderHandle::parse_into` call — the maximum valid
|
||||
/// `offset_samples` value for `DecoderHandle::reconstruct_from_dred`.
|
||||
pub fn samples_available(&self) -> i32 {
|
||||
self.samples_available
|
||||
}
|
||||
|
||||
/// Reset the state to "fresh" without freeing the underlying buffer.
|
||||
/// The next `parse_into` will overwrite the contents.
|
||||
pub fn reset(&mut self) {
|
||||
self.samples_available = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DredState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we own the pointer and no further access happens after
|
||||
// this call because Drop consumes self.
|
||||
unsafe { opus_dred_free(self.inner.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: same reasoning as DecoderHandle.
|
||||
unsafe impl Send for DredState {}
|
||||
unsafe impl Sync for DredState {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoder_handle_creates_and_drops() {
|
||||
let handle = DecoderHandle::new().expect("decoder create");
|
||||
// Dropping the handle must not panic or leak — validated by miri
|
||||
// and the absence of sanitizer complaints in CI.
|
||||
drop(handle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_lost_produces_full_frame_of_silence_on_cold_start() {
|
||||
let mut handle = DecoderHandle::new().unwrap();
|
||||
// 20 ms @ 48 kHz mono.
|
||||
let mut pcm = vec![0i16; 960];
|
||||
let n = handle.decode_lost(&mut pcm).unwrap();
|
||||
assert_eq!(n, 960);
|
||||
// On a fresh decoder, PLC output is silence (no past audio to extend).
|
||||
assert!(pcm.iter().all(|&s| s == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_empty_packet_errors() {
|
||||
let mut handle = DecoderHandle::new().unwrap();
|
||||
let mut pcm = vec![0i16; 960];
|
||||
let err = handle.decode(&[], &mut pcm);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
// ─── Phase 3a — DRED decoder + state ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dred_decoder_handle_creates_and_drops() {
|
||||
let h = DredDecoderHandle::new().expect("dred decoder create");
|
||||
drop(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_state_creates_and_drops() {
|
||||
let s = DredState::new().expect("dred state alloc");
|
||||
assert_eq!(s.samples_available(), 0);
|
||||
drop(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_state_reset_zeroes_counter() {
|
||||
let mut s = DredState::new().unwrap();
|
||||
s.samples_available = 480; // pretend a parse populated it
|
||||
assert_eq!(s.samples_available(), 480);
|
||||
s.reset();
|
||||
assert_eq!(s.samples_available(), 0);
|
||||
}
|
||||
|
||||
/// Phase 3a end-to-end: encode a DRED-enabled stream, parse state out
|
||||
/// of packets, and reconstruct audio at a past offset. Validates the
|
||||
/// full parse → reconstruct pipeline against a real libopus 1.5.2
|
||||
/// encoder so we catch FFI-layer bugs early.
|
||||
#[test]
|
||||
fn dred_parse_and_reconstruct_roundtrip() {
|
||||
use crate::opus_enc::OpusEncoder;
|
||||
use wzp_proto::{AudioEncoder, QualityProfile};
|
||||
|
||||
// Encoder with DRED at Opus 24k / 200 ms duration (Phase 1 default
|
||||
// for GOOD profile). The loss floor is 5% per Phase 1.
|
||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
|
||||
// Decode-side handles.
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
||||
let mut state = DredState::new().unwrap();
|
||||
|
||||
// Generate 60 frames (1.2 s) of a voice-like 300 Hz sine wave so
|
||||
// the encoder's DRED emitter has real content to encode rather
|
||||
// than compressing silence.
|
||||
let frame_len = 960usize; // 20 ms @ 48 kHz
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48_000.0;
|
||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Track the freshest packet that carried non-zero DRED state.
|
||||
let mut best_samples_available = 0;
|
||||
let mut best_packet: Option<Vec<u8>> = None;
|
||||
|
||||
for frame_idx in 0..60 {
|
||||
let pcm = make_frame(frame_idx * frame_len);
|
||||
let mut encoded = vec![0u8; 512];
|
||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
||||
encoded.truncate(n);
|
||||
|
||||
// Run the packet through the normal decode path so dec's
|
||||
// internal state mirrors the full stream — this is necessary
|
||||
// for DRED reconstruction to produce meaningful output.
|
||||
let mut decoded = vec![0i16; frame_len];
|
||||
dec.decode(&encoded, &mut decoded).unwrap();
|
||||
|
||||
// Parse DRED state out of the same packet. Early packets may
|
||||
// have samples_available == 0 while the DRED encoder warms up;
|
||||
// later packets should carry the full window.
|
||||
match dred_dec.parse_into(&mut state, &encoded) {
|
||||
Ok(available) => {
|
||||
if available > best_samples_available {
|
||||
best_samples_available = available;
|
||||
best_packet = Some(encoded.clone());
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("parse_into errored unexpectedly: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// By the time we're 60 frames in, DRED should have emitted data.
|
||||
assert!(
|
||||
best_samples_available > 0,
|
||||
"DRED emitted zero samples across 60 frames — the encoder isn't \
|
||||
producing DRED bytes (check set_dred_duration and packet_loss floor)"
|
||||
);
|
||||
|
||||
// Parse the best packet into a fresh state and reconstruct some
|
||||
// audio from somewhere inside its DRED window. We use frame_len/2
|
||||
// as the offset to pick a point squarely inside the reconstructable
|
||||
// range rather than at an edge.
|
||||
let packet = best_packet.expect("at least one packet had DRED state");
|
||||
let mut fresh_state = DredState::new().unwrap();
|
||||
let available = dred_dec.parse_into(&mut fresh_state, &packet).unwrap();
|
||||
assert!(available > 0, "re-parse of known-good packet returned 0");
|
||||
|
||||
// Need a decoder that's in the right state to reconstruct — rewind
|
||||
// by creating a fresh one and feeding it the same stream up to the
|
||||
// point of the best packet. Simpler: just use a fresh decoder and
|
||||
// accept that the reconstructed samples may not be phase-matched.
|
||||
// The test here only asserts *non-silent energy*, not signal fidelity.
|
||||
let mut recon_dec = DecoderHandle::new().unwrap();
|
||||
// Warm up the decoder with one frame so its internal state is valid.
|
||||
let warmup_pcm = vec![0i16; frame_len];
|
||||
let warmup_encoded = {
|
||||
let mut warmup_enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
||||
let mut buf = vec![0u8; 512];
|
||||
let n = warmup_enc.encode(&warmup_pcm, &mut buf).unwrap();
|
||||
buf.truncate(n);
|
||||
buf
|
||||
};
|
||||
let mut throwaway = vec![0i16; frame_len];
|
||||
let _ = recon_dec.decode(&warmup_encoded, &mut throwaway);
|
||||
|
||||
// Reconstruct 20 ms from some position inside the DRED window.
|
||||
let offset = (available / 2).max(480).min(available);
|
||||
let mut recon_pcm = vec![0i16; frame_len];
|
||||
let n = recon_dec
|
||||
.reconstruct_from_dred(&fresh_state, offset, &mut recon_pcm)
|
||||
.expect("reconstruct_from_dred failed");
|
||||
assert_eq!(n, frame_len);
|
||||
|
||||
// Energy check: reconstructed audio should not be all zeros. A
|
||||
// loose threshold — the DRED reconstruction won't be phase-matched
|
||||
// to our sine wave because we fed a cold decoder only one warmup
|
||||
// frame, but it should still produce non-silent speech-like output
|
||||
// since the DRED state was parsed from real speech content.
|
||||
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum();
|
||||
assert!(
|
||||
energy > 0,
|
||||
"reconstructed audio has zero total energy — DRED reconstruction produced silence"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second roundtrip variant: offset too large errors cleanly rather
|
||||
/// than crashing the FFI.
|
||||
#[test]
|
||||
fn reconstruct_with_out_of_range_offset_errors() {
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let state = DredState::new().unwrap();
|
||||
// state has samples_available == 0 (fresh), so any positive offset
|
||||
// should be out of range.
|
||||
let mut out = vec![0i16; 960];
|
||||
let err = dec.reconstruct_from_dred(&state, 480, &mut out);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstruct_with_zero_offset_errors() {
|
||||
let mut dec = DecoderHandle::new().unwrap();
|
||||
let state = DredState::new().unwrap();
|
||||
let mut out = vec![0i16; 960];
|
||||
let err = dec.reconstruct_from_dred(&state, 0, &mut out);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dred_parse_empty_packet_returns_zero() {
|
||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
||||
let mut state = DredState::new().unwrap();
|
||||
let result = dred_dec.parse_into(&mut state, &[]).unwrap();
|
||||
assert_eq!(result, 0);
|
||||
assert_eq!(state.samples_available(), 0);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod agc;
|
||||
pub mod codec2_dec;
|
||||
pub mod codec2_enc;
|
||||
pub mod denoise;
|
||||
pub mod dred_ffi;
|
||||
pub mod opus_dec;
|
||||
pub mod opus_enc;
|
||||
pub mod resample;
|
||||
@@ -27,6 +28,26 @@ 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
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
//! Opus decoder wrapping the `audiopus` crate.
|
||||
//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`.
|
||||
//!
|
||||
//! Phase 0 of the DRED integration: we went straight to a custom
|
||||
//! `DecoderHandle` instead of `opusic_c::Decoder` because the latter's
|
||||
//! inner pointer is `pub(crate)` and we need to reach it in Phase 3 for
|
||||
//! `opus_decoder_dred_decode`. See `dred_ffi.rs` for the rationale and
|
||||
//! `docs/PRD-dred-integration.md` for the full plan.
|
||||
|
||||
use audiopus::coder::Decoder;
|
||||
use audiopus::{Channels, MutSignals, SampleRate};
|
||||
use audiopus::packet::Packet;
|
||||
use crate::dred_ffi::{DecoderHandle, DredState};
|
||||
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||
|
||||
/// Opus decoder implementing `AudioDecoder`.
|
||||
/// Opus decoder implementing [`AudioDecoder`].
|
||||
///
|
||||
/// Operates at 48 kHz mono output.
|
||||
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via
|
||||
/// the active `QualityProfile`. Behavior is intentionally identical to
|
||||
/// the pre-swap audiopus-based decoder at this phase — DRED reconstruction
|
||||
/// lands in Phase 3.
|
||||
pub struct OpusDecoder {
|
||||
inner: Decoder,
|
||||
inner: DecoderHandle,
|
||||
codec_id: CodecId,
|
||||
frame_duration_ms: u8,
|
||||
}
|
||||
|
||||
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
|
||||
unsafe impl Sync for OpusDecoder {}
|
||||
|
||||
impl OpusDecoder {
|
||||
/// Create a new Opus decoder for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||
|
||||
let inner = DecoderHandle::new()?;
|
||||
Ok(Self {
|
||||
inner: decoder,
|
||||
inner,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
})
|
||||
@@ -34,6 +36,24 @@ impl OpusDecoder {
|
||||
pub fn frame_samples(&self) -> usize {
|
||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||
}
|
||||
|
||||
/// Reconstruct a lost frame from a previously parsed `DredState`.
|
||||
///
|
||||
/// Phase 3b entry point: callers (CallDecoder / engine.rs) use this to
|
||||
/// synthesize audio for gaps detected by the jitter buffer when DRED
|
||||
/// side-channel state from a later-arriving packet covers the gap's
|
||||
/// sample offset. `offset_samples` is measured backward from the anchor
|
||||
/// packet that produced `state`. See `DecoderHandle::reconstruct_from_dred`
|
||||
/// for the full semantics.
|
||||
pub fn reconstruct_from_dred(
|
||||
&mut self,
|
||||
state: &DredState,
|
||||
offset_samples: i32,
|
||||
output: &mut [i16],
|
||||
) -> Result<usize, CodecError> {
|
||||
self.inner
|
||||
.reconstruct_from_dred(state, offset_samples, output)
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioDecoder for OpusDecoder {
|
||||
@@ -45,15 +65,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
let packet = Packet::try_from(encoded)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
|
||||
let signals = MutSignals::try_from(pcm)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||
let n = self
|
||||
.inner
|
||||
.decode(Some(packet), signals, false)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
|
||||
Ok(n)
|
||||
self.inner.decode(encoded, pcm)
|
||||
}
|
||||
|
||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||
@@ -64,13 +76,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
let signals = MutSignals::try_from(pcm)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||
let n = self
|
||||
.inner
|
||||
.decode(None, signals, false)
|
||||
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
|
||||
Ok(n)
|
||||
self.inner.decode_lost(pcm)
|
||||
}
|
||||
|
||||
fn codec_id(&self) -> CodecId {
|
||||
|
||||
@@ -1,58 +1,225 @@
|
||||
//! Opus encoder wrapping the `audiopus` crate.
|
||||
//! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2).
|
||||
//!
|
||||
//! Phase 1 of the DRED integration: encoder-side DRED is enabled on every
|
||||
//! Opus profile with a tiered duration (studio 100 ms / normal 200 ms /
|
||||
//! degraded 500 ms), and Opus inband FEC (LBRR) is disabled because DRED
|
||||
//! is the stronger mechanism for the same failure mode. The legacy behavior
|
||||
//! is preserved behind the `AUDIO_USE_LEGACY_FEC` environment variable as a
|
||||
//! runtime escape hatch for rollout. See `docs/PRD-dred-integration.md`.
|
||||
//!
|
||||
//! # DRED duration policy
|
||||
//!
|
||||
//! Rationale from the PRD:
|
||||
//! - Studio tiers (Opus 32k/48k/64k): 100 ms — loss is rare on high-quality
|
||||
//! networks; short window keeps decoder CPU modest.
|
||||
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
|
||||
//! VoIP loss patterns (20–150 ms bursts from wifi roam, transient congestion).
|
||||
//! - Degraded tier (Opus 6k): 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.
|
||||
|
||||
use audiopus::coder::Encoder;
|
||||
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||
use tracing::debug;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
|
||||
use tracing::{debug, info, warn};
|
||||
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): DRED is N/A.
|
||||
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the legacy-FEC escape hatch is active.
|
||||
///
|
||||
/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy
|
||||
/// mode; unset or empty leaves DRED enabled.
|
||||
fn read_legacy_fec_env() -> bool {
|
||||
match std::env::var(LEGACY_FEC_ENV) {
|
||||
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opus encoder implementing `AudioEncoder`.
|
||||
///
|
||||
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||
/// and 40 ms (1920 samples).
|
||||
/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active
|
||||
/// `QualityProfile`.
|
||||
pub struct OpusEncoder {
|
||||
inner: Encoder,
|
||||
codec_id: CodecId,
|
||||
frame_duration_ms: u8,
|
||||
/// When `true`, revert to the Phase 0 behavior: inband FEC Mode1, DRED
|
||||
/// disabled, no loss floor. Captured at construction time and not
|
||||
/// re-read mid-call.
|
||||
legacy_fec_mode: bool,
|
||||
}
|
||||
|
||||
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
||||
// audiopus Encoder contains a raw pointer that is !Sync, but we never
|
||||
// share it across threads without exclusive access.
|
||||
// opusic-c Encoder wraps a non-null pointer that is !Sync by default,
|
||||
// but we never share it across threads without exclusive access.
|
||||
unsafe impl Sync for OpusEncoder {}
|
||||
|
||||
impl OpusEncoder {
|
||||
/// Create a new Opus encoder for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||
// opusic-c argument order: (Channels, SampleRate, Application)
|
||||
// — different from audiopus's (SampleRate, Channels, Application).
|
||||
let encoder = Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e:?}")))?;
|
||||
|
||||
let legacy_fec_mode = read_legacy_fec_env();
|
||||
if legacy_fec_mode {
|
||||
warn!(
|
||||
"AUDIO_USE_LEGACY_FEC active — reverting Opus encoder to Phase 0 \
|
||||
behavior (inband FEC Mode1, no DRED)"
|
||||
);
|
||||
}
|
||||
|
||||
let mut enc = Self {
|
||||
inner: encoder,
|
||||
codec_id: profile.codec,
|
||||
frame_duration_ms: profile.frame_duration_ms,
|
||||
legacy_fec_mode,
|
||||
};
|
||||
enc.apply_bitrate(profile.codec)?;
|
||||
enc.set_inband_fec(true);
|
||||
enc.set_dtx(true);
|
||||
|
||||
// Voice signal type hint for better compression
|
||||
// Common setup — bitrate, DTX, signal hint, complexity. These are
|
||||
// identical regardless of the protection mode below.
|
||||
enc.apply_bitrate(profile.codec)?;
|
||||
enc.set_dtx(true);
|
||||
enc.inner
|
||||
.set_signal(Signal::Voice)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||
|
||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?;
|
||||
enc.inner
|
||||
.set_complexity(7)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?;
|
||||
|
||||
// Protection mode: DRED (Phase 1 default) or legacy inband FEC.
|
||||
enc.apply_protection_mode(profile.codec)?;
|
||||
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
let bps = codec.bitrate_bps() as i32;
|
||||
/// Configure the protection mode for the active codec.
|
||||
///
|
||||
/// In DRED mode (default): disable inband FEC, set DRED duration for the
|
||||
/// codec tier, clamp packet_loss to the 5% floor so DRED stays active.
|
||||
///
|
||||
/// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave
|
||||
/// DRED and packet_loss at libopus defaults.
|
||||
fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||
if self.legacy_fec_mode {
|
||||
self.inner
|
||||
.set_inband_fec(InbandFec::Mode1)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?;
|
||||
// Leave DRED at 0 and packet_loss at default — matches Phase 0.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// DRED path: disable the overlapping inband FEC, enable DRED with
|
||||
// per-profile duration, floor packet_loss so DRED emits.
|
||||
self.inner
|
||||
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||
.set_inband_fec(InbandFec::Off)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?;
|
||||
|
||||
let dred_frames = dred_duration_for(codec);
|
||||
self.inner
|
||||
.set_dred_duration(dred_frames)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?;
|
||||
|
||||
self.inner
|
||||
.set_packet_loss(DRED_LOSS_FLOOR_PCT)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
|
||||
|
||||
// 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();
|
||||
self.inner
|
||||
.set_bitrate(Bitrate::Value(bps))
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?;
|
||||
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||
Ok(())
|
||||
}
|
||||
@@ -71,10 +238,36 @@ impl OpusEncoder {
|
||||
|
||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||
///
|
||||
/// Higher values cause the encoder to use more redundancy to survive
|
||||
/// packet loss, at the expense of slightly higher bitrate.
|
||||
/// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the
|
||||
/// encoder never drops DRED emission even on a perfect network. Real
|
||||
/// loss measurements from the quality adapter override upward.
|
||||
///
|
||||
/// In legacy mode, the value is passed through unchanged (min 0, max 100).
|
||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||
let clamped = if self.legacy_fec_mode {
|
||||
loss_pct.min(100)
|
||||
} else {
|
||||
loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100)
|
||||
};
|
||||
let _ = self.inner.set_packet_loss(clamped);
|
||||
}
|
||||
|
||||
/// Set the DRED duration in 10 ms frame units (0 disables, max 104).
|
||||
///
|
||||
/// No-op in legacy mode. Normally driven automatically by the active
|
||||
/// quality profile via `apply_protection_mode`; this setter exists for
|
||||
/// tests and for the rare case where a caller needs to override the
|
||||
/// per-profile default.
|
||||
pub fn set_dred_duration(&mut self, frames: u8) {
|
||||
if self.legacy_fec_mode {
|
||||
return;
|
||||
}
|
||||
let _ = self.inner.set_dred_duration(frames.min(104));
|
||||
}
|
||||
|
||||
/// Test/introspection accessor: whether legacy FEC mode is active.
|
||||
pub fn is_legacy_fec_mode(&self) -> bool {
|
||||
self.legacy_fec_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +280,14 @@ impl AudioEncoder for OpusEncoder {
|
||||
pcm.len()
|
||||
)));
|
||||
}
|
||||
// opusic-c takes &[u16] for the sample input. Bit pattern is
|
||||
// identical to i16 — the cast is zero-cost and the encoder
|
||||
// interprets the bytes the same way as libopus internally.
|
||||
let pcm_u16: &[u16] = bytemuck::cast_slice(pcm);
|
||||
let n = self
|
||||
.inner
|
||||
.encode(pcm, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||
.encode_to_slice(pcm_u16, out)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -104,6 +301,9 @@ 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 {
|
||||
@@ -120,10 +320,198 @@ impl AudioEncoder for OpusEncoder {
|
||||
}
|
||||
|
||||
fn set_inband_fec(&mut self, enabled: bool) {
|
||||
let _ = self.inner.set_inband_fec(enabled);
|
||||
// In DRED mode, ignore external requests to re-enable inband FEC —
|
||||
// running both mechanisms wastes bitrate on overlapping protection
|
||||
// and opusic-c's own docs recommend disabling inband FEC when DRED
|
||||
// is on. Trait callers that genuinely want classical FEC should set
|
||||
// `AUDIO_USE_LEGACY_FEC=1` and re-create the encoder.
|
||||
if !self.legacy_fec_mode {
|
||||
debug!(
|
||||
enabled,
|
||||
"set_inband_fec ignored: DRED mode is active (set AUDIO_USE_LEGACY_FEC to revert)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off };
|
||||
let _ = self.inner.set_inband_fec(mode);
|
||||
}
|
||||
|
||||
fn set_dtx(&mut self, enabled: bool) {
|
||||
let _ = self.inner.set_dtx(enabled);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
|
||||
// Encode as featherChat CallSignal payload
|
||||
@@ -198,6 +199,7 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
|
||||
fn wzp_hangup_round_trips_through_fc_callsignal() {
|
||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
};
|
||||
|
||||
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
|
||||
@@ -273,13 +275,14 @@ fn auth_invalid_response_matches() {
|
||||
|
||||
#[test]
|
||||
fn all_signal_types_map_correctly() {
|
||||
use wzp_client::featherchat::{signal_to_call_type, CallSignalType};
|
||||
use wzp_client::featherchat::signal_to_call_type;
|
||||
|
||||
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
||||
(
|
||||
wzp_proto::SignalMessage::CallOffer {
|
||||
identity_pub: [0; 32], ephemeral_pub: [0; 32],
|
||||
signature: vec![], supported_profiles: vec![],
|
||||
alias: None,
|
||||
},
|
||||
"Offer",
|
||||
),
|
||||
@@ -300,6 +303,7 @@ fn all_signal_types_map_correctly() {
|
||||
(
|
||||
wzp_proto::SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
},
|
||||
"Hangup",
|
||||
),
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#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__)
|
||||
@@ -254,14 +256,28 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
oboe::AudioStreamBuilder captureBuilder;
|
||||
captureBuilder.setDirection(oboe::Direction::Input)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setSharingMode(oboe::SharingMode::Shared)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
||||
->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));
|
||||
@@ -314,14 +330,23 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
oboe::AudioStreamBuilder playoutBuilder;
|
||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setSharingMode(oboe::SharingMode::Shared)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setUsage(oboe::Usage::VoiceCommunication)
|
||||
->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));
|
||||
@@ -365,6 +390,38 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
||||
return 0;
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
|
||||
@@ -47,6 +47,10 @@ 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)]
|
||||
@@ -174,6 +178,13 @@ struct AudioBackend {
|
||||
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();
|
||||
@@ -185,6 +196,8 @@ fn backend() -> &'static AudioBackend {
|
||||
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),
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -195,6 +208,17 @@ fn backend() -> &'static AudioBackend {
|
||||
/// 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,
|
||||
@@ -208,6 +232,7 @@ pub extern "C" fn wzp_native_audio_start() -> i32 {
|
||||
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(),
|
||||
@@ -262,6 +287,77 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
|
||||
}
|
||||
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);
|
||||
|
||||
312
crates/wzp-proto/src/dred_tuner.rs
Normal file
312
crates/wzp-proto/src/dred_tuner.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
//! 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.
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,15 @@ 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),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod bandwidth;
|
||||
pub mod codec_id;
|
||||
pub mod dred_tuner;
|
||||
pub mod error;
|
||||
pub mod jitter;
|
||||
pub mod packet;
|
||||
@@ -30,6 +31,7 @@ pub use packet::{
|
||||
FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use dred_tuner::{DredTuner, DredTuning};
|
||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
|
||||
@@ -584,12 +584,38 @@ pub enum SignalMessage {
|
||||
recommended_profile: crate::QualityProfile,
|
||||
},
|
||||
|
||||
/// Phase 4 telemetry: loss-recovery counts for the current session.
|
||||
/// Sent periodically from receivers to the relay so Prometheus metrics
|
||||
/// can distinguish DRED reconstructions from classical PLC invocations.
|
||||
/// Fields default to 0 on old receivers (`#[serde(default)]`), so
|
||||
/// introducing this variant is backward-compatible with pre-Phase-4
|
||||
/// relays — they'll just log "unknown signal variant" on receipt.
|
||||
LossRecoveryUpdate {
|
||||
/// Total frames reconstructed via DRED since call start (monotonic).
|
||||
#[serde(default)]
|
||||
dred_reconstructions: u64,
|
||||
/// Total frames filled via classical Opus/Codec2 PLC since call
|
||||
/// start (monotonic).
|
||||
#[serde(default)]
|
||||
classical_plc_invocations: u64,
|
||||
/// Total frames decoded since call start. Used by the relay to
|
||||
/// compute recovery rates as a fraction of total frames.
|
||||
#[serde(default)]
|
||||
frames_decoded: u64,
|
||||
},
|
||||
|
||||
/// Connection keepalive / RTT measurement.
|
||||
Ping { timestamp_ms: u64 },
|
||||
Pong { timestamp_ms: u64 },
|
||||
|
||||
/// End the call.
|
||||
Hangup { reason: HangupReason },
|
||||
/// End the call. `call_id` is optional for backwards compatibility
|
||||
/// with older clients that send Hangup without it — the relay falls
|
||||
/// back to ending ALL active calls for the sender in that case.
|
||||
Hangup {
|
||||
reason: HangupReason,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
call_id: Option<String>,
|
||||
},
|
||||
|
||||
/// featherChat bearer token for relay authentication.
|
||||
/// Sent as the first signal message when --auth-url is configured.
|
||||
@@ -696,6 +722,9 @@ pub enum SignalMessage {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
/// Relay's build version (git short hash).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
relay_build: Option<String>,
|
||||
},
|
||||
|
||||
/// Direct call offer routed through the relay to a specific peer.
|
||||
@@ -716,6 +745,28 @@ pub enum SignalMessage {
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
/// Phase 3 (hole-punching): caller's own server-reflexive
|
||||
/// address as learned via `SignalMessage::Reflect`. The
|
||||
/// relay stashes this in its call registry and later
|
||||
/// injects it into the callee's `CallSetup.peer_direct_addr`
|
||||
/// so the callee can try a direct QUIC handshake to the
|
||||
/// caller instead of routing media through the relay.
|
||||
/// `None` means "caller doesn't want P2P, use relay only".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_reflexive_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): caller's LAN-local
|
||||
/// interface addresses paired with its signal endpoint's
|
||||
/// port. Peers on the same physical LAN can direct-dial
|
||||
/// these without going through the WAN reflex addr,
|
||||
/// which is important because most consumer NATs
|
||||
/// (including MikroTik masquerade) don't support NAT
|
||||
/// hairpinning — the reflex addr is unreachable from
|
||||
/// the same LAN.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
caller_local_addrs: Vec<String>,
|
||||
/// Build version (git short hash) for debugging.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
caller_build_version: Option<String>,
|
||||
},
|
||||
|
||||
/// Callee's response to a direct call.
|
||||
@@ -735,6 +786,23 @@ pub enum SignalMessage {
|
||||
/// Chosen quality profile (present when accepting).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chosen_profile: Option<crate::QualityProfile>,
|
||||
/// Phase 3 (hole-punching): callee's own server-reflexive
|
||||
/// address, only populated on `AcceptTrusted` — privacy-mode
|
||||
/// answers leave this `None` so the callee's real IP stays
|
||||
/// hidden (the whole point of `AcceptGeneric`). The relay
|
||||
/// carries it opaquely into the caller's `CallSetup`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_reflexive_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): callee's LAN-local
|
||||
/// interface addresses. Same purpose as
|
||||
/// `caller_local_addrs` in `DirectCallOffer`. Only
|
||||
/// populated on `AcceptTrusted` alongside
|
||||
/// `callee_reflexive_addr`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
callee_local_addrs: Vec<String>,
|
||||
/// Build version (git short hash) for debugging.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
callee_build_version: Option<String>,
|
||||
},
|
||||
|
||||
/// Relay tells both parties: media room is ready.
|
||||
@@ -744,12 +812,119 @@ pub enum SignalMessage {
|
||||
room: String,
|
||||
/// Relay address for the QUIC media connection.
|
||||
relay_addr: String,
|
||||
/// Phase 3 (hole-punching): the OTHER party's server-reflexive
|
||||
/// address as the relay learned it from the offer/answer
|
||||
/// exchange. When populated, clients attempt a direct QUIC
|
||||
/// handshake to this address in parallel with the existing
|
||||
/// relay path and use whichever connects first. `None`
|
||||
/// means the relay path is the only option — either because
|
||||
/// a peer didn't advertise its addr (Phase 1/2 relay or
|
||||
/// privacy-mode answer) or because the relay decided P2P
|
||||
/// wasn't viable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
peer_direct_addr: Option<String>,
|
||||
/// Phase 5.5 (ICE host candidates): the OTHER party's LAN
|
||||
/// host addresses (RFC1918 IPv4 + CGNAT + non-link-local
|
||||
/// IPv6). On same-LAN calls these are directly dialable
|
||||
/// and bypass the NAT-hairpinning problem that blocks
|
||||
/// same-LAN peers from using `peer_direct_addr`.
|
||||
/// Client-side race tries all of these in parallel.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
peer_local_addrs: Vec<String>,
|
||||
},
|
||||
|
||||
/// Ringing notification (relay → caller, callee received the offer).
|
||||
CallRinging {
|
||||
call_id: String,
|
||||
},
|
||||
|
||||
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
|
||||
|
||||
/// Client → relay: "please tell me the source IP:port you see on
|
||||
/// this connection". A QUIC-native replacement for classic STUN
|
||||
/// that reuses the TLS-authenticated signal channel to the relay
|
||||
/// instead of running a separate UDP reflection service on port
|
||||
/// 3478. The relay answers with `ReflectResponse`.
|
||||
///
|
||||
/// No payload — the relay already knows which connection the
|
||||
/// request arrived on, and `connection.remote_address()` gives it
|
||||
/// the exact source address (post-NAT) as observed from the
|
||||
/// server side of the TLS session.
|
||||
Reflect,
|
||||
|
||||
/// Relay → client: response to `Reflect`. Carries the socket
|
||||
/// address the relay observes as the client's source for this
|
||||
/// QUIC connection in `SocketAddr::to_string()` form — "a.b.c.d:p"
|
||||
/// for IPv4, "[::1]:p" for IPv6. Clients parse it with
|
||||
/// `SocketAddr::from_str`.
|
||||
ReflectResponse {
|
||||
observed_addr: String,
|
||||
},
|
||||
|
||||
// ── Phase 6: ICE-style path negotiation ─────────────────────
|
||||
|
||||
/// Phase 6: each side reports the result of its local dual-
|
||||
/// path race to the other side through the relay. Both peers
|
||||
/// send this after their race completes; both wait for the
|
||||
/// other's report before committing a transport to the
|
||||
/// CallEngine.
|
||||
///
|
||||
/// The decision rule is: if BOTH sides report `direct_ok =
|
||||
/// true`, use the direct P2P connection. If EITHER reports
|
||||
/// `direct_ok = false`, BOTH fall back to relay. This
|
||||
/// eliminates the race condition where one side picks Direct
|
||||
/// and the other picks Relay — they now agree on the path
|
||||
/// before any media flows.
|
||||
MediaPathReport {
|
||||
call_id: String,
|
||||
/// Did the direct QUIC connection (P2P dial or accept)
|
||||
/// complete successfully on this side?
|
||||
direct_ok: bool,
|
||||
/// Which future won the local tokio::select race?
|
||||
/// "Direct" or "Relay" — informational for debug logs.
|
||||
#[serde(default)]
|
||||
race_winner: String,
|
||||
},
|
||||
|
||||
// ── Phase 4: cross-relay direct-call signaling ────────────────────
|
||||
|
||||
/// Phase 4: relay-to-relay envelope for forwarding direct-call
|
||||
/// signaling across a federation link. When Alice on Relay A
|
||||
/// sends a `DirectCallOffer` for Bob whose fingerprint isn't
|
||||
/// in A's local SignalHub, Relay A wraps the offer in this
|
||||
/// envelope and broadcasts it over every active federation
|
||||
/// peer link. Whichever peer has Bob registered unwraps the
|
||||
/// inner message and delivers it locally.
|
||||
///
|
||||
/// Never originated by clients — only relays create and
|
||||
/// consume this variant.
|
||||
///
|
||||
/// Loop prevention: the receiving relay drops any forward
|
||||
/// where `origin_relay_fp` matches its own federation TLS
|
||||
/// fingerprint. With broadcast-to-all-peers this prevents
|
||||
/// A→B→A echo loops; proper TTL + dedup will land when
|
||||
/// multi-hop federation is added (Phase 4.2).
|
||||
FederatedSignalForward {
|
||||
/// The signal message being forwarded
|
||||
/// (`DirectCallOffer`, `DirectCallAnswer`, `CallRinging`,
|
||||
/// `Hangup`, ...). Boxed because `SignalMessage` is
|
||||
/// relatively large and JSON serde handles recursion
|
||||
/// cleanly.
|
||||
inner: Box<SignalMessage>,
|
||||
/// Federation TLS fingerprint of the sending relay.
|
||||
/// Used (a) for loop prevention by the receiver and (b)
|
||||
/// to route the peer's reply back through the same
|
||||
/// federation link via `send_signal_to_peer`.
|
||||
origin_relay_fp: String,
|
||||
},
|
||||
|
||||
/// Relay-initiated quality directive: all participants should switch
|
||||
/// to the recommended profile to match the weakest link.
|
||||
QualityDirective {
|
||||
recommended_profile: crate::QualityProfile,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the callee responds to a direct call.
|
||||
@@ -888,6 +1063,272 @@ mod tests {
|
||||
assert_eq!(packet.quality_report, decoded.quality_report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_serialize_roundtrip() {
|
||||
// Reflect is a unit variant — the client sends it with no
|
||||
// payload and the relay answers with the observed source addr.
|
||||
let req = SignalMessage::Reflect;
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(decoded, SignalMessage::Reflect));
|
||||
|
||||
// ReflectResponse carries a string — exercise both IPv4 and
|
||||
// IPv6 shapes because SocketAddr::to_string uses [::1]:port
|
||||
// for v6 and the client side has to parse that back.
|
||||
for addr in ["192.0.2.17:4433", "[2001:db8::1]:4433", "127.0.0.1:54321"] {
|
||||
let resp = SignalMessage::ReflectResponse {
|
||||
observed_addr: addr.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::ReflectResponse { observed_addr } => {
|
||||
assert_eq!(observed_addr, addr);
|
||||
// Must parse back to a SocketAddr cleanly.
|
||||
let _parsed: std::net::SocketAddr = observed_addr.parse()
|
||||
.expect("observed_addr must parse as SocketAddr");
|
||||
}
|
||||
_ => panic!("wrong variant after roundtrip"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_signal_forward_roundtrip() {
|
||||
// Wrap a DirectCallOffer inside FederatedSignalForward and
|
||||
// prove both directions of serde preserve every field.
|
||||
let inner = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: Some("Alice".into()),
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [1u8; 32],
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_build_version: None,
|
||||
};
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(inner),
|
||||
origin_relay_fp: "relay-a-tls-fp".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&forward).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
|
||||
assert_eq!(origin_relay_fp, "relay-a-tls-fp");
|
||||
match *inner {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
target_fingerprint,
|
||||
caller_reflexive_addr,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(caller_fingerprint, "alice");
|
||||
assert_eq!(target_fingerprint, "bob");
|
||||
assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
_ => panic!("inner was not DirectCallOffer after roundtrip"),
|
||||
}
|
||||
}
|
||||
_ => panic!("outer was not FederatedSignalForward"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn federated_signal_forward_can_nest_any_inner() {
|
||||
// Sanity check that every direct-call signaling variant
|
||||
// we intend to forward survives being boxed + re-serialized.
|
||||
let cases: Vec<SignalMessage> = vec![
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id: "c1".into(),
|
||||
accept_mode: CallAcceptMode::AcceptTrusted,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_build_version: None,
|
||||
},
|
||||
SignalMessage::CallRinging { call_id: "c1".into() },
|
||||
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
|
||||
];
|
||||
for inner in cases {
|
||||
let inner_disc = std::mem::discriminant(&inner);
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(inner),
|
||||
origin_relay_fp: "r".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&forward).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::FederatedSignalForward { inner, .. } => {
|
||||
assert_eq!(std::mem::discriminant(&*inner), inner_disc);
|
||||
}
|
||||
_ => panic!("outer variant lost"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_punching_optional_fields_roundtrip() {
|
||||
// DirectCallOffer with Some(caller_reflexive_addr)
|
||||
let offer = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json = serde_json::to_string(&offer).unwrap();
|
||||
assert!(
|
||||
json.contains("caller_reflexive_addr"),
|
||||
"Some field must serialize: {json}"
|
||||
);
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
||||
assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// DirectCallOffer with None — skip_serializing_if must
|
||||
// OMIT the field from the JSON so older relays that don't
|
||||
// know about caller_reflexive_addr don't see it.
|
||||
let offer_none = SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: "c1".into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: None,
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_build_version: None,
|
||||
};
|
||||
let json_none = serde_json::to_string(&offer_none).unwrap();
|
||||
assert!(
|
||||
!json_none.contains("caller_reflexive_addr"),
|
||||
"None field must NOT serialize: {json_none}"
|
||||
);
|
||||
|
||||
// DirectCallAnswer with callee_reflexive_addr.
|
||||
let answer = SignalMessage::DirectCallAnswer {
|
||||
call_id: "c1".into(),
|
||||
accept_mode: CallAcceptMode::AcceptTrusted,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_build_version: None,
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallAnswer { callee_reflexive_addr, .. } => {
|
||||
assert_eq!(
|
||||
callee_reflexive_addr.as_deref(),
|
||||
Some("198.51.100.9:4433")
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// CallSetup with peer_direct_addr.
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: "c1".into(),
|
||||
room: "call-c1".into(),
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: Some("192.0.2.1:4433".into()),
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let decoded: SignalMessage =
|
||||
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_punching_backward_compat_old_json_parses() {
|
||||
// An older client/relay wouldn't include the new fields at
|
||||
// all — the new code must still accept that JSON because
|
||||
// of #[serde(default)] on the Option<String>.
|
||||
let old_offer_json = r#"{
|
||||
"DirectCallOffer": {
|
||||
"caller_fingerprint": "alice",
|
||||
"caller_alias": null,
|
||||
"target_fingerprint": "bob",
|
||||
"call_id": "c1",
|
||||
"identity_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"ephemeral_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
"signature": [],
|
||||
"supported_profiles": []
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
||||
assert!(caller_reflexive_addr.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
let old_setup_json = r#"{
|
||||
"CallSetup": {
|
||||
"call_id": "c1",
|
||||
"room": "call-c1",
|
||||
"relay_addr": "203.0.113.5:4433"
|
||||
}
|
||||
}"#;
|
||||
let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert!(peer_direct_addr.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_backward_compat_with_existing_variants() {
|
||||
// Adding Reflect/ReflectResponse at the end of the enum must
|
||||
// not break JSON round-tripping of existing variants. Smoke-
|
||||
// test a sample of the pre-existing ones.
|
||||
let cases = vec![
|
||||
SignalMessage::Ping { timestamp_ms: 12345 },
|
||||
SignalMessage::Hold,
|
||||
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
|
||||
SignalMessage::CallRinging { call_id: "abcd".into() },
|
||||
];
|
||||
for m in cases {
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
// Discriminant equality proves variant tag survived.
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&m),
|
||||
std::mem::discriminant(&decoded)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_unhold_serialize() {
|
||||
let hold = SignalMessage::Hold;
|
||||
@@ -1232,6 +1673,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_directive_roundtrip() {
|
||||
let msg = SignalMessage::QualityDirective {
|
||||
recommended_profile: crate::QualityProfile::DEGRADED,
|
||||
reason: Some("weakest link degraded".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::QualityDirective { recommended_profile, reason } => {
|
||||
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
|
||||
assert_eq!(reason.as_deref(), Some("weakest link degraded"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_directive_without_reason_roundtrip() {
|
||||
let msg = SignalMessage::QualityDirective {
|
||||
recommended_profile: crate::QualityProfile::GOOD,
|
||||
reason: None,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
// None reason should be omitted from JSON
|
||||
assert!(!json.contains("reason"));
|
||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
SignalMessage::QualityDirective { reason, .. } => {
|
||||
assert!(reason.is_none());
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_frame_disabled() {
|
||||
// Simulate disabled mini-frames by always keeping frames_since_full at 0
|
||||
|
||||
@@ -28,6 +28,13 @@ 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.
|
||||
|
||||
@@ -31,6 +31,36 @@ 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>,
|
||||
}
|
||||
|
||||
/// Registry of active direct calls.
|
||||
@@ -57,11 +87,61 @@ 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(),
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a call by ID.
|
||||
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
||||
self.calls.get(call_id)
|
||||
@@ -196,4 +276,79 @@ 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_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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -142,13 +142,18 @@ pub struct FederationManager {
|
||||
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
|
||||
/// Dedup filter for incoming federation datagrams.
|
||||
dedup: Mutex<Deduplicator>,
|
||||
/// Per-room seq counter for federation media delivered to local clients.
|
||||
/// Ensures clients see monotonically increasing seq regardless of federation sender.
|
||||
local_delivery_seq: std::sync::atomic::AtomicU16,
|
||||
/// JSONL event log for protocol analysis.
|
||||
event_log: EventLogger,
|
||||
/// Per-room rate limiters for inbound federation media.
|
||||
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
|
||||
/// Phase 4: channel for handing cross-relay direct-call
|
||||
/// signaling (inner message + origin relay fp) back to the
|
||||
/// main signal loop in `main.rs`. Set once at startup via
|
||||
/// `set_cross_relay_tx`. `None` when the main loop hasn't
|
||||
/// wired it up yet (e.g. during startup warmup) — forwards
|
||||
/// that arrive before wiring are dropped with a warning.
|
||||
cross_relay_signal_tx:
|
||||
Mutex<Option<tokio::sync::mpsc::Sender<(wzp_proto::SignalMessage, String)>>>,
|
||||
}
|
||||
|
||||
impl FederationManager {
|
||||
@@ -172,34 +177,133 @@ impl FederationManager {
|
||||
metrics,
|
||||
peer_links: Arc::new(Mutex::new(HashMap::new())),
|
||||
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
|
||||
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
|
||||
event_log,
|
||||
rate_limiters: Mutex::new(HashMap::new()),
|
||||
cross_relay_signal_tx: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: expose this relay's federation TLS fingerprint so
|
||||
/// the main signal loop can populate
|
||||
/// `SignalMessage::FederatedSignalForward.origin_relay_fp`.
|
||||
pub fn local_tls_fp(&self) -> &str {
|
||||
&self.local_tls_fp
|
||||
}
|
||||
|
||||
/// Phase 4: wire the channel that the main signal loop uses
|
||||
/// to receive unwrapped cross-relay direct-call signals. Called
|
||||
/// once at startup from `main.rs`.
|
||||
pub async fn set_cross_relay_tx(
|
||||
&self,
|
||||
tx: tokio::sync::mpsc::Sender<(wzp_proto::SignalMessage, String)>,
|
||||
) {
|
||||
*self.cross_relay_signal_tx.lock().await = Some(tx);
|
||||
}
|
||||
|
||||
/// Phase 4: broadcast a `SignalMessage::FederatedSignalForward`
|
||||
/// to every active federation peer link. Returns the number of
|
||||
/// peers the broadcast reached (not the number that successfully
|
||||
/// delivered the message further). Used when the local relay
|
||||
/// doesn't know which peer holds the target fingerprint for a
|
||||
/// `DirectCallOffer` — whichever peer has it will unwrap and
|
||||
/// handle locally; the rest drop silently after "target not
|
||||
/// local" check.
|
||||
///
|
||||
/// Loop prevention: the receiving relay checks
|
||||
/// `origin_relay_fp` against its own fp and drops self-sourced
|
||||
/// forwards.
|
||||
pub async fn broadcast_signal(&self, msg: &wzp_proto::SignalMessage) -> usize {
|
||||
let links = self.peer_links.lock().await;
|
||||
let mut count = 0;
|
||||
for (fp, link) in links.iter() {
|
||||
match link.transport.send_signal(msg).await {
|
||||
Ok(()) => {
|
||||
count += 1;
|
||||
tracing::debug!(peer = %link.label, %fp, "federation: broadcast signal ok");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(peer = %link.label, %fp, error = %e, "federation: broadcast signal failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Phase 4: targeted send — used by the
|
||||
/// `DirectCallAnswer` path when the registry knows exactly
|
||||
/// which peer relay to route the reply back to. More efficient
|
||||
/// than re-broadcasting and avoids leaking the call to
|
||||
/// uninvolved peers.
|
||||
///
|
||||
/// Returns `Ok(())` on success, `Err(String)` when the peer
|
||||
/// isn't currently linked or the send fails.
|
||||
pub async fn send_signal_to_peer(
|
||||
&self,
|
||||
peer_relay_fp: &str,
|
||||
msg: &wzp_proto::SignalMessage,
|
||||
) -> Result<(), String> {
|
||||
let normalized = normalize_fp(peer_relay_fp);
|
||||
let links = self.peer_links.lock().await;
|
||||
match links.get(&normalized) {
|
||||
Some(link) => link
|
||||
.transport
|
||||
.send_signal(msg)
|
||||
.await
|
||||
.map_err(|e| format!("send to peer {normalized}: {e}")),
|
||||
None => Err(format!("no active federation link for {normalized}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a room name (which may be hashed) is a global room.
|
||||
///
|
||||
/// Phase 4.1: ALL `call-*` rooms are implicitly global for
|
||||
/// federation. This is the simplest path to cross-relay direct
|
||||
/// calling with relay-mediated media fallback: when both peers
|
||||
/// join the same `call-<id>` room on their respective relays,
|
||||
/// the federation media pipeline automatically forwards
|
||||
/// datagrams between them. The relay's existing ACL (`call-*`
|
||||
/// rooms are restricted to the two authorized participants in
|
||||
/// the call registry) prevents random clients from creating or
|
||||
/// joining `call-*` rooms.
|
||||
pub fn is_global_room(&self, room: &str) -> bool {
|
||||
if room.starts_with("call-") {
|
||||
return true;
|
||||
}
|
||||
self.resolve_global_room(room).is_some()
|
||||
}
|
||||
|
||||
/// Resolve a room name (raw or hashed) to the canonical global room name.
|
||||
/// Returns the configured global room name if it matches.
|
||||
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
|
||||
///
|
||||
/// Phase 4.1: `call-*` rooms resolve to themselves (they ARE
|
||||
/// the canonical name — no hashing or aliasing involved).
|
||||
///
|
||||
/// Returns `Option<String>` (owned) instead of `Option<&str>`
|
||||
/// because call-* room names aren't stored on `self` — they
|
||||
/// come from the caller and we just confirm "yes, this is
|
||||
/// global" by returning it back. Pre-4.1 callers that used
|
||||
/// the reference for equality checks or hashing work
|
||||
/// unchanged via String/&str auto-deref.
|
||||
pub fn resolve_global_room(&self, room: &str) -> Option<String> {
|
||||
// Phase 4.1: call-* rooms are implicitly global, resolve
|
||||
// to themselves
|
||||
if room.starts_with("call-") {
|
||||
return Some(room.to_string());
|
||||
}
|
||||
// Direct match (raw room name, e.g. Android clients)
|
||||
if self.global_rooms.contains(room) {
|
||||
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
|
||||
return Some(room.to_string());
|
||||
}
|
||||
// Hashed match (desktop clients hash room names for SNI privacy)
|
||||
self.global_rooms.iter().find(|name| {
|
||||
wzp_crypto::hash_room_name(name) == room
|
||||
}).map(|s| s.as_str())
|
||||
}).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Get the canonical federation room hash for a room.
|
||||
/// Always uses the configured global room name, not the client-provided name.
|
||||
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
|
||||
if let Some(canonical) = self.resolve_global_room(room) {
|
||||
if let Some(ref canonical) = self.resolve_global_room(room) {
|
||||
room_hash(canonical)
|
||||
} else {
|
||||
room_hash(room)
|
||||
@@ -271,8 +375,8 @@ impl FederationManager {
|
||||
let mut result = Vec::new();
|
||||
for link in links.values() {
|
||||
// Check canonical name
|
||||
if let Some(c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c) {
|
||||
if let Some(ref c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c.as_str()) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
// Also check raw room name, but only if different from canonical
|
||||
@@ -296,7 +400,12 @@ impl FederationManager {
|
||||
/// Forward locally-generated media to all connected peers.
|
||||
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
|
||||
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
|
||||
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
||||
///
|
||||
/// `_room_name` is kept in the signature for caller-site symmetry with
|
||||
/// the other room-tagged helpers and for future per-room-name logging
|
||||
/// or rate limiting; the body currently forwards on `room_hash` alone
|
||||
/// because that's what the wire format carries.
|
||||
pub async fn forward_to_peers(&self, _room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
||||
let links = self.peer_links.lock().await;
|
||||
if links.is_empty() {
|
||||
return;
|
||||
@@ -623,11 +732,20 @@ async fn run_federation_link(
|
||||
}
|
||||
};
|
||||
|
||||
// RTT monitor: periodically sample QUIC RTT for this peer
|
||||
// RTT monitor: periodically sample QUIC RTT for this peer and push it
|
||||
// into the `wzp_federation_peer_rtt_ms` gauge. The gauge is registered
|
||||
// in metrics.rs but previously never received any samples — the task
|
||||
// computed rtt_ms and dropped it on the floor, leaving the Grafana
|
||||
// panel blank. Fixed as part of the workspace warning sweep.
|
||||
let rtt_task = async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
|
||||
fm_rtt
|
||||
.metrics
|
||||
.federation_peer_rtt_ms
|
||||
.with_label_values(&[&label_rtt])
|
||||
.set(rtt_ms);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -717,12 +835,12 @@ async fn handle_signal(
|
||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
||||
let links = fm.peer_links.lock().await;
|
||||
for link in links.values() {
|
||||
if let Some(canonical) = fm.resolve_global_room(&local_room) {
|
||||
if let Some(remote) = link.remote_participants.get(canonical) {
|
||||
if let Some(ref canonical) = fm.resolve_global_room(&local_room) {
|
||||
if let Some(remote) = link.remote_participants.get(canonical.as_str()) {
|
||||
all_participants.extend(remote.iter().cloned());
|
||||
}
|
||||
// Also check raw room name, but only if different from canonical
|
||||
if canonical != local_room {
|
||||
if canonical != &local_room {
|
||||
if let Some(remote) = link.remote_participants.get(&local_room) {
|
||||
all_participants.extend(remote.iter().cloned());
|
||||
}
|
||||
@@ -753,8 +871,8 @@ async fn handle_signal(
|
||||
// Clear remote participants for this peer+room
|
||||
link.remote_participants.remove(&room);
|
||||
// Also try canonical name
|
||||
if let Some(canonical) = fm.resolve_global_room(&room) {
|
||||
link.remote_participants.remove(canonical);
|
||||
if let Some(ref canonical) = fm.resolve_global_room(&room) {
|
||||
link.remote_participants.remove(canonical.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,8 +886,8 @@ async fn handle_signal(
|
||||
let mut result = Vec::new();
|
||||
for (fp, link) in links.iter() {
|
||||
if fp == peer_fp { continue; }
|
||||
if let Some(c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c) {
|
||||
if let Some(ref c) = canonical {
|
||||
if let Some(remote) = link.remote_participants.get(c.as_str()) {
|
||||
result.extend(remote.iter().cloned());
|
||||
}
|
||||
}
|
||||
@@ -842,6 +960,57 @@ async fn handle_signal(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Phase 4: cross-relay direct-call signal envelope.
|
||||
//
|
||||
// Unwrap the inner message and hand it off to the main
|
||||
// signal loop via the cross_relay_signal_tx channel. The
|
||||
// main loop will then dispatch the inner DirectCallOffer/
|
||||
// Answer/Ringing/Hangup exactly as if it had arrived on a
|
||||
// local signal transport — with the extra context that
|
||||
// the call is "federated" (origin_relay_fp).
|
||||
//
|
||||
// Loop prevention: drop any forward whose origin matches
|
||||
// our own federation TLS fingerprint. With
|
||||
// broadcast-to-all-peers this prevents A→B→A echo loops.
|
||||
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
|
||||
if origin_relay_fp == fm.local_tls_fp {
|
||||
tracing::debug!(
|
||||
peer = %peer_label,
|
||||
"federation: dropping self-sourced FederatedSignalForward (loop prevention)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let tx_opt = {
|
||||
let guard = fm.cross_relay_signal_tx.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
match tx_opt {
|
||||
Some(tx) => {
|
||||
let inner_discriminant = std::mem::discriminant(&*inner);
|
||||
if let Err(e) = tx.send((*inner, origin_relay_fp.clone())).await {
|
||||
warn!(
|
||||
peer = %peer_label,
|
||||
?inner_discriminant,
|
||||
error = %e,
|
||||
"federation: cross-relay signal dispatcher full / closed"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
peer = %peer_label,
|
||||
?inner_discriminant,
|
||||
%origin_relay_fp,
|
||||
"federation: forwarded cross-relay signal to main dispatcher"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!(
|
||||
peer = %peer_label,
|
||||
"federation: cross_relay_signal_tx not wired yet — dropping forward"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // ignore other signals
|
||||
}
|
||||
}
|
||||
@@ -908,7 +1077,7 @@ async fn handle_datagram(
|
||||
// First: check local rooms (has participants)
|
||||
active.iter().find(|r| room_hash(r) == rh).cloned()
|
||||
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
|
||||
// Second: check global room config (hub relay may have no local participants)
|
||||
// Second: check static global room config (hub relay may have no local participants)
|
||||
.or_else(|| {
|
||||
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
|
||||
})
|
||||
@@ -918,6 +1087,23 @@ async fn handle_datagram(
|
||||
Some(r) => r,
|
||||
None => {
|
||||
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
|
||||
// Phase 4.1 diagnostic: log the hash + active rooms
|
||||
// so we can diagnose cross-relay call-* media routing
|
||||
// failures. This fires when a peer relay sends media
|
||||
// for a room we don't have locally — could be a
|
||||
// timing issue (peer joined before us) or a hash
|
||||
// mismatch.
|
||||
let active = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.active_rooms()
|
||||
};
|
||||
warn!(
|
||||
room_hash = ?rh,
|
||||
active_rooms = ?active,
|
||||
seq = pkt.header.seq,
|
||||
peer = %peer_label,
|
||||
"federation datagram for unknown room — no local room matches hash"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,9 +94,13 @@ pub async fn accept_handshake(
|
||||
}
|
||||
|
||||
/// Select the best quality profile from those the caller supports.
|
||||
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested
|
||||
// for federation reliability (large packets may exceed path MTU).
|
||||
///
|
||||
/// The `_supported` list is currently ignored — we hardcode GOOD (24k) until
|
||||
/// studio tiers (32k/48k/64k) have been validated across federation (large
|
||||
/// packets may exceed path MTU and fragment in unpleasant ways). Once that's
|
||||
/// tested, the body should pick the highest supported profile ≤ the relay's
|
||||
/// configured ceiling.
|
||||
fn choose_profile(_supported: &[QualityProfile]) -> QualityProfile {
|
||||
QualityProfile::GOOD
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_relay::config::RelayConfig;
|
||||
@@ -272,7 +272,7 @@ const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args();
|
||||
let CliResult { config, identity_path, config_file, config_needs_create } = parse_args();
|
||||
tracing_subscriber::fmt().init();
|
||||
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
||||
rustls::crypto::ring::default_provider()
|
||||
@@ -453,6 +453,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
let signal_hub = Arc::new(Mutex::new(wzp_relay::signal_hub::SignalHub::new()));
|
||||
let call_registry = Arc::new(Mutex::new(wzp_relay::call_registry::CallRegistry::new()));
|
||||
|
||||
// Phase 4: cross-relay direct-call signal dispatcher.
|
||||
//
|
||||
// The federation layer unwraps incoming
|
||||
// `SignalMessage::FederatedSignalForward` envelopes and pushes
|
||||
// (inner, origin_relay_fp) onto this channel. A dedicated task
|
||||
// further down reads from it and routes the inner message
|
||||
// through signal_hub / call_registry exactly as if it had
|
||||
// arrived on a local signal transport — with the extra
|
||||
// context that a peer relay is on the other side of the call.
|
||||
let (cross_relay_tx, mut cross_relay_rx) =
|
||||
tokio::sync::mpsc::channel::<(wzp_proto::SignalMessage, String)>(32);
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
fm.set_cross_relay_tx(cross_relay_tx.clone()).await;
|
||||
}
|
||||
|
||||
// Spawn inter-relay health probes via ProbeMesh coordinator
|
||||
if !config.probe_targets.is_empty() {
|
||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||
@@ -497,6 +512,240 @@ async fn main() -> anyhow::Result<()> {
|
||||
info!(filter = %tap, "debug tap enabled — logging packet headers");
|
||||
}
|
||||
|
||||
// Phase 4: cross-relay direct-call dispatcher task.
|
||||
//
|
||||
// Reads unwrapped (inner, origin_relay_fp) tuples that the
|
||||
// federation layer pushes out of its `handle_signal` arm for
|
||||
// `FederatedSignalForward`, and routes the inner message
|
||||
// through the local signal_hub / call_registry exactly as if
|
||||
// the message had arrived on a local client signal transport.
|
||||
//
|
||||
// In Phase 4 MVP the dispatcher handles:
|
||||
// * DirectCallOffer — if target is local, stash in registry
|
||||
// with peer_relay_fp and deliver to
|
||||
// local callee via signal_hub.
|
||||
// * DirectCallAnswer — stash callee addr, forward answer to
|
||||
// local caller, emit local CallSetup.
|
||||
// * CallRinging — forward to local caller for UX.
|
||||
// * Hangup — forward to the local participant(s).
|
||||
// Everything else is dropped.
|
||||
{
|
||||
let signal_hub_d = signal_hub.clone();
|
||||
let call_registry_d = call_registry.clone();
|
||||
let advertised_addr_d = advertised_addr_str.clone();
|
||||
let federation_mgr_d = federation_mgr.clone();
|
||||
tokio::spawn(async move {
|
||||
use wzp_proto::{CallAcceptMode, SignalMessage};
|
||||
while let Some((inner, origin_relay_fp)) = cross_relay_rx.recv().await {
|
||||
match inner {
|
||||
SignalMessage::DirectCallOffer {
|
||||
ref target_fingerprint,
|
||||
ref caller_fingerprint,
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
..
|
||||
} => {
|
||||
// Is the target on THIS relay? If not, drop —
|
||||
// Phase 4 MVP is single-hop federation only.
|
||||
let online = {
|
||||
let hub = signal_hub_d.lock().await;
|
||||
hub.is_online(target_fingerprint)
|
||||
};
|
||||
if !online {
|
||||
tracing::debug!(
|
||||
target = %target_fingerprint,
|
||||
%origin_relay_fp,
|
||||
"cross-relay: offer target not local, dropping (no multi-hop)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Stash in local registry so the answer path
|
||||
// can find the call + route the reply back
|
||||
// through the same federation link. Include
|
||||
// Phase 5.5 LAN host candidates too.
|
||||
{
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.create_call(
|
||||
call_id.clone(),
|
||||
caller_fingerprint.clone(),
|
||||
target_fingerprint.clone(),
|
||||
);
|
||||
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
|
||||
reg.set_caller_local_addrs(call_id, caller_local_addrs.clone());
|
||||
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
|
||||
}
|
||||
// Deliver the offer to the local target.
|
||||
let hub = signal_hub_d.lock().await;
|
||||
if let Err(e) = hub.send_to(target_fingerprint, &inner).await {
|
||||
tracing::warn!(
|
||||
target = %target_fingerprint,
|
||||
error = %e,
|
||||
"cross-relay: failed to deliver forwarded offer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::DirectCallAnswer {
|
||||
ref call_id,
|
||||
accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
..
|
||||
} => {
|
||||
// Look up the local caller fp from the registry.
|
||||
let caller_fp = {
|
||||
let reg = call_registry_d.lock().await;
|
||||
reg.get(call_id).map(|c| c.caller_fingerprint.clone())
|
||||
};
|
||||
let Some(caller_fp) = caller_fp else {
|
||||
tracing::debug!(%call_id, "cross-relay: answer for unknown call, dropping");
|
||||
continue;
|
||||
};
|
||||
|
||||
if accept_mode == CallAcceptMode::Reject {
|
||||
// Forward hangup to local caller + clean up registry.
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub
|
||||
.send_to(
|
||||
&caller_fp,
|
||||
&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
drop(hub);
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.end_call(call_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept — stash the callee's reflex addr + LAN
|
||||
// host candidates + mark the call active,
|
||||
// then read back everything needed to cross-
|
||||
// wire peer_direct_addr + peer_local_addrs in
|
||||
// the local CallSetup.
|
||||
// Also set peer_relay_fp so the originating
|
||||
// relay knows where to forward MediaPathReport.
|
||||
let room_name = format!("call-{call_id}");
|
||||
let (callee_addr_for_setup, callee_local_for_setup) = {
|
||||
let mut reg = call_registry_d.lock().await;
|
||||
reg.set_active(call_id, accept_mode, room_name.clone());
|
||||
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
|
||||
reg.set_callee_reflexive_addr(
|
||||
call_id,
|
||||
callee_reflexive_addr.clone(),
|
||||
);
|
||||
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
|
||||
let c = reg.get(call_id);
|
||||
(
|
||||
c.and_then(|c| c.callee_reflexive_addr.clone()),
|
||||
c.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
|
||||
// Forward the raw answer to the local caller so
|
||||
// the JS side sees DirectCallAnswer (fires any
|
||||
// "call answered" UX that looks at this message).
|
||||
{
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub.send_to(&caller_fp, &inner).await;
|
||||
}
|
||||
|
||||
// Emit the LOCAL CallSetup to our local caller.
|
||||
// relay_addr = our own advertised addr so if P2P
|
||||
// fails the caller will at least dial OUR relay
|
||||
// (single-relay fallback — Phase 4.1 will wire
|
||||
// federated media so that actually reaches the
|
||||
// peer). peer_direct_addr = the callee's reflex
|
||||
// addr carried in the answer. peer_local_addrs
|
||||
// = callee's LAN host candidates (Phase 5.5 ICE).
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room_name.clone(),
|
||||
relay_addr: advertised_addr_d.clone(),
|
||||
peer_direct_addr: callee_addr_for_setup,
|
||||
peer_local_addrs: callee_local_for_setup,
|
||||
};
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub.send_to(&caller_fp, &setup).await;
|
||||
|
||||
tracing::info!(
|
||||
%call_id,
|
||||
%caller_fp,
|
||||
%origin_relay_fp,
|
||||
"cross-relay: delivered answer + CallSetup to local caller"
|
||||
);
|
||||
}
|
||||
|
||||
SignalMessage::CallRinging { ref call_id } => {
|
||||
// Forward to local caller for "ringing..." UX.
|
||||
let caller_fp = {
|
||||
let reg = call_registry_d.lock().await;
|
||||
reg.get(call_id).map(|c| c.caller_fingerprint.clone())
|
||||
};
|
||||
if let Some(fp) = caller_fp {
|
||||
let hub = signal_hub_d.lock().await;
|
||||
let _ = hub.send_to(&fp, &inner).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: MediaPathReport forwarded across
|
||||
// federation — deliver to the LOCAL participant.
|
||||
// The report comes from the remote side, so we
|
||||
// deliver to whichever participant is local. In
|
||||
// the cross-relay case, one is local and one is
|
||||
// remote. Try both — send_to is a no-op if the
|
||||
// target isn't connected to this relay.
|
||||
SignalMessage::MediaPathReport { ref call_id, .. } => {
|
||||
let (caller_fp, callee_fp) = {
|
||||
let reg = call_registry_d.lock().await;
|
||||
match reg.get(call_id) {
|
||||
Some(c) => (
|
||||
Some(c.caller_fingerprint.clone()),
|
||||
Some(c.callee_fingerprint.clone()),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
let hub = signal_hub_d.lock().await;
|
||||
if let Some(fp) = caller_fp {
|
||||
let _ = hub.send_to(&fp, &inner).await;
|
||||
}
|
||||
if let Some(fp) = callee_fp {
|
||||
let _ = hub.send_to(&fp, &inner).await;
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Hangup { .. } => {
|
||||
// Best-effort: broadcast the hangup to every
|
||||
// local participant of any call that currently
|
||||
// has this origin as its peer_relay_fp.
|
||||
// The forwarded hangup doesn't carry a call_id
|
||||
// so we can't target precisely — Phase 4.1 will
|
||||
// tighten this once hangup tracking is stricter.
|
||||
tracing::debug!(
|
||||
%origin_relay_fp,
|
||||
"cross-relay: forwarded Hangup (Phase 4.1 will target by call_id)"
|
||||
);
|
||||
}
|
||||
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
%origin_relay_fp,
|
||||
"cross-relay: dispatcher ignoring unsupported inner variant"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress the warning if federation_mgr_d is unused —
|
||||
// it's held here so the Arc doesn't drop during the
|
||||
// dispatcher's lifetime.
|
||||
drop(federation_mgr_d);
|
||||
});
|
||||
}
|
||||
|
||||
info!("Listening for connections...");
|
||||
|
||||
loop {
|
||||
@@ -529,6 +778,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let signal_hub = signal_hub.clone();
|
||||
let call_registry = call_registry.clone();
|
||||
let advertised_addr_str = advertised_addr_str.clone();
|
||||
// Phase 4: per-task clone of this relay's federation TLS
|
||||
// fingerprint so the FederatedSignalForward envelopes the
|
||||
// spawned signal handler builds carry `origin_relay_fp`.
|
||||
let tls_fp = tls_fp.clone();
|
||||
|
||||
let incoming_addr = incoming.remote_address();
|
||||
info!(%incoming_addr, "accept queue: new Incoming, spawning handshake task");
|
||||
@@ -757,6 +1010,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
|
||||
success: true,
|
||||
error: None,
|
||||
relay_build: Some(BUILD_GIT_HASH.to_string()),
|
||||
}).await;
|
||||
|
||||
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
|
||||
@@ -766,9 +1020,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
match transport.recv_signal().await {
|
||||
Ok(Some(msg)) => {
|
||||
match msg {
|
||||
SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, ref caller_alias, .. } => {
|
||||
SignalMessage::DirectCallOffer {
|
||||
ref target_fingerprint,
|
||||
ref call_id,
|
||||
ref caller_reflexive_addr,
|
||||
ref caller_local_addrs,
|
||||
..
|
||||
} => {
|
||||
let target_fp = target_fingerprint.clone();
|
||||
let call_id = call_id.clone();
|
||||
let caller_addr_for_registry = caller_reflexive_addr.clone();
|
||||
let caller_local_for_registry = caller_local_addrs.clone();
|
||||
|
||||
// Check if target is online
|
||||
let online = {
|
||||
@@ -776,17 +1038,92 @@ async fn main() -> anyhow::Result<()> {
|
||||
hub.is_online(&target_fp)
|
||||
};
|
||||
if !online {
|
||||
info!(%addr, target = %target_fp, "call target not online");
|
||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
// Phase 4: maybe the target is on a
|
||||
// federation peer. Wrap the offer in
|
||||
// FederatedSignalForward and broadcast
|
||||
// it over every active peer link —
|
||||
// whichever relay has the target will
|
||||
// unwrap and dispatch locally. We also
|
||||
// stash the call in OUR registry so
|
||||
// the eventual answer coming back via
|
||||
// federation has a matching entry.
|
||||
let forwarded = if let Some(ref fm) = federation_mgr {
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(msg.clone()),
|
||||
origin_relay_fp: tls_fp.clone(),
|
||||
};
|
||||
let count = fm.broadcast_signal(&forward).await;
|
||||
if count > 0 {
|
||||
info!(
|
||||
%addr,
|
||||
target = %target_fp,
|
||||
peers = count,
|
||||
"direct-call offer forwarded to federation peers"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !forwarded {
|
||||
info!(%addr, target = %target_fp, "call target not online (no federation route)");
|
||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: None,
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create call in registry with the
|
||||
// caller's reflex addr + LAN host
|
||||
// candidates, and mark it as
|
||||
// cross-relay so the answer path knows
|
||||
// to route the CallSetup's
|
||||
// peer_direct_addr from what the
|
||||
// federated answer carries. peer_relay_fp
|
||||
// stays None here because we broadcast —
|
||||
// the receiving relay picks itself as
|
||||
// the answer source and its forwarded
|
||||
// answer will identify itself there.
|
||||
{
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.create_call(
|
||||
call_id.clone(),
|
||||
client_fp.clone(),
|
||||
target_fp.clone(),
|
||||
);
|
||||
reg.set_caller_reflexive_addr(
|
||||
&call_id,
|
||||
caller_addr_for_registry.clone(),
|
||||
);
|
||||
reg.set_caller_local_addrs(
|
||||
&call_id,
|
||||
caller_local_for_registry.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Send ringing to caller immediately
|
||||
// so the UI shows feedback while the
|
||||
// federated delivery is in flight.
|
||||
let _ = transport.send_signal(&SignalMessage::CallRinging {
|
||||
call_id: call_id.clone(),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create call in registry
|
||||
// Create call in registry + stash the caller's
|
||||
// reflex addr (Phase 3 hole-punching) AND its
|
||||
// LAN host candidates (Phase 5.5 ICE). The
|
||||
// relay treats both as opaque. Both are
|
||||
// injected later into the callee's CallSetup.
|
||||
{
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
|
||||
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry);
|
||||
reg.set_caller_local_addrs(&call_id, caller_local_for_registry);
|
||||
}
|
||||
|
||||
// Forward offer to callee
|
||||
@@ -803,16 +1140,37 @@ async fn main() -> anyhow::Result<()> {
|
||||
}).await;
|
||||
}
|
||||
|
||||
SignalMessage::DirectCallAnswer { ref call_id, ref accept_mode, .. } => {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
ref call_id,
|
||||
ref accept_mode,
|
||||
ref callee_reflexive_addr,
|
||||
ref callee_local_addrs,
|
||||
..
|
||||
} => {
|
||||
let call_id = call_id.clone();
|
||||
let mode = *accept_mode;
|
||||
let callee_addr_for_registry = callee_reflexive_addr.clone();
|
||||
let callee_local_for_registry = callee_local_addrs.clone();
|
||||
|
||||
let peer_fp = {
|
||||
// Phase 4: look up peer fingerprint AND
|
||||
// peer_relay_fp in one lock acquisition.
|
||||
// peer_relay_fp being Some means the
|
||||
// caller is on a remote federation peer
|
||||
// and we have to route the answer /
|
||||
// hangup back through that link instead
|
||||
// of local signal_hub.
|
||||
let (peer_fp, peer_relay_fp) = {
|
||||
let reg = call_registry.lock().await;
|
||||
reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())
|
||||
match reg.get(&call_id) {
|
||||
Some(c) => (
|
||||
Some(reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())),
|
||||
c.peer_relay_fp.clone(),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
let Some(peer_fp) = peer_fp else {
|
||||
let Some(Some(peer_fp)) = peer_fp else {
|
||||
warn!(call_id = %call_id, "answer for unknown call");
|
||||
continue;
|
||||
};
|
||||
@@ -822,69 +1180,211 @@ async fn main() -> anyhow::Result<()> {
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.end_call(&call_id);
|
||||
drop(reg);
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
}).await;
|
||||
|
||||
// Phase 4: cross-relay reject —
|
||||
// forward the hangup to the origin
|
||||
// relay instead of local signal_hub.
|
||||
if let Some(ref origin_fp) = peer_relay_fp {
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
let hangup = SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: Some(call_id.clone()),
|
||||
};
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(hangup),
|
||||
origin_relay_fp: tls_fp.clone(),
|
||||
};
|
||||
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
|
||||
warn!(%call_id, %origin_fp, error = %e, "cross-relay reject forward failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: Some(call_id.clone()),
|
||||
}).await;
|
||||
}
|
||||
} else {
|
||||
// Accept — create private room
|
||||
// Accept — create private room + stash the
|
||||
// callee's reflex addr if it advertised one
|
||||
// (AcceptTrusted only — privacy-mode answers
|
||||
// leave it None by design). Then read back
|
||||
// BOTH parties' addrs so we can cross-wire
|
||||
// peer_direct_addr on the CallSetups below.
|
||||
let room = format!("call-{call_id}");
|
||||
{
|
||||
let (caller_addr, callee_addr, caller_local, callee_local) = {
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.set_active(&call_id, mode, room.clone());
|
||||
}
|
||||
info!(call_id = %call_id, room = %room, mode = ?mode, "call accepted, creating room");
|
||||
|
||||
// Forward answer to caller
|
||||
{
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &msg).await;
|
||||
}
|
||||
|
||||
// Send CallSetup to both parties.
|
||||
//
|
||||
// BUG FIX: the previous version of this used `addr.ip()`
|
||||
// which is `connection.remote_address()` — the CLIENT'S
|
||||
// IP, not the relay's. So CallSetup told both parties to
|
||||
// dial the answerer's own IP, which meant the caller was
|
||||
// sending QUIC Initials into the callee's client (no
|
||||
// server listening there) and the callee was sending to
|
||||
// itself. In both cases endpoint.connect() hung forever.
|
||||
//
|
||||
// Use the relay's precomputed advertised address instead.
|
||||
let relay_addr_for_setup = advertised_addr_str.clone();
|
||||
let setup = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry);
|
||||
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone());
|
||||
let call = reg.get(&call_id);
|
||||
(
|
||||
call.and_then(|c| c.caller_reflexive_addr.clone()),
|
||||
call.and_then(|c| c.callee_reflexive_addr.clone()),
|
||||
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(),
|
||||
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
{
|
||||
info!(
|
||||
call_id = %call_id,
|
||||
room = %room,
|
||||
?mode,
|
||||
p2p_viable = caller_addr.is_some() && callee_addr.is_some(),
|
||||
"call accepted, creating room"
|
||||
);
|
||||
|
||||
let relay_addr_for_setup = advertised_addr_str.clone();
|
||||
|
||||
if let Some(ref origin_fp) = peer_relay_fp {
|
||||
// Phase 4 cross-relay: the caller
|
||||
// is on a remote peer. Forward the
|
||||
// raw answer (which carries the
|
||||
// callee's reflex addr) back over
|
||||
// federation — the peer's
|
||||
// cross-relay dispatcher will
|
||||
// deliver it to the local caller
|
||||
// AND emit a CallSetup on that
|
||||
// side with peer_direct_addr =
|
||||
// callee_addr.
|
||||
//
|
||||
// Here we emit only the LOCAL
|
||||
// CallSetup (to our callee) with
|
||||
// peer_direct_addr = caller_addr.
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(msg.clone()),
|
||||
origin_relay_fp: tls_fp.clone(),
|
||||
};
|
||||
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
|
||||
warn!(
|
||||
%call_id,
|
||||
%origin_fp,
|
||||
error = %e,
|
||||
"cross-relay answer forward failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &setup).await;
|
||||
let _ = hub.send_to(&client_fp, &setup).await;
|
||||
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
|
||||
} else {
|
||||
// Local call (existing Phase 3 path).
|
||||
// Forward answer to caller
|
||||
{
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &msg).await;
|
||||
}
|
||||
|
||||
// Send CallSetup to BOTH parties with
|
||||
// cross-wired peer_direct_addr +
|
||||
// peer_local_addrs (Phase 5.5 ICE).
|
||||
let setup_for_caller = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup.clone(),
|
||||
peer_direct_addr: callee_addr.clone(),
|
||||
peer_local_addrs: callee_local.clone(),
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: relay_addr_for_setup,
|
||||
peer_direct_addr: caller_addr.clone(),
|
||||
peer_local_addrs: caller_local.clone(),
|
||||
};
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
|
||||
let _ = hub.send_to(&client_fp, &setup_for_callee).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalMessage::Hangup { .. } => {
|
||||
// Forward hangup to all active calls for this user
|
||||
SignalMessage::Hangup { ref call_id, .. } => {
|
||||
// If the client sent a call_id, only end
|
||||
// that specific call. Otherwise (old clients)
|
||||
// fall back to ending ALL active calls for
|
||||
// this user — which can race with new calls.
|
||||
let calls = {
|
||||
let reg = call_registry.lock().await;
|
||||
reg.calls_for_fingerprint(&client_fp)
|
||||
.iter()
|
||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||
c.callee_fingerprint.clone()
|
||||
} else {
|
||||
c.caller_fingerprint.clone()
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
if let Some(cid) = call_id {
|
||||
// Targeted hangup: only the named call
|
||||
reg.get(cid)
|
||||
.map(|c| vec![(c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||
c.callee_fingerprint.clone()
|
||||
} else {
|
||||
c.caller_fingerprint.clone()
|
||||
})])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// Legacy: end all calls for this user
|
||||
reg.calls_for_fingerprint(&client_fp)
|
||||
.iter()
|
||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
||||
c.callee_fingerprint.clone()
|
||||
} else {
|
||||
c.caller_fingerprint.clone()
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
};
|
||||
for (call_id, peer_fp) in &calls {
|
||||
for (cid, peer_fp) in &calls {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(peer_fp, &msg).await;
|
||||
drop(hub);
|
||||
let mut reg = call_registry.lock().await;
|
||||
reg.end_call(call_id);
|
||||
reg.end_call(cid);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: forward MediaPathReport to the
|
||||
// call peer so both sides can negotiate
|
||||
// the media path before committing.
|
||||
SignalMessage::MediaPathReport { ref call_id, .. } => {
|
||||
// Look up peer AND check if this is a
|
||||
// cross-relay call (same pattern as
|
||||
// DirectCallAnswer).
|
||||
let (peer_fp, peer_relay_fp) = {
|
||||
let reg = call_registry.lock().await;
|
||||
match reg.get(call_id) {
|
||||
Some(c) => (
|
||||
reg.peer_fingerprint(call_id, &client_fp)
|
||||
.map(|s| s.to_string()),
|
||||
c.peer_relay_fp.clone(),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(fp) = peer_fp {
|
||||
if let Some(ref origin_fp) = peer_relay_fp {
|
||||
// Cross-relay: wrap and forward
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(msg.clone()),
|
||||
origin_relay_fp: tls_fp.clone(),
|
||||
};
|
||||
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
|
||||
warn!(
|
||||
%call_id,
|
||||
%origin_fp,
|
||||
error = %e,
|
||||
"cross-relay MediaPathReport forward failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local call
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(&fp, &msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,6 +1392,31 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
|
||||
}
|
||||
|
||||
// QUIC-native NAT reflection ("STUN for QUIC").
|
||||
// The client asks "what source address do you
|
||||
// see for me?" and we reply with whatever
|
||||
// quinn reports as this connection's remote
|
||||
// address — i.e. the post-NAT public address
|
||||
// as observed from the server side of the TLS
|
||||
// session. Used by the P2P path to learn the
|
||||
// client's server-reflexive address without
|
||||
// running a separate STUN server. No auth or
|
||||
// rate-limit in Phase 1 — the client is
|
||||
// already TLS-authenticated by the time it
|
||||
// reaches this match arm.
|
||||
SignalMessage::Reflect => {
|
||||
let observed_addr = addr.to_string();
|
||||
if let Err(e) = transport.send_signal(
|
||||
&SignalMessage::ReflectResponse {
|
||||
observed_addr: observed_addr.clone(),
|
||||
},
|
||||
).await {
|
||||
warn!(%addr, error = %e, "reflect: failed to send response");
|
||||
} else {
|
||||
debug!(%addr, %observed_addr, "reflect: responded");
|
||||
}
|
||||
}
|
||||
|
||||
other => {
|
||||
warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other));
|
||||
}
|
||||
@@ -901,6 +1426,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
info!(%addr, "signal connection closed");
|
||||
break;
|
||||
}
|
||||
Err(wzp_proto::TransportError::Deserialize(e)) => {
|
||||
// Forward-compat: the peer sent a
|
||||
// SignalMessage variant we don't know
|
||||
// (newer client, newer federation peer).
|
||||
// Log and continue — tearing down the
|
||||
// connection on unknown variants would
|
||||
// silently kill interop across minor
|
||||
// protocol version bumps.
|
||||
warn!(%addr, "signal deserialize (unknown variant?), continuing: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%addr, "signal recv error: {e}");
|
||||
break;
|
||||
@@ -924,6 +1459,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let hub = signal_hub.lock().await;
|
||||
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
|
||||
reason: wzp_proto::HangupReason::Normal,
|
||||
call_id: Some(call_id.clone()),
|
||||
}).await;
|
||||
drop(hub);
|
||||
let mut reg = call_registry.lock().await;
|
||||
|
||||
@@ -29,6 +29,9 @@ pub struct RelayMetrics {
|
||||
pub session_rtt_ms: GaugeVec,
|
||||
pub session_underruns: IntCounterVec,
|
||||
pub session_overruns: IntCounterVec,
|
||||
// Phase 4: loss-recovery breakdown per session.
|
||||
pub session_dred_reconstructions: IntCounterVec,
|
||||
pub session_classical_plc: IntCounterVec,
|
||||
registry: Registry,
|
||||
}
|
||||
|
||||
@@ -130,6 +133,23 @@ impl RelayMetrics {
|
||||
)
|
||||
.expect("metric");
|
||||
|
||||
let session_dred_reconstructions = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"wzp_relay_session_dred_reconstructions_total",
|
||||
"Frames reconstructed via DRED (Deep REDundancy) per session",
|
||||
),
|
||||
&["session_id"],
|
||||
)
|
||||
.expect("metric");
|
||||
let session_classical_plc = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"wzp_relay_session_classical_plc_total",
|
||||
"Frames filled via classical Opus/Codec2 PLC per session",
|
||||
),
|
||||
&["session_id"],
|
||||
)
|
||||
.expect("metric");
|
||||
|
||||
registry.register(Box::new(active_sessions.clone())).expect("register");
|
||||
registry.register(Box::new(active_rooms.clone())).expect("register");
|
||||
registry.register(Box::new(packets_forwarded.clone())).expect("register");
|
||||
@@ -147,6 +167,8 @@ impl RelayMetrics {
|
||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||
registry.register(Box::new(session_underruns.clone())).expect("register");
|
||||
registry.register(Box::new(session_overruns.clone())).expect("register");
|
||||
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register");
|
||||
registry.register(Box::new(session_classical_plc.clone())).expect("register");
|
||||
|
||||
Self {
|
||||
active_sessions,
|
||||
@@ -166,6 +188,8 @@ impl RelayMetrics {
|
||||
session_rtt_ms,
|
||||
session_underruns,
|
||||
session_overruns,
|
||||
session_dred_reconstructions,
|
||||
session_classical_plc,
|
||||
registry,
|
||||
}
|
||||
}
|
||||
@@ -217,6 +241,39 @@ impl RelayMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: update per-session loss-recovery counters from a client's
|
||||
/// `LossRecoveryUpdate` signal message. The client sends monotonic
|
||||
/// totals (frames reconstructed since call start); we compute the
|
||||
/// delta against the current Prometheus counter and increment by it.
|
||||
/// IntCounterVec only increases, so a client restart that resets the
|
||||
/// counter to 0 simply produces no delta until the new totals exceed
|
||||
/// the Prometheus state.
|
||||
pub fn update_session_loss_recovery(
|
||||
&self,
|
||||
session_id: &str,
|
||||
dred_reconstructions: u64,
|
||||
classical_plc: u64,
|
||||
) {
|
||||
let cur_dred = self
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[session_id])
|
||||
.get();
|
||||
if dred_reconstructions > cur_dred {
|
||||
self.session_dred_reconstructions
|
||||
.with_label_values(&[session_id])
|
||||
.inc_by(dred_reconstructions - cur_dred);
|
||||
}
|
||||
let cur_plc = self
|
||||
.session_classical_plc
|
||||
.with_label_values(&[session_id])
|
||||
.get();
|
||||
if classical_plc > cur_plc {
|
||||
self.session_classical_plc
|
||||
.with_label_values(&[session_id])
|
||||
.inc_by(classical_plc - cur_plc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove all per-session label values for a disconnected session.
|
||||
pub fn remove_session_metrics(&self, session_id: &str) {
|
||||
let _ = self.session_buffer_depth.remove_label_values(&[session_id]);
|
||||
@@ -224,6 +281,10 @@ impl RelayMetrics {
|
||||
let _ = self.session_rtt_ms.remove_label_values(&[session_id]);
|
||||
let _ = self.session_underruns.remove_label_values(&[session_id]);
|
||||
let _ = self.session_overruns.remove_label_values(&[session_id]);
|
||||
let _ = self
|
||||
.session_dred_reconstructions
|
||||
.remove_label_values(&[session_id]);
|
||||
let _ = self.session_classical_plc.remove_label_values(&[session_id]);
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying Prometheus registry.
|
||||
@@ -418,10 +479,13 @@ mod tests {
|
||||
};
|
||||
m.update_session_quality("sess-cleanup", &report);
|
||||
m.update_session_buffer("sess-cleanup", 42, 3, 1);
|
||||
m.update_session_loss_recovery("sess-cleanup", 17, 4);
|
||||
|
||||
// Verify they appear
|
||||
let output = m.metrics_handler();
|
||||
assert!(output.contains("sess-cleanup"));
|
||||
assert!(output.contains("wzp_relay_session_dred_reconstructions_total"));
|
||||
assert!(output.contains("wzp_relay_session_classical_plc_total"));
|
||||
|
||||
// Remove and verify they are gone
|
||||
m.remove_session_metrics("sess-cleanup");
|
||||
@@ -429,6 +493,55 @@ mod tests {
|
||||
assert!(!output.contains("sess-cleanup"));
|
||||
}
|
||||
|
||||
/// Phase 4: LossRecoveryUpdate → per-session counters, monotonic delta
|
||||
/// application.
|
||||
#[test]
|
||||
fn session_loss_recovery_monotonic_delta() {
|
||||
let m = RelayMetrics::new();
|
||||
let sess = "sess-dred";
|
||||
|
||||
// First update: 10 DRED, 2 PLC
|
||||
m.update_session_loss_recovery(sess, 10, 2);
|
||||
let dred1 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc1 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred1, 10);
|
||||
assert_eq!(plc1, 2);
|
||||
|
||||
// Second update: 25 DRED, 5 PLC — counter advances by (15, 3)
|
||||
m.update_session_loss_recovery(sess, 25, 5);
|
||||
let dred2 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc2 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred2, 25);
|
||||
assert_eq!(plc2, 5);
|
||||
|
||||
// Third update with LOWER values (e.g., client reset) — counters
|
||||
// hold steady, no decrement.
|
||||
m.update_session_loss_recovery(sess, 5, 1);
|
||||
let dred3 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc3 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred3, 25, "counter must not decrease");
|
||||
assert_eq!(plc3, 5, "counter must not decrease");
|
||||
|
||||
// Fourth update: client caught up and exceeded the old max.
|
||||
m.update_session_loss_recovery(sess, 30, 8);
|
||||
let dred4 = m
|
||||
.session_dred_reconstructions
|
||||
.with_label_values(&[sess])
|
||||
.get();
|
||||
let plc4 = m.session_classical_plc.with_label_values(&[sess]).get();
|
||||
assert_eq!(dred4, 30);
|
||||
assert_eq!(plc4, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_increment() {
|
||||
let m = RelayMetrics::new();
|
||||
|
||||
@@ -10,9 +10,11 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::quality::{AdaptiveQualityController, Tier};
|
||||
use wzp_proto::traits::QualityController;
|
||||
use wzp_proto::MediaTransport;
|
||||
|
||||
use crate::metrics::RelayMetrics;
|
||||
@@ -50,6 +52,45 @@ impl DebugTap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks network quality for a single participant in a room.
|
||||
struct ParticipantQuality {
|
||||
controller: AdaptiveQualityController,
|
||||
current_tier: Tier,
|
||||
}
|
||||
|
||||
impl ParticipantQuality {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
controller: AdaptiveQualityController::new(),
|
||||
current_tier: Tier::Good,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a quality report and return the new tier if it changed.
|
||||
fn observe(&mut self, report: &wzp_proto::packet::QualityReport) -> Option<Tier> {
|
||||
let _ = self.controller.observe(report);
|
||||
let new_tier = self.controller.tier();
|
||||
if new_tier != self.current_tier {
|
||||
self.current_tier = new_tier;
|
||||
Some(new_tier)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the weakest (worst) quality tier across all tracked participants.
|
||||
fn weakest_tier<'a>(qualities: impl Iterator<Item = &'a ParticipantQuality>) -> Tier {
|
||||
qualities
|
||||
.map(|pq| pq.current_tier)
|
||||
.min_by_key(|t| match t {
|
||||
Tier::Good => 2,
|
||||
Tier::Degraded => 1,
|
||||
Tier::Catastrophic => 0,
|
||||
})
|
||||
.unwrap_or(Tier::Good)
|
||||
}
|
||||
|
||||
/// Unique participant ID within a room.
|
||||
pub type ParticipantId = u64;
|
||||
|
||||
@@ -208,6 +249,10 @@ pub struct RoomManager {
|
||||
acl: Option<HashMap<String, HashSet<String>>>,
|
||||
/// Channel for room lifecycle events (federation subscribes).
|
||||
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
||||
/// Per-participant quality tracking, keyed by (room_name, participant_id).
|
||||
qualities: HashMap<(String, ParticipantId), ParticipantQuality>,
|
||||
/// Current room-wide tier per room (to avoid repeated broadcasts).
|
||||
room_tiers: HashMap<String, Tier>,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
@@ -217,6 +262,8 @@ impl RoomManager {
|
||||
rooms: HashMap::new(),
|
||||
acl: None,
|
||||
event_tx,
|
||||
qualities: HashMap::new(),
|
||||
room_tiers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +274,8 @@ impl RoomManager {
|
||||
rooms: HashMap::new(),
|
||||
acl: Some(HashMap::new()),
|
||||
event_tx,
|
||||
qualities: HashMap::new(),
|
||||
room_tiers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +326,7 @@ impl RoomManager {
|
||||
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty());
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||
self.qualities.insert((room_name.to_string(), id), ParticipantQuality::new());
|
||||
if was_empty {
|
||||
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
|
||||
}
|
||||
@@ -323,10 +373,12 @@ impl RoomManager {
|
||||
|
||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
self.qualities.remove(&(room_name.to_string(), participant_id));
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
room.remove(participant_id);
|
||||
if room.is_empty() {
|
||||
self.rooms.remove(room_name);
|
||||
self.room_tiers.remove(room_name);
|
||||
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
||||
info!(room = room_name, "room closed (empty)");
|
||||
return None;
|
||||
@@ -363,6 +415,58 @@ impl RoomManager {
|
||||
pub fn list(&self) -> Vec<(String, usize)> {
|
||||
self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect()
|
||||
}
|
||||
|
||||
/// Feed a quality report from a participant. If the room-wide weakest
|
||||
/// tier changes, returns `(QualityDirective signal, all senders)` for
|
||||
/// broadcasting.
|
||||
pub fn observe_quality(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
participant_id: ParticipantId,
|
||||
report: &wzp_proto::packet::QualityReport,
|
||||
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
let key = (room_name.to_string(), participant_id);
|
||||
let tier_changed = self.qualities
|
||||
.get_mut(&key)
|
||||
.and_then(|pq| pq.observe(report))
|
||||
.is_some();
|
||||
|
||||
if !tier_changed {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute the weakest tier across all participants in this room
|
||||
let room_qualities = self.qualities.iter()
|
||||
.filter(|((rn, _), _)| rn == room_name)
|
||||
.map(|(_, pq)| pq);
|
||||
let weakest = weakest_tier(room_qualities);
|
||||
|
||||
let current_room_tier = self.room_tiers.get(room_name).copied().unwrap_or(Tier::Good);
|
||||
if weakest == current_room_tier {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Room-wide tier changed — update and broadcast directive
|
||||
self.room_tiers.insert(room_name.to_string(), weakest);
|
||||
let profile = weakest.profile();
|
||||
info!(
|
||||
room = room_name,
|
||||
old_tier = ?current_room_tier,
|
||||
new_tier = ?weakest,
|
||||
codec = ?profile.codec,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
"room quality directive"
|
||||
);
|
||||
|
||||
let directive = wzp_proto::SignalMessage::QualityDirective {
|
||||
recommended_profile: profile,
|
||||
reason: Some(format!("weakest link: {weakest:?}")),
|
||||
};
|
||||
let senders = self.rooms.get(room_name)
|
||||
.map(|r| r.all_senders())
|
||||
.unwrap_or_default();
|
||||
Some((directive, senders))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -382,18 +486,32 @@ impl TrunkedForwarder {
|
||||
/// Create a new trunked forwarder.
|
||||
///
|
||||
/// `session_id` tags every entry pushed into the batcher so the receiver
|
||||
/// can demultiplex packets by session.
|
||||
/// can demultiplex packets by session. The batcher's `max_bytes` is
|
||||
/// initialized from the transport's current PMTUD-discovered MTU so that
|
||||
/// trunk frames fill the largest datagram the path supports (instead of
|
||||
/// the conservative 1200-byte default).
|
||||
pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self {
|
||||
let mut batcher = TrunkBatcher::new();
|
||||
if let Some(mtu) = transport.max_datagram_size() {
|
||||
batcher.max_bytes = mtu;
|
||||
}
|
||||
Self {
|
||||
transport,
|
||||
batcher: TrunkBatcher::new(),
|
||||
batcher,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a media packet into the batcher. If the batcher is full it will
|
||||
/// flush automatically and the resulting trunk frame is sent immediately.
|
||||
///
|
||||
/// Also refreshes `max_bytes` from the transport's PMTUD-discovered MTU
|
||||
/// so the batcher fills larger datagrams as the path MTU grows.
|
||||
pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> {
|
||||
// Refresh batcher limit from PMTUD (cheap: reads an atomic in quinn).
|
||||
if let Some(mtu) = self.transport.max_datagram_size() {
|
||||
self.batcher.max_bytes = mtu;
|
||||
}
|
||||
let payload: Bytes = pkt.to_bytes();
|
||||
if let Some(frame) = self.batcher.push(self.session_id, payload) {
|
||||
self.send_frame(&frame)?;
|
||||
@@ -483,7 +601,6 @@ async fn run_participant_plain(
|
||||
);
|
||||
|
||||
loop {
|
||||
let recv_start = std::time::Instant::now();
|
||||
let pkt = match transport.recv_media().await {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
@@ -522,11 +639,17 @@ async fn run_participant_plain(
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
// Get current list of other participants
|
||||
// Get current list of other participants + check quality directive
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
let (others, quality_directive) = {
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
let directive = if let Some(ref report) = pkt.quality_report {
|
||||
mgr.observe_quality(&room_name, participant_id, report)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let o = mgr.others(&room_name, participant_id);
|
||||
(o, directive)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
@@ -538,6 +661,11 @@ async fn run_participant_plain(
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast quality directive to all participants if tier changed
|
||||
if let Some((directive, all_senders)) = quality_directive {
|
||||
broadcast_signal(&all_senders, &directive).await;
|
||||
}
|
||||
|
||||
// Debug tap: log packet metadata
|
||||
if let Some(ref tap) = debug_tap {
|
||||
if tap.matches(&room_name) {
|
||||
@@ -706,9 +834,15 @@ async fn run_participant_trunked(
|
||||
}
|
||||
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
let (others, quality_directive) = {
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
let directive = if let Some(ref report) = pkt.quality_report {
|
||||
mgr.observe_quality(&room_name, participant_id, report)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let o = mgr.others(&room_name, participant_id);
|
||||
(o, directive)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
@@ -720,6 +854,11 @@ async fn run_participant_trunked(
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast quality directive to all participants if tier changed
|
||||
if let Some((directive, all_senders)) = quality_directive {
|
||||
broadcast_signal(&all_senders, &directive).await;
|
||||
}
|
||||
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
@@ -838,7 +977,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn room_join_leave() {
|
||||
let mut mgr = RoomManager::new();
|
||||
let mgr = RoomManager::new();
|
||||
assert_eq!(mgr.room_size("test"), 0);
|
||||
assert!(mgr.list().is_empty());
|
||||
}
|
||||
@@ -960,4 +1099,47 @@ mod tests {
|
||||
// Batcher should now be empty — nothing to flush.
|
||||
assert!(batcher.flush().is_none());
|
||||
}
|
||||
|
||||
fn make_report(loss_pct_f: f32, rtt_ms: u16) -> wzp_proto::packet::QualityReport {
|
||||
wzp_proto::packet::QualityReport {
|
||||
loss_pct: (loss_pct_f / 100.0 * 255.0) as u8,
|
||||
rtt_4ms: (rtt_ms / 4) as u8,
|
||||
jitter_ms: 10,
|
||||
bitrate_cap_kbps: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_quality_starts_good() {
|
||||
let pq = ParticipantQuality::new();
|
||||
assert_eq!(pq.current_tier, Tier::Good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_quality_degrades_on_bad_reports() {
|
||||
let mut pq = ParticipantQuality::new();
|
||||
let bad = make_report(50.0, 300);
|
||||
// Feed enough bad reports to trigger downgrade (3 consecutive)
|
||||
for _ in 0..5 {
|
||||
pq.observe(&bad);
|
||||
}
|
||||
assert_ne!(pq.current_tier, Tier::Good, "should degrade from Good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weakest_tier_picks_worst() {
|
||||
let good = ParticipantQuality::new();
|
||||
// good stays at Good tier
|
||||
|
||||
let mut bad = ParticipantQuality::new();
|
||||
let bad_report = make_report(50.0, 300);
|
||||
for _ in 0..5 {
|
||||
bad.observe(&bad_report);
|
||||
}
|
||||
// bad should be degraded or catastrophic
|
||||
|
||||
let participants = vec![good, bad];
|
||||
let weakest = weakest_tier(participants.iter());
|
||||
assert_ne!(weakest, Tier::Good, "weakest should not be Good when one participant is bad");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::QuinnTransport;
|
||||
|
||||
@@ -94,7 +94,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn register_unregister() {
|
||||
let mut hub = SignalHub::new();
|
||||
let hub = SignalHub::new();
|
||||
assert_eq!(hub.online_count(), 0);
|
||||
assert!(!hub.is_online("alice"));
|
||||
|
||||
|
||||
317
crates/wzp-relay/tests/cross_relay_direct_call.rs
Normal file
317
crates/wzp-relay/tests/cross_relay_direct_call.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! Phase 4 integration test for cross-relay direct calling
|
||||
//! (PRD: .taskmaster/docs/prd_phase4_cross_relay_p2p.txt).
|
||||
//!
|
||||
//! Drives the call-registry cross-wiring + a simulated federation
|
||||
//! forward without spinning up actual relay binaries. The real
|
||||
//! main-loop and dispatcher code are exercised end-to-end in
|
||||
//! `reflect.rs` / `hole_punching.rs` already; this file focuses on
|
||||
//! the *new* invariants Phase 4 adds:
|
||||
//!
|
||||
//! 1. When Relay A forwards a DirectCallOffer, its local registry
|
||||
//! stashes caller_reflexive_addr and leaves peer_relay_fp
|
||||
//! unset (broadcast, answer-side will identify itself).
|
||||
//! 2. When Relay B's cross-relay dispatcher receives the forward,
|
||||
//! its local registry stores the call with
|
||||
//! peer_relay_fp = Some(relay_a_tls_fp).
|
||||
//! 3. When Relay B processes the local callee's answer, it sees
|
||||
//! peer_relay_fp.is_some() and MUST NOT deliver the answer via
|
||||
//! local signal_hub — instead it routes through federation.
|
||||
//! 4. When Relay A receives the forwarded answer via its
|
||||
//! cross-relay dispatcher, it stashes callee_reflexive_addr
|
||||
//! and emits a CallSetup to its local caller with
|
||||
//! peer_direct_addr = callee_addr.
|
||||
//! 5. Final state: Alice's CallSetup carries Bob's reflex addr,
|
||||
//! Bob's CallSetup carries Alice's reflex addr — cross-wired
|
||||
//! through two relays + a federation link.
|
||||
|
||||
use wzp_proto::{CallAcceptMode, SignalMessage};
|
||||
use wzp_relay::call_registry::CallRegistry;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Simulated dispatch helpers — these reproduce the exact logic
|
||||
// in main.rs without the tokio + federation boilerplate.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
const RELAY_A_TLS_FP: &str = "relay-A-tls-fingerprint";
|
||||
const RELAY_B_TLS_FP: &str = "relay-B-tls-fingerprint";
|
||||
const ALICE_ADDR: &str = "192.0.2.1:4433";
|
||||
const BOB_ADDR: &str = "198.51.100.9:4433";
|
||||
const RELAY_A_ADDR: &str = "203.0.113.5:4433";
|
||||
const RELAY_B_ADDR: &str = "203.0.113.10:4433";
|
||||
|
||||
/// Helper that Alice's place_call sends.
|
||||
fn alice_offer(call_id: &str) -> SignalMessage {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: call_id.into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: Some(ALICE_ADDR.into()),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_build_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay A receives Alice's offer. Target Bob is not local.
|
||||
/// Relay A wraps + broadcasts over federation, stashes the call
|
||||
/// locally with peer_relay_fp = None (broadcast — answer-side
|
||||
/// identifies itself).
|
||||
fn relay_a_handle_offer(reg_a: &mut CallRegistry, offer: &SignalMessage) -> SignalMessage {
|
||||
match offer {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
target_fingerprint,
|
||||
call_id,
|
||||
caller_reflexive_addr,
|
||||
..
|
||||
} => {
|
||||
reg_a.create_call(
|
||||
call_id.clone(),
|
||||
caller_fingerprint.clone(),
|
||||
target_fingerprint.clone(),
|
||||
);
|
||||
reg_a.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
|
||||
// peer_relay_fp stays None — we don't know which peer
|
||||
// will respond yet.
|
||||
}
|
||||
_ => panic!("not an offer"),
|
||||
}
|
||||
// Build the federation envelope the main loop would
|
||||
// broadcast.
|
||||
SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(offer.clone()),
|
||||
origin_relay_fp: RELAY_A_TLS_FP.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay B receives a FederatedSignalForward(DirectCallOffer).
|
||||
/// This is the cross-relay dispatcher task code in main.rs —
|
||||
/// reproduced here for the test.
|
||||
fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMessage) {
|
||||
let (inner, origin_relay_fp) = match forward {
|
||||
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
|
||||
(inner.as_ref().clone(), origin_relay_fp.clone())
|
||||
}
|
||||
_ => panic!("not a forward"),
|
||||
};
|
||||
// Loop-prevention: drop self-sourced.
|
||||
assert_ne!(origin_relay_fp, RELAY_B_TLS_FP);
|
||||
|
||||
let SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
target_fingerprint,
|
||||
call_id,
|
||||
caller_reflexive_addr,
|
||||
..
|
||||
} = inner
|
||||
else {
|
||||
panic!("inner was not DirectCallOffer");
|
||||
};
|
||||
|
||||
// Simulated: target is local to B (Bob is registered here).
|
||||
reg_b.create_call(
|
||||
call_id.clone(),
|
||||
caller_fingerprint,
|
||||
target_fingerprint,
|
||||
);
|
||||
reg_b.set_caller_reflexive_addr(&call_id, caller_reflexive_addr);
|
||||
reg_b.set_peer_relay_fp(&call_id, Some(origin_relay_fp));
|
||||
}
|
||||
|
||||
/// Bob's answer — AcceptTrusted with his reflex addr.
|
||||
fn bob_answer(call_id: &str) -> SignalMessage {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id: call_id.into(),
|
||||
accept_mode: CallAcceptMode::AcceptTrusted,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: Some(BOB_ADDR.into()),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_build_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay B handles the LOCAL callee's answer. If peer_relay_fp
|
||||
/// is Some, wrap the answer in a FederatedSignalForward + emit the
|
||||
/// local CallSetup to Bob. Returns the (forward_envelope,
|
||||
/// bob_call_setup) pair.
|
||||
fn relay_b_handle_local_answer(
|
||||
reg_b: &mut CallRegistry,
|
||||
answer: &SignalMessage,
|
||||
) -> (SignalMessage, SignalMessage) {
|
||||
let (call_id, mode, callee_addr) = match answer {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode,
|
||||
callee_reflexive_addr,
|
||||
..
|
||||
} => (call_id.clone(), *accept_mode, callee_reflexive_addr.clone()),
|
||||
_ => panic!(),
|
||||
};
|
||||
// Stash callee addr + activate.
|
||||
reg_b.set_active(&call_id, mode, format!("call-{call_id}"));
|
||||
reg_b.set_callee_reflexive_addr(&call_id, callee_addr);
|
||||
let call = reg_b.get(&call_id).unwrap();
|
||||
let caller_addr = call.caller_reflexive_addr.clone();
|
||||
let callee_addr = call.callee_reflexive_addr.clone();
|
||||
assert!(
|
||||
call.peer_relay_fp.is_some(),
|
||||
"Relay B must know this call is cross-relay"
|
||||
);
|
||||
|
||||
// Forward the answer back over federation.
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(answer.clone()),
|
||||
origin_relay_fp: RELAY_B_TLS_FP.into(),
|
||||
};
|
||||
|
||||
// Local CallSetup for Bob — peer_direct_addr = Alice's addr.
|
||||
let setup_for_bob = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: format!("call-{call_id}"),
|
||||
relay_addr: RELAY_B_ADDR.into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let _ = callee_addr;
|
||||
(forward, setup_for_bob)
|
||||
}
|
||||
|
||||
/// Relay A's cross-relay dispatcher receives the forwarded answer.
|
||||
/// It stashes the callee addr, forwards the raw answer to local
|
||||
/// Alice, and emits a CallSetup with peer_direct_addr = Bob's addr.
|
||||
fn relay_a_handle_forwarded_answer(
|
||||
reg_a: &mut CallRegistry,
|
||||
forward: &SignalMessage,
|
||||
) -> SignalMessage {
|
||||
let (inner, origin_relay_fp) = match forward {
|
||||
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
|
||||
(inner.as_ref().clone(), origin_relay_fp.clone())
|
||||
}
|
||||
_ => panic!("not a forward"),
|
||||
};
|
||||
assert_ne!(origin_relay_fp, RELAY_A_TLS_FP);
|
||||
|
||||
let SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode,
|
||||
callee_reflexive_addr,
|
||||
..
|
||||
} = inner
|
||||
else {
|
||||
panic!("inner was not DirectCallAnswer");
|
||||
};
|
||||
assert_eq!(accept_mode, CallAcceptMode::AcceptTrusted);
|
||||
|
||||
reg_a.set_active(&call_id, accept_mode, format!("call-{call_id}"));
|
||||
reg_a.set_callee_reflexive_addr(&call_id, callee_reflexive_addr.clone());
|
||||
|
||||
// Alice's CallSetup — peer_direct_addr = Bob's addr.
|
||||
SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: format!("call-{call_id}"),
|
||||
relay_addr: RELAY_A_ADDR.into(),
|
||||
peer_direct_addr: callee_reflexive_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cross_relay_offer_forwards_and_stashes_peer_relay_fp() {
|
||||
let mut reg_a = CallRegistry::new();
|
||||
let mut reg_b = CallRegistry::new();
|
||||
|
||||
let offer = alice_offer("c-xrelay-1");
|
||||
let forward = relay_a_handle_offer(&mut reg_a, &offer);
|
||||
|
||||
// Relay A's local view: call exists, caller addr stashed,
|
||||
// peer_relay_fp still None (broadcast — answer identifies the
|
||||
// peer).
|
||||
let call_a = reg_a.get("c-xrelay-1").unwrap();
|
||||
assert_eq!(call_a.caller_fingerprint, "alice");
|
||||
assert_eq!(call_a.callee_fingerprint, "bob");
|
||||
assert_eq!(call_a.caller_reflexive_addr.as_deref(), Some(ALICE_ADDR));
|
||||
assert!(call_a.peer_relay_fp.is_none());
|
||||
|
||||
// Relay B dispatches the forward: creates the call locally
|
||||
// and stashes peer_relay_fp = Relay A.
|
||||
relay_b_handle_forwarded_offer(&mut reg_b, &forward);
|
||||
let call_b = reg_b.get("c-xrelay-1").unwrap();
|
||||
assert_eq!(call_b.caller_fingerprint, "alice");
|
||||
assert_eq!(call_b.callee_fingerprint, "bob");
|
||||
assert_eq!(call_b.caller_reflexive_addr.as_deref(), Some(ALICE_ADDR));
|
||||
assert_eq!(call_b.peer_relay_fp.as_deref(), Some(RELAY_A_TLS_FP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_relay_answer_crosswires_peer_direct_addrs() {
|
||||
let mut reg_a = CallRegistry::new();
|
||||
let mut reg_b = CallRegistry::new();
|
||||
|
||||
// Full round trip: offer → forward → dispatch → answer →
|
||||
// forward back → dispatch → both CallSetups.
|
||||
let offer = alice_offer("c-xrelay-2");
|
||||
let offer_forward = relay_a_handle_offer(&mut reg_a, &offer);
|
||||
relay_b_handle_forwarded_offer(&mut reg_b, &offer_forward);
|
||||
|
||||
// Bob answers on Relay B.
|
||||
let answer = bob_answer("c-xrelay-2");
|
||||
let (answer_forward, setup_for_bob) =
|
||||
relay_b_handle_local_answer(&mut reg_b, &answer);
|
||||
|
||||
// Bob's CallSetup carries Alice's addr.
|
||||
match setup_for_bob {
|
||||
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some(ALICE_ADDR));
|
||||
assert_eq!(relay_addr, RELAY_B_ADDR);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// Alice's dispatcher receives the forwarded answer and builds
|
||||
// her CallSetup.
|
||||
let setup_for_alice = relay_a_handle_forwarded_answer(&mut reg_a, &answer_forward);
|
||||
match setup_for_alice {
|
||||
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some(BOB_ADDR));
|
||||
assert_eq!(relay_addr, RELAY_A_ADDR);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// Both registries agree on caller + callee reflex addrs after
|
||||
// the full round-trip.
|
||||
for reg in [®_a, ®_b] {
|
||||
let c = reg.get("c-xrelay-2").unwrap();
|
||||
assert_eq!(c.caller_reflexive_addr.as_deref(), Some(ALICE_ADDR));
|
||||
assert_eq!(c.callee_reflexive_addr.as_deref(), Some(BOB_ADDR));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_relay_loop_prevention_drops_self_sourced_forward() {
|
||||
// A FederatedSignalForward that circles back to the origin
|
||||
// relay should be dropped before it hits the call registry.
|
||||
let forward = SignalMessage::FederatedSignalForward {
|
||||
inner: Box::new(alice_offer("c-loop")),
|
||||
origin_relay_fp: RELAY_B_TLS_FP.into(),
|
||||
};
|
||||
// The dispatcher in main.rs calls this explicit check before
|
||||
// doing any work. Reproduce it inline.
|
||||
let origin = match &forward {
|
||||
SignalMessage::FederatedSignalForward { origin_relay_fp, .. } => origin_relay_fp.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// Relay B sees origin == its own fp → drop.
|
||||
assert_eq!(origin, RELAY_B_TLS_FP, "loop-prevention triggers on self-fp");
|
||||
}
|
||||
@@ -63,11 +63,11 @@ async fn handshake_succeeds() {
|
||||
accept_handshake(server_t.as_ref(), &callee_seed).await
|
||||
});
|
||||
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed)
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
|
||||
.await
|
||||
.expect("perform_handshake should succeed");
|
||||
|
||||
let (callee_session, chosen_profile) = callee_handle
|
||||
let (callee_session, chosen_profile, _caller_fp, _caller_alias) = callee_handle
|
||||
.await
|
||||
.expect("join callee task")
|
||||
.expect("accept_handshake should succeed");
|
||||
@@ -124,11 +124,11 @@ async fn handshake_verifies_identity() {
|
||||
accept_handshake(server_t.as_ref(), &callee_seed).await
|
||||
});
|
||||
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed)
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
|
||||
.await
|
||||
.expect("handshake must succeed even with different identities");
|
||||
|
||||
let (callee_session, _profile) = callee_handle
|
||||
let (callee_session, _profile, _caller_fp, _caller_alias) = callee_handle
|
||||
.await
|
||||
.expect("join")
|
||||
.expect("accept_handshake must succeed");
|
||||
@@ -183,7 +183,7 @@ async fn auth_then_handshake() {
|
||||
};
|
||||
|
||||
// 2. Run the cryptographic handshake
|
||||
let (session, profile) = accept_handshake(server_t.as_ref(), &callee_seed)
|
||||
let (session, profile, _caller_fp, _caller_alias) = accept_handshake(server_t.as_ref(), &callee_seed)
|
||||
.await
|
||||
.expect("accept_handshake after auth");
|
||||
|
||||
@@ -199,7 +199,7 @@ async fn auth_then_handshake() {
|
||||
.await
|
||||
.expect("send AuthToken");
|
||||
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed)
|
||||
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
|
||||
.await
|
||||
.expect("perform_handshake after auth");
|
||||
|
||||
@@ -270,6 +270,7 @@ async fn handshake_rejects_bad_signature() {
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
|
||||
client_transport
|
||||
|
||||
294
crates/wzp-relay/tests/hole_punching.rs
Normal file
294
crates/wzp-relay/tests/hole_punching.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! Phase 3 integration tests for hole-punching advertising
|
||||
//! (PRD: .taskmaster/docs/prd_hole_punching.txt).
|
||||
//!
|
||||
//! These verify the end-to-end protocol cross-wiring:
|
||||
//! caller (places offer with caller_reflexive_addr=A)
|
||||
//! → relay (stashes A in registry)
|
||||
//! → callee (reads A off the forwarded offer)
|
||||
//! callee (sends AcceptTrusted answer with callee_reflexive_addr=B)
|
||||
//! → relay (stashes B, emits CallSetup to both parties)
|
||||
//! → caller receives CallSetup.peer_direct_addr = B
|
||||
//! → callee receives CallSetup.peer_direct_addr = A
|
||||
//!
|
||||
//! The actual QUIC hole-punch race is a Phase 3.5 follow-up.
|
||||
//! These tests only cover the signal-plane plumbing — that the
|
||||
//! addrs make it from each peer's offer/answer through the relay
|
||||
//! cross-wiring back out in CallSetup with the peer's addr.
|
||||
//!
|
||||
//! We drive the call registry + a minimal routing function
|
||||
//! directly instead of spinning up a full relay process — easier
|
||||
//! to reason about, no real network, and what we actually want to
|
||||
//! test is the cross-wiring logic, not the whole signal stack.
|
||||
|
||||
use wzp_proto::{CallAcceptMode, SignalMessage};
|
||||
use wzp_relay::call_registry::CallRegistry;
|
||||
|
||||
/// Helper: simulate the relay's handling of a DirectCallOffer. In
|
||||
/// `wzp-relay/src/main.rs` this is the match arm that creates the
|
||||
/// call in the registry and stashes the caller's reflex addr.
|
||||
fn handle_offer(reg: &mut CallRegistry, offer: &SignalMessage) -> String {
|
||||
match offer {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint,
|
||||
target_fingerprint,
|
||||
call_id,
|
||||
caller_reflexive_addr,
|
||||
..
|
||||
} => {
|
||||
reg.create_call(
|
||||
call_id.clone(),
|
||||
caller_fingerprint.clone(),
|
||||
target_fingerprint.clone(),
|
||||
);
|
||||
reg.set_caller_reflexive_addr(call_id, caller_reflexive_addr.clone());
|
||||
call_id.clone()
|
||||
}
|
||||
_ => panic!("not an offer"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: simulate the relay's handling of a DirectCallAnswer +
|
||||
/// the subsequent CallSetup emission. Returns the two CallSetup
|
||||
/// messages the relay would push: (for_caller, for_callee).
|
||||
fn handle_answer_and_build_setups(
|
||||
reg: &mut CallRegistry,
|
||||
answer: &SignalMessage,
|
||||
) -> (SignalMessage, SignalMessage) {
|
||||
let (call_id, mode, callee_addr) = match answer {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id,
|
||||
accept_mode,
|
||||
callee_reflexive_addr,
|
||||
..
|
||||
} => (call_id.clone(), *accept_mode, callee_reflexive_addr.clone()),
|
||||
_ => panic!("not an answer"),
|
||||
};
|
||||
|
||||
reg.set_callee_reflexive_addr(&call_id, callee_addr);
|
||||
let room = format!("call-{call_id}");
|
||||
reg.set_active(&call_id, mode, room.clone());
|
||||
|
||||
let (caller_addr, callee_addr) = {
|
||||
let c = reg.get(&call_id).unwrap();
|
||||
(
|
||||
c.caller_reflexive_addr.clone(),
|
||||
c.callee_reflexive_addr.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let setup_for_caller = SignalMessage::CallSetup {
|
||||
call_id: call_id.clone(),
|
||||
room: room.clone(),
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: callee_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
let setup_for_callee = SignalMessage::CallSetup {
|
||||
call_id,
|
||||
room,
|
||||
relay_addr: "203.0.113.5:4433".into(),
|
||||
peer_direct_addr: caller_addr,
|
||||
peer_local_addrs: Vec::new(),
|
||||
};
|
||||
(setup_for_caller, setup_for_callee)
|
||||
}
|
||||
|
||||
fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage {
|
||||
SignalMessage::DirectCallOffer {
|
||||
caller_fingerprint: "alice".into(),
|
||||
caller_alias: None,
|
||||
target_fingerprint: "bob".into(),
|
||||
call_id: call_id.into(),
|
||||
identity_pub: [0; 32],
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
|
||||
caller_local_addrs: Vec::new(),
|
||||
caller_build_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mk_answer(
|
||||
call_id: &str,
|
||||
mode: CallAcceptMode,
|
||||
callee_reflexive_addr: Option<&str>,
|
||||
) -> SignalMessage {
|
||||
SignalMessage::DirectCallAnswer {
|
||||
call_id: call_id.into(),
|
||||
accept_mode: mode,
|
||||
identity_pub: None,
|
||||
ephemeral_pub: None,
|
||||
signature: None,
|
||||
chosen_profile: None,
|
||||
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
|
||||
callee_local_addrs: Vec::new(),
|
||||
callee_build_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: both peers advertise — CallSetup cross-wires correctly
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn both_peers_advertise_reflex_addrs_cross_wire_in_setup() {
|
||||
let mut reg = CallRegistry::new();
|
||||
|
||||
let caller_addr = "192.0.2.1:4433";
|
||||
let callee_addr = "198.51.100.9:4433";
|
||||
|
||||
let offer = mk_offer("c1", Some(caller_addr));
|
||||
let call_id = handle_offer(&mut reg, &offer);
|
||||
assert_eq!(call_id, "c1");
|
||||
assert_eq!(
|
||||
reg.get("c1").unwrap().caller_reflexive_addr.as_deref(),
|
||||
Some(caller_addr)
|
||||
);
|
||||
|
||||
let answer = mk_answer("c1", CallAcceptMode::AcceptTrusted, Some(callee_addr));
|
||||
let (setup_caller, setup_callee) =
|
||||
handle_answer_and_build_setups(&mut reg, &answer);
|
||||
|
||||
// The CALLER's setup should carry the CALLEE's addr as peer_direct_addr.
|
||||
match setup_caller {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert_eq!(
|
||||
peer_direct_addr.as_deref(),
|
||||
Some(callee_addr),
|
||||
"caller's CallSetup must contain callee's addr"
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// The CALLEE's setup should carry the CALLER's addr.
|
||||
match setup_callee {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert_eq!(
|
||||
peer_direct_addr.as_deref(),
|
||||
Some(caller_addr),
|
||||
"callee's CallSetup must contain caller's addr"
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: callee uses AcceptGeneric (privacy) — no addr leaks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn privacy_mode_answer_omits_callee_addr_from_setup() {
|
||||
let mut reg = CallRegistry::new();
|
||||
let caller_addr = "192.0.2.1:4433";
|
||||
|
||||
handle_offer(&mut reg, &mk_offer("c2", Some(caller_addr)));
|
||||
|
||||
// AcceptGeneric explicitly passes None for callee_reflexive_addr —
|
||||
// the whole point is to hide the callee's IP from the caller.
|
||||
let answer = mk_answer("c2", CallAcceptMode::AcceptGeneric, None);
|
||||
let (setup_caller, setup_callee) =
|
||||
handle_answer_and_build_setups(&mut reg, &answer);
|
||||
|
||||
// CALLER should see peer_direct_addr = None (privacy preserved).
|
||||
match setup_caller {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert!(
|
||||
peer_direct_addr.is_none(),
|
||||
"privacy mode must not leak callee addr to caller"
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// CALLEE still gets the caller's addr — only the callee opted for
|
||||
// privacy, the caller already volunteered its addr in the offer.
|
||||
match setup_callee {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert_eq!(
|
||||
peer_direct_addr.as_deref(),
|
||||
Some(caller_addr),
|
||||
"callee's CallSetup should still carry caller's volunteered addr"
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: old caller (no addr) + new callee — relay path only
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pre_phase3_caller_leaves_both_setups_relay_only() {
|
||||
let mut reg = CallRegistry::new();
|
||||
|
||||
// Pre-Phase-3 client doesn't know about caller_reflexive_addr
|
||||
// so the field is None.
|
||||
handle_offer(&mut reg, &mk_offer("c3", None));
|
||||
|
||||
// New callee advertises its addr — doesn't matter because
|
||||
// without caller_reflexive_addr the caller has nothing to
|
||||
// attempt a direct handshake to, so the cross-wiring should
|
||||
// still leave the caller's CallSetup without peer_direct_addr.
|
||||
let answer = mk_answer(
|
||||
"c3",
|
||||
CallAcceptMode::AcceptTrusted,
|
||||
Some("198.51.100.9:4433"),
|
||||
);
|
||||
let (setup_caller, setup_callee) =
|
||||
handle_answer_and_build_setups(&mut reg, &answer);
|
||||
|
||||
match setup_caller {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
// Phase 3 relay behavior: we always inject whatever
|
||||
// addrs are in the registry, regardless of who
|
||||
// advertised. The caller here gets the callee's addr
|
||||
// because the callee did advertise.
|
||||
assert_eq!(peer_direct_addr.as_deref(), Some("198.51.100.9:4433"));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
|
||||
// The callee's setup has no caller addr (pre-Phase-3 offer).
|
||||
match setup_callee {
|
||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
||||
assert!(
|
||||
peer_direct_addr.is_none(),
|
||||
"callee should see no caller addr when offer was pre-Phase-3"
|
||||
);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 4: neither side advertises — both CallSetups fall back cleanly
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn neither_peer_advertises_both_setups_are_relay_only() {
|
||||
let mut reg = CallRegistry::new();
|
||||
|
||||
handle_offer(&mut reg, &mk_offer("c4", None));
|
||||
let answer = mk_answer("c4", CallAcceptMode::AcceptTrusted, None);
|
||||
let (setup_caller, setup_callee) =
|
||||
handle_answer_and_build_setups(&mut reg, &answer);
|
||||
|
||||
for (label, setup) in [("caller", setup_caller), ("callee", setup_callee)] {
|
||||
match setup {
|
||||
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => {
|
||||
assert!(
|
||||
peer_direct_addr.is_none(),
|
||||
"{label}'s CallSetup must have no peer_direct_addr"
|
||||
);
|
||||
// Relay addr is always filled — that's the fallback
|
||||
// path and the existing behavior.
|
||||
assert!(!relay_addr.is_empty(), "{label} relay_addr must be set");
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
229
crates/wzp-relay/tests/multi_reflect.rs
Normal file
229
crates/wzp-relay/tests/multi_reflect.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
//! Phase 2 integration tests for multi-relay NAT reflection
|
||||
//! (PRD: .taskmaster/docs/prd_multi_relay_reflect.txt).
|
||||
//!
|
||||
//! These spin up one or two mock relays that implement the full
|
||||
//! pre-reflect dance — RegisterPresence → RegisterPresenceAck →
|
||||
//! Reflect → ReflectResponse — which is what the transient
|
||||
//! probe helper in `wzp_client::reflect::probe_reflect_addr` does
|
||||
//! against a real relay.
|
||||
//!
|
||||
//! Test matrix:
|
||||
//! 1. `probe_reflect_addr_happy_path`
|
||||
//! — single mock relay, assert the probe helper returns the
|
||||
//! observed addr as 127.0.0.1:<client ephemeral port>
|
||||
//! 2. `detect_nat_type_two_loopback_relays_is_cone`
|
||||
//! — two mock relays, one client; loopback single-host means
|
||||
//! every probe sees the same (127.0.0.1, same_port) so the
|
||||
//! classifier returns `Cone` + a consensus addr
|
||||
//! 3. `detect_nat_type_dead_relay_is_unknown`
|
||||
//! — one alive relay + one dead address; aggregator returns
|
||||
//! `Unknown` with a non-empty `error` field on the failed
|
||||
//! probe
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_client::reflect::{detect_nat_type, probe_reflect_addr, NatType};
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::{create_endpoint, server_config, QuinnTransport};
|
||||
|
||||
/// Minimal mock relay that loops accepting connections, handles
|
||||
/// RegisterPresence + Reflect, and responds correctly. Mirrors the
|
||||
/// two match arms from `wzp-relay/src/main.rs` that matter here.
|
||||
///
|
||||
/// Each accepted connection gets its own inner task so multiple
|
||||
/// simultaneous probes work.
|
||||
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 endpoint = create_endpoint(bind, Some(sc)).expect("server endpoint");
|
||||
let listen_addr = endpoint.local_addr().expect("local_addr");
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
// Accept the next incoming connection. `wzp_transport::accept`
|
||||
// returns the established `quinn::Connection`.
|
||||
let conn = match wzp_transport::accept(&endpoint).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => break, // endpoint closed
|
||||
};
|
||||
let observed_addr = conn.remote_address();
|
||||
let transport = Arc::new(QuinnTransport::new(conn));
|
||||
|
||||
// Per-connection handler. Keep servicing messages until
|
||||
// the peer closes so one probe connection can do
|
||||
// RegisterPresence → Ack → Reflect → Response without
|
||||
// racing other incoming connections.
|
||||
let t = transport;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match t.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RegisterPresence { .. })) => {
|
||||
let _ = t
|
||||
.send_signal(&SignalMessage::RegisterPresenceAck {
|
||||
success: true,
|
||||
error: None,
|
||||
relay_build: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(Some(SignalMessage::Reflect)) => {
|
||||
let _ = t
|
||||
.send_signal(&SignalMessage::ReflectResponse {
|
||||
observed_addr: observed_addr.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(Some(_other)) => { /* ignore */ }
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(listen_addr, handle)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: probe_reflect_addr against a single mock relay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn probe_reflect_addr_happy_path() {
|
||||
let (relay_addr, _relay_handle) = spawn_mock_relay().await;
|
||||
|
||||
let (observed, latency_ms) = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
probe_reflect_addr(relay_addr, 2000, None),
|
||||
)
|
||||
.await
|
||||
.expect("probe must complete within 3s")
|
||||
.expect("probe must succeed");
|
||||
|
||||
assert_eq!(
|
||||
observed.ip().to_string(),
|
||||
"127.0.0.1",
|
||||
"loopback test should see 127.0.0.1"
|
||||
);
|
||||
assert_ne!(observed.port(), 0, "observed port must be non-zero");
|
||||
// Latency on same host is dominated by the handshake — generously
|
||||
// allow up to 2s (the timeout) rather than picking a tight number
|
||||
// that would be flaky on busy CI runners.
|
||||
assert!(latency_ms < 2000, "latency {latency_ms}ms too high");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: two loopback relays → probes succeed, classification is Unknown
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// With the private-IP filter added in the NAT classifier, loopback
|
||||
// reflex addrs (127.0.0.1) are dropped before classification —
|
||||
// they can't possibly indicate public-internet NAT state. So the
|
||||
// test now asserts:
|
||||
// - both probes succeed end-to-end (wire plumbing works)
|
||||
// - both return 127.0.0.1 (same-host is visible)
|
||||
// - the aggregated verdict is Unknown (no public probes)
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn detect_nat_type_two_loopback_relays_probes_work_but_classify_unknown() {
|
||||
let (addr_a, _h_a) = spawn_mock_relay().await;
|
||||
let (addr_b, _h_b) = spawn_mock_relay().await;
|
||||
|
||||
let detection = detect_nat_type(
|
||||
vec![
|
||||
("RelayA".into(), addr_a),
|
||||
("RelayB".into(), addr_b),
|
||||
],
|
||||
2000,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(detection.probes.len(), 2);
|
||||
for p in &detection.probes {
|
||||
assert!(
|
||||
p.observed_addr.is_some(),
|
||||
"probe {:?} failed: {:?}",
|
||||
p.relay_name,
|
||||
p.error
|
||||
);
|
||||
}
|
||||
let observed_ips: Vec<String> = detection
|
||||
.probes
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.observed_addr
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<SocketAddr>().ok())
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(observed_ips[0], "127.0.0.1");
|
||||
assert_eq!(observed_ips[1], "127.0.0.1");
|
||||
|
||||
// Classification: loopback probes are filtered out of the
|
||||
// public-NAT classifier, so with 0 public probes the result
|
||||
// is Unknown.
|
||||
assert_eq!(
|
||||
detection.nat_type,
|
||||
NatType::Unknown,
|
||||
"loopback-only probes must not contribute to public NAT classification"
|
||||
);
|
||||
assert!(detection.consensus_addr.is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: one alive relay + one dead address → Unknown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn detect_nat_type_dead_relay_is_unknown() {
|
||||
let (alive_addr, _alive_handle) = spawn_mock_relay().await;
|
||||
|
||||
// Dead relay: a port that nothing is listening on. OS will drop
|
||||
// the packets, the probe should time out within the 600ms budget
|
||||
// we give it. Pick a port unlikely to be in use — port 1 on
|
||||
// loopback works on every OS I care about and fails fast.
|
||||
let dead_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
|
||||
let detection = detect_nat_type(
|
||||
vec![
|
||||
("Alive".into(), alive_addr),
|
||||
("Dead".into(), dead_addr),
|
||||
],
|
||||
600, // tight timeout so the dead probe fails fast
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(detection.probes.len(), 2);
|
||||
|
||||
// Find the alive and dead probes by name (order of JoinSet
|
||||
// completions is not guaranteed).
|
||||
let alive = detection.probes.iter().find(|p| p.relay_name == "Alive").unwrap();
|
||||
let dead = detection.probes.iter().find(|p| p.relay_name == "Dead").unwrap();
|
||||
|
||||
assert!(
|
||||
alive.observed_addr.is_some(),
|
||||
"alive probe must succeed: {:?}",
|
||||
alive.error
|
||||
);
|
||||
assert!(
|
||||
dead.observed_addr.is_none(),
|
||||
"dead probe must fail, got addr {:?}",
|
||||
dead.observed_addr
|
||||
);
|
||||
assert!(
|
||||
dead.error.is_some(),
|
||||
"dead probe must surface an error string"
|
||||
);
|
||||
|
||||
// With only 1 successful probe, the classifier returns Unknown.
|
||||
assert_eq!(detection.nat_type, NatType::Unknown);
|
||||
assert!(detection.consensus_addr.is_none());
|
||||
}
|
||||
318
crates/wzp-relay/tests/reflect.rs
Normal file
318
crates/wzp-relay/tests/reflect.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! Integration tests for the "STUN for QUIC" reflect protocol
|
||||
//! (PRD: .taskmaster/docs/prd_reflect_over_quic.txt, Phase 1).
|
||||
//!
|
||||
//! We don't spin up the full relay binary — instead we exercise the
|
||||
//! same wire-level request/response dance with a mock relay loop
|
||||
//! that implements exactly the match arm added to
|
||||
//! `wzp-relay/src/main.rs`. This isolates the protocol test from the
|
||||
//! rest of the relay state (rooms, federation, call registry, ...).
|
||||
//!
|
||||
//! Three test cases:
|
||||
//! 1. `reflect_happy_path` — client sends `Reflect`, mock relay
|
||||
//! replies with `ReflectResponse { observed_addr }`, client
|
||||
//! parses it back to a `SocketAddr` and confirms the IP is
|
||||
//! `127.0.0.1` and the port matches its own bound port.
|
||||
//! 2. `reflect_two_clients_distinct_ports` — two simultaneous
|
||||
//! client connections on different ephemeral ports get back
|
||||
//! different reflected ports, proving the relay uses
|
||||
//! per-connection `remote_address` rather than a global.
|
||||
//! 3. `reflect_old_relay_times_out` — mock relay that *doesn't*
|
||||
//! handle `Reflect`; client side times out in the expected
|
||||
//! window and does not hang.
|
||||
//!
|
||||
//! The third test uses a `tokio::time::timeout` wrapper directly
|
||||
//! (the client-side `request_reflect` helper lives in
|
||||
//! `desktop/src-tauri/src/lib.rs` which isn't a library we can
|
||||
//! depend on from here, so we reproduce the timeout semantics
|
||||
//! inline).
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport};
|
||||
|
||||
/// Spawn a minimal mock relay that loops over `recv_signal`,
|
||||
/// matches on `Reflect`, and responds with `ReflectResponse` using
|
||||
/// the remote_address observed for this connection. Mirrors the
|
||||
/// match arm in `crates/wzp-relay/src/main.rs`.
|
||||
async fn spawn_mock_relay_with_reflect(
|
||||
server_transport: Arc<QuinnTransport>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
// Observed remote address at the time the connection was
|
||||
// accepted. Stable for the life of the connection under quinn's
|
||||
// normal operation. This is exactly what the real relay does.
|
||||
let observed = server_transport.connection().remote_address();
|
||||
loop {
|
||||
match server_transport.recv_signal().await {
|
||||
Ok(Some(SignalMessage::Reflect)) => {
|
||||
let resp = SignalMessage::ReflectResponse {
|
||||
observed_addr: observed.to_string(),
|
||||
};
|
||||
// If the send fails the client has gone; just exit.
|
||||
if server_transport.send_signal(&resp).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(_other)) => {
|
||||
// Ignore anything else — not relevant to this test.
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_e) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a mock relay that intentionally DOES NOT handle Reflect.
|
||||
/// Models a pre-Phase-1 relay — it keeps reading signal messages and
|
||||
/// logs them to stderr, but never produces a `ReflectResponse`.
|
||||
async fn spawn_mock_relay_without_reflect(
|
||||
server_transport: Arc<QuinnTransport>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match server_transport.recv_signal().await {
|
||||
Ok(Some(_msg)) => {
|
||||
// Deliberately do nothing. Old relay.
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an in-process QUIC client/server pair on loopback and
|
||||
/// return (client_transport, server_transport, endpoints). The
|
||||
/// endpoints tuple must be kept alive for the test duration.
|
||||
///
|
||||
/// `client_port_hint` of 0 means "let OS pick". Pass an explicit
|
||||
/// port to pin the client's source port (useful for the
|
||||
/// distinct-ports test).
|
||||
async fn connected_pair_with_port(
|
||||
_client_port_hint: u16,
|
||||
) -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let (sc, _cert_der) = server_config();
|
||||
let server_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
||||
let server_ep = create_endpoint(server_addr, Some(sc)).expect("server endpoint");
|
||||
let server_listen = server_ep.local_addr().expect("server local addr");
|
||||
|
||||
// Always bind the client to an ephemeral port — we'll read back
|
||||
// the actual assigned port via `local_addr()` in the assertions.
|
||||
let client_bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
||||
let client_ep = create_endpoint(client_bind, None).expect("client endpoint");
|
||||
|
||||
let server_ep_clone = server_ep.clone();
|
||||
let accept_fut = tokio::spawn(async move {
|
||||
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept");
|
||||
Arc::new(QuinnTransport::new(conn))
|
||||
});
|
||||
|
||||
let client_conn =
|
||||
wzp_transport::connect(&client_ep, server_listen, "localhost", client_config())
|
||||
.await
|
||||
.expect("connect");
|
||||
let client_transport = Arc::new(QuinnTransport::new(client_conn));
|
||||
let server_transport = accept_fut.await.expect("join accept task");
|
||||
|
||||
(client_transport, server_transport, (server_ep, client_ep))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: happy path — client learns its own port via Reflect
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reflect_happy_path() {
|
||||
let (client_transport, server_transport, (_server_ep, client_ep)) =
|
||||
connected_pair_with_port(0).await;
|
||||
|
||||
// Grab the client's actual bound port so we can cross-check
|
||||
// against the reflected response.
|
||||
let client_port = client_ep
|
||||
.local_addr()
|
||||
.expect("client local addr")
|
||||
.port();
|
||||
assert_ne!(client_port, 0, "client must have a real bound port");
|
||||
|
||||
// Start the mock relay's reflect handler.
|
||||
let _relay_handle = spawn_mock_relay_with_reflect(Arc::clone(&server_transport)).await;
|
||||
|
||||
// Client sends Reflect and awaits the response. The real
|
||||
// request_reflect helper in desktop/src-tauri/src/lib.rs uses a
|
||||
// oneshot channel driven off the spawned recv loop; here we just
|
||||
// do it inline because there's no spawned loop yet in this test
|
||||
// — this isolates the wire protocol from the client-side state
|
||||
// machine.
|
||||
client_transport
|
||||
.send_signal(&SignalMessage::Reflect)
|
||||
.await
|
||||
.expect("send Reflect");
|
||||
|
||||
let resp = tokio::time::timeout(Duration::from_secs(2), client_transport.recv_signal())
|
||||
.await
|
||||
.expect("reflect response should arrive within 2s")
|
||||
.expect("recv_signal ok")
|
||||
.expect("some message");
|
||||
|
||||
let observed_addr = match resp {
|
||||
SignalMessage::ReflectResponse { observed_addr } => observed_addr,
|
||||
other => panic!("expected ReflectResponse, got {:?}", std::mem::discriminant(&other)),
|
||||
};
|
||||
|
||||
let parsed: SocketAddr = observed_addr
|
||||
.parse()
|
||||
.expect("ReflectResponse.observed_addr must parse as SocketAddr");
|
||||
|
||||
// The relay should see the client on 127.0.0.1 (loopback in the
|
||||
// test harness) and on the client's bound ephemeral port.
|
||||
assert_eq!(parsed.ip().to_string(), "127.0.0.1");
|
||||
assert_eq!(
|
||||
parsed.port(),
|
||||
client_port,
|
||||
"reflected port must match the client's local_addr port"
|
||||
);
|
||||
|
||||
drop(client_transport);
|
||||
drop(server_transport);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: two clients get DIFFERENT reflected ports
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn reflect_two_clients_distinct_ports() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// Shared server: one endpoint, two incoming accepts.
|
||||
let (sc, _cert_der) = server_config();
|
||||
let server_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
||||
let server_ep = create_endpoint(server_addr, Some(sc)).expect("server endpoint");
|
||||
let server_listen = server_ep.local_addr().expect("server local addr");
|
||||
|
||||
// Accept two clients in parallel.
|
||||
let server_ep_a = server_ep.clone();
|
||||
let accept_a = tokio::spawn(async move {
|
||||
let conn = wzp_transport::accept(&server_ep_a).await.expect("accept A");
|
||||
Arc::new(QuinnTransport::new(conn))
|
||||
});
|
||||
let server_ep_b = server_ep.clone();
|
||||
let accept_b = tokio::spawn(async move {
|
||||
let conn = wzp_transport::accept(&server_ep_b).await.expect("accept B");
|
||||
Arc::new(QuinnTransport::new(conn))
|
||||
});
|
||||
|
||||
// Client A
|
||||
let client_ep_a = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep A");
|
||||
let conn_a =
|
||||
wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config())
|
||||
.await
|
||||
.expect("connect A");
|
||||
let client_a = Arc::new(QuinnTransport::new(conn_a));
|
||||
let port_a = client_ep_a.local_addr().unwrap().port();
|
||||
|
||||
// Client B
|
||||
let client_ep_b = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep B");
|
||||
let conn_b =
|
||||
wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config())
|
||||
.await
|
||||
.expect("connect B");
|
||||
let client_b = Arc::new(QuinnTransport::new(conn_b));
|
||||
let port_b = client_ep_b.local_addr().unwrap().port();
|
||||
|
||||
assert_ne!(
|
||||
port_a, port_b,
|
||||
"preconditions: OS must assign two clients different ephemeral ports"
|
||||
);
|
||||
|
||||
let server_a = accept_a.await.expect("join A");
|
||||
let server_b = accept_b.await.expect("join B");
|
||||
|
||||
// Spawn a reflect handler for each server-side transport.
|
||||
let _relay_a = spawn_mock_relay_with_reflect(Arc::clone(&server_a)).await;
|
||||
let _relay_b = spawn_mock_relay_with_reflect(Arc::clone(&server_b)).await;
|
||||
|
||||
// Each client requests reflect concurrently.
|
||||
let reflect_for = |t: Arc<QuinnTransport>| async move {
|
||||
t.send_signal(&SignalMessage::Reflect).await.expect("send");
|
||||
let resp = tokio::time::timeout(Duration::from_secs(2), t.recv_signal())
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("ok")
|
||||
.expect("some");
|
||||
match resp {
|
||||
SignalMessage::ReflectResponse { observed_addr } => observed_addr,
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
};
|
||||
|
||||
let (addr_a, addr_b) = tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone()));
|
||||
|
||||
let parsed_a: SocketAddr = addr_a.parse().unwrap();
|
||||
let parsed_b: SocketAddr = addr_b.parse().unwrap();
|
||||
|
||||
assert_eq!(parsed_a.port(), port_a, "client A's reflected port");
|
||||
assert_eq!(parsed_b.port(), port_b, "client B's reflected port");
|
||||
assert_ne!(
|
||||
parsed_a.port(),
|
||||
parsed_b.port(),
|
||||
"each client must see its own port, not a shared one"
|
||||
);
|
||||
|
||||
drop(client_a);
|
||||
drop(client_b);
|
||||
drop(server_a);
|
||||
drop(server_b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: old relay never answers — client times out cleanly
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reflect_old_relay_times_out() {
|
||||
let (client_transport, server_transport, _endpoints) =
|
||||
connected_pair_with_port(0).await;
|
||||
|
||||
// Mock relay that ignores Reflect — simulates a pre-Phase-1 build.
|
||||
let _relay_handle =
|
||||
spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
|
||||
|
||||
client_transport
|
||||
.send_signal(&SignalMessage::Reflect)
|
||||
.await
|
||||
.expect("send Reflect");
|
||||
|
||||
// 1100ms ceiling matches the 1s timeout baked into
|
||||
// get_reflected_address plus a tiny bit of slack. If this
|
||||
// regression ever fires it probably means recv_signal blocked
|
||||
// longer than expected and the Tauri command would hang the UI.
|
||||
let start = std::time::Instant::now();
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_millis(1100), client_transport.recv_signal()).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"recv_signal must time out when the relay ignores Reflect"
|
||||
);
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(1000),
|
||||
"timeout fired too early ({:?})",
|
||||
elapsed
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1200),
|
||||
"timeout fired too late ({:?}), client would feel unresponsive",
|
||||
elapsed
|
||||
);
|
||||
|
||||
drop(client_transport);
|
||||
drop(server_transport);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
socket2 = { workspace = true }
|
||||
rcgen = "0.13"
|
||||
ed25519-dalek = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
|
||||
@@ -123,7 +123,6 @@ fn transport_config() -> quinn::TransportConfig {
|
||||
config.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
|
||||
// Enable DATAGRAM extension for unreliable media packets.
|
||||
// Allow datagrams up to 1200 bytes (conservative for lossy links).
|
||||
config.datagram_receive_buffer_size(Some(65536));
|
||||
|
||||
// Conservative flow control for bandwidth-constrained links
|
||||
@@ -134,6 +133,26 @@ fn transport_config() -> quinn::TransportConfig {
|
||||
// Aggressive initial RTT estimate for high-latency links
|
||||
config.initial_rtt(Duration::from_millis(300));
|
||||
|
||||
// PMTUD (Path MTU Discovery) — quinn 0.11 enables this by default but
|
||||
// with conservative bounds (initial 1200, upper 1452). We keep the safe
|
||||
// initial_mtu of 1200 so the first packets always get through, but raise
|
||||
// upper_bound so the binary search can discover larger MTUs on paths that
|
||||
// support them. Typical results:
|
||||
// - Ethernet/fiber: discovers ~1452 (Ethernet MTU minus IP/UDP/QUIC)
|
||||
// - WireGuard/VPN: discovers ~1380-1420
|
||||
// - Starlink: discovers ~1400-1452
|
||||
// - Cellular: stays at 1200-1300
|
||||
// Black hole detection automatically falls back to 1200 if probes fail.
|
||||
// This matters for future video frames which can be 1-50 KB and benefit
|
||||
// from fewer application-layer fragments per frame.
|
||||
let mut mtu_config = quinn::MtuDiscoveryConfig::default();
|
||||
mtu_config
|
||||
.upper_bound(1452)
|
||||
.interval(Duration::from_secs(300)) // re-probe every 5 min
|
||||
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
|
||||
config.mtu_discovery_config(Some(mtu_config));
|
||||
config.initial_mtu(1200); // safe starting point
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,71 @@ pub async fn connect(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Create an IPv6-only QUIC endpoint with `IPV6_V6ONLY=1`.
|
||||
///
|
||||
/// Tries `[::]:preferred_port` first (same port as the IPv4 signal
|
||||
/// endpoint — allowed on Linux/Android when the AFs differ and
|
||||
/// V6ONLY is set). Falls back to `[::]:0` (OS-assigned) if the
|
||||
/// preferred port is already taken.
|
||||
///
|
||||
/// Must be called from within a tokio runtime (quinn needs the
|
||||
/// async runtime handle for its I/O driver).
|
||||
pub fn create_ipv6_endpoint(
|
||||
preferred_port: u16,
|
||||
server_config: Option<quinn::ServerConfig>,
|
||||
) -> Result<quinn::Endpoint, TransportError> {
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{Ipv6Addr, SocketAddrV6};
|
||||
|
||||
let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))
|
||||
.map_err(|e| TransportError::Internal(format!("ipv6 socket: {e}")))?;
|
||||
|
||||
// Critical: IPv6-only so this socket never intercepts IPv4.
|
||||
// On Android some kernels default to V6ONLY=1 anyway, but we
|
||||
// set it explicitly for cross-platform consistency.
|
||||
sock.set_only_v6(true)
|
||||
.map_err(|e| TransportError::Internal(format!("set_only_v6: {e}")))?;
|
||||
|
||||
sock.set_reuse_address(true)
|
||||
.map_err(|e| TransportError::Internal(format!("set_reuse_address: {e}")))?;
|
||||
|
||||
// Try the preferred port (same as IPv4 signal endpoint), fall
|
||||
// back to ephemeral if the OS rejects it.
|
||||
let bind_addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, preferred_port, 0, 0);
|
||||
if let Err(e) = sock.bind(&bind_addr.into()) {
|
||||
if preferred_port != 0 {
|
||||
tracing::debug!(
|
||||
preferred_port,
|
||||
error = %e,
|
||||
"ipv6 bind to preferred port failed, falling back to ephemeral"
|
||||
);
|
||||
let fallback = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0);
|
||||
sock.bind(&fallback.into())
|
||||
.map_err(|e| TransportError::Internal(format!("ipv6 bind fallback: {e}")))?;
|
||||
} else {
|
||||
return Err(TransportError::Internal(format!("ipv6 bind: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
sock.set_nonblocking(true)
|
||||
.map_err(|e| TransportError::Internal(format!("set_nonblocking: {e}")))?;
|
||||
|
||||
let udp_socket: std::net::UdpSocket = sock.into();
|
||||
|
||||
let runtime = quinn::default_runtime()
|
||||
.ok_or_else(|| TransportError::Internal("no async runtime for ipv6 endpoint".into()))?;
|
||||
|
||||
let endpoint = quinn::Endpoint::new(
|
||||
quinn::EndpointConfig::default(),
|
||||
server_config,
|
||||
udp_socket,
|
||||
runtime,
|
||||
)
|
||||
.map_err(|e| TransportError::Internal(format!("ipv6 endpoint: {e}")))?;
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Accept the next incoming connection on an endpoint.
|
||||
pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, TransportError> {
|
||||
let incoming = endpoint
|
||||
|
||||
@@ -23,9 +23,9 @@ pub mod quic;
|
||||
pub mod reliable;
|
||||
|
||||
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
||||
pub use connection::{accept, connect, create_endpoint};
|
||||
pub use connection::{accept, connect, create_endpoint, create_ipv6_endpoint};
|
||||
pub use path_monitor::PathMonitor;
|
||||
pub use quic::QuinnTransport;
|
||||
pub use quic::{QuinnPathSnapshot, QuinnTransport};
|
||||
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||
|
||||
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
//!
|
||||
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use wzp_proto::PathQuality;
|
||||
|
||||
/// EWMA smoothing factor.
|
||||
const ALPHA: f64 = 0.1;
|
||||
|
||||
/// Maximum number of RTT samples in the jitter variance sliding window.
|
||||
/// At ~50 packets/sec (20 ms frame), 10 samples ≈ 200 ms.
|
||||
const JITTER_VARIANCE_WINDOW_SIZE: usize = 10;
|
||||
|
||||
/// Monitors network path quality metrics.
|
||||
pub struct PathMonitor {
|
||||
/// EWMA-smoothed loss percentage (0.0 - 100.0).
|
||||
@@ -31,6 +37,8 @@ pub struct PathMonitor {
|
||||
last_rtt_ms: Option<f64>,
|
||||
/// Whether we have any observations yet.
|
||||
initialized: bool,
|
||||
/// Sliding window of recent RTT samples for variance calculation.
|
||||
rtt_window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl PathMonitor {
|
||||
@@ -51,6 +59,7 @@ impl PathMonitor {
|
||||
total_received: 0,
|
||||
last_rtt_ms: None,
|
||||
initialized: false,
|
||||
rtt_window: VecDeque::with_capacity(JITTER_VARIANCE_WINDOW_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +131,12 @@ impl PathMonitor {
|
||||
} else {
|
||||
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
|
||||
}
|
||||
|
||||
// Maintain sliding window for variance calculation
|
||||
if self.rtt_window.len() >= JITTER_VARIANCE_WINDOW_SIZE {
|
||||
self.rtt_window.pop_front();
|
||||
}
|
||||
self.rtt_window.push_back(rtt);
|
||||
}
|
||||
|
||||
/// Get the current estimated path quality.
|
||||
@@ -155,6 +170,20 @@ impl PathMonitor {
|
||||
0
|
||||
}
|
||||
|
||||
/// Compute the jitter (RTT standard deviation) over the sliding window.
|
||||
///
|
||||
/// Returns the standard deviation in milliseconds, or 0.0 if insufficient
|
||||
/// samples. Used by `DredTuner` for spike detection.
|
||||
pub fn jitter_variance_ms(&self) -> f64 {
|
||||
let n = self.rtt_window.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
|
||||
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
|
||||
var.sqrt()
|
||||
}
|
||||
|
||||
/// Detect whether a network handoff likely occurred.
|
||||
///
|
||||
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
|
||||
|
||||
@@ -13,6 +13,29 @@ use crate::datagram;
|
||||
use crate::path_monitor::PathMonitor;
|
||||
use crate::reliable;
|
||||
|
||||
/// Snapshot of quinn's QUIC-level path statistics.
|
||||
///
|
||||
/// Provides more accurate loss/RTT data than `PathMonitor`'s sequence-gap
|
||||
/// heuristic because quinn sees ACK frames and congestion signals directly.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct QuinnPathSnapshot {
|
||||
/// Smoothed RTT in milliseconds (from quinn's congestion controller).
|
||||
pub rtt_ms: u32,
|
||||
/// Cumulative loss percentage (lost_packets / sent_packets × 100).
|
||||
pub loss_pct: f32,
|
||||
/// Total congestion events observed by the QUIC stack.
|
||||
pub congestion_events: u64,
|
||||
/// Current congestion window in bytes.
|
||||
pub cwnd: u64,
|
||||
/// Total packets sent on this path.
|
||||
pub sent_packets: u64,
|
||||
/// Total packets lost on this path.
|
||||
pub lost_packets: u64,
|
||||
/// Current PMTUD-discovered maximum datagram payload size (bytes).
|
||||
/// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed.
|
||||
pub current_mtu: usize,
|
||||
}
|
||||
|
||||
/// QUIC-based transport implementing the `MediaTransport` trait.
|
||||
pub struct QuinnTransport {
|
||||
connection: quinn::Connection,
|
||||
@@ -33,6 +56,11 @@ impl QuinnTransport {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
/// Remote address of the peer on this connection.
|
||||
pub fn remote_address(&self) -> std::net::SocketAddr {
|
||||
self.connection.remote_address()
|
||||
}
|
||||
|
||||
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
||||
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||
self.connection
|
||||
@@ -61,6 +89,31 @@ impl QuinnTransport {
|
||||
datagram::max_datagram_payload(&self.connection)
|
||||
}
|
||||
|
||||
/// Snapshot of QUIC-level path stats from quinn, useful for DRED tuning.
|
||||
///
|
||||
/// Returns `(rtt_ms, loss_pct, congestion_events)` derived from quinn's
|
||||
/// internal congestion controller — more accurate than our own sequence-gap
|
||||
/// heuristic in `PathMonitor` because quinn sees ACK frames directly.
|
||||
pub fn quinn_path_stats(&self) -> QuinnPathSnapshot {
|
||||
let stats = self.connection.stats();
|
||||
let rtt_ms = stats.path.rtt.as_millis() as u32;
|
||||
let loss_pct = if stats.path.sent_packets > 0 {
|
||||
(stats.path.lost_packets as f32 / stats.path.sent_packets as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let current_mtu = self.connection.max_datagram_size().unwrap_or(1200);
|
||||
QuinnPathSnapshot {
|
||||
rtt_ms,
|
||||
loss_pct,
|
||||
congestion_events: stats.path.congestion_events,
|
||||
cwnd: stats.path.cwnd,
|
||||
sent_packets: stats.path.sent_packets,
|
||||
lost_packets: stats.path.lost_packets,
|
||||
current_mtu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an encoded [`TrunkFrame`] as a single QUIC datagram.
|
||||
pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> {
|
||||
let data = frame.encode();
|
||||
|
||||
@@ -53,6 +53,13 @@ pub async fn recv_signal(recv: &mut quinn::RecvStream) -> Result<SignalMessage,
|
||||
.await
|
||||
.map_err(|e| TransportError::Internal(format!("stream read payload error: {e}")))?;
|
||||
|
||||
serde_json::from_slice(&payload)
|
||||
.map_err(|e| TransportError::Internal(format!("signal deserialize error: {e}")))
|
||||
serde_json::from_slice(&payload).map_err(|e| {
|
||||
// Distinguish serde failures from transport failures so the
|
||||
// caller (relay main loop, client recv loop) can continue on
|
||||
// unknown-variant / parse errors instead of tearing down the
|
||||
// whole signal connection. Forward-compat: adding a new
|
||||
// `SignalMessage` variant in one side must not break the
|
||||
// other side's signal connection.
|
||||
TransportError::Deserialize(format!("{e}"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
|
||||
<div id="direct-registered" class="hidden" style="margin-top:12px">
|
||||
<div class="direct-registered-header">
|
||||
<p style="color:var(--green);font-size:13px;margin:0">✅ Registered — waiting for calls</p>
|
||||
<p id="registered-status" style="color:var(--green);font-size:13px;margin:0">✅ Registered — waiting for calls</p>
|
||||
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
|
||||
</div>
|
||||
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
|
||||
@@ -111,6 +111,16 @@
|
||||
<div class="level-meter">
|
||||
<div id="level-bar" class="level-bar-fill"></div>
|
||||
</div>
|
||||
<!-- Direct-call phone layout — shown instead of the group
|
||||
participant list when directCallPeer is set. Centered
|
||||
identicon, name, fp, connection badge. Hidden for
|
||||
room calls (directCallPeer == null). -->
|
||||
<div id="direct-call-view" class="direct-call-view hidden">
|
||||
<div id="dc-identicon" class="dc-identicon"></div>
|
||||
<div id="dc-name" class="dc-name">Unknown</div>
|
||||
<div id="dc-fp" class="dc-fp"></div>
|
||||
<div id="dc-badge" class="dc-badge">Connecting...</div>
|
||||
</div>
|
||||
<div id="participants" class="participants"></div>
|
||||
<div class="controls">
|
||||
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
||||
@@ -169,6 +179,29 @@
|
||||
<input id="s-agc" type="checkbox" checked />
|
||||
Automatic Gain Control
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="s-dred-debug" type="checkbox" />
|
||||
DRED debug logs (verbose, dev only)
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="s-call-debug" type="checkbox" />
|
||||
Call flow debug logs (trace every step of a call)
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-section" id="s-call-debug-section" style="display:none">
|
||||
<h3>Call Debug Log</h3>
|
||||
<div id="s-call-debug-log" style="max-height:220px;overflow-y:auto;background:#0a0a0a;color:#e0e0e0;font-family:ui-monospace,Menlo,Monaco,'Courier New',monospace;font-size:10px;padding:6px;border-radius:4px;line-height:1.4;white-space:pre-wrap"></div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button id="s-call-debug-copy" class="secondary-btn" style="flex:1">Copy log</button>
|
||||
<button id="s-call-debug-share" class="secondary-btn" style="flex:1">Share</button>
|
||||
<button id="s-call-debug-clear" class="secondary-btn" style="flex:1">Clear log</button>
|
||||
</div>
|
||||
<small id="s-call-debug-copy-status" style="display:block;margin-top:4px;color:var(--text-dim);font-size:10px"></small>
|
||||
<small style="color:var(--text-dim);display:block;margin-top:4px">
|
||||
Rolling buffer of the last 200 call-flow events. Turned off by
|
||||
default — the GUI overlay only populates when the checkbox above
|
||||
is on, but logcat (adb) always keeps a copy regardless.
|
||||
</small>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Identity</h3>
|
||||
@@ -181,6 +214,29 @@
|
||||
<span class="fp-display">~/.wzp/identity</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Network</h3>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Public address</span>
|
||||
<span id="s-reflected-addr" class="fp-display">(not queried)</span>
|
||||
<button id="s-reflect-btn" class="secondary-btn">Detect</button>
|
||||
</div>
|
||||
<small style="color:var(--text-dim);display:block;margin-top:4px">
|
||||
Asks the registered relay to echo back the IP:port it sees for this
|
||||
connection (QUIC-native NAT reflection, replaces STUN).
|
||||
</small>
|
||||
<div class="setting-row" style="margin-top:10px">
|
||||
<span class="setting-label">NAT type</span>
|
||||
<span id="s-nat-type" class="fp-display">(not detected)</span>
|
||||
<button id="s-nat-detect-btn" class="secondary-btn">Detect NAT</button>
|
||||
</div>
|
||||
<div id="s-nat-probes" style="margin-top:6px;font-size:11px;color:var(--text-dim)"></div>
|
||||
<small style="color:var(--text-dim);display:block;margin-top:4px">
|
||||
Probes every configured relay in parallel and compares the results
|
||||
to classify the NAT: cone (P2P viable), symmetric (must relay),
|
||||
multiple, or unknown.
|
||||
</small>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Recent Rooms</h3>
|
||||
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
||||
|
||||
@@ -36,6 +36,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
21
desktop/src-tauri/Info.plist
Normal file
21
desktop/src-tauri/Info.plist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
Custom Info.plist keys merged into the bundled WarzonePhone.app by
|
||||
tauri-bundler. The base Info.plist (CFBundleIdentifier, version,
|
||||
etc.) is generated from tauri.conf.json — only put *additional*
|
||||
keys here.
|
||||
|
||||
NSMicrophoneUsageDescription is required by macOS TCC for any
|
||||
app that opens an audio input unit. Without this string the OS
|
||||
silently denies CoreAudio capture (input callbacks return zeros)
|
||||
and the app never appears in System Settings → Privacy &
|
||||
Security → Microphone. This was the root cause of the desktop
|
||||
mic regression where phones could not hear the desktop client.
|
||||
-->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>WarzonePhone needs microphone access to transmit your voice during calls.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -21,6 +21,10 @@
|
||||
"core:window:default",
|
||||
"core:app:default",
|
||||
"core:webview:default",
|
||||
"shell:default"
|
||||
"shell:default",
|
||||
"notification:default",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-is-permission-granted"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,18 +72,22 @@ class MainActivity : TauriActivity() {
|
||||
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
|
||||
* slider is separate from media volume on most devices.
|
||||
*/
|
||||
/**
|
||||
* Pre-flight: only set volumes. Do NOT set MODE_IN_COMMUNICATION here —
|
||||
* that hijacks the entire audio routing (music stops, BT A2DP drops to
|
||||
* earpiece) even before a call starts. The Rust side sets the mode via
|
||||
* JNI when the call engine actually starts, and restores MODE_NORMAL
|
||||
* when the call ends.
|
||||
*/
|
||||
private fun configureAudioForCall() {
|
||||
try {
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
||||
Log.i(TAG, "audio state: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
||||
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
|
||||
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
|
||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
|
||||
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
||||
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
am.isSpeakerphoneOn = false // default: handset / earpiece
|
||||
|
||||
// Crank both voice-call and music volumes so nothing silent slips
|
||||
// through regardless of which stream actually ends up driving.
|
||||
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
|
||||
@@ -91,9 +95,7 @@ class MainActivity : TauriActivity() {
|
||||
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
|
||||
|
||||
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
||||
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
|
||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
|
||||
Log.i(TAG, "volumes set: voiceVol=$maxVoice musicVol=$maxMusic (mode left at ${am.mode})")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default"],"platforms":["linux","macOS","windows","android","iOS"]}}
|
||||
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default","notification:default","notification:allow-notify","notification:allow-request-permission","notification:allow-is-permission-granted"],"platforms":["linux","macOS","windows","android","iOS"]}}
|
||||
@@ -2354,6 +2354,204 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2354,6 +2354,204 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -57,11 +57,37 @@ fn audio_manager<'local>(
|
||||
Ok(am)
|
||||
}
|
||||
|
||||
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
|
||||
/// This tells the audio policy to route through the communication device
|
||||
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
|
||||
pub fn set_audio_mode_communication() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
// MODE_IN_COMMUNICATION = 3
|
||||
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(3)])
|
||||
.map_err(|e| format!("setMode(MODE_IN_COMMUNICATION): {e}"))?;
|
||||
tracing::info!("AudioManager: mode set to MODE_IN_COMMUNICATION");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
|
||||
pub fn set_audio_mode_normal() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
// MODE_NORMAL = 0
|
||||
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(0)])
|
||||
.map_err(|e| format!("setMode(MODE_NORMAL): {e}"))?;
|
||||
tracing::info!("AudioManager: mode set to MODE_NORMAL");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
|
||||
///
|
||||
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
|
||||
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
|
||||
/// sets this at startup, so by the time a call is up this is always true.
|
||||
pub fn set_speakerphone(on: bool) -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
@@ -96,3 +122,238 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
|
||||
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
|
||||
|
||||
/// Start Bluetooth SCO audio routing.
|
||||
///
|
||||
/// On API 31+ uses `setCommunicationDevice()` which is the modern way to
|
||||
/// route voice audio to a specific device. Falls back to the deprecated
|
||||
/// `startBluetoothSco()` path on older APIs.
|
||||
///
|
||||
/// The caller must restart Oboe streams after this call.
|
||||
pub fn start_bluetooth_sco() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
// Ensure speaker is off — mutually exclusive with BT.
|
||||
env.call_method(
|
||||
&am,
|
||||
"setSpeakerphoneOn",
|
||||
"(Z)V",
|
||||
&[JValue::Bool(0)],
|
||||
)
|
||||
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
|
||||
|
||||
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
|
||||
// Find a BT SCO or BLE device from getAvailableCommunicationDevices()
|
||||
let used_modern = try_set_communication_device(&mut env, &am, true)?;
|
||||
|
||||
if !used_modern {
|
||||
// Fallback: deprecated startBluetoothSco (API < 31)
|
||||
tracing::info!("start_bluetooth_sco: falling back to deprecated startBluetoothSco");
|
||||
env.call_method(&am, "startBluetoothSco", "()V", &[])
|
||||
.map_err(|e| format!("startBluetoothSco: {e}"))?;
|
||||
}
|
||||
|
||||
tracing::info!(used_modern, "AudioManager: Bluetooth SCO started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
|
||||
///
|
||||
/// The caller must restart Oboe streams after this call.
|
||||
pub fn stop_bluetooth_sco() -> Result<(), String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
// Modern API: clearCommunicationDevice() (API 31+)
|
||||
let cleared = try_set_communication_device(&mut env, &am, false)?;
|
||||
|
||||
if !cleared {
|
||||
// Fallback: deprecated stopBluetoothSco
|
||||
env.call_method(&am, "stopBluetoothSco", "()V", &[])
|
||||
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
|
||||
}
|
||||
|
||||
tracing::info!(cleared, "AudioManager: Bluetooth SCO stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to use the modern `setCommunicationDevice` / `clearCommunicationDevice`
|
||||
/// API (Android 12 / API 31+). Returns `true` if the modern API was used.
|
||||
fn try_set_communication_device(
|
||||
env: &mut jni::AttachGuard<'_>,
|
||||
am: &JObject<'_>,
|
||||
enable: bool,
|
||||
) -> Result<bool, String> {
|
||||
// Check SDK_INT >= 31 (Android 12)
|
||||
let sdk_int = env
|
||||
.get_static_field(
|
||||
"android/os/Build$VERSION",
|
||||
"SDK_INT",
|
||||
"I",
|
||||
)
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
|
||||
if sdk_int < 31 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !enable {
|
||||
// clearCommunicationDevice()
|
||||
env.call_method(am, "clearCommunicationDevice", "()V", &[])
|
||||
.map_err(|e| format!("clearCommunicationDevice: {e}"))?;
|
||||
tracing::info!("clearCommunicationDevice: done");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// getAvailableCommunicationDevices() → List<AudioDeviceInfo>
|
||||
let device_list = env
|
||||
.call_method(
|
||||
am,
|
||||
"getAvailableCommunicationDevices",
|
||||
"()Ljava/util/List;",
|
||||
&[],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
.map_err(|e| format!("getAvailableCommunicationDevices: {e}"))?;
|
||||
|
||||
let size = env
|
||||
.call_method(&device_list, "size", "()I", &[])
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Find first BT device: TYPE_BLUETOOTH_SCO (7), TYPE_BLUETOOTH_A2DP (8),
|
||||
// TYPE_BLE_HEADSET (26), TYPE_BLE_SPEAKER (27)
|
||||
for i in 0..size {
|
||||
let device = env
|
||||
.call_method(
|
||||
&device_list,
|
||||
"get",
|
||||
"(I)Ljava/lang/Object;",
|
||||
&[JValue::Int(i)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
.map_err(|e| format!("list.get({i}): {e}"))?;
|
||||
|
||||
let device_type = env
|
||||
.call_method(&device, "getType", "()I", &[])
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
|
||||
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
|
||||
if matches!(device_type, 7 | 8 | 26 | 27) {
|
||||
let ok = env
|
||||
.call_method(
|
||||
am,
|
||||
"setCommunicationDevice",
|
||||
"(Landroid/media/AudioDeviceInfo;)Z",
|
||||
&[JValue::Object(&device)],
|
||||
)
|
||||
.and_then(|v| v.z())
|
||||
.unwrap_or(false);
|
||||
|
||||
tracing::info!(
|
||||
device_type,
|
||||
ok,
|
||||
"setCommunicationDevice: set BT device"
|
||||
);
|
||||
return Ok(ok);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("setCommunicationDevice: no BT device in available list");
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Query whether Bluetooth audio is currently the active communication device.
|
||||
///
|
||||
/// On API 31+ checks `getCommunicationDevice()` type. Falls back to the
|
||||
/// deprecated `isBluetoothScoOn()` on older APIs.
|
||||
pub fn is_bluetooth_sco_on() -> Result<bool, String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
let sdk_int = env
|
||||
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
|
||||
if sdk_int >= 31 {
|
||||
// getCommunicationDevice() → AudioDeviceInfo (nullable)
|
||||
let device = env
|
||||
.call_method(am, "getCommunicationDevice", "()Landroid/media/AudioDeviceInfo;", &[])
|
||||
.and_then(|v| v.l())
|
||||
.unwrap_or(JObject::null());
|
||||
if device.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
let device_type = env
|
||||
.call_method(&device, "getType", "()I", &[])
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
|
||||
return Ok(matches!(device_type, 7 | 8 | 26 | 27));
|
||||
}
|
||||
|
||||
// Fallback: deprecated API
|
||||
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
|
||||
.and_then(|v| v.z())
|
||||
.map_err(|e| format!("isBluetoothScoOn: {e}"))
|
||||
}
|
||||
|
||||
/// Check whether a Bluetooth audio device is currently connected.
|
||||
///
|
||||
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
|
||||
/// any Bluetooth device type. Many headsets only register as A2DP until
|
||||
/// SCO is explicitly started, so we check for both SCO and A2DP types.
|
||||
pub fn is_bluetooth_available() -> Result<bool, String> {
|
||||
let (vm, activity) = jvm_and_activity()?;
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
let am = audio_manager(&mut env, &activity)?;
|
||||
|
||||
// AudioManager.GET_DEVICES_OUTPUTS = 2
|
||||
let devices = env
|
||||
.call_method(
|
||||
&am,
|
||||
"getDevices",
|
||||
"(I)[Landroid/media/AudioDeviceInfo;",
|
||||
&[JValue::Int(2)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
|
||||
|
||||
let arr = jni::objects::JObjectArray::from(devices);
|
||||
let len = env
|
||||
.get_array_length(&arr)
|
||||
.map_err(|e| format!("get_array_length: {e}"))?;
|
||||
|
||||
for i in 0..len {
|
||||
let device = env
|
||||
.get_object_array_element(&arr, i)
|
||||
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
|
||||
let device_type = env
|
||||
.call_method(&device, "getType", "()I", &[])
|
||||
.and_then(|v| v.i())
|
||||
.unwrap_or(0);
|
||||
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
|
||||
if device_type == 7 || device_type == 8 {
|
||||
tracing::info!(device_type, idx = i, "is_bluetooth_available: found BT device");
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ static LIB: OnceLock<libloading::Library> = OnceLock::new();
|
||||
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
|
||||
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
||||
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
|
||||
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
|
||||
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
|
||||
@@ -65,6 +66,7 @@ pub fn init() -> Result<(), String> {
|
||||
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
|
||||
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
|
||||
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
|
||||
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt");
|
||||
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
|
||||
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
|
||||
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
|
||||
@@ -104,6 +106,14 @@ pub fn audio_start() -> Result<(), i32> {
|
||||
if ret == 0 { Ok(()) } else { Err(ret) }
|
||||
}
|
||||
|
||||
/// Start Oboe in Bluetooth SCO mode — capture skips sample rate and
|
||||
/// input preset so the system routes to the BT SCO device natively.
|
||||
pub fn audio_start_bt() -> Result<(), i32> {
|
||||
let f = AUDIO_START_BT.get().ok_or(-100_i32)?;
|
||||
let ret = unsafe { f() };
|
||||
if ret == 0 { Ok(()) } else { Err(ret) }
|
||||
}
|
||||
|
||||
/// Stop both streams. Safe to call even if not running.
|
||||
pub fn audio_stop() {
|
||||
if let Some(f) = AUDIO_STOP.get() {
|
||||
|
||||
@@ -2,6 +2,125 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { generateIdenticon, createIdenticonEl } from "./identicon";
|
||||
|
||||
// ── Incoming-call ringer ─────────────────────────────────────────────
|
||||
//
|
||||
// Web Audio synthesized two-tone ring that loops until stop() is
|
||||
// called. No external asset file — works immediately on every
|
||||
// platform Tauri has a WebView on (Android, macOS, Windows, Linux).
|
||||
//
|
||||
// The pattern is a classic North American ring cadence: 440Hz +
|
||||
// 480Hz tone for 2s, 4s silence, repeat. Volume ramps to ~30%
|
||||
// peak so it's audible without being obnoxious on laptop
|
||||
// speakers. Stops cleanly on stop() — cancels the timer AND
|
||||
// disconnects the active oscillators so there's no tail audio.
|
||||
class Ringer {
|
||||
private ctx: AudioContext | null = null;
|
||||
private timer: number | null = null;
|
||||
private activeNodes: AudioNode[] = [];
|
||||
private running = false;
|
||||
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
// Construct the AudioContext lazily on the first ring — some
|
||||
// platforms (iOS WebView, Android WebView) refuse to create
|
||||
// one until after a user gesture, so we MUST be past that
|
||||
// point by the time start() is called. Incoming call event is
|
||||
// user-adjacent enough that the WebView normally allows it.
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Ringer: AudioContext unavailable", e);
|
||||
this.running = false;
|
||||
return;
|
||||
}
|
||||
this.playOnce();
|
||||
// 2s tone + 4s silence = 6s cadence. Loop with setInterval.
|
||||
this.timer = window.setInterval(() => this.playOnce(), 6000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer != null) {
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
for (const n of this.activeNodes) {
|
||||
try {
|
||||
(n as any).disconnect();
|
||||
} catch {}
|
||||
}
|
||||
this.activeNodes = [];
|
||||
}
|
||||
|
||||
private playOnce() {
|
||||
if (!this.ctx || !this.running) return;
|
||||
const ctx = this.ctx;
|
||||
const now = ctx.currentTime;
|
||||
const toneDurSec = 2.0;
|
||||
// Two-tone ring: 440Hz (A4) + 480Hz (close to B4). Mix both
|
||||
// through one gain node for envelope control.
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(0.3, now + 0.05);
|
||||
gain.gain.setValueAtTime(0.3, now + toneDurSec - 0.05);
|
||||
gain.gain.linearRampToValueAtTime(0, now + toneDurSec);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
for (const freq of [440, 480]) {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
osc.start(now);
|
||||
osc.stop(now + toneDurSec);
|
||||
this.activeNodes.push(osc);
|
||||
}
|
||||
this.activeNodes.push(gain);
|
||||
|
||||
// Schedule a cleanup of old nodes after this tone finishes so
|
||||
// the activeNodes array doesn't grow unbounded across long
|
||||
// rings.
|
||||
window.setTimeout(() => {
|
||||
this.activeNodes = this.activeNodes.filter((n) => n !== gain);
|
||||
}, (toneDurSec + 0.1) * 1000);
|
||||
}
|
||||
}
|
||||
const ringer = new Ringer();
|
||||
|
||||
/// Best-effort system notification via the tauri-plugin-notification
|
||||
/// plugin. Uses raw `invoke` so we don't need to import
|
||||
/// `@tauri-apps/plugin-notification` — just invoke the plugin
|
||||
/// commands directly. Silently no-ops if the plugin isn't
|
||||
/// available or permission is denied.
|
||||
async function notifyIncomingCall(from: string) {
|
||||
try {
|
||||
// Make sure we have permission first. On Android this prompts
|
||||
// the user once; after that it's cached.
|
||||
const granted = await invoke<boolean>(
|
||||
"plugin:notification|is_permission_granted",
|
||||
).catch(() => false);
|
||||
if (!granted) {
|
||||
const result = await invoke<string>(
|
||||
"plugin:notification|request_permission",
|
||||
).catch(() => "denied");
|
||||
if (result !== "granted") return;
|
||||
}
|
||||
await invoke("plugin:notification|notify", {
|
||||
options: {
|
||||
title: "Incoming call",
|
||||
body: `From ${from}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Notification plugin missing or refused — not fatal, the
|
||||
// visible panel + ringer still alert the user.
|
||||
console.debug("notify: plugin unavailable or refused", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebView hardening ──
|
||||
// Suppress the browser-style right-click context menu on desktop Tauri — it
|
||||
// exposes Inspect/Reload/Back/Forward entries that don't belong in a native-
|
||||
@@ -50,6 +169,11 @@ const callTimer = document.getElementById("call-timer")!;
|
||||
const callStatus = document.getElementById("call-status")!;
|
||||
const levelBar = document.getElementById("level-bar")!;
|
||||
const participantsDiv = document.getElementById("participants")!;
|
||||
const directCallView = document.getElementById("direct-call-view")!;
|
||||
const dcIdenticon = document.getElementById("dc-identicon")!;
|
||||
const dcName = document.getElementById("dc-name")!;
|
||||
const dcFp = document.getElementById("dc-fp")!;
|
||||
const dcBadge = document.getElementById("dc-badge")!;
|
||||
const micBtn = document.getElementById("mic-btn")!;
|
||||
const micIcon = document.getElementById("mic-icon")!;
|
||||
const spkBtn = document.getElementById("spk-btn")!;
|
||||
@@ -82,6 +206,19 @@ const settingsBtnCall = document.getElementById("settings-btn-call")!;
|
||||
const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
||||
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
|
||||
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
|
||||
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
|
||||
const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement;
|
||||
const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement;
|
||||
const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement;
|
||||
const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement;
|
||||
const sCallDebugCopyBtn = document.getElementById("s-call-debug-copy") as HTMLButtonElement;
|
||||
const sCallDebugShareBtn = document.getElementById("s-call-debug-share") as HTMLButtonElement;
|
||||
const sCallDebugCopyStatus = document.getElementById("s-call-debug-copy-status") as HTMLElement;
|
||||
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
|
||||
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
|
||||
const sNatType = document.getElementById("s-nat-type") as HTMLSpanElement;
|
||||
const sNatDetectBtn = document.getElementById("s-nat-detect-btn") as HTMLButtonElement;
|
||||
const sNatProbes = document.getElementById("s-nat-probes") as HTMLDivElement;
|
||||
const sAgc = document.getElementById("s-agc") as HTMLInputElement;
|
||||
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
||||
const sQualityLabel = document.getElementById("s-quality-label")!;
|
||||
@@ -140,6 +277,16 @@ interface Settings {
|
||||
agc: boolean;
|
||||
quality: string;
|
||||
recentRooms: RecentRoom[];
|
||||
/// When true, the Rust side emits the chatty per-frame DRED parse +
|
||||
/// reconstruction + classical-PLC logs and adds DRED counters to the
|
||||
/// recv heartbeat. Off in normal mode keeps logcat clean.
|
||||
dredDebugLogs: boolean;
|
||||
/// Phase 3.5: when true, every step of a call's lifecycle (register,
|
||||
/// reflect query, offer/answer, relay setup, dual-path race, engine
|
||||
/// start, media) emits a `call-debug-log` Tauri event that this UI
|
||||
/// renders into the rolling Debug Log panel in settings. Off in
|
||||
/// normal mode keeps the GUI quiet but logcat always has a copy.
|
||||
callDebugLogs: boolean;
|
||||
}
|
||||
|
||||
function loadSettings(): Settings {
|
||||
@@ -152,6 +299,8 @@ function loadSettings(): Settings {
|
||||
],
|
||||
selectedRelay: 0, room: "general", alias: "",
|
||||
osAec: true, agc: true, quality: "auto", recentRooms: [],
|
||||
dredDebugLogs: false,
|
||||
callDebugLogs: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem("wzp-settings");
|
||||
@@ -325,6 +474,9 @@ function renderRelayDialogList() {
|
||||
|
||||
// Click to select
|
||||
item.addEventListener("click", () => {
|
||||
const prev = loadSettings();
|
||||
const prevRelayAddr = prev.relays[prev.selectedRelay]?.address;
|
||||
|
||||
const s = loadSettings();
|
||||
s.selectedRelay = i;
|
||||
|
||||
@@ -336,6 +488,30 @@ function renderRelayDialogList() {
|
||||
saveSettingsObj(s);
|
||||
renderRelayDialogList();
|
||||
renderRelayButton();
|
||||
|
||||
// If the user switched relays and we're currently registered,
|
||||
// transparently re-register against the new one. The Rust
|
||||
// `register_signal` command is idempotent and handles the
|
||||
// swap internally (close old transport → connect new). This
|
||||
// makes "change server" a single-click operation instead of
|
||||
// manual deregister + re-register.
|
||||
const newRelayAddr = r.address;
|
||||
if (newRelayAddr && newRelayAddr !== prevRelayAddr) {
|
||||
(async () => {
|
||||
// Is a signal currently registered? get_signal_status is
|
||||
// cheap and lets us decide whether to kick the swap.
|
||||
try {
|
||||
const st: any = await invoke("get_signal_status");
|
||||
if (st && st.status === "registered") {
|
||||
await invoke<string>("register_signal", { relay: newRelayAddr });
|
||||
// `signal-event { type: "registered" }` from Rust will
|
||||
// update directRegistered for us — no manual render here.
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("relay swap: failed to re-register", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
relayDialogList.appendChild(item);
|
||||
@@ -402,6 +578,146 @@ function renderRecentRooms(rooms: RecentRoom[]) {
|
||||
// ── Init ──
|
||||
applySettings();
|
||||
setTimeout(pingAllRelays, 300);
|
||||
// Hydrate the Rust DRED + call-debug verbose-logs flags from saved
|
||||
// settings on boot so the choice survives app restarts without
|
||||
// needing the user to reopen the settings panel.
|
||||
invoke("set_dred_verbose_logs", { enabled: !!loadSettings().dredDebugLogs }).catch(() => {});
|
||||
invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch(() => {});
|
||||
|
||||
// ── Phase 3.5: call-flow debug log rolling buffer ─────────────────
|
||||
// Backend emits `call-debug-log` events at every step of the call
|
||||
// lifecycle when the flag is on. We keep a cap-200 ring here and
|
||||
// render into the Settings panel's Debug Log section.
|
||||
interface CallDebugEntry {
|
||||
ts_ms: number;
|
||||
step: string;
|
||||
details: any;
|
||||
}
|
||||
const CALL_DEBUG_MAX = 200;
|
||||
const callDebugBuffer: CallDebugEntry[] = [];
|
||||
|
||||
function renderCallDebugLog() {
|
||||
// Skip the render if the section isn't visible — cheap guard on
|
||||
// hot path, repainted each time the user opens settings.
|
||||
if (sCallDebugSection.style.display === "none") return;
|
||||
const lines = callDebugBuffer.map((e) => {
|
||||
const iso = new Date(e.ts_ms).toISOString().slice(11, 23); // HH:MM:SS.mmm
|
||||
const details = e.details && Object.keys(e.details).length > 0
|
||||
? " " + JSON.stringify(e.details)
|
||||
: "";
|
||||
return `${iso} ${e.step}${details}`;
|
||||
});
|
||||
sCallDebugLogEl.textContent = lines.join("\n");
|
||||
sCallDebugLogEl.scrollTop = sCallDebugLogEl.scrollHeight;
|
||||
}
|
||||
|
||||
listen("call-debug-log", (event: any) => {
|
||||
const entry: CallDebugEntry = event.payload;
|
||||
callDebugBuffer.push(entry);
|
||||
if (callDebugBuffer.length > CALL_DEBUG_MAX) {
|
||||
callDebugBuffer.shift();
|
||||
}
|
||||
renderCallDebugLog();
|
||||
});
|
||||
|
||||
sCallDebugClearBtn.addEventListener("click", () => {
|
||||
callDebugBuffer.length = 0;
|
||||
sCallDebugLogEl.textContent = "";
|
||||
});
|
||||
|
||||
/// Serialise the rolling call-debug buffer as plain text for
|
||||
/// copy/share. One entry per line, HH:MM:SS.mmm + step +
|
||||
/// compact JSON details. Same format the on-screen panel uses.
|
||||
function formatCallDebugLog(): string {
|
||||
return callDebugBuffer
|
||||
.map((e) => {
|
||||
const iso = new Date(e.ts_ms).toISOString().slice(11, 23);
|
||||
const details =
|
||||
e.details && Object.keys(e.details).length > 0
|
||||
? " " + JSON.stringify(e.details)
|
||||
: "";
|
||||
return `${iso} ${e.step}${details}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/// One-shot status helper for the copy/share buttons.
|
||||
function flashCallDebugStatus(msg: string, isError: boolean = false) {
|
||||
sCallDebugCopyStatus.textContent = msg;
|
||||
sCallDebugCopyStatus.style.color = isError ? "var(--yellow)" : "var(--green)";
|
||||
setTimeout(() => {
|
||||
sCallDebugCopyStatus.textContent = "";
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
sCallDebugCopyBtn.addEventListener("click", async () => {
|
||||
const text = formatCallDebugLog();
|
||||
if (!text) {
|
||||
flashCallDebugStatus("Log is empty", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
flashCallDebugStatus(`✓ Copied ${callDebugBuffer.length} entries`);
|
||||
} catch (e) {
|
||||
// Some WebViews refuse clipboard access without a user
|
||||
// permission prompt; fall back to a selection-based copy.
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.top = "0";
|
||||
ta.style.left = "0";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
if (ok) {
|
||||
flashCallDebugStatus(`✓ Copied ${callDebugBuffer.length} entries`);
|
||||
} else {
|
||||
throw new Error("execCommand returned false");
|
||||
}
|
||||
} catch (e2) {
|
||||
flashCallDebugStatus(`⚠ Copy failed: ${String(e2)}`, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sCallDebugShareBtn.addEventListener("click", async () => {
|
||||
const text = formatCallDebugLog();
|
||||
if (!text) {
|
||||
flashCallDebugStatus("Log is empty", true);
|
||||
return;
|
||||
}
|
||||
// Try the Web Share API first — on Android WebView, this opens
|
||||
// the standard Share sheet and the user can send the text to
|
||||
// any messaging app. Falls back to clipboard copy if the
|
||||
// WebView doesn't expose navigator.share (most desktop
|
||||
// WebViews don't).
|
||||
const nav: any = navigator;
|
||||
if (nav.share) {
|
||||
try {
|
||||
await nav.share({
|
||||
title: "WarzonePhone debug log",
|
||||
text,
|
||||
});
|
||||
flashCallDebugStatus(`✓ Shared ${callDebugBuffer.length} entries`);
|
||||
return;
|
||||
} catch (e) {
|
||||
// User cancelled or WebView rejected — fall through to
|
||||
// clipboard copy as a best-effort.
|
||||
console.debug("share failed, falling back to clipboard", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
flashCallDebugStatus(`✓ Copied (no share API)`);
|
||||
} catch (e) {
|
||||
flashCallDebugStatus(`⚠ Share + copy both failed`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Load fingerprint + alias + git hash + render identicon
|
||||
interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string }
|
||||
@@ -524,18 +840,43 @@ async function doConnect() {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5.6: when we're in a direct P2P call (not relay-
|
||||
// mediated), the relay's room infrastructure never sends a
|
||||
// RoomUpdate because neither peer actually joined the room.
|
||||
// pollStatus sees an empty participant list and shows "Waiting
|
||||
// for participants...". Track the peer's identity from the
|
||||
// signal plane and render a synthetic participant entry instead.
|
||||
let directCallPeer: { fingerprint: string; alias: string | null } | null = null;
|
||||
|
||||
function showCallScreen() {
|
||||
connectScreen.classList.add("hidden");
|
||||
callScreen.classList.remove("hidden");
|
||||
roomName.textContent = roomInput.value;
|
||||
|
||||
// Direct call → phone-style layout; room call → group layout.
|
||||
if (directCallPeer) {
|
||||
const fp = directCallPeer.fingerprint || "";
|
||||
const alias = directCallPeer.alias;
|
||||
roomName.textContent = alias || fp.substring(0, 16) || "Direct Call";
|
||||
dcName.textContent = alias || "Unknown";
|
||||
dcFp.textContent = fp;
|
||||
dcIdenticon.innerHTML = "";
|
||||
dcIdenticon.appendChild(createIdenticonEl(fp || "?", 96, true));
|
||||
dcBadge.textContent = "Connecting...";
|
||||
dcBadge.className = "dc-badge connecting";
|
||||
directCallView.classList.remove("hidden");
|
||||
participantsDiv.classList.add("hidden");
|
||||
} else {
|
||||
roomName.textContent = roomInput.value;
|
||||
directCallView.classList.add("hidden");
|
||||
participantsDiv.classList.remove("hidden");
|
||||
}
|
||||
callStatus.className = "status-dot";
|
||||
statusInterval = window.setInterval(pollStatus, 250);
|
||||
// Sync the Speaker/Earpiece label with the OS state (Android only; on
|
||||
// desktop the command is a no-op returning false so we land on "Earpiece"
|
||||
// which is fine because desktop has no routing concept).
|
||||
invoke<boolean>("is_speakerphone_on")
|
||||
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
|
||||
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
|
||||
// Sync the audio route label with the OS state (Android only; on desktop
|
||||
// get_audio_route returns "earpiece" so we land on the default).
|
||||
invoke<string>("get_audio_route")
|
||||
.then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
|
||||
.catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
|
||||
}
|
||||
|
||||
function showConnectScreen() {
|
||||
@@ -544,6 +885,10 @@ function showConnectScreen() {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = "Connect";
|
||||
levelBar.style.width = "0%";
|
||||
directCallPeer = null;
|
||||
// Clear the media-degraded banner if present
|
||||
const banner = document.getElementById("media-degraded-banner");
|
||||
if (banner) banner.remove();
|
||||
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||
}
|
||||
|
||||
@@ -552,41 +897,92 @@ micBtn.addEventListener("click", async () => {
|
||||
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
|
||||
});
|
||||
|
||||
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
|
||||
// stops and restarts the Oboe streams so AAudio reconfigures with the new
|
||||
// routing. The Rust-side Tauri command handles the restart, we just swap
|
||||
// the button label.
|
||||
// Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
|
||||
// SCO. Each transition calls the corresponding Tauri command which sets the
|
||||
// AudioManager state and restarts Oboe streams so AAudio picks up the new
|
||||
// route. On desktop all commands are no-ops.
|
||||
//
|
||||
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
|
||||
// (which would tint the button red); that was a bug in 0178cbd that made
|
||||
// earpiece mode look like playback was off. A separate `.speaker-on` class
|
||||
// is available for css styling if we want to visually indicate loud mode.
|
||||
let speakerphoneOn = false;
|
||||
let speakerphoneBusy = false;
|
||||
function updateSpkLabel() {
|
||||
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
|
||||
// earpiece mode look like playback was off.
|
||||
type AudioRoute = "earpiece" | "speaker" | "bluetooth";
|
||||
let currentAudioRoute: AudioRoute = "earpiece";
|
||||
let routeBusy = false;
|
||||
|
||||
function updateRouteLabel() {
|
||||
spkBtn.classList.remove("speaker-on", "bt-on");
|
||||
spkBtn.classList.remove("muted");
|
||||
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
|
||||
switch (currentAudioRoute) {
|
||||
case "speaker":
|
||||
spkIcon.textContent = "🔊 Speaker";
|
||||
spkBtn.classList.add("speaker-on");
|
||||
break;
|
||||
case "bluetooth":
|
||||
spkIcon.textContent = "🎧 BT";
|
||||
spkBtn.classList.add("bt-on");
|
||||
break;
|
||||
default:
|
||||
spkIcon.textContent = "🔈 Earpiece";
|
||||
break;
|
||||
}
|
||||
}
|
||||
spkBtn.addEventListener("click", async () => {
|
||||
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
|
||||
speakerphoneBusy = true;
|
||||
const next = !speakerphoneOn;
|
||||
|
||||
async function cycleAudioRoute() {
|
||||
if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
|
||||
routeBusy = true;
|
||||
spkBtn.disabled = true;
|
||||
try {
|
||||
await invoke("set_speakerphone", { on: next });
|
||||
speakerphoneOn = next;
|
||||
updateSpkLabel();
|
||||
const btAvailable = await invoke<boolean>("is_bluetooth_available");
|
||||
const routes: AudioRoute[] = btAvailable
|
||||
? ["earpiece", "speaker", "bluetooth"]
|
||||
: ["earpiece", "speaker"];
|
||||
const idx = routes.indexOf(currentAudioRoute);
|
||||
const next = routes[(idx + 1) % routes.length];
|
||||
|
||||
// Tear down current route, then activate next.
|
||||
// start_bluetooth_sco() already calls setSpeakerphoneOn(false)
|
||||
// internally, so we skip the separate speakerphone toggle when
|
||||
// transitioning to BT to avoid a redundant Oboe restart.
|
||||
if (currentAudioRoute === "bluetooth") {
|
||||
await invoke("set_bluetooth_sco", { on: false });
|
||||
}
|
||||
if (next === "speaker") {
|
||||
await invoke("set_speakerphone", { on: true });
|
||||
} else if (next === "bluetooth") {
|
||||
// BT start handles speaker-off internally + waits for SCO link
|
||||
await invoke("set_bluetooth_sco", { on: true });
|
||||
} else {
|
||||
// earpiece — turn everything off
|
||||
await invoke("set_speakerphone", { on: false });
|
||||
}
|
||||
|
||||
currentAudioRoute = next;
|
||||
updateRouteLabel();
|
||||
} catch (e) {
|
||||
console.error("set_speakerphone failed:", e);
|
||||
console.error("cycleAudioRoute failed:", e);
|
||||
} finally {
|
||||
spkBtn.disabled = false;
|
||||
speakerphoneBusy = false;
|
||||
routeBusy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
spkBtn.addEventListener("click", cycleAudioRoute);
|
||||
hangupBtn.addEventListener("click", async () => {
|
||||
userDisconnected = true;
|
||||
try { await invoke("disconnect"); } catch {}
|
||||
// Use the new hangup_call command instead of raw disconnect —
|
||||
// it sends a Hangup signal to the relay FIRST so the peer
|
||||
// gets auto-dismissed from the call screen, then tears down
|
||||
// our local engine. Plain `disconnect` would leave the peer
|
||||
// stuck on the call screen with silent audio.
|
||||
try {
|
||||
await invoke("hangup_call");
|
||||
} catch {
|
||||
// Fall back to plain disconnect if hangup_call errors
|
||||
// (older Rust build without the new command).
|
||||
try {
|
||||
await invoke("disconnect");
|
||||
} catch {}
|
||||
}
|
||||
showConnectScreen();
|
||||
});
|
||||
|
||||
@@ -643,7 +1039,7 @@ async function pollStatus() {
|
||||
micBtn.classList.toggle("muted", st.mic_muted);
|
||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||
// NB: spkBtn label is driven by the Android audio routing state
|
||||
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
|
||||
// (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
|
||||
// Skip that here so pollStatus doesn't clobber the routing UI.
|
||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||
|
||||
@@ -651,8 +1047,32 @@ async function pollStatus() {
|
||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||
levelBar.style.width = `${pct}%`;
|
||||
|
||||
// Participants grouped by relay
|
||||
if (st.participants.length === 0) {
|
||||
// Direct-call phone-style layout: update the connection
|
||||
// badge from the call-debug buffer or from participants.
|
||||
if (directCallPeer) {
|
||||
// Check the debug buffer for the race result to label
|
||||
// the connection type (P2P Direct vs Relay).
|
||||
const pathNeg = callDebugBuffer.find((e) => e.step === "connect:path_negotiated");
|
||||
const engineOk = callDebugBuffer.find((e) => e.step === "connect:call_engine_started");
|
||||
if (engineOk) {
|
||||
if (pathNeg?.details?.use_direct === true) {
|
||||
dcBadge.textContent = "P2P Direct";
|
||||
dcBadge.className = "dc-badge";
|
||||
} else {
|
||||
dcBadge.textContent = "Via Relay";
|
||||
dcBadge.className = "dc-badge relay";
|
||||
}
|
||||
}
|
||||
// Skip the group participant rendering — direct-call
|
||||
// view is already visible and showing the peer.
|
||||
}
|
||||
|
||||
// Participants grouped by relay (group/room calls only).
|
||||
// Hidden when directCallPeer is set — the phone-style
|
||||
// layout above handles the 1:1 display.
|
||||
if (directCallPeer) {
|
||||
// no-op: direct call view handles it
|
||||
} else if (st.participants.length === 0) {
|
||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||
} else {
|
||||
participantsDiv.innerHTML = "";
|
||||
@@ -708,12 +1128,54 @@ listen("call-event", (event: any) => {
|
||||
const { kind } = event.payload;
|
||||
if (kind === "room-update") pollStatus();
|
||||
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
||||
|
||||
// Phase 5.6: media health watchdog — show/clear a warning
|
||||
// banner when the media path dies (e.g., P2P direct
|
||||
// established but the network path changed, or cross-relay
|
||||
// media forwarding isn't working).
|
||||
if (kind === "media-degraded") {
|
||||
// Show a warning banner on the call screen. Don't auto-
|
||||
// disconnect — the user might be on a briefly-unstable
|
||||
// network and recovery is possible (the engine tracks
|
||||
// "media-recovered" and clears the banner if packets
|
||||
// resume).
|
||||
let banner = document.getElementById("media-degraded-banner");
|
||||
if (!banner) {
|
||||
banner = document.createElement("div");
|
||||
banner.id = "media-degraded-banner";
|
||||
banner.style.cssText =
|
||||
"background:rgba(239,68,68,0.15);color:var(--red);padding:8px 12px;" +
|
||||
"border-radius:8px;text-align:center;font-size:13px;margin:8px 0;";
|
||||
banner.innerHTML =
|
||||
'⚠ No audio — connection may be lost.<br>' +
|
||||
'<small style="color:var(--text-dim)">Try hanging up and reconnecting, or switch to a different relay.</small>';
|
||||
// Insert at the top of the call screen, below the header
|
||||
const participants = document.getElementById("participants");
|
||||
const directView = document.getElementById("direct-call-view");
|
||||
const insertBefore = (directView && !directView.classList.contains("hidden"))
|
||||
? directView
|
||||
: participants;
|
||||
if (insertBefore?.parentNode) {
|
||||
insertBefore.parentNode.insertBefore(banner, insertBefore);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kind === "media-recovered") {
|
||||
const banner = document.getElementById("media-degraded-banner");
|
||||
if (banner) banner.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings ──
|
||||
function openSettings() {
|
||||
const s = loadSettings();
|
||||
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
|
||||
sDredDebug.checked = !!s.dredDebugLogs;
|
||||
sCallDebug.checked = !!s.callDebugLogs;
|
||||
// Show the debug-log panel only when the user has the flag on —
|
||||
// keeps the settings panel short in normal use.
|
||||
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
|
||||
renderCallDebugLog();
|
||||
const qi = qualityToIndex(s.quality || "auto");
|
||||
sQuality.value = String(qi);
|
||||
updateQualityUI(qi);
|
||||
@@ -746,6 +1208,123 @@ function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
||||
|
||||
settingsBtnHome.addEventListener("click", openSettings);
|
||||
settingsBtnCall.addEventListener("click", openSettings);
|
||||
// "STUN for QUIC" — ask the registered relay for our own public
|
||||
// address. Requires register_signal to have been run first
|
||||
// (otherwise the Rust side returns "not registered"). The button
|
||||
// shows its working state inline so the user knows it's waiting on
|
||||
// the relay rather than the network.
|
||||
// Phase 2 multi-relay NAT type detection. Probes every configured
|
||||
// relay in parallel and classifies the result.
|
||||
//
|
||||
// Cone = P2P direct path viable, green cue
|
||||
// SymmetricPort = per-destination port mapping, informational
|
||||
// (P2P will fall back to relay but calls still work)
|
||||
// Multiple = classifier saw different public IPs; informational
|
||||
// Unknown = not enough public probes, neutral
|
||||
//
|
||||
// The classifier drops LAN / private / CGNAT reflex addrs before
|
||||
// deciding, so a mixed "LAN relay + internet relay" setup does NOT
|
||||
// falsely flag as symmetric. Failed probes are shown in the list
|
||||
// for transparency but dimmed, not highlighted.
|
||||
sNatDetectBtn.addEventListener("click", async () => {
|
||||
const s = loadSettings();
|
||||
if (!s.relays || s.relays.length === 0) {
|
||||
sNatType.textContent = "⚠ no relays configured";
|
||||
sNatType.style.color = "var(--yellow)";
|
||||
return;
|
||||
}
|
||||
sNatType.textContent = "probing...";
|
||||
sNatType.style.color = "var(--text)";
|
||||
sNatProbes.innerHTML = "";
|
||||
sNatDetectBtn.disabled = true;
|
||||
try {
|
||||
const detection = await invoke<{
|
||||
probes: Array<{
|
||||
relay_name: string;
|
||||
relay_addr: string;
|
||||
observed_addr: string | null;
|
||||
latency_ms: number | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
nat_type: "Cone" | "SymmetricPort" | "Multiple" | "Unknown";
|
||||
consensus_addr: string | null;
|
||||
}>("detect_nat_type", {
|
||||
relays: s.relays.map((r) => ({ name: r.name, address: r.address })),
|
||||
});
|
||||
|
||||
const verdictLabel =
|
||||
detection.nat_type === "Cone"
|
||||
? `✓ Cone NAT — P2P viable (${detection.consensus_addr})`
|
||||
: detection.nat_type === "SymmetricPort"
|
||||
? "ℹ Symmetric NAT — P2P falls back to relay, calls still work"
|
||||
: detection.nat_type === "Multiple"
|
||||
? "ℹ Multiple public IPs observed"
|
||||
: "? Unknown (not enough public probes)";
|
||||
|
||||
// Only Cone is "good news green". Everything else is neutral
|
||||
// informational — the user has configured relays so any
|
||||
// classification result just describes their network; none
|
||||
// are "wrong" per se.
|
||||
const verdictColor =
|
||||
detection.nat_type === "Cone"
|
||||
? "var(--green)"
|
||||
: "var(--text-dim)";
|
||||
|
||||
sNatType.textContent = verdictLabel;
|
||||
sNatType.style.color = verdictColor;
|
||||
|
||||
sNatProbes.innerHTML = detection.probes
|
||||
.map((p) => {
|
||||
if (p.observed_addr) {
|
||||
return `<div>• ${escapeHtml(p.relay_name)} (${escapeHtml(
|
||||
p.relay_addr
|
||||
)}) → ${escapeHtml(p.observed_addr)} [${p.latency_ms ?? "?"}ms]</div>`;
|
||||
} else {
|
||||
// Failed probes are dimmed, not highlighted — the classifier
|
||||
// already ignores them, and the user doesn't need to be
|
||||
// alarmed by a momentarily-offline relay.
|
||||
return `<div style="color:var(--text-dim);opacity:0.7">• ${escapeHtml(
|
||||
p.relay_name
|
||||
)} (${escapeHtml(p.relay_addr)}) → ${escapeHtml(
|
||||
p.error ?? "probe failed"
|
||||
)}</div>`;
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
} catch (e: any) {
|
||||
sNatType.textContent = `⚠ ${String(e)}`;
|
||||
sNatType.style.color = "var(--red)";
|
||||
sNatProbes.innerHTML = "";
|
||||
} finally {
|
||||
sNatDetectBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
sReflectBtn.addEventListener("click", async () => {
|
||||
sReflectedAddr.textContent = "querying...";
|
||||
sReflectBtn.disabled = true;
|
||||
try {
|
||||
const addr = await invoke<string>("get_reflected_address");
|
||||
sReflectedAddr.textContent = addr;
|
||||
sReflectedAddr.style.color = "var(--green)";
|
||||
} catch (e: any) {
|
||||
// Two main failure modes surfaced via the error string:
|
||||
// - "not registered" — user hasn't registered
|
||||
// against a relay yet
|
||||
// - "reflect timeout (relay may not support reflection)"
|
||||
// — old relay, pre-Phase-1
|
||||
const msg = String(e);
|
||||
sReflectedAddr.textContent = msg.includes("not registered")
|
||||
? "⚠ register first"
|
||||
: msg.includes("timeout")
|
||||
? "⚠ relay does not support reflection"
|
||||
: `⚠ ${msg}`;
|
||||
sReflectedAddr.style.color = "var(--yellow)";
|
||||
} finally {
|
||||
sReflectBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
settingsClose.addEventListener("click", closeSettings);
|
||||
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
|
||||
|
||||
@@ -753,7 +1332,15 @@ settingsSave.addEventListener("click", () => {
|
||||
const s = loadSettings();
|
||||
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
|
||||
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
|
||||
s.dredDebugLogs = sDredDebug.checked;
|
||||
s.callDebugLogs = sCallDebug.checked;
|
||||
saveSettingsObj(s);
|
||||
// Push the new flags to the Rust side immediately so the next
|
||||
// frame / call already honors them without waiting for a restart.
|
||||
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
|
||||
invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {});
|
||||
// Reveal or hide the debug-log panel based on the new setting.
|
||||
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
|
||||
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
|
||||
renderRecentRooms(s.recentRooms);
|
||||
closeSettings();
|
||||
@@ -939,11 +1526,51 @@ clearHistoryBtn.addEventListener("click", async () => {
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
|
||||
// Track whether a registration is in flight so the same button
|
||||
// can toggle between "Register" and "Cancel". The cancel path
|
||||
// calls deregister which closes the transport and makes the
|
||||
// in-flight connect fail, breaking the await cleanly.
|
||||
let registerInFlight = false;
|
||||
|
||||
registerBtn.addEventListener("click", async () => {
|
||||
// ── Cancel path: user tapped the button while registration
|
||||
// is in flight (it says "Cancel") → tear down the attempt
|
||||
// so we don't block for 30s on an unreachable relay.
|
||||
if (registerInFlight) {
|
||||
registerInFlight = false;
|
||||
try { await invoke("deregister"); } catch {}
|
||||
registerBtn.textContent = "Register on Relay";
|
||||
registerBtn.disabled = false;
|
||||
connectError.textContent = "Registration cancelled";
|
||||
return;
|
||||
}
|
||||
|
||||
const relay = getSelectedRelay();
|
||||
if (!relay) { connectError.textContent = "No relay selected"; return; }
|
||||
connectError.textContent = "";
|
||||
|
||||
// ── Pre-flight ping: quick 3s QUIC handshake to check if
|
||||
// the relay is reachable BEFORE committing to the full
|
||||
// register flow (which takes ~10s to time out against a dead
|
||||
// host). If the ping fails, show "server unavailable"
|
||||
// immediately without blocking.
|
||||
registerBtn.textContent = "Checking...";
|
||||
registerBtn.disabled = true;
|
||||
registerBtn.textContent = "Registering...";
|
||||
try {
|
||||
await invoke("ping_relay", { relay: relay.address });
|
||||
} catch (e: any) {
|
||||
connectError.textContent = `Server unavailable: ${String(e)}`;
|
||||
registerBtn.disabled = false;
|
||||
registerBtn.textContent = "Register on Relay";
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Register path: ping succeeded, proceed with the full
|
||||
// registration. Show "Cancel" on the button so the user
|
||||
// can bail if the relay goes unreachable mid-handshake.
|
||||
registerInFlight = true;
|
||||
registerBtn.disabled = false;
|
||||
registerBtn.textContent = "Cancel";
|
||||
try {
|
||||
const fp = await invoke<string>("register_signal", { relay: relay.address });
|
||||
registerBtn.classList.add("hidden");
|
||||
@@ -951,9 +1578,14 @@ registerBtn.addEventListener("click", async () => {
|
||||
callStatusText.textContent = `Your fingerprint: ${fp}`;
|
||||
refreshHistory();
|
||||
} catch (e: any) {
|
||||
connectError.textContent = String(e);
|
||||
if (registerInFlight) {
|
||||
// Real failure, not a user cancel
|
||||
connectError.textContent = String(e);
|
||||
}
|
||||
registerBtn.disabled = false;
|
||||
registerBtn.textContent = "Register on Relay";
|
||||
} finally {
|
||||
registerInFlight = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -975,6 +1607,10 @@ callBtn.addEventListener("click", async () => {
|
||||
const target = targetFpInput.value.trim();
|
||||
if (!target) return;
|
||||
callStatusText.textContent = "Calling...";
|
||||
// Remember the target for P2P participant display — on a
|
||||
// direct call the relay never sends RoomUpdate so pollStatus
|
||||
// would otherwise show "Waiting for participants...".
|
||||
directCallPeer = { fingerprint: target, alias: null };
|
||||
try {
|
||||
await invoke("place_call", { targetFp: target });
|
||||
} catch (e: any) {
|
||||
@@ -983,14 +1619,24 @@ callBtn.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
acceptCallBtn.addEventListener("click", async () => {
|
||||
ringer.stop();
|
||||
const status = await invoke<any>("get_signal_status");
|
||||
if (status.incoming_call_id) {
|
||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 });
|
||||
// mode=1 → AcceptTrusted — enables P2P direct path by
|
||||
// querying + advertising the callee's reflex addr in the
|
||||
// answer. The alternative is mode=2 → AcceptGeneric
|
||||
// (privacy mode) which intentionally skips the reflex query
|
||||
// to keep the callee's IP hidden from the caller but forces
|
||||
// the call onto the relay path. Default to trusted so the
|
||||
// Accept button gets real P2P; privacy can be a future
|
||||
// dedicated button if anyone needs it.
|
||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 1 });
|
||||
incomingCallPanel.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
rejectCallBtn.addEventListener("click", async () => {
|
||||
ringer.stop();
|
||||
const status = await invoke<any>("get_signal_status");
|
||||
if (status.incoming_call_id) {
|
||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
|
||||
@@ -1008,13 +1654,30 @@ listen("signal-event", (event: any) => {
|
||||
case "incoming":
|
||||
incomingCallPanel.classList.remove("hidden");
|
||||
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
|
||||
// Remember the peer for the P2P participant display.
|
||||
directCallPeer = {
|
||||
fingerprint: data.caller_fp || "",
|
||||
alias: data.caller_alias || null,
|
||||
};
|
||||
// Start ringing + fire a system notification. Both stop in
|
||||
// the hangup/answered/accepted paths below (and via the
|
||||
// accept/reject button handlers).
|
||||
ringer.start();
|
||||
notifyIncomingCall(
|
||||
data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown",
|
||||
);
|
||||
break;
|
||||
case "answered":
|
||||
callStatusText.textContent = `Call answered (${data.mode})`;
|
||||
ringer.stop();
|
||||
break;
|
||||
case "setup":
|
||||
callStatusText.textContent = "Connecting to media...";
|
||||
// Auto-connect to the call room
|
||||
ringer.stop();
|
||||
// Phase 3 hole-punching: peer_direct_addr carries the OTHER
|
||||
// party's reflex addr when both sides advertised one. Forward
|
||||
// to Rust connect() which currently logs it + takes the relay
|
||||
// path; Phase 3.5 will race direct vs relay here.
|
||||
(async () => {
|
||||
try {
|
||||
await invoke("connect", {
|
||||
@@ -1023,6 +1686,8 @@ listen("signal-event", (event: any) => {
|
||||
alias: aliasInput.value,
|
||||
osAec: osAecCheckbox.checked,
|
||||
quality: loadSettings().quality || "auto",
|
||||
peerDirectAddr: data.peer_direct_addr ?? null,
|
||||
peerLocalAddrs: data.peer_local_addrs ?? [],
|
||||
});
|
||||
showCallScreen();
|
||||
} catch (e: any) {
|
||||
@@ -1031,8 +1696,71 @@ listen("signal-event", (event: any) => {
|
||||
})();
|
||||
break;
|
||||
case "hangup":
|
||||
// Peer (or the relay) ended the call. Tear down OUR side
|
||||
// of the media engine and return to the connect screen
|
||||
// automatically — the user shouldn't have to hit End Call
|
||||
// on a call that's already over.
|
||||
//
|
||||
// Scenarios this handles:
|
||||
// * active direct call, peer hung up → disconnect + back
|
||||
// to connect screen
|
||||
// * incoming call was ringing but caller bailed → hide
|
||||
// incoming panel (no engine to disconnect)
|
||||
// * setup failure mid-handshake → same as above
|
||||
callStatusText.textContent = "";
|
||||
incomingCallPanel.classList.add("hidden");
|
||||
ringer.stop();
|
||||
(async () => {
|
||||
try {
|
||||
// disconnect errors out with "not connected" if there's
|
||||
// no active engine — safe to ignore, we just want to
|
||||
// make sure any engine IS torn down.
|
||||
await invoke("disconnect");
|
||||
} catch {}
|
||||
// Suppress the call-event "disconnected" auto-reconnect
|
||||
// path since this was a peer-initiated hangup, not a
|
||||
// transport drop.
|
||||
userDisconnected = true;
|
||||
if (!callScreen.classList.contains("hidden")) {
|
||||
showConnectScreen();
|
||||
}
|
||||
})();
|
||||
break;
|
||||
case "reconnecting":
|
||||
// Signal supervisor is retrying the relay connection. Show
|
||||
// a non-blocking indicator on the small status line INSIDE
|
||||
// the registered panel — do NOT touch directRegistered
|
||||
// itself, that's the parent that holds the entire
|
||||
// registered UI (address bar, call button, history, ...)
|
||||
// and overwriting its textContent wipes all children.
|
||||
{
|
||||
const relay = typeof data.relay === "string" ? data.relay : "relay";
|
||||
const status = document.getElementById("registered-status");
|
||||
if (status) {
|
||||
status.textContent = `🔄 reconnecting to ${relay}…`;
|
||||
(status as HTMLElement).style.color = "var(--yellow)";
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "registered":
|
||||
// Supervisor (re-)succeeded, or the first register landed.
|
||||
// Clear the reconnecting badge and keep the registered UI.
|
||||
{
|
||||
const fp = typeof data.fingerprint === "string" ? data.fingerprint : "";
|
||||
const status = document.getElementById("registered-status");
|
||||
if (status) {
|
||||
status.textContent = fp
|
||||
? `✅ Registered (${fp.slice(0, 16)}…)`
|
||||
: "✅ Registered — waiting for calls";
|
||||
(status as HTMLElement).style.color = "var(--green)";
|
||||
}
|
||||
// Make sure the registered panel is visible and the
|
||||
// Register button is hidden. This is the critical path
|
||||
// both for the first register and for a transparent
|
||||
// supervisor-driven reconnect.
|
||||
directRegistered.classList.remove("hidden");
|
||||
registerBtn.classList.add("hidden");
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -371,7 +371,65 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* ── Participants ── */
|
||||
/* ── Direct call phone-style layout ── */
|
||||
.direct-call-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 32px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
.dc-identicon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 0 24px rgba(74, 222, 128, 0.15);
|
||||
}
|
||||
.dc-identicon canvas,
|
||||
.dc-identicon svg,
|
||||
.dc-identicon img {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
.dc-name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
.dc-fp {
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
max-width: 280px;
|
||||
}
|
||||
.dc-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
color: var(--green);
|
||||
}
|
||||
.dc-badge.relay {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.dc-badge.connecting {
|
||||
background: rgba(250, 204, 21, 0.12);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ── Participants (group call layout) ── */
|
||||
.participants {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
@@ -1025,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Speaker routing button (non-muted earpiece state should not look red) */
|
||||
/* Audio routing button — highlight color depends on active route */
|
||||
#spk-btn.speaker-on .icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
#spk-btn.bt-on .icon {
|
||||
color: #60a5fa; /* blue-400 for Bluetooth */
|
||||
}
|
||||
|
||||
@@ -103,11 +103,13 @@ sequenceDiagram
|
||||
participant RNN as RNNoise<br/>(2 x 480)
|
||||
participant VAD as SilenceDetector
|
||||
participant Codec as Opus / Codec2
|
||||
participant DT as DredTuner<br/>(wzp-proto)
|
||||
participant FEC as RaptorQ FEC
|
||||
participant INT as Interleaver<br/>(depth=3)
|
||||
participant HDR as MediaHeader<br/>(12B or Mini 4B)
|
||||
participant Enc as ChaCha20-Poly1305
|
||||
participant QUIC as QUIC Datagram
|
||||
participant QPS as QuinnPathSnapshot
|
||||
|
||||
Mic->>Ring: f32 x 512 (macOS callback)
|
||||
Ring->>Ring: Accumulate to 960 samples
|
||||
@@ -118,10 +120,19 @@ sequenceDiagram
|
||||
else Silence (>100ms)
|
||||
VAD->>Codec: ComfortNoise (every 200ms)
|
||||
end
|
||||
Codec->>FEC: Compressed bytes (pad to 256B symbol)
|
||||
FEC->>FEC: Accumulate block (5-10 symbols)
|
||||
FEC->>INT: Source + repair symbols
|
||||
INT->>HDR: Interleaved packets
|
||||
|
||||
Note over QPS,DT: Every 25 frames (~500ms)
|
||||
QPS->>DT: loss_pct, rtt_ms, jitter_ms
|
||||
DT->>Codec: set_dred_duration() + set_expected_loss()
|
||||
|
||||
alt Opus tier (any bitrate)
|
||||
Codec->>HDR: Compressed bytes + DRED side-channel (no RaptorQ)
|
||||
else Codec2 tier
|
||||
Codec->>FEC: Compressed bytes (pad to 256B symbol)
|
||||
FEC->>FEC: Accumulate block (5-10 symbols)
|
||||
FEC->>INT: Source + repair symbols
|
||||
INT->>HDR: Interleaved packets
|
||||
end
|
||||
HDR->>Enc: Header as AAD
|
||||
Enc->>QUIC: Encrypted payload + 16B tag
|
||||
```
|
||||
@@ -134,6 +145,9 @@ sequenceDiagram
|
||||
- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
|
||||
- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
|
||||
- MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames
|
||||
- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
|
||||
- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
|
||||
- Opus6k DRED window: 1040ms (maximum libopus allows)
|
||||
|
||||
## Audio Decode Pipeline
|
||||
|
||||
@@ -154,13 +168,30 @@ sequenceDiagram
|
||||
Dec->>AR: Decrypt (header = AAD)
|
||||
AR->>AR: Check seq window (reject replay)
|
||||
AR->>HDR: Verified packet
|
||||
HDR->>DEINT: MediaHeader + payload
|
||||
DEINT->>FEC: Reordered symbols by block
|
||||
FEC->>FEC: Attempt decode (need K of K+R)
|
||||
FEC->>JIT: Recovered audio frames
|
||||
|
||||
alt Opus packet
|
||||
HDR->>JIT: Direct to jitter buffer (no FEC/interleave)
|
||||
else Codec2 packet
|
||||
HDR->>DEINT: MediaHeader + payload
|
||||
DEINT->>FEC: Reordered symbols by block
|
||||
FEC->>FEC: Attempt decode (need K of K+R)
|
||||
FEC->>JIT: Recovered audio frames
|
||||
end
|
||||
|
||||
JIT->>JIT: BTreeMap ordered by seq
|
||||
JIT->>JIT: Wait until depth >= target
|
||||
JIT->>Codec: Pop lowest seq frame
|
||||
|
||||
alt Packet present
|
||||
JIT->>Codec: Pop lowest seq frame
|
||||
else Packet missing (Opus)
|
||||
JIT->>Codec: DRED reconstruction (neural)
|
||||
alt DRED fails or unavailable
|
||||
Codec->>Codec: Classical PLC fallback
|
||||
end
|
||||
else Packet missing (Codec2)
|
||||
Codec->>Codec: Classical PLC
|
||||
end
|
||||
|
||||
Codec->>Ring: PCM i16 x 960
|
||||
Ring->>SPK: Audio callback pulls samples
|
||||
```
|
||||
@@ -172,6 +203,8 @@ sequenceDiagram
|
||||
- Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay
|
||||
- Desktop client uses **direct playout** (no jitter buffer) with lock-free ring
|
||||
- Codec2 frames at 8 kHz are resampled to 48 kHz transparently
|
||||
- DRED reconstruction: on packet loss, decoder tries neural DRED reconstruction before falling back to classical PLC
|
||||
- Jitter-spike detection pre-emptively boosts DRED to ceiling when jitter variance spikes >30%
|
||||
|
||||
## Relay SFU Forwarding
|
||||
|
||||
@@ -211,6 +244,7 @@ graph TB
|
||||
3. If one send fails, the relay continues to the next participant (best-effort)
|
||||
4. The relay never decodes or re-encodes audio (preserves E2E encryption)
|
||||
5. With trunking enabled, packets to the same receiver are batched into TrunkFrames (flushed every 5ms)
|
||||
6. Relay tracks per-participant quality from QualityReport trailers and broadcasts `QualityDirective` when the room-wide tier degrades (coordinated codec switching)
|
||||
|
||||
## Federation Topology
|
||||
|
||||
@@ -348,7 +382,7 @@ Used for 49 of every 50 frames (~1s cycle). Saves 8 bytes per packet (67% header
|
||||
[session_id: 2][len: u16][payload: len] x count
|
||||
```
|
||||
|
||||
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or 1200 bytes, flushed every 5ms.
|
||||
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or PMTUD-discovered MTU (starts at 1200, grows to ~1452 on Ethernet), flushed every 5ms.
|
||||
|
||||
### QualityReport (4 bytes, optional trailer)
|
||||
|
||||
@@ -361,6 +395,40 @@ Byte 3: bitrate_cap_kbps (0-255 kbps)
|
||||
|
||||
Appended to a media packet when the Q flag is set in the MediaHeader.
|
||||
|
||||
## Path MTU Discovery
|
||||
|
||||
Quinn's PLPMTUD is enabled with:
|
||||
- `initial_mtu`: 1200 bytes (QUIC minimum, always safe)
|
||||
- `upper_bound`: 1452 bytes (Ethernet minus IP/UDP/QUIC headers)
|
||||
- `interval`: 300s (re-probe every 5 minutes)
|
||||
- `black_hole_cooldown`: 30s (faster retry on lossy links)
|
||||
|
||||
The discovered MTU is exposed via `QuinnPathSnapshot::current_mtu` and used by:
|
||||
- `TrunkedForwarder`: refreshes `max_bytes` on every send to fill larger datagrams
|
||||
- Future video framer: larger MTU = fewer application-layer fragments per frame
|
||||
|
||||
## Continuous DRED Tuning
|
||||
|
||||
Instead of locking DRED duration to 3 discrete quality tiers, the `DredTuner` (in `wzp-proto::dred_tuner`) maps live path quality to a continuous DRED duration:
|
||||
|
||||
| Input | Source | Update Rate |
|
||||
|-------|--------|-------------|
|
||||
| Loss % | `QuinnPathSnapshot::loss_pct` (from quinn ACK frames) | Every 25 packets (~500ms) |
|
||||
| RTT ms | `QuinnPathSnapshot::rtt_ms` (quinn congestion controller) | Every 25 packets |
|
||||
| Jitter ms | `PathMonitor::jitter_ms` (EWMA of RTT variance) | Every 25 packets |
|
||||
|
||||
### Mapping Logic
|
||||
|
||||
- **Baseline**: codec-tier default (Studio=100ms, Good=200ms, Degraded=500ms)
|
||||
- **Ceiling**: codec-tier max (Studio=300ms, Good=500ms, Degraded=1040ms)
|
||||
- **Continuous**: linear interpolation between baseline and ceiling based on loss (0%->baseline, 40%->ceiling)
|
||||
- **RTT phantom loss**: high RTT (>200ms) adds phantom loss contribution to keep DRED generous
|
||||
- **Jitter spike**: >30% EWMA spike pre-emptively boosts to ceiling for ~5s cooldown
|
||||
|
||||
### Output
|
||||
|
||||
`DredTuning { dred_frames: u8, expected_loss_pct: u8 }` -> fed to `CallEncoder::apply_dred_tuning()` -> `OpusEncoder::set_dred_duration()` + `set_expected_loss()`
|
||||
|
||||
## Signal Message Handshake Flow
|
||||
|
||||
```mermaid
|
||||
@@ -940,3 +1008,67 @@ The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (
|
||||
This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
|
||||
|
||||
Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
|
||||
|
||||
## Network Awareness (Android)
|
||||
|
||||
The adaptive quality controller (`AdaptiveQualityController` in `wzp-proto`) supports proactive network-aware adaptation via `signal_network_change(NetworkContext)`. On Android, this is fed by `NetworkMonitor.kt` which wraps `ConnectivityManager.NetworkCallback`.
|
||||
|
||||
```
|
||||
ConnectivityManager
|
||||
│ onCapabilitiesChanged / onLost
|
||||
▼
|
||||
NetworkMonitor.kt ──classify──► type: Int (WiFi=0, LTE=1, 5G=2, 3G=3)
|
||||
│ onNetworkChanged(type, bw)
|
||||
▼
|
||||
CallViewModel ──► WzpEngine.onNetworkChanged()
|
||||
│ JNI
|
||||
▼
|
||||
jni_bridge.rs
|
||||
│
|
||||
▼
|
||||
EngineState.pending_network_type (AtomicU8, lock-free)
|
||||
│ polled every ~20ms
|
||||
▼
|
||||
recv task: quality_ctrl.signal_network_change(ctx)
|
||||
│
|
||||
├─ WiFi → Cellular: preemptive 1-tier downgrade
|
||||
├─ Any change: 10s FEC boost (+0.2 ratio)
|
||||
└─ Cellular: faster downgrade thresholds (2 vs 3)
|
||||
```
|
||||
|
||||
Cellular generation is approximated from `getLinkDownstreamBandwidthKbps()` to avoid requiring `READ_PHONE_STATE` permission.
|
||||
|
||||
## Audio Routing (Android)
|
||||
|
||||
Both Android app variants support 3-way audio routing: **Earpiece → Speaker → Bluetooth SCO**.
|
||||
|
||||
### Audio Mode Lifecycle
|
||||
|
||||
`MODE_IN_COMMUNICATION` is set by the Rust call engine (via JNI `AudioManager.setMode()`) right before Oboe streams open — NOT at app launch. Restored to `MODE_NORMAL` when the call ends. This prevents hijacking system audio routing (music, BT A2DP) before a call is active.
|
||||
|
||||
### Native Kotlin App
|
||||
|
||||
`AudioRouteManager.kt` handles device detection (via `AudioDeviceCallback`), SCO lifecycle, and auto-fallback on BT disconnect. `CallViewModel.cycleAudioRoute()` cycles through available routes.
|
||||
|
||||
### Tauri Desktop App
|
||||
|
||||
`android_audio.rs` provides JNI bridges to `AudioManager` for speakerphone and Bluetooth SCO control. After each route change, Oboe streams are stopped and restarted via `spawn_blocking`.
|
||||
|
||||
```
|
||||
User tap ──► cycleAudioRoute()
|
||||
│
|
||||
├─ Earpiece: setSpeakerphoneOn(false) + clearCommunicationDevice()
|
||||
├─ Speaker: setSpeakerphoneOn(true)
|
||||
└─ BT SCO: setCommunicationDevice(bt_device) [API 31+]
|
||||
│ fallback: startBluetoothSco() [API < 31]
|
||||
▼
|
||||
Oboe stop + start_bt() for BT / start() for others
|
||||
```
|
||||
|
||||
### BT SCO and Oboe
|
||||
|
||||
BT SCO only supports 8/16kHz. When `bt_active=1`, Oboe capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system choose the native BT rate. Oboe's `SampleRateConversionQuality::Best` bridges to our 48kHz ring buffers. Playout uses `Usage::Media` in BT mode to avoid conflicts with the communication device routing.
|
||||
|
||||
### Hangup Signal Fix
|
||||
|
||||
`SignalMessage::Hangup` now carries an optional `call_id` field. The relay uses it to end only the specific call instead of broadcasting to all active calls for the user — preventing a race where a hangup for call 1 kills a newly-placed call 2.
|
||||
|
||||
@@ -583,9 +583,79 @@ Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
|
||||
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
|
||||
| wzp-web | 2 | Metrics |
|
||||
|
||||
## Audio Routing (Android)
|
||||
|
||||
WarzonePhone supports three audio output routes on Android: **Earpiece**, **Speaker**, and **Bluetooth SCO**. The user cycles through available routes with a single button.
|
||||
|
||||
### Audio mode lifecycle
|
||||
|
||||
`MODE_IN_COMMUNICATION` is set **when the call engine starts** (right before Oboe `audio_start()`), not at app launch. This is critical — setting it early hijacks system audio routing (e.g. music drops from BT A2DP to earpiece). `MODE_NORMAL` is restored when the call engine stops.
|
||||
|
||||
```
|
||||
App launch → MODE_NORMAL (other apps' audio unaffected)
|
||||
Call start → set_audio_mode_communication() → MODE_IN_COMMUNICATION
|
||||
Call end → audio_stop() → set_audio_mode_normal() → MODE_NORMAL
|
||||
```
|
||||
|
||||
### Route lifecycle
|
||||
|
||||
1. Call starts → Earpiece (default).
|
||||
2. User taps route button → cycles to next available route.
|
||||
3. Route change requires Oboe stream restart (~60-400ms) because AAudio silently tears down streams on some OEMs when the routing target changes mid-stream.
|
||||
4. Bluetooth disconnect mid-call → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece or Speaker.
|
||||
|
||||
### Bluetooth SCO
|
||||
|
||||
SCO (Synchronous Connection Oriented) is the correct Bluetooth profile for VoIP — it provides bidirectional mono audio at 8/16 kHz with ~30ms latency. A2DP (stereo, high-quality) is unidirectional and adds 100-200ms of buffering, making it unsuitable for real-time voice.
|
||||
|
||||
On API 31+ (Android 12), we use the modern `setCommunicationDevice(AudioDeviceInfo)` API to route audio to the BT SCO device. The deprecated `startBluetoothSco()` + `setBluetoothScoOn()` path is used as fallback on older APIs. `setBluetoothScoOn()` is silently rejected on Android 12+ for non-system apps.
|
||||
|
||||
BT SCO devices only support 8/16kHz sample rates, but our pipeline runs at 48kHz. When BT is active, Oboe opens in **BT mode** (`bt_active=1`): capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system open at the device's native rate. Oboe's `SampleRateConversionQuality::Best` resamples to/from 48kHz for our ring buffers.
|
||||
|
||||
### Two app variants
|
||||
|
||||
Both the native Kotlin app (`AudioRouteManager.kt`) and the Tauri app (`android_audio.rs` JNI bridge) support BT SCO routing. The native app uses `AudioDeviceCallback` for automatic device detection; the Tauri app uses `getAvailableCommunicationDevices()` (API 31+) or `getDevices()` on demand.
|
||||
|
||||
## Network Change Response
|
||||
|
||||
The `AdaptiveQualityController` in `wzp-proto` reacts to network transport changes signaled via `signal_network_change(NetworkContext)`:
|
||||
|
||||
| Transition | Response |
|
||||
|-----------|----------|
|
||||
| WiFi → Cellular | Preemptive 1-tier quality downgrade + 10s FEC boost |
|
||||
| Cellular → WiFi | FEC boost only (quality recovers via normal adaptive logic) |
|
||||
| Any change | Reset hysteresis counters to avoid stale state |
|
||||
|
||||
On Android, `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback` and classifies the transport type using bandwidth heuristics (no `READ_PHONE_STATE` needed). The classification is delivered to the Rust engine via JNI → `AtomicU8` → recv task polling — the same lock-free cross-task signaling pattern used for adaptive profile switches.
|
||||
|
||||
### Cellular generation heuristics
|
||||
|
||||
| Downstream bandwidth | Classification |
|
||||
|---------------------|---------------|
|
||||
| >= 100 Mbps | 5G NR |
|
||||
| >= 10 Mbps | LTE |
|
||||
| < 10 Mbps | 3G or worse |
|
||||
|
||||
These thresholds are conservative. Carriers over-report bandwidth, but for VoIP quality decisions the exact generation matters less than the rough category.
|
||||
|
||||
## Build Requirements
|
||||
|
||||
- **Rust** 1.85+ (2024 edition)
|
||||
- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
|
||||
- **macOS**: Xcode command line tools (CoreAudio included)
|
||||
- **Android**: NDK r27c, cmake 3.28+ (from pip)
|
||||
- **Android**: NDK 26.1 (r26b), cmake 3.25-3.28 (system package)
|
||||
|
||||
### Android APK Builds
|
||||
|
||||
```bash
|
||||
# arm64 only (default, 25MB release APK)
|
||||
./scripts/build-tauri-android.sh --init --release --arch arm64
|
||||
|
||||
# armv7 only (smaller devices)
|
||||
./scripts/build-tauri-android.sh --init --release --arch armv7
|
||||
|
||||
# both architectures as separate APKs
|
||||
./scripts/build-tauri-android.sh --init --release --arch all
|
||||
```
|
||||
|
||||
Release APKs are signed with `android/keystore/wzp-release.jks` via `apksigner`. Per-arch builds produce separate APKs (~25MB each vs ~50MB universal) for easier sharing with testers.
|
||||
|
||||
105
docs/PRD-bluetooth-audio.md
Normal file
105
docs/PRD-bluetooth-audio.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# PRD: Bluetooth Audio Routing
|
||||
|
||||
> Phase: Implemented
|
||||
> Status: Ready for testing
|
||||
> Platforms: Android (native Kotlin app + Tauri desktop app)
|
||||
|
||||
## Problem
|
||||
|
||||
WarzonePhone had `AudioRouteManager.kt` with complete Bluetooth SCO support, but it was disconnected from both UIs. Users with Bluetooth headsets had no way to route call audio to them.
|
||||
|
||||
## Solution
|
||||
|
||||
Wire Bluetooth SCO routing end-to-end through both app variants, replacing the binary speaker toggle with a 3-way audio route cycle: **Earpiece → Speaker → Bluetooth**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Native Kotlin App (com.wzp) │
|
||||
│ │
|
||||
│ InCallScreen ──► CallViewModel ──► AudioRouteManager
|
||||
│ (Compose UI) cycleAudioRoute() setSpeaker() │
|
||||
│ "Ear/Spk/BT" audioRoute Flow setBluetoothSco()
|
||||
│ isBluetoothAvailable()
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Tauri Desktop App (com.wzp.desktop) │
|
||||
│ │
|
||||
│ main.ts ──► Tauri Commands ──► android_audio.rs │
|
||||
│ cycleAudioRoute() set_bluetooth_sco() JNI calls │
|
||||
│ "Ear/Spk/BT" is_bluetooth_available() │
|
||||
│ get_audio_route() │
|
||||
│ │
|
||||
│ After each route change: Oboe stop + start │
|
||||
│ (spawn_blocking to avoid stalling tokio) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components Modified
|
||||
|
||||
### Native Kotlin App
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `CallViewModel.kt` | Added `audioRoute: StateFlow<AudioRoute>`, `cycleAudioRoute()`, wired `onRouteChanged` callback |
|
||||
| `InCallScreen.kt` | `ControlRow` now takes `audioRoute: AudioRoute` + `onCycleRoute`, displays Ear/Spk/BT with distinct colors |
|
||||
|
||||
### Tauri App
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `android_audio.rs` | `setCommunicationDevice()` (API 31+) with `startBluetoothSco()` fallback; `set_audio_mode_communication/normal()` for call lifecycle |
|
||||
| `lib.rs` | `set_bluetooth_sco`, `is_bluetooth_available`, `get_audio_route` Tauri commands; SCO polling + 500ms route delay |
|
||||
| `wzp_native.rs` | Added `audio_start_bt()` for BT-mode Oboe (skips 48kHz + VoiceCommunication preset) |
|
||||
| `oboe_bridge.cpp` | `bt_active` flag: capture skips sample rate + input preset; playout uses `Usage::Media`; both use `Shared` mode + `SampleRateConversionQuality::Best` |
|
||||
| `engine.rs` | `set_audio_mode_communication()` before `audio_start()`; `set_audio_mode_normal()` after `audio_stop()` |
|
||||
| `MainActivity.kt` | Removed `MODE_IN_COMMUNICATION` from app launch — deferred to call start |
|
||||
| `main.ts` | Replaced `speakerphoneOn` toggle with `currentAudioRoute` cycling logic |
|
||||
| `style.css` | Added `.bt-on` CSS class (blue-400 highlight) |
|
||||
|
||||
## Audio Route Lifecycle
|
||||
|
||||
1. **App launch** → `MODE_NORMAL` (other apps' audio unaffected — BT A2DP music keeps playing)
|
||||
2. **Call starts** → `MODE_IN_COMMUNICATION` set via JNI, Oboe opens with earpiece routing
|
||||
3. **User taps route button** → cycles to next available route
|
||||
4. **Route changes** → `setCommunicationDevice()` (API 31+) + Oboe restart in BT mode or normal mode
|
||||
5. **BT device disconnects mid-call** → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece/Speaker
|
||||
6. **Call ends** → route reset, `MODE_NORMAL` restored
|
||||
|
||||
## Route Cycling Logic
|
||||
|
||||
```
|
||||
Available routes = [Earpiece, Speaker] + [Bluetooth] if SCO device connected
|
||||
|
||||
Tap cycle:
|
||||
Earpiece → Speaker → Bluetooth (if available) → Earpiece → ...
|
||||
|
||||
If BT not available:
|
||||
Earpiece → Speaker → Earpiece → ...
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
- `BLUETOOTH_CONNECT` (Android 12+) — already in `AndroidManifest.xml`
|
||||
- `MODIFY_AUDIO_SETTINGS` — already in manifest
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **SCO only** — no A2DP (stereo music profile). SCO is correct for VoIP (bidirectional mono).
|
||||
- **API 31+ required for modern path** — `setCommunicationDevice()` is the primary BT routing API. Fallback to deprecated `startBluetoothSco()` on API < 31 (untested).
|
||||
- **BT SCO capture at 8/16kHz** — Oboe resamples to 48kHz via `SampleRateConversionQuality::Best`. Quality is inherently limited by the SCO codec (CVSD at 8kHz or mSBC at 16kHz).
|
||||
- **No auto-switch on BT connect** — when a BT device connects mid-call, user must tap the route button.
|
||||
- **500ms route switch delay** — after `setCommunicationDevice()` returns, the audio policy needs time to apply the bt-sco route. We wait 500ms before restarting Oboe.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Pair a Bluetooth SCO headset with Android device
|
||||
2. Start call → verify Earpiece is default
|
||||
3. Tap route → Speaker (audio moves to loudspeaker, button shows "Spk")
|
||||
4. Tap route → BT (audio moves to headset, button shows "BT", blue highlight)
|
||||
5. Tap route → Earpiece (audio back to earpiece, button shows "Ear")
|
||||
6. Disconnect BT mid-call → verify auto-fallback
|
||||
7. Verify both app variants work identically
|
||||
8. Verify no audio glitches during route transitions
|
||||
@@ -196,3 +196,19 @@ Implementation strategy: build for P2P first (simpler, 2 parties), then wrap the
|
||||
| 4 | Upgrade proposal + negotiation protocol | 2 days |
|
||||
| 5 | P2P quality adaptation (direct observation) | 1 day |
|
||||
| 6 | Per-participant asymmetric encoding (Option 2) | 1 day |
|
||||
|
||||
## Implementation Status (2026-04-12)
|
||||
|
||||
Phases 1-2 are now implemented:
|
||||
|
||||
### What was built
|
||||
|
||||
- **`QualityDirective` signal** (`crates/wzp-proto/src/packet.rs`): New `SignalMessage` variant with `recommended_profile` and optional `reason`
|
||||
- **`ParticipantQuality`** (`crates/wzp-relay/src/room.rs`): Per-participant quality tracking using `AdaptiveQualityController`, created on join, removed on leave
|
||||
- **Weakest-link broadcast**: `observe_quality()` method computes room-wide worst tier, broadcasts `QualityDirective` to all participants when tier changes
|
||||
- **Desktop engine handling** (`desktop/src-tauri/src/engine.rs`): `AdaptiveQualityController` in recv task, `pending_profile` AtomicU8 bridge to send task, auto-mode profile switching
|
||||
|
||||
### Phases 3-4 remaining
|
||||
|
||||
- Phase 3: Client-side handling of `QualityDirective` (reacting to relay-pushed profile)
|
||||
- Phase 4: Upgrade proposal/negotiation protocol for quality recovery
|
||||
|
||||
388
docs/PRD-dred-integration.md
Normal file
388
docs/PRD-dred-integration.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# PRD: DRED Integration & Opus-Tier FEC Simplification
|
||||
|
||||
## Problem
|
||||
|
||||
WarzonePhone's audio loss-recovery stack is built around classical Opus + application-level RaptorQ FEC. It was the right answer when WZP was designed, but libopus 1.5 (December 2023) introduced **Deep REDundancy (DRED)** — a neural speech-recovery feature that is strictly better than classical FEC for the loss patterns VoIP calls actually experience. We are paying real latency, bitrate, and complexity costs for protection that DRED now does better and cheaper.
|
||||
|
||||
Concretely, on every Opus call today we pay:
|
||||
|
||||
- **~40–100 ms of receiver-side latency** waiting for RaptorQ block completion before decode
|
||||
- **10–20% bitrate overhead** from RaptorQ repair symbols (more on studio profiles)
|
||||
- **~20–40% codec-internal overhead** from Opus inband FEC (LBRR)
|
||||
- Classical Opus PLC on loss bursts exceeding the RaptorQ block size — which sounds robotic and gap-ridden
|
||||
|
||||
…in exchange for bit-exact recovery of isolated single-frame losses, which is perceptually indistinguishable from classical Opus PLC for 20 ms of speech. The protection is misaligned with the failure modes.
|
||||
|
||||
DRED delivers:
|
||||
|
||||
- **Zero added receive latency** — reconstruction runs only on detected loss
|
||||
- **~1 kbps flat bitrate overhead** regardless of base bitrate
|
||||
- **Plausible reconstruction of bursts up to ~1 second** — DRED's headline capability, exactly the regime RaptorQ can't touch
|
||||
- Neural PLC that sounds like continuous speech, not a gap
|
||||
|
||||
We also have a second, unrelated problem blocking adoption: our FFI crate `audiopus_sys 0.2.2` vendors **libopus 1.3**, predating DRED entirely. We cannot enable DRED without first swapping the FFI layer. The naïve choice (`opus` crate from SpaceManiac) is a trap — it depends on the same dead `audiopus_sys`. The real target is `opusic-c 1.5.5` by DoumanAsh, which vendors libopus 1.5.2 with full DRED support and documents Android NDK cross-compile.
|
||||
|
||||
This PRD covers the FFI swap, DRED enablement, the decision to **remove RaptorQ and Opus inband FEC from the Opus tiers entirely** (keeping RaptorQ only for Codec2 where DRED is N/A), and the jitter buffer refactor that the DRED lookahead/backfill pattern requires.
|
||||
|
||||
## Goals
|
||||
|
||||
- Replace `audiopus 0.3.0-rc.0` + `audiopus_sys 0.2.2` (dead upstream, libopus 1.3) with `opusic-c 1.5.5` + `opusic-sys 0.6.0` (active upstream, libopus 1.5.2)
|
||||
- Enable DRED on every Opus profile with a tiered duration policy, lower at studio bitrates and higher at degraded bitrates
|
||||
- Disable Opus inband FEC (LBRR) on all Opus profiles — opusic-c's own docs recommend this, and it overlaps DRED's job
|
||||
- Remove `wzp-fec` (RaptorQ) from the Opus tiers entirely — the latency and bitrate savings are real, and DRED strictly dominates it on speech
|
||||
- Keep RaptorQ + current FEC ratios on the Codec2 tiers unchanged — DRED is libopus-only, Codec2 has no neural equivalent
|
||||
- Refactor `wzp-transport::jitter` to a lookahead/backfill pattern that lets DRED reconstruct loss windows when the next packet arrives, instead of the current "wait for block completion or fall through to classical PLC" policy
|
||||
- Ship behind a runtime escape hatch (`AUDIO_USE_LEGACY_FEC`) for the first rollout window so we can revert to RaptorQ if DRED has surprises in real-world conditions
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing Codec2 at all. Codec2 1200 / 3200 are outside the DRED lineage and keep their current RaptorQ protection, block sizes, and PLC path.
|
||||
- Adding new Opus bitrate tiers or changing the quality adaptation thresholds. This PRD is about the protection layer, not the bitrate ladder.
|
||||
- Enabling OSCE (Opus Speech Coding Enhancement — a separate libopus 1.5 neural post-processor that opusic-c exposes via an `osce` feature flag). Valuable, complementary, and free once opusic-c is in — but out of scope here to keep the PRD focused. Track as follow-up.
|
||||
- Video, audio-over-MoQ, or any protocol-layer changes discussed in prior conversations.
|
||||
- Touching the wzp-web / browser client. Browser Opus is a separate codepath via WebAudio / WASM libopus and is not affected by the native FFI swap.
|
||||
|
||||
## Background
|
||||
|
||||
### How the three protection mechanisms actually differ
|
||||
|
||||
| | Opus inband FEC (LBRR) | RaptorQ (wzp-fec) | DRED |
|
||||
|---|---|---|---|
|
||||
| Layer | codec-internal | application, across Opus packets | codec-internal |
|
||||
| What it sends | low-bitrate copy of the *previous* frame, embedded in every packet | fountain-code repair symbols across a block | neural-coded history of the recent past |
|
||||
| Protection horizon | 1 packet back | block duration (currently 100 ms, proposed 40 ms) | configurable, 0–1040 ms |
|
||||
| Recovery granularity | 1 frame (lower quality) | 1 frame (bit-exact) | 10 ms frames (plausible reconstruction) |
|
||||
| Latency cost | 0 ms | block duration on receive | 0 ms |
|
||||
| Bitrate cost | ~20–40% of base | `fec_ratio × base` (currently +20% GOOD, +50% DEGRADED) | ~1 kbps flat |
|
||||
| Effective loss tolerance | ~single-packet losses | up to `(repair symbols / block)` losses, cliff beyond | bursts up to the configured duration |
|
||||
| Content assumption | any Opus audio | any | speech (DRED model is speech-trained) |
|
||||
|
||||
### Why DRED dominates on the Opus tiers
|
||||
|
||||
Loss-scenario walkthrough (verified against opusic-c and libopus 1.5 docs):
|
||||
|
||||
- **1-frame loss (20 ms)**: RaptorQ recovers bit-exactly, DRED wouldn't run (classical Opus PLC is perceptually indistinguishable for single 20 ms frames). RaptorQ "wins" on paper but not on ears.
|
||||
- **2–3 frame burst (40–60 ms)**: RaptorQ at current ratio 0.2 hits its tolerance cliff. DRED handles this trivially — well within a 200 ms window.
|
||||
- **5–10 frame burst (100–200 ms)**: RaptorQ completely overwhelmed at any reasonable ratio. DRED's sweet spot.
|
||||
- **10+ frame burst (>200 ms)**: RaptorQ useless. DRED at 500–1000 ms still recovers.
|
||||
|
||||
The only scenario where RaptorQ strictly beats DRED is bit-exact recovery of isolated single-frame losses — which is perceptually irrelevant for speech. In every other scenario DRED either ties or wins.
|
||||
|
||||
### Why Codec2 keeps RaptorQ
|
||||
|
||||
DRED lives inside libopus — it does not help Codec2 at all. Codec2's classical PLC is a parametric-vocoder interpolation that produces noticeably robotic artifacts on loss. On the Codec2 tiers, RaptorQ is the only protection we have, and it should stay at current ratios (1.0 on CATASTROPHIC, 0.5 on the Codec2 3200 tier).
|
||||
|
||||
### The opusic-c / opusic-sys situation
|
||||
|
||||
- `opusic-sys 0.6.0` — FFI crate, published 2026-03-17, vendors libopus 1.5.2 via its `bundled` feature (on by default), documents Android NDK cross-compile via `ANDROID_NDK_HOME` (which our `wzp-android/build.rs` already sets). Exposes raw bindings to `opus_dred_parse`, `opus_decoder_dred_decode`, and the `OpusDRED` state struct.
|
||||
- `opusic-c 1.5.5` — high-level safe wrapper. Its **encoder** side is fine: exposes `Encoder::set_dred_duration(value: u8) -> Result<(), ErrorCode>` with range `0..=104` (each unit is 10 ms, so 0–1040 ms configurable). Also exposes `set_bitrate`, `set_inband_fec`, `set_dtx`, `set_packet_loss`, `set_signal`, `set_complexity`, `set_bandwidth`, `set_application` on the encoder.
|
||||
- **opusic-c's decoder-side DRED wrapper is NOT sufficient for our architecture.** Confirmed by reading the source of `opusic-c/src/dred.rs`:
|
||||
1. `Dred::decode_to` ignores the `dred_end` output of `opus_dred_parse` (prefixed `_dred_end`), so the caller cannot know how much DRED history a given packet actually carried.
|
||||
2. In `opus_decoder_dred_decode(decoder, dred, dred_offset, pcm, frame_size)`, the wrapper passes `frame_size` to BOTH the `dred_offset` and `frame_size` arguments. This looks like a bug — it means reconstruction always starts at offset `frame_size` into the DRED window, not at an arbitrary caller-chosen offset. Arbitrary-gap reconstruction (which we need for the lookahead/backfill pattern) requires proper offset control.
|
||||
3. `DredPacket` is owned internally by a `Dred` instance; its internal buffer is overwritten on every `decode_to` call. We cannot hold a ring of parsed DredPackets from multiple recent arrivals — which is exactly what the lookahead/backfill jitter buffer pattern requires.
|
||||
- **Decision**: use opusic-c for the encoder path (its wrapper is correct and saves work), and drop to `opusic-sys` raw FFI for the entire decoder path AND the DRED reconstruction path. Both use a single shared `DecoderHandle` so internal decoder state stays consistent. **Verified at pre-flight**: `opusic_c::Decoder.inner` is `pub(crate)`, so there is no way to reach the raw `*mut OpusDecoder` from outside opusic-c. Running two parallel decoders (one from opusic-c for audio, one from opusic-sys for DRED) would cause state drift because the DRED-only decoder wouldn't see the normal decode calls. Single unified decoder via opusic-sys is the only correct architecture.
|
||||
- **Three FFI handles required** per decode session: `opusic_c::Encoder` (encoder side, unchanged), our own `DecoderHandle` wrapping `*mut OpusDecoder` from opusic-sys (for normal decode AND for the `OpusDecoder` pointer passed to `opus_decoder_dred_decode`), and a new `DredDecoderHandle` wrapping `*mut OpusDREDDecoder` from opusic-sys (passed to `opus_dred_parse`). Note: `OpusDREDDecoder` is a **separate struct** from `OpusDecoder` in libopus 1.5 — verified from opus.h. Allocation via `opus_dred_decoder_create()` (confirm exact symbol name at Phase 3a start).
|
||||
- The `opus` crate from SpaceManiac (0.3.1, published 2026-01-03) is a trap: it depends on `audiopus_sys ^0.2.0` — the same dead FFI crate we're trying to get away from. Do not use.
|
||||
- **Follow-up (out of scope for this PRD)**: upstream the fixes to `opusic-c/src/dred.rs` (preserve `dred_end`, fix the `dred_offset` double-pass, expose `DredPacket` externally). Worth a GitHub PR once our own implementation has proven correct. Would let us eventually delete our internal FFI wrapper.
|
||||
|
||||
### Critical note from opusic-c docs
|
||||
|
||||
From the `dred` module documentation: *"The documentation recommends disabling in-band FEC and using `Application::Voip` for optimal results."* This applies to the **codec-internal** Opus inband FEC (LBRR), not our application-level RaptorQ. The two are independent layers. This PRD disables both on Opus tiers, but for different reasons — inband FEC per upstream recommendation, RaptorQ per the analysis above.
|
||||
|
||||
### The libopus 1.5 loss-percentage gating quirk
|
||||
|
||||
In libopus 1.5, both inband FEC and DRED are gated on `OPUS_SET_PACKET_LOSS_PERC` being non-zero. If the encoder thinks loss is 0%, it will not emit DRED data even when `set_dred_duration` is configured. We must plumb a meaningful loss percentage into the encoder continuously, floored at a small non-zero value so DRED stays active even when the network is perfect. Planned floor: **5%**, overridden upward by the real `QualityReport` loss value when it exceeds the floor.
|
||||
|
||||
## Solution
|
||||
|
||||
### High-level architecture change
|
||||
|
||||
**Before** (per Opus frame encode path):
|
||||
```
|
||||
PCM → AdaptiveEncoder.encode (Opus)
|
||||
→ inband FEC embedded in packet
|
||||
→ wzp-fec FEC encoder (accumulate into block, generate repair symbols)
|
||||
→ DATAGRAM out
|
||||
```
|
||||
|
||||
**Before** (per Opus frame decode path):
|
||||
```
|
||||
DATAGRAM in → wzp-fec block assembly (wait for block, recover if possible)
|
||||
→ AdaptiveDecoder.decode (Opus) / decode_lost (classical PLC)
|
||||
→ PCM
|
||||
```
|
||||
|
||||
**After** (Opus tiers):
|
||||
```
|
||||
PCM → OpusEncoder.encode (opusic-c, DRED enabled via set_dred_duration, inband FEC off)
|
||||
→ DATAGRAM out directly (no RaptorQ block)
|
||||
```
|
||||
|
||||
```
|
||||
DATAGRAM in → jitter buffer (lookahead/backfill)
|
||||
→ on frame arrival: OpusDecoder.decode
|
||||
→ on detected gap: if next packet has DRED state → dred::Dred.reconstruct(gap)
|
||||
else → OpusDecoder.decode_lost (classical PLC)
|
||||
→ PCM
|
||||
```
|
||||
|
||||
**After** (Codec2 tiers): unchanged. RaptorQ block encoding + classical Codec2 decode path stay exactly as they are today.
|
||||
|
||||
### New per-profile protection matrix
|
||||
|
||||
| Profile | Codec | Inband FEC | RaptorQ ratio | DRED duration | Total overhead |
|
||||
|---|---|---|---|---|---|
|
||||
| `STUDIO_64K` | Opus 64k | **off** | **none** | **10 frames (100 ms)** | +1 kbps |
|
||||
| `STUDIO_48K` | Opus 48k | **off** | **none** | **10 frames (100 ms)** | +1 kbps |
|
||||
| `STUDIO_32K` | Opus 32k | **off** | **none** | **10 frames (100 ms)** | +1 kbps |
|
||||
| `GOOD` | Opus 24k | **off** | **none** | **20 frames (200 ms)** | +1 kbps |
|
||||
| `NORMAL_16K` | Opus 16k | **off** | **none** | **20 frames (200 ms)** | +1 kbps |
|
||||
| `DEGRADED` | Opus 6k | **off** | **none** | **50 frames (500 ms)** | +1 kbps |
|
||||
| `CODEC2_3200` | Codec2 3200 | N/A | **0.5 (unchanged)** | N/A | +50% |
|
||||
| `CATASTROPHIC` | Codec2 1200 | N/A | **1.0 (unchanged)** | N/A | +100% |
|
||||
| `COMFORT_NOISE` | CN | — | — | — | — |
|
||||
|
||||
DRED duration rationale:
|
||||
|
||||
- **Studio tiers (100 ms)**: loss is rare on the networks where users pick studio quality. Short DRED window keeps decode-side CPU modest. Still covers multi-frame bursts that classical PLC can't touch.
|
||||
- **Normal tiers (200 ms)**: balanced baseline. Handles the common VoIP loss pattern (20–150 ms bursts from wifi roam, transient congestion).
|
||||
- **Degraded tier (500 ms)**: users on Opus 6k are by definition on a bad link. Long DRED window buys maximum burst resilience where it matters most. Still well under the 1040 ms cap.
|
||||
|
||||
### Runtime escape hatch
|
||||
|
||||
Ship with a single environment variable / settings flag: **`AUDIO_USE_LEGACY_FEC`**. When set, the entire Opus-tier path reverts to the pre-PRD behavior: RaptorQ re-enabled at the old ratios, Opus inband FEC re-enabled, DRED disabled (`set_dred_duration(0)`). This is the rollback safety valve for the first production window.
|
||||
|
||||
Escape hatch semantics:
|
||||
- Read once at `CallEncoder::new` / `CallDecoder::new` time. Call-scoped, not re-read mid-call.
|
||||
- Exposed via Android Settings UI as a hidden "Legacy FEC (debug)" toggle, and as a CLI flag `--legacy-fec` on the desktop client.
|
||||
- Logged in `DebugReporter` so we can tell which mode a call was in when diagnosing.
|
||||
- Removed entirely after 2 months of stable production with no regressions reported. Removal is a follow-up PR, not part of this PRD's scope.
|
||||
|
||||
## Detailed design
|
||||
|
||||
### Phase 0 — FFI crate swap (prerequisite, no behavior change)
|
||||
|
||||
**Files touched:**
|
||||
- `Cargo.toml` (workspace root) — replace `audiopus = "0.3.0-rc.0"` with `opusic-c = { version = "1.5.5", features = ["bundled", "dred"] }` and `opusic-sys = { version = "0.6.0", features = ["bundled"] }`. The `opusic-sys` direct dep is for the DRED decoder path below.
|
||||
- `crates/wzp-codec/Cargo.toml` — update `audiopus = { workspace = true }` to `opusic-c = { workspace = true }`, add `opusic-sys = { workspace = true }`, add `bytemuck = "1"` for the i16↔u16 slice cast.
|
||||
- `crates/wzp-codec/src/opus_enc.rs` — rewrite against opusic-c. API mapping:
|
||||
- `audiopus::coder::Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)` → `opusic_c::Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip)` (argument order swapped)
|
||||
- `set_bitrate(Bitrate::BitsPerSecond(bps))` → `set_bitrate(Bitrate::Bits(bps))` or equivalent variant — verify at implementation time
|
||||
- `set_inband_fec(true/false)` → `set_inband_fec(InbandFec::On/Off)` (now an enum)
|
||||
- `set_packet_loss_perc(u8)` → `set_packet_loss(u8)` (method renamed)
|
||||
- `set_dtx(bool)`, `set_signal(Signal::Voice)`, `set_complexity(u8)` — names match
|
||||
- `encode(&[i16], &mut [u8])` → `encode_to_slice(&[u16], &mut [u8])` with `bytemuck::cast_slice::<i16, u16>(pcm)` at the call site
|
||||
- `crates/wzp-codec/src/opus_dec.rs` — same-style rewrite for the `Decoder` path. Note that opusic-c's decoder methods take `decode_fec: bool` as a parameter directly (not a separate ctl).
|
||||
- `vendor/audiopus_sys/` — delete the directory (only exists on `feat/desktop-audio-rewrite`, not on `android-rewrite`, so this is a no-op on the current branch but do remove the `[patch.crates-io]` block from Cargo.toml when merging back).
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `cargo check --workspace` passes on Linux x86_64, macOS, and Android NDK cross-compile.
|
||||
- All existing codec unit tests in `crates/wzp-codec/src/adaptive.rs` pass unchanged. DRED is still disabled at this phase (default `set_dred_duration(0)`), so behavior is equivalent to pre-swap libopus 1.3 for call quality purposes.
|
||||
- A short real-call smoke test produces audio identical to current behavior (no audible regression).
|
||||
- `opusic_c::version()` at startup logs libopus version containing `1.5.2` — hard signal that the swap landed correctly.
|
||||
|
||||
### Phase 1 — DRED encoder enable on all Opus profiles
|
||||
|
||||
**Files touched:**
|
||||
- `crates/wzp-codec/src/opus_enc.rs`:
|
||||
- Add `fn dred_duration_for(codec: CodecId) -> u8` returning the per-profile value from the matrix above (10 / 20 / 50 frames).
|
||||
- In `OpusEncoder::new`, after the existing `set_bitrate`/`set_signal`/`set_complexity` block: call `inner.set_inband_fec(InbandFec::Off)`, then `inner.set_dred_duration(dred_duration_for(profile.codec))`, then `inner.set_packet_loss(5)` as the default floor.
|
||||
- Add `pub fn set_dred_duration(&mut self, frames: u8)` to allow the adaptive ladder to update DRED duration on profile switch.
|
||||
- In the existing `set_profile` impl, call `set_dred_duration(dred_duration_for(profile.codec))` after `apply_bitrate`.
|
||||
- `crates/wzp-codec/src/adaptive.rs`:
|
||||
- `AdaptiveEncoder::set_profile` already delegates to `self.opus.set_profile` — no changes needed. DRED update rides along.
|
||||
- `crates/wzp-client/src/call.rs` (and equivalent on `wzp-android/src/pipeline.rs`):
|
||||
- In the `QualityReport` handler (wherever we currently call `set_expected_loss` / `set_packet_loss_perc`), also ensure the loss value is floored at 5% before passing to the Opus encoder. This is a 1-line change.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Encoder produces DRED-enabled Opus packets. Verifiable via libopus's reference decoder in debug mode, or by wire capture + inspection — a DRED-bearing Opus packet has a larger `opus_packet_get_nb_frames` footprint than a non-DRED one of the same nominal bitrate.
|
||||
- Total outgoing bitrate on Opus 24k is ~25 kbps (up from ~24 kbps) — confirms ~1 kbps DRED overhead.
|
||||
- On a lossless path, decoder output is audibly identical to Phase 0.
|
||||
- Escape hatch `AUDIO_USE_LEGACY_FEC=1` cleanly reverts the DRED enable (calls `set_dred_duration(0)` and `set_inband_fec(InbandFec::On)` instead).
|
||||
|
||||
### Phase 2 — RaptorQ removal on Opus tiers
|
||||
|
||||
**Files touched:**
|
||||
- `crates/wzp-client/src/call.rs`:
|
||||
- In `CallEncoder::encode_frame` (or wherever `wzp_fec::Encoder::add_source_symbol` is called), gate the RaptorQ path on `!profile.codec.is_opus()` — Opus frames go straight to DATAGRAM emit, Codec2 frames continue through RaptorQ.
|
||||
- When a profile switch crosses the Opus↔Codec2 boundary, flush/reset the RaptorQ encoder state.
|
||||
- `crates/wzp-android/src/pipeline.rs`:
|
||||
- Mirror the same gate in the Android encode path.
|
||||
- `crates/wzp-proto/src/packet.rs`:
|
||||
- `MediaHeader.fec_block` and `fec_symbol` are still valid fields on the wire. For Opus packets we emit `fec_block = 0`, `fec_symbol = 0`, `fec_ratio_encoded = 0`. No wire format change; the receiver just sees all-zeros in the FEC fields for Opus packets and skips the FEC decoder path.
|
||||
- Bump protocol version to v1 → v2? **No** — the change is semantically backward compatible because existing RaptorQ decoders handle a zero ratio correctly (ratio 0.0 means "no repair symbols expected"). Old receivers can still decode new Opus packets; they just won't see any DRED benefit because their libopus is old. This is a property we want: the opposite (new receiver, old sender) is the more common mixed-version case during rollout and also Just Works.
|
||||
- `crates/wzp-client/src/call.rs` — `CallDecoder`:
|
||||
- Symmetric change: Opus frames bypass the RaptorQ block assembly, go straight to the decoder. Only Codec2 frames (`codec_id.is_codec2()`) feed through `wzp-fec` block decoding.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Outgoing Opus packets have `fec_ratio_encoded == 0` (verifiable with the existing wire capture tooling in `wzp-client/src/echo_test.rs`).
|
||||
- On a clean network, receiver latency (measured as encode-to-playout one-way delay) drops by ~40 ms versus Phase 1. This is the primary win and should be directly measurable with the existing telemetry.
|
||||
- Codec2 calls show no latency change and no packet-format change. Regression-test Codec2 3200 and Codec2 1200 specifically.
|
||||
- Total outgoing bitrate on Opus 24k drops from ~28.8 kbps (24k base + 0.2 RaptorQ ratio) to ~25 kbps (24k base + ~1 kbps DRED). Direct savings observable in network telemetry.
|
||||
|
||||
### Phase 3 — DRED reconstruction wrapper + jitter buffer lookahead/backfill refactor
|
||||
|
||||
This phase is larger than originally estimated because opusic-c's decoder-side DRED wrapper is unusable for our architecture (see Background). We write our own safe wrapper over `opusic-sys` raw FFI first, then plumb it through the jitter buffer.
|
||||
|
||||
**Step 3a — Safe DRED reconstruction wrapper in `wzp-codec`:**
|
||||
|
||||
New file `crates/wzp-codec/src/dred_ffi.rs`. Wraps the raw libopus 1.5 DRED API:
|
||||
|
||||
- `pub struct DredState` — owns an `OpusDRED` buffer (allocated via `opusic_sys::opus_dred_alloc` or equivalent; size is fixed at 10,592 bytes per libopus 1.5). `Clone` is intentionally NOT implemented — the state is heap-owned and non-trivial to copy.
|
||||
- `pub fn parse_from_packet(&mut self, decoder: &opusic_c::Decoder, packet: &[u8], max_dred_samples: i32) -> Result<DredParseResult, DredError>` — wraps `opus_dred_parse`, preserves the `dred_end` output (number of samples of history the packet carried), returns it in `DredParseResult { samples_available: i32, frames_available: u8 }`.
|
||||
- `pub fn reconstruct_into(&self, decoder: &mut opusic_c::Decoder, dred_offset_samples: i32, output: &mut [i16]) -> Result<usize, DredError>` — wraps `opus_decoder_dred_decode`, takes the offset explicitly, decodes `output.len()` samples starting from that offset in the DRED window.
|
||||
- All `unsafe` contained here, strict bounds checking on offsets, Rust-level panic safety. Unit tests use a reference encoder + known-good reference decoder to verify that reconstruction at specific offsets produces expected output.
|
||||
- Depends on `opusic-sys` directly and on `opusic-c::Decoder` for the decoder handle. The Decoder handle must be reachable as a raw pointer; opusic-c exposes this via an unstable internal or we wrap the pointer ourselves. **Verify at implementation time** — if opusic-c doesn't expose the raw decoder pointer safely, we create our own thin Decoder wrapper in `dred_ffi.rs` using raw opusic-sys, losing the convenience of opusic-c's decoder but keeping its encoder. This is the smaller-risk fallback.
|
||||
|
||||
New `pub trait DredReconstructor` in `wzp-codec/src/lib.rs`:
|
||||
```rust
|
||||
pub trait DredReconstructor: Send {
|
||||
/// Parse DRED state from an arriving Opus packet into `state`.
|
||||
/// Returns number of 48 kHz samples of history available, or 0 if the packet has no DRED.
|
||||
fn parse(&mut self, state: &mut DredState, packet: &[u8]) -> Result<i32, DredError>;
|
||||
|
||||
/// Reconstruct `output.len()` samples from `state`, starting at the given
|
||||
/// sample offset (measured from the end of the DRED window going backward).
|
||||
fn reconstruct(&mut self, state: &DredState, offset_samples: i32, output: &mut [i16]) -> Result<usize, DredError>;
|
||||
}
|
||||
```
|
||||
|
||||
Implement `DredReconstructor` over the `dred_ffi::DredState` + opusic-c Decoder combination. This is the clean boundary the jitter buffer will talk to.
|
||||
|
||||
**Step 3b — Jitter buffer refactor in `crates/wzp-transport/src/jitter.rs`:**
|
||||
|
||||
- Current behavior: buffer waits a fixed number of frames of jitter before emitting; on a missing slot, after a timeout it gives up and signals the decoder to run `decode_lost()` (classical Opus PLC or Codec2 PLC).
|
||||
- New behavior on Opus tiers: when a frame arrives (in-order or late), first call `DredReconstructor::parse` on it to update a rolling ring of `DredState` instances tagged with their originating sequence number. When a gap is detected (missing sequence number between last-emitted and current arrival), and the ring contains a `DredState` from a nearby packet that covers the gap's sample offset, call `DredReconstructor::reconstruct` with the correct offset to synthesize the missing frames, splice them into playout, then continue normal decode.
|
||||
- If no DRED state covers the gap (e.g., gap too far back, or every nearby packet was dropped), fall through to classical PLC exactly as today. The classical path stays intact as the ultimate fallback.
|
||||
- Codec2 packets bypass the entire DRED ring. They are not inspected for DRED state and take the unchanged classical PLC path.
|
||||
- Ring sizing: `max_dred_duration_frames` + `jitter_depth_frames` worth of `DredState` instances. At 500 ms DRED on degraded tier + 60 ms jitter depth, that's ~28 DredState instances × 10,592 bytes ≈ 300 KB. Acceptable. On studio tier with 100 ms DRED it's only ~80 KB.
|
||||
- The jitter buffer takes a `Box<dyn DredReconstructor>` at construction, passed in by the call engine. `wzp-transport` does NOT take a direct dep on `opusic-c` or `opusic-sys` — it only knows about the trait defined in `wzp-codec`.
|
||||
|
||||
**Files touched:**
|
||||
- `crates/wzp-codec/src/dred_ffi.rs` (new, ~150–300 lines)
|
||||
- `crates/wzp-codec/src/lib.rs` — expose `DredReconstructor`, `DredState`, `DredError` types
|
||||
- `crates/wzp-codec/Cargo.toml` — add `opusic-sys = { workspace = true }` as a direct dep (already done in Phase 0)
|
||||
- `crates/wzp-transport/src/jitter.rs` — lookahead/backfill refactor, DRED ring
|
||||
- `crates/wzp-transport/Cargo.toml` — add `wzp-codec = { workspace = true }` (likely already present) for the trait import
|
||||
- `crates/wzp-client/src/call.rs` — construct a `DredReconstructor` and pass into `CallDecoder`'s jitter buffer
|
||||
- `crates/wzp-android/src/pipeline.rs` — same on Android
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Unit tests in `dred_ffi.rs`: round-trip a known speech waveform through an encoder with DRED enabled, parse the resulting packets, reconstruct at several different offsets, verify the reconstructed samples are within an energy/spectral threshold of the original. (Not bit-exact — DRED reconstruction is lossy by design.)
|
||||
- Synthetic loss test on the full pipeline: inject 200 ms bursts at 10% rate into a looped call, verify the DRED reconstruction rate on receiver telemetry is ≥95% of all loss events whose gaps fall within the configured DRED duration window.
|
||||
- Reconstructed audio is audibly continuous on 40–200 ms bursts — no gaps, no classical-PLC robot artifact. Verified on real voice samples (not just sine tones), and on at least two distinct speaker profiles (male, female) because DRED can have voice-dependent quality.
|
||||
- End-to-end latency metric is unchanged versus Phase 2 (no regression from adding the lookahead path). The DRED ring insertion on packet arrival must be O(1) in practice.
|
||||
- Existing `echo_test.rs` and `drift_test.rs` pass with the new jitter buffer.
|
||||
- Codec2 path uses classical PLC exclusively (no DRED invocation) because Codec2 packets don't carry DRED state. Verify by injecting loss on a Codec2 call and confirming zero DRED reconstruction telemetry events during that call.
|
||||
- `wzp-transport` has no direct dependency on `opusic-sys` or `opusic-c` in its `Cargo.toml` after the refactor — only on `wzp-codec`. Verify by grepping the Cargo.toml file.
|
||||
|
||||
### Phase 4 — Telemetry and tooling updates
|
||||
|
||||
**Files touched:**
|
||||
- `crates/wzp-proto/src/packet.rs` — `QualityReport` or equivalent telemetry message gains `dred_reconstructions: u32` as a new counter (frames reconstructed via DRED this reporting window) and `classical_plc_invocations: u32` (frames filled by Opus/Codec2 classical PLC). These are separate counters because they're different recovery mechanisms.
|
||||
- `crates/wzp-relay/src/*` — relay telemetry pipeline surfaces both counters in Prometheus metrics: `wzp_dred_reconstructions_total{call_id}`, `wzp_classical_plc_total{call_id}`.
|
||||
- `docs/grafana-dashboard.json` — new panel: "Loss recovery breakdown" stacked bar, DRED vs classical PLC vs clean decode, per call.
|
||||
- `android/app/src/main/java/com/wzp/debug/DebugReporter.kt` — surfaces `dredReconstructions` and `classicalPlc` counts in the debug report; also logs active DRED duration and whether legacy-FEC mode is engaged.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Grafana dashboard shows a clear visual distinction between DRED-recovered and classical-PLC-recovered frames across a test fleet of calls.
|
||||
- Debug report includes the active protection mode ("DRED 200 ms" / "Legacy RaptorQ") and reconstruction counts, so incidents can be classified unambiguously.
|
||||
|
||||
### Phase 5 — Escape hatch removal (follow-up, ~2 months post-ship)
|
||||
|
||||
After 2 months of stable production with no rollbacks triggered:
|
||||
- Delete `AUDIO_USE_LEGACY_FEC` handling in `opus_enc.rs` / `call.rs` / `pipeline.rs`
|
||||
- Delete the Opus-tier paths of `wzp-fec` (the crate stays for Codec2)
|
||||
- Delete the Android settings toggle and desktop CLI flag
|
||||
- Remove the `--legacy-fec` path from smoke tests
|
||||
|
||||
## Critical files to modify (summary)
|
||||
|
||||
- `Cargo.toml` (workspace) — dep swap (audiopus → opusic-c + opusic-sys)
|
||||
- `crates/wzp-codec/Cargo.toml` — dep swap + `bytemuck` for slice cast
|
||||
- `crates/wzp-codec/src/opus_enc.rs` — opusic-c rewrite + DRED enable + inband FEC off
|
||||
- `crates/wzp-codec/src/opus_dec.rs` — opusic-c rewrite
|
||||
- `crates/wzp-codec/src/dred_ffi.rs` — **new file**, safe wrapper over opusic-sys raw DRED FFI
|
||||
- `crates/wzp-codec/src/lib.rs` — expose `DredReconstructor` trait, `DredState`, `DredError`
|
||||
- `crates/wzp-codec/src/adaptive.rs` — verify profile switch carries DRED duration
|
||||
- `crates/wzp-client/src/call.rs` — Opus/Codec2 gate on RaptorQ path, loss floor, wire DredReconstructor into CallDecoder
|
||||
- `crates/wzp-android/src/pipeline.rs` — same gate, same loss floor, wire DredReconstructor
|
||||
- `crates/wzp-transport/src/jitter.rs` — lookahead/backfill refactor, DRED ring, reconstruction dispatch
|
||||
- `crates/wzp-transport/Cargo.toml` — verify it depends only on `wzp-codec`, not directly on opusic-*
|
||||
- `crates/wzp-proto/src/packet.rs` — new telemetry counters
|
||||
- `crates/wzp-relay/` — Prometheus metric exposure
|
||||
- `android/app/src/main/java/com/wzp/debug/DebugReporter.kt` — debug output
|
||||
- `docs/grafana-dashboard.json` — loss-recovery panel
|
||||
- (delete) `vendor/audiopus_sys/` on `feat/desktop-audio-rewrite` when merging back
|
||||
|
||||
## Existing utilities to reuse
|
||||
|
||||
- `wzp_codec::resample::Downsampler48to8` / `Upsampler8to48` — unchanged, only Codec2 path uses them
|
||||
- `wzp_codec::adaptive::AdaptiveEncoder` / `AdaptiveDecoder` — existing profile-switching machinery, DRED duration changes ride along
|
||||
- `wzp_codec::silence::SilenceDetector` / `ComfortNoise` — unchanged
|
||||
- `wzp_codec::agc::AutoGainControl` — unchanged, runs before encode as today
|
||||
- `wzp_fec::RaptorQFecEncoder` / decoder — unchanged, still used for Codec2 tiers
|
||||
- `wzp_client::call::QualityAdapter` — unchanged; drives profile switching, which now also reconfigures DRED duration via the existing `set_profile` path
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end testing, in order:
|
||||
|
||||
1. **Unit**: `cargo test -p wzp-codec` — Opus encode/decode round-trip at every profile, DRED enabled. Verify `version()` reports libopus 1.5.2.
|
||||
2. **Unit**: `cargo test -p wzp-transport` — jitter buffer lookahead/backfill behavior with injected loss patterns (0%, 5%, 15%, 30%, 50% loss; isolated losses, 40 ms bursts, 200 ms bursts, 500 ms bursts).
|
||||
3. **Integration**: `crates/wzp-client/src/echo_test.rs` — existing echo test must pass on all Opus profiles with <5% perceived quality regression (measure via the time-window analysis already built into `echo_test.rs`).
|
||||
4. **Integration**: `crates/wzp-client/src/drift_test.rs` — latency measurement. Must show ~40 ms reduction on Opus profiles versus pre-PRD baseline. Codec2 profiles unchanged.
|
||||
5. **Manual**: Android release build, real call over bad wifi (or a shaped network via `tc netem` on Linux). Burst losses of 200 ms should be perceptually continuous speech, not robotic gaps.
|
||||
6. **Manual**: Same call with `AUDIO_USE_LEGACY_FEC=1` — verify behavior reverts to current production behavior. This is the pre-ship rollback rehearsal.
|
||||
7. **Cross-compile**: full build matrix — Android arm64-v8a + armeabi-v7a (via `scripts/build-and-notify.sh`), macOS universal, Linux x86_64 (via `scripts/build-linux-docker.sh`). Windows cross-compile via cargo-xwin should also pass — libopus 1.5 upstream fixed the clang-cl SIMD issue that required the vendor patch on `feat/desktop-audio-rewrite`.
|
||||
8. **Telemetry smoke**: deploy to staging relay, make 10 test calls, verify Grafana's new "Loss recovery breakdown" panel shows DRED reconstruction events firing on injected loss and classical-PLC on packet-loss beyond DRED's window.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **Custom DRED FFI wrapper is WZP-maintained code with no second source.** opusic-c's decoder-side DRED wrapper is insufficient (see Background), so we carry our own `dred_ffi.rs` that calls `opus_dred_parse` and `opus_decoder_dred_decode` directly via opusic-sys. Bugs in this wrapper — offset arithmetic off-by-ones, lifetime errors on `OpusDRED` buffers, UB from misuse of the C API — could manifest as silent audio corruption on loss bursts, hard to diagnose. **Mitigation**: extensive unit tests in `dred_ffi.rs` using a reference encoder + reference decoder round-trip with known offsets; strict bounds checking on every `unsafe` boundary; Miri run in CI if feasible; the legacy-FEC escape hatch disables the entire DRED code path including our custom wrapper, giving us a single flag to revert any wrapper bug in production. Long-term: upstream the fixes to opusic-c (follow-up task, not blocking).
|
||||
- **opusic-c's encoder-side API and internal Decoder pointer access**. Step 3a depends on being able to call opusic-sys raw functions that take an `*mut OpusDecoder` pointer while still using opusic-c's `Decoder` for normal decode. If opusic-c doesn't expose the raw pointer cleanly, we fall back to a thin opusic-sys-direct Decoder wrapper inside `dred_ffi.rs` and lose some of opusic-c's convenience. **Mitigation**: verify at the start of Phase 3 (one afternoon of reading opusic-c source). If the clean path doesn't work, the fallback is not difficult — it's what we'd have built anyway if opusic-c didn't exist.
|
||||
- **DRED reconstruction quality varies by voice / content**. The neural model is trained on speech; edge cases (shouting, whispering, heavy accents, music-on-hold, cough, laughter) may reconstruct less cleanly than continuous speech. **Mitigation**: escape hatch ships from day one. If production telemetry shows perceptible quality regression on specific voice patterns, flip legacy mode for affected users while tuning. Also: classical Opus PLC remains as the third-tier fallback when DRED state is unavailable.
|
||||
- **Removing RaptorQ removes bit-exact recovery**. Isolated single-packet losses are now reconstructed plausibly instead of bit-exactly. **Mitigation**: as argued in Background, bit-exactness on a single 20 ms speech frame is perceptually meaningless. The assumption is "speech is the workload" — if we ever add non-speech features (music bot, ringtones over the call path, DTMF-over-audio) we revisit.
|
||||
- **libopus 1.5 DRED API stability**. **Verified at pre-flight**: opus.h in the upstream xiph/opus repository has no "experimental" marker on the DRED API declarations. The earlier characterization was incorrect. DRED shipped as a first-class feature in libopus 1.5.0 (Dec 2023) and has been iterated in 1.5.1 and 1.5.2. Google Meet and Duo ship it at scale. **Mitigation**: pin `opusic-sys` exactly (no `^` range) to ensure reproducible builds, follow upstream 1.5.x bugfixes as they land. No special stability concerns beyond normal dependency hygiene.
|
||||
- **Jitter buffer refactor is the largest code change**. Jitter bugs are notoriously subtle (off-by-one on sequence wraparound, clock drift interactions, playout starvation corner cases). **Mitigation**: keep the classical-PLC path intact as the DRED fallback, so jitter bugs degrade to "current behavior" rather than "broken audio". Write targeted unit tests for the buffer at each loss-pattern scenario before touching production paths. Consider shipping Phase 3 behind a sub-flag separate from the main escape hatch, so we can independently toggle "DRED enabled but classical jitter buffer" for bisection.
|
||||
- **Cross-compile surprises**. `opusic-sys` is actively maintained but our exact combination of Android NDK version / Docker builder environment / Windows cross-compile via cargo-xwin has not been tested by upstream. **Mitigation**: Phase 0 includes the full cross-compile matrix as an acceptance criterion. Any blockers surface before we touch loss-recovery behavior.
|
||||
- **Wire-format compatibility during rollout**. Mixed-version calls (new sender + old receiver, or vice versa) need to keep working. **Verified at pre-flight**: traced both live receive paths (`wzp-client/src/call.rs::CallDecoder::ingest` and `wzp-android/src/engine.rs` the JNI-driven engine path), and both degrade gracefully: new-sender Opus packets with `fec_ratio_encoded=0` / `fec_block=0` / `fec_symbol=0` flow through to the jitter buffer and decode normally on old receivers. The RaptorQ decoder either ignores zero-FEC packets entirely (Android pipeline.rs gates on non-zero fec_block/fec_symbol) or accumulates them harmlessly until the 2-second staleness eviction (desktop call.rs). Old-sender packets with populated RaptorQ fields are handled by new receivers via the unchanged Codec2 path (new receivers keep wzp-fec for Codec2 tiers and simply ignore RaptorQ fields on Opus packets). **No wire format version bump required.**
|
||||
- **Pre-existing desktop RaptorQ gap** (incidental finding, NOT caused by this PRD). The desktop `wzp-client/src/call.rs::CallDecoder` feeds packets into `fec_dec.add_symbol` but **never calls `fec_dec.try_decode`** — RaptorQ recovery is effectively dead code on the desktop path today. Main decode reads from the jitter buffer directly, falling through to classical Opus PLC on missing packets. The Android `engine.rs` path properly uses `try_decode` for recovery. This PRD does not fix the desktop gap — it's unrelated — but is noted here so nobody is surprised that removing RaptorQ from Opus tiers on the desktop client causes no measurable recovery regression (there was nothing to lose). Recommend filing a follow-up task to either fix or remove the vestigial desktop RaptorQ wiring independently of this work.
|
||||
- **`AUDIO_USE_LEGACY_FEC` itself becoming permanent tech debt**. Escape hatches have a way of outliving their intended lifespan. **Mitigation**: put an explicit removal date in a `// TODO(2026-06-15): remove legacy FEC path` comment at the flag-handling site. Track in taskmaster.
|
||||
|
||||
## Open questions
|
||||
|
||||
- ~~**Does opusic-c expose `opusic_c::Decoder`'s raw inner pointer?**~~ **Resolved at pre-flight**: no, it's `pub(crate)`. We build a unified `DecoderHandle` over raw opusic-sys in `dred_ffi.rs` and use it for both normal decode and DRED reconstruction. Opusic-c is used only for the encoder side.
|
||||
- **Exact opusic-sys symbol name for DRED decoder allocation**. opus.h documents the `OpusDREDDecoder` type and `opus_dred_parse`/`opus_decoder_dred_decode` functions, but the allocation function name is not in the fetched snippet. Expected to be `opus_dred_decoder_create` / `opus_dred_decoder_destroy` per libopus naming convention, but confirm at the very start of Phase 3a by reading the actual opusic-sys bindings. If the function is not exported by opusic-sys, we file a PR upstream to opusic-sys (small fix, trivially mergeable) and temporarily vendor the function declaration locally.
|
||||
- **Should the 5% loss floor be configurable per profile?** Currently specified as a constant. A future refinement might make it higher at degraded tiers and lower at studio tiers, but without real telemetry we don't know if the constant is wrong. Keep as a constant for now, revisit after 1 month of production data.
|
||||
- **OSCE enable**: opusic-c has an `osce` feature flag for Opus Speech Coding Enhancement, a separate libopus 1.5 neural post-processor. Out of scope for this PRD but should be the next audio-quality follow-up. Probably one-line enable once opusic-c is in.
|
||||
- **Upstream PR to opusic-c**: our own `dred_ffi.rs` wrapper should be proven in production first, then the fixes upstreamed to `opusic-c/src/dred.rs` (preserve `dred_end`, fix `dred_offset` double-pass, expose `DredPacket` externally). Follow-up task, not blocking this PRD.
|
||||
- **`feat/desktop-audio-rewrite` merge**: the vendored `audiopus_sys` patch on that branch becomes obsolete under this PRD. Coordinate removal with whoever owns that branch.
|
||||
|
||||
## Phase A: Continuous DRED Tuning (Implemented 2026-04-12)
|
||||
|
||||
Phase A extends the discrete tier-locked DRED durations from Phases 1-3 with continuous, network-driven tuning.
|
||||
|
||||
### What was built
|
||||
|
||||
- **`DredTuner`** (`crates/wzp-proto/src/dred_tuner.rs`): Maps `(loss_pct, rtt_ms, jitter_ms)` → `(dred_frames, expected_loss_pct)` continuously
|
||||
- **Quinn stats exposure** (`crates/wzp-transport/src/quic.rs`): `QuinnPathSnapshot` provides quinn's internal RTT, loss, congestion events — more accurate than sequence-gap heuristics
|
||||
- **Jitter variance window** (`crates/wzp-transport/src/path_monitor.rs`): 10-sample sliding window for RTT standard deviation, used for spike detection
|
||||
- **`AudioEncoder` trait extensions** (`crates/wzp-proto/src/traits.rs`): `set_expected_loss()` and `set_dred_duration()` with default no-op, overridden by `OpusEncoder` and `AdaptiveEncoder`
|
||||
- **Engine integration** (`desktop/src-tauri/src/engine.rs`): Both Android and desktop send tasks poll every 25 frames and apply tuning
|
||||
|
||||
### Opus6k DRED extended
|
||||
|
||||
`dred_duration_for(Opus6k)` changed from 50 (500ms) to 104 (1040ms) — the maximum libopus 1.5 supports. The RDO-VAE's quality-vs-offset curve makes this nearly free in bitrate terms while doubling burst resilience on the worst links.
|
||||
|
||||
### Jitter spike detection ("Sawtooth" prediction)
|
||||
|
||||
When instantaneous jitter exceeds the EWMA × 1.3 (asymmetric: fast-up α=0.3, slow-down α=0.05), the tuner enters spike-boost mode:
|
||||
- DRED immediately jumps to the codec tier's ceiling
|
||||
- Cooldown: 10 cycles (~5 seconds at 25 packets/cycle)
|
||||
- Designed for Starlink satellite handover sawtooth jitter pattern
|
||||
|
||||
### Test coverage
|
||||
|
||||
- 10 unit tests for tuner math (baseline, scaling, spike, cooldown, codec switch, Codec2 no-op)
|
||||
- 4 integration tests (encoder adjustment, spike boost, Codec2 no-op, profile switch with encode verification)
|
||||
@@ -57,3 +57,28 @@ When the path MTU is small, the relay or client should:
|
||||
- MTU-based codec selection (future, needs adaptive quality)
|
||||
|
||||
## Effort: 1 day
|
||||
|
||||
## Implementation Status (2026-04-12)
|
||||
|
||||
Phase 1 is now implemented:
|
||||
|
||||
### What was built
|
||||
|
||||
- **Transport config** (`crates/wzp-transport/src/config.rs`):
|
||||
- `MtuDiscoveryConfig` with `upper_bound=1452`, `interval=300s`, `black_hole_cooldown=30s`
|
||||
- `initial_mtu=1200` (safe QUIC minimum)
|
||||
- Quinn's PLPMTUD binary-searches from 1200 up to 1452 automatically
|
||||
|
||||
- **`QuinnPathSnapshot::current_mtu`** (`crates/wzp-transport/src/quic.rs`):
|
||||
- Reads `connection.max_datagram_size()` which reflects the PMTUD-discovered value
|
||||
- Available to all callers via `transport.quinn_path_stats()`
|
||||
|
||||
- **Trunk batcher MTU-aware** (`crates/wzp-relay/src/room.rs`):
|
||||
- `TrunkedForwarder::new()` initializes `max_bytes` from discovered MTU
|
||||
- `send()` refreshes `max_bytes` on every call (cheap atomic read in quinn)
|
||||
- Federation trunk frames grow automatically as PMTUD discovers larger paths
|
||||
|
||||
### Phases 2-3 status
|
||||
|
||||
- Phase 2 (handle MTU failures): Already handled — `send_media()`/`send_trunk()` check `max_datagram_size()` and return `DatagramTooLarge` errors. These are logged and the packet is dropped gracefully.
|
||||
- Phase 3 (codec-aware MTU): Not yet implemented. Future video frames will need application-layer fragmentation when they exceed the discovered MTU.
|
||||
|
||||
129
docs/PRD-network-awareness.md
Normal file
129
docs/PRD-network-awareness.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# PRD: Network Awareness
|
||||
|
||||
> Phase: Implemented (core path)
|
||||
> Status: Ready for testing
|
||||
> Platform: Android native Kotlin app (com.wzp)
|
||||
|
||||
## Problem
|
||||
|
||||
WarzonePhone's quality controller (`AdaptiveQualityController`) had a `signal_network_change()` API for proactive adaptation to WiFi↔cellular transitions, but nothing called it. Network handoffs during calls were only detected reactively via jitter spikes — by which time the user had already experienced degraded audio.
|
||||
|
||||
## Solution
|
||||
|
||||
Integrate Android's `ConnectivityManager.NetworkCallback` to detect network transport changes in real-time and feed them to the quality controller. This enables:
|
||||
|
||||
1. **Preemptive quality downgrade** when switching from WiFi to cellular
|
||||
2. **FEC boost** (10-second window with +0.2 ratio) after any network change
|
||||
3. **Faster downgrade thresholds** on cellular (2 consecutive reports vs 3 on WiFi)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Android │
|
||||
│ │
|
||||
│ ConnectivityManager │
|
||||
│ │ NetworkCallback │
|
||||
│ ▼ │
|
||||
│ NetworkMonitor.kt │
|
||||
│ │ onNetworkChanged(type, bandwidthKbps) │
|
||||
│ ▼ │
|
||||
│ CallViewModel.kt ──► WzpEngine.onNetworkChanged() │
|
||||
│ │ JNI │
|
||||
│ ▼ │
|
||||
│ jni_bridge.rs: nativeOnNetworkChanged(handle, type, bw) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ engine.rs: state.pending_network_type.store(type) │
|
||||
│ │ AtomicU8 (lock-free) │
|
||||
│ ▼ │
|
||||
│ recv task: quality_ctrl.signal_network_change(ctx) │
|
||||
│ │ │
|
||||
│ ├─ Preemptive downgrade (WiFi → cellular) │
|
||||
│ ├─ FEC boost 10s │
|
||||
│ └─ Faster cellular thresholds │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Network Classification
|
||||
|
||||
`NetworkMonitor` classifies the active transport without requiring `READ_PHONE_STATE` permission by using bandwidth heuristics:
|
||||
|
||||
| Downstream Bandwidth | Classification | Rust `NetworkContext` |
|
||||
|----------------------|---------------|----------------------|
|
||||
| N/A (WiFi transport) | WiFi | `WiFi` |
|
||||
| >= 100 Mbps | 5G NR | `Cellular5g` |
|
||||
| >= 10 Mbps | LTE | `CellularLte` |
|
||||
| < 10 Mbps | 3G or worse | `Cellular3g` |
|
||||
| Ethernet | WiFi (equivalent) | `WiFi` |
|
||||
| Network lost | None | `Unknown` |
|
||||
|
||||
## Cross-Task Signaling
|
||||
|
||||
The network type is communicated from the JNI thread to the recv task via `AtomicU8` — the same pattern used for `pending_profile` (adaptive quality profile switches):
|
||||
|
||||
```
|
||||
JNI thread recv task (tokio)
|
||||
│ │
|
||||
│ store(type, Release) │
|
||||
│──────────────────────────────►│
|
||||
│ │ swap(0xFF, Acquire)
|
||||
│ │ if != 0xFF:
|
||||
│ │ quality_ctrl.signal_network_change(ctx)
|
||||
│ │
|
||||
```
|
||||
|
||||
Sentinel value `0xFF` means "no change pending". The recv task polls on every received packet (~20-40ms), so latency is bounded by the inter-packet interval.
|
||||
|
||||
## Components
|
||||
|
||||
### New File
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `android/.../net/NetworkMonitor.kt` | ConnectivityManager callback, transport classification, deduplication |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `android/.../engine/WzpEngine.kt` | Added `onNetworkChanged()` method + `nativeOnNetworkChanged` external |
|
||||
| `android/.../ui/call/CallViewModel.kt` | Instantiates NetworkMonitor, wires callback, register/unregister lifecycle |
|
||||
| `crates/wzp-android/src/jni_bridge.rs` | Added `Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged` JNI entry |
|
||||
| `crates/wzp-android/src/engine.rs` | Added `pending_network_type: AtomicU8` to EngineState, recv task polls it |
|
||||
|
||||
### Unchanged (already implemented)
|
||||
|
||||
| File | API |
|
||||
|------|-----|
|
||||
| `crates/wzp-proto/src/quality.rs` | `AdaptiveQualityController::signal_network_change(NetworkContext)` |
|
||||
| `crates/wzp-transport/src/path_monitor.rs` | `PathMonitor::detect_handoff()` (available for future use) |
|
||||
|
||||
## Deferred Work
|
||||
|
||||
### Tauri Desktop App (com.wzp.desktop)
|
||||
|
||||
The Tauri engine doesn't use `AdaptiveQualityController` — quality is resolved once at call start. Adding network monitoring requires first adding adaptive quality to the Tauri call engine, which is a larger change.
|
||||
|
||||
### Mid-Call ICE Re-gathering
|
||||
|
||||
When the device's IP address changes, ideally we should:
|
||||
1. Re-gather local host candidates (`local_host_candidates()`)
|
||||
2. Re-probe STUN (`probe_reflect_addr()`)
|
||||
3. Send updated candidates to the peer (`CandidateUpdate` signal message)
|
||||
4. Attempt new dual-path race for path upgrade
|
||||
|
||||
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready, but the signaling and re-racing logic is not yet implemented.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build native APK
|
||||
2. Start a call on WiFi
|
||||
3. Verify logcat: `quality controller: network context updated` with `ctx=WiFi`
|
||||
4. Disable WiFi → device falls to cellular
|
||||
5. Verify logcat: `ctx=CellularLte` (or `Cellular5g`/`Cellular3g`)
|
||||
6. Verify FEC boost activates (check quality_ctrl logs)
|
||||
7. Verify preemptive quality downgrade (tier drops one level on WiFi→cellular)
|
||||
8. Re-enable WiFi → verify transition back
|
||||
9. Rapid WiFi toggle (5x in 10s) → verify no crashes, deduplication works
|
||||
10. Airplane mode → verify `onLost` fires with `TYPE_NONE`
|
||||
@@ -120,7 +120,7 @@
|
||||
|
||||
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
|
||||
|
||||
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics.
|
||||
- **Adaptive loop integration (resolved)**: AdaptiveQualityController is now fully wired into both desktop and Android send/recv tasks. Relay-coordinated codec switching broadcasts QualityDirective to all participants based on weakest-link policy.
|
||||
|
||||
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
|
||||
|
||||
@@ -128,18 +128,18 @@
|
||||
|
||||
## Test Coverage
|
||||
|
||||
119 tests across 7 crates (wzp-web has no Rust tests):
|
||||
307+ tests across 7 crates (wzp-web has no Rust tests):
|
||||
|
||||
| Crate | Test Files | Test Count |
|
||||
|-------|-----------|------------|
|
||||
| wzp-proto | 5 | 27 |
|
||||
| wzp-codec | 3 | 24 |
|
||||
| wzp-fec | 5 | 21 |
|
||||
| wzp-crypto | 5 | 21 |
|
||||
| wzp-transport | 3 | 12 |
|
||||
| wzp-relay | 4 | 10 |
|
||||
| wzp-client | 3 | 8 |
|
||||
| **Total** | **28** | **119** |
|
||||
| Crate | Test Count |
|
||||
|-------|------------|
|
||||
| wzp-proto | ~79 |
|
||||
| wzp-codec | ~69 |
|
||||
| wzp-fec | ~21 |
|
||||
| wzp-crypto | ~21 |
|
||||
| wzp-transport | ~11 |
|
||||
| wzp-relay | ~50 |
|
||||
| wzp-client | ~57 |
|
||||
| **Total** | **307+** |
|
||||
|
||||
Tests cover:
|
||||
- Wire format roundtrip (header, quality report, full packet)
|
||||
@@ -191,3 +191,72 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
|
||||
- **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries
|
||||
- **CI**: Gitea workflow defined for amd64/arm64/armv7 builds
|
||||
- **Production**: Not yet deployed to production networks
|
||||
|
||||
## Recent Changes (2026-04-12)
|
||||
|
||||
### Bluetooth Audio Routing
|
||||
- 3-way route cycling: Earpiece → Speaker → Bluetooth SCO
|
||||
- `setCommunicationDevice()` API 31+ with `startBluetoothSco()` fallback
|
||||
- BT-mode Oboe: capture skips 48kHz + VoiceCommunication, Oboe resamples 8/16kHz ↔ 48kHz
|
||||
- `MODE_IN_COMMUNICATION` deferred to call start (was at app launch — hijacked system audio)
|
||||
|
||||
### Network Change Detection
|
||||
- `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback`
|
||||
- WiFi/cellular classification via bandwidth heuristics (no READ_PHONE_STATE needed)
|
||||
- Feeds `AdaptiveQualityController::signal_network_change()` via JNI → AtomicU8 → recv task
|
||||
|
||||
### Hangup Signal Fix
|
||||
- `SignalMessage::Hangup` now carries optional `call_id`
|
||||
- Relay only ends the named call (not all calls for the user)
|
||||
- Fixes race: hangup for call 1 no longer kills newly-placed call 2
|
||||
|
||||
### Per-Architecture APK Builds
|
||||
- `build-tauri-android.sh --arch arm64|armv7|all`
|
||||
- Separate per-arch APKs (~25MB each vs ~50MB universal)
|
||||
- Release APKs signed with `wzp-release.jks` via `apksigner`
|
||||
|
||||
### Continuous DRED Tuning (Phase A: opus-DRED-v2)
|
||||
- `DredTuner` in `wzp-proto::dred_tuner` maps live network metrics to continuous DRED duration
|
||||
- Polls quinn path stats every 25 frames (~500ms): loss%, RTT, jitter
|
||||
- Linear interpolation between baseline and ceiling per codec tier (not discrete tier jumps)
|
||||
- Jitter-spike detection: >30% EWMA spike pre-emptively boosts DRED to ceiling for ~5s
|
||||
- RTT phantom loss: high RTT (>200ms) adds phantom contribution to keep DRED generous
|
||||
- `set_expected_loss()` and `set_dred_duration()` added to `AudioEncoder` trait
|
||||
- Integrated into both Android and desktop send tasks in engine.rs
|
||||
|
||||
### Extended DRED Window
|
||||
- Opus6k DRED duration increased from 500ms to 1040ms (max libopus 1.5 supports)
|
||||
- RDO-VAE naturally degrades quality at longer offsets — extra window costs ~1-2 kbps
|
||||
|
||||
### PMTUD (Path MTU Discovery)
|
||||
- Quinn's PLPMTUD explicitly configured: initial 1200, upper bound 1452, 300s interval
|
||||
- `QuinnPathSnapshot` exposes discovered MTU via `current_mtu` field
|
||||
- `TrunkedForwarder` refreshes `max_bytes` from PMTUD (was hard-coded 1200)
|
||||
- Federation trunk frames now fill the discovered path MTU automatically
|
||||
|
||||
### New Tests
|
||||
- 4 DRED tuner integration tests in wzp-client (encoder adjustment, spike boost, Codec2 no-op, profile switch)
|
||||
- 10 unit tests in wzp-proto for DredTuner mapping logic
|
||||
- Jitter variance window tests in wzp-transport PathMonitor
|
||||
- Pre-existing test fixes: added missing `build_version` fields to 7 SignalMessage constructors
|
||||
|
||||
### Desktop Adaptive Quality (#7, #31)
|
||||
- `AdaptiveQualityController` wired into both Android and desktop send/recv tasks
|
||||
- `pending_profile: Arc<AtomicU8>` bridge between recv (writer) and send (reader)
|
||||
- Auto mode: ingests QualityReports from relay, switches encoder profile when adapter recommends
|
||||
- `tx_codec` display string updated on profile switch for UI indicator
|
||||
- `profile_to_index()` / `index_to_profile()` mapping for 6-tier range
|
||||
|
||||
### Relay Coordinated Codec Switching (#25, #26)
|
||||
- `ParticipantQuality` struct in relay RoomManager tracks per-participant quality
|
||||
- Quality reports from forwarded packets feed per-participant `AdaptiveQualityController`
|
||||
- `weakest_tier()` computes room-wide worst tier across all participants
|
||||
- `QualityDirective` SignalMessage variant: relay broadcasts recommended profile to all participants
|
||||
- Triggered on tier change — instant, no negotiation (weakest-link policy)
|
||||
|
||||
### Oboe Stream State Polling (#35)
|
||||
- C++ polling loop after `requestStart()`: checks `getState()` every 10ms for up to 2s
|
||||
- Waits for both capture and playout streams to reach `Started` state
|
||||
- Logs initial state, poll count, and final state for HAL debugging
|
||||
- Does NOT fail on timeout — Rust-side stall detector remains as safety net
|
||||
- Targets Nothing Phone A059 intermittent silent calls on cold start
|
||||
|
||||
@@ -5,10 +5,15 @@ set -euo pipefail
|
||||
# notify via ntfy.sh/wzp. Fire and forget.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-and-notify.sh Build + upload + notify
|
||||
# ./scripts/build-and-notify.sh --rust Force Rust rebuild
|
||||
# ./scripts/build-and-notify.sh --pull Git pull before building
|
||||
# ./scripts/build-and-notify.sh --install Also download + adb install locally
|
||||
# ./scripts/build-and-notify.sh Build current local branch
|
||||
# ./scripts/build-and-notify.sh --branch opus-DRED Build a specific branch
|
||||
# ./scripts/build-and-notify.sh --rust Force Rust rebuild
|
||||
# ./scripts/build-and-notify.sh --no-pull Skip git pull (use cached source)
|
||||
# ./scripts/build-and-notify.sh --install Also download + adb install locally
|
||||
#
|
||||
# The remote builder pulls the requested branch from its `origin` (gitea:
|
||||
# git.manko.yoga). Make sure you've pushed the branch to `origin` before
|
||||
# running this script, otherwise the remote fetch will fail loudly.
|
||||
|
||||
REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
@@ -19,14 +24,29 @@ SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=
|
||||
REBUILD_RUST=0
|
||||
DO_PULL=1
|
||||
DO_INSTALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
# Default to whatever branch the local workspace is on — "build what I'm
|
||||
# working on" is the intuitive behavior for iterative development.
|
||||
BRANCH=$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rust) REBUILD_RUST=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--install) DO_INSTALL=1 ;;
|
||||
--branch)
|
||||
shift
|
||||
BRANCH="$1"
|
||||
;;
|
||||
--branch=*) BRANCH="${1#--branch=}" ;;
|
||||
*) echo "Unknown arg: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ERROR: could not determine target branch (detached HEAD?). Pass --branch NAME."
|
||||
exit 1
|
||||
fi
|
||||
echo "Target branch: $BRANCH"
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
|
||||
@@ -42,20 +62,33 @@ BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
REBUILD_RUST="${1:-0}"
|
||||
DO_PULL="${2:-0}"
|
||||
BRANCH="${3:-}"
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ERROR: remote script invoked without a BRANCH argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
trap 'notify "WZP Android build FAILED! Check /tmp/wzp-build.log"' ERR
|
||||
trap 'notify "WZP Android build FAILED [$BRANCH]! Check /tmp/wzp-build.log"' ERR
|
||||
|
||||
# Pull if requested
|
||||
# Pull the requested branch. Previously this was hardcoded to
|
||||
# feat/android-voip-client with `|| true` on the reset, which silently
|
||||
# left the tree on whatever branch it was last on when the hardcoded
|
||||
# branch didn't exist on origin. Now the branch is a parameter and any
|
||||
# failure aborts the build so nobody ships an APK from the wrong source.
|
||||
if [ "$DO_PULL" = "1" ]; then
|
||||
echo ">>> Pulling latest..."
|
||||
echo ">>> Pulling branch '$BRANCH' from origin..."
|
||||
cd "$BASE_DIR/data/source"
|
||||
git reset --hard HEAD 2>/dev/null || true
|
||||
git clean -fd 2>/dev/null || true
|
||||
git gc --prune=now 2>/dev/null || true
|
||||
git fetch origin feat/android-voip-client 2>&1 | tail -3
|
||||
git reset --hard origin/feat/android-voip-client 2>/dev/null || true
|
||||
git fetch origin "$BRANCH"
|
||||
git reset --hard "origin/$BRANCH"
|
||||
BUILT_HASH=$(git rev-parse --short HEAD)
|
||||
BUILT_SUBJECT=$(git log -1 --format=%s)
|
||||
echo ">>> HEAD after pull: $BUILT_HASH — $BUILT_SUBJECT"
|
||||
fi
|
||||
|
||||
# Clean Rust if requested
|
||||
@@ -73,7 +106,7 @@ find "$BASE_DIR/data/source" "$BASE_DIR/data/cache" \
|
||||
rm -rf "$BASE_DIR/data/source/android/app/src/main/jniLibs/arm64-v8a"
|
||||
|
||||
GIT_HASH=$(cd $BASE_DIR/data/source && git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
notify "WZP Android build started [$GIT_HASH]..."
|
||||
notify "WZP Android build started [$BRANCH @ $GIT_HASH]..."
|
||||
|
||||
echo ">>> Building in Docker..."
|
||||
docker run --rm --user 1000:1000 \
|
||||
@@ -117,10 +150,10 @@ APK=$(find "$BASE_DIR/data/source/android" -name "app-debug*.apk" -path "*/outpu
|
||||
if [ -n "$APK" ]; then
|
||||
URL=$(curl -s -F "file=@$APK" -H "Authorization: $rusty_auth_token" "$rusty_address")
|
||||
echo "UPLOAD_URL=$URL"
|
||||
notify "WZP Android [$GIT_HASH] done! APK: $URL"
|
||||
notify "WZP Android [$BRANCH @ $GIT_HASH] done! APK: $URL"
|
||||
echo ">>> Done! APK at: $URL"
|
||||
else
|
||||
notify "WZP build FAILED - no APK"
|
||||
notify "WZP Android FAILED [$BRANCH @ $GIT_HASH] - no APK"
|
||||
echo "ERROR: No APK found"
|
||||
exit 1
|
||||
fi
|
||||
@@ -129,9 +162,9 @@ REMOTE_SCRIPT
|
||||
ssh_cmd "chmod +x /tmp/wzp-docker-build.sh"
|
||||
|
||||
# Run in tmux
|
||||
log "Starting build in tmux..."
|
||||
log "Starting build in tmux (branch: $BRANCH)..."
|
||||
ssh_cmd "tmux kill-session -t wzp-build 2>/dev/null; true"
|
||||
ssh_cmd "tmux new-session -d -s wzp-build '/tmp/wzp-docker-build.sh $REBUILD_RUST $DO_PULL 2>&1 | tee /tmp/wzp-build.log'"
|
||||
ssh_cmd "tmux new-session -d -s wzp-build '/tmp/wzp-docker-build.sh $REBUILD_RUST $DO_PULL $BRANCH 2>&1 | tee /tmp/wzp-build.log'"
|
||||
|
||||
log "Build running! You'll get a notification on ntfy.sh/wzp with the download URL."
|
||||
echo ""
|
||||
@@ -17,6 +17,12 @@ NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/linux-x86_64"
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||
|
||||
# Branch to build. Default matches the current active development branch
|
||||
# (opus-DRED-v2 as of 2026-04-11). Override with `WZP_BRANCH=<name> ./build-linux-docker.sh`
|
||||
# if you need a different one — e.g. to rebuild the relay from a feature
|
||||
# branch for A/B testing.
|
||||
WZP_BRANCH="${WZP_BRANCH:-opus-DRED-v2}"
|
||||
|
||||
DO_PULL=1
|
||||
DO_CLEAN=0
|
||||
DO_INSTALL=0
|
||||
@@ -44,19 +50,21 @@ BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
DO_PULL="${1:-0}"
|
||||
DO_CLEAN="${2:-0}"
|
||||
BRANCH="${3:-opus-DRED-v2}"
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
trap 'notify "WZP Linux build FAILED! Check /tmp/wzp-linux-build.log"' ERR
|
||||
|
||||
if [ "$DO_PULL" = "1" ]; then
|
||||
echo ">>> Pulling latest..."
|
||||
echo ">>> Pulling latest ($BRANCH)..."
|
||||
cd "$BASE_DIR/data/source"
|
||||
git reset --hard HEAD 2>/dev/null || true
|
||||
git clean -fd 2>/dev/null || true
|
||||
git gc --prune=now 2>/dev/null || true
|
||||
git fetch origin feat/android-voip-client 2>&1 | tail -3
|
||||
git reset --hard origin/feat/android-voip-client 2>/dev/null || true
|
||||
git fetch origin "$BRANCH" 2>&1 | tail -3
|
||||
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
|
||||
git reset --hard "origin/$BRANCH" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$DO_CLEAN" = "1" ]; then
|
||||
@@ -133,7 +141,7 @@ ssh_cmd "chmod +x /tmp/wzp-linux-build.sh"
|
||||
# Run in tmux
|
||||
log "Starting Linux build in tmux..."
|
||||
ssh_cmd "tmux kill-session -t wzp-linux 2>/dev/null; true"
|
||||
ssh_cmd "tmux new-session -d -s wzp-linux '/tmp/wzp-linux-build.sh $DO_PULL $DO_CLEAN 2>&1 | tee /tmp/wzp-linux-build.log'"
|
||||
ssh_cmd "tmux new-session -d -s wzp-linux '/tmp/wzp-linux-build.sh $DO_PULL $DO_CLEAN $WZP_BRANCH 2>&1 | tee /tmp/wzp-linux-build.log'"
|
||||
|
||||
log "Build running! Notification on ntfy.sh/wzp when done."
|
||||
echo ""
|
||||
|
||||
@@ -15,11 +15,14 @@ set -euo pipefail
|
||||
# - Output: desktop/src-tauri/gen/android/.../*.apk
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-tauri-android.sh # full pipeline (debug)
|
||||
# ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only)
|
||||
# ./scripts/build-tauri-android.sh --release # release APK
|
||||
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch
|
||||
# ./scripts/build-tauri-android.sh --rust # force-clean rust target
|
||||
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
|
||||
# ./scripts/build-tauri-android.sh --arch arm64 # arm64 only (default)
|
||||
# ./scripts/build-tauri-android.sh --arch armv7 # armv7 only (smaller APK)
|
||||
# ./scripts/build-tauri-android.sh --arch all # both arm64 + armv7 (separate APKs)
|
||||
#
|
||||
# Environment:
|
||||
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
|
||||
@@ -29,27 +32,47 @@ REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/tauri-android-apk"
|
||||
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}"
|
||||
BRANCH="${WZP_BRANCH:-$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")}"
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||
|
||||
REBUILD_RUST=0
|
||||
DO_PULL=1
|
||||
DO_INIT=0
|
||||
BUILD_RELEASE=0
|
||||
BUILD_ARCH="arm64"
|
||||
NEXT_IS_ARCH=0
|
||||
for arg in "$@"; do
|
||||
if [ "$NEXT_IS_ARCH" = "1" ]; then
|
||||
BUILD_ARCH="$arg"
|
||||
NEXT_IS_ARCH=0
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--rust) REBUILD_RUST=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--init) DO_INIT=1 ;;
|
||||
--release) BUILD_RELEASE=1 ;;
|
||||
--arch) NEXT_IS_ARCH=1 ;;
|
||||
-h|--help)
|
||||
sed -n '3,30p' "$0"
|
||||
sed -n '3,32p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate --arch
|
||||
case "$BUILD_ARCH" in
|
||||
arm64|armv7|all) ;;
|
||||
*) echo "ERROR: --arch must be arm64, armv7, or all (got: $BUILD_ARCH)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name."
|
||||
exit 1
|
||||
fi
|
||||
echo "Target branch: $BRANCH arch: $BUILD_ARCH"
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
|
||||
|
||||
@@ -69,6 +92,7 @@ DO_PULL="${2:-1}"
|
||||
REBUILD_RUST="${3:-0}"
|
||||
DO_INIT="${4:-0}"
|
||||
BUILD_RELEASE="${5:-0}"
|
||||
BUILD_ARCH="${6:-arm64}"
|
||||
|
||||
LOG_FILE=/tmp/wzp-tauri-build.log
|
||||
GIT_HASH="unknown" # populated after fetch
|
||||
@@ -149,10 +173,25 @@ PROFILE_FLAG="--debug"
|
||||
mkdir -p "$BASE_DIR/data/cache/android-home"
|
||||
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true
|
||||
|
||||
# ─── Determine target architectures ──────────────────────────────────────
|
||||
# Maps BUILD_ARCH to cargo-ndk ABI names and cargo-tauri target names.
|
||||
# BUILD_ARCH=arm64 → one APK; BUILD_ARCH=armv7 → one APK; BUILD_ARCH=all → two APKs.
|
||||
case "$BUILD_ARCH" in
|
||||
arm64) ARCH_LIST="arm64" ;;
|
||||
armv7) ARCH_LIST="armv7" ;;
|
||||
all) ARCH_LIST="arm64 armv7" ;;
|
||||
esac
|
||||
|
||||
# Mapping functions (used inside docker via env vars)
|
||||
# cargo-ndk ABI: arm64-v8a | armeabi-v7a
|
||||
# cargo-tauri: aarch64 | armv7
|
||||
# NDK sysroot: aarch64-linux-android | arm-linux-androideabi
|
||||
|
||||
docker run --rm \
|
||||
--user 1000:1000 \
|
||||
-e DO_INIT="$DO_INIT" \
|
||||
-e PROFILE_FLAG="$PROFILE_FLAG" \
|
||||
-e BUILD_ARCH="$BUILD_ARCH" \
|
||||
-v "$BASE_DIR/data/source:/build/source" \
|
||||
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
|
||||
@@ -179,37 +218,179 @@ if [ "${DO_INIT}" = "1" ] || [ ! -x gen/android/gradlew ]; then
|
||||
cargo tauri android init 2>&1 | tail -20
|
||||
fi
|
||||
|
||||
# ─── Arch list from BUILD_ARCH env var ───────────────────────────────────
|
||||
case "${BUILD_ARCH}" in
|
||||
arm64) ARCHS="arm64" ;;
|
||||
armv7) ARCHS="armv7" ;;
|
||||
all) ARCHS="arm64 armv7" ;;
|
||||
*) ARCHS="arm64" ;;
|
||||
esac
|
||||
|
||||
ndk_abi() {
|
||||
case "$1" in
|
||||
arm64) echo "arm64-v8a" ;;
|
||||
armv7) echo "armeabi-v7a" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
tauri_target() {
|
||||
case "$1" in
|
||||
arm64) echo "aarch64" ;;
|
||||
armv7) echo "armv7" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ndk_sysroot_dir() {
|
||||
case "$1" in
|
||||
arm64) echo "aarch64-linux-android" ;;
|
||||
armv7) echo "arm-linux-androideabi" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
|
||||
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via
|
||||
# libloading. Split exists because cargo-tauri`s linker wiring pulls
|
||||
# libloading. Split exists because cargo-tauri linker wiring pulls
|
||||
# bionic private symbols into any cdylib with cc::Build C++, causing
|
||||
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
|
||||
# legacy wzp-android crate which works.
|
||||
echo ">>> cargo ndk build -p wzp-native --release"
|
||||
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
|
||||
mkdir -p "$JNI_ABI_DIR"
|
||||
(
|
||||
cd /build/source
|
||||
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
|
||||
build --release -p wzp-native 2>&1 | tail -10
|
||||
)
|
||||
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
|
||||
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
|
||||
else
|
||||
echo ">>> WARNING: libwzp_native.so not produced"
|
||||
fi
|
||||
JNILIBS_BASE=gen/android/app/src/main/jniLibs
|
||||
|
||||
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
|
||||
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk
|
||||
for ARCH in $ARCHS; do
|
||||
ABI=$(ndk_abi "$ARCH")
|
||||
SYSROOT_DIR=$(ndk_sysroot_dir "$ARCH")
|
||||
JNI_ABI_DIR="$JNILIBS_BASE/$ABI"
|
||||
mkdir -p "$JNI_ABI_DIR"
|
||||
|
||||
echo ">>> cargo ndk build -p wzp-native --release -t $ABI"
|
||||
(
|
||||
cd /build/source
|
||||
cargo ndk -t "$ABI" -o "desktop/src-tauri/$JNILIBS_BASE" \
|
||||
build --release -p wzp-native 2>&1 | tail -10
|
||||
)
|
||||
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
|
||||
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
|
||||
else
|
||||
echo ">>> WARNING: libwzp_native.so not produced for $ABI"
|
||||
fi
|
||||
|
||||
# ─── libc++_shared.so — required by wzp-native at runtime ────────────
|
||||
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
|
||||
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
|
||||
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
|
||||
# explicitly, the APK ships without it and the Android dynamic linker
|
||||
# fails the dlopen with "library libc++_shared.so not found" at runtime.
|
||||
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
|
||||
echo ">>> libc++_shared.so missing for $ABI, copying from NDK..."
|
||||
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/${SYSROOT_DIR}/*" | head -1)
|
||||
if [ -n "$NDK_LIBCXX" ]; then
|
||||
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
|
||||
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
|
||||
else
|
||||
echo ">>> ERROR: libc++_shared.so not found in NDK for $ABI — APK will crash at dlopen time"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── Build per-arch APKs ────────────────────────────────────────────────
|
||||
# When building for a single arch, only that arch jniLibs dir exists so
|
||||
# the APK is naturally single-arch and smaller.
|
||||
# When building --arch all, we produce SEPARATE per-arch APKs by:
|
||||
# 1. Building each target individually with cargo tauri android build
|
||||
# 2. Temporarily hiding the other arch jniLibs so the APK only contains one
|
||||
# This keeps APKs small (~15-20MB instead of ~30-40MB for universal).
|
||||
|
||||
APK_OUTPUT_DIR="/build/source/target/apk-output"
|
||||
mkdir -p "$APK_OUTPUT_DIR"
|
||||
|
||||
for ARCH in $ARCHS; do
|
||||
TARGET=$(tauri_target "$ARCH")
|
||||
ABI=$(ndk_abi "$ARCH")
|
||||
|
||||
# If building all, temporarily hide other arches to get single-arch APK
|
||||
if [ "${BUILD_ARCH}" = "all" ]; then
|
||||
for OTHER_ARCH in $ARCHS; do
|
||||
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
|
||||
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/$OTHER_ABI" ]; then
|
||||
mv "$JNILIBS_BASE/$OTHER_ABI" "$JNILIBS_BASE/_hide_$OTHER_ABI"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk"
|
||||
cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk
|
||||
|
||||
# Copy produced APK with arch suffix
|
||||
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
|
||||
if [ -z "$BUILT_APK" ]; then
|
||||
BUILT_APK=$(find gen/android -name "*.apk" -type f 2>/dev/null | sort -t/ -k1 | tail -1)
|
||||
fi
|
||||
if [ -n "$BUILT_APK" ]; then
|
||||
OUT_APK="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}.apk"
|
||||
cp "$BUILT_APK" "$OUT_APK"
|
||||
|
||||
# ─── Sign release APKs with the project keystore ─────────────
|
||||
# Release builds are unsigned by default. Sign with the release
|
||||
# keystore (checked into the repo at android/keystore/) so the
|
||||
# APK can be installed on real devices.
|
||||
# Pick keystore + credentials (release preferred, debug fallback)
|
||||
KS_RELEASE="/build/source/android/keystore/wzp-release.jks"
|
||||
KS_DEBUG="/build/source/android/keystore/wzp-debug.jks"
|
||||
if [ -f "$KS_RELEASE" ]; then
|
||||
KEYSTORE="$KS_RELEASE"; KS_PASS="wzphone2024"; KS_ALIAS="wzp-release"
|
||||
elif [ -f "$KS_DEBUG" ]; then
|
||||
KEYSTORE="$KS_DEBUG"; KS_PASS="android"; KS_ALIAS="wzp-debug"
|
||||
else
|
||||
KEYSTORE=""
|
||||
fi
|
||||
if [ -n "$KEYSTORE" ]; then
|
||||
ZIPALIGN=$(find "$ANDROID_HOME" -name zipalign -type f 2>/dev/null | head -1)
|
||||
APKSIGNER=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | head -1)
|
||||
if [ -n "$ZIPALIGN" ] && [ -n "$APKSIGNER" ]; then
|
||||
echo ">>> Signing $ARCH APK with $(basename "$KEYSTORE")..."
|
||||
ALIGNED="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}-aligned.apk"
|
||||
"$ZIPALIGN" -f 4 "$OUT_APK" "$ALIGNED"
|
||||
"$APKSIGNER" sign \
|
||||
--ks "$KEYSTORE" \
|
||||
--ks-pass "pass:$KS_PASS" \
|
||||
--ks-key-alias "$KS_ALIAS" \
|
||||
--key-pass "pass:$KS_PASS" \
|
||||
"$ALIGNED"
|
||||
mv "$ALIGNED" "$OUT_APK"
|
||||
echo ">>> Signed: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
|
||||
else
|
||||
echo ">>> WARNING: zipalign/apksigner not found — APK is unsigned"
|
||||
fi
|
||||
else
|
||||
echo ">>> WARNING: no keystore found — APK is unsigned"
|
||||
fi
|
||||
|
||||
echo ">>> $ARCH APK: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
|
||||
fi
|
||||
|
||||
# Restore hidden arches
|
||||
if [ "${BUILD_ARCH}" = "all" ]; then
|
||||
for OTHER_ARCH in $ARCHS; do
|
||||
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
|
||||
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/_hide_$OTHER_ABI" ]; then
|
||||
mv "$JNILIBS_BASE/_hide_$OTHER_ABI" "$JNILIBS_BASE/$OTHER_ABI"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ">>> Build artifacts:"
|
||||
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
|
||||
ls -lh "$APK_OUTPUT_DIR/"*.apk 2>/dev/null || echo " (none)"
|
||||
'
|
||||
|
||||
# Locate the produced APK
|
||||
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
|
||||
if [ -z "$APK" ] || [ ! -f "$APK" ]; then
|
||||
# ─── Collect and upload APKs ────────────────────────────────────────────
|
||||
# target/ is mounted from cache, not source
|
||||
APK_OUTPUT="$BASE_DIR/data/cache/target/apk-output"
|
||||
APK_LIST=$(find "$APK_OUTPUT" -name "wzp-tauri-*.apk" -type f 2>/dev/null | sort)
|
||||
|
||||
if [ -z "$APK_LIST" ]; then
|
||||
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
|
||||
if [ -n "$LOG_URL" ]; then
|
||||
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
|
||||
@@ -219,35 +400,56 @@ log: $LOG_URL"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
APK_SIZE=$(du -h "$APK" | cut -f1)
|
||||
|
||||
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
|
||||
if [ -n "$RUSTY_URL" ]; then
|
||||
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE)
|
||||
$RUSTY_URL"
|
||||
else
|
||||
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped"
|
||||
fi
|
||||
# Upload each APK and collect URLs
|
||||
NOTIFY_MSG="WZP Tauri Android build OK [$GIT_HASH] ($BUILD_ARCH)"
|
||||
APK_PATHS=""
|
||||
for APK in $APK_LIST; do
|
||||
APK_NAME=$(basename "$APK")
|
||||
APK_SIZE=$(du -h "$APK" | cut -f1)
|
||||
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
|
||||
if [ -n "$RUSTY_URL" ]; then
|
||||
NOTIFY_MSG="$NOTIFY_MSG
|
||||
$APK_NAME ($APK_SIZE): $RUSTY_URL"
|
||||
else
|
||||
NOTIFY_MSG="$NOTIFY_MSG
|
||||
$APK_NAME ($APK_SIZE) — upload skipped"
|
||||
fi
|
||||
APK_PATHS="$APK_PATHS $APK"
|
||||
done
|
||||
notify "$NOTIFY_MSG"
|
||||
|
||||
# Print path so the local script can grab it
|
||||
echo "APK_REMOTE_PATH=$APK"
|
||||
# Print paths so the local script can grab them
|
||||
for APK in $APK_LIST; do
|
||||
echo "APK_REMOTE_PATH=$APK"
|
||||
done
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
|
||||
|
||||
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)"
|
||||
log "Triggering remote build (branch=$BRANCH)..."
|
||||
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, arch=$BUILD_ARCH, release=$BUILD_RELEASE)"
|
||||
log "Triggering remote build (branch=$BRANCH, arch=$BUILD_ARCH)..."
|
||||
|
||||
# Run; capture full output, last line is APK_REMOTE_PATH=...
|
||||
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE'" || true)
|
||||
# Run; last lines are APK_REMOTE_PATH=... (one per arch)
|
||||
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE' '$BUILD_ARCH'" || true)
|
||||
echo "$REMOTE_OUTPUT" | tail -60
|
||||
|
||||
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
|
||||
if [ -n "$APK_REMOTE" ]; then
|
||||
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..."
|
||||
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/wzp-tauri.apk"
|
||||
echo " $LOCAL_OUTPUT/wzp-tauri.apk ($(du -h "$LOCAL_OUTPUT/wzp-tauri.apk" | cut -f1))"
|
||||
else
|
||||
# Download all produced APKs
|
||||
APK_REMOTES=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | cut -d= -f2-)
|
||||
if [ -z "$APK_REMOTES" ]; then
|
||||
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOWNLOADED=0
|
||||
echo "$APK_REMOTES" | while IFS= read -r APK_REMOTE; do
|
||||
[ -z "$APK_REMOTE" ] && continue
|
||||
APK_NAME=$(basename "$APK_REMOTE")
|
||||
log "Downloading $APK_NAME..."
|
||||
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/$APK_NAME"
|
||||
echo " $LOCAL_OUTPUT/$APK_NAME ($(du -h "$LOCAL_OUTPUT/$APK_NAME" | cut -f1))"
|
||||
DOWNLOADED=$((DOWNLOADED + 1))
|
||||
done
|
||||
|
||||
log "Done! APKs in $LOCAL_OUTPUT/"
|
||||
ls -lh "$LOCAL_OUTPUT"/wzp-tauri-*.apk 2>/dev/null || true
|
||||
|
||||
363
scripts/build.sh
Executable file
363
scripts/build.sh
Executable file
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# WZ Phone — unified build script
|
||||
#
|
||||
# Builds Tauri Android APK and/or Linux x86_64 binaries via Docker on a
|
||||
# remote build server. Uploads artifacts, notifies via ntfy.sh/wzp.
|
||||
#
|
||||
# Two servers:
|
||||
# PRIMARY (default) SepehrHomeserverdk paste.dk.manko.yoga origin (gitea)
|
||||
# ALT (--alt) manwe@172.16.81.175 paste.tbs.amn.gg fj (forgejo)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build.sh Android APK (current branch, primary)
|
||||
# ./scripts/build.sh --alt Android APK on alt server
|
||||
# ./scripts/build.sh --linux Linux binaries only
|
||||
# ./scripts/build.sh --all Android + Linux
|
||||
# ./scripts/build.sh --branch NAME Override branch
|
||||
# ./scripts/build.sh --rust Force Rust rebuild
|
||||
# ./scripts/build.sh --no-pull Skip git pull
|
||||
# ./scripts/build.sh --init First-time setup (clone + Docker image)
|
||||
# ./scripts/build.sh --install Download APK + adb install locally
|
||||
# ./scripts/build.sh --release Release APK (not debug)
|
||||
# ./scripts/build.sh --android64 Release arm64 APK (shorthand for --android --release)
|
||||
# =============================================================================
|
||||
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/tauri-android-apk"
|
||||
SSH_BASE_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||
|
||||
# ── Server profiles ─────────────────────────────────────────────────────────
|
||||
USE_ALT=0
|
||||
REBUILD_RUST=0
|
||||
DO_PULL=1
|
||||
DO_INSTALL=0
|
||||
DO_INIT=0
|
||||
BUILD_ANDROID=1
|
||||
BUILD_LINUX=0
|
||||
BUILD_RELEASE=0
|
||||
BRANCH=$(git -C "$(dirname "$0")/.." branch --show-current 2>/dev/null || echo "")
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--alt) USE_ALT=1 ;;
|
||||
--rust) REBUILD_RUST=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--install) DO_INSTALL=1 ;;
|
||||
--init) DO_INIT=1 ;;
|
||||
--android) BUILD_ANDROID=1; BUILD_LINUX=0 ;;
|
||||
--android64) BUILD_ANDROID=1; BUILD_LINUX=0; BUILD_RELEASE=1; BRANCH="main" ;;
|
||||
--linux) BUILD_ANDROID=0; BUILD_LINUX=1 ;;
|
||||
--all) BUILD_ANDROID=1; BUILD_LINUX=1 ;;
|
||||
--release) BUILD_RELEASE=1 ;;
|
||||
--branch) shift; BRANCH="$1" ;;
|
||||
--branch=*) BRANCH="${1#--branch=}" ;;
|
||||
-h|--help) sed -n '3,22p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown arg: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "ERROR: could not determine target branch (detached HEAD?). Pass --branch NAME."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Select server profile ───────────────────────────────────────────────────
|
||||
if [ "$USE_ALT" = "1" ]; then
|
||||
SERVER_TAG="ALT"
|
||||
REMOTE_HOST="manwe@172.16.81.175"
|
||||
BASE_DIR="/home/manwe/wzp-builder"
|
||||
SSH_OPTS="$SSH_BASE_OPTS"
|
||||
GIT_ORIGIN="ssh://git@git.tbs.amn.gg:2222/manawenuz/wzp.git"
|
||||
# Alt server uploads directly (no .env file)
|
||||
UPLOAD_MODE="direct"
|
||||
PASTE_URL="https://paste.tbs.manko.yoga"
|
||||
PASTE_AUTH="X2j6szIQaoJGaxZjLkpl3A8IX9/mTkDgdhhgyYFcpaU="
|
||||
else
|
||||
SERVER_TAG="PRI"
|
||||
REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
SSH_OPTS="-A $SSH_BASE_OPTS"
|
||||
GIT_ORIGIN="" # uses existing origin on the remote
|
||||
# Primary server uses .env file for rustypaste credentials
|
||||
UPLOAD_MODE="envfile"
|
||||
PASTE_URL=""
|
||||
PASTE_AUTH=""
|
||||
fi
|
||||
|
||||
TARGETS=""
|
||||
[ "$BUILD_ANDROID" = 1 ] && TARGETS="Android"
|
||||
[ "$BUILD_LINUX" = 1 ] && TARGETS="${TARGETS:+$TARGETS + }Linux"
|
||||
echo "[$SERVER_TAG] branch: $BRANCH | targets: $TARGETS"
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
ssh_cmd() { ssh $SSH_OPTS "$REMOTE_HOST" "$@"; }
|
||||
|
||||
# ── First-time setup (--init) ───────────────────────────────────────────────
|
||||
if [ "$DO_INIT" = "1" ]; then
|
||||
log "[$SERVER_TAG] First-time setup..."
|
||||
ssh_cmd "mkdir -p $BASE_DIR/data/{source,cache/target,cache/cargo-registry,cache/cargo-git,cache/gradle,cache/android-home,cache-linux/target,cache-linux/cargo-registry,cache-linux/cargo-git}"
|
||||
|
||||
if [ -n "$GIT_ORIGIN" ]; then
|
||||
log "Cloning from $GIT_ORIGIN..."
|
||||
ssh_cmd "if [ ! -d $BASE_DIR/data/source/.git ]; then git clone $GIT_ORIGIN $BASE_DIR/data/source; else echo 'Repo already cloned'; fi"
|
||||
fi
|
||||
|
||||
log "Uploading Dockerfile..."
|
||||
cat scripts/Dockerfile.android-builder | ssh_cmd "cat > /tmp/Dockerfile.android-builder"
|
||||
log "Building Docker image (10-20 min on first run)..."
|
||||
ssh_cmd "cd /tmp && docker build -t wzp-android-builder -f Dockerfile.android-builder . 2>&1 | tail -20"
|
||||
|
||||
log "[$SERVER_TAG] Init done! Run without --init to build."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Upload remote build script ──────────────────────────────────────────────
|
||||
log "[$SERVER_TAG] Uploading build script..."
|
||||
ssh_cmd "cat > /tmp/wzp-build.sh" <<REMOTE_SCRIPT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="$BASE_DIR"
|
||||
NTFY_TOPIC="$NTFY_TOPIC"
|
||||
REBUILD_RUST="$REBUILD_RUST"
|
||||
DO_PULL="$DO_PULL"
|
||||
BRANCH="$BRANCH"
|
||||
BUILD_ANDROID="$BUILD_ANDROID"
|
||||
BUILD_LINUX="$BUILD_LINUX"
|
||||
BUILD_RELEASE="$BUILD_RELEASE"
|
||||
SERVER_TAG="$SERVER_TAG"
|
||||
UPLOAD_MODE="$UPLOAD_MODE"
|
||||
PASTE_URL="$PASTE_URL"
|
||||
PASTE_AUTH="$PASTE_AUTH"
|
||||
|
||||
notify() { curl -s -d "\$1" "\$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
# Upload a file; print URL on stdout.
|
||||
upload_file() {
|
||||
local file="\$1"
|
||||
if [ "\$UPLOAD_MODE" = "direct" ]; then
|
||||
curl -s -F "file=@\$file" -H "Authorization: \$PASTE_AUTH" "\$PASTE_URL" || echo ""
|
||||
else
|
||||
local env_file="\$BASE_DIR/.env"
|
||||
[ ! -f "\$env_file" ] && { echo ""; return; }
|
||||
source "\$env_file"
|
||||
if [ -n "\${rusty_address:-}" ] && [ -n "\${rusty_auth_token:-}" ]; then
|
||||
curl -s -F "file=@\$file" -H "Authorization: \$rusty_auth_token" "\$rusty_address" || echo ""
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
trap 'notify "WZP [\$SERVER_TAG] build FAILED [\$BRANCH]! Check /tmp/wzp-build.log"' ERR
|
||||
|
||||
# ── Pull source ─────────────────────────────────────────────────────────
|
||||
if [ "\$DO_PULL" = "1" ]; then
|
||||
echo ">>> Pulling branch '\$BRANCH' from origin..."
|
||||
cd "\$BASE_DIR/data/source"
|
||||
git reset --hard HEAD 2>/dev/null || true
|
||||
# NOTE: do NOT git clean -fd — it wipes tauri-generated scaffold
|
||||
git fetch origin "\$BRANCH" 2>&1 | tail -3
|
||||
git checkout "\$BRANCH" 2>/dev/null || git checkout -b "\$BRANCH" "origin/\$BRANCH"
|
||||
git reset --hard "origin/\$BRANCH"
|
||||
git submodule update --init || true
|
||||
echo ">>> HEAD: \$(git rev-parse --short HEAD) — \$(git log -1 --format=%s)"
|
||||
fi
|
||||
|
||||
GIT_HASH=\$(cd "\$BASE_DIR/data/source" && git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
GIT_MSG=\$(cd "\$BASE_DIR/data/source" && git log -1 --pretty=%s 2>/dev/null | head -c 60 || echo "?")
|
||||
|
||||
# ── Clean Rust if requested ─────────────────────────────────────────────
|
||||
if [ "\$REBUILD_RUST" = "1" ]; then
|
||||
echo ">>> Cleaning Rust targets..."
|
||||
rm -rf "\$BASE_DIR/data/cache/target/aarch64-linux-android" \
|
||||
"\$BASE_DIR/data/cache/target/armv7-linux-androideabi" \
|
||||
"\$BASE_DIR/data/cache/target/i686-linux-android" \
|
||||
"\$BASE_DIR/data/cache/target/x86_64-linux-android"
|
||||
rm -rf "\$BASE_DIR/data/cache-linux/target/release"
|
||||
fi
|
||||
|
||||
# ── Fix perms ───────────────────────────────────────────────────────────
|
||||
find "\$BASE_DIR/data/source" "\$BASE_DIR/data/cache" \
|
||||
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||
xargs -r chown 1000:1000 2>/dev/null || true
|
||||
if [ -d "\$BASE_DIR/data/cache-linux" ]; then
|
||||
find "\$BASE_DIR/data/cache-linux" \
|
||||
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||
xargs -r chown 1000:1000 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Tauri Android APK ──────────────────────────────────────────────────
|
||||
if [ "\$BUILD_ANDROID" = "1" ]; then
|
||||
notify "WZP [\$SERVER_TAG] Tauri Android build STARTED [\$BRANCH @ \$GIT_HASH] — \$GIT_MSG"
|
||||
echo ">>> Building Tauri Android APK..."
|
||||
|
||||
PROFILE_FLAG="--debug"
|
||||
[ "\$BUILD_RELEASE" = "1" ] && PROFILE_FLAG=""
|
||||
|
||||
mkdir -p "\$BASE_DIR/data/cache/android-home"
|
||||
chown 1000:1000 "\$BASE_DIR/data/cache/android-home" 2>/dev/null || true
|
||||
|
||||
docker run --rm --user 1000:1000 \
|
||||
-e PROFILE_FLAG="\$PROFILE_FLAG" \
|
||||
-v "\$BASE_DIR/data/source:/build/source" \
|
||||
-v "\$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "\$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
|
||||
-v "\$BASE_DIR/data/cache/target:/build/source/target" \
|
||||
-v "\$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \
|
||||
-v "\$BASE_DIR/data/cache/android-home:/home/builder/.android" \
|
||||
wzp-android-builder bash -c '
|
||||
set -euo pipefail
|
||||
cd /build/source/desktop
|
||||
|
||||
echo ">>> npm install"
|
||||
npm install --silent 2>&1 | tail -5 || npm install 2>&1 | tail -20
|
||||
|
||||
cd src-tauri
|
||||
|
||||
if [ ! -x gen/android/gradlew ]; then
|
||||
echo ">>> cargo tauri android init"
|
||||
cargo tauri android init 2>&1 | tail -20
|
||||
fi
|
||||
|
||||
echo ">>> cargo ndk build -p wzp-native --release"
|
||||
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
|
||||
mkdir -p "\$JNI_ABI_DIR"
|
||||
(
|
||||
cd /build/source
|
||||
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
|
||||
build --release -p wzp-native 2>&1 | tail -10
|
||||
)
|
||||
[ -f "\$JNI_ABI_DIR/libwzp_native.so" ] && ls -lh "\$JNI_ABI_DIR/libwzp_native.so"
|
||||
|
||||
if [ ! -f "\$JNI_ABI_DIR/libc++_shared.so" ]; then
|
||||
echo ">>> libc++_shared.so missing, copying from NDK..."
|
||||
NDK_LIBCXX=\$(find "\$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
|
||||
if [ -n "\$NDK_LIBCXX" ]; then
|
||||
cp "\$NDK_LIBCXX" "\$JNI_ABI_DIR/"
|
||||
else
|
||||
echo "ERROR: libc++_shared.so not found in NDK"; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">>> cargo tauri android build \${PROFILE_FLAG} --target aarch64 --apk"
|
||||
cargo tauri android build \${PROFILE_FLAG} --target aarch64 --apk
|
||||
|
||||
echo ">>> Build artifacts:"
|
||||
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
|
||||
echo "APK_BUILT"
|
||||
'
|
||||
|
||||
echo ">>> Uploading APK..."
|
||||
APK=\$(find "\$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
|
||||
if [ -n "\$APK" ]; then
|
||||
APK_SIZE=\$(du -h "\$APK" | cut -f1)
|
||||
URL=\$(upload_file "\$APK")
|
||||
echo "APK_URL=\$URL"
|
||||
notify "WZP [\$SERVER_TAG] Tauri Android OK [\$BRANCH @ \$GIT_HASH] (\$APK_SIZE)
|
||||
\$URL"
|
||||
echo ">>> APK: \$URL (\$APK_SIZE)"
|
||||
else
|
||||
notify "WZP [\$SERVER_TAG] Tauri Android FAILED [\$BRANCH @ \$GIT_HASH] - no APK"
|
||||
echo "ERROR: No APK found"; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Linux x86_64 binaries ───────────────────────────────────────────────
|
||||
if [ "\$BUILD_LINUX" = "1" ]; then
|
||||
mkdir -p "\$BASE_DIR/data/cache-linux/target" \
|
||||
"\$BASE_DIR/data/cache-linux/cargo-registry" \
|
||||
"\$BASE_DIR/data/cache-linux/cargo-git"
|
||||
|
||||
notify "WZP [\$SERVER_TAG] Linux x86_64 build STARTED [\$BRANCH @ \$GIT_HASH]..."
|
||||
echo ">>> Building Linux binaries..."
|
||||
|
||||
docker run --rm --user 1000:1000 \
|
||||
-v "\$BASE_DIR/data/source:/build/source" \
|
||||
-v "\$BASE_DIR/data/cache-linux/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "\$BASE_DIR/data/cache-linux/cargo-git:/home/builder/.cargo/git" \
|
||||
-v "\$BASE_DIR/data/cache-linux/target:/build/source/target" \
|
||||
wzp-android-builder bash -c '
|
||||
set -euo pipefail
|
||||
cd /build/source
|
||||
|
||||
echo ">>> Building relay + client + web + bench..."
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5
|
||||
|
||||
echo ">>> Building audio client..."
|
||||
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3
|
||||
cp target/release/wzp-client target/release/wzp-client-audio
|
||||
cargo build --release --bin wzp-client 2>&1 | tail -3
|
||||
|
||||
echo ">>> Binaries:"
|
||||
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench
|
||||
|
||||
echo ">>> Packaging..."
|
||||
tar czf /tmp/wzp-linux-x86_64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench
|
||||
echo "BINARIES_BUILT"
|
||||
'
|
||||
|
||||
echo ">>> Uploading Linux binaries..."
|
||||
docker run --rm \
|
||||
-v "\$BASE_DIR/data/cache-linux/target:/build/target" \
|
||||
wzp-android-builder bash -c \
|
||||
"cp /build/target/release/wzp-relay /build/target/release/wzp-client /build/target/release/wzp-client-audio /build/target/release/wzp-web /build/target/release/wzp-bench /tmp/ && tar czf /tmp/wzp-linux-x86_64.tar.gz -C /tmp wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench && cat /tmp/wzp-linux-x86_64.tar.gz" \
|
||||
> /tmp/wzp-linux-x86_64.tar.gz
|
||||
|
||||
URL=\$(upload_file /tmp/wzp-linux-x86_64.tar.gz)
|
||||
if [ -n "\$URL" ]; then
|
||||
echo "LINUX_URL=\$URL"
|
||||
notify "WZP [\$SERVER_TAG] Linux x86_64 OK [\$BRANCH @ \$GIT_HASH]
|
||||
\$URL"
|
||||
echo ">>> Linux binaries: \$URL"
|
||||
else
|
||||
notify "WZP [\$SERVER_TAG] Linux build FAILED - upload error"
|
||||
echo "ERROR: Linux upload failed"; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">>> All builds complete!"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ssh_cmd "chmod +x /tmp/wzp-build.sh"
|
||||
|
||||
# Run in tmux
|
||||
log "[$SERVER_TAG] Starting build in tmux (branch: $BRANCH)..."
|
||||
ssh_cmd "tmux kill-session -t wzp-build 2>/dev/null; true"
|
||||
ssh_cmd "tmux new-session -d -s wzp-build '/tmp/wzp-build.sh 2>&1 | tee /tmp/wzp-build.log'"
|
||||
|
||||
log "[$SERVER_TAG] Build running! Notification on ntfy.sh/wzp when done."
|
||||
echo ""
|
||||
echo " Monitor: ssh $REMOTE_HOST 'tail -f /tmp/wzp-build.log'"
|
||||
echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-build.log'"
|
||||
echo ""
|
||||
|
||||
# Optionally wait and install locally
|
||||
if [ "$DO_INSTALL" = "1" ]; then
|
||||
log "Waiting for build..."
|
||||
while true; do
|
||||
sleep 15
|
||||
if ssh_cmd "grep -q 'APK_URL\|LINUX_URL\|ERROR\|All builds complete' /tmp/wzp-build.log 2>/dev/null"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
URL=$(ssh_cmd "grep APK_URL /tmp/wzp-build.log | tail -1 | cut -d= -f2")
|
||||
if [ -n "$URL" ]; then
|
||||
log "Downloading APK..."
|
||||
mkdir -p "$LOCAL_OUTPUT"
|
||||
curl -s -o "$LOCAL_OUTPUT/wzp-tauri.apk" "$URL"
|
||||
log "Installing..."
|
||||
adb uninstall com.wzp.phone 2>/dev/null || true
|
||||
adb install "$LOCAL_OUTPUT/wzp-tauri.apk"
|
||||
log "Done!"
|
||||
else
|
||||
log "No APK URL found in log"
|
||||
fi
|
||||
fi
|
||||
72
vendor/audiopus_sys/.github/workflows/ci.yml
vendored
72
vendor/audiopus_sys/.github/workflows/ci.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os || 'ubuntu-latest' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
name:
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
- macOS
|
||||
- Windows
|
||||
|
||||
include:
|
||||
- name: beta
|
||||
toolchain: beta
|
||||
- name: nightly
|
||||
toolchain: nightly
|
||||
- name: macOS
|
||||
os: macOS-latest
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Install toolchain
|
||||
id: tc
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Install dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libopus-dev
|
||||
|
||||
- name: Setup cache
|
||||
if: runner.os != 'macOS'
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ matrix.os }}-test-${{ steps.tc.outputs.rustc_hash }}-${{ hashFiles('**/Cargo.toml') }}
|
||||
|
||||
- name: Build static
|
||||
run: cargo build --features "static"
|
||||
|
||||
- name: Build dynamic
|
||||
run: cargo build --features "dynamic"
|
||||
|
||||
# TODO: Fix for CI environment.
|
||||
#- name: Generate bindings
|
||||
# run: cargo build --features "generate_binding"
|
||||
|
||||
- name: Test all features
|
||||
# TODO: Once "generate_binding" is fixed, replace with `--all-features`
|
||||
# again.
|
||||
run: cargo test --features "static dynamic"
|
||||
3
vendor/audiopus_sys/.gitignore
vendored
3
vendor/audiopus_sys/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
60
vendor/audiopus_sys/CHANGELOG.md
vendored
60
vendor/audiopus_sys/CHANGELOG.md
vendored
@@ -1,60 +0,0 @@
|
||||
# Change Log
|
||||
|
||||
An overview of changes:
|
||||
|
||||
## [0.2.0]
|
||||
|
||||
* Now requires `cmake`.
|
||||
* Windows will build via `cmake` too.
|
||||
* Windows pre-built binaries have been removed.
|
||||
* Updated `bindgen` to version `0.58`.
|
||||
|
||||
## [0.1.8]
|
||||
|
||||
This release adds build support for FreeBSD.
|
||||
|
||||
## [0.1.7]
|
||||
|
||||
Add missing `opus`-folder.
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
This release removes the `bindgen`-dependency from the default features.
|
||||
Additionally, the `bindgen`-feature has been added in order to generate a new binding.
|
||||
|
||||
## [0.1.4 and 0.1.5]
|
||||
|
||||
v0.1.4:
|
||||
This release fixes a problem where `audiopus_sys` could not find the
|
||||
Opus folder.
|
||||
|
||||
v0.1.5:
|
||||
Convert Unix-relevant files' EOLs from CRLF to LF inside the opus-folder.
|
||||
|
||||
### **Fix**
|
||||
* Bundle the Opus project again.
|
||||
* Added missing `cfg` on `find_via_pkg_config`.
|
||||
|
||||
## [0.1.3]
|
||||
|
||||
Fixes build-issues related to `pkg-config`.
|
||||
|
||||
## [0.1.2]
|
||||
|
||||
This release adds the ability to bypass `pkg-config`.
|
||||
|
||||
### **Added:**
|
||||
|
||||
* Ignore `pkg-config` when `LIBOPUS_NO_PKG` or `OPUS_NO_PKG` is set.
|
||||
* Print the dynamic/static build cause via `cargo:info`.
|
||||
* Add missing repository-link in `Cargo.toml`.
|
||||
|
||||
## [0.1.1]
|
||||
|
||||
### **Added:**
|
||||
|
||||
* Copy Opus' source to `OUT_DIR` before building to avoid modifying and generating files outside of `OUT_DIR`.
|
||||
|
||||
### **Fixed:**
|
||||
* Convert Unix-relevant files' EOLs from `CRLF` to `LF` inside the `opus`-folder.
|
||||
* Resolve unused import warnings when building with Unix.
|
||||
62
vendor/audiopus_sys/CONTRIBUTING.md
vendored
62
vendor/audiopus_sys/CONTRIBUTING.md
vendored
@@ -1,62 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
Everyone is welcome to get involved, may it be a pull request, suggestion, bug
|
||||
report, or a textual improvement! : )
|
||||
|
||||
The language applied in this repository is British English.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions to `audiopus_sys` should be first discussed up via an issue and then
|
||||
implemented via pull request.
|
||||
Issues display development-plans or required brainstorming, feel free to ask,
|
||||
suggest, and discuss!
|
||||
The `master`-branch contains the latest release.
|
||||
|
||||
## Comments & Documentation Style
|
||||
|
||||
- Comments are placed the lines before the related code line, not on the same
|
||||
line.
|
||||
|
||||
- Write full sentences in British English.
|
||||
|
||||
- `unsafe` must always be reasoned and their soundness must be proven via a
|
||||
comment.
|
||||
|
||||
- Use Rust intra-doc-links paths to refer Rust items in documentation:
|
||||
`[name](crate::module::struct::method)`.
|
||||
|
||||
- If code ends up difficult, try to simplify it, if unavoidable, explain code
|
||||
with comments. Prefer explicit variable naming instead of abbreviations.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Write full sentences in British English.
|
||||
|
||||
Commits should describe the action being peformed.
|
||||
|
||||
Example:
|
||||
- *Fix deadlock for events.*
|
||||
- *Correct grammar in `command`-example.*
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- Make sure to open an issue prior working on a problem or ask on existing
|
||||
issue be assigned.
|
||||
|
||||
- If a pull requests breaks the current API, use the `breaking-changes`-branch,
|
||||
otherwise `stable-changes`.
|
||||
|
||||
- Commits shall be as small as possible, compile, and pass all tests.
|
||||
|
||||
- Make sure your code is formatted with `rustfmt` and free of lints,
|
||||
run `cargo fmt` and `cargo clippy`.
|
||||
|
||||
- If you fixed a bug, add a test for that bug. Unit tests belong inside the
|
||||
same file's `mod` named `tests`, integrational tests belong inside the
|
||||
`tests`-folder.
|
||||
|
||||
- Last but not least, make sure your planned pull request merges cleanly,
|
||||
if it does not, rebase your changes.
|
||||
|
||||
If you have any questions left, please reach out via the issue system : )
|
||||
44
vendor/audiopus_sys/Cargo.toml
vendored
44
vendor/audiopus_sys/Cargo.toml
vendored
@@ -1,44 +0,0 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies
|
||||
#
|
||||
# If you believe there's an error in this file please file an
|
||||
# issue against the rust-lang/cargo repository. If you're
|
||||
# editing this file be aware that the upstream Cargo.toml
|
||||
# will likely look very different (and much more reasonable)
|
||||
|
||||
[package]
|
||||
edition = "2018"
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
authors = ["Lakelezz <lakelezz@protonmail.ch>"]
|
||||
description = "FFI-Binding to Opus, dynamically or statically linked for Windows and UNIX."
|
||||
documentation = "https://docs.rs/audiopus_sys"
|
||||
readme = "README.md"
|
||||
keywords = ["audio", "opus", "codec"]
|
||||
categories = ["api-bindings", "compression", "encoding", "multimedia::audio", "multimedia::encoding"]
|
||||
license = "ISC"
|
||||
repository = "https://github.com/lakelezz/audiopus_sys.git"
|
||||
|
||||
[dependencies]
|
||||
[build-dependencies.bindgen]
|
||||
version = "0.58"
|
||||
optional = true
|
||||
|
||||
[build-dependencies.cmake]
|
||||
version = "0.1"
|
||||
|
||||
[build-dependencies.log]
|
||||
version = "0.4"
|
||||
|
||||
[build-dependencies.pkg-config]
|
||||
version = "0.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
dynamic = []
|
||||
generate_binding = ["bindgen"]
|
||||
static = []
|
||||
30
vendor/audiopus_sys/Cargo.toml.orig
generated
vendored
30
vendor/audiopus_sys/Cargo.toml.orig
generated
vendored
@@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "audiopus_sys"
|
||||
version = "0.2.2"
|
||||
license = "ISC"
|
||||
repository = "https://github.com/lakelezz/audiopus_sys.git"
|
||||
authors = ["Lakelezz <lakelezz@protonmail.ch>"]
|
||||
keywords = ["audio", "opus", "codec"]
|
||||
categories = ["api-bindings", "compression", "encoding",
|
||||
"multimedia::audio", "multimedia::encoding"]
|
||||
description = "FFI-Binding to Opus, dynamically or statically linked for Windows and UNIX."
|
||||
readme = "README.md"
|
||||
documentation = "https://docs.rs/audiopus_sys"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[build-dependencies]
|
||||
log = "0.4"
|
||||
pkg-config = "0.3"
|
||||
cmake = "0.1"
|
||||
|
||||
[build-dependencies.bindgen]
|
||||
version = "0.58"
|
||||
optional = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
dynamic = []
|
||||
static = []
|
||||
generate_binding = ["bindgen"]
|
||||
15
vendor/audiopus_sys/LICENSE.md
vendored
15
vendor/audiopus_sys/LICENSE.md
vendored
@@ -1,15 +0,0 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2019, Lakelezz
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
81
vendor/audiopus_sys/README.md
vendored
81
vendor/audiopus_sys/README.md
vendored
@@ -1,81 +0,0 @@
|
||||
[![ci-badge][]][ci] [![docs-badge][]][docs] [![rust version badge]][rust version link] [![crates.io version]][crates.io link]
|
||||
|
||||
# About
|
||||
|
||||
`audiopus_sys` is an FFI-Rust-binding to [`Opus`] version 1.3.
|
||||
|
||||
Orginally, this sys-crate was made to empower the [`serenity`]-crate to build audio features on Windows, Linux, and Mac. However, it's not limited to that.
|
||||
|
||||
Everyone is welcome to contribute,
|
||||
check out the [`CONTRIBUTING.md`](CONTRIBUTING.md) for further guidance.
|
||||
|
||||
# Building
|
||||
|
||||
## Requirements
|
||||
If you want to build Opus, you will need `cmake`.
|
||||
|
||||
If you have `pkg-config`, it will attempt to use that before building.
|
||||
|
||||
You can also link a pre-installed Opus, see [**Pre-installed Opus**](#Pre-installed-Opus)
|
||||
below.
|
||||
|
||||
This crate provides a pre-built binding. In case you want to generate the
|
||||
binding yourself, you will need [`Clang`](https://rust-lang.github.io/rust-bindgen/requirements.html#clang),
|
||||
see [**Pre-installed Opus**](#Generating-The-Binding) below for further
|
||||
instructions.
|
||||
|
||||
## Linking
|
||||
`audiopus_sys` links to Opus 1.3 and supports Windows, Linux, and MacOS
|
||||
By default, we statically link to Windows, MacOS, and if you use the
|
||||
`musl`-environment. We will link dynamically for Linux except when using
|
||||
mentioned `musl`.
|
||||
|
||||
This can be altered by compiling with the `static` or `dynamic` feature having
|
||||
effects respective to their names. If both features are enabled,
|
||||
we will pick your system's default.
|
||||
|
||||
Environment variables named `LIBOPUS_STATIC` or `OPUS_STATIC` will take
|
||||
precedence over features thus overriding the behaviour. The value of these
|
||||
environment variables have no influence of the result: If one of them is set,
|
||||
statically linking will be picked.
|
||||
|
||||
## Pkg-Config
|
||||
By default, `audiopus_sys` will use `pkg-config` on Unix or GNU.
|
||||
Setting the environment variable `LIBOPUS_NO_PKG` or `OPUS_NO_PKG` will bypass
|
||||
probing for Opus via `pkg-config`.
|
||||
|
||||
## Pre-installed Opus
|
||||
If you have Opus pre-installed, you can set `LIBOPUS_LIB_DIR` or
|
||||
`OPUS_LIB_DIR` to the directory containing Opus.
|
||||
|
||||
Be aware that using an Opus other than version 1.3 may not work.
|
||||
|
||||
# Generating The Binding
|
||||
If you want to generate the binding yourself, you can use the
|
||||
`generate_binding`-feature.
|
||||
|
||||
Be aware, `bindgen` requires Clang and its `LIBCLANG_PATH`
|
||||
environment variable to be specified.
|
||||
|
||||
# Installation
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
audiopus_sys = "0.2"
|
||||
```
|
||||
[`serenity`]: https://crates.io/crates/serenity
|
||||
|
||||
[`Opus`]: https://www.opus-codec.org/
|
||||
|
||||
[ci-badge]: https://img.shields.io/github/workflow/status/Lakelezz/audiopus_sys/CI?style=flat-square
|
||||
[ci]: https://github.com/Lakelezz/audiopus_sys/actions
|
||||
|
||||
[docs-badge]: https://img.shields.io/badge/docs-online-5023dd.svg?style=flat-square&colorB=32b6b7
|
||||
[docs]: https://docs.rs/audiopus_sys
|
||||
|
||||
[rust version badge]: https://img.shields.io/badge/rust-1.51+-93450a.svg?style=flat-square&colorB=ff9a0d
|
||||
[rust version link]: https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html
|
||||
|
||||
[crates.io link]: https://crates.io/crates/audiopus_sys
|
||||
[crates.io version]: https://img.shields.io/crates/v/audiopus_sys.svg?style=flat-square&colorB=b73732
|
||||
149
vendor/audiopus_sys/build.rs
vendored
149
vendor/audiopus_sys/build.rs
vendored
@@ -1,149 +0,0 @@
|
||||
#![deny(rust_2018_idioms)]
|
||||
|
||||
#[cfg(feature = "generate_binding")]
|
||||
use std::path::PathBuf;
|
||||
use std::{env, fmt::Display, path::Path};
|
||||
|
||||
/// Outputs the library-file's prefix as word usable for actual arguments on
|
||||
/// commands or paths.
|
||||
const fn rustc_linking_word(is_static_link: bool) -> &'static str {
|
||||
if is_static_link {
|
||||
"static"
|
||||
} else {
|
||||
"dylib"
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a new binding at `src/lib.rs` using `src/wrapper.h`.
|
||||
#[cfg(feature = "generate_binding")]
|
||||
fn generate_binding() {
|
||||
const ALLOW_UNCONVENTIONALS: &'static str = "#![allow(non_upper_case_globals)]\n\
|
||||
#![allow(non_camel_case_types)]\n\
|
||||
#![allow(non_snake_case)]\n";
|
||||
|
||||
let bindings = bindgen::Builder::default()
|
||||
.header("src/wrapper.h")
|
||||
.raw_line(ALLOW_UNCONVENTIONALS)
|
||||
.generate()
|
||||
.expect("Unable to generate binding");
|
||||
|
||||
let binding_target_path = PathBuf::new().join("src").join("lib.rs");
|
||||
|
||||
bindings
|
||||
.write_to_file(binding_target_path)
|
||||
.expect("Could not write binding to the file at `src/lib.rs`");
|
||||
|
||||
println!("cargo:info=Successfully generated binding.");
|
||||
}
|
||||
|
||||
fn build_opus(is_static: bool) {
|
||||
let opus_path = Path::new("opus");
|
||||
|
||||
println!(
|
||||
"cargo:info=Opus source path used: {:?}.",
|
||||
opus_path
|
||||
.canonicalize()
|
||||
.expect("Could not canonicalise to absolute path")
|
||||
);
|
||||
|
||||
println!("cargo:info=Building Opus via CMake.");
|
||||
let opus_build_dir = cmake::build(opus_path);
|
||||
link_opus(is_static, opus_build_dir.display())
|
||||
}
|
||||
|
||||
fn link_opus(is_static: bool, opus_build_dir: impl Display) {
|
||||
let is_static_text = rustc_linking_word(is_static);
|
||||
|
||||
println!(
|
||||
"cargo:info=Linking Opus as {} lib: {}",
|
||||
is_static_text, opus_build_dir
|
||||
);
|
||||
println!("cargo:rustc-link-lib={}=opus", is_static_text);
|
||||
println!("cargo:rustc-link-search=native={}/lib", opus_build_dir);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, target_env = "gnu"))]
|
||||
fn find_via_pkg_config(is_static: bool) -> bool {
|
||||
pkg_config::Config::new()
|
||||
.statik(is_static)
|
||||
.probe("opus")
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Based on the OS or target environment we are building for,
|
||||
/// this function will return an expected default library linking method.
|
||||
///
|
||||
/// If we build for Windows, MacOS, or Linux with musl, we will link statically.
|
||||
/// However, if you build for Linux without musl, we will link dynamically.
|
||||
///
|
||||
/// **Info**:
|
||||
/// This is a helper-function and may not be called if
|
||||
/// if the `static`-feature is enabled, the environment variable
|
||||
/// `LIBOPUS_STATIC` or `OPUS_STATIC` is set.
|
||||
fn default_library_linking() -> bool {
|
||||
#[cfg(any(windows, target_os = "macos", target_env = "musl"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(any(target_os = "freebsd", all(unix, target_env = "gnu")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_installed_opus() -> Option<String> {
|
||||
if let Ok(lib_directory) = env::var("LIBOPUS_LIB_DIR") {
|
||||
Some(lib_directory)
|
||||
} else if let Ok(lib_directory) = env::var("OPUS_LIB_DIR") {
|
||||
Some(lib_directory)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_static_build() -> bool {
|
||||
if cfg!(feature = "static") && cfg!(feature = "dynamic") {
|
||||
default_library_linking()
|
||||
} else if cfg!(feature = "static")
|
||||
|| env::var("LIBOPUS_STATIC").is_ok()
|
||||
|| env::var("OPUS_STATIC").is_ok()
|
||||
{
|
||||
println!("cargo:info=Static feature or environment variable found.");
|
||||
|
||||
true
|
||||
} else if cfg!(feature = "dynamic") {
|
||||
println!("cargo:info=Dynamic feature enabled.");
|
||||
|
||||
false
|
||||
} else {
|
||||
println!("cargo:info=No feature or environment variable found, linking by default.");
|
||||
|
||||
default_library_linking()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(feature = "generate_binding")]
|
||||
generate_binding();
|
||||
|
||||
let is_static = is_static_build();
|
||||
|
||||
#[cfg(any(unix, target_env = "gnu"))]
|
||||
{
|
||||
if std::env::var("LIBOPUS_NO_PKG").is_ok() || std::env::var("OPUS_NO_PKG").is_ok() {
|
||||
println!("cargo:info=Bypassed `pkg-config`.");
|
||||
} else if find_via_pkg_config(is_static) {
|
||||
println!("cargo:info=Found `Opus` via `pkg_config`.");
|
||||
|
||||
return;
|
||||
} else {
|
||||
println!("cargo:info=`pkg_config` could not find `Opus`.");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(installed_opus) = find_installed_opus() {
|
||||
link_opus(is_static, installed_opus);
|
||||
} else {
|
||||
build_opus(is_static);
|
||||
}
|
||||
}
|
||||
37
vendor/audiopus_sys/opus/.appveyor.yml
vendored
37
vendor/audiopus_sys/opus/.appveyor.yml
vendored
@@ -1,37 +0,0 @@
|
||||
image: Visual Studio 2015
|
||||
configuration:
|
||||
- Debug
|
||||
- DebugDLL
|
||||
- DebugDLL_fixed
|
||||
- Release
|
||||
- ReleaseDLL
|
||||
- ReleaseDLL_fixed
|
||||
|
||||
platform:
|
||||
- Win32
|
||||
- x64
|
||||
|
||||
environment:
|
||||
api_key:
|
||||
secure: kR3Ac0NjGwFnTmXdFrR8d6VXjdk5F7L4F/BilC4nvaM=
|
||||
|
||||
build:
|
||||
project: win32\VS2015\opus.sln
|
||||
parallel: true
|
||||
verbosity: minimal
|
||||
|
||||
after_build:
|
||||
- cd %APPVEYOR_BUILD_FOLDER%
|
||||
- 7z a opus.zip win32\VS2015\%PLATFORM%\%CONFIGURATION%\opus.??? include\*.h
|
||||
|
||||
test_script:
|
||||
- cd %APPVEYOR_BUILD_FOLDER%\win32\VS2015\%PLATFORM%\%CONFIGURATION%
|
||||
- test_opus_api.exe
|
||||
- test_opus_decode.exe
|
||||
- test_opus_encode.exe
|
||||
|
||||
artifacts:
|
||||
- path: opus.zip
|
||||
|
||||
on_success:
|
||||
- ps: if ($env:api_key -and "$env:configuration/$env:platform" -eq "ReleaseDLL_fixed/x64") { Start-AppveyorBuild -ApiKey $env:api_key -ProjectSlug 'opus-tools' }
|
||||
10
vendor/audiopus_sys/opus/.gitattributes
vendored
10
vendor/audiopus_sys/opus/.gitattributes
vendored
@@ -1,10 +0,0 @@
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
|
||||
update_version export-ignore
|
||||
|
||||
*.bat eol=crlf
|
||||
*.sln eol=crlf
|
||||
*.vcxproj eol=crlf
|
||||
*.vcxproj.filters eol=crlf
|
||||
common.props eol=crlf
|
||||
90
vendor/audiopus_sys/opus/.gitignore
vendored
90
vendor/audiopus_sys/opus/.gitignore
vendored
@@ -1,90 +0,0 @@
|
||||
Doxyfile
|
||||
Makefile
|
||||
Makefile.in
|
||||
TAGS
|
||||
aclocal.m4
|
||||
autom4te.cache
|
||||
*.kdevelop.pcs
|
||||
*.kdevses
|
||||
compile
|
||||
config.guess
|
||||
config.h
|
||||
config.h.in
|
||||
config.log
|
||||
config.status
|
||||
config.sub
|
||||
configure
|
||||
depcomp
|
||||
INSTALL
|
||||
install-sh
|
||||
.deps
|
||||
.libs
|
||||
.dirstamp
|
||||
*.a
|
||||
*.exe
|
||||
*.la
|
||||
*-gnu.S
|
||||
testcelt
|
||||
libtool
|
||||
ltmain.sh
|
||||
missing
|
||||
m4/libtool.m4
|
||||
m4/ltoptions.m4
|
||||
m4/ltsugar.m4
|
||||
m4/ltversion.m4
|
||||
m4/lt~obsolete.m4
|
||||
opus_compare
|
||||
opus_demo
|
||||
repacketizer_demo
|
||||
stamp-h1
|
||||
test-driver
|
||||
trivial_example
|
||||
*.sw*
|
||||
*.o
|
||||
*.lo
|
||||
*.pc
|
||||
*.tar.gz
|
||||
*~
|
||||
tests/*test
|
||||
tests/test_opus_api
|
||||
tests/test_opus_decode
|
||||
tests/test_opus_encode
|
||||
tests/test_opus_padding
|
||||
tests/test_opus_projection
|
||||
celt/arm/armopts.s
|
||||
celt/dump_modes/dump_modes
|
||||
celt/tests/test_unit_cwrs32
|
||||
celt/tests/test_unit_dft
|
||||
celt/tests/test_unit_entropy
|
||||
celt/tests/test_unit_laplace
|
||||
celt/tests/test_unit_mathops
|
||||
celt/tests/test_unit_mdct
|
||||
celt/tests/test_unit_rotation
|
||||
celt/tests/test_unit_types
|
||||
doc/doxygen_sqlite3.db
|
||||
doc/doxygen-build.stamp
|
||||
doc/html
|
||||
doc/latex
|
||||
doc/man
|
||||
package_version
|
||||
version.h
|
||||
celt/Debug
|
||||
celt/Release
|
||||
celt/x64
|
||||
silk/Debug
|
||||
silk/Release
|
||||
silk/x64
|
||||
silk/fixed/Debug
|
||||
silk/fixed/Release
|
||||
silk/fixed/x64
|
||||
silk/float/Debug
|
||||
silk/float/Release
|
||||
silk/float/x64
|
||||
silk/tests/test_unit_LPC_inv_pred_gain
|
||||
src/Debug
|
||||
src/Release
|
||||
src/x64
|
||||
/*[Bb]uild*/
|
||||
.vs/
|
||||
.vscode/
|
||||
CMakeSettings.json
|
||||
61
vendor/audiopus_sys/opus/.gitlab-ci.yml
vendored
61
vendor/audiopus_sys/opus/.gitlab-ci.yml
vendored
@@ -1,61 +0,0 @@
|
||||
include:
|
||||
- template: 'Workflows/Branch-Pipelines.gitlab-ci.yml'
|
||||
|
||||
default:
|
||||
tags:
|
||||
- docker
|
||||
# Image from https://hub.docker.com/_/gcc/ based on Debian
|
||||
image: gcc:9
|
||||
|
||||
whitespace:
|
||||
stage: test
|
||||
script:
|
||||
- git diff-tree --check origin/master HEAD
|
||||
|
||||
autoconf:
|
||||
stage: build
|
||||
before_script:
|
||||
- apt-get update &&
|
||||
apt-get install -y zip doxygen
|
||||
script:
|
||||
- ./autogen.sh
|
||||
- ./configure
|
||||
- make -j4
|
||||
- make distcheck
|
||||
cache:
|
||||
paths:
|
||||
- "src/*.o"
|
||||
- "src/.libs/*.o"
|
||||
- "silk/*.o"
|
||||
- "silk/.libs/*.o"
|
||||
- "celt/*.o"
|
||||
- "celt/.libs/*.o"
|
||||
|
||||
cmake:
|
||||
stage: build
|
||||
before_script:
|
||||
- apt-get update &&
|
||||
apt-get install -y cmake ninja-build
|
||||
script:
|
||||
- mkdir build
|
||||
- cmake -S . -B build -G "Ninja" -DCMAKE_BUILD_TYPE=Release -DOPUS_BUILD_TESTING=ON -DOPUS_BUILD_PROGRAMS=ON
|
||||
- cmake --build build
|
||||
- cd build && ctest --output-on-failure
|
||||
|
||||
meson:
|
||||
stage: build
|
||||
before_script:
|
||||
- apt-get update &&
|
||||
apt-get install -y python3-pip ninja-build doxygen
|
||||
- export XDG_CACHE_HOME=$PWD/pip-cache
|
||||
- pip3 install --user meson
|
||||
script:
|
||||
- export PATH=$PATH:$HOME/.local/bin
|
||||
- mkdir builddir
|
||||
- meson setup --werror -Dtests=enabled -Ddocs=enabled -Dbuildtype=release builddir
|
||||
- meson compile -C builddir
|
||||
- meson test -C builddir
|
||||
#- meson dist --no-tests -C builddir
|
||||
cache:
|
||||
paths:
|
||||
- 'pip-cache/*'
|
||||
21
vendor/audiopus_sys/opus/.travis.yml
vendored
21
vendor/audiopus_sys/opus/.travis.yml
vendored
@@ -1,21 +0,0 @@
|
||||
language: c
|
||||
|
||||
compiler:
|
||||
- gcc
|
||||
- clang
|
||||
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
|
||||
env:
|
||||
- CONFIG=""
|
||||
- CONFIG="--enable-assertions"
|
||||
- CONFIG="--enable-fixed-point"
|
||||
- CONFIG="--enable-fixed-point --disable-float-api"
|
||||
- CONFIG="--enable-fixed-point --enable-assertions"
|
||||
|
||||
script:
|
||||
- ./autogen.sh
|
||||
- ./configure $CONFIG
|
||||
- make distcheck
|
||||
6
vendor/audiopus_sys/opus/AUTHORS
vendored
6
vendor/audiopus_sys/opus/AUTHORS
vendored
@@ -1,6 +0,0 @@
|
||||
Jean-Marc Valin (jmvalin@jmvalin.ca)
|
||||
Koen Vos (koenvos74@gmail.com)
|
||||
Timothy Terriberry (tterribe@xiph.org)
|
||||
Karsten Vandborg Sorensen (karsten.vandborg.sorensen@skype.net)
|
||||
Soren Skak Jensen (ssjensen@gn.com)
|
||||
Gregory Maxwell (greg@xiph.org)
|
||||
643
vendor/audiopus_sys/opus/CMakeLists.txt
vendored
643
vendor/audiopus_sys/opus/CMakeLists.txt
vendored
@@ -1,643 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.1)
|
||||
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
|
||||
|
||||
include(OpusPackageVersion)
|
||||
get_package_version(PACKAGE_VERSION PROJECT_VERSION)
|
||||
|
||||
project(Opus LANGUAGES C VERSION ${PROJECT_VERSION})
|
||||
|
||||
include(OpusFunctions)
|
||||
include(OpusBuildtype)
|
||||
include(OpusConfig)
|
||||
include(OpusSources)
|
||||
include(GNUInstallDirs)
|
||||
include(CMakeDependentOption)
|
||||
include(FeatureSummary)
|
||||
|
||||
set(OPUS_BUILD_SHARED_LIBRARY_HELP_STR "build shared library.")
|
||||
option(OPUS_BUILD_SHARED_LIBRARY ${OPUS_BUILD_SHARED_LIBRARY_HELP_STR} OFF)
|
||||
if(OPUS_BUILD_SHARED_LIBRARY OR BUILD_SHARED_LIBS OR OPUS_BUILD_FRAMEWORK)
|
||||
# Global flag to cause add_library() to create shared libraries if on.
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
set(OPUS_BUILD_SHARED_LIBRARY ON)
|
||||
endif()
|
||||
add_feature_info(OPUS_BUILD_SHARED_LIBRARY OPUS_BUILD_SHARED_LIBRARY ${OPUS_BUILD_SHARED_LIBRARY_HELP_STR})
|
||||
|
||||
set(OPUS_BUILD_TESTING_HELP_STR "build tests.")
|
||||
option(OPUS_BUILD_TESTING ${OPUS_BUILD_TESTING_HELP_STR} OFF)
|
||||
if(OPUS_BUILD_TESTING OR BUILD_TESTING)
|
||||
set(OPUS_BUILD_TESTING ON)
|
||||
set(BUILD_TESTING ON)
|
||||
endif()
|
||||
add_feature_info(OPUS_BUILD_TESTING OPUS_BUILD_TESTING ${OPUS_BUILD_TESTING_HELP_STR})
|
||||
|
||||
set(OPUS_CUSTOM_MODES_HELP_STR "enable non-Opus modes, e.g. 44.1 kHz & 2^n frames.")
|
||||
option(OPUS_CUSTOM_MODES ${OPUS_CUSTOM_MODES_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_CUSTOM_MODES OPUS_CUSTOM_MODES ${OPUS_CUSTOM_MODES_HELP_STR})
|
||||
|
||||
set(OPUS_BUILD_PROGRAMS_HELP_STR "build programs.")
|
||||
option(OPUS_BUILD_PROGRAMS ${OPUS_BUILD_PROGRAMS_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_BUILD_PROGRAMS OPUS_BUILD_PROGRAMS ${OPUS_BUILD_PROGRAMS_HELP_STR})
|
||||
|
||||
set(OPUS_DISABLE_INTRINSICS_HELP_STR "disable all intrinsics optimizations.")
|
||||
option(OPUS_DISABLE_INTRINSICS ${OPUS_DISABLE_INTRINSICS_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_DISABLE_INTRINSICS OPUS_DISABLE_INTRINSICS ${OPUS_DISABLE_INTRINSICS_HELP_STR})
|
||||
|
||||
set(OPUS_FIXED_POINT_HELP_STR "compile as fixed-point (for machines without a fast enough FPU).")
|
||||
option(OPUS_FIXED_POINT ${OPUS_FIXED_POINT_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_FIXED_POINT OPUS_FIXED_POINT ${OPUS_FIXED_POINT_HELP_STR})
|
||||
|
||||
set(OPUS_ENABLE_FLOAT_API_HELP_STR "compile with the floating point API (for machines with float library).")
|
||||
option(OPUS_ENABLE_FLOAT_API ${OPUS_ENABLE_FLOAT_API_HELP_STR} ON)
|
||||
add_feature_info(OPUS_ENABLE_FLOAT_API OPUS_ENABLE_FLOAT_API ${OPUS_ENABLE_FLOAT_API_HELP_STR})
|
||||
|
||||
set(OPUS_FLOAT_APPROX_HELP_STR "enable floating point approximations (Ensure your platform supports IEEE 754 before enabling).")
|
||||
option(OPUS_FLOAT_APPROX ${OPUS_FLOAT_APPROX_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_FLOAT_APPROX OPUS_FLOAT_APPROX ${OPUS_FLOAT_APPROX_HELP_STR})
|
||||
|
||||
set(OPUS_ASSERTIONS_HELP_STR "additional software error checking.")
|
||||
option(OPUS_ASSERTIONS ${OPUS_ASSERTIONS_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_ASSERTIONS OPUS_ASSERTIONS ${OPUS_ASSERTIONS_HELP_STR})
|
||||
|
||||
set(OPUS_HARDENING_HELP_STR "run-time checks that are cheap and safe for use in production.")
|
||||
option(OPUS_HARDENING ${OPUS_HARDENING_HELP_STR} ON)
|
||||
add_feature_info(OPUS_HARDENING OPUS_HARDENING ${OPUS_HARDENING_HELP_STR})
|
||||
|
||||
set(OPUS_FUZZING_HELP_STR "causes the encoder to make random decisions (do not use in production).")
|
||||
option(OPUS_FUZZING ${OPUS_FUZZING_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_FUZZING OPUS_FUZZING ${OPUS_FUZZING_HELP_STR})
|
||||
|
||||
set(OPUS_CHECK_ASM_HELP_STR "enable bit-exactness checks between optimized and c implementations.")
|
||||
option(OPUS_CHECK_ASM ${OPUS_CHECK_ASM_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_CHECK_ASM OPUS_CHECK_ASM ${OPUS_CHECK_ASM_HELP_STR})
|
||||
|
||||
set(OPUS_INSTALL_PKG_CONFIG_MODULE_HELP_STR "install pkg-config module.")
|
||||
option(OPUS_INSTALL_PKG_CONFIG_MODULE ${OPUS_INSTALL_PKG_CONFIG_MODULE_HELP_STR} ON)
|
||||
add_feature_info(OPUS_INSTALL_PKG_CONFIG_MODULE OPUS_INSTALL_PKG_CONFIG_MODULE ${OPUS_INSTALL_PKG_CONFIG_MODULE_HELP_STR})
|
||||
|
||||
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE_HELP_STR "install CMake package config module.")
|
||||
option(OPUS_INSTALL_CMAKE_CONFIG_MODULE ${OPUS_INSTALL_CMAKE_CONFIG_MODULE_HELP_STR} ON)
|
||||
add_feature_info(OPUS_INSTALL_CMAKE_CONFIG_MODULE OPUS_INSTALL_CMAKE_CONFIG_MODULE ${OPUS_INSTALL_CMAKE_CONFIG_MODULE_HELP_STR})
|
||||
|
||||
if(APPLE)
|
||||
set(OPUS_BUILD_FRAMEWORK_HELP_STR "build Framework bundle for Apple systems.")
|
||||
option(OPUS_BUILD_FRAMEWORK ${OPUS_BUILD_FRAMEWORK_HELP_STR} OFF)
|
||||
add_feature_info(OPUS_BUILD_FRAMEWORK OPUS_BUILD_FRAMEWORK ${OPUS_BUILD_FRAMEWORK_HELP_STR})
|
||||
endif()
|
||||
|
||||
set(OPUS_FIXED_POINT_DEBUG_HELP_STR "debug fixed-point implementation.")
|
||||
cmake_dependent_option(OPUS_FIXED_POINT_DEBUG
|
||||
${OPUS_FIXED_POINT_DEBUG_HELP_STR}
|
||||
ON
|
||||
"OPUS_FIXED_POINT; OPUS_FIXED_POINT_DEBUG"
|
||||
OFF)
|
||||
add_feature_info(OPUS_FIXED_POINT_DEBUG OPUS_FIXED_POINT_DEBUG ${OPUS_FIXED_POINT_DEBUG_HELP_STR})
|
||||
|
||||
set(OPUS_VAR_ARRAYS_HELP_STR "use variable length arrays for stack arrays.")
|
||||
cmake_dependent_option(OPUS_VAR_ARRAYS
|
||||
${OPUS_VAR_ARRAYS_HELP_STR}
|
||||
ON
|
||||
"VLA_SUPPORTED; NOT OPUS_USE_ALLOCA; NOT OPUS_NONTHREADSAFE_PSEUDOSTACK"
|
||||
OFF)
|
||||
add_feature_info(OPUS_VAR_ARRAYS OPUS_VAR_ARRAYS ${OPUS_VAR_ARRAYS_HELP_STR})
|
||||
|
||||
set(OPUS_USE_ALLOCA_HELP_STR "use alloca for stack arrays (on non-C99 compilers).")
|
||||
cmake_dependent_option(OPUS_USE_ALLOCA
|
||||
${OPUS_USE_ALLOCA_HELP_STR}
|
||||
ON
|
||||
"USE_ALLOCA_SUPPORTED; NOT OPUS_VAR_ARRAYS; NOT OPUS_NONTHREADSAFE_PSEUDOSTACK"
|
||||
OFF)
|
||||
add_feature_info(OPUS_USE_ALLOCA OPUS_USE_ALLOCA ${OPUS_USE_ALLOCA_HELP_STR})
|
||||
|
||||
set(OPUS_NONTHREADSAFE_PSEUDOSTACK_HELP_STR "use a non threadsafe pseudostack when neither variable length arrays or alloca is supported.")
|
||||
cmake_dependent_option(OPUS_NONTHREADSAFE_PSEUDOSTACK
|
||||
${OPUS_NONTHREADSAFE_PSEUDOSTACK_HELP_STR}
|
||||
ON
|
||||
"NOT OPUS_VAR_ARRAYS; NOT OPUS_USE_ALLOCA"
|
||||
OFF)
|
||||
add_feature_info(OPUS_NONTHREADSAFE_PSEUDOSTACK OPUS_NONTHREADSAFE_PSEUDOSTACK ${OPUS_NONTHREADSAFE_PSEUDOSTACK_HELP_STR})
|
||||
|
||||
set(OPUS_FAST_MATH_HELP_STR "enable fast math (unsupported and discouraged use, as code is not well tested with this build option).")
|
||||
cmake_dependent_option(OPUS_FAST_MATH
|
||||
${OPUS_FAST_MATH_HELP_STR}
|
||||
ON
|
||||
"OPUS_FLOAT_APPROX; OPUS_FAST_MATH; FAST_MATH_SUPPORTED"
|
||||
OFF)
|
||||
add_feature_info(OPUS_FAST_MATH OPUS_FAST_MATH ${OPUS_FAST_MATH_HELP_STR})
|
||||
|
||||
set(OPUS_STACK_PROTECTOR_HELP_STR "use stack protection.")
|
||||
cmake_dependent_option(OPUS_STACK_PROTECTOR
|
||||
${OPUS_STACK_PROTECTOR_HELP_STR}
|
||||
ON
|
||||
"STACK_PROTECTOR_SUPPORTED"
|
||||
OFF)
|
||||
add_feature_info(OPUS_STACK_PROTECTOR OPUS_STACK_PROTECTOR ${OPUS_STACK_PROTECTOR_HELP_STR})
|
||||
|
||||
if(NOT MSVC)
|
||||
set(OPUS_FORTIFY_SOURCE_HELP_STR "add protection against buffer overflows.")
|
||||
cmake_dependent_option(OPUS_FORTIFY_SOURCE
|
||||
${OPUS_FORTIFY_SOURCE_HELP_STR}
|
||||
ON
|
||||
"FORTIFY_SOURCE_SUPPORTED"
|
||||
OFF)
|
||||
add_feature_info(OPUS_FORTIFY_SOURCE OPUS_FORTIFY_SOURCE ${OPUS_FORTIFY_SOURCE_HELP_STR})
|
||||
endif()
|
||||
|
||||
if(MINGW AND (OPUS_FORTIFY_SOURCE OR OPUS_STACK_PROTECTOR))
|
||||
# ssp lib is needed for security features for MINGW
|
||||
list(APPEND OPUS_REQUIRED_LIBRARIES ssp)
|
||||
endif()
|
||||
|
||||
if(OPUS_CPU_X86 OR OPUS_CPU_X64)
|
||||
set(OPUS_X86_MAY_HAVE_SSE_HELP_STR "does runtime check for SSE1 support.")
|
||||
cmake_dependent_option(OPUS_X86_MAY_HAVE_SSE
|
||||
${OPUS_X86_MAY_HAVE_SSE_HELP_STR}
|
||||
ON
|
||||
"SSE1_SUPPORTED; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_MAY_HAVE_SSE OPUS_X86_MAY_HAVE_SSE ${OPUS_X86_MAY_HAVE_SSE_HELP_STR})
|
||||
|
||||
set(OPUS_X86_MAY_HAVE_SSE2_HELP_STR "does runtime check for SSE2 support.")
|
||||
cmake_dependent_option(OPUS_X86_MAY_HAVE_SSE2
|
||||
${OPUS_X86_MAY_HAVE_SSE2_HELP_STR}
|
||||
ON
|
||||
"SSE2_SUPPORTED; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_MAY_HAVE_SSE2 OPUS_X86_MAY_HAVE_SSE2 ${OPUS_X86_MAY_HAVE_SSE2_HELP_STR})
|
||||
|
||||
set(OPUS_X86_MAY_HAVE_SSE4_1_HELP_STR "does runtime check for SSE4.1 support.")
|
||||
cmake_dependent_option(OPUS_X86_MAY_HAVE_SSE4_1
|
||||
${OPUS_X86_MAY_HAVE_SSE4_1_HELP_STR}
|
||||
ON
|
||||
"SSE4_1_SUPPORTED; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_MAY_HAVE_SSE4_1 OPUS_X86_MAY_HAVE_SSE4_1 ${OPUS_X86_MAY_HAVE_SSE4_1_HELP_STR})
|
||||
|
||||
set(OPUS_X86_MAY_HAVE_AVX_HELP_STR "does runtime check for AVX support.")
|
||||
cmake_dependent_option(OPUS_X86_MAY_HAVE_AVX
|
||||
${OPUS_X86_MAY_HAVE_AVX_HELP_STR}
|
||||
ON
|
||||
"AVX_SUPPORTED; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_MAY_HAVE_AVX OPUS_X86_MAY_HAVE_AVX ${OPUS_X86_MAY_HAVE_AVX_HELP_STR})
|
||||
|
||||
# PRESUME depends on MAY HAVE, but PRESUME will override runtime detection
|
||||
set(OPUS_X86_PRESUME_SSE_HELP_STR "assume target CPU has SSE1 support (override runtime check).")
|
||||
set(OPUS_X86_PRESUME_SSE2_HELP_STR "assume target CPU has SSE2 support (override runtime check).")
|
||||
if(OPUS_CPU_X64) # Assume x86_64 has up to SSE2 support
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_SSE
|
||||
${OPUS_X86_PRESUME_SSE_HELP_STR}
|
||||
ON
|
||||
"OPUS_X86_MAY_HAVE_SSE; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_SSE2
|
||||
${OPUS_X86_PRESUME_SSE2_HELP_STR}
|
||||
ON
|
||||
"OPUS_X86_MAY_HAVE_SSE2; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
else()
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_SSE
|
||||
${OPUS_X86_PRESUME_SSE_HELP_STR}
|
||||
OFF
|
||||
"OPUS_X86_MAY_HAVE_SSE; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_SSE2
|
||||
${OPUS_X86_PRESUME_SSE2_HELP_STR}
|
||||
OFF
|
||||
"OPUS_X86_MAY_HAVE_SSE2; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
endif()
|
||||
add_feature_info(OPUS_X86_PRESUME_SSE OPUS_X86_PRESUME_SSE ${OPUS_X86_PRESUME_SSE_HELP_STR})
|
||||
add_feature_info(OPUS_X86_PRESUME_SSE2 OPUS_X86_PRESUME_SSE2 ${OPUS_X86_PRESUME_SSE2_HELP_STR})
|
||||
|
||||
set(OPUS_X86_PRESUME_SSE4_1_HELP_STR "assume target CPU has SSE4.1 support (override runtime check).")
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_SSE4_1
|
||||
${OPUS_X86_PRESUME_SSE4_1_HELP_STR}
|
||||
OFF
|
||||
"OPUS_X86_MAY_HAVE_SSE4_1; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_PRESUME_SSE4_1 OPUS_X86_PRESUME_SSE4_1 ${OPUS_X86_PRESUME_SSE4_1_HELP_STR})
|
||||
|
||||
set(OPUS_X86_PRESUME_AVX_HELP_STR "assume target CPU has AVX support (override runtime check).")
|
||||
cmake_dependent_option(OPUS_X86_PRESUME_AVX
|
||||
${OPUS_X86_PRESUME_AVX_HELP_STR}
|
||||
OFF
|
||||
"OPUS_X86_MAY_HAVE_AVX; NOT OPUS_DISABLE_INTRINSICS"
|
||||
OFF)
|
||||
add_feature_info(OPUS_X86_PRESUME_AVX OPUS_X86_PRESUME_AVX ${OPUS_X86_PRESUME_AVX_HELP_STR})
|
||||
endif()
|
||||
|
||||
feature_summary(WHAT ALL)
|
||||
|
||||
set_package_properties(Git
|
||||
PROPERTIES
|
||||
TYPE
|
||||
REQUIRED
|
||||
DESCRIPTION
|
||||
"fast, scalable, distributed revision control system"
|
||||
URL
|
||||
"https://git-scm.com/"
|
||||
PURPOSE
|
||||
"required to set up package version")
|
||||
|
||||
set(Opus_PUBLIC_HEADER
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/opus.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/opus_defines.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/opus_multistream.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/opus_projection.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/opus_types.h)
|
||||
|
||||
if(OPUS_CUSTOM_MODES)
|
||||
list(APPEND Opus_PUBLIC_HEADER ${CMAKE_CURRENT_SOURCE_DIR}/include/opus_custom.h)
|
||||
endif()
|
||||
|
||||
add_library(opus ${opus_headers} ${opus_sources} ${opus_sources_float} ${Opus_PUBLIC_HEADER})
|
||||
add_library(Opus::opus ALIAS opus)
|
||||
|
||||
get_library_version(OPUS_LIBRARY_VERSION OPUS_LIBRARY_VERSION_MAJOR)
|
||||
message(DEBUG "Opus library version: ${OPUS_LIBRARY_VERSION}")
|
||||
|
||||
set_target_properties(opus
|
||||
PROPERTIES SOVERSION
|
||||
${OPUS_LIBRARY_VERSION_MAJOR}
|
||||
VERSION
|
||||
${OPUS_LIBRARY_VERSION}
|
||||
PUBLIC_HEADER
|
||||
"${Opus_PUBLIC_HEADER}")
|
||||
|
||||
target_include_directories(
|
||||
opus
|
||||
PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/opus>
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
celt
|
||||
silk)
|
||||
|
||||
target_link_libraries(opus PRIVATE ${OPUS_REQUIRED_LIBRARIES})
|
||||
target_compile_definitions(opus PRIVATE OPUS_BUILD)
|
||||
|
||||
if(OPUS_FIXED_POINT_DEBUG)
|
||||
target_compile_definitions(opus PRIVATE FIXED_DEBUG)
|
||||
endif()
|
||||
|
||||
if(OPUS_FORTIFY_SOURCE AND NOT MSVC)
|
||||
target_compile_definitions(opus PRIVATE
|
||||
$<$<NOT:$<CONFIG:debug>>:_FORTIFY_SOURCE=2>)
|
||||
endif()
|
||||
|
||||
if(OPUS_FLOAT_APPROX)
|
||||
target_compile_definitions(opus PRIVATE FLOAT_APPROX)
|
||||
endif()
|
||||
|
||||
if(OPUS_ASSERTIONS)
|
||||
target_compile_definitions(opus PRIVATE ENABLE_ASSERTIONS)
|
||||
endif()
|
||||
|
||||
if(OPUS_HARDENING)
|
||||
target_compile_definitions(opus PRIVATE ENABLE_HARDENING)
|
||||
endif()
|
||||
|
||||
if(OPUS_FUZZING)
|
||||
target_compile_definitions(opus PRIVATE FUZZING)
|
||||
endif()
|
||||
|
||||
if(OPUS_CHECK_ASM)
|
||||
target_compile_definitions(opus PRIVATE OPUS_CHECK_ASM)
|
||||
endif()
|
||||
|
||||
if(OPUS_VAR_ARRAYS)
|
||||
target_compile_definitions(opus PRIVATE VAR_ARRAYS)
|
||||
elseif(OPUS_USE_ALLOCA)
|
||||
target_compile_definitions(opus PRIVATE USE_ALLOCA)
|
||||
elseif(OPUS_NONTHREADSAFE_PSEUDOSTACK)
|
||||
target_compile_definitions(opus PRIVATE NONTHREADSAFE_PSEUDOSTACK)
|
||||
else()
|
||||
message(ERROR "Need to set a define for stack allocation")
|
||||
endif()
|
||||
|
||||
if(OPUS_CUSTOM_MODES)
|
||||
target_compile_definitions(opus PRIVATE CUSTOM_MODES)
|
||||
endif()
|
||||
|
||||
if(OPUS_FAST_MATH)
|
||||
if(MSVC)
|
||||
target_compile_options(opus PRIVATE /fp:fast)
|
||||
else()
|
||||
target_compile_options(opus PRIVATE -ffast-math)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(OPUS_STACK_PROTECTOR)
|
||||
if(MSVC)
|
||||
target_compile_options(opus PRIVATE /GS)
|
||||
else()
|
||||
target_compile_options(opus PRIVATE -fstack-protector-strong)
|
||||
endif()
|
||||
elseif(STACK_PROTECTOR_DISABLED_SUPPORTED)
|
||||
target_compile_options(opus PRIVATE /GS-)
|
||||
endif()
|
||||
|
||||
if(BUILD_SHARED_LIBS)
|
||||
if(WIN32)
|
||||
target_compile_definitions(opus PRIVATE DLL_EXPORT)
|
||||
elseif(HIDDEN_VISIBILITY_SUPPORTED)
|
||||
set_target_properties(opus PROPERTIES C_VISIBILITY_PRESET hidden)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_sources_group(opus silk ${silk_headers} ${silk_sources})
|
||||
add_sources_group(opus celt ${celt_headers} ${celt_sources})
|
||||
|
||||
if(OPUS_FIXED_POINT)
|
||||
add_sources_group(opus silk ${silk_sources_fixed})
|
||||
target_include_directories(opus PRIVATE silk/fixed)
|
||||
target_compile_definitions(opus PRIVATE FIXED_POINT=1)
|
||||
else()
|
||||
add_sources_group(opus silk ${silk_sources_float})
|
||||
target_include_directories(opus PRIVATE silk/float)
|
||||
endif()
|
||||
|
||||
if(NOT OPUS_ENABLE_FLOAT_API)
|
||||
target_compile_definitions(opus PRIVATE DISABLE_FLOAT_API)
|
||||
endif()
|
||||
|
||||
# WZP: distinguish real cl.exe from clang-cl. libopus uses `if(NOT MSVC)`
|
||||
# to guard per-file `-msse4.1` / `-mssse3` / `-msse2` flags that GCC and
|
||||
# clang (GNU driver) accept. Under clang-cl (Clang running in MSVC driver
|
||||
# mode, as used by cargo-xwin cross-compiles), CMake sets MSVC=1 via
|
||||
# Platform/Windows-MSVC.cmake, so those guards become false and the
|
||||
# SIMD source files end up compiled WITHOUT the required target feature
|
||||
# — which then explodes in silk/x86/NSQ_sse4_1.c with
|
||||
# "always_inline function '_mm_cvtepi16_epi32' requires target feature
|
||||
# 'sse4.1'" errors. clang-cl, unlike real cl.exe, still honors Clang's
|
||||
# target-feature system, and accepts `-msse4.1` (LLVM 14+) to enable it.
|
||||
#
|
||||
# Split real cl.exe (which genuinely doesn't need per-feature gating
|
||||
# because its SIMD intrinsic headers are unconditionally available) from
|
||||
# clang-cl (which does need gating) using CMAKE_C_COMPILER_ID. Then the
|
||||
# `if(NOT MSVC)` guards below become `if(NOT MSVC_CL)` so clang-cl gets
|
||||
# the GCC-style per-file flags, and the `if(MSVC)` global /arch block at
|
||||
# the bottom becomes `if(MSVC_CL)` so only real cl.exe applies `/arch:AVX`
|
||||
# / `/arch:SSE2` globally (clang-cl relies on per-file `-msse` instead).
|
||||
#
|
||||
# Upstream tracking: xiph/opus#256, xiph/opus PR #257 (stale).
|
||||
set(MSVC_CL OFF)
|
||||
if(MSVC AND CMAKE_C_COMPILER_ID STREQUAL "MSVC")
|
||||
set(MSVC_CL ON)
|
||||
endif()
|
||||
|
||||
if(NOT OPUS_DISABLE_INTRINSICS)
|
||||
if((OPUS_X86_MAY_HAVE_SSE AND NOT OPUS_X86_PRESUME_SSE) OR
|
||||
(OPUS_X86_MAY_HAVE_SSE2 AND NOT OPUS_X86_PRESUME_SSE2) OR
|
||||
(OPUS_X86_MAY_HAVE_SSE4_1 AND NOT OPUS_X86_PRESUME_SSE4_1) OR
|
||||
(OPUS_X86_MAY_HAVE_AVX AND NOT OPUS_X86_PRESUME_AVX))
|
||||
target_compile_definitions(opus PRIVATE OPUS_HAVE_RTCD)
|
||||
endif()
|
||||
|
||||
if(SSE1_SUPPORTED)
|
||||
if(OPUS_X86_MAY_HAVE_SSE)
|
||||
add_sources_group(opus celt ${celt_sources_sse})
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_MAY_HAVE_SSE)
|
||||
if(NOT MSVC_CL)
|
||||
set_source_files_properties(${celt_sources_sse} PROPERTIES COMPILE_FLAGS -msse)
|
||||
endif()
|
||||
endif()
|
||||
if(OPUS_X86_PRESUME_SSE)
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_PRESUME_SSE)
|
||||
if(NOT MSVC_CL)
|
||||
target_compile_options(opus PRIVATE -msse)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(SSE2_SUPPORTED)
|
||||
if(OPUS_X86_MAY_HAVE_SSE2)
|
||||
add_sources_group(opus celt ${celt_sources_sse2})
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_MAY_HAVE_SSE2)
|
||||
if(NOT MSVC_CL)
|
||||
set_source_files_properties(${celt_sources_sse2} PROPERTIES COMPILE_FLAGS -msse2)
|
||||
endif()
|
||||
endif()
|
||||
if(OPUS_X86_PRESUME_SSE2)
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_PRESUME_SSE2)
|
||||
if(NOT MSVC_CL)
|
||||
target_compile_options(opus PRIVATE -msse2)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(SSE4_1_SUPPORTED)
|
||||
if(OPUS_X86_MAY_HAVE_SSE4_1)
|
||||
add_sources_group(opus celt ${celt_sources_sse4_1})
|
||||
add_sources_group(opus silk ${silk_sources_sse4_1})
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_MAY_HAVE_SSE4_1)
|
||||
if(NOT MSVC_CL)
|
||||
set_source_files_properties(${celt_sources_sse4_1} ${silk_sources_sse4_1} PROPERTIES COMPILE_FLAGS -msse4.1)
|
||||
endif()
|
||||
|
||||
if(OPUS_FIXED_POINT)
|
||||
add_sources_group(opus silk ${silk_sources_fixed_sse4_1})
|
||||
if(NOT MSVC_CL)
|
||||
set_source_files_properties(${silk_sources_fixed_sse4_1} PROPERTIES COMPILE_FLAGS -msse4.1)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(OPUS_X86_PRESUME_SSE4_1)
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_PRESUME_SSE4_1)
|
||||
if(NOT MSVC_CL)
|
||||
target_compile_options(opus PRIVATE -msse4.1)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(AVX_SUPPORTED)
|
||||
# mostly placeholder in case of avx intrinsics is added
|
||||
if(OPUS_X86_MAY_HAVE_AVX)
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_MAY_HAVE_AVX)
|
||||
endif()
|
||||
if(OPUS_X86_PRESUME_AVX)
|
||||
target_compile_definitions(opus PRIVATE OPUS_X86_PRESUME_AVX)
|
||||
if(NOT MSVC_CL)
|
||||
target_compile_options(opus PRIVATE -mavx)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(MSVC_CL)
|
||||
if(AVX_SUPPORTED AND OPUS_X86_PRESUME_AVX) # on 64 bit and 32 bits
|
||||
add_definitions(/arch:AVX)
|
||||
elseif(OPUS_CPU_X86) # if AVX not supported then set SSE flag
|
||||
if((SSE4_1_SUPPORTED AND OPUS_X86_PRESUME_SSE4_1)
|
||||
OR (SSE2_SUPPORTED AND OPUS_X86_PRESUME_SSE2))
|
||||
target_compile_definitions(opus PRIVATE /arch:SSE2)
|
||||
elseif(SSE1_SUPPORTED AND OPUS_X86_PRESUME_SSE)
|
||||
target_compile_definitions(opus PRIVATE /arch:SSE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(CMAKE_SYSTEM_PROCESSOR MATCHES "(arm|aarch64)")
|
||||
add_sources_group(opus celt ${celt_sources_arm})
|
||||
endif()
|
||||
|
||||
if(COMPILER_SUPPORT_NEON)
|
||||
if(OPUS_MAY_HAVE_NEON)
|
||||
if(RUNTIME_CPU_CAPABILITY_DETECTION)
|
||||
message(STATUS "OPUS_MAY_HAVE_NEON enabling runtime detection")
|
||||
target_compile_definitions(opus PRIVATE OPUS_HAVE_RTCD)
|
||||
else()
|
||||
message(ERROR "Runtime cpu capability detection needed for MAY_HAVE_NEON")
|
||||
endif()
|
||||
# Do runtime check for NEON
|
||||
target_compile_definitions(opus
|
||||
PRIVATE
|
||||
OPUS_ARM_MAY_HAVE_NEON
|
||||
OPUS_ARM_MAY_HAVE_NEON_INTR)
|
||||
endif()
|
||||
|
||||
add_sources_group(opus celt ${celt_sources_arm_neon_intr})
|
||||
add_sources_group(opus silk ${silk_sources_arm_neon_intr})
|
||||
|
||||
# silk arm neon depends on main_Fix.h
|
||||
target_include_directories(opus PRIVATE silk/fixed)
|
||||
|
||||
if(OPUS_FIXED_POINT)
|
||||
add_sources_group(opus silk ${silk_sources_fixed_arm_neon_intr})
|
||||
endif()
|
||||
|
||||
if(OPUS_PRESUME_NEON)
|
||||
target_compile_definitions(opus
|
||||
PRIVATE
|
||||
OPUS_ARM_PRESUME_NEON
|
||||
OPUS_ARM_PRESUME_NEON_INTR)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
target_compile_definitions(opus
|
||||
PRIVATE
|
||||
$<$<BOOL:${HAVE_LRINT}>:HAVE_LRINT>
|
||||
$<$<BOOL:${HAVE_LRINTF}>:HAVE_LRINTF>)
|
||||
|
||||
if(OPUS_BUILD_FRAMEWORK)
|
||||
set_target_properties(opus PROPERTIES
|
||||
FRAMEWORK TRUE
|
||||
FRAMEWORK_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_FRAMEWORK_IDENTIFIER org.xiph.opus
|
||||
MACOSX_FRAMEWORK_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||
MACOSX_FRAMEWORK_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
XCODE_ATTRIBUTE_INSTALL_PATH "@rpath"
|
||||
OUTPUT_NAME Opus)
|
||||
endif()
|
||||
|
||||
install(TARGETS opus
|
||||
EXPORT OpusTargets
|
||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
FRAMEWORK DESTINATION ${CMAKE_INSTALL_PREFIX}
|
||||
PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/opus)
|
||||
|
||||
if(OPUS_INSTALL_PKG_CONFIG_MODULE)
|
||||
set(prefix ${CMAKE_INSTALL_PREFIX})
|
||||
set(exec_prefix ${CMAKE_INSTALL_PREFIX})
|
||||
set(libdir ${CMAKE_INSTALL_FULL_LIBDIR})
|
||||
set(includedir ${CMAKE_INSTALL_FULL_INCLUDEDIR})
|
||||
set(VERSION ${PACKAGE_VERSION})
|
||||
if(HAVE_LIBM)
|
||||
set(LIBM "-lm")
|
||||
endif()
|
||||
configure_file(opus.pc.in opus.pc)
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/opus.pc
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
|
||||
endif()
|
||||
|
||||
if(OPUS_INSTALL_CMAKE_CONFIG_MODULE)
|
||||
set(CPACK_GENERATOR TGZ)
|
||||
include(CPack)
|
||||
set(CMAKE_INSTALL_PACKAGEDIR ${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME})
|
||||
install(EXPORT OpusTargets
|
||||
NAMESPACE Opus::
|
||||
DESTINATION ${CMAKE_INSTALL_PACKAGEDIR})
|
||||
|
||||
include(CMakePackageConfigHelpers)
|
||||
|
||||
set(INCLUDE_INSTALL_DIR ${CMAKE_INSTALL_INCLUDEDIR})
|
||||
configure_package_config_file(${PROJECT_SOURCE_DIR}/cmake/OpusConfig.cmake.in
|
||||
OpusConfig.cmake
|
||||
INSTALL_DESTINATION
|
||||
${CMAKE_INSTALL_PACKAGEDIR}
|
||||
PATH_VARS
|
||||
INCLUDE_INSTALL_DIR
|
||||
INSTALL_PREFIX
|
||||
${CMAKE_INSTALL_PREFIX})
|
||||
write_basic_package_version_file(OpusConfigVersion.cmake
|
||||
VERSION ${PROJECT_VERSION}
|
||||
COMPATIBILITY SameMajorVersion)
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/OpusConfig.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/OpusConfigVersion.cmake
|
||||
DESTINATION ${CMAKE_INSTALL_PACKAGEDIR})
|
||||
endif()
|
||||
|
||||
if(OPUS_BUILD_PROGRAMS)
|
||||
# demo
|
||||
if(OPUS_CUSTOM_MODES)
|
||||
add_executable(opus_custom_demo ${opus_custom_demo_sources})
|
||||
target_include_directories(opus_custom_demo
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
target_link_libraries(opus_custom_demo PRIVATE opus)
|
||||
endif()
|
||||
|
||||
add_executable(opus_demo ${opus_demo_sources})
|
||||
target_include_directories(opus_demo PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
target_include_directories(opus_demo PRIVATE silk) # debug.h
|
||||
target_include_directories(opus_demo PRIVATE celt) # arch.h
|
||||
target_link_libraries(opus_demo PRIVATE opus ${OPUS_REQUIRED_LIBRARIES})
|
||||
|
||||
# compare
|
||||
add_executable(opus_compare ${opus_compare_sources})
|
||||
target_include_directories(opus_compare PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
target_link_libraries(opus_compare PRIVATE opus ${OPUS_REQUIRED_LIBRARIES})
|
||||
endif()
|
||||
|
||||
if(BUILD_TESTING)
|
||||
enable_testing()
|
||||
|
||||
# tests
|
||||
add_executable(test_opus_decode ${test_opus_decode_sources})
|
||||
target_include_directories(test_opus_decode
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
target_link_libraries(test_opus_decode PRIVATE opus)
|
||||
if(OPUS_FIXED_POINT)
|
||||
target_compile_definitions(test_opus_decode PRIVATE DISABLE_FLOAT_API)
|
||||
endif()
|
||||
add_test(NAME test_opus_decode COMMAND $<TARGET_FILE:test_opus_decode> WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
|
||||
add_executable(test_opus_padding ${test_opus_padding_sources})
|
||||
target_include_directories(test_opus_padding
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||
target_link_libraries(test_opus_padding PRIVATE opus)
|
||||
add_test(NAME test_opus_padding COMMAND $<TARGET_FILE:test_opus_padding> WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
|
||||
if(NOT BUILD_SHARED_LIBS)
|
||||
# disable tests that depends on private API when building shared lib
|
||||
add_executable(test_opus_api ${test_opus_api_sources})
|
||||
target_include_directories(test_opus_api
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR} celt)
|
||||
target_link_libraries(test_opus_api PRIVATE opus)
|
||||
if(OPUS_FIXED_POINT)
|
||||
target_compile_definitions(test_opus_api PRIVATE DISABLE_FLOAT_API)
|
||||
endif()
|
||||
add_test(NAME test_opus_api COMMAND $<TARGET_FILE:test_opus_api> WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
|
||||
add_executable(test_opus_encode ${test_opus_encode_sources})
|
||||
target_include_directories(test_opus_encode
|
||||
PRIVATE ${CMAKE_CURRENT_BINARY_DIR} celt)
|
||||
target_link_libraries(test_opus_encode PRIVATE opus)
|
||||
add_test(NAME test_opus_encode COMMAND $<TARGET_FILE:test_opus_encode> WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
|
||||
endif()
|
||||
endif()
|
||||
44
vendor/audiopus_sys/opus/COPYING
vendored
44
vendor/audiopus_sys/opus/COPYING
vendored
@@ -1,44 +0,0 @@
|
||||
Copyright 2001-2011 Xiph.Org, Skype Limited, Octasic,
|
||||
Jean-Marc Valin, Timothy B. Terriberry,
|
||||
CSIRO, Gregory Maxwell, Mark Borgerding,
|
||||
Erik de Castro Lopo
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
- Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
- Neither the name of Internet Society, IETF or IETF Trust, nor the
|
||||
names of specific contributors, may be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
||||
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Opus is subject to the royalty-free patent licenses which are
|
||||
specified at:
|
||||
|
||||
Xiph.Org Foundation:
|
||||
https://datatracker.ietf.org/ipr/1524/
|
||||
|
||||
Microsoft Corporation:
|
||||
https://datatracker.ietf.org/ipr/1914/
|
||||
|
||||
Broadcom Corporation:
|
||||
https://datatracker.ietf.org/ipr/1526/
|
||||
0
vendor/audiopus_sys/opus/ChangeLog
vendored
0
vendor/audiopus_sys/opus/ChangeLog
vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user