Compare commits
2 Commits
video-usab
...
debug/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073756ed4b | ||
|
|
2fcc2d77cf |
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: caveman
|
|
||||||
description: >
|
|
||||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
|
||||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
|
||||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
|
||||||
when token efficiency is requested.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Caveman Mode
|
|
||||||
|
|
||||||
## Core Rule
|
|
||||||
|
|
||||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
|
||||||
|
|
||||||
## Grammar
|
|
||||||
|
|
||||||
- Drop articles (a, an, the)
|
|
||||||
- Drop filler (just, really, basically, actually, simply)
|
|
||||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
|
||||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
|
||||||
- No hedging (skip "it might be worth considering")
|
|
||||||
- Fragments fine. No need full sentence
|
|
||||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
|
||||||
- Code blocks unchanged. Caveman speak around code, not in code
|
|
||||||
- Error messages quoted exact. Caveman only for explanation
|
|
||||||
|
|
||||||
## Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
[thing] [action] [reason]. [next step].
|
|
||||||
```
|
|
||||||
|
|
||||||
Not:
|
|
||||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
|
||||||
|
|
||||||
Yes:
|
|
||||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
**User:** Why is my React component re-rendering?
|
|
||||||
|
|
||||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
|
||||||
|
|
||||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**User:** How do I set up a PostgreSQL connection pool?
|
|
||||||
|
|
||||||
**Caveman:**
|
|
||||||
```
|
|
||||||
Use `pg` pool:
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
const pool = new Pool({
|
|
||||||
max: 20,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 2000,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
```
|
|
||||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Code: write normal. Caveman English only
|
|
||||||
- Git commits: normal
|
|
||||||
- PR descriptions: normal
|
|
||||||
- User say "stop caveman" or "normal mode": revert immediately
|
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -4,33 +4,3 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
dev-debug.log
|
|
||||||
|
|
||||||
# Debug frame dump artifacts
|
|
||||||
android-frame-dumps/
|
|
||||||
wzp-frame-dumps.tar
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
# OS specific
|
|
||||||
|
|
||||||
# Taskmaster (local workflow tool)
|
|
||||||
.taskmaster/
|
|
||||||
.env.example
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
[extend]
|
|
||||||
useDefault = true
|
|
||||||
|
|
||||||
[[allowlists]]
|
|
||||||
description = "Pre-existing historical findings already on fj/main and github/main. The two PASTE_AUTH tokens in scripts/build.sh and scripts/build-linux-notify.sh are real — rotate if those endpoints still authenticate; this allowlist only silences the pre-push hook, it does not remove the exposure."
|
|
||||||
commits = [
|
|
||||||
# wzp-crypto module doc: false positive on "SHA-256(Ed25519 pub)[:16]"
|
|
||||||
"51e893590c1b9fa49e9f6ae5c96c26deb58f353b",
|
|
||||||
# build.sh PASTE_AUTH (paste.tbs.amn.gg)
|
|
||||||
"bd6733b2e5d76b5259020f1c30a5223a9773b6aa",
|
|
||||||
# build-linux-notify Authorization header (paste.dk.manko.yoga)
|
|
||||||
"6d776097c83bc6fbe3f3565e080513d8af93b550",
|
|
||||||
"7751439e2bca9eacf2c30929c8124a4eb6136df2",
|
|
||||||
]
|
|
||||||
2348
Cargo.lock
generated
2348
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@@ -10,8 +10,6 @@ members = [
|
|||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-android",
|
||||||
"crates/wzp-native",
|
|
||||||
"crates/wzp-video",
|
|
||||||
"desktop/src-tauri",
|
"desktop/src-tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -33,25 +31,17 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
|
|
||||||
# Transport
|
# Transport
|
||||||
quinn = "0.11"
|
quinn = "0.11"
|
||||||
socket2 = "0.5"
|
|
||||||
|
|
||||||
# FEC
|
# FEC
|
||||||
raptorq = "2"
|
raptorq = "2"
|
||||||
|
|
||||||
# Codec
|
# Codec
|
||||||
# opusic-c: high-level safe bindings over libopus 1.5.2 (encoder side).
|
audiopus = "0.3.0-rc.0"
|
||||||
# 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"
|
codec2 = "0.3"
|
||||||
|
|
||||||
# Crypto
|
# Crypto
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
@@ -75,7 +65,9 @@ opt-level = 2
|
|||||||
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
||||||
[profile.dev.package.nnnoiseless]
|
[profile.dev.package.nnnoiseless]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
[profile.dev.package.opusic-sys]
|
[profile.dev.package.audiopus_sys]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.audiopus]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
[profile.dev.package.raptorq]
|
[profile.dev.package.raptorq]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
@@ -83,10 +75,3 @@ opt-level = 3
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
[profile.dev.package.wzp-fec]
|
[profile.dev.package.wzp-fec]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
# Phase 0 (opus-DRED): removed the [patch.crates-io] audiopus_sys = { path =
|
|
||||||
# "vendor/audiopus_sys" } block. That patch existed to fix a Windows clang-cl
|
|
||||||
# SIMD compile bug in libopus 1.3.1. With the swap to opusic-sys (libopus
|
|
||||||
# 1.5.2), the upstream SIMD gating was fixed and the vendor patch is
|
|
||||||
# obsolete. The vendor/audiopus_sys directory itself should be deleted as
|
|
||||||
# part of the same cleanup — see the commit that follows this Phase 0.
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
./scripts/android-build-async.sh --init
|
|
||||||
@@ -19,8 +19,6 @@ import java.io.FileOutputStream
|
|||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
@@ -57,23 +55,10 @@ class AudioPipeline(private val context: Context) {
|
|||||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||||
var aecEnabled: Boolean = true
|
var aecEnabled: Boolean = true
|
||||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||||
var debugRecording: Boolean = false
|
var debugRecording: Boolean = true
|
||||||
private var captureThread: Thread? = null
|
private var captureThread: Thread? = null
|
||||||
private var playoutThread: Thread? = null
|
private var playoutThread: Thread? = null
|
||||||
|
|
||||||
// DirectByteBuffers for zero-copy JNI audio transfer.
|
|
||||||
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
|
||||||
// can null local variables when it replaces the stack frame mid-loop.
|
|
||||||
// These survive OSR because they're on the heap.
|
|
||||||
private val captureDirectBuf: ByteBuffer =
|
|
||||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
private val playoutDirectBuf: ByteBuffer =
|
|
||||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
|
|
||||||
/** Latch counted down by each audio thread after exiting its loop.
|
|
||||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
|
||||||
private var drainLatch: CountDownLatch? = null
|
|
||||||
|
|
||||||
private val debugDir: File by lazy {
|
private val debugDir: File by lazy {
|
||||||
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
||||||
}
|
}
|
||||||
@@ -81,11 +66,9 @@ class AudioPipeline(private val context: Context) {
|
|||||||
fun start(engine: WzpEngine) {
|
fun start(engine: WzpEngine) {
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
|
||||||
|
|
||||||
captureThread = Thread({
|
captureThread = Thread({
|
||||||
runCapture(engine)
|
runCapture(engine)
|
||||||
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
|
|
||||||
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
||||||
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
||||||
parkThread()
|
parkThread()
|
||||||
@@ -97,7 +80,6 @@ class AudioPipeline(private val context: Context) {
|
|||||||
|
|
||||||
playoutThread = Thread({
|
playoutThread = Thread({
|
||||||
runPlayout(engine)
|
runPlayout(engine)
|
||||||
drainLatch?.countDown() // signal: playout loop exited
|
|
||||||
parkThread()
|
parkThread()
|
||||||
}, "wzp-playout").apply {
|
}, "wzp-playout").apply {
|
||||||
isDaemon = true
|
isDaemon = true
|
||||||
@@ -110,20 +92,10 @@ class AudioPipeline(private val context: Context) {
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
// Don't join threads — they are parked as daemons to avoid native TLS crash.
|
// Don't join — threads are parked as daemons to avoid native TLS crash
|
||||||
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
|
|
||||||
Log.i(TAG, "audio pipeline stopped (running=false)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Block until both audio threads have exited their loops (max 200ms).
|
|
||||||
* After this returns, no more JNI calls to the engine will be made. */
|
|
||||||
fun awaitDrain(): Boolean {
|
|
||||||
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
|
||||||
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
|
|
||||||
captureThread = null
|
captureThread = null
|
||||||
playoutThread = null
|
playoutThread = null
|
||||||
drainLatch = null
|
Log.i(TAG, "audio pipeline stopped")
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
||||||
@@ -234,10 +206,7 @@ class AudioPipeline(private val context: Context) {
|
|||||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||||
if (read > 0) {
|
if (read > 0) {
|
||||||
applyGain(pcm, read, captureGainDb)
|
applyGain(pcm, read, captureGainDb)
|
||||||
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
engine.writeAudio(pcm)
|
||||||
captureDirectBuf.clear()
|
|
||||||
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
|
||||||
engine.writeAudioDirect(captureDirectBuf, read)
|
|
||||||
|
|
||||||
// Debug: write raw PCM + RMS
|
// Debug: write raw PCM + RMS
|
||||||
if (pcmOut != null) {
|
if (pcmOut != null) {
|
||||||
@@ -316,12 +285,8 @@ class AudioPipeline(private val context: Context) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
while (running) {
|
while (running) {
|
||||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
val read = engine.readAudio(pcm)
|
||||||
playoutDirectBuf.clear()
|
|
||||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
|
||||||
if (read >= FRAME_SAMPLES) {
|
if (read >= FRAME_SAMPLES) {
|
||||||
playoutDirectBuf.rewind()
|
|
||||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
|
||||||
applyGain(pcm, read, playoutGainDb)
|
applyGain(pcm, read, playoutGainDb)
|
||||||
track.write(pcm, 0, read)
|
track.write(pcm, 0, read)
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ class SettingsRepository(context: Context) {
|
|||||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
|
||||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
|
||||||
private const val TOFU_PREFIX = "tofu_"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Servers ---
|
// --- Servers ---
|
||||||
@@ -121,16 +118,6 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||||
|
|
||||||
// --- Debug recording ---
|
|
||||||
|
|
||||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
|
||||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
|
||||||
|
|
||||||
// --- Codec choice ---
|
|
||||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
|
||||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
|
||||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
|
||||||
|
|
||||||
// --- Identity seed ---
|
// --- Identity seed ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,53 +138,4 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveSeedHex(hex: String) {
|
fun saveSeedHex(hex: String) {
|
||||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Recent rooms ---
|
|
||||||
|
|
||||||
data class RecentRoom(val relay: String, val room: String)
|
|
||||||
|
|
||||||
fun addRecentRoom(relay: String, room: String) {
|
|
||||||
val rooms = loadRecentRooms().toMutableList()
|
|
||||||
rooms.removeAll { it.relay == relay && it.room == room }
|
|
||||||
rooms.add(0, RecentRoom(relay, room))
|
|
||||||
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
|
||||||
val arr = JSONArray()
|
|
||||||
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
|
||||||
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadRecentRooms(): List<RecentRoom> {
|
|
||||||
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
|
||||||
return try {
|
|
||||||
val arr = JSONArray(json)
|
|
||||||
(0 until arr.length()).map { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
RecentRoom(o.getString("relay"), o.getString("room"))
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearRecentRooms() {
|
|
||||||
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Server fingerprint TOFU ---
|
|
||||||
|
|
||||||
fun saveServerFingerprint(address: String, fingerprint: String) {
|
|
||||||
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadServerFingerprint(address: String): String? {
|
|
||||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Ping RTT cache ---
|
|
||||||
|
|
||||||
fun savePingRtt(address: String, rttMs: Int) {
|
|
||||||
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadPingRtt(address: String): Int {
|
|
||||||
return prefs.getInt("ping_rtt_$address", -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ class DebugReporter(private val context: Context) {
|
|||||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||||
|
|
||||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
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
|
// 1. Call metadata
|
||||||
val meta = buildString {
|
val meta = buildString {
|
||||||
appendLine("=== WZ Phone Debug Report ===")
|
appendLine("=== WZ Phone Debug Report ===")
|
||||||
@@ -66,18 +58,6 @@ class DebugReporter(private val context: Context) {
|
|||||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||||
appendLine()
|
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("=== Final Stats ===")
|
||||||
appendLine(finalStatsJson)
|
appendLine(finalStatsJson)
|
||||||
}
|
}
|
||||||
@@ -215,28 +195,4 @@ class DebugReporter(private val context: Context) {
|
|||||||
FileInputStream(file).use { it.copyTo(zos) }
|
FileInputStream(file).use { it.copyTo(zos) }
|
||||||
zos.closeEntry()
|
zos.closeEntry()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tiny JSON field extractor — pulls an integer value for a top-level
|
|
||||||
* field like `"dred_reconstructions":42`. We don't want to pull in a
|
|
||||||
* full JSON parser just for the debug preamble, and the CallStats
|
|
||||||
* output is a flat record with well-known field names.
|
|
||||||
*
|
|
||||||
* Returns 0 if the field is missing or unparseable.
|
|
||||||
*/
|
|
||||||
private fun extractLongField(json: String, field: String): Long {
|
|
||||||
val key = "\"$field\":"
|
|
||||||
val idx = json.indexOf(key)
|
|
||||||
if (idx < 0) return 0
|
|
||||||
var i = idx + key.length
|
|
||||||
// Skip whitespace
|
|
||||||
while (i < json.length && json[i].isWhitespace()) i++
|
|
||||||
val start = i
|
|
||||||
while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
|
|
||||||
return try {
|
|
||||||
json.substring(start, i).toLong()
|
|
||||||
} catch (_: NumberFormatException) {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,24 +33,10 @@ data class CallStats(
|
|||||||
val fecRecovered: Long = 0,
|
val fecRecovered: Long = 0,
|
||||||
/** Current mic audio level (RMS, 0-32767). */
|
/** Current mic audio level (RMS, 0-32767). */
|
||||||
val audioLevel: Int = 0,
|
val audioLevel: Int = 0,
|
||||||
/** Our current outgoing codec (e.g. "Opus24k"). */
|
|
||||||
val currentCodec: String = "",
|
|
||||||
/** Last seen incoming codec from peers. */
|
|
||||||
val peerCodec: String = "",
|
|
||||||
/** Whether auto quality mode is active. */
|
|
||||||
val autoMode: Boolean = false,
|
|
||||||
/** Number of participants in the room. */
|
/** Number of participants in the room. */
|
||||||
val roomParticipantCount: Int = 0,
|
val roomParticipantCount: Int = 0,
|
||||||
/** Participants in the room (fingerprint + optional alias). */
|
/** Participants in the room (fingerprint + optional alias). */
|
||||||
val roomParticipants: List<RoomMember> = emptyList(),
|
val roomParticipants: List<RoomMember> = emptyList(),
|
||||||
/** SAS verification code (4-digit, null if not in a call). */
|
|
||||||
val sasCode: Int? = null,
|
|
||||||
/** Incoming call ID (or "relay|room" for CallSetup). */
|
|
||||||
val incomingCallId: String? = null,
|
|
||||||
/** Incoming caller's fingerprint. */
|
|
||||||
val incomingCallerFp: String? = null,
|
|
||||||
/** Incoming caller's alias. */
|
|
||||||
val incomingCallerAlias: String? = null,
|
|
||||||
) {
|
) {
|
||||||
/** Human-readable quality label. */
|
/** Human-readable quality label. */
|
||||||
val qualityLabel: String
|
val qualityLabel: String
|
||||||
@@ -68,8 +54,7 @@ data class CallStats(
|
|||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
RoomMember(
|
RoomMember(
|
||||||
fingerprint = o.optString("fingerprint", ""),
|
fingerprint = o.optString("fingerprint", ""),
|
||||||
alias = if (o.isNull("alias")) null else o.optString("alias", null),
|
alias = if (o.isNull("alias")) null else o.optString("alias", null)
|
||||||
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,15 +76,8 @@ data class CallStats(
|
|||||||
underruns = obj.optLong("underruns", 0),
|
underruns = obj.optLong("underruns", 0),
|
||||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||||
audioLevel = obj.optInt("audio_level", 0),
|
audioLevel = obj.optInt("audio_level", 0),
|
||||||
currentCodec = obj.optString("current_codec", ""),
|
|
||||||
peerCodec = obj.optString("peer_codec", ""),
|
|
||||||
autoMode = obj.optBoolean("auto_mode", false),
|
|
||||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
|
||||||
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
|
||||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
|
||||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
|
||||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
CallStats()
|
CallStats()
|
||||||
@@ -110,8 +88,7 @@ data class CallStats(
|
|||||||
|
|
||||||
data class RoomMember(
|
data class RoomMember(
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
val alias: String? = null,
|
val alias: String? = null
|
||||||
val relayLabel: String? = null
|
|
||||||
) {
|
) {
|
||||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||||
val displayName: String
|
val displayName: String
|
||||||
|
|||||||
@@ -38,12 +38,9 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
* @param alias display name sent to relay for room participant list
|
* @param alias display name sent to relay for room participant list
|
||||||
* @return 0 on success, negative error code on failure
|
* @return 0 on success, negative error code on failure
|
||||||
*/
|
*/
|
||||||
/**
|
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = ""): Int {
|
||||||
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
|
||||||
*/
|
|
||||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias)
|
||||||
if (result == 0) {
|
if (result == 0) {
|
||||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||||
} else {
|
} else {
|
||||||
@@ -53,7 +50,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the active call. Safe to call when no call is active. */
|
/** Stop the active call. Safe to call when no call is active. */
|
||||||
@Synchronized
|
|
||||||
fun stopCall() {
|
fun stopCall() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeStopCall(nativeHandle)
|
nativeStopCall(nativeHandle)
|
||||||
@@ -77,7 +73,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
*
|
*
|
||||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
|
||||||
fun getStats(): String {
|
fun getStats(): String {
|
||||||
if (nativeHandle == 0L) return "{}"
|
if (nativeHandle == 0L) return "{}"
|
||||||
return try {
|
return try {
|
||||||
@@ -96,19 +91,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
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. */
|
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||||
@Synchronized
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeDestroy(nativeHandle)
|
nativeDestroy(nativeHandle)
|
||||||
@@ -134,31 +117,11 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
return nativeReadAudio(nativeHandle, pcm)
|
return nativeReadAudio(nativeHandle, pcm)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
|
|
||||||
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
|
|
||||||
* Called from the AudioRecord capture thread.
|
|
||||||
*/
|
|
||||||
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
|
|
||||||
if (nativeHandle == 0L) return 0
|
|
||||||
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
|
|
||||||
* The buffer must be a direct ByteBuffer with native byte order.
|
|
||||||
* Called from the AudioTrack playout thread.
|
|
||||||
*/
|
|
||||||
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
|
|
||||||
if (nativeHandle == 0L) return 0
|
|
||||||
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- JNI native methods --------------------------------------------------
|
// -- JNI native methods --------------------------------------------------
|
||||||
|
|
||||||
private external fun nativeInit(): Long
|
private external fun nativeInit(): Long
|
||||||
private external fun nativeStartCall(
|
private external fun nativeStartCall(
|
||||||
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
handle: Long, relay: String, room: String, seed: String, token: String, alias: String
|
||||||
): Int
|
): Int
|
||||||
private external fun nativeStopCall(handle: Long)
|
private external fun nativeStopCall(handle: Long)
|
||||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||||
@@ -167,58 +130,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
private external fun nativeForceProfile(handle: Long, profile: Int)
|
private external fun nativeForceProfile(handle: Long, profile: Int)
|
||||||
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
||||||
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
||||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
|
||||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
|
||||||
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
|
||||||
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
|
||||||
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
|
|
||||||
private external fun nativeOnNetworkChanged(handle: Long, networkType: Int, bandwidthKbps: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ping a relay server. Requires engine to be initialized.
|
|
||||||
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
|
||||||
*/
|
|
||||||
fun pingRelay(address: String): String? {
|
|
||||||
if (nativeHandle == 0L) return null
|
|
||||||
return nativePingRelay(nativeHandle, address)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start persistent signaling connection for direct 1:1 calls.
|
|
||||||
* The engine registers on the relay and listens for incoming calls.
|
|
||||||
* Call state updates are available via [getStats].
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Place a direct call to a peer by fingerprint.
|
|
||||||
* Requires [startSignaling] to have been called first.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun placeCall(targetFingerprint: String): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativePlaceCall(nativeHandle, targetFingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answer an incoming direct call.
|
|
||||||
*
|
|
||||||
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
|
|
||||||
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package com.wzp.net
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monitors network connectivity changes via [ConnectivityManager.NetworkCallback]
|
|
||||||
* and classifies the active transport (WiFi, LTE, 5G, 3G).
|
|
||||||
*
|
|
||||||
* Callbacks fire on the main looper so callers can safely update UI state or
|
|
||||||
* dispatch to a native engine from any callback.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Set [onNetworkChanged] to receive `(type: Int, downlinkKbps: Int)` events
|
|
||||||
* 2. Optionally set [onIpChanged] for IP address change events (mid-call ICE refresh)
|
|
||||||
* 3. Call [register] when the call starts
|
|
||||||
* 4. Call [unregister] when the call ends
|
|
||||||
*/
|
|
||||||
class NetworkMonitor(context: Context) {
|
|
||||||
|
|
||||||
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the network transport type or bandwidth changes.
|
|
||||||
* `type` constants match the Rust `NetworkContext` enum ordinals.
|
|
||||||
*/
|
|
||||||
var onNetworkChanged: ((type: Int, downlinkKbps: Int) -> Unit)? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the device's IP address changes (link properties changed).
|
|
||||||
* Useful for triggering mid-call ICE candidate re-gathering.
|
|
||||||
*/
|
|
||||||
var onIpChanged: (() -> Unit)? = null
|
|
||||||
|
|
||||||
// Track the last emitted type to avoid redundant callbacks
|
|
||||||
@Volatile
|
|
||||||
private var lastEmittedType: Int = TYPE_UNKNOWN
|
|
||||||
|
|
||||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
classifyAndEmit(network)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
|
||||||
classifyFromCaps(caps)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLinkPropertiesChanged(
|
|
||||||
network: Network,
|
|
||||||
linkProperties: android.net.LinkProperties
|
|
||||||
) {
|
|
||||||
// IP address may have changed — notify for ICE refresh
|
|
||||||
onIpChanged?.invoke()
|
|
||||||
// Also re-classify in case the transport changed simultaneously
|
|
||||||
classifyAndEmit(network)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
lastEmittedType = TYPE_NONE
|
|
||||||
onNetworkChanged?.invoke(TYPE_NONE, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
|
||||||
|
|
||||||
/** Register the network callback. Call when a call starts. */
|
|
||||||
fun register() {
|
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
cm.registerNetworkCallback(request, callback, mainHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unregister the network callback. Call when the call ends. */
|
|
||||||
fun unregister() {
|
|
||||||
try {
|
|
||||||
cm.unregisterNetworkCallback(callback)
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
// Already unregistered — safe to ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Classification -------------------------------------------------------
|
|
||||||
|
|
||||||
private fun classifyAndEmit(network: Network) {
|
|
||||||
val caps = cm.getNetworkCapabilities(network) ?: return
|
|
||||||
classifyFromCaps(caps)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun classifyFromCaps(caps: NetworkCapabilities) {
|
|
||||||
val type = when {
|
|
||||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
|
||||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_WIFI // treat as WiFi
|
|
||||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> classifyCellular(caps)
|
|
||||||
else -> TYPE_UNKNOWN
|
|
||||||
}
|
|
||||||
val bw = caps.getLinkDownstreamBandwidthKbps()
|
|
||||||
|
|
||||||
// Deduplicate: only emit when the transport type actually changes
|
|
||||||
if (type != lastEmittedType) {
|
|
||||||
lastEmittedType = type
|
|
||||||
onNetworkChanged?.invoke(type, bw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approximate cellular generation from reported downstream bandwidth.
|
|
||||||
* This avoids requiring READ_PHONE_STATE permission (needed for
|
|
||||||
* TelephonyManager.getNetworkType on API 30+).
|
|
||||||
*
|
|
||||||
* Thresholds are conservative — carriers over-report bandwidth, so we
|
|
||||||
* classify based on what's actually usable for VoIP:
|
|
||||||
* - >= 100 Mbps → 5G NR
|
|
||||||
* - >= 10 Mbps → LTE
|
|
||||||
* - < 10 Mbps → 3G or worse
|
|
||||||
*/
|
|
||||||
private fun classifyCellular(caps: NetworkCapabilities): Int {
|
|
||||||
val bw = caps.getLinkDownstreamBandwidthKbps()
|
|
||||||
return when {
|
|
||||||
bw >= 100_000 -> TYPE_CELLULAR_5G
|
|
||||||
bw >= 10_000 -> TYPE_CELLULAR_LTE
|
|
||||||
else -> TYPE_CELLULAR_3G
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Constants matching Rust `NetworkContext` enum ordinals. */
|
|
||||||
const val TYPE_WIFI = 0
|
|
||||||
const val TYPE_CELLULAR_LTE = 1
|
|
||||||
const val TYPE_CELLULAR_5G = 2
|
|
||||||
const val TYPE_CELLULAR_3G = 3
|
|
||||||
const val TYPE_UNKNOWN = 4
|
|
||||||
const val TYPE_NONE = 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.wzp.net
|
|
||||||
|
|
||||||
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
|
||||||
// This file kept for the data class only.
|
|
||||||
|
|
||||||
object RelayPinger {
|
|
||||||
data class PingResult(
|
|
||||||
val rttMs: Int,
|
|
||||||
val reachable: Boolean,
|
|
||||||
val serverFingerprint: String = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.wzp.audio.AudioPipeline
|
import com.wzp.audio.AudioPipeline
|
||||||
import com.wzp.audio.AudioRoute
|
|
||||||
import com.wzp.audio.AudioRouteManager
|
import com.wzp.audio.AudioRouteManager
|
||||||
import com.wzp.data.SettingsRepository
|
import com.wzp.data.SettingsRepository
|
||||||
import com.wzp.debug.DebugReporter
|
import com.wzp.debug.DebugReporter
|
||||||
@@ -13,8 +12,6 @@ import com.wzp.engine.CallStats
|
|||||||
import com.wzp.service.CallService
|
import com.wzp.service.CallService
|
||||||
import com.wzp.engine.WzpCallback
|
import com.wzp.engine.WzpCallback
|
||||||
import com.wzp.engine.WzpEngine
|
import com.wzp.engine.WzpEngine
|
||||||
import com.wzp.net.NetworkMonitor
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -22,8 +19,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
@@ -31,21 +26,12 @@ import java.net.InetAddress
|
|||||||
|
|
||||||
data class ServerEntry(val address: String, val label: String)
|
data class ServerEntry(val address: String, val label: String)
|
||||||
|
|
||||||
data class PingResult(
|
|
||||||
val rttMs: Int,
|
|
||||||
val serverFingerprint: String = "",
|
|
||||||
val reachable: Boolean = rttMs > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
|
||||||
|
|
||||||
class CallViewModel : ViewModel(), WzpCallback {
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
private var engine: WzpEngine? = null
|
private var engine: WzpEngine? = null
|
||||||
private var engineInitialized = false
|
private var engineInitialized = false
|
||||||
private var audioPipeline: AudioPipeline? = null
|
private var audioPipeline: AudioPipeline? = null
|
||||||
private var audioRouteManager: AudioRouteManager? = null
|
private var audioRouteManager: AudioRouteManager? = null
|
||||||
private var networkMonitor: NetworkMonitor? = null
|
|
||||||
private var audioStarted = false
|
private var audioStarted = false
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var settings: SettingsRepository? = null
|
private var settings: SettingsRepository? = null
|
||||||
@@ -63,9 +49,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _isSpeaker = MutableStateFlow(false)
|
private val _isSpeaker = MutableStateFlow(false)
|
||||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||||
|
|
||||||
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
|
|
||||||
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
|
|
||||||
|
|
||||||
private val _stats = MutableStateFlow(CallStats())
|
private val _stats = MutableStateFlow(CallStats())
|
||||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||||
|
|
||||||
@@ -87,16 +70,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _preferIPv6 = MutableStateFlow(false)
|
private val _preferIPv6 = MutableStateFlow(false)
|
||||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||||
|
|
||||||
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
|
||||||
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
|
||||||
|
|
||||||
/** Ping results keyed by server address. */
|
|
||||||
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
|
||||||
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
|
||||||
|
|
||||||
/** Known server fingerprints (TOFU). */
|
|
||||||
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
|
||||||
|
|
||||||
private val _playoutGainDb = MutableStateFlow(0f)
|
private val _playoutGainDb = MutableStateFlow(0f)
|
||||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||||
|
|
||||||
@@ -112,18 +85,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _aecEnabled = MutableStateFlow(true)
|
private val _aecEnabled = MutableStateFlow(true)
|
||||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _debugRecording = MutableStateFlow(false)
|
|
||||||
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
|
||||||
|
|
||||||
// Quality profile index (matches JNI bridge profile_from_int)
|
|
||||||
private val _codecChoice = MutableStateFlow(0)
|
|
||||||
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
|
||||||
|
|
||||||
/** Key-change warning dialog state. */
|
|
||||||
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
|
||||||
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
|
||||||
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
|
||||||
|
|
||||||
/** True when a call just ended and debug report can be sent. */
|
/** True when a call just ended and debug report can be sent. */
|
||||||
private val _debugReportAvailable = MutableStateFlow(false)
|
private val _debugReportAvailable = MutableStateFlow(false)
|
||||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||||
@@ -138,91 +99,13 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
|
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
// ── Direct calling state ──
|
|
||||||
/** 0=room mode, 1=direct call mode */
|
|
||||||
private val _callMode = MutableStateFlow(0)
|
|
||||||
val callMode: StateFlow<Int> = _callMode.asStateFlow()
|
|
||||||
|
|
||||||
/** Target fingerprint for direct call */
|
|
||||||
private val _targetFingerprint = MutableStateFlow("")
|
|
||||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
|
||||||
|
|
||||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
|
||||||
private val _signalState = MutableStateFlow(0)
|
|
||||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
|
||||||
|
|
||||||
/** Incoming call info */
|
|
||||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
|
|
||||||
|
|
||||||
private val _incomingCallerFp = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
|
|
||||||
|
|
||||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
|
||||||
|
|
||||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
|
||||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
|
||||||
|
|
||||||
/** Register on relay for direct calls */
|
|
||||||
fun registerForCalls() {
|
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this).also { it.init() }
|
|
||||||
}
|
|
||||||
val serverIdx = _selectedServer.value
|
|
||||||
val serverList = _servers.value
|
|
||||||
if (serverIdx >= serverList.size) return
|
|
||||||
|
|
||||||
val relay = serverList[serverIdx].address
|
|
||||||
val seed = _seedHex.value
|
|
||||||
val alias = _alias.value
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
|
||||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
|
||||||
if (result == 0) {
|
|
||||||
_signalState.value = 5 // Registered
|
|
||||||
startStatsPolling()
|
|
||||||
} else {
|
|
||||||
_errorMessage.value = "Failed to register on relay"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Place a direct call to the target fingerprint */
|
|
||||||
fun placeDirectCall() {
|
|
||||||
val target = _targetFingerprint.value.trim()
|
|
||||||
if (target.isEmpty()) {
|
|
||||||
_errorMessage.value = "Enter a fingerprint to call"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine?.placeCall(target)
|
|
||||||
_signalState.value = 6 // Ringing
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer an incoming direct call */
|
|
||||||
fun answerIncomingCall(mode: Int = 2) {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject an incoming direct call */
|
|
||||||
fun rejectIncomingCall() {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, 0) // 0 = Reject
|
|
||||||
_signalState.value = 5 // Back to registered
|
|
||||||
_incomingCallId.value = null
|
|
||||||
_incomingCallerFp.value = null
|
|
||||||
_incomingCallerAlias.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WzpCall"
|
private const val TAG = "WzpCall"
|
||||||
val DEFAULT_SERVERS = listOf(
|
val DEFAULT_SERVERS = listOf(
|
||||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||||
)
|
)
|
||||||
const val DEFAULT_ROOM = "general"
|
const val DEFAULT_ROOM = "android"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContext(context: Context) {
|
fun setContext(context: Context) {
|
||||||
@@ -232,19 +115,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
audioPipeline = AudioPipeline(appCtx)
|
audioPipeline = AudioPipeline(appCtx)
|
||||||
}
|
}
|
||||||
if (audioRouteManager == null) {
|
if (audioRouteManager == null) {
|
||||||
audioRouteManager = AudioRouteManager(appCtx).also { arm ->
|
audioRouteManager = AudioRouteManager(appCtx)
|
||||||
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) {
|
if (debugReporter == null) {
|
||||||
debugReporter = DebugReporter(appCtx)
|
debugReporter = DebugReporter(appCtx)
|
||||||
@@ -268,9 +139,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_captureGainDb.value = s.loadCaptureGain()
|
_captureGainDb.value = s.loadCaptureGain()
|
||||||
_seedHex.value = s.getOrCreateSeedHex()
|
_seedHex.value = s.getOrCreateSeedHex()
|
||||||
_aecEnabled.value = s.loadAecEnabled()
|
_aecEnabled.value = s.loadAecEnabled()
|
||||||
_debugRecording.value = s.loadDebugRecording()
|
|
||||||
_codecChoice.value = s.loadCodecChoice()
|
|
||||||
_recentRooms.value = s.loadRecentRooms()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectServer(index: Int) {
|
fun selectServer(index: Int) {
|
||||||
@@ -314,70 +182,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ping all servers via native QUIC. Requires engine to be initialized.
|
|
||||||
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
|
|
||||||
*/
|
|
||||||
fun pingAllServers() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Ensure engine exists
|
|
||||||
if (engine == null || engine?.isInitialized != true) {
|
|
||||||
try {
|
|
||||||
engine = WzpEngine(this@CallViewModel).also { it.init() }
|
|
||||||
engineInitialized = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "engine init for ping failed: $e")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val eng = engine ?: return@launch
|
|
||||||
|
|
||||||
val results = mutableMapOf<String, PingResult>()
|
|
||||||
val known = mutableMapOf<String, String>()
|
|
||||||
_servers.value.forEach { server ->
|
|
||||||
val json = withContext(Dispatchers.IO) {
|
|
||||||
eng.pingRelay(server.address)
|
|
||||||
}
|
|
||||||
if (json != null) {
|
|
||||||
try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
val rtt = obj.getInt("rtt_ms")
|
|
||||||
val fp = obj.optString("server_fingerprint", "")
|
|
||||||
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
|
|
||||||
// TOFU
|
|
||||||
if (fp.isNotEmpty()) {
|
|
||||||
val saved = settings?.loadServerFingerprint(server.address)
|
|
||||||
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
|
|
||||||
known[server.address] = saved ?: fp
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_pingResults.value = results
|
|
||||||
_knownFingerprints.value = known
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load saved TOFU fingerprints. */
|
|
||||||
fun loadSavedFingerprints() {
|
|
||||||
val known = mutableMapOf<String, String>()
|
|
||||||
_servers.value.forEach { server ->
|
|
||||||
settings?.loadServerFingerprint(server.address)?.let {
|
|
||||||
known[server.address] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_knownFingerprints.value = known
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get lock status for a server. */
|
|
||||||
fun lockStatus(address: String): LockStatus {
|
|
||||||
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
|
||||||
if (!pr.reachable) return LockStatus.OFFLINE
|
|
||||||
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
|
||||||
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
|
||||||
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setRoomName(name: String) {
|
fun setRoomName(name: String) {
|
||||||
_roomName.value = name
|
_roomName.value = name
|
||||||
settings?.saveRoom(name)
|
settings?.saveRoom(name)
|
||||||
@@ -410,16 +214,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveAecEnabled(enabled)
|
settings?.saveAecEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDebugRecording(enabled: Boolean) {
|
|
||||||
_debugRecording.value = enabled
|
|
||||||
settings?.saveDebugRecording(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCodecChoice(choice: Int) {
|
|
||||||
_codecChoice.value = choice
|
|
||||||
settings?.saveCodecChoice(choice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||||
* since Rust's DNS resolution may not work on Android.
|
* since Rust's DNS resolution may not work on Android.
|
||||||
@@ -460,17 +254,8 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||||
val hadCall = audioStarted
|
val hadCall = audioStarted
|
||||||
CallService.onStopFromNotification = null
|
CallService.onStopFromNotification = null
|
||||||
stopAudio() // sets running=false (non-blocking)
|
stopAudio()
|
||||||
stopStatsPolling()
|
stopStatsPolling()
|
||||||
|
|
||||||
// Wait for audio threads to exit their loops before destroying the engine.
|
|
||||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
|
||||||
val drained = audioPipeline?.awaitDrain() ?: true
|
|
||||||
if (!drained) {
|
|
||||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
|
||||||
}
|
|
||||||
audioPipeline = null
|
|
||||||
|
|
||||||
Log.i(TAG, "teardown: stopping engine")
|
Log.i(TAG, "teardown: stopping engine")
|
||||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||||
@@ -486,82 +271,13 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
Log.i(TAG, "teardown: done")
|
Log.i(TAG, "teardown: done")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accept the new server key and proceed with the call. */
|
|
||||||
fun acceptNewFingerprint() {
|
|
||||||
val info = _keyWarning.value ?: return
|
|
||||||
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
|
||||||
it[info.address] = info.newFp
|
|
||||||
}
|
|
||||||
settings?.saveServerFingerprint(info.address, info.newFp)
|
|
||||||
_keyWarning.value = null
|
|
||||||
startCallInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissKeyWarning() {
|
|
||||||
_keyWarning.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startCall() {
|
fun startCall() {
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
|
||||||
// Check for key change before connecting
|
|
||||||
val ls = lockStatus(serverEntry.address)
|
|
||||||
if (ls == LockStatus.CHANGED) {
|
|
||||||
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
|
||||||
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
|
||||||
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startCallInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start a call to a specific relay + room (used by direct call setup). */
|
|
||||||
private fun startCallInternal(relay: String, room: String) {
|
|
||||||
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
|
|
||||||
try {
|
|
||||||
// Don't teardown — keep the signal connection alive
|
|
||||||
engine = WzpEngine(this)
|
|
||||||
engine!!.init()
|
|
||||||
engineInitialized = true
|
|
||||||
_callState.value = 1
|
|
||||||
_errorMessage.value = null
|
|
||||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
|
||||||
Log.w(TAG, "service start err: $e")
|
|
||||||
}
|
|
||||||
startStatsPolling()
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val seed = _seedHex.value
|
|
||||||
val name = _alias.value
|
|
||||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
|
||||||
if (result != 0) {
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Failed to connect to call room (code $result)"
|
|
||||||
appContext?.let { CallService.stop(it) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "startCallDirect error", e)
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Engine error: ${e.message}"
|
|
||||||
appContext?.let { CallService.stop(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "startCallDirect error", e)
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Engine error: ${e.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCallInternal() {
|
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
val room = _roomName.value
|
val room = _roomName.value
|
||||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||||
_debugReportAvailable.value = false
|
_debugReportAvailable.value = false
|
||||||
_debugReportStatus.value = null
|
_debugReportStatus.value = null
|
||||||
lastCallServer = serverEntry.address
|
lastCallServer = serverEntry.address
|
||||||
settings?.addRecentRoom(serverEntry.address, room)
|
|
||||||
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
|
||||||
debugReporter?.prepareForCall()
|
debugReporter?.prepareForCall()
|
||||||
try {
|
try {
|
||||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||||
@@ -584,7 +300,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
val seed = _seedHex.value
|
val seed = _seedHex.value
|
||||||
val name = _alias.value
|
val name = _alias.value
|
||||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1
|
||||||
Log.i(TAG, "startCall: engine returned $result")
|
Log.i(TAG, "startCall: engine returned $result")
|
||||||
// Only wire up notification callback after engine is running
|
// Only wire up notification callback after engine is running
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
CallService.onStopFromNotification = { stopCall() }
|
||||||
@@ -625,27 +341,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
audioRouteManager?.setSpeaker(newSpeaker)
|
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 clearError() { _errorMessage.value = null }
|
||||||
|
|
||||||
fun sendDebugReport() {
|
fun sendDebugReport() {
|
||||||
@@ -696,22 +391,19 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
it.playoutGainDb = _playoutGainDb.value
|
it.playoutGainDb = _playoutGainDb.value
|
||||||
it.captureGainDb = _captureGainDb.value
|
it.captureGainDb = _captureGainDb.value
|
||||||
it.aecEnabled = _aecEnabled.value
|
it.aecEnabled = _aecEnabled.value
|
||||||
it.debugRecording = _debugRecording.value
|
|
||||||
it.start(e)
|
it.start(e)
|
||||||
}
|
}
|
||||||
audioRouteManager?.register()
|
audioRouteManager?.register()
|
||||||
networkMonitor?.register()
|
|
||||||
audioStarted = true
|
audioStarted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopAudio() {
|
private fun stopAudio() {
|
||||||
if (!audioStarted) return
|
if (!audioStarted) return
|
||||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
audioPipeline?.stop()
|
||||||
|
audioPipeline = null
|
||||||
audioRouteManager?.unregister()
|
audioRouteManager?.unregister()
|
||||||
networkMonitor?.unregister()
|
|
||||||
audioRouteManager?.setSpeaker(false)
|
audioRouteManager?.setSpeaker(false)
|
||||||
_isSpeaker.value = false
|
_isSpeaker.value = false
|
||||||
_audioRoute.value = AudioRoute.EARPIECE
|
|
||||||
audioStarted = false
|
audioStarted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,27 +422,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
if (s.state != 0) {
|
if (s.state != 0) {
|
||||||
_callState.value = s.state
|
_callState.value = s.state
|
||||||
}
|
}
|
||||||
// Track signal state changes for direct calling
|
|
||||||
if (s.state in 5..7) {
|
|
||||||
_signalState.value = s.state
|
|
||||||
}
|
|
||||||
// Incoming call detection
|
|
||||||
if (s.state == 7) { // IncomingCall
|
|
||||||
_incomingCallId.value = s.incomingCallId
|
|
||||||
_incomingCallerFp.value = s.incomingCallerFp
|
|
||||||
_incomingCallerAlias.value = s.incomingCallerAlias
|
|
||||||
}
|
|
||||||
// CallSetup: auto-connect to media room
|
|
||||||
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
|
||||||
// Format: "relay_addr|room_name"
|
|
||||||
val parts = s.incomingCallId.split("|", limit = 2)
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val mediaRelay = parts[0]
|
|
||||||
val mediaRoom = parts[1]
|
|
||||||
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
|
||||||
startCallInternal(mediaRelay, mediaRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (s.state == 2 && !audioStarted) {
|
if (s.state == 2 && !audioStarted) {
|
||||||
startAudio()
|
startAudio()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
|||||||
package com.wzp.ui.components
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
|
||||||
* from a hex fingerprint string. Identical algorithm to the desktop
|
|
||||||
* TypeScript implementation in identicon.ts.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Identicon(
|
|
||||||
fingerprint: String,
|
|
||||||
size: Dp = 36.dp,
|
|
||||||
clickToCopy: Boolean = true,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val bytes = hashBytes(fingerprint)
|
|
||||||
val (bg, fg) = deriveColors(bytes)
|
|
||||||
val grid = buildGrid(bytes)
|
|
||||||
|
|
||||||
Canvas(
|
|
||||||
modifier = modifier
|
|
||||||
.size(size)
|
|
||||||
.clip(RoundedCornerShape(size * 0.12f))
|
|
||||||
.then(
|
|
||||||
if (clickToCopy && fingerprint.isNotEmpty()) {
|
|
||||||
Modifier.clickable {
|
|
||||||
clipboard.setText(AnnotatedString(fingerprint))
|
|
||||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val cellW = this.size.width / 5f
|
|
||||||
val cellH = this.size.height / 5f
|
|
||||||
|
|
||||||
// Background
|
|
||||||
drawRect(color = bg, size = this.size)
|
|
||||||
|
|
||||||
// Foreground cells
|
|
||||||
for (y in 0 until 5) {
|
|
||||||
for (x in 0 until 5) {
|
|
||||||
if (grid[y][x]) {
|
|
||||||
drawRect(
|
|
||||||
color = fg,
|
|
||||||
topLeft = Offset(x * cellW, y * cellH),
|
|
||||||
size = Size(cellW, cellH),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fingerprint text that copies to clipboard on tap.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CopyableFingerprint(
|
|
||||||
fingerprint: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
|
||||||
color: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
androidx.compose.material3.Text(
|
|
||||||
text = fingerprint,
|
|
||||||
style = style,
|
|
||||||
color = color,
|
|
||||||
modifier = modifier.clickable {
|
|
||||||
if (fingerprint.isNotEmpty()) {
|
|
||||||
clipboard.setText(AnnotatedString(fingerprint))
|
|
||||||
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Internal helpers (matching desktop identicon.ts) ---
|
|
||||||
|
|
||||||
private fun hashBytes(hex: String): List<Int> {
|
|
||||||
val clean = hex.filter { it.isLetterOrDigit() }
|
|
||||||
val bytes = mutableListOf<Int>()
|
|
||||||
var i = 0
|
|
||||||
while (i + 1 < clean.length) {
|
|
||||||
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
|
||||||
bytes.add(b)
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
// Pad to at least 16 bytes
|
|
||||||
while (bytes.size < 16) bytes.add(0)
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
|
||||||
val hue1 = bytes[0] * 360f / 256f
|
|
||||||
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
|
||||||
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
|
||||||
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
|
||||||
return bg to fg
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
|
||||||
return (0 until 5).map { y ->
|
|
||||||
val left = (0 until 3).map { x ->
|
|
||||||
val idx = 2 + y * 3 + x
|
|
||||||
bytes[idx % bytes.size] > 128
|
|
||||||
}
|
|
||||||
// Mirror: col3 = col1, col4 = col0
|
|
||||||
listOf(left[0], left[1], left[2], left[1], left[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
|
||||||
val k = { n: Float -> (n + h / 30f) % 12f }
|
|
||||||
val a = s * min(l, 1f - l)
|
|
||||||
val f = { n: Float ->
|
|
||||||
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
|
||||||
}
|
|
||||||
return Color(f(0f), f(8f), f(4f))
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.wzp.ui.settings
|
package com.wzp.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -23,7 +22,6 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
@@ -160,30 +158,20 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Fingerprint display with identicon
|
// Fingerprint display
|
||||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||||
Text(
|
Text(
|
||||||
text = "Fingerprint",
|
text = "Fingerprint",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = fingerprint.chunked(4).joinToString(" "),
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
) {
|
fontFamily = FontFamily.Monospace
|
||||||
com.wzp.ui.components.Identicon(
|
),
|
||||||
fingerprint = draftSeedHex,
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
size = 40.dp,
|
)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
com.wzp.ui.components.CopyableFingerprint(
|
|
||||||
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
@@ -243,51 +231,6 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
|
||||||
val qualityLabels = listOf(
|
|
||||||
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
|
||||||
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
|
||||||
)
|
|
||||||
// Map slider position to JNI profile int:
|
|
||||||
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
|
||||||
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
|
||||||
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
|
||||||
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
|
||||||
val qualityColors = listOf(
|
|
||||||
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
|
||||||
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
|
||||||
)
|
|
||||||
val currentCodec by viewModel.codecChoice.collectAsState()
|
|
||||||
val sliderPos = profileToSlider[currentCodec] ?: 3
|
|
||||||
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
text = "Decode always accepts all codecs",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = qualityLabels[sliderPos],
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = qualityColors[sliderPos]
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = sliderPos.toFloat(),
|
|
||||||
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
|
||||||
valueRange = 0f..7f,
|
|
||||||
steps = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
|
||||||
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
Divider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ wzp-crypto = { workspace = true }
|
|||||||
wzp-transport = { workspace = true }
|
wzp-transport = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
tracing-subscriber = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -28,7 +28,6 @@ libc = "0.2"
|
|||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
tracing-android = "0.2"
|
tracing-android = "0.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
"aarch64-linux-android"
|
"aarch64-linux-android"
|
||||||
};
|
};
|
||||||
let lib_dir =
|
let lib_dir = format!(
|
||||||
format!("{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}");
|
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
|
||||||
|
);
|
||||||
println!("cargo:rustc-link-search=native={lib_dir}");
|
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||||
|
|
||||||
// Copy libc++_shared.so to the jniLibs directory
|
// Copy libc++_shared.so to the jniLibs directory
|
||||||
@@ -81,7 +82,9 @@ fn main() {
|
|||||||
};
|
};
|
||||||
// Try to copy to the Gradle jniLibs directory
|
// Try to copy to the Gradle jniLibs directory
|
||||||
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||||
let jni_dir = format!("{manifest}/../../android/app/src/main/jniLibs/{jni_abi}");
|
let jni_dir = format!(
|
||||||
|
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
|
||||||
|
);
|
||||||
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
||||||
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
||||||
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
||||||
@@ -124,12 +127,7 @@ fn fetch_oboe() -> Option<PathBuf> {
|
|||||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||||
let oboe_dir = out_dir.join("oboe");
|
let oboe_dir = out_dir.join("oboe");
|
||||||
|
|
||||||
if oboe_dir
|
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||||
.join("include")
|
|
||||||
.join("oboe")
|
|
||||||
.join("Oboe.h")
|
|
||||||
.exists()
|
|
||||||
{
|
|
||||||
return Some(oboe_dir);
|
return Some(oboe_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +143,7 @@ fn fetch_oboe() -> Option<PathBuf> {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
Ok(s) if s.success() => {
|
Ok(s) if s.success() => {
|
||||||
if oboe_dir
|
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||||
.join("include")
|
|
||||||
.join("oboe")
|
|
||||||
.join("Oboe.h")
|
|
||||||
.exists()
|
|
||||||
{
|
|
||||||
Some(oboe_dir)
|
Some(oboe_dir)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -326,10 +326,7 @@ pub fn pin_to_big_core() {
|
|||||||
&set,
|
&set,
|
||||||
);
|
);
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
warn!(
|
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
||||||
"sched_setaffinity failed: {}",
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
info!(start, num_cpus, "pinned to big cores");
|
info!(start, num_cpus, "pinned to big cores");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +1,91 @@
|
|||||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
//! Lock-free SPSC ring buffers for audio PCM transfer between
|
||||||
|
//! Kotlin AudioRecord/AudioTrack threads and the Rust engine.
|
||||||
//!
|
//!
|
||||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
//! These use a simple spin-free design: the producer writes and advances
|
||||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
//! a write cursor, the consumer reads and advances a read cursor.
|
||||||
//!
|
//! Both cursors are atomic so no mutex is needed.
|
||||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
|
||||||
//! old buffer data. The reader detects the lap via `available() >
|
|
||||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
|
||||||
//!
|
|
||||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
/// Ring buffer capacity in i16 samples.
|
||||||
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
|
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono.
|
||||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
const RING_CAPACITY: usize = 960 * 10;
|
||||||
const RING_CAPACITY: usize = 16384; // 2^14
|
|
||||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
|
||||||
|
|
||||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||||
pub struct AudioRing {
|
pub struct AudioRing {
|
||||||
buf: Box<[i16]>,
|
buf: Box<[i16; RING_CAPACITY]>,
|
||||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
|
||||||
write_pos: AtomicUsize,
|
write_pos: AtomicUsize,
|
||||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
|
||||||
read_pos: AtomicUsize,
|
read_pos: AtomicUsize,
|
||||||
/// Incremented by reader when it detects it was lapped (overflow).
|
|
||||||
overflow_count: AtomicU64,
|
|
||||||
/// Incremented by reader when ring is empty (underrun).
|
|
||||||
underrun_count: AtomicU64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
// SAFETY: AudioRing is designed for SPSC — one thread writes, one reads.
|
||||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
// The atomics ensure visibility. The buffer itself is never accessed
|
||||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
// from the same index by both threads simultaneously because the
|
||||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
// producer only writes to positions between write_pos and read_pos,
|
||||||
|
// and the consumer only reads from positions between read_pos and write_pos.
|
||||||
unsafe impl Send for AudioRing {}
|
unsafe impl Send for AudioRing {}
|
||||||
unsafe impl Sync for AudioRing {}
|
unsafe impl Sync for AudioRing {}
|
||||||
|
|
||||||
impl AudioRing {
|
impl AudioRing {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
|
||||||
Self {
|
Self {
|
||||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
buf: Box::new([0i16; RING_CAPACITY]),
|
||||||
write_pos: AtomicUsize::new(0),
|
write_pos: AtomicUsize::new(0),
|
||||||
read_pos: AtomicUsize::new(0),
|
read_pos: AtomicUsize::new(0),
|
||||||
overflow_count: AtomicU64::new(0),
|
|
||||||
underrun_count: AtomicU64::new(0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of samples available to read (clamped to capacity).
|
/// Number of samples available to read.
|
||||||
pub fn available(&self) -> usize {
|
pub fn available(&self) -> usize {
|
||||||
let w = self.write_pos.load(Ordering::Acquire);
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
let r = self.read_pos.load(Ordering::Relaxed);
|
let r = self.read_pos.load(Ordering::Acquire);
|
||||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
w.wrapping_sub(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of samples that can be written without overwriting unread data.
|
/// Number of samples that can be written without overwriting.
|
||||||
pub fn free_space(&self) -> usize {
|
pub fn free_space(&self) -> usize {
|
||||||
RING_CAPACITY.saturating_sub(self.available())
|
RING_CAPACITY - self.available()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write samples into the ring. Returns number of samples written.
|
/// Write samples into the ring. Returns number of samples written.
|
||||||
///
|
/// Drops oldest samples if the ring is full.
|
||||||
/// If the ring is full, old data is silently overwritten. The reader
|
|
||||||
/// will detect the lap and self-correct. The writer NEVER touches
|
|
||||||
/// `read_pos` — this is the key invariant that prevents cursor desync.
|
|
||||||
pub fn write(&self, samples: &[i16]) -> usize {
|
pub fn write(&self, samples: &[i16]) -> usize {
|
||||||
let count = samples.len().min(RING_CAPACITY);
|
|
||||||
let w = self.write_pos.load(Ordering::Relaxed);
|
let w = self.write_pos.load(Ordering::Relaxed);
|
||||||
|
let count = samples.len().min(RING_CAPACITY);
|
||||||
|
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
|
let idx = (w + i) % RING_CAPACITY;
|
||||||
|
// SAFETY: We're the only writer, and the reader won't read
|
||||||
|
// past read_pos which we haven't advanced past yet.
|
||||||
unsafe {
|
unsafe {
|
||||||
let ptr = self.buf.as_ptr() as *mut i16;
|
let ptr = self.buf.as_ptr() as *mut i16;
|
||||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
*ptr.add(idx) = samples[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.write_pos
|
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||||
.store(w.wrapping_add(count), Ordering::Release);
|
|
||||||
|
// If we overwrote unread data, advance read_pos
|
||||||
|
if self.available() > RING_CAPACITY {
|
||||||
|
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
|
||||||
|
self.read_pos.store(new_read, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||||
///
|
|
||||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
|
||||||
/// forward to the oldest valid data. This is safe because only the
|
|
||||||
/// reader thread writes `read_pos`.
|
|
||||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||||
let w = self.write_pos.load(Ordering::Acquire);
|
let avail = self.available();
|
||||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
let mut avail = w.wrapping_sub(r);
|
|
||||||
|
|
||||||
// Lap detection: writer has overwritten our unread data.
|
|
||||||
// Snap read_pos forward to oldest valid data in the buffer.
|
|
||||||
if avail > RING_CAPACITY {
|
|
||||||
r = w.wrapping_sub(RING_CAPACITY);
|
|
||||||
avail = RING_CAPACITY;
|
|
||||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = out.len().min(avail);
|
let count = out.len().min(avail);
|
||||||
if count == 0 {
|
|
||||||
if w == r {
|
|
||||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let r = self.read_pos.load(Ordering::Relaxed);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
let idx = (r + i) % RING_CAPACITY;
|
||||||
|
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
|
||||||
}
|
}
|
||||||
|
|
||||||
self.read_pos
|
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||||
.store(r.wrapping_add(count), Ordering::Release);
|
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of overflow events (reader was lapped by writer).
|
|
||||||
pub fn overflow_count(&self) -> u64 {
|
|
||||||
self.overflow_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of underrun events (reader found empty buffer).
|
|
||||||
pub fn underrun_count(&self) -> u64 {
|
|
||||||
self.underrun_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,4 @@ pub enum EngineCommand {
|
|||||||
ForceProfile(QualityProfile),
|
ForceProfile(QualityProfile),
|
||||||
/// Stop the call and shut down the engine.
|
/// Stop the call and shut down the engine.
|
||||||
Stop,
|
Stop,
|
||||||
/// Place a direct call to a fingerprint (requires signal connection).
|
|
||||||
PlaceCall { target_fingerprint: String },
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
AnswerCall {
|
|
||||||
call_id: String,
|
|
||||||
accept_mode: wzp_proto::CallAcceptMode,
|
|
||||||
},
|
|
||||||
/// Reject an incoming direct call.
|
|
||||||
RejectCall { call_id: String },
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,9 @@
|
|||||||
use std::panic;
|
use std::panic;
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
|
|
||||||
use jni::JNIEnv;
|
|
||||||
use jni::objects::{JClass, JObject, JString};
|
use jni::objects::{JClass, JObject, JString};
|
||||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||||
|
use jni::JNIEnv;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use wzp_proto::QualityProfile;
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
@@ -21,26 +21,11 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
|||||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 7 = auto (use relay's chosen profile)
|
|
||||||
const PROFILE_AUTO: jint = 7;
|
|
||||||
|
|
||||||
fn profile_from_int(value: jint) -> QualityProfile {
|
fn profile_from_int(value: jint) -> QualityProfile {
|
||||||
match value {
|
match value {
|
||||||
0 => QualityProfile::GOOD, // Opus 24k
|
1 => QualityProfile::DEGRADED,
|
||||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
2 => QualityProfile::CATASTROPHIC,
|
||||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
_ => QualityProfile::GOOD,
|
||||||
3 => QualityProfile {
|
|
||||||
// Codec2 3.2k
|
|
||||||
codec: wzp_proto::CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
..QualityProfile::GOOD
|
|
||||||
},
|
|
||||||
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
|
||||||
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
|
||||||
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
|
||||||
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,32 +35,10 @@ static INIT_LOGGING: Once = Once::new();
|
|||||||
/// Safe to call multiple times — only the first call takes effect.
|
/// Safe to call multiple times — only the first call takes effect.
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
INIT_LOGGING.call_once(|| {
|
INIT_LOGGING.call_once(|| {
|
||||||
#[cfg(target_os = "android")]
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
{
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||||
// tracing_subscriber::registry() can crash on some Android
|
let _ = tracing_subscriber::registry().with(layer).try_init();
|
||||||
// devices if scudo malloc fails during early initialization.
|
|
||||||
let _ = std::panic::catch_unwind(|| {
|
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
|
||||||
// Filter: INFO for our crates, WARN for everything else.
|
|
||||||
// The jni crate emits VERBOSE logs for every method lookup
|
|
||||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
|
||||||
// and causes the system to kill the app.
|
|
||||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
|
||||||
let _ = tracing_subscriber::registry()
|
|
||||||
.with(layer)
|
|
||||||
.with(filter)
|
|
||||||
.try_init();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
// On non-Android targets tracing-android is unavailable.
|
|
||||||
let _ = tracing_subscriber::fmt::try_init();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,29 +71,13 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
seed_hex_j: JString,
|
seed_hex_j: JString,
|
||||||
token_j: JString,
|
token_j: JString,
|
||||||
alias_j: JString,
|
alias_j: JString,
|
||||||
profile_j: jint,
|
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
let relay_addr: String = env
|
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||||
.get_string(&relay_addr_j)
|
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
|
||||||
.map(|s| s.into())
|
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
||||||
let room: String = env
|
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
||||||
.get_string(&room_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let seed_hex: String = env
|
|
||||||
.get_string(&seed_hex_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let token: String = env
|
|
||||||
.get_string(&token_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let alias: String = env
|
|
||||||
.get_string(&alias_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
let h = unsafe { handle_ref(handle) };
|
||||||
|
|
||||||
@@ -149,15 +96,10 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = CallStartConfig {
|
let config = CallStartConfig {
|
||||||
profile: profile_from_int(profile_j),
|
profile: QualityProfile::GOOD,
|
||||||
auto_profile: profile_j == PROFILE_AUTO,
|
|
||||||
relay_addr,
|
relay_addr,
|
||||||
room,
|
room,
|
||||||
auth_token: if token.is_empty() {
|
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
token.into_bytes()
|
|
||||||
},
|
|
||||||
identity_seed,
|
identity_seed,
|
||||||
alias: if alias.is_empty() { None } else { Some(alias) },
|
alias: if alias.is_empty() { None } else { Some(alias) },
|
||||||
};
|
};
|
||||||
@@ -251,30 +193,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signal a network transport change from the Android ConnectivityManager.
|
|
||||||
///
|
|
||||||
/// `network_type` matches the Rust `NetworkContext` enum:
|
|
||||||
/// 0=WiFi, 1=CellularLte, 2=Cellular5g, 3=Cellular3g, 4=Unknown, 5=None
|
|
||||||
///
|
|
||||||
/// The engine forwards this to the `AdaptiveQualityController` which:
|
|
||||||
/// - Preemptively downgrades one tier on WiFi→cellular
|
|
||||||
/// - Activates a 10-second FEC boost
|
|
||||||
/// - Uses faster downgrade thresholds on cellular
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged(
|
|
||||||
_env: JNIEnv,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
network_type: jint,
|
|
||||||
bandwidth_kbps: jint,
|
|
||||||
) {
|
|
||||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
h.engine
|
|
||||||
.on_network_changed(network_type as u8, bandwidth_kbps as u32);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
|
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
|
||||||
/// pcm is a Java short[] array.
|
/// pcm is a Java short[] array.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
@@ -291,6 +209,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let mut buf = vec![0i16; len];
|
let mut buf = vec![0i16; len];
|
||||||
|
// GetShortArrayRegion copies Java array into our buffer
|
||||||
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
|
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -324,58 +243,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
|
|||||||
result.unwrap_or(0)
|
result.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
|
|
||||||
/// The ByteBuffer must contain little-endian i16 samples.
|
|
||||||
/// Called from the AudioRecord capture thread.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
|
|
||||||
env: JNIEnv,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
buffer: jni::objects::JByteBuffer,
|
|
||||||
sample_count: jint,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let ptr = env
|
|
||||||
.get_direct_buffer_address(&buffer)
|
|
||||||
.unwrap_or(std::ptr::null_mut());
|
|
||||||
if ptr.is_null() || sample_count <= 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let samples =
|
|
||||||
unsafe { std::slice::from_raw_parts(ptr as *const i16, sample_count as usize) };
|
|
||||||
h.engine.write_audio(samples) as jint
|
|
||||||
}));
|
|
||||||
result.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
|
|
||||||
/// The ByteBuffer will be filled with little-endian i16 samples.
|
|
||||||
/// Called from the AudioTrack playout thread.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
|
|
||||||
env: JNIEnv,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
buffer: jni::objects::JByteBuffer,
|
|
||||||
max_samples: jint,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let ptr = env
|
|
||||||
.get_direct_buffer_address(&buffer)
|
|
||||||
.unwrap_or(std::ptr::null_mut());
|
|
||||||
if ptr.is_null() || max_samples <= 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let samples =
|
|
||||||
unsafe { std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize) };
|
|
||||||
h.engine.read_audio(samples) as jint
|
|
||||||
}));
|
|
||||||
result.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
@@ -387,155 +254,3 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|||||||
drop(h);
|
drop(h);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ping a relay server — instance method, requires engine handle.
|
|
||||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
relay_j: JString,
|
|
||||||
) -> jstring {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let relay: String = env
|
|
||||||
.get_string(&relay_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
match h.engine.ping_relay(&relay) {
|
|
||||||
Ok(json) => Some(json),
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
let json = match result {
|
|
||||||
Ok(Some(s)) => s,
|
|
||||||
_ => return JObject::null().into_raw(),
|
|
||||||
};
|
|
||||||
env.new_string(&json)
|
|
||||||
.map(|s| s.into_raw())
|
|
||||||
.unwrap_or(JObject::null().into_raw())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Direct calling JNI functions ──
|
|
||||||
|
|
||||||
/// Start persistent signaling connection to relay for direct calls.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
relay_addr_j: JString,
|
|
||||||
seed_hex_j: JString,
|
|
||||||
token_j: JString,
|
|
||||||
alias_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let relay_addr: String = env
|
|
||||||
.get_string(&relay_addr_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let seed_hex: String = env
|
|
||||||
.get_string(&seed_hex_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let token: String = env
|
|
||||||
.get_string(&token_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let alias: String = env
|
|
||||||
.get_string(&alias_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
h.engine.start_signaling(
|
|
||||||
&relay_addr,
|
|
||||||
&seed_hex,
|
|
||||||
if token.is_empty() { None } else { Some(&token) },
|
|
||||||
if alias.is_empty() { None } else { Some(&alias) },
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("start_signaling failed: {e}");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
error!("start_signaling panicked");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
target_fp_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let target: String = env
|
|
||||||
.get_string(&target_fp_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
h.engine.place_call(&target)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("place_call failed: {e}");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
error!("place_call panicked");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
call_id_j: JString,
|
|
||||||
mode: jint,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let call_id: String = env
|
|
||||||
.get_string(&call_id_j)
|
|
||||||
.map(|s| s.into())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let accept_mode = match mode {
|
|
||||||
0 => wzp_proto::CallAcceptMode::Reject,
|
|
||||||
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
|
||||||
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
|
||||||
};
|
|
||||||
h.engine.answer_call(&call_id, accept_mode)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("answer_call failed: {e}");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
error!("answer_call panicked");
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,24 +8,11 @@
|
|||||||
//!
|
//!
|
||||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
||||||
//! allowing `cargo check` and unit tests on the host.
|
//! 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_android;
|
||||||
pub mod audio_ring;
|
pub mod audio_ring;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod jni_bridge;
|
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod jni_bridge;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller
|
|||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::quality::AdaptiveQualityController;
|
use wzp_proto::quality::AdaptiveQualityController;
|
||||||
use wzp_proto::traits::QualityController;
|
|
||||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||||
|
use wzp_proto::traits::QualityController;
|
||||||
use wzp_proto::{MediaPacket, QualityProfile};
|
use wzp_proto::{MediaPacket, QualityProfile};
|
||||||
|
|
||||||
use crate::audio_android::FRAME_SAMPLES;
|
use crate::audio_android::FRAME_SAMPLES;
|
||||||
@@ -58,12 +58,14 @@ pub struct Pipeline {
|
|||||||
impl Pipeline {
|
impl Pipeline {
|
||||||
/// Create a new pipeline configured for the given quality profile.
|
/// Create a new pipeline configured for the given quality profile.
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
||||||
let encoder =
|
let encoder = AdaptiveEncoder::new(profile)
|
||||||
AdaptiveEncoder::new(profile).map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
||||||
let decoder =
|
let decoder = AdaptiveDecoder::new(profile)
|
||||||
AdaptiveDecoder::new(profile).map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
||||||
let fec_encoder = RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
let fec_encoder =
|
||||||
let fec_decoder = RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
||||||
|
let fec_decoder =
|
||||||
|
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
||||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
||||||
let quality_ctrl = AdaptiveQualityController::new();
|
let quality_ctrl = AdaptiveQualityController::new();
|
||||||
|
|
||||||
@@ -134,11 +136,11 @@ impl Pipeline {
|
|||||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
||||||
// Feed FEC symbols if present
|
// Feed FEC symbols if present
|
||||||
let header = &packet.header;
|
let header = &packet.header;
|
||||||
if header.fec_block != 0 {
|
if header.fec_block != 0 || header.fec_symbol != 0 {
|
||||||
let is_repair = header.is_repair();
|
let is_repair = header.is_repair;
|
||||||
if let Err(e) = self.fec_decoder.add_symbol(
|
if let Err(e) = self.fec_decoder.add_symbol(
|
||||||
header.fec_block,
|
header.fec_block,
|
||||||
header.fec_block >> 8,
|
header.fec_symbol,
|
||||||
is_repair,
|
is_repair,
|
||||||
&packet.payload,
|
&packet.payload,
|
||||||
) {
|
) {
|
||||||
@@ -209,7 +211,10 @@ impl Pipeline {
|
|||||||
///
|
///
|
||||||
/// Returns a new profile if a tier transition occurred.
|
/// Returns a new profile if a tier transition occurred.
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub fn observe_quality(&mut self, report: &wzp_proto::QualityReport) -> Option<QualityProfile> {
|
pub fn observe_quality(
|
||||||
|
&mut self,
|
||||||
|
report: &wzp_proto::QualityReport,
|
||||||
|
) -> Option<QualityProfile> {
|
||||||
let new_profile = self.quality_ctrl.observe(report);
|
let new_profile = self.quality_ctrl.observe(report);
|
||||||
if let Some(ref profile) = new_profile {
|
if let Some(ref profile) = new_profile {
|
||||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
if let Err(e) = self.encoder.set_profile(*profile) {
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ pub enum CallState {
|
|||||||
Active,
|
Active,
|
||||||
Reconnecting,
|
Reconnecting,
|
||||||
Closed,
|
Closed,
|
||||||
/// Connected to relay signal channel, registered for direct calls.
|
|
||||||
Registered,
|
|
||||||
/// Outgoing call ringing on callee's side.
|
|
||||||
Ringing,
|
|
||||||
/// Incoming call received, waiting for user to accept/reject.
|
|
||||||
IncomingCall,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for CallState {
|
impl serde::Serialize for CallState {
|
||||||
@@ -27,9 +21,6 @@ impl serde::Serialize for CallState {
|
|||||||
CallState::Active => 2,
|
CallState::Active => 2,
|
||||||
CallState::Reconnecting => 3,
|
CallState::Reconnecting => 3,
|
||||||
CallState::Closed => 4,
|
CallState::Closed => 4,
|
||||||
CallState::Registered => 5,
|
|
||||||
CallState::Ringing => 6,
|
|
||||||
CallState::IncomingCall => 7,
|
|
||||||
};
|
};
|
||||||
serializer.serialize_u8(n)
|
serializer.serialize_u8(n)
|
||||||
}
|
}
|
||||||
@@ -58,46 +49,14 @@ pub struct CallStats {
|
|||||||
pub frames_decoded: u64,
|
pub frames_decoded: u64,
|
||||||
/// Number of playout underruns (buffer empty when audio needed).
|
/// Number of playout underruns (buffer empty when audio needed).
|
||||||
pub underruns: u64,
|
pub underruns: u64,
|
||||||
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
|
/// Frames recovered by FEC.
|
||||||
/// RaptorQ per Phase 2).
|
|
||||||
pub fec_recovered: u64,
|
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).
|
|
||||||
pub playout_underruns: u64,
|
|
||||||
/// Capture ring overflow count.
|
|
||||||
pub capture_overflows: u64,
|
|
||||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||||
pub audio_level: u32,
|
pub audio_level: u32,
|
||||||
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
|
||||||
pub current_codec: String,
|
|
||||||
/// Last seen incoming codec from other participants.
|
|
||||||
pub peer_codec: String,
|
|
||||||
/// Whether auto quality mode is active.
|
|
||||||
pub auto_mode: bool,
|
|
||||||
/// Number of participants in the room (from last RoomUpdate).
|
/// Number of participants in the room (from last RoomUpdate).
|
||||||
pub room_participant_count: u32,
|
pub room_participant_count: u32,
|
||||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||||
pub room_participants: Vec<RoomMember>,
|
pub room_participants: Vec<RoomMember>,
|
||||||
/// SAS code for verbal verification (None if not in a call).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sas_code: Option<u32>,
|
|
||||||
/// Incoming call info (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_call_id: Option<String>,
|
|
||||||
/// Fingerprint of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_fp: Option<String>,
|
|
||||||
/// Alias of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_alias: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room member entry, serialized into the stats JSON.
|
/// A room member entry, serialized into the stats JSON.
|
||||||
@@ -105,5 +64,4 @@ pub struct CallStats {
|
|||||||
pub struct RoomMember {
|
pub struct RoomMember {
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
pub relay_label: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ wzp-codec = { workspace = true }
|
|||||||
wzp-fec = { workspace = true }
|
wzp-fec = { workspace = true }
|
||||||
wzp-crypto = { workspace = true }
|
wzp-crypto = { workspace = true }
|
||||||
wzp-transport = { workspace = true }
|
wzp-transport = { workspace = true }
|
||||||
wzp-video = { path = "../wzp-video" }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
@@ -22,93 +21,20 @@ anyhow = "1"
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
ratatui = "0.29"
|
|
||||||
crossterm = "0.28"
|
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
libc = "0.2"
|
|
||||||
# Phase 5.5 — LAN host-candidate ICE: enumerate local network
|
|
||||||
# interface addresses for inclusion in DirectCallOffer/Answer so
|
|
||||||
# peers on the same LAN can direct-connect without NAT hairpinning
|
|
||||||
# through the WAN reflex addr (which many consumer NATs, including
|
|
||||||
# MikroTik's default masquerade, don't support).
|
|
||||||
if-addrs = "0.13"
|
|
||||||
rand = { workspace = true }
|
|
||||||
socket2 = "0.5"
|
|
||||||
|
|
||||||
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
|
||||||
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
|
||||||
# pulling in a crate that can only link against Apple frameworks.
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
coreaudio-rs = { version = "0.11", optional = true }
|
coreaudio-rs = { version = "0.11", optional = true }
|
||||||
|
libc = "0.2"
|
||||||
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
|
|
||||||
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
|
|
||||||
# only the audio + COM subfeatures we need — the crate is organized as
|
|
||||||
# a massive optional-feature tree, so enabling just these keeps compile
|
|
||||||
# times reasonable (~5s for these features vs ~60s for the full crate).
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
windows = { version = "0.58", optional = true, features = [
|
|
||||||
"Win32_Foundation",
|
|
||||||
"Win32_Media_Audio",
|
|
||||||
"Win32_Security",
|
|
||||||
"Win32_System_Com",
|
|
||||||
"Win32_System_Com_StructuredStorage",
|
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_Variant",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
|
|
||||||
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
|
|
||||||
# webrtc-audio-processing` crate, which links against Debian's
|
|
||||||
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
|
|
||||||
#
|
|
||||||
# Note: we attempted the 2.x line with its `bundled` sub-feature first
|
|
||||||
# (which would give us AEC3 instead of AEC2), but both the crates.io
|
|
||||||
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
|
|
||||||
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
|
|
||||||
# --reconfigure unconditionally even on first-run empty build dirs,
|
|
||||||
# causing the bundled build to fail with "Directory does not contain a
|
|
||||||
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
|
|
||||||
# this entirely by linking the apt-provided library. AEC2 is older than
|
|
||||||
# AEC3 but still the same algorithm family — this is what PulseAudio's
|
|
||||||
# module-echo-cancel and PipeWire's filter-chain use by default on
|
|
||||||
# current Debian-family distros.
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
webrtc-audio-processing = { version = "0.3", optional = true }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
|
vpio = ["coreaudio-rs"]
|
||||||
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
|
|
||||||
# module is also #[cfg(target_os = "macos")] in lib.rs).
|
|
||||||
vpio = ["dep:coreaudio-rs"]
|
|
||||||
# windows-aec enables a direct WASAPI capture backend that opens the
|
|
||||||
# microphone under AudioCategory_Communications, turning on Windows's
|
|
||||||
# OS-level communications audio processing (AEC + noise suppression +
|
|
||||||
# AGC). The `windows` dep is itself target-gated to Windows above, so
|
|
||||||
# enabling this feature on non-Windows targets is a no-op (the
|
|
||||||
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
|
|
||||||
windows-aec = ["dep:windows"]
|
|
||||||
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
|
|
||||||
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
|
|
||||||
# Teams) in-process, using the playback PCM as the reference signal for
|
|
||||||
# echo cancellation. The webrtc-audio-processing dep is target-gated to
|
|
||||||
# Linux above, so enabling this feature on non-Linux targets is a no-op
|
|
||||||
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
|
|
||||||
# lib.rs).
|
|
||||||
linux-aec = ["dep:webrtc-audio-processing"]
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
path = "src/cli.rs"
|
path = "src/cli.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "wzp-analyzer"
|
|
||||||
path = "src/analyzer.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-bench"
|
name = "wzp-bench"
|
||||||
path = "src/bench_cli.rs"
|
path = "src/bench_cli.rs"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,10 @@
|
|||||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, anyhow};
|
use anyhow::{anyhow, Context};
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -78,10 +78,7 @@ impl AudioCapture {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
eprintln!(
|
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
||||||
"[audio] capture callback: {} f32 samples",
|
|
||||||
data.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||||
@@ -106,10 +103,7 @@ impl AudioCapture {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
eprintln!(
|
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
||||||
"[audio] capture callback: {} i16 samples",
|
|
||||||
data.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ring.write(data);
|
ring.write(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,537 +0,0 @@
|
|||||||
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
|
|
||||||
//! Processing Module (AEC3 + noise suppression + high-pass filter).
|
|
||||||
//!
|
|
||||||
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
|
|
||||||
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
|
|
||||||
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
|
|
||||||
//! identically on ALSA / PulseAudio / PipeWire systems.
|
|
||||||
//!
|
|
||||||
//! ## Architecture
|
|
||||||
//!
|
|
||||||
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
|
|
||||||
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
|
|
||||||
//! mono):
|
|
||||||
//!
|
|
||||||
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
|
|
||||||
//! output thread, but wraps each chunk in a call to
|
|
||||||
//! `Processor::process_render_frame` **before** handing it to CPAL. That
|
|
||||||
//! gives APM an authoritative reference of exactly what's going out to
|
|
||||||
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
|
|
||||||
//! what to cancel when it sees echo in the capture stream.
|
|
||||||
//!
|
|
||||||
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
|
|
||||||
//! input thread, and runs `Processor::process_capture_frame` on each
|
|
||||||
//! incoming mic chunk **in place** before pushing it into the ring
|
|
||||||
//! buffer. The AEC subtracts the echo using the render reference it
|
|
||||||
//! saw on the playback side.
|
|
||||||
//!
|
|
||||||
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
|
|
||||||
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
|
|
||||||
//! frame is split into two 480-sample halves, APM is called twice, and the
|
|
||||||
//! halves are stitched back together.
|
|
||||||
//!
|
|
||||||
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
|
|
||||||
//! before the call and f32 → i16 after (with clamping on the return path).
|
|
||||||
//!
|
|
||||||
//! ## Stream delay
|
|
||||||
//!
|
|
||||||
//! AEC needs to know roughly how long it takes between a sample being passed
|
|
||||||
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
|
|
||||||
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
|
|
||||||
//! → CPAL capture. AEC3's internal estimator tracks this within a window
|
|
||||||
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
|
|
||||||
//! starting point for typical Linux audio stacks; the delay estimator does
|
|
||||||
//! the fine-tuning automatically.
|
|
||||||
//!
|
|
||||||
//! ## Thread safety
|
|
||||||
//!
|
|
||||||
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
|
|
||||||
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
|
|
||||||
//! needs a `Mutex` around it for cross-thread sharing. The capture and
|
|
||||||
//! playback threads each acquire the lock briefly (sub-millisecond per
|
|
||||||
//! 10 ms frame) so contention is minimal at our frame rates.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
use anyhow::{Context, anyhow};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|
||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use webrtc_audio_processing::{
|
|
||||||
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
|
||||||
NUM_SAMPLES_PER_FRAME, NoiseSuppression, NoiseSuppressionLevel, Processor,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
|
|
||||||
/// Imported from the webrtc-audio-processing crate so we can't drift out
|
|
||||||
/// of sync with whatever sample rate / frame length the C++ lib is using.
|
|
||||||
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
|
|
||||||
const APM_NUM_CHANNELS: usize = 1;
|
|
||||||
/// Round-trip delay hint passed to APM; the estimator refines from here.
|
|
||||||
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const STREAM_DELAY_MS: i32 = 60;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared APM instance
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Module-level lazily-initialized APM. Shared between capture and playback
|
|
||||||
/// so they operate on the same echo-cancellation state — the render frames
|
|
||||||
/// pushed by playback are what the capture path subtracts from the mic input.
|
|
||||||
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
|
|
||||||
/// process_capture_frame and process_render_frame.
|
|
||||||
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
|
||||||
if let Some(p) = PROCESSOR.get() {
|
|
||||||
return Ok(p.clone());
|
|
||||||
}
|
|
||||||
let init_config = InitializationConfig {
|
|
||||||
num_capture_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
num_render_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut processor =
|
|
||||||
Processor::new(&init_config).map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
echo_cancellation: Some(EchoCancellation {
|
|
||||||
suppression_level: EchoCancellationSuppressionLevel::High,
|
|
||||||
stream_delay_ms: Some(STREAM_DELAY_MS),
|
|
||||||
enable_delay_agnostic: true,
|
|
||||||
enable_extended_filter: true,
|
|
||||||
}),
|
|
||||||
noise_suppression: Some(NoiseSuppression {
|
|
||||||
suppression_level: NoiseSuppressionLevel::High,
|
|
||||||
}),
|
|
||||||
enable_high_pass_filter: true,
|
|
||||||
// AGC left off for now — it can fight the Opus encoder's own gain
|
|
||||||
// staging and the adaptive-quality controller. Add later if users
|
|
||||||
// report low mic levels.
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
processor.set_config(config);
|
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(processor));
|
|
||||||
let _ = PROCESSOR.set(arc.clone());
|
|
||||||
info!(
|
|
||||||
stream_delay_ms = STREAM_DELAY_MS,
|
|
||||||
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
|
|
||||||
);
|
|
||||||
Ok(arc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers: i16 ↔ f32 and APM frame processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn i16_to_f32(s: i16) -> f32 {
|
|
||||||
s as f32 / 32768.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn f32_to_i16(s: f32) -> i16 {
|
|
||||||
(s.clamp(-1.0, 1.0) * 32767.0) as i16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
|
|
||||||
/// Splits into two 10 ms halves because APM is strict about frame size.
|
|
||||||
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
|
|
||||||
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_render_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_render_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in render path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
|
|
||||||
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
|
|
||||||
/// results back into the caller's buffer. Briefly holds the Mutex once
|
|
||||||
/// per 10 ms half.
|
|
||||||
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_capture_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_capture_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in capture path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (i, d) in half.iter_mut().enumerate() {
|
|
||||||
*d = f32_to_i16(buf[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Microphone capture with WebRTC AEC3 applied in place before the codec
|
|
||||||
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
/// downstream code doesn't change.
|
|
||||||
pub struct LinuxAecCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecCapture {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
// Eagerly init the APM so the playback side can find it already
|
|
||||||
// configured, and so init errors surface on the caller thread
|
|
||||||
// instead of silently failing inside the capture thread.
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
|
||||||
let apm_capture = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_input_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default input audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC input stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Leftover buffer for when CPAL gives us partial frames.
|
|
||||||
// We need exactly 960-sample chunks to feed APM.
|
|
||||||
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.reserve(data.len());
|
|
||||||
for &s in data {
|
|
||||||
lv.push(f32_to_i16(s));
|
|
||||||
}
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.extend_from_slice(data);
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC input stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC capture started (AEC3 active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull whole 960-sample frames out of the leftover buffer, run them through
|
|
||||||
/// APM's capture-side processing, and push to the ring. Leaves any partial
|
|
||||||
/// sub-960 remainder in `leftover` for the next callback.
|
|
||||||
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
while leftover.len() >= FRAME_SAMPLES {
|
|
||||||
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
|
|
||||||
process_capture_frame_20ms(apm, &mut frame);
|
|
||||||
ring.write(&frame);
|
|
||||||
leftover.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Speaker playback with a render-side tee: each frame written to CPAL is
|
|
||||||
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
|
|
||||||
/// reference signal. This is the "tee the playback ring" approach (Zoom,
|
|
||||||
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
|
|
||||||
/// PipeWire monitor sources.
|
|
||||||
pub struct LinuxAecPlayback {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecPlayback {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_clone = running.clone();
|
|
||||||
let apm_render = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-playback-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_output_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default output audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC output stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Same 960-sample batching approach as the capture side:
|
|
||||||
// CPAL may ask for N samples in a callback where N doesn't
|
|
||||||
// divide 960. We accumulate partial frames in a Vec and
|
|
||||||
// feed APM as soon as we have a whole 20 ms frame.
|
|
||||||
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_f32(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_i16(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC output stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC playback started (render tee active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecPlayback {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_i16(
|
|
||||||
data: &mut [i16],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let read = ring.read(data);
|
|
||||||
for s in &mut data[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
tee_render_samples(data, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_f32(
|
|
||||||
data: &mut [f32],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let mut tmp = vec![0i16; data.len()];
|
|
||||||
let read = ring.read(&mut tmp);
|
|
||||||
for s in &mut tmp[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
|
|
||||||
*d = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
tee_render_samples(&tmp, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
|
|
||||||
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
|
|
||||||
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
|
|
||||||
let mut lv = carry.lock().unwrap();
|
|
||||||
lv.extend_from_slice(samples);
|
|
||||||
while lv.len() >= FRAME_SAMPLES {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
|
|
||||||
push_render_frame_20ms(apm, &frame);
|
|
||||||
lv.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
|
|
||||||
// independent — each backend file is a self-contained unit)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_input_configs()
|
|
||||||
.context("failed to query input configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_output_configs()
|
|
||||||
.context("failed to query output configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
||||||
//! This is the same engine FaceTime and other Apple apps use.
|
//! This is the same engine FaceTime and other Apple apps use.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||||
@@ -28,60 +28,6 @@ pub struct VpioAudio {
|
|||||||
playout_ring: Arc<AudioRing>,
|
playout_ring: Arc<AudioRing>,
|
||||||
_audio_unit: AudioUnit,
|
_audio_unit: AudioUnit,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
stats: Arc<VpioStats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render/capture counters for diagnosing macOS VoiceProcessingIO.
|
|
||||||
///
|
|
||||||
/// These are atomics because CoreAudio callbacks run on realtime audio
|
|
||||||
/// threads. The Tauri engine polls snapshots from a normal async task and
|
|
||||||
/// emits them to the call debug log.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct VpioStats {
|
|
||||||
capture_callbacks: AtomicU64,
|
|
||||||
capture_samples: AtomicU64,
|
|
||||||
render_callbacks: AtomicU64,
|
|
||||||
render_requested_samples: AtomicU64,
|
|
||||||
render_read_samples: AtomicU64,
|
|
||||||
render_underrun_callbacks: AtomicU64,
|
|
||||||
render_nonzero_callbacks: AtomicU64,
|
|
||||||
render_last_requested: AtomicU64,
|
|
||||||
render_last_read: AtomicU64,
|
|
||||||
render_last_rms: AtomicU64,
|
|
||||||
render_last_ring_available: AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct VpioStatsSnapshot {
|
|
||||||
pub capture_callbacks: u64,
|
|
||||||
pub capture_samples: u64,
|
|
||||||
pub render_callbacks: u64,
|
|
||||||
pub render_requested_samples: u64,
|
|
||||||
pub render_read_samples: u64,
|
|
||||||
pub render_underrun_callbacks: u64,
|
|
||||||
pub render_nonzero_callbacks: u64,
|
|
||||||
pub render_last_requested: u64,
|
|
||||||
pub render_last_read: u64,
|
|
||||||
pub render_last_rms: u64,
|
|
||||||
pub render_last_ring_available: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VpioStats {
|
|
||||||
pub fn snapshot(&self) -> VpioStatsSnapshot {
|
|
||||||
VpioStatsSnapshot {
|
|
||||||
capture_callbacks: self.capture_callbacks.load(Ordering::Relaxed),
|
|
||||||
capture_samples: self.capture_samples.load(Ordering::Relaxed),
|
|
||||||
render_callbacks: self.render_callbacks.load(Ordering::Relaxed),
|
|
||||||
render_requested_samples: self.render_requested_samples.load(Ordering::Relaxed),
|
|
||||||
render_read_samples: self.render_read_samples.load(Ordering::Relaxed),
|
|
||||||
render_underrun_callbacks: self.render_underrun_callbacks.load(Ordering::Relaxed),
|
|
||||||
render_nonzero_callbacks: self.render_nonzero_callbacks.load(Ordering::Relaxed),
|
|
||||||
render_last_requested: self.render_last_requested.load(Ordering::Relaxed),
|
|
||||||
render_last_read: self.render_last_read.load(Ordering::Relaxed),
|
|
||||||
render_last_rms: self.render_last_rms.load(Ordering::Relaxed),
|
|
||||||
render_last_ring_available: self.render_last_ring_available.load(Ordering::Relaxed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VpioAudio {
|
impl VpioAudio {
|
||||||
@@ -90,7 +36,6 @@ impl VpioAudio {
|
|||||||
let capture_ring = Arc::new(AudioRing::new());
|
let capture_ring = Arc::new(AudioRing::new());
|
||||||
let playout_ring = Arc::new(AudioRing::new());
|
let playout_ring = Arc::new(AudioRing::new());
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let stats = Arc::new(VpioStats::default());
|
|
||||||
|
|
||||||
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
||||||
.context("failed to create VoiceProcessingIO audio unit")?;
|
.context("failed to create VoiceProcessingIO audio unit")?;
|
||||||
@@ -153,7 +98,6 @@ impl VpioAudio {
|
|||||||
// Set up input callback (mic capture with AEC applied)
|
// Set up input callback (mic capture with AEC applied)
|
||||||
let cap_ring = capture_ring.clone();
|
let cap_ring = capture_ring.clone();
|
||||||
let cap_running = running.clone();
|
let cap_running = running.clone();
|
||||||
let cap_stats = stats.clone();
|
|
||||||
let logged = Arc::new(AtomicBool::new(false));
|
let logged = Arc::new(AtomicBool::new(false));
|
||||||
au.set_input_callback(
|
au.set_input_callback(
|
||||||
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
@@ -162,10 +106,6 @@ impl VpioAudio {
|
|||||||
}
|
}
|
||||||
let mut buffers = args.data.channels();
|
let mut buffers = args.data.channels();
|
||||||
if let Some(ch) = buffers.next() {
|
if let Some(ch) = buffers.next() {
|
||||||
cap_stats.capture_callbacks.fetch_add(1, Ordering::Relaxed);
|
|
||||||
cap_stats
|
|
||||||
.capture_samples
|
|
||||||
.fetch_add(ch.len() as u64, Ordering::Relaxed);
|
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
||||||
}
|
}
|
||||||
@@ -185,80 +125,28 @@ impl VpioAudio {
|
|||||||
|
|
||||||
// Set up output callback (speaker playback — AEC uses this as reference)
|
// Set up output callback (speaker playback — AEC uses this as reference)
|
||||||
let play_ring = playout_ring.clone();
|
let play_ring = playout_ring.clone();
|
||||||
let render_stats = stats.clone();
|
|
||||||
let logged_render = Arc::new(AtomicBool::new(false));
|
|
||||||
au.set_render_callback(
|
au.set_render_callback(
|
||||||
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
let mut buffers = args.data.channels_mut();
|
let mut buffers = args.data.channels_mut();
|
||||||
if let Some(ch) = buffers.next() {
|
if let Some(ch) = buffers.next() {
|
||||||
render_stats
|
|
||||||
.render_callbacks
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
render_stats
|
|
||||||
.render_requested_samples
|
|
||||||
.fetch_add(ch.len() as u64, Ordering::Relaxed);
|
|
||||||
render_stats
|
|
||||||
.render_last_requested
|
|
||||||
.store(ch.len() as u64, Ordering::Relaxed);
|
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
let mut total_read = 0usize;
|
|
||||||
let mut sum_sq = 0u64;
|
|
||||||
let ring_available = play_ring.available();
|
|
||||||
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
||||||
let n = chunk.len();
|
let n = chunk.len();
|
||||||
let read = play_ring.read(&mut tmp[..n]);
|
let read = play_ring.read(&mut tmp[..n]);
|
||||||
total_read += read;
|
|
||||||
for i in 0..read {
|
for i in 0..read {
|
||||||
let s = tmp[i] as i64;
|
|
||||||
sum_sq = sum_sq.saturating_add((s * s) as u64);
|
|
||||||
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
||||||
}
|
}
|
||||||
for i in read..n {
|
for i in read..n {
|
||||||
chunk[i] = 0.0;
|
chunk[i] = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render_stats
|
|
||||||
.render_read_samples
|
|
||||||
.fetch_add(total_read as u64, Ordering::Relaxed);
|
|
||||||
render_stats
|
|
||||||
.render_last_read
|
|
||||||
.store(total_read as u64, Ordering::Relaxed);
|
|
||||||
render_stats
|
|
||||||
.render_last_ring_available
|
|
||||||
.store(ring_available as u64, Ordering::Relaxed);
|
|
||||||
if total_read == 0 {
|
|
||||||
render_stats
|
|
||||||
.render_underrun_callbacks
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let rms = if total_read > 0 {
|
|
||||||
((sum_sq as f64 / total_read as f64).sqrt()) as u64
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
render_stats.render_last_rms.store(rms, Ordering::Relaxed);
|
|
||||||
if rms > 0 {
|
|
||||||
render_stats
|
|
||||||
.render_nonzero_callbacks
|
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
if !logged_render.swap(true, Ordering::Relaxed) {
|
|
||||||
eprintln!(
|
|
||||||
"[vpio] render callback: {} f32 samples, ring_available={}, ring_read={}, rms={}",
|
|
||||||
ch.len(),
|
|
||||||
ring_available,
|
|
||||||
total_read,
|
|
||||||
rms
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.context("failed to set render callback")?;
|
.context("failed to set render callback")?;
|
||||||
|
|
||||||
au.initialize()
|
au.initialize().context("failed to initialize VoiceProcessingIO")?;
|
||||||
.context("failed to initialize VoiceProcessingIO")?;
|
|
||||||
au.start().context("failed to start VoiceProcessingIO")?;
|
au.start().context("failed to start VoiceProcessingIO")?;
|
||||||
|
|
||||||
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
||||||
@@ -268,7 +156,6 @@ impl VpioAudio {
|
|||||||
playout_ring,
|
playout_ring,
|
||||||
_audio_unit: au,
|
_audio_unit: au,
|
||||||
running,
|
running,
|
||||||
stats,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +167,6 @@ impl VpioAudio {
|
|||||||
&self.playout_ring
|
&self.playout_ring
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stats(&self) -> Arc<VpioStats> {
|
|
||||||
self.stats.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
self.running.store(false, Ordering::Relaxed);
|
self.running.store(false, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
|
|
||||||
//!
|
|
||||||
//! Bypasses CPAL and opens the default capture endpoint directly via
|
|
||||||
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
|
|
||||||
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
|
|
||||||
//! the switch that tells Windows "this is a VoIP call" — the OS then
|
|
||||||
//! enables its communications audio processing chain (AEC, noise
|
|
||||||
//! suppression, automatic gain control) for the stream. AEC operates at
|
|
||||||
//! the OS level using the currently-playing audio as the reference
|
|
||||||
//! signal, so it cancels echo from our CPAL playback (and any other app's
|
|
||||||
//! audio) without us having to plumb a reference signal ourselves.
|
|
||||||
//!
|
|
||||||
//! Platform: Windows only, compiled only when the `windows-aec` feature
|
|
||||||
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
|
||||||
//! `AudioCapture`.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
|
|
||||||
use anyhow::{Context, anyhow};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use windows::Win32::Foundation::{BOOL, CloseHandle, WAIT_OBJECT_0};
|
|
||||||
use windows::Win32::Media::Audio::{
|
|
||||||
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
|
||||||
AudioCategory_Communications, AudioClientProperties, IAudioCaptureClient, IAudioClient,
|
|
||||||
IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator, WAVE_FORMAT_PCM, WAVEFORMATEX,
|
|
||||||
eCapture, eCommunications,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Com::{
|
|
||||||
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
|
|
||||||
use windows::core::{GUID, Interface};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono. Matches the rest of the audio pipeline.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Microphone capture via WASAPI with Windows's communications AEC enabled.
|
|
||||||
///
|
|
||||||
/// The WASAPI capture stream runs on a dedicated OS thread. This handle is
|
|
||||||
/// `Send + Sync`. Dropping it stops the stream and joins the thread.
|
|
||||||
pub struct WasapiAudioCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasapiAudioCapture {
|
|
||||||
/// Open the default communications microphone, enable OS AEC, and start
|
|
||||||
/// streaming PCM into a lock-free ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns only after the capture thread has successfully initialized
|
|
||||||
/// the stream, or propagates the error back to the caller.
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
|
||||||
let ring_cb = ring.clone();
|
|
||||||
let running_cb = running.clone();
|
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-wasapi".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = unsafe { capture_thread_main(ring_cb, running_cb.clone(), &init_tx) };
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!("wasapi capture thread exited with error: {e}");
|
|
||||||
// If we failed before signaling init, signal now so the
|
|
||||||
// caller unblocks. Double-send is harmless (channel is
|
|
||||||
// bounded to 1 and we only hit the second send path on
|
|
||||||
// late errors).
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.context("failed to spawn WASAPI capture thread")?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("WASAPI capture thread exited before signaling init"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
ring,
|
|
||||||
running,
|
|
||||||
thread: Some(thread),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to the capture ring buffer for direct polling.
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop capturing.
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WasapiAudioCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
if let Some(handle) = self.thread.take() {
|
|
||||||
// Join best-effort. The thread loop polls `running` every 200ms
|
|
||||||
// via a short WaitForSingleObject timeout, so it should exit
|
|
||||||
// within ~200ms of `stop()`.
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WASAPI thread entry point — everything below this line runs on the
|
|
||||||
// dedicated wzp-audio-capture-wasapi thread.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
unsafe fn capture_thread_main(
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
init_tx: &std::sync::mpsc::SyncSender<Result<(), String>>,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
// COM init for the capture thread. MULTITHREADED because we're not
|
|
||||||
// running a message pump. Must be balanced by CoUninitialize on exit.
|
|
||||||
CoInitializeEx(None, COINIT_MULTITHREADED)
|
|
||||||
.ok()
|
|
||||||
.context("CoInitializeEx failed")?;
|
|
||||||
|
|
||||||
// Use a guard struct so CoUninitialize runs even on early returns.
|
|
||||||
struct ComGuard;
|
|
||||||
impl Drop for ComGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe { CoUninitialize() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _com_guard = ComGuard;
|
|
||||||
|
|
||||||
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
|
||||||
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
|
||||||
|
|
||||||
// eCommunications role (not eConsole) — this picks the device the user
|
|
||||||
// has designated for communications in Sound Settings. It's the one
|
|
||||||
// Windows's AEC is actually tuned for and the one Teams/Zoom use.
|
|
||||||
let device = enumerator
|
|
||||||
.GetDefaultAudioEndpoint(eCapture, eCommunications)
|
|
||||||
.context("GetDefaultAudioEndpoint(eCapture, eCommunications) failed")?;
|
|
||||||
|
|
||||||
if let Ok(name) = device_name(&device) {
|
|
||||||
info!(device = %name, "opening WASAPI communications capture endpoint");
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_client: IAudioClient = device
|
|
||||||
.Activate(CLSCTX_ALL, None)
|
|
||||||
.context("IMMDevice::Activate(IAudioClient) failed")?;
|
|
||||||
|
|
||||||
// IAudioClient2 exposes SetClientProperties, which is the ONLY way to
|
|
||||||
// set AudioCategory_Communications pre-Initialize. Calling it on the
|
|
||||||
// base IAudioClient would not compile, and setting it after Initialize
|
|
||||||
// is a no-op.
|
|
||||||
let audio_client2: IAudioClient2 = audio_client
|
|
||||||
.cast()
|
|
||||||
.context("QueryInterface IAudioClient2 failed")?;
|
|
||||||
|
|
||||||
let mut props = AudioClientProperties {
|
|
||||||
cbSize: std::mem::size_of::<AudioClientProperties>() as u32,
|
|
||||||
bIsOffload: BOOL(0),
|
|
||||||
eCategory: AudioCategory_Communications,
|
|
||||||
// 0 = AUDCLNT_STREAMOPTIONS_NONE. The `windows` crate doesn't
|
|
||||||
// export the enum constant in all versions, so use 0 directly.
|
|
||||||
Options: Default::default(),
|
|
||||||
};
|
|
||||||
audio_client2
|
|
||||||
.SetClientProperties(&mut props as *mut _)
|
|
||||||
.context("SetClientProperties(AudioCategory_Communications) failed")?;
|
|
||||||
|
|
||||||
// Request 48 kHz mono i16 directly. AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
// tells Windows to do any needed format conversion inside the audio
|
|
||||||
// engine rather than rejecting our format. SRC_DEFAULT_QUALITY picks
|
|
||||||
// the standard Windows resampler quality (fine for voice).
|
|
||||||
let wave_format = WAVEFORMATEX {
|
|
||||||
wFormatTag: WAVE_FORMAT_PCM as u16,
|
|
||||||
nChannels: 1,
|
|
||||||
nSamplesPerSec: 48_000,
|
|
||||||
nAvgBytesPerSec: 48_000 * 2, // 1 ch * 2 bytes/sample * 48000 Hz
|
|
||||||
nBlockAlign: 2, // 1 ch * 2 bytes/sample
|
|
||||||
wBitsPerSample: 16,
|
|
||||||
cbSize: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1,000,000 hns = 100 ms buffer (hns = 100-nanosecond units). Windows
|
|
||||||
// treats this as the minimum; the engine may give us a larger one.
|
|
||||||
const BUFFER_DURATION_HNS: i64 = 1_000_000;
|
|
||||||
|
|
||||||
audio_client
|
|
||||||
.Initialize(
|
|
||||||
AUDCLNT_SHAREMODE_SHARED,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
|
|
||||||
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
|
||||||
BUFFER_DURATION_HNS,
|
|
||||||
0,
|
|
||||||
&wave_format,
|
|
||||||
Some(&GUID::zeroed()),
|
|
||||||
)
|
|
||||||
.context(
|
|
||||||
"IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Event-driven capture: Windows signals this handle each time a new
|
|
||||||
// audio packet is available. We wait on it from the loop below.
|
|
||||||
let event = CreateEventW(None, false, false, None).context("CreateEventW failed")?;
|
|
||||||
audio_client
|
|
||||||
.SetEventHandle(event)
|
|
||||||
.context("SetEventHandle failed")?;
|
|
||||||
|
|
||||||
let capture_client: IAudioCaptureClient = audio_client
|
|
||||||
.GetService()
|
|
||||||
.context("IAudioClient::GetService(IAudioCaptureClient) failed")?;
|
|
||||||
|
|
||||||
audio_client.Start().context("IAudioClient::Start failed")?;
|
|
||||||
|
|
||||||
// Signal to the parent thread that init succeeded before entering the
|
|
||||||
// hot loop. From this point on, errors get logged but don't propagate
|
|
||||||
// back to the caller (they'd just cause the ring buffer to stop
|
|
||||||
// filling, which the main thread detects as underruns).
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("WASAPI communications-mode capture started with OS AEC enabled");
|
|
||||||
|
|
||||||
let mut logged_first_packet = false;
|
|
||||||
|
|
||||||
// Main capture loop. Exit when `running` goes false (from Drop or an
|
|
||||||
// explicit stop() call).
|
|
||||||
while running.load(Ordering::Relaxed) {
|
|
||||||
// 200 ms timeout so we check `running` regularly even if the audio
|
|
||||||
// engine stops delivering packets (e.g. device unplugged).
|
|
||||||
let wait = WaitForSingleObject(event, 200);
|
|
||||||
if wait.0 != WAIT_OBJECT_0.0 {
|
|
||||||
// Timeout or failure — just loop and re-check running.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain all available packets. Windows may have queued more than
|
|
||||||
// one since we were last scheduled.
|
|
||||||
loop {
|
|
||||||
let packet_length = match capture_client.GetNextPacketSize() {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("GetNextPacketSize failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if packet_length == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buffer_ptr: *mut u8 = std::ptr::null_mut();
|
|
||||||
let mut num_frames: u32 = 0;
|
|
||||||
let mut flags: u32 = 0;
|
|
||||||
let mut device_position: u64 = 0;
|
|
||||||
let mut qpc_position: u64 = 0;
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.GetBuffer(
|
|
||||||
&mut buffer_ptr,
|
|
||||||
&mut num_frames,
|
|
||||||
&mut flags,
|
|
||||||
Some(&mut device_position),
|
|
||||||
Some(&mut qpc_position),
|
|
||||||
) {
|
|
||||||
warn!("GetBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if num_frames > 0 && !buffer_ptr.is_null() {
|
|
||||||
if !logged_first_packet {
|
|
||||||
info!(
|
|
||||||
frames = num_frames,
|
|
||||||
flags, "WASAPI capture: first packet received"
|
|
||||||
);
|
|
||||||
logged_first_packet = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because we asked for 48 kHz mono i16, each frame is
|
|
||||||
// exactly one i16. Windows's AUTOCONVERTPCM handles the
|
|
||||||
// conversion from whatever the engine mix format is.
|
|
||||||
let samples =
|
|
||||||
std::slice::from_raw_parts(buffer_ptr as *const i16, num_frames as usize);
|
|
||||||
ring.write(samples);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.ReleaseBuffer(num_frames) {
|
|
||||||
warn!("ReleaseBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("WASAPI capture thread stopping");
|
|
||||||
let _ = audio_client.Stop();
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
// _com_guard drops here, calling CoUninitialize.
|
|
||||||
|
|
||||||
// Silence INFINITE unused-import warning — it's referenced by the
|
|
||||||
// `windows` crate's WaitForSingleObject alternative but we use the
|
|
||||||
// 200 ms timeout variant instead. Explicit suppression for clarity.
|
|
||||||
let _ = INFINITE;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Best-effort device ID string for logging. Grabbing the friendly name via
|
|
||||||
/// PKEY_Device_FriendlyName requires IPropertyStore + PROPVARIANT plumbing
|
|
||||||
/// that's far more ceremony than a log line justifies; the ID is already
|
|
||||||
/// sufficient to confirm we opened the right endpoint.
|
|
||||||
///
|
|
||||||
/// Rust 2024 edition's `unsafe_op_in_unsafe_fn` lint requires explicit
|
|
||||||
/// `unsafe { ... }` blocks inside `unsafe fn` bodies for each unsafe call,
|
|
||||||
/// even though the whole function is already marked unsafe.
|
|
||||||
unsafe fn device_name(
|
|
||||||
device: &windows::Win32::Media::Audio::IMMDevice,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
|
||||||
let id = unsafe { device.GetId() }.context("IMMDevice::GetId failed")?;
|
|
||||||
Ok(unsafe { id.to_string() }.unwrap_or_else(|_| "<non-utf16>".to_string()))
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use wzp_crypto::ChaChaSession;
|
use wzp_crypto::ChaChaSession;
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::QualityProfile;
|
|
||||||
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
|
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
|
||||||
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
use crate::call::{CallConfig, CallDecoder, CallEncoder};
|
use crate::call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
|||||||
let mut total_repair_bytes = 0usize;
|
let mut total_repair_bytes = 0usize;
|
||||||
|
|
||||||
for block_idx in 0..num_blocks {
|
for block_idx in 0..num_blocks {
|
||||||
let block_id = (block_idx % 65536) as u16;
|
let block_id = (block_idx % 256) as u8;
|
||||||
|
|
||||||
// Create fresh encoder and decoder for each block
|
// Create fresh encoder and decoder for each block
|
||||||
let mut fec_enc = RaptorQFecEncoder::new(frames_per_block, 256);
|
let mut fec_enc = RaptorQFecEncoder::new(frames_per_block, 256);
|
||||||
@@ -170,7 +170,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
|||||||
|
|
||||||
// Collect all symbols: source + repair
|
// Collect all symbols: source + repair
|
||||||
struct Symbol {
|
struct Symbol {
|
||||||
index: u16,
|
index: u8,
|
||||||
is_repair: bool,
|
is_repair: bool,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
|||||||
// For add_symbol we need to provide the raw data; the decoder pads internally
|
// For add_symbol we need to provide the raw data; the decoder pads internally
|
||||||
total_source_bytes += sym.len();
|
total_source_bytes += sym.len();
|
||||||
all_symbols.push(Symbol {
|
all_symbols.push(Symbol {
|
||||||
index: i as u16,
|
index: i as u8,
|
||||||
is_repair: false,
|
is_repair: false,
|
||||||
data: sym.clone(),
|
data: sym.clone(),
|
||||||
});
|
});
|
||||||
@@ -201,13 +201,9 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
|
|||||||
// Deterministic shuffle for reproducibility using a simple seed
|
// Deterministic shuffle for reproducibility using a simple seed
|
||||||
// We use a basic Fisher-Yates with a fixed-per-block seed
|
// We use a basic Fisher-Yates with a fixed-per-block seed
|
||||||
let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
|
let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
|
||||||
let mut seed = (block_idx as u64)
|
let mut seed = (block_idx as u64).wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||||
.wrapping_mul(6364136223846793005)
|
|
||||||
.wrapping_add(1);
|
|
||||||
for i in (1..indices.len()).rev() {
|
for i in (1..indices.len()).rev() {
|
||||||
seed = seed
|
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||||
.wrapping_mul(6364136223846793005)
|
|
||||||
.wrapping_add(1442695040888963407);
|
|
||||||
let j = (seed >> 33) as usize % (i + 1);
|
let j = (seed >> 33) as usize % (i + 1);
|
||||||
indices.swap(i, j);
|
indices.swap(i, j);
|
||||||
}
|
}
|
||||||
@@ -263,36 +259,17 @@ pub fn bench_encrypt_decrypt() -> CryptoResult {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Build valid v2 MediaHeader bytes — encrypt/decrypt now derive nonces from
|
let header = b"bench-header";
|
||||||
// header.seq and require a parseable MediaHeader (WIRE_SIZE bytes minimum).
|
|
||||||
use wzp_proto::packet::MediaHeader;
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
let mut total_bytes: usize = 0;
|
let mut total_bytes: usize = 0;
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
for (i, payload) in payloads.iter().enumerate() {
|
for payload in &payloads {
|
||||||
let hdr = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq: i as u32,
|
|
||||||
timestamp: (i as u32).wrapping_mul(20),
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
|
||||||
hdr.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let mut ciphertext = Vec::with_capacity(payload.len() + 16);
|
let mut ciphertext = Vec::with_capacity(payload.len() + 16);
|
||||||
encryptor
|
encryptor.encrypt(header, payload, &mut ciphertext).unwrap();
|
||||||
.encrypt(&header_bytes, payload, &mut ciphertext)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut plaintext = Vec::with_capacity(payload.len());
|
let mut plaintext = Vec::with_capacity(payload.len());
|
||||||
decryptor
|
decryptor
|
||||||
.decrypt(&header_bytes, &ciphertext, &mut plaintext)
|
.decrypt(header, &ciphertext, &mut plaintext)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
total_bytes += payload.len();
|
total_bytes += payload.len();
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ fn run_codec() {
|
|||||||
print_header("Codec Roundtrip (Opus 24kbps)");
|
print_header("Codec Roundtrip (Opus 24kbps)");
|
||||||
let r = bench::bench_codec_roundtrip();
|
let r = bench::bench_codec_roundtrip();
|
||||||
print_row("Frames", &format!("{}", r.frames));
|
print_row("Frames", &format!("{}", r.frames));
|
||||||
print_row(
|
print_row("Encode total", &format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0));
|
||||||
"Encode total",
|
print_row("Decode total", &format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0));
|
||||||
&format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0),
|
|
||||||
);
|
|
||||||
print_row(
|
|
||||||
"Decode total",
|
|
||||||
&format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0),
|
|
||||||
);
|
|
||||||
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
|
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
|
||||||
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
|
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
|
||||||
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
|
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
|
||||||
@@ -47,10 +41,7 @@ fn run_fec(loss_pct: f32) {
|
|||||||
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
|
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
|
||||||
print_row("Source bytes", &format!("{}", r.total_source_bytes));
|
print_row("Source bytes", &format!("{}", r.total_source_bytes));
|
||||||
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
|
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
|
||||||
print_row(
|
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||||
"Total time",
|
|
||||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
|
||||||
);
|
|
||||||
print_footer();
|
print_footer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +49,7 @@ fn run_crypto() {
|
|||||||
print_header("Crypto (ChaCha20-Poly1305)");
|
print_header("Crypto (ChaCha20-Poly1305)");
|
||||||
let r = bench::bench_encrypt_decrypt();
|
let r = bench::bench_encrypt_decrypt();
|
||||||
print_row("Packets", &format!("{}", r.packets));
|
print_row("Packets", &format!("{}", r.packets));
|
||||||
print_row(
|
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0));
|
||||||
"Total time",
|
|
||||||
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
|
|
||||||
);
|
|
||||||
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
|
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
|
||||||
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
|
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
|
||||||
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
|
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
|
||||||
@@ -72,18 +60,9 @@ fn run_pipeline() {
|
|||||||
print_header("Full Pipeline (E2E)");
|
print_header("Full Pipeline (E2E)");
|
||||||
let r = bench::bench_full_pipeline();
|
let r = bench::bench_full_pipeline();
|
||||||
print_row("Frames", &format!("{}", r.frames));
|
print_row("Frames", &format!("{}", r.frames));
|
||||||
print_row(
|
print_row("Encode pipeline", &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0));
|
||||||
"Encode pipeline",
|
print_row("Decode pipeline", &format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0));
|
||||||
&format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0),
|
print_row("Avg E2E latency", &format!("{:.1} us/frame", r.avg_e2e_latency_us));
|
||||||
);
|
|
||||||
print_row(
|
|
||||||
"Decode pipeline",
|
|
||||||
&format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0),
|
|
||||||
);
|
|
||||||
print_row(
|
|
||||||
"Avg E2E latency",
|
|
||||||
&format!("{:.1} us/frame", r.avg_e2e_latency_us),
|
|
||||||
);
|
|
||||||
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
|
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
|
||||||
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
|
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
|
||||||
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));
|
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
//! Birthday attack for hard NAT traversal.
|
|
||||||
//!
|
|
||||||
//! When both peers are behind symmetric NATs with random port
|
|
||||||
//! allocation, standard hole-punching fails because neither side
|
|
||||||
//! can predict the other's external port. This module implements
|
|
||||||
//! the birthday-paradox approach:
|
|
||||||
//!
|
|
||||||
//! 1. **Acceptor** opens N sockets, STUN-probes each to learn
|
|
||||||
//! their external ports, reports them to the Dialer.
|
|
||||||
//! 2. **Dialer** sprays QUIC connect attempts to the Acceptor's
|
|
||||||
//! reported ports + random ports on the Acceptor's IP.
|
|
||||||
//! 3. Birthday paradox: with N=64 ports and M=256 probes across
|
|
||||||
//! 65536 ports, collision probability is high.
|
|
||||||
//!
|
|
||||||
//! In practice, the Acceptor's STUN-probed ports are known
|
|
||||||
//! exactly (not random), so the Dialer targets them first —
|
|
||||||
//! making this more like "spray-and-pray with a hit list" than
|
|
||||||
//! a pure birthday attack.
|
|
||||||
|
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::stun;
|
|
||||||
|
|
||||||
/// Configuration for the birthday attack.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BirthdayConfig {
|
|
||||||
/// Number of sockets the Acceptor opens (default: 32).
|
|
||||||
/// Each socket gets STUN-probed to learn its external port.
|
|
||||||
/// More = higher chance of collision, but more resource usage.
|
|
||||||
pub acceptor_ports: u16,
|
|
||||||
/// Number of QUIC connect attempts the Dialer makes (default: 128).
|
|
||||||
/// Spread across the Acceptor's known ports + random ports.
|
|
||||||
pub dialer_probes: u16,
|
|
||||||
/// Rate limit: ms between consecutive probes (default: 20ms = 50/s).
|
|
||||||
pub probe_interval_ms: u16,
|
|
||||||
/// Overall timeout for the birthday attack phase.
|
|
||||||
pub timeout: Duration,
|
|
||||||
/// STUN config for probing external ports.
|
|
||||||
pub stun_config: stun::StunConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BirthdayConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
acceptor_ports: 32,
|
|
||||||
dialer_probes: 128,
|
|
||||||
probe_interval_ms: 20,
|
|
||||||
timeout: Duration::from_secs(8),
|
|
||||||
stun_config: stun::StunConfig {
|
|
||||||
servers: vec!["stun.l.google.com:19302".into()],
|
|
||||||
timeout: Duration::from_secs(2),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of the Acceptor's port-opening phase.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct AcceptorPorts {
|
|
||||||
/// External IP (from STUN).
|
|
||||||
pub external_ip: Option<Ipv4Addr>,
|
|
||||||
/// List of (local_port, external_port) for each opened socket.
|
|
||||||
pub ports: Vec<PortMapping>,
|
|
||||||
/// How many sockets we attempted to open.
|
|
||||||
pub attempted: u16,
|
|
||||||
/// How many STUN probes succeeded.
|
|
||||||
pub succeeded: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single socket's local↔external port mapping.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct PortMapping {
|
|
||||||
pub local_port: u16,
|
|
||||||
pub external_port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open N sockets and STUN-probe each to discover external ports.
|
|
||||||
///
|
|
||||||
/// Returns the set of known external ports that the Dialer should
|
|
||||||
/// target. Each socket stays open (bound) so the NAT mapping
|
|
||||||
/// remains active until the returned `PortGuard` is dropped.
|
|
||||||
///
|
|
||||||
/// The sockets are returned so the caller can keep them alive
|
|
||||||
/// during the attack. Dropping them closes the NAT pinholes.
|
|
||||||
pub async fn open_acceptor_ports(
|
|
||||||
config: &BirthdayConfig,
|
|
||||||
) -> (AcceptorPorts, Vec<tokio::net::UdpSocket>) {
|
|
||||||
let mut sockets = Vec::new();
|
|
||||||
let mut mappings = Vec::new();
|
|
||||||
let mut external_ip: Option<Ipv4Addr> = None;
|
|
||||||
let mut succeeded: u16 = 0;
|
|
||||||
|
|
||||||
let stun_server = match config.stun_config.servers.first() {
|
|
||||||
Some(s) => match stun::resolve_stun_server(s).await {
|
|
||||||
Ok(a) => Some(a),
|
|
||||||
Err(_) => None,
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
for _ in 0..config.acceptor_ports {
|
|
||||||
// Bind to random port
|
|
||||||
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let local_port = match sock.local_addr() {
|
|
||||||
Ok(a) => a.port(),
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// STUN probe to learn external port
|
|
||||||
if let Some(stun_addr) = stun_server {
|
|
||||||
match stun::stun_reflect(&sock, stun_addr, config.stun_config.timeout).await {
|
|
||||||
Ok(ext_addr) => {
|
|
||||||
if external_ip.is_none() {
|
|
||||||
if let std::net::IpAddr::V4(ip) = ext_addr.ip() {
|
|
||||||
external_ip = Some(ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mappings.push(PortMapping {
|
|
||||||
local_port,
|
|
||||||
external_port: ext_addr.port(),
|
|
||||||
});
|
|
||||||
succeeded += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(local_port, error = %e, "birthday: STUN probe failed for socket");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sockets.push(sock);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
attempted = config.acceptor_ports,
|
|
||||||
succeeded,
|
|
||||||
external_ip = ?external_ip,
|
|
||||||
"birthday: acceptor ports opened"
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = AcceptorPorts {
|
|
||||||
external_ip,
|
|
||||||
ports: mappings,
|
|
||||||
attempted: config.acceptor_ports,
|
|
||||||
succeeded,
|
|
||||||
};
|
|
||||||
|
|
||||||
(result, sockets)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate the list of target addresses for the Dialer to spray.
|
|
||||||
///
|
|
||||||
/// Priority order:
|
|
||||||
/// 1. Acceptor's known external ports (from STUN probes) — highest hit rate
|
|
||||||
/// 2. Random ports on the Acceptor's IP — birthday paradox fill
|
|
||||||
pub fn generate_dialer_targets(
|
|
||||||
acceptor_ip: Ipv4Addr,
|
|
||||||
known_ports: &[u16],
|
|
||||||
total_probes: u16,
|
|
||||||
) -> Vec<SocketAddr> {
|
|
||||||
let mut targets = Vec::with_capacity(total_probes as usize);
|
|
||||||
|
|
||||||
// First: all known ports (guaranteed targets)
|
|
||||||
for &port in known_ports {
|
|
||||||
targets.push(SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill remaining with random ports (birthday attack)
|
|
||||||
let remaining = total_probes.saturating_sub(known_ports.len() as u16);
|
|
||||||
if remaining > 0 {
|
|
||||||
use rand::Rng;
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
for _ in 0..remaining {
|
|
||||||
let port = rng.gen_range(1024..=65535u16);
|
|
||||||
let addr = SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port);
|
|
||||||
if !targets.contains(&addr) {
|
|
||||||
targets.push(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
targets
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the Dialer side of the birthday attack.
|
|
||||||
///
|
|
||||||
/// Sprays QUIC connection attempts at the target addresses.
|
|
||||||
/// Returns the first successful connection, or None on timeout.
|
|
||||||
pub async fn spray_dialer(
|
|
||||||
endpoint: &wzp_transport::Endpoint,
|
|
||||||
targets: &[SocketAddr],
|
|
||||||
call_sni: &str,
|
|
||||||
probe_interval: Duration,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Option<wzp_transport::QuinnTransport> {
|
|
||||||
let start = Instant::now();
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
target_count = targets.len(),
|
|
||||||
interval_ms = probe_interval.as_millis(),
|
|
||||||
timeout_s = timeout.as_secs(),
|
|
||||||
"birthday: dialer starting spray"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spray connects with rate limiting
|
|
||||||
for (idx, &target) in targets.iter().enumerate() {
|
|
||||||
if start.elapsed() >= timeout {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ep = endpoint.clone();
|
|
||||||
let sni = call_sni.to_string();
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
set.spawn(async move {
|
|
||||||
let result = wzp_transport::connect(&ep, target, &sni, client_cfg).await;
|
|
||||||
(idx, target, result)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rate limit — don't blast the NAT
|
|
||||||
if idx < targets.len() - 1 {
|
|
||||||
tokio::time::sleep(probe_interval).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
spawned = set.len(),
|
|
||||||
elapsed_ms = start.elapsed().as_millis(),
|
|
||||||
"birthday: all probes spawned, waiting for first success"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for first success or all failures
|
|
||||||
let deadline = start + timeout;
|
|
||||||
while let Some(join_res) = tokio::select! {
|
|
||||||
r = set.join_next() => r,
|
|
||||||
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => None,
|
|
||||||
} {
|
|
||||||
match join_res {
|
|
||||||
Ok((idx, target, Ok(conn))) => {
|
|
||||||
tracing::info!(
|
|
||||||
idx,
|
|
||||||
%target,
|
|
||||||
remote = %conn.remote_address(),
|
|
||||||
elapsed_ms = start.elapsed().as_millis(),
|
|
||||||
"birthday: HIT! QUIC handshake succeeded"
|
|
||||||
);
|
|
||||||
set.abort_all();
|
|
||||||
return Some(wzp_transport::QuinnTransport::new(conn));
|
|
||||||
}
|
|
||||||
Ok((idx, target, Err(e))) => {
|
|
||||||
tracing::debug!(
|
|
||||||
idx,
|
|
||||||
%target,
|
|
||||||
error = %e,
|
|
||||||
"birthday: probe failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
elapsed_ms = start.elapsed().as_millis(),
|
|
||||||
"birthday: all probes failed or timed out"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_targets_known_ports_first() {
|
|
||||||
let ip = Ipv4Addr::new(203, 0, 113, 5);
|
|
||||||
let known = vec![10000, 10001, 10002];
|
|
||||||
let targets = generate_dialer_targets(ip, &known, 10);
|
|
||||||
|
|
||||||
// Known ports should be first
|
|
||||||
assert_eq!(targets[0].port(), 10000);
|
|
||||||
assert_eq!(targets[1].port(), 10001);
|
|
||||||
assert_eq!(targets[2].port(), 10002);
|
|
||||||
// Rest are random
|
|
||||||
assert!(targets.len() <= 10);
|
|
||||||
// All target the right IP
|
|
||||||
assert!(targets.iter().all(|a| a.ip() == std::net::IpAddr::V4(ip)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_targets_no_known_all_random() {
|
|
||||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
|
||||||
let targets = generate_dialer_targets(ip, &[], 50);
|
|
||||||
assert!(!targets.is_empty());
|
|
||||||
assert!(targets.len() <= 50);
|
|
||||||
// All ports in valid range
|
|
||||||
assert!(targets.iter().all(|a| a.port() >= 1024));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_targets_more_known_than_total() {
|
|
||||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
|
||||||
let known: Vec<u16> = (10000..10100).collect();
|
|
||||||
let targets = generate_dialer_targets(ip, &known, 50);
|
|
||||||
// All 100 known ports included even though total=50
|
|
||||||
assert_eq!(targets.len(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_targets_dedup() {
|
|
||||||
let ip = Ipv4Addr::new(10, 0, 0, 1);
|
|
||||||
let targets = generate_dialer_targets(ip, &[], 100);
|
|
||||||
// No duplicates
|
|
||||||
let mut sorted = targets.clone();
|
|
||||||
sorted.sort();
|
|
||||||
sorted.dedup();
|
|
||||||
assert_eq!(sorted.len(), targets.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_config() {
|
|
||||||
let cfg = BirthdayConfig::default();
|
|
||||||
assert_eq!(cfg.acceptor_ports, 32);
|
|
||||||
assert_eq!(cfg.dialer_probes, 128);
|
|
||||||
assert!(cfg.timeout.as_secs() > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acceptor_ports_serializes() {
|
|
||||||
let result = AcceptorPorts {
|
|
||||||
external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)),
|
|
||||||
ports: vec![PortMapping {
|
|
||||||
local_port: 12345,
|
|
||||||
external_port: 54321,
|
|
||||||
}],
|
|
||||||
attempted: 32,
|
|
||||||
succeeded: 1,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
|
||||||
assert!(json.contains("54321"));
|
|
||||||
assert!(json.contains("203.0.113.5"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -144,7 +144,7 @@ pub async fn run_drift_test(
|
|||||||
}
|
}
|
||||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||||
Ok(Ok(Some(pkt))) => {
|
Ok(Ok(Some(pkt))) => {
|
||||||
let is_repair = pkt.header.is_repair();
|
let is_repair = pkt.header.is_repair;
|
||||||
decoder.ingest(pkt);
|
decoder.ingest(pkt);
|
||||||
if !is_repair {
|
if !is_repair {
|
||||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||||
@@ -180,7 +180,7 @@ pub async fn run_drift_test(
|
|||||||
while Instant::now() < drain_deadline {
|
while Instant::now() < drain_deadline {
|
||||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||||
Ok(Ok(Some(pkt))) => {
|
Ok(Ok(Some(pkt))) => {
|
||||||
let is_repair = pkt.header.is_repair();
|
let is_repair = pkt.header.is_repair;
|
||||||
decoder.ingest(pkt);
|
decoder.ingest(pkt);
|
||||||
if !is_repair {
|
if !is_repair {
|
||||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||||
@@ -234,10 +234,7 @@ pub fn print_drift_report(result: &DriftResult) {
|
|||||||
println!();
|
println!();
|
||||||
println!("Expected duration: {} ms", result.expected_duration_ms);
|
println!("Expected duration: {} ms", result.expected_duration_ms);
|
||||||
println!("Actual duration: {} ms", result.actual_duration_ms);
|
println!("Actual duration: {} ms", result.actual_duration_ms);
|
||||||
println!(
|
println!("Drift: {} ms ({:+.4}%)", result.drift_ms, result.drift_pct);
|
||||||
"Drift: {} ms ({:+.4}%)",
|
|
||||||
result.drift_ms, result.drift_pct
|
|
||||||
);
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Interpretation
|
// Interpretation
|
||||||
@@ -249,15 +246,9 @@ pub fn print_drift_report(result: &DriftResult) {
|
|||||||
} else if abs_drift < 20 {
|
} else if abs_drift < 20 {
|
||||||
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
|
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
|
||||||
} else if abs_drift < 100 {
|
} else if abs_drift < 100 {
|
||||||
println!(
|
println!("Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.", abs_drift);
|
||||||
"Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.",
|
|
||||||
abs_drift
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!("Result: POOR -- significant drift ({} ms). Investigate clock sources.", abs_drift);
|
||||||
"Result: POOR -- significant drift ({} ms). Investigate clock sources.",
|
|
||||||
abs_drift
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,976 +0,0 @@
|
|||||||
//! Phase 3.5 — dual-path QUIC connect race for P2P hole-punching.
|
|
||||||
//!
|
|
||||||
//! When both peers advertised reflex addrs in the
|
|
||||||
//! DirectCallOffer/Answer flow, the relay cross-wires them into
|
|
||||||
//! `CallSetup.peer_direct_addr`. This module races a direct QUIC
|
|
||||||
//! handshake against the existing relay dial and returns whichever
|
|
||||||
//! completes first — with automatic drop of the loser via
|
|
||||||
//! `tokio::select!`.
|
|
||||||
//!
|
|
||||||
//! Role determination is deterministic and symmetric
|
|
||||||
//! (`wzp_client::reflect::determine_role`): whichever peer has the
|
|
||||||
//! lexicographically smaller reflex addr becomes the **Acceptor**
|
|
||||||
//! (listens on a server-capable endpoint), the other becomes the
|
|
||||||
//! **Dialer** (dials the peer's addr). Because the rule is
|
|
||||||
//! identical on both sides, the Acceptor's inbound QUIC session
|
|
||||||
//! and the Dialer's outbound are the SAME connection — no
|
|
||||||
//! negotiation needed, no two-conns-per-call confusion.
|
|
||||||
//!
|
|
||||||
//! Timeout policy:
|
|
||||||
//! - Direct path: 2s from the start of `race`. Cone-NAT hole-punch
|
|
||||||
//! typically completes in < 500ms on a LAN; 2s gives us tolerance
|
|
||||||
//! for a single QUIC Initial retry on unreliable networks.
|
|
||||||
//! - Relay path: 10s (existing behavior elsewhere in the codebase).
|
|
||||||
//! - Overall: `tokio::select!` returns as soon as either succeeds.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::reflect::Role;
|
|
||||||
use wzp_transport::QuinnTransport;
|
|
||||||
|
|
||||||
/// Which path won the race. Used by the `connect` command for
|
|
||||||
/// logging + (in the future) metrics.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum WinningPath {
|
|
||||||
Direct,
|
|
||||||
Relay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Diagnostic info for a single candidate dial attempt.
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
|
||||||
pub struct CandidateDiag {
|
|
||||||
pub index: usize,
|
|
||||||
pub addr: String,
|
|
||||||
pub result: String, // "ok", "skipped:ipv6", "error:..."
|
|
||||||
pub elapsed_ms: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 6: the race now returns BOTH transports (when available)
|
|
||||||
/// so the connect command can negotiate with the peer before
|
|
||||||
/// committing. The negotiation decides which transport to use
|
|
||||||
/// based on whether BOTH sides report `direct_ok = true`.
|
|
||||||
pub struct RaceResult {
|
|
||||||
/// The direct P2P transport, if the direct path completed.
|
|
||||||
/// `None` if the direct dial/accept failed or timed out.
|
|
||||||
pub direct_transport: Option<Arc<QuinnTransport>>,
|
|
||||||
/// The relay transport, if the relay dial completed.
|
|
||||||
/// `None` if the relay dial failed (shouldn't happen in
|
|
||||||
/// practice since relay is always reachable).
|
|
||||||
pub relay_transport: Option<Arc<QuinnTransport>>,
|
|
||||||
/// Which future completed first in the local race.
|
|
||||||
/// Informational — the actual path used is decided by the
|
|
||||||
/// Phase 6 negotiation after both sides exchange reports.
|
|
||||||
pub local_winner: WinningPath,
|
|
||||||
/// Per-candidate diagnostic info for debugging.
|
|
||||||
pub candidate_diags: Vec<CandidateDiag>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt a direct QUIC connection to the peer in parallel with
|
|
||||||
/// the relay dial and return the winning `QuinnTransport`.
|
|
||||||
///
|
|
||||||
/// `role` selects the direction of the direct attempt:
|
|
||||||
/// - `Role::Acceptor` creates a server-capable endpoint and waits
|
|
||||||
/// for the peer to dial in.
|
|
||||||
/// - `Role::Dialer` creates a client-only endpoint and dials
|
|
||||||
/// `peer_direct_addr`.
|
|
||||||
///
|
|
||||||
/// The relay path is always attempted in parallel as a fallback so
|
|
||||||
/// the race ALWAYS produces a working transport unless both paths
|
|
||||||
/// genuinely fail (network partition). Returns
|
|
||||||
/// `Err(anyhow::anyhow!(...))` if both paths fail within the
|
|
||||||
/// timeout.
|
|
||||||
/// Phase 5.5 candidate bundle — full ICE-ish candidate list for
|
|
||||||
/// the peer. The race tries them all in parallel alongside the
|
|
||||||
/// relay path. At minimum this should contain the peer's
|
|
||||||
/// server-reflexive address; `local_addrs` carries LAN host
|
|
||||||
/// candidates gathered from their physical interfaces.
|
|
||||||
///
|
|
||||||
/// Empty is valid: the D-role has nothing to dial and the race
|
|
||||||
/// reduces to "relay only" + (if A-role) accepting on the
|
|
||||||
/// shared endpoint.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PeerCandidates {
|
|
||||||
/// Peer's server-reflexive address (Phase 3). `None` if the
|
|
||||||
/// peer didn't advertise one.
|
|
||||||
pub reflexive: Option<SocketAddr>,
|
|
||||||
/// Peer's LAN host addresses (Phase 5.5). Tried first on
|
|
||||||
/// same-LAN pairs — direct dials to these bypass the NAT
|
|
||||||
/// entirely.
|
|
||||||
pub local: Vec<SocketAddr>,
|
|
||||||
/// Phase 8 (Tailscale-inspired): peer's port-mapped external
|
|
||||||
/// address from NAT-PMP/PCP/UPnP. When the router supports
|
|
||||||
/// port mapping, this gives a stable external address even
|
|
||||||
/// behind symmetric NATs.
|
|
||||||
pub mapped: Option<SocketAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PeerCandidates {
|
|
||||||
/// Flatten into the list of addrs the D-role should dial.
|
|
||||||
/// Order: LAN host candidates first (fastest when they
|
|
||||||
/// work), then port-mapped (stable even behind symmetric
|
|
||||||
/// NATs), then reflexive (covers the non-LAN case).
|
|
||||||
pub fn dial_order(&self) -> Vec<SocketAddr> {
|
|
||||||
let mut out = Vec::with_capacity(self.local.len() + 2);
|
|
||||||
out.extend(self.local.iter().copied());
|
|
||||||
// Port-mapped address goes before reflexive — it's
|
|
||||||
// more reliable on symmetric NATs where the reflexive
|
|
||||||
// addr might not match what the peer actually sees.
|
|
||||||
if let Some(a) = self.mapped {
|
|
||||||
if !out.contains(&a) {
|
|
||||||
out.push(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(a) = self.reflexive {
|
|
||||||
if !out.contains(&a) {
|
|
||||||
out.push(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Smart dial order: filters out candidates that can't possibly
|
|
||||||
/// work given our own reflexive address.
|
|
||||||
///
|
|
||||||
/// - **LAN candidates**: only included if peer's public IP
|
|
||||||
/// matches ours (same network). Private IPs are unreachable
|
|
||||||
/// cross-network.
|
|
||||||
/// - **IPv6 candidates**: stripped entirely (Phase 7 disabled).
|
|
||||||
/// - **Reflexive + mapped**: always included.
|
|
||||||
pub fn smart_dial_order(&self, own_reflexive: Option<&SocketAddr>) -> Vec<SocketAddr> {
|
|
||||||
let own_public_ip = own_reflexive.map(|a| a.ip());
|
|
||||||
let peer_public_ip = self.reflexive.map(|a| a.ip());
|
|
||||||
let same_network = match (own_public_ip, peer_public_ip) {
|
|
||||||
(Some(a), Some(b)) => a == b,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(self.local.len() + 2);
|
|
||||||
|
|
||||||
// LAN candidates only when on the same network.
|
|
||||||
if same_network {
|
|
||||||
for addr in &self.local {
|
|
||||||
if !addr.is_ipv6() {
|
|
||||||
out.push(*addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Port-mapped (always useful — it's a public addr).
|
|
||||||
if let Some(a) = self.mapped {
|
|
||||||
if !a.is_ipv6() && !out.contains(&a) {
|
|
||||||
out.push(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reflexive (always useful — it's the peer's public addr).
|
|
||||||
if let Some(a) = self.reflexive {
|
|
||||||
if !a.is_ipv6() && !out.contains(&a) {
|
|
||||||
out.push(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is there anything for the D-role to dial? If not, the
|
|
||||||
/// race reduces to relay-only.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.reflexive.is_none() && self.local.is_empty() && self.mapped.is_none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn race(
|
|
||||||
role: Role,
|
|
||||||
peer_candidates: PeerCandidates,
|
|
||||||
relay_addr: SocketAddr,
|
|
||||||
room_sni: String,
|
|
||||||
call_sni: String,
|
|
||||||
// Our own reflexive address — used to filter LAN candidates
|
|
||||||
// that can't work cross-network.
|
|
||||||
own_reflexive: Option<SocketAddr>,
|
|
||||||
// Phase 5: when `Some`, reuse this endpoint for BOTH the
|
|
||||||
// direct-path branch AND the relay dial. Pass the signal
|
|
||||||
// endpoint. The endpoint MUST be server-capable (created
|
|
||||||
// with a server config) for the A-role accept branch to
|
|
||||||
// work.
|
|
||||||
//
|
|
||||||
// When `None`, falls back to fresh endpoints per role.
|
|
||||||
// Used by tests.
|
|
||||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
// Phase 7: dedicated IPv6 endpoint with IPV6_V6ONLY=1.
|
|
||||||
// When `Some`, A-role accepts on both v4+v6, D-role routes
|
|
||||||
// each candidate to its matching-AF endpoint. When `None`,
|
|
||||||
// IPv6 candidates are skipped (IPv4-only, pre-Phase-7).
|
|
||||||
ipv6_endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
) -> anyhow::Result<RaceResult> {
|
|
||||||
// Rustls provider must be installed before any quinn endpoint
|
|
||||||
// is created. Install attempt is idempotent.
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
// Shared diagnostic collector for per-candidate results.
|
|
||||||
let diags_collector: Arc<std::sync::Mutex<Vec<CandidateDiag>>> =
|
|
||||||
Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
// Build the direct-path endpoint + future based on role.
|
|
||||||
//
|
|
||||||
// A-role: one accept future on the shared endpoint. The
|
|
||||||
// first incoming QUIC connection wins — we don't care
|
|
||||||
// which peer candidate the dialer used to reach us.
|
|
||||||
//
|
|
||||||
// D-role: N parallel dial futures, one per peer candidate
|
|
||||||
// (all LAN host addrs + the reflex addr), consolidated
|
|
||||||
// into a single direct_fut via FuturesUnordered-style
|
|
||||||
// "first OK wins" semantics. The first successful dial
|
|
||||||
// becomes the direct path; the losers are dropped (quinn
|
|
||||||
// will abort the in-flight handshakes via the dropped
|
|
||||||
// Connecting futures).
|
|
||||||
//
|
|
||||||
// Either way, direct_fut resolves to a single QuinnTransport
|
|
||||||
// (or an error) and is raced against the relay_fut by the
|
|
||||||
// outer tokio::select!.
|
|
||||||
let direct_ep: wzp_transport::Endpoint;
|
|
||||||
let direct_fut: std::pin::Pin<
|
|
||||||
Box<dyn std::future::Future<Output = anyhow::Result<QuinnTransport>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
match role {
|
|
||||||
Role::Acceptor => {
|
|
||||||
let ep = match shared_endpoint.clone() {
|
|
||||||
Some(ep) => {
|
|
||||||
tracing::info!(
|
|
||||||
local_addr = ?ep.local_addr().ok(),
|
|
||||||
"dual_path: A-role reusing shared endpoint for accept"
|
|
||||||
);
|
|
||||||
ep
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let (sc, _cert_der) = wzp_transport::server_config();
|
|
||||||
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
|
|
||||||
// tried but breaks on Android devices where
|
|
||||||
// IPV6_V6ONLY=1 (default on some kernels) —
|
|
||||||
// IPv4 candidates silently fail. IPv6 host
|
|
||||||
// candidates are skipped for now; they need a
|
|
||||||
// dedicated IPv6 socket alongside the v4 one
|
|
||||||
// (like WebRTC's dual-socket approach).
|
|
||||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let fresh = wzp_transport::create_endpoint(bind, Some(sc))?;
|
|
||||||
tracing::info!(
|
|
||||||
local_addr = ?fresh.local_addr().ok(),
|
|
||||||
"dual_path: A-role fresh endpoint up, awaiting peer dial"
|
|
||||||
);
|
|
||||||
fresh
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ep_for_fut = ep.clone();
|
|
||||||
// Phase 7: IPv6 accept temporarily disabled (same reason
|
|
||||||
// as dial — IPv6 connections die on datagram send).
|
|
||||||
// Accept on IPv4 shared endpoint only.
|
|
||||||
let _v6_ep_unused = ipv6_endpoint.clone();
|
|
||||||
// Collect peer addrs for NAT tickle (Acceptor-side).
|
|
||||||
let tickle_addrs: Vec<SocketAddr> = peer_candidates
|
|
||||||
.smart_dial_order(own_reflexive.as_ref())
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !a.ip().is_loopback() && !a.ip().is_unspecified())
|
|
||||||
.collect();
|
|
||||||
direct_fut = Box::pin(async move {
|
|
||||||
// NAT tickle: send a small UDP packet to each of the
|
|
||||||
// Dialer's candidate addresses FROM our shared endpoint.
|
|
||||||
// This opens our NAT's pinhole for return traffic from
|
|
||||||
// those IPs — critical for address-restricted NATs that
|
|
||||||
// only allow inbound from IPs they've seen outbound
|
|
||||||
// traffic to. Without this, the Dialer's QUIC Initial
|
|
||||||
// gets dropped by our NAT.
|
|
||||||
if !tickle_addrs.is_empty() {
|
|
||||||
if let Ok(local_addr) = ep_for_fut.local_addr() {
|
|
||||||
// Send a tickle to each peer candidate address
|
|
||||||
// to open our NAT for return traffic from that IP.
|
|
||||||
//
|
|
||||||
// We use a socket2 socket with SO_REUSEADDR +
|
|
||||||
// SO_REUSEPORT on the SAME port as the quinn
|
|
||||||
// endpoint. This is necessary because quinn
|
|
||||||
// already holds the port — a plain bind() would
|
|
||||||
// fail with EADDRINUSE.
|
|
||||||
let tickle_result: Result<(), String> = (|| {
|
|
||||||
use std::net::UdpSocket as StdUdpSocket;
|
|
||||||
let sock = socket2::Socket::new(
|
|
||||||
socket2::Domain::IPV4,
|
|
||||||
socket2::Type::DGRAM,
|
|
||||||
Some(socket2::Protocol::UDP),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("socket: {e}"))?;
|
|
||||||
sock.set_reuse_address(true)
|
|
||||||
.map_err(|e| format!("reuseaddr: {e}"))?;
|
|
||||||
// macOS/BSD/Linux also need SO_REUSEPORT
|
|
||||||
#[cfg(any(
|
|
||||||
target_os = "macos",
|
|
||||||
target_os = "linux",
|
|
||||||
target_os = "android"
|
|
||||||
))]
|
|
||||||
{
|
|
||||||
// socket2 exposes set_reuse_port on unix
|
|
||||||
unsafe {
|
|
||||||
let optval: libc::c_int = 1;
|
|
||||||
libc::setsockopt(
|
|
||||||
std::os::unix::io::AsRawFd::as_raw_fd(&sock),
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_REUSEPORT,
|
|
||||||
&optval as *const _ as *const libc::c_void,
|
|
||||||
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sock.set_nonblocking(true)
|
|
||||||
.map_err(|e| format!("nonblock: {e}"))?;
|
|
||||||
let bind_addr: SocketAddr = SocketAddr::new(
|
|
||||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
|
||||||
local_addr.port(),
|
|
||||||
);
|
|
||||||
sock.bind(&bind_addr.into())
|
|
||||||
.map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
|
|
||||||
let std_sock: StdUdpSocket = sock.into();
|
|
||||||
for addr in &tickle_addrs {
|
|
||||||
let _ = std_sock.send_to(&[0u8; 1], addr);
|
|
||||||
tracing::info!(
|
|
||||||
%addr,
|
|
||||||
local_port = local_addr.port(),
|
|
||||||
"dual_path: A-role sent NAT tickle"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if let Err(e) = tickle_result {
|
|
||||||
tracing::warn!(error = %e, "dual_path: A-role NAT tickle failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept loop: retry if we get a stale/closed
|
|
||||||
// connection from a previous call. Max 3 retries
|
|
||||||
// to avoid spinning until the race timeout.
|
|
||||||
const MAX_STALE: usize = 3;
|
|
||||||
let mut stale_count: usize = 0;
|
|
||||||
loop {
|
|
||||||
let conn = wzp_transport::accept(&ep_for_fut)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
|
|
||||||
|
|
||||||
if let Some(reason) = conn.close_reason() {
|
|
||||||
// Explicitly close so the peer gets a
|
|
||||||
// close frame instead of idle timeout.
|
|
||||||
conn.close(0u32.into(), b"stale");
|
|
||||||
stale_count += 1;
|
|
||||||
tracing::warn!(
|
|
||||||
remote = %conn.remote_address(),
|
|
||||||
stable_id = conn.stable_id(),
|
|
||||||
stale_count,
|
|
||||||
?reason,
|
|
||||||
"dual_path: A-role skipping stale connection"
|
|
||||||
);
|
|
||||||
if stale_count >= MAX_STALE {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"A-role: {stale_count} stale connections, aborting"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_dgram = conn.max_datagram_size().is_some();
|
|
||||||
tracing::info!(
|
|
||||||
remote = %conn.remote_address(),
|
|
||||||
stable_id = conn.stable_id(),
|
|
||||||
has_dgram,
|
|
||||||
"dual_path: A-role accepted direct connection"
|
|
||||||
);
|
|
||||||
|
|
||||||
break Ok(QuinnTransport::new(conn));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
direct_ep = ep;
|
|
||||||
}
|
|
||||||
Role::Dialer => {
|
|
||||||
let ep = match shared_endpoint.clone() {
|
|
||||||
Some(ep) => {
|
|
||||||
tracing::info!(
|
|
||||||
local_addr = ?ep.local_addr().ok(),
|
|
||||||
candidates = ?peer_candidates.dial_order(),
|
|
||||||
"dual_path: D-role reusing shared endpoint to dial peer candidates"
|
|
||||||
);
|
|
||||||
ep
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// 0.0.0.0:0 = IPv4 socket. [::]:0 dual-stack was
|
|
||||||
// tried but breaks on Android devices where
|
|
||||||
// IPV6_V6ONLY=1 (default on some kernels) —
|
|
||||||
// IPv4 candidates silently fail. IPv6 host
|
|
||||||
// candidates are skipped for now; they need a
|
|
||||||
// dedicated IPv6 socket alongside the v4 one
|
|
||||||
// (like WebRTC's dual-socket approach).
|
|
||||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let fresh = wzp_transport::create_endpoint(bind, None)?;
|
|
||||||
tracing::info!(
|
|
||||||
local_addr = ?fresh.local_addr().ok(),
|
|
||||||
candidates = ?peer_candidates.dial_order(),
|
|
||||||
"dual_path: D-role fresh endpoint up, dialing peer candidates"
|
|
||||||
);
|
|
||||||
fresh
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ep_for_fut = ep.clone();
|
|
||||||
let _v6_ep_for_dial = ipv6_endpoint.clone();
|
|
||||||
let dial_order = peer_candidates.smart_dial_order(own_reflexive.as_ref());
|
|
||||||
let sni = call_sni.clone();
|
|
||||||
let diags = diags_collector.clone();
|
|
||||||
direct_fut = Box::pin(async move {
|
|
||||||
if dial_order.is_empty() {
|
|
||||||
// No candidates — the race reduces to
|
|
||||||
// relay-only. Surface a stable error so the
|
|
||||||
// outer select falls through to relay_fut
|
|
||||||
// without a spurious "direct failed" warning.
|
|
||||||
// Use a pending future that never resolves so
|
|
||||||
// the select's "other side wins" branch is
|
|
||||||
// the natural outcome.
|
|
||||||
std::future::pending::<anyhow::Result<QuinnTransport>>().await
|
|
||||||
} else {
|
|
||||||
// Fan out N parallel dials via JoinSet. First
|
|
||||||
// `Ok` wins; `Err` from a single candidate is
|
|
||||||
// not fatal — we wait for the others. Only
|
|
||||||
// when ALL have failed do we return Err.
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
|
||||||
for (idx, candidate) in dial_order.iter().enumerate() {
|
|
||||||
// Phase 7: route each candidate to the
|
|
||||||
// endpoint matching its address family.
|
|
||||||
let candidate = *candidate;
|
|
||||||
// Phase 7: IPv6 dials temporarily disabled.
|
|
||||||
// IPv6 QUIC handshakes succeed but the
|
|
||||||
// connection dies immediately on datagram
|
|
||||||
// send ("connection lost"). Root cause is
|
|
||||||
// likely router-level IPv6 UDP filtering.
|
|
||||||
// Re-enable once IPv6 datagram delivery is
|
|
||||||
// verified on target networks.
|
|
||||||
if candidate.is_ipv6() {
|
|
||||||
tracing::info!(
|
|
||||||
%candidate,
|
|
||||||
candidate_idx = idx,
|
|
||||||
"dual_path: skipping IPv6 candidate (disabled)"
|
|
||||||
);
|
|
||||||
if let Ok(mut d) = diags.lock() {
|
|
||||||
d.push(CandidateDiag {
|
|
||||||
index: idx,
|
|
||||||
addr: candidate.to_string(),
|
|
||||||
result: "skipped:ipv6".into(),
|
|
||||||
elapsed_ms: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let ep = ep_for_fut.clone();
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
let sni = sni.clone();
|
|
||||||
let diags_inner = diags.clone();
|
|
||||||
set.spawn(async move {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
tracing::info!(
|
|
||||||
%candidate,
|
|
||||||
candidate_idx = idx,
|
|
||||||
"dual_path: dialing candidate"
|
|
||||||
);
|
|
||||||
let result =
|
|
||||||
wzp_transport::connect(&ep, candidate, &sni, client_cfg).await;
|
|
||||||
let elapsed = start.elapsed().as_millis() as u32;
|
|
||||||
let diag_result = match &result {
|
|
||||||
Ok(_) => "ok".to_string(),
|
|
||||||
Err(e) => format!("error:{e}"),
|
|
||||||
};
|
|
||||||
if let Ok(mut d) = diags_inner.lock() {
|
|
||||||
d.push(CandidateDiag {
|
|
||||||
index: idx,
|
|
||||||
addr: candidate.to_string(),
|
|
||||||
result: diag_result,
|
|
||||||
elapsed_ms: Some(elapsed),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
(idx, candidate, result)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let mut last_err: Option<String> = None;
|
|
||||||
while let Some(join_res) = set.join_next().await {
|
|
||||||
let (idx, candidate, dial_res) = match join_res {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
last_err = Some(format!("join {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match dial_res {
|
|
||||||
Ok(conn) => {
|
|
||||||
tracing::info!(
|
|
||||||
%candidate,
|
|
||||||
candidate_idx = idx,
|
|
||||||
remote = %conn.remote_address(),
|
|
||||||
stable_id = conn.stable_id(),
|
|
||||||
"dual_path: direct dial succeeded on candidate"
|
|
||||||
);
|
|
||||||
// Abort the remaining in-flight
|
|
||||||
// dials so they don't complete
|
|
||||||
// and leak QUIC sessions.
|
|
||||||
set.abort_all();
|
|
||||||
return Ok(QuinnTransport::new(conn));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::info!(
|
|
||||||
%candidate,
|
|
||||||
candidate_idx = idx,
|
|
||||||
error = %e,
|
|
||||||
"dual_path: direct dial failed, trying others"
|
|
||||||
);
|
|
||||||
last_err = Some(format!("candidate {candidate}: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"all {} direct candidates failed; last: {}",
|
|
||||||
dial_order.len(),
|
|
||||||
last_err.unwrap_or_else(|| "n/a".into())
|
|
||||||
))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
direct_ep = ep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relay path: classic dial to the relay's media room. Phase 5:
|
|
||||||
// reuse the shared endpoint here too so MikroTik-style NATs
|
|
||||||
// keep a stable external port across all flows from this
|
|
||||||
// client. Falls back to a fresh endpoint when not shared.
|
|
||||||
let relay_ep = match shared_endpoint.clone() {
|
|
||||||
Some(ep) => ep,
|
|
||||||
None => {
|
|
||||||
let relay_bind: SocketAddr = "[::]:0".parse().unwrap();
|
|
||||||
wzp_transport::create_endpoint(relay_bind, None)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let relay_ep_for_fut = relay_ep.clone();
|
|
||||||
let relay_client_cfg = wzp_transport::client_config();
|
|
||||||
let relay_sni = room_sni.clone();
|
|
||||||
// Phase 5.5 direct-path head-start: hold the relay dial for
|
|
||||||
// 500ms before attempting it. On same-LAN cone-NAT pairs the
|
|
||||||
// direct dial finishes in ~30-100ms, so giving direct a 500ms
|
|
||||||
// head start means direct reliably wins when it's going to
|
|
||||||
// work at all. The worst case adds 500ms to the fall-back-
|
|
||||||
// to-relay scenario, which is imperceptible for users on
|
|
||||||
// setups where direct isn't available anyway.
|
|
||||||
//
|
|
||||||
// Prior behavior (immediate race) caused the relay to win
|
|
||||||
// ~105ms races on a MikroTik LAN because:
|
|
||||||
// - Acceptor role's direct_fut = accept() can only fire
|
|
||||||
// when the peer has completed its outbound LAN dial
|
|
||||||
// - Dialer role's parallel LAN dials need the peer's
|
|
||||||
// CallSetup processed + the race started on the other
|
|
||||||
// side before they can reach us
|
|
||||||
// - Meanwhile relay_fut is a plain dial that completes in
|
|
||||||
// whatever the client→relay RTT is (often <100ms)
|
|
||||||
//
|
|
||||||
// The 500ms head start is the minimum that empirically makes
|
|
||||||
// same-LAN direct reliably beat relay, without penalizing
|
|
||||||
// users who genuinely need the relay path.
|
|
||||||
const DIRECT_HEAD_START: Duration = Duration::from_millis(500);
|
|
||||||
let relay_fut = async move {
|
|
||||||
tokio::time::sleep(DIRECT_HEAD_START).await;
|
|
||||||
let conn =
|
|
||||||
wzp_transport::connect(&relay_ep_for_fut, relay_addr, &relay_sni, relay_client_cfg)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("relay dial: {e}"))?;
|
|
||||||
Ok::<_, anyhow::Error>(QuinnTransport::new(conn))
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase 6: run both paths concurrently via tokio::spawn and
|
|
||||||
// collect BOTH results. The old tokio::select! approach dropped
|
|
||||||
// the loser, which meant the connect command couldn't negotiate
|
|
||||||
// with the peer — it had to commit to whichever path won locally.
|
|
||||||
//
|
|
||||||
// Now we spawn both as tasks, wait for the first to complete
|
|
||||||
// (that determines `local_winner`), then give the loser a short
|
|
||||||
// grace period to also complete. The connect command gets a
|
|
||||||
// RaceResult with both transports (when available) and uses the
|
|
||||||
// Phase 6 MediaPathReport exchange to decide which one to
|
|
||||||
// actually use for media.
|
|
||||||
let smart_order = peer_candidates.smart_dial_order(own_reflexive.as_ref());
|
|
||||||
tracing::info!(
|
|
||||||
?role,
|
|
||||||
raw_candidates = ?peer_candidates.dial_order(),
|
|
||||||
filtered_candidates = ?smart_order,
|
|
||||||
?own_reflexive,
|
|
||||||
%relay_addr,
|
|
||||||
"dual_path: racing direct vs relay"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut direct_task = tokio::spawn(tokio::time::timeout(Duration::from_secs(4), direct_fut));
|
|
||||||
let mut relay_task = tokio::spawn(async move {
|
|
||||||
// Keep the 500ms head start so direct has a chance
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), relay_fut).await
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the first one to complete. This tells us the
|
|
||||||
// local_winner — but we DON'T commit to it yet. Phase 6
|
|
||||||
// negotiation decides the actual path.
|
|
||||||
let (mut direct_result, mut relay_result): (
|
|
||||||
Option<anyhow::Result<QuinnTransport>>,
|
|
||||||
Option<anyhow::Result<QuinnTransport>>,
|
|
||||||
) = (None, None);
|
|
||||||
|
|
||||||
let local_winner;
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
d = &mut direct_task => {
|
|
||||||
match d {
|
|
||||||
Ok(Ok(Ok(t))) => {
|
|
||||||
tracing::info!("dual_path: direct completed first");
|
|
||||||
direct_result = Some(Ok(t));
|
|
||||||
local_winner = WinningPath::Direct;
|
|
||||||
}
|
|
||||||
Ok(Ok(Err(e))) => {
|
|
||||||
tracing::warn!(error = %e, "dual_path: direct failed");
|
|
||||||
direct_result = Some(Err(anyhow::anyhow!("{e}")));
|
|
||||||
local_winner = WinningPath::Relay; // direct failed → relay is our only hope
|
|
||||||
}
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
tracing::warn!("dual_path: direct timed out (4s)");
|
|
||||||
direct_result = Some(Err(anyhow::anyhow!("direct timeout")));
|
|
||||||
local_winner = WinningPath::Relay;
|
|
||||||
// Record timeout diag for candidates that were
|
|
||||||
// still in-flight when the timeout fired.
|
|
||||||
if let Ok(mut d) = diags_collector.lock() {
|
|
||||||
let recorded_indices: std::collections::HashSet<usize> =
|
|
||||||
d.iter().map(|diag| diag.index).collect();
|
|
||||||
for (idx, addr) in smart_order.iter().enumerate() {
|
|
||||||
if !recorded_indices.contains(&idx) {
|
|
||||||
d.push(CandidateDiag {
|
|
||||||
index: idx,
|
|
||||||
addr: addr.to_string(),
|
|
||||||
result: "timeout:4s".into(),
|
|
||||||
elapsed_ms: Some(4000),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "dual_path: direct task panicked");
|
|
||||||
direct_result = Some(Err(anyhow::anyhow!("direct task panic")));
|
|
||||||
local_winner = WinningPath::Relay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r = &mut relay_task => {
|
|
||||||
match r {
|
|
||||||
Ok(Ok(Ok(t))) => {
|
|
||||||
tracing::info!("dual_path: relay completed first");
|
|
||||||
relay_result = Some(Ok(t));
|
|
||||||
local_winner = WinningPath::Relay;
|
|
||||||
}
|
|
||||||
Ok(Ok(Err(e))) => {
|
|
||||||
tracing::warn!(error = %e, "dual_path: relay failed");
|
|
||||||
relay_result = Some(Err(anyhow::anyhow!("{e}")));
|
|
||||||
local_winner = WinningPath::Direct;
|
|
||||||
}
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
tracing::warn!("dual_path: relay timed out");
|
|
||||||
relay_result = Some(Err(anyhow::anyhow!("relay timeout")));
|
|
||||||
local_winner = WinningPath::Direct;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
relay_result = Some(Err(anyhow::anyhow!("relay task panic: {e}")));
|
|
||||||
local_winner = WinningPath::Direct;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the loser a short grace period (1s) to also complete.
|
|
||||||
// If it does, we have both transports for Phase 6 negotiation.
|
|
||||||
// If it doesn't, we still proceed with just the winner.
|
|
||||||
if direct_result.is_none() {
|
|
||||||
match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
|
|
||||||
Ok(Ok(Ok(Ok(t)))) => {
|
|
||||||
direct_result = Some(Ok(t));
|
|
||||||
}
|
|
||||||
Ok(Ok(Ok(Err(e)))) => {
|
|
||||||
direct_result = Some(Err(anyhow::anyhow!("{e}")));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period")));
|
|
||||||
// Fill timeout diags for candidates that never reported.
|
|
||||||
if let Ok(mut d) = diags_collector.lock() {
|
|
||||||
let recorded: std::collections::HashSet<usize> =
|
|
||||||
d.iter().map(|diag| diag.index).collect();
|
|
||||||
for (idx, addr) in smart_order.iter().enumerate() {
|
|
||||||
if !recorded.contains(&idx) {
|
|
||||||
d.push(CandidateDiag {
|
|
||||||
index: idx,
|
|
||||||
addr: addr.to_string(),
|
|
||||||
result: "timeout:grace".into(),
|
|
||||||
elapsed_ms: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if relay_result.is_none() {
|
|
||||||
match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
|
|
||||||
Ok(Ok(Ok(Ok(t)))) => {
|
|
||||||
relay_result = Some(Ok(t));
|
|
||||||
}
|
|
||||||
Ok(Ok(Ok(Err(e)))) => {
|
|
||||||
relay_result = Some(Err(anyhow::anyhow!("{e}")));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let direct_ok = direct_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
|
|
||||||
let relay_ok = relay_result.as_ref().map(|r| r.is_ok()).unwrap_or(false);
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
?local_winner,
|
|
||||||
direct_ok,
|
|
||||||
relay_ok,
|
|
||||||
"dual_path: race finished, both results collected for Phase 6 negotiation"
|
|
||||||
);
|
|
||||||
|
|
||||||
if !direct_ok && !relay_ok {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"both paths failed: no media transport available"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = (direct_ep, relay_ep, ipv6_endpoint);
|
|
||||||
|
|
||||||
let candidate_diags = diags_collector
|
|
||||||
.lock()
|
|
||||||
.map(|d| d.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(RaceResult {
|
|
||||||
direct_transport: direct_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
|
||||||
relay_transport: relay_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
|
|
||||||
local_winner,
|
|
||||||
candidate_diags,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_dial_order_all_types() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
|
||||||
local: vec![
|
|
||||||
"192.168.1.10:4433".parse().unwrap(),
|
|
||||||
"10.0.0.5:4433".parse().unwrap(),
|
|
||||||
],
|
|
||||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let order = candidates.dial_order();
|
|
||||||
// Order: local first, then mapped, then reflexive
|
|
||||||
assert_eq!(order.len(), 4);
|
|
||||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
|
||||||
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
order[2],
|
|
||||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
|
||||||
);
|
|
||||||
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_dial_order_no_mapped() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
|
||||||
local: vec!["192.168.1.10:4433".parse().unwrap()],
|
|
||||||
mapped: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let order = candidates.dial_order();
|
|
||||||
assert_eq!(order.len(), 2);
|
|
||||||
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
|
|
||||||
assert_eq!(order[1], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_dial_order_only_mapped() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: None,
|
|
||||||
local: vec![],
|
|
||||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let order = candidates.dial_order();
|
|
||||||
assert_eq!(order.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
order[0],
|
|
||||||
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_dial_order_dedup_mapped_equals_reflexive() {
|
|
||||||
let addr: SocketAddr = "203.0.113.5:4433".parse().unwrap();
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some(addr),
|
|
||||||
local: vec![],
|
|
||||||
mapped: Some(addr), // same as reflexive
|
|
||||||
};
|
|
||||||
|
|
||||||
let order = candidates.dial_order();
|
|
||||||
// Should be deduped to 1
|
|
||||||
assert_eq!(order.len(), 1);
|
|
||||||
assert_eq!(order[0], addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_dial_order_dedup_mapped_in_local() {
|
|
||||||
let addr: SocketAddr = "192.168.1.10:4433".parse().unwrap();
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: None,
|
|
||||||
local: vec![addr],
|
|
||||||
mapped: Some(addr), // same as a local addr
|
|
||||||
};
|
|
||||||
|
|
||||||
let order = candidates.dial_order();
|
|
||||||
assert_eq!(order.len(), 1);
|
|
||||||
assert_eq!(order[0], addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_is_empty() {
|
|
||||||
let empty = PeerCandidates::default();
|
|
||||||
assert!(empty.is_empty());
|
|
||||||
|
|
||||||
let with_reflexive = PeerCandidates {
|
|
||||||
reflexive: Some("1.2.3.4:5".parse().unwrap()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(!with_reflexive.is_empty());
|
|
||||||
|
|
||||||
let with_local = PeerCandidates {
|
|
||||||
local: vec!["10.0.0.1:5".parse().unwrap()],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(!with_local.is_empty());
|
|
||||||
|
|
||||||
let with_mapped = PeerCandidates {
|
|
||||||
mapped: Some("1.2.3.4:5".parse().unwrap()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(!with_mapped.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_candidates_empty_dial_order() {
|
|
||||||
let empty = PeerCandidates::default();
|
|
||||||
assert!(empty.dial_order().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn winning_path_debug() {
|
|
||||||
// Just verify Debug impl doesn't panic
|
|
||||||
let _ = format!("{:?}", WinningPath::Direct);
|
|
||||||
let _ = format!("{:?}", WinningPath::Relay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── smart_dial_order tests ─────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smart_dial_order_same_network_includes_lan() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("203.0.113.5:4433".parse().unwrap()),
|
|
||||||
local: vec![
|
|
||||||
"192.168.1.10:4433".parse().unwrap(),
|
|
||||||
"10.0.0.5:4433".parse().unwrap(),
|
|
||||||
],
|
|
||||||
mapped: None,
|
|
||||||
};
|
|
||||||
let own: SocketAddr = "203.0.113.5:12345".parse().unwrap();
|
|
||||||
let order = candidates.smart_dial_order(Some(&own));
|
|
||||||
// Same public IP → LAN candidates included
|
|
||||||
assert!(order.contains(&"192.168.1.10:4433".parse().unwrap()));
|
|
||||||
assert!(order.contains(&"10.0.0.5:4433".parse().unwrap()));
|
|
||||||
assert!(order.contains(&"203.0.113.5:4433".parse().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smart_dial_order_different_network_strips_lan() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
|
||||||
local: vec![
|
|
||||||
"172.16.81.126:4433".parse().unwrap(),
|
|
||||||
"10.0.0.5:4433".parse().unwrap(),
|
|
||||||
],
|
|
||||||
mapped: None,
|
|
||||||
};
|
|
||||||
// Different public IP → LAN candidates stripped
|
|
||||||
let own: SocketAddr = "185.115.4.212:12345".parse().unwrap();
|
|
||||||
let order = candidates.smart_dial_order(Some(&own));
|
|
||||||
assert!(!order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
|
||||||
assert!(!order.contains(&"10.0.0.5:4433".parse().unwrap()));
|
|
||||||
// Reflexive still included
|
|
||||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smart_dial_order_strips_ipv6() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
|
||||||
local: vec![
|
|
||||||
"[2a0d:3344:692c::1]:4433".parse().unwrap(),
|
|
||||||
"172.16.81.126:4433".parse().unwrap(),
|
|
||||||
],
|
|
||||||
mapped: None,
|
|
||||||
};
|
|
||||||
// Same network, but IPv6 should be stripped
|
|
||||||
let own: SocketAddr = "150.228.49.65:5555".parse().unwrap();
|
|
||||||
let order = candidates.smart_dial_order(Some(&own));
|
|
||||||
assert!(!order.iter().any(|a| a.is_ipv6()));
|
|
||||||
assert!(order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smart_dial_order_no_own_reflexive_strips_lan() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
|
||||||
local: vec!["172.16.81.126:4433".parse().unwrap()],
|
|
||||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
|
||||||
};
|
|
||||||
// No own reflexive → can't determine same network → strip LAN
|
|
||||||
let order = candidates.smart_dial_order(None);
|
|
||||||
assert!(!order.contains(&"172.16.81.126:4433".parse().unwrap()));
|
|
||||||
assert!(order.contains(&"198.51.100.42:12345".parse().unwrap()));
|
|
||||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smart_dial_order_mapped_always_included() {
|
|
||||||
let candidates = PeerCandidates {
|
|
||||||
reflexive: Some("150.228.49.65:4433".parse().unwrap()),
|
|
||||||
local: vec![],
|
|
||||||
mapped: Some("198.51.100.42:12345".parse().unwrap()),
|
|
||||||
};
|
|
||||||
let own: SocketAddr = "185.115.4.212:12345".parse().unwrap();
|
|
||||||
let order = candidates.smart_dial_order(Some(&own));
|
|
||||||
assert_eq!(order.len(), 2); // mapped + reflexive
|
|
||||||
assert!(order.contains(&"198.51.100.42:12345".parse().unwrap()));
|
|
||||||
assert!(order.contains(&"150.228.49.65:4433".parse().unwrap()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -166,7 +166,7 @@ pub async fn run_echo_test(
|
|||||||
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
|
||||||
Ok(Ok(Some(pkt))) => {
|
Ok(Ok(Some(pkt))) => {
|
||||||
total_packets_received += 1;
|
total_packets_received += 1;
|
||||||
let is_repair = pkt.header.is_repair();
|
let is_repair = pkt.header.is_repair;
|
||||||
decoder.ingest(pkt);
|
decoder.ingest(pkt);
|
||||||
if !is_repair {
|
if !is_repair {
|
||||||
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
if let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||||
@@ -184,8 +184,7 @@ pub async fn run_echo_test(
|
|||||||
let time_offset = start.elapsed().as_secs_f64();
|
let time_offset = start.elapsed().as_secs_f64();
|
||||||
|
|
||||||
// Compare sent vs received for this window
|
// Compare sent vs received for this window
|
||||||
let sent_start =
|
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
||||||
(window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
|
|
||||||
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
|
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
|
||||||
let sent_window = if sent_end <= sent_pcm.len() {
|
let sent_window = if sent_end <= sent_pcm.len() {
|
||||||
&sent_pcm[sent_start..sent_end]
|
&sent_pcm[sent_start..sent_end]
|
||||||
@@ -193,9 +192,7 @@ pub async fn run_echo_test(
|
|||||||
&sent_pcm[sent_start..]
|
&sent_pcm[sent_start..]
|
||||||
};
|
};
|
||||||
|
|
||||||
let recv_start = recv_pcm
|
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
||||||
.len()
|
|
||||||
.saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
|
|
||||||
let recv_window = &recv_pcm[recv_start..];
|
let recv_window = &recv_pcm[recv_start..];
|
||||||
|
|
||||||
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
|
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
|
||||||
@@ -259,7 +256,7 @@ pub async fn run_echo_test(
|
|||||||
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
|
||||||
Ok(Ok(Some(pkt))) => {
|
Ok(Ok(Some(pkt))) => {
|
||||||
total_packets_received += 1;
|
total_packets_received += 1;
|
||||||
let is_repair = pkt.header.is_repair();
|
let is_repair = pkt.header.is_repair;
|
||||||
decoder.ingest(pkt);
|
decoder.ingest(pkt);
|
||||||
if !is_repair {
|
if !is_repair {
|
||||||
decoder.decode_next(&mut pcm_buf);
|
decoder.decode_next(&mut pcm_buf);
|
||||||
@@ -313,14 +310,8 @@ pub fn print_report(result: &EchoTestResult) {
|
|||||||
let status = if w.is_silent { " !" } else { " " };
|
let status = if w.is_silent { " !" } else { " " };
|
||||||
println!(
|
println!(
|
||||||
"│ {:>3}{} │ {:>5.1}s │ {:>4} │ {:>4} │ {:>5.1}% │ {:>5.1} │ {:.3} │",
|
"│ {:>3}{} │ {:>5.1}s │ {:>4} │ {:>4} │ {:>5.1}% │ {:>5.1} │ {:.3} │",
|
||||||
w.index,
|
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received,
|
||||||
status,
|
w.loss_pct, w.snr_db, w.correlation
|
||||||
w.time_offset_secs,
|
|
||||||
w.frames_sent,
|
|
||||||
w.frames_received,
|
|
||||||
w.loss_pct,
|
|
||||||
w.snr_db,
|
|
||||||
w.correlation
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
|
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
|
||||||
@@ -330,28 +321,18 @@ pub fn print_report(result: &EchoTestResult) {
|
|||||||
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
|
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
|
||||||
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
|
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
|
||||||
|
|
||||||
let avg_loss_first =
|
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
||||||
first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
|
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
||||||
let avg_loss_second =
|
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
||||||
second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
|
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
||||||
let avg_corr_first =
|
|
||||||
first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
|
|
||||||
let avg_corr_second =
|
|
||||||
second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
if avg_loss_second > avg_loss_first + 5.0 {
|
if avg_loss_second > avg_loss_first + 5.0 {
|
||||||
println!("WARNING: Quality degradation detected!");
|
println!("WARNING: Quality degradation detected!");
|
||||||
println!(
|
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second);
|
||||||
" Loss increased from {:.1}% to {:.1}% over time",
|
|
||||||
avg_loss_first, avg_loss_second
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if avg_corr_second < avg_corr_first - 0.1 {
|
if avg_corr_second < avg_corr_first - 0.1 {
|
||||||
println!(
|
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second);
|
||||||
"WARNING: Signal correlation dropped from {:.3} to {:.3}",
|
|
||||||
avg_corr_first, avg_corr_second
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
|
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
|
||||||
println!("Quality is STABLE over the test duration.");
|
println!("Quality is STABLE over the test duration.");
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
//! `EncryptingTransport` — wraps any `MediaTransport` with a `CryptoSession`.
|
|
||||||
//!
|
|
||||||
//! All outbound `send_media` calls encrypt the payload before handing off to
|
|
||||||
//! the inner transport; all inbound `recv_media` calls decrypt after receiving.
|
|
||||||
//! Signal, quality, and close are forwarded unchanged.
|
|
||||||
//!
|
|
||||||
//! The quality report travels in plaintext so the relay can make QoS decisions
|
|
||||||
//! without being able to decrypt media content.
|
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use wzp_proto::{
|
|
||||||
CryptoSession, MediaHeader, MediaPacket, MediaTransport, PathQuality, SignalMessage,
|
|
||||||
TransportError,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Wraps a `MediaTransport` and applies AEAD encryption/decryption to media payloads.
|
|
||||||
pub struct EncryptingTransport {
|
|
||||||
inner: Arc<dyn MediaTransport>,
|
|
||||||
session: Mutex<Box<dyn CryptoSession>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncryptingTransport {
|
|
||||||
pub fn new(inner: Arc<dyn MediaTransport>, session: Box<dyn CryptoSession>) -> Self {
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
session: Mutex::new(session),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl MediaTransport for EncryptingTransport {
|
|
||||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
|
||||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
|
||||||
packet.header.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let mut ciphertext = Vec::new();
|
|
||||||
self.session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.encrypt(&header_bytes, &packet.payload, &mut ciphertext)
|
|
||||||
.map_err(|e| TransportError::Internal(format!("encrypt: {e}")))?;
|
|
||||||
|
|
||||||
let encrypted = MediaPacket {
|
|
||||||
header: packet.header,
|
|
||||||
payload: Bytes::from(ciphertext),
|
|
||||||
quality_report: packet.quality_report.clone(),
|
|
||||||
};
|
|
||||||
self.inner.send_media(&encrypted).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
|
||||||
let packet = match self.inner.recv_media().await? {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut header_bytes = Vec::with_capacity(MediaHeader::WIRE_SIZE);
|
|
||||||
packet.header.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let mut plaintext = Vec::new();
|
|
||||||
self.session
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.decrypt(&header_bytes, &packet.payload, &mut plaintext)
|
|
||||||
.map_err(|e| TransportError::Internal(format!("decrypt: {e}")))?;
|
|
||||||
|
|
||||||
Ok(Some(MediaPacket {
|
|
||||||
header: packet.header,
|
|
||||||
payload: Bytes::from(plaintext),
|
|
||||||
quality_report: packet.quality_report,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_signal(&self, msg: &SignalMessage) -> Result<(), TransportError> {
|
|
||||||
self.inner.send_signal(msg).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
|
||||||
self.inner.recv_signal().await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_quality(&self) -> PathQuality {
|
|
||||||
self.inner.path_quality()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TransportError> {
|
|
||||||
self.inner.close().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Mutex as StdMutex;
|
|
||||||
use wzp_crypto::ChaChaSession;
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
|
|
||||||
struct LoopbackTransport {
|
|
||||||
sent: StdMutex<Vec<MediaPacket>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoopbackTransport {
|
|
||||||
fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
sent: StdMutex::new(Vec::new()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fn take_sent(&self) -> Vec<MediaPacket> {
|
|
||||||
self.sent.lock().unwrap().drain(..).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl MediaTransport for LoopbackTransport {
|
|
||||||
async fn send_media(&self, packet: &MediaPacket) -> Result<(), TransportError> {
|
|
||||||
self.sent.lock().unwrap().push(packet.clone());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn recv_media(&self) -> Result<Option<MediaPacket>, TransportError> {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
async fn send_signal(&self, _msg: &SignalMessage) -> Result<(), TransportError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn recv_signal(&self) -> Result<Option<SignalMessage>, TransportError> {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
fn path_quality(&self) -> PathQuality {
|
|
||||||
PathQuality::default()
|
|
||||||
}
|
|
||||||
async fn close(&self) -> Result<(), TransportError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_header(seq: u32) -> MediaHeader {
|
|
||||||
MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq,
|
|
||||||
timestamp: seq * 20,
|
|
||||||
fec_block: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn payload_is_encrypted_on_wire() {
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
|
|
||||||
let loopback = LoopbackTransport::new();
|
|
||||||
let enc = EncryptingTransport::new(loopback.clone(), session);
|
|
||||||
|
|
||||||
let header = make_header(1);
|
|
||||||
let plaintext = b"secret audio frame";
|
|
||||||
let pkt = MediaPacket {
|
|
||||||
header,
|
|
||||||
payload: Bytes::from_static(plaintext),
|
|
||||||
quality_report: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
enc.send_media(&pkt).await.unwrap();
|
|
||||||
|
|
||||||
let sent = loopback.take_sent();
|
|
||||||
assert_eq!(sent.len(), 1);
|
|
||||||
assert_eq!(sent[0].header, header, "header must be preserved");
|
|
||||||
assert_ne!(
|
|
||||||
sent[0].payload.as_ref(),
|
|
||||||
plaintext.as_ref(),
|
|
||||||
"plaintext must not appear on wire"
|
|
||||||
);
|
|
||||||
// Ciphertext is longer by exactly the AEAD tag (16 bytes)
|
|
||||||
assert_eq!(sent[0].payload.len(), plaintext.len() + 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn encrypt_then_decrypt_roundtrip() {
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let send_session: Box<dyn CryptoSession> = Box::new(ChaChaSession::new(key));
|
|
||||||
let mut recv_session = ChaChaSession::new(key);
|
|
||||||
|
|
||||||
let loopback = LoopbackTransport::new();
|
|
||||||
let enc = EncryptingTransport::new(loopback.clone(), send_session);
|
|
||||||
|
|
||||||
let header = make_header(5);
|
|
||||||
let plaintext = b"hello encrypted world";
|
|
||||||
let pkt = MediaPacket {
|
|
||||||
header,
|
|
||||||
payload: Bytes::from_static(plaintext),
|
|
||||||
quality_report: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
enc.send_media(&pkt).await.unwrap();
|
|
||||||
|
|
||||||
let sent = loopback.take_sent();
|
|
||||||
let wire_pkt = &sent[0];
|
|
||||||
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
header.write_to(&mut header_bytes);
|
|
||||||
let mut decrypted = Vec::new();
|
|
||||||
recv_session
|
|
||||||
.decrypt(&header_bytes, &wire_pkt.payload, &mut decrypted)
|
|
||||||
.expect("decrypt should succeed with matching key");
|
|
||||||
assert_eq!(&decrypted[..], plaintext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,54 +96,21 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
||||||
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
|
||||||
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||||
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||||
SignalMessage::Hold { .. } => CallSignalType::Hold,
|
SignalMessage::Hold => CallSignalType::Hold,
|
||||||
SignalMessage::Unhold { .. } => CallSignalType::Unhold,
|
SignalMessage::Unhold => CallSignalType::Unhold,
|
||||||
SignalMessage::Mute { .. } => CallSignalType::Mute,
|
SignalMessage::Mute => CallSignalType::Mute,
|
||||||
SignalMessage::Unmute { .. } => CallSignalType::Unmute,
|
SignalMessage::Unmute => CallSignalType::Unmute,
|
||||||
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
||||||
SignalMessage::TransferAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::TransportFeedback { .. } => CallSignalType::Offer, // reuse (BWE)
|
|
||||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::FederationHello { .. }
|
SignalMessage::SetAlias { .. } => CallSignalType::Offer, // reuse
|
||||||
| SignalMessage::GlobalRoomActive { .. }
|
|
||||||
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
|
||||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
|
||||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
|
||||||
SignalMessage::RegisterPresence { .. } | SignalMessage::RegisterPresenceAck { .. } => {
|
|
||||||
CallSignalType::Offer
|
|
||||||
} // relay-only
|
|
||||||
// NAT reflection is a client↔relay control exchange that
|
|
||||||
// never crosses the featherChat bridge — if it ever reaches
|
|
||||||
// this mapper something is wrong, but we still have to give
|
|
||||||
// an answer. "Offer" is the generic catch-all.
|
|
||||||
SignalMessage::Reflect | SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
|
|
||||||
// Phase 4 cross-relay forwarding envelope — strictly a
|
|
||||||
// relay-to-relay message, never rides the featherChat
|
|
||||||
// bridge. Catch-all mapping for completeness.
|
|
||||||
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
|
|
||||||
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
|
|
||||||
SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather
|
|
||||||
SignalMessage::HardNatProbe { .. } => CallSignalType::IceCandidate, // hard NAT coordination
|
|
||||||
SignalMessage::HardNatBirthdayStart { .. } => CallSignalType::IceCandidate, // birthday attack
|
|
||||||
SignalMessage::UpgradeProposal { .. }
|
|
||||||
| SignalMessage::UpgradeResponse { .. }
|
|
||||||
| SignalMessage::UpgradeConfirm { .. }
|
|
||||||
| SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation
|
|
||||||
SignalMessage::PresenceList { .. } => CallSignalType::Offer, // lobby presence
|
|
||||||
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
|
|
||||||
SignalMessage::Nack { .. }
|
|
||||||
| SignalMessage::PictureLossIndication { .. }
|
|
||||||
| SignalMessage::SetPriorityMode { .. } => CallSignalType::Offer, // relay-initiated (video loss recovery)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,20 +118,15 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use wzp_proto::QualityProfile;
|
use wzp_proto::QualityProfile;
|
||||||
use wzp_proto::default_signal_version;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn payload_roundtrip() {
|
fn payload_roundtrip() {
|
||||||
let signal = SignalMessage::CallOffer {
|
let signal = SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub: [1u8; 32],
|
identity_pub: [1u8; 32],
|
||||||
ephemeral_pub: [2u8; 32],
|
ephemeral_pub: [2u8; 32],
|
||||||
signature: vec![3u8; 64],
|
signature: vec![3u8; 64],
|
||||||
supported_profiles: vec![QualityProfile::GOOD],
|
supported_profiles: vec![QualityProfile::GOOD],
|
||||||
alias: None,
|
alias: None,
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||||
@@ -178,53 +140,28 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn signal_type_mapping() {
|
fn signal_type_mapping() {
|
||||||
let offer = SignalMessage::CallOffer {
|
let offer = SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub: [0; 32],
|
identity_pub: [0; 32],
|
||||||
ephemeral_pub: [0; 32],
|
ephemeral_pub: [0; 32],
|
||||||
signature: vec![],
|
signature: vec![],
|
||||||
supported_profiles: vec![],
|
supported_profiles: vec![],
|
||||||
alias: None,
|
alias: None,
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs: vec![],
|
|
||||||
};
|
};
|
||||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||||
|
|
||||||
let hangup = SignalMessage::Hangup {
|
let hangup = SignalMessage::Hangup {
|
||||||
version: default_signal_version(),
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
call_id: None,
|
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
||||||
signal_to_call_type(&hangup),
|
|
||||||
CallSignalType::Hangup
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
|
||||||
signal_to_call_type(&SignalMessage::Hold { version: default_signal_version() }),
|
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
|
||||||
CallSignalType::Hold
|
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
|
||||||
));
|
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
|
||||||
assert!(matches!(
|
|
||||||
signal_to_call_type(&SignalMessage::Unhold { version: default_signal_version() }),
|
|
||||||
CallSignalType::Unhold
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
signal_to_call_type(&SignalMessage::Mute { version: default_signal_version() }),
|
|
||||||
CallSignalType::Mute
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
signal_to_call_type(&SignalMessage::Unmute { version: default_signal_version() }),
|
|
||||||
CallSignalType::Unmute
|
|
||||||
));
|
|
||||||
|
|
||||||
let transfer = SignalMessage::Transfer {
|
let transfer = SignalMessage::Transfer {
|
||||||
version: default_signal_version(),
|
|
||||||
target_fingerprint: "abc".to_string(),
|
target_fingerprint: "abc".to_string(),
|
||||||
relay_addr: None,
|
relay_addr: None,
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
|
||||||
signal_to_call_type(&transfer),
|
|
||||||
CallSignalType::Transfer
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,62 +4,7 @@
|
|||||||
//! send `CallOffer` → recv `CallAnswer` → derive shared `CryptoSession`.
|
//! send `CallOffer` → recv `CallAnswer` → derive shared `CryptoSession`.
|
||||||
|
|
||||||
use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange};
|
use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange};
|
||||||
use wzp_proto::{
|
use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||||
CodecId, HangupReason, MediaTransport, QualityProfile, SignalMessage, default_signal_version,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUPPORTED_VIDEO_CODECS: &[CodecId] = &[CodecId::H264Baseline];
|
|
||||||
|
|
||||||
/// Result of a successful client-side handshake.
|
|
||||||
pub struct HandshakeResult {
|
|
||||||
pub session: Box<dyn CryptoSession>,
|
|
||||||
/// Video codec agreed with the relay. `None` if peer is audio-only.
|
|
||||||
pub video_codec: Option<CodecId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that can occur during the client-side cryptographic handshake.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum HandshakeError {
|
|
||||||
ConnectionClosed,
|
|
||||||
ProtocolVersionMismatch { server_supported: Vec<u8> },
|
|
||||||
UnexpectedSignal(&'static str),
|
|
||||||
SignatureVerificationFailed,
|
|
||||||
KeyDerivation(String),
|
|
||||||
Transport(wzp_proto::TransportError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for HandshakeError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::ConnectionClosed => write!(f, "connection closed before receiving CallAnswer"),
|
|
||||||
Self::ProtocolVersionMismatch { server_supported } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"protocol version mismatch: server supports {server_supported:?}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Self::UnexpectedSignal(expected) => write!(f, "expected CallAnswer, got {expected}"),
|
|
||||||
Self::SignatureVerificationFailed => write!(f, "callee signature verification failed"),
|
|
||||||
Self::KeyDerivation(msg) => write!(f, "key derivation failed: {msg}"),
|
|
||||||
Self::Transport(e) => write!(f, "transport error: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for HandshakeError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Transport(e) => Some(e),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<wzp_proto::TransportError> for HandshakeError {
|
|
||||||
fn from(e: wzp_proto::TransportError) -> Self {
|
|
||||||
Self::Transport(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform the client (caller) side of the cryptographic handshake.
|
/// Perform the client (caller) side of the cryptographic handshake.
|
||||||
///
|
///
|
||||||
@@ -73,17 +18,7 @@ pub async fn perform_handshake(
|
|||||||
transport: &dyn MediaTransport,
|
transport: &dyn MediaTransport,
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
alias: Option<&str>,
|
alias: Option<&str>,
|
||||||
) -> Result<HandshakeResult, HandshakeError> {
|
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||||
perform_handshake_with_video_codecs(transport, seed, alias, SUPPORTED_VIDEO_CODECS.to_vec())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn perform_handshake_with_video_codecs(
|
|
||||||
transport: &dyn MediaTransport,
|
|
||||||
seed: &[u8; 32],
|
|
||||||
alias: Option<&str>,
|
|
||||||
video_codecs: Vec<CodecId>,
|
|
||||||
) -> Result<HandshakeResult, HandshakeError> {
|
|
||||||
// 1. Create key exchange from identity seed
|
// 1. Create key exchange from identity seed
|
||||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||||
let identity_pub = kx.identity_public_key();
|
let identity_pub = kx.identity_public_key();
|
||||||
@@ -99,79 +34,52 @@ pub async fn perform_handshake_with_video_codecs(
|
|||||||
|
|
||||||
// 4. Send CallOffer
|
// 4. Send CallOffer
|
||||||
let offer = SignalMessage::CallOffer {
|
let offer = SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub,
|
identity_pub,
|
||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
QualityProfile::STUDIO_64K,
|
|
||||||
QualityProfile::STUDIO_48K,
|
|
||||||
QualityProfile::STUDIO_32K,
|
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
],
|
],
|
||||||
alias: alias.map(|s| s.to_string()),
|
alias: alias.map(|s| s.to_string()),
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs,
|
|
||||||
};
|
};
|
||||||
transport
|
transport.send_signal(&offer).await?;
|
||||||
.send_signal(&offer)
|
|
||||||
.await
|
|
||||||
.map_err(HandshakeError::Transport)?;
|
|
||||||
|
|
||||||
// 5. Wait for CallAnswer — 10s timeout guards against relay not responding.
|
// 5. Wait for CallAnswer
|
||||||
let answer = tokio::time::timeout(std::time::Duration::from_secs(10), transport.recv_signal())
|
let answer = transport
|
||||||
.await
|
.recv_signal()
|
||||||
.map_err(|_| HandshakeError::Transport(wzp_proto::TransportError::Timeout { ms: 10_000 }))?
|
.await?
|
||||||
.map_err(HandshakeError::Transport)?
|
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?;
|
||||||
.ok_or(HandshakeError::ConnectionClosed)?;
|
|
||||||
|
|
||||||
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile, video_codec) =
|
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) = match answer
|
||||||
match answer {
|
{
|
||||||
SignalMessage::CallAnswer {
|
SignalMessage::CallAnswer {
|
||||||
identity_pub,
|
identity_pub,
|
||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
chosen_profile,
|
chosen_profile,
|
||||||
video_codec,
|
} => (identity_pub, ephemeral_pub, signature, chosen_profile),
|
||||||
..
|
other => {
|
||||||
} => (
|
return Err(anyhow::anyhow!(
|
||||||
identity_pub,
|
"expected CallAnswer, got {:?}",
|
||||||
ephemeral_pub,
|
std::mem::discriminant(&other)
|
||||||
signature,
|
))
|
||||||
chosen_profile,
|
}
|
||||||
video_codec,
|
};
|
||||||
),
|
|
||||||
SignalMessage::Hangup {
|
|
||||||
reason: HangupReason::ProtocolVersionMismatch { server_supported },
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
return Err(HandshakeError::ProtocolVersionMismatch { server_supported });
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(HandshakeError::UnexpectedSignal("CallAnswer"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Verify callee's signature over (ephemeral_pub || "call-answer")
|
// 6. Verify callee's signature over (ephemeral_pub || "call-answer")
|
||||||
let mut verify_data = Vec::with_capacity(32 + 11);
|
let mut verify_data = Vec::with_capacity(32 + 11);
|
||||||
verify_data.extend_from_slice(&callee_ephemeral_pub);
|
verify_data.extend_from_slice(&callee_ephemeral_pub);
|
||||||
verify_data.extend_from_slice(b"call-answer");
|
verify_data.extend_from_slice(b"call-answer");
|
||||||
if !WarzoneKeyExchange::verify(&callee_identity_pub, &verify_data, &callee_signature) {
|
if !WarzoneKeyExchange::verify(&callee_identity_pub, &verify_data, &callee_signature) {
|
||||||
return Err(HandshakeError::SignatureVerificationFailed);
|
return Err(anyhow::anyhow!("callee signature verification failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Derive session
|
// 7. Derive session
|
||||||
let session = kx
|
let session = kx.derive_session(&callee_ephemeral_pub)?;
|
||||||
.derive_session(&callee_ephemeral_pub)
|
|
||||||
.map_err(|e| HandshakeError::KeyDerivation(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(HandshakeResult {
|
Ok(session)
|
||||||
session,
|
|
||||||
video_codec,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -193,34 +101,4 @@ mod tests {
|
|||||||
&sig,
|
&sig,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handshake_result_carries_video_codec() {
|
|
||||||
// Verify that HandshakeResult has both fields accessible and that
|
|
||||||
// None is the correct default for audio-only peers.
|
|
||||||
let mut kx = WarzoneKeyExchange::from_identity_seed(&[0x55; 32]);
|
|
||||||
kx.generate_ephemeral();
|
|
||||||
let session = kx.derive_session(&[0u8; 32]).unwrap();
|
|
||||||
let hs = HandshakeResult {
|
|
||||||
session,
|
|
||||||
video_codec: None,
|
|
||||||
};
|
|
||||||
assert!(hs.video_codec.is_none());
|
|
||||||
|
|
||||||
let mut kx2 = WarzoneKeyExchange::from_identity_seed(&[0x66; 32]);
|
|
||||||
kx2.generate_ephemeral();
|
|
||||||
let session2 = kx2.derive_session(&[0u8; 32]).unwrap();
|
|
||||||
let hs2 = HandshakeResult {
|
|
||||||
session: session2,
|
|
||||||
video_codec: Some(CodecId::H264Baseline),
|
|
||||||
};
|
|
||||||
assert_eq!(hs2.video_codec, Some(CodecId::H264Baseline));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn offer_contains_h264_only() {
|
|
||||||
// Keep room video on the common denominator until Android AV1/HEVC
|
|
||||||
// send paths are proven in-device.
|
|
||||||
assert_eq!(SUPPORTED_VIDEO_CODECS, &[CodecId::H264Baseline]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,440 +0,0 @@
|
|||||||
//! Phase 8 (Tailscale-inspired): ICE agent for candidate lifecycle
|
|
||||||
//! management and mid-call re-gathering.
|
|
||||||
//!
|
|
||||||
//! The `IceAgent` owns the state of all candidate discovery
|
|
||||||
//! mechanisms (STUN, port mapping, host candidates) and provides:
|
|
||||||
//!
|
|
||||||
//! - `gather()`: initial candidate gathering during call setup
|
|
||||||
//! - `re_gather()`: triggered on network change, produces a
|
|
||||||
//! `CandidateUpdate` to send to the peer
|
|
||||||
//! - `apply_peer_update()`: processes peer's candidate updates
|
|
||||||
//!
|
|
||||||
//! This is NOT a full ICE agent (RFC 8445). It's the Tailscale-style
|
|
||||||
//! "gather all candidates, race them all in parallel, pick the
|
|
||||||
//! winner" approach, adapted for QUIC transport.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use wzp_proto::{SignalMessage, default_signal_version};
|
|
||||||
|
|
||||||
use crate::dual_path::PeerCandidates;
|
|
||||||
use crate::portmap;
|
|
||||||
use crate::reflect;
|
|
||||||
use crate::stun;
|
|
||||||
|
|
||||||
/// All candidates gathered for the local side.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CandidateSet {
|
|
||||||
/// STUN-discovered server-reflexive address.
|
|
||||||
pub reflexive: Option<SocketAddr>,
|
|
||||||
/// LAN host candidates from local interfaces.
|
|
||||||
pub local: Vec<SocketAddr>,
|
|
||||||
/// Port-mapped address from NAT-PMP/PCP/UPnP.
|
|
||||||
pub mapped: Option<SocketAddr>,
|
|
||||||
/// Generation counter (monotonically increasing per call).
|
|
||||||
pub generation: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for the ICE agent.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct IceAgentConfig {
|
|
||||||
/// STUN servers to use for reflexive discovery.
|
|
||||||
pub stun_config: stun::StunConfig,
|
|
||||||
/// Whether to attempt port mapping.
|
|
||||||
pub enable_portmap: bool,
|
|
||||||
/// Timeout for each discovery mechanism.
|
|
||||||
pub gather_timeout: Duration,
|
|
||||||
/// The QUIC endpoint's local port (for host candidate pairing).
|
|
||||||
pub local_v4_port: u16,
|
|
||||||
/// Optional IPv6 port.
|
|
||||||
pub local_v6_port: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for IceAgentConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
stun_config: stun::StunConfig::default(),
|
|
||||||
enable_portmap: true,
|
|
||||||
gather_timeout: Duration::from_secs(3),
|
|
||||||
local_v4_port: 0,
|
|
||||||
local_v6_port: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ICE agent managing candidate lifecycle.
|
|
||||||
pub struct IceAgent {
|
|
||||||
config: IceAgentConfig,
|
|
||||||
generation: AtomicU32,
|
|
||||||
call_id: String,
|
|
||||||
/// Last-seen peer generation (to filter stale updates).
|
|
||||||
peer_generation: AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IceAgent {
|
|
||||||
pub fn new(call_id: String, config: IceAgentConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
generation: AtomicU32::new(0),
|
|
||||||
call_id,
|
|
||||||
peer_generation: AtomicU32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initial candidate gathering. Runs all discovery mechanisms
|
|
||||||
/// in parallel and returns the full candidate set.
|
|
||||||
pub async fn gather(&self) -> CandidateSet {
|
|
||||||
let generation = self.generation.fetch_add(1, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Run STUN + port mapping + host candidates in parallel.
|
|
||||||
let stun_fut = stun::discover_reflexive(&self.config.stun_config);
|
|
||||||
let portmap_fut = async {
|
|
||||||
if self.config.enable_portmap && self.config.local_v4_port > 0 {
|
|
||||||
portmap::acquire_port_mapping(self.config.local_v4_port, None)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (stun_result, portmap_result) = tokio::join!(
|
|
||||||
tokio::time::timeout(self.config.gather_timeout, stun_fut),
|
|
||||||
tokio::time::timeout(self.config.gather_timeout, portmap_fut),
|
|
||||||
);
|
|
||||||
|
|
||||||
let reflexive = stun_result.ok().and_then(|r| r.ok());
|
|
||||||
let mapped = portmap_result.ok().flatten().map(|m| m.external_addr);
|
|
||||||
let local =
|
|
||||||
reflect::local_host_candidates(self.config.local_v4_port, self.config.local_v6_port);
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
generation,
|
|
||||||
reflexive = ?reflexive,
|
|
||||||
mapped = ?mapped,
|
|
||||||
local_count = local.len(),
|
|
||||||
"ice_agent: gathered candidates"
|
|
||||||
);
|
|
||||||
|
|
||||||
CandidateSet {
|
|
||||||
reflexive,
|
|
||||||
local,
|
|
||||||
mapped,
|
|
||||||
generation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-gather candidates after a network change. Increments the
|
|
||||||
/// generation counter and returns a `CandidateUpdate` signal
|
|
||||||
/// message to send to the peer.
|
|
||||||
pub async fn re_gather(&self) -> (CandidateSet, SignalMessage) {
|
|
||||||
let candidates = self.gather().await;
|
|
||||||
|
|
||||||
let update = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: self.call_id.clone(),
|
|
||||||
reflexive_addr: candidates.reflexive.map(|a| a.to_string()),
|
|
||||||
local_addrs: candidates.local.iter().map(|a| a.to_string()).collect(),
|
|
||||||
mapped_addr: candidates.mapped.map(|a| a.to_string()),
|
|
||||||
generation: candidates.generation,
|
|
||||||
};
|
|
||||||
|
|
||||||
(candidates, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a peer's candidate update. Returns `Some(PeerCandidates)`
|
|
||||||
/// if the update is newer than the last-seen generation, `None`
|
|
||||||
/// if it's stale.
|
|
||||||
pub fn apply_peer_update(&self, update: &SignalMessage) -> Option<PeerCandidates> {
|
|
||||||
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
|
|
||||||
SignalMessage::CandidateUpdate {
|
|
||||||
reflexive_addr,
|
|
||||||
local_addrs,
|
|
||||||
mapped_addr,
|
|
||||||
generation,
|
|
||||||
..
|
|
||||||
} => (reflexive_addr, local_addrs, mapped_addr, *generation),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only accept if newer than last-seen generation.
|
|
||||||
let prev = self.peer_generation.fetch_max(generation, Ordering::AcqRel);
|
|
||||||
if generation <= prev {
|
|
||||||
tracing::debug!(
|
|
||||||
generation,
|
|
||||||
prev,
|
|
||||||
"ice_agent: ignoring stale CandidateUpdate"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reflexive = reflexive_addr.as_deref().and_then(|s| s.parse().ok());
|
|
||||||
let local: Vec<SocketAddr> = local_addrs.iter().filter_map(|s| s.parse().ok()).collect();
|
|
||||||
let mapped = mapped_addr.as_deref().and_then(|s| s.parse().ok());
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
generation,
|
|
||||||
reflexive = ?reflexive,
|
|
||||||
mapped = ?mapped,
|
|
||||||
local_count = local.len(),
|
|
||||||
"ice_agent: applied peer candidate update"
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(PeerCandidates {
|
|
||||||
reflexive,
|
|
||||||
local,
|
|
||||||
mapped,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current generation counter.
|
|
||||||
pub fn generation(&self) -> u32 {
|
|
||||||
self.generation.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_peer_update_rejects_stale() {
|
|
||||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
|
||||||
|
|
||||||
// First update (gen=1) should succeed.
|
|
||||||
let update1 = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test-call".into(),
|
|
||||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
||||||
local_addrs: vec!["192.168.1.10:4433".into()],
|
|
||||||
mapped_addr: None,
|
|
||||||
generation: 1,
|
|
||||||
};
|
|
||||||
let result = agent.apply_peer_update(&update1);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let candidates = result.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
candidates.reflexive,
|
|
||||||
Some("203.0.113.5:4433".parse().unwrap())
|
|
||||||
);
|
|
||||||
assert_eq!(candidates.local.len(), 1);
|
|
||||||
|
|
||||||
// Same generation (gen=1) should be rejected.
|
|
||||||
let update1b = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test-call".into(),
|
|
||||||
reflexive_addr: Some("198.51.100.9:4433".into()),
|
|
||||||
local_addrs: vec![],
|
|
||||||
mapped_addr: None,
|
|
||||||
generation: 1,
|
|
||||||
};
|
|
||||||
assert!(agent.apply_peer_update(&update1b).is_none());
|
|
||||||
|
|
||||||
// Older generation (gen=0) should be rejected.
|
|
||||||
let update0 = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test-call".into(),
|
|
||||||
reflexive_addr: Some("10.0.0.1:4433".into()),
|
|
||||||
local_addrs: vec![],
|
|
||||||
mapped_addr: None,
|
|
||||||
generation: 0,
|
|
||||||
};
|
|
||||||
assert!(agent.apply_peer_update(&update0).is_none());
|
|
||||||
|
|
||||||
// Newer generation (gen=2) should succeed.
|
|
||||||
let update2 = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test-call".into(),
|
|
||||||
reflexive_addr: Some("198.51.100.9:5555".into()),
|
|
||||||
local_addrs: vec![],
|
|
||||||
mapped_addr: Some("203.0.113.5:12345".into()),
|
|
||||||
generation: 2,
|
|
||||||
};
|
|
||||||
let result = agent.apply_peer_update(&update2);
|
|
||||||
assert!(result.is_some());
|
|
||||||
let candidates = result.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
candidates.reflexive,
|
|
||||||
Some("198.51.100.9:5555".parse().unwrap())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
candidates.mapped,
|
|
||||||
Some("203.0.113.5:12345".parse().unwrap())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_wrong_signal_returns_none() {
|
|
||||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
|
||||||
let wrong = SignalMessage::Reflect;
|
|
||||||
assert!(agent.apply_peer_update(&wrong).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generation_increments() {
|
|
||||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
|
||||||
assert_eq!(agent.generation(), 0);
|
|
||||||
// Simulate what gather() does internally
|
|
||||||
let g1 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
|
||||||
assert_eq!(g1, 0);
|
|
||||||
assert_eq!(agent.generation(), 1);
|
|
||||||
let g2 = agent.generation.fetch_add(1, Ordering::Relaxed);
|
|
||||||
assert_eq!(g2, 1);
|
|
||||||
assert_eq!(agent.generation(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_peer_update_parses_all_fields() {
|
|
||||||
let agent = IceAgent::new("test-call".into(), IceAgentConfig::default());
|
|
||||||
|
|
||||||
let update = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test-call".into(),
|
|
||||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
||||||
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
|
|
||||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
|
||||||
generation: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
candidates.reflexive,
|
|
||||||
Some("203.0.113.5:4433".parse().unwrap())
|
|
||||||
);
|
|
||||||
assert_eq!(candidates.local.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
candidates.local[0],
|
|
||||||
"192.168.1.10:4433".parse::<SocketAddr>().unwrap()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
candidates.mapped,
|
|
||||||
Some("198.51.100.42:12345".parse().unwrap())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_peer_update_handles_empty_fields() {
|
|
||||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
|
||||||
|
|
||||||
let update = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test".into(),
|
|
||||||
reflexive_addr: None,
|
|
||||||
local_addrs: vec![],
|
|
||||||
mapped_addr: None,
|
|
||||||
generation: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
|
||||||
assert!(candidates.reflexive.is_none());
|
|
||||||
assert!(candidates.local.is_empty());
|
|
||||||
assert!(candidates.mapped.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn apply_peer_update_skips_unparseable_addrs() {
|
|
||||||
let agent = IceAgent::new("test".into(), IceAgentConfig::default());
|
|
||||||
|
|
||||||
let update = SignalMessage::CandidateUpdate {
|
|
||||||
version: default_signal_version(),
|
|
||||||
call_id: "test".into(),
|
|
||||||
reflexive_addr: Some("not-an-addr".into()),
|
|
||||||
local_addrs: vec![
|
|
||||||
"192.168.1.10:4433".into(),
|
|
||||||
"garbage".into(),
|
|
||||||
"10.0.0.5:4433".into(),
|
|
||||||
],
|
|
||||||
mapped_addr: Some("also-bad".into()),
|
|
||||||
generation: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let candidates = agent.apply_peer_update(&update).unwrap();
|
|
||||||
assert!(candidates.reflexive.is_none()); // unparseable
|
|
||||||
assert_eq!(candidates.local.len(), 2); // garbage filtered
|
|
||||||
assert!(candidates.mapped.is_none()); // unparseable
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_config_values() {
|
|
||||||
let cfg = IceAgentConfig::default();
|
|
||||||
assert!(cfg.enable_portmap);
|
|
||||||
assert!(cfg.gather_timeout.as_secs() > 0);
|
|
||||||
assert!(!cfg.stun_config.servers.is_empty());
|
|
||||||
assert_eq!(cfg.local_v4_port, 0);
|
|
||||||
assert!(cfg.local_v6_port.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gather_returns_candidates_even_with_no_stun() {
|
|
||||||
// With default config (port 0 = no portmap, STUN will timeout
|
|
||||||
// quickly on loopback), gather should still return host candidates.
|
|
||||||
let agent = IceAgent::new(
|
|
||||||
"test".into(),
|
|
||||||
IceAgentConfig {
|
|
||||||
stun_config: stun::StunConfig {
|
|
||||||
servers: vec![], // no servers = quick failure
|
|
||||||
timeout: Duration::from_millis(100),
|
|
||||||
},
|
|
||||||
enable_portmap: false,
|
|
||||||
gather_timeout: Duration::from_millis(200),
|
|
||||||
local_v4_port: 12345,
|
|
||||||
local_v6_port: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let candidates = agent.gather().await;
|
|
||||||
assert_eq!(candidates.generation, 0);
|
|
||||||
// Reflexive should be None (no STUN servers)
|
|
||||||
assert!(candidates.reflexive.is_none());
|
|
||||||
// Mapped should be None (portmap disabled)
|
|
||||||
assert!(candidates.mapped.is_none());
|
|
||||||
// Local candidates depend on the machine's interfaces
|
|
||||||
// but gather() should not panic.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn re_gather_produces_signal_message() {
|
|
||||||
let agent = IceAgent::new(
|
|
||||||
"call-42".into(),
|
|
||||||
IceAgentConfig {
|
|
||||||
stun_config: stun::StunConfig {
|
|
||||||
servers: vec![],
|
|
||||||
timeout: Duration::from_millis(50),
|
|
||||||
},
|
|
||||||
enable_portmap: false,
|
|
||||||
gather_timeout: Duration::from_millis(100),
|
|
||||||
local_v4_port: 4433,
|
|
||||||
local_v6_port: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let (candidates, signal) = agent.re_gather().await;
|
|
||||||
assert_eq!(candidates.generation, 0);
|
|
||||||
|
|
||||||
match signal {
|
|
||||||
SignalMessage::CandidateUpdate {
|
|
||||||
call_id,
|
|
||||||
generation,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
assert_eq!(call_id, "call-42");
|
|
||||||
assert_eq!(generation, 0);
|
|
||||||
}
|
|
||||||
_ => panic!("expected CandidateUpdate"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second re_gather increments generation
|
|
||||||
let (candidates2, signal2) = agent.re_gather().await;
|
|
||||||
assert_eq!(candidates2.generation, 1);
|
|
||||||
match signal2 {
|
|
||||||
SignalMessage::CandidateUpdate { generation, .. } => {
|
|
||||||
assert_eq!(generation, 1);
|
|
||||||
}
|
|
||||||
_ => panic!("expected CandidateUpdate"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,82 +10,18 @@
|
|||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_ring;
|
pub mod audio_ring;
|
||||||
// VoiceProcessingIO is an Apple Core Audio API — only compile the module
|
#[cfg(feature = "vpio")]
|
||||||
// when the `vpio` feature is on AND we're targeting macOS. Enabling the
|
|
||||||
// feature on Windows/Linux was previously silently broken.
|
|
||||||
#[cfg(all(feature = "vpio", target_os = "macos"))]
|
|
||||||
pub mod audio_vpio;
|
pub mod audio_vpio;
|
||||||
// WASAPI-direct capture with Windows's OS-level AEC (AudioCategory_Communications).
|
|
||||||
// Only compiled when `windows-aec` feature is on AND target is Windows. The
|
|
||||||
// `windows` dependency is itself gated to Windows in Cargo.toml, so enabling
|
|
||||||
// this feature on non-Windows targets is a no-op.
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub mod audio_wasapi;
|
|
||||||
// WebRTC AEC3 (Audio Processing Module) wrapper around CPAL capture + playback
|
|
||||||
// on Linux. Only compiled when `linux-aec` feature is on AND target is Linux.
|
|
||||||
// The webrtc-audio-processing dep is itself gated to Linux in Cargo.toml.
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub mod audio_linux_aec;
|
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod birthday;
|
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod encrypted_transport;
|
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
pub mod dual_path;
|
|
||||||
pub mod echo_test;
|
pub mod echo_test;
|
||||||
pub mod featherchat;
|
pub mod featherchat;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod ice_agent;
|
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod netcheck;
|
|
||||||
pub mod portmap;
|
|
||||||
pub mod reflect;
|
|
||||||
pub mod relay_map;
|
|
||||||
pub mod stun;
|
|
||||||
pub mod sweep;
|
pub mod sweep;
|
||||||
|
|
||||||
// AudioPlayback: three possible backends depending on feature flags.
|
#[cfg(feature = "audio")]
|
||||||
// 1. Default CPAL (`audio_io::AudioPlayback`) — baseline on every platform.
|
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||||
// 2. Linux AEC (`audio_linux_aec::LinuxAecPlayback`) — CPAL + WebRTC APM
|
|
||||||
// render-side tee, so echo from speakers gets cancelled from the mic.
|
|
||||||
//
|
|
||||||
// On macOS and Windows we always use the default CPAL playback because:
|
|
||||||
// - macOS: VoiceProcessingIO handles AEC at the capture side (Apple's
|
|
||||||
// native hardware AEC uses its own reference signal handling).
|
|
||||||
// - Windows: WASAPI AudioCategory_Communications AEC uses the system
|
|
||||||
// render mix as reference — no per-process plumbing needed.
|
|
||||||
//
|
|
||||||
// Linux is the only platform where the in-app approach is necessary, so
|
|
||||||
// the AEC playback path is gated to target_os = "linux".
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioPlayback;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecPlayback as AudioPlayback;
|
|
||||||
|
|
||||||
// AudioCapture: three possible backends depending on feature flags.
|
|
||||||
// 1. Default CPAL (`audio_io::AudioCapture`) — baseline on every platform.
|
|
||||||
// 2. Windows AEC (`audio_wasapi::WasapiAudioCapture`) — direct WASAPI
|
|
||||||
// with AudioCategory_Communications, OS APO chain does AEC.
|
|
||||||
// 3. Linux AEC (`audio_linux_aec::LinuxAecCapture`) — CPAL + WebRTC APM
|
|
||||||
// capture-side echo cancellation using the playback tee as reference.
|
|
||||||
// All three expose the same public API (`start`, `ring`, `stop`, `Drop`).
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "windows-aec"), not(target_os = "windows")),
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub use audio_wasapi::WasapiAudioCapture as AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecCapture as AudioCapture;
|
|
||||||
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
pub use handshake::perform_handshake;
|
pub use handshake::perform_handshake;
|
||||||
|
|||||||
@@ -178,10 +178,7 @@ mod tests {
|
|||||||
|
|
||||||
// Immediate second write should be skipped (60s interval).
|
// Immediate second write should be skipped (60s interval).
|
||||||
let second = writer.maybe_write(&snap).unwrap();
|
let second = writer.maybe_write(&snap).unwrap();
|
||||||
assert!(
|
assert!(!second, "second write should be skipped — interval not elapsed");
|
||||||
!second,
|
|
||||||
"second write should be skipped — interval not elapsed"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up.
|
// Clean up.
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|||||||
@@ -1,537 +0,0 @@
|
|||||||
//! Phase 8 (Tailscale-inspired): Comprehensive network diagnostic.
|
|
||||||
//!
|
|
||||||
//! Probes STUN servers, relay infrastructure, port mapping
|
|
||||||
//! capabilities, IPv6 reachability, and NAT hairpinning in parallel
|
|
||||||
//! to produce a `NetcheckReport` that captures the client's network
|
|
||||||
//! environment at a point in time.
|
|
||||||
//!
|
|
||||||
//! Used for:
|
|
||||||
//! - Troubleshooting connectivity issues
|
|
||||||
//! - Automatic relay selection (Phase 5)
|
|
||||||
//! - Pre-call NAT assessment
|
|
||||||
//! - Quality prediction
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::portmap::{self, PortMapProtocol};
|
|
||||||
use crate::reflect::{self, NatType};
|
|
||||||
use crate::stun::{self, StunConfig};
|
|
||||||
|
|
||||||
/// Complete network diagnostic report.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct NetcheckReport {
|
|
||||||
/// NAT type classification (from combined STUN + relay probes).
|
|
||||||
pub nat_type: NatType,
|
|
||||||
/// Server-reflexive address (consensus from probes).
|
|
||||||
pub reflexive_addr: Option<String>,
|
|
||||||
/// Whether IPv4 connectivity is available.
|
|
||||||
pub ipv4_reachable: bool,
|
|
||||||
/// Whether IPv6 connectivity is available.
|
|
||||||
pub ipv6_reachable: bool,
|
|
||||||
/// Whether the NAT supports hairpinning (loopback to own
|
|
||||||
/// reflexive address).
|
|
||||||
pub hairpin_works: Option<bool>,
|
|
||||||
/// Which port mapping protocol is available (if any).
|
|
||||||
pub port_mapping: Option<PortMapProtocol>,
|
|
||||||
/// Per-relay latency measurements.
|
|
||||||
pub relay_latencies: Vec<RelayLatency>,
|
|
||||||
/// Preferred relay (lowest latency).
|
|
||||||
pub preferred_relay: Option<String>,
|
|
||||||
/// STUN latency to first responding server (ms).
|
|
||||||
pub stun_latency_ms: Option<u32>,
|
|
||||||
/// Whether UPnP is available on the gateway.
|
|
||||||
pub upnp_available: bool,
|
|
||||||
/// Whether PCP is available on the gateway.
|
|
||||||
pub pcp_available: bool,
|
|
||||||
/// Whether NAT-PMP is available on the gateway.
|
|
||||||
pub nat_pmp_available: bool,
|
|
||||||
/// Default gateway address.
|
|
||||||
pub gateway: Option<String>,
|
|
||||||
/// Total time taken for the diagnostic (ms).
|
|
||||||
pub duration_ms: u32,
|
|
||||||
/// Individual STUN probe results.
|
|
||||||
pub stun_probes: Vec<reflect::NatProbeResult>,
|
|
||||||
/// NAT port allocation pattern (sequential vs random).
|
|
||||||
pub port_allocation: Option<stun::PortAllocation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Latency to a specific relay.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct RelayLatency {
|
|
||||||
pub name: String,
|
|
||||||
pub addr: String,
|
|
||||||
pub rtt_ms: Option<u32>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for the netcheck run.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NetcheckConfig {
|
|
||||||
/// STUN servers to probe.
|
|
||||||
pub stun_config: StunConfig,
|
|
||||||
/// Relay servers to probe (name, address pairs).
|
|
||||||
pub relays: Vec<(String, SocketAddr)>,
|
|
||||||
/// Per-probe timeout.
|
|
||||||
pub timeout: Duration,
|
|
||||||
/// Whether to test port mapping.
|
|
||||||
pub test_portmap: bool,
|
|
||||||
/// Whether to test IPv6.
|
|
||||||
pub test_ipv6: bool,
|
|
||||||
/// Local port for port mapping test (0 = skip).
|
|
||||||
pub local_port: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NetcheckConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
stun_config: StunConfig::default(),
|
|
||||||
relays: Vec::new(),
|
|
||||||
timeout: Duration::from_secs(5),
|
|
||||||
test_portmap: true,
|
|
||||||
test_ipv6: true,
|
|
||||||
local_port: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a comprehensive network diagnostic.
|
|
||||||
///
|
|
||||||
/// Probes run in parallel for speed — the total time is bounded
|
|
||||||
/// by the slowest individual probe, not the sum.
|
|
||||||
pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// Run all probes in parallel.
|
|
||||||
let stun_fut = stun::probe_stun_servers(&config.stun_config);
|
|
||||||
let relay_fut = probe_relays(&config.relays, config.timeout);
|
|
||||||
let portmap_fut = probe_portmap(config.test_portmap, config.local_port);
|
|
||||||
let gateway_fut = portmap::default_gateway();
|
|
||||||
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
|
|
||||||
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
|
|
||||||
|
|
||||||
let (
|
|
||||||
stun_probes,
|
|
||||||
relay_latencies,
|
|
||||||
portmap_result,
|
|
||||||
gateway_result,
|
|
||||||
ipv6_reachable,
|
|
||||||
port_alloc_result,
|
|
||||||
) = tokio::join!(
|
|
||||||
stun_fut,
|
|
||||||
relay_fut,
|
|
||||||
portmap_fut,
|
|
||||||
gateway_result_fut(gateway_fut),
|
|
||||||
ipv6_fut,
|
|
||||||
port_alloc_fut
|
|
||||||
);
|
|
||||||
|
|
||||||
// Classify NAT from STUN probes.
|
|
||||||
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
|
|
||||||
|
|
||||||
// Determine STUN latency (first successful probe).
|
|
||||||
let stun_latency_ms = stun_probes.iter().filter_map(|p| p.latency_ms).min();
|
|
||||||
|
|
||||||
// IPv4 reachable if any STUN probe succeeded.
|
|
||||||
let ipv4_reachable = stun_probes.iter().any(|p| p.observed_addr.is_some());
|
|
||||||
|
|
||||||
// Preferred relay = lowest RTT.
|
|
||||||
let preferred_relay = relay_latencies
|
|
||||||
.iter()
|
|
||||||
.filter_map(|r| r.rtt_ms.map(|rtt| (r.name.clone(), rtt)))
|
|
||||||
.min_by_key(|(_, rtt)| *rtt)
|
|
||||||
.map(|(name, _)| name);
|
|
||||||
|
|
||||||
// Port mapping availability.
|
|
||||||
let (port_mapping, nat_pmp_available, pcp_available, upnp_available) = match portmap_result {
|
|
||||||
Some(mapping) => {
|
|
||||||
let proto = mapping.protocol;
|
|
||||||
(
|
|
||||||
Some(proto),
|
|
||||||
proto == PortMapProtocol::NatPmp,
|
|
||||||
proto == PortMapProtocol::Pcp,
|
|
||||||
proto == PortMapProtocol::UPnP,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => (None, false, false, false),
|
|
||||||
};
|
|
||||||
|
|
||||||
let gateway = match gateway_result {
|
|
||||||
Ok(gw) => Some(gw.to_string()),
|
|
||||||
Err(_) => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
NetcheckReport {
|
|
||||||
nat_type,
|
|
||||||
reflexive_addr: consensus_addr,
|
|
||||||
ipv4_reachable,
|
|
||||||
ipv6_reachable,
|
|
||||||
hairpin_works: None, // TODO: implement hairpin test
|
|
||||||
port_mapping,
|
|
||||||
relay_latencies,
|
|
||||||
preferred_relay,
|
|
||||||
stun_latency_ms,
|
|
||||||
upnp_available,
|
|
||||||
pcp_available,
|
|
||||||
nat_pmp_available,
|
|
||||||
gateway,
|
|
||||||
duration_ms: start.elapsed().as_millis() as u32,
|
|
||||||
stun_probes,
|
|
||||||
port_allocation: Some(port_alloc_result.allocation),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe relay latencies via reflect.
|
|
||||||
async fn probe_relays(relays: &[(String, SocketAddr)], timeout: Duration) -> Vec<RelayLatency> {
|
|
||||||
if relays.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout_ms = timeout.as_millis() as u64;
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
|
||||||
|
|
||||||
for (name, addr) in relays {
|
|
||||||
let name = name.clone();
|
|
||||||
let addr = *addr;
|
|
||||||
set.spawn(async move {
|
|
||||||
let start = Instant::now();
|
|
||||||
match reflect::probe_reflect_addr(addr, timeout_ms, None).await {
|
|
||||||
Ok((_observed, _latency)) => RelayLatency {
|
|
||||||
name,
|
|
||||||
addr: addr.to_string(),
|
|
||||||
rtt_ms: Some(start.elapsed().as_millis() as u32),
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
Err(e) => RelayLatency {
|
|
||||||
name,
|
|
||||||
addr: addr.to_string(),
|
|
||||||
rtt_ms: None,
|
|
||||||
error: Some(e),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(relays.len());
|
|
||||||
while let Some(join_result) = set.join_next().await {
|
|
||||||
match join_result {
|
|
||||||
Ok(r) => results.push(r),
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by RTT (lowest first).
|
|
||||||
results.sort_by_key(|r| r.rtt_ms.unwrap_or(u32::MAX));
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt port mapping and return the mapping if successful.
|
|
||||||
async fn probe_portmap(enabled: bool, local_port: u16) -> Option<portmap::PortMapping> {
|
|
||||||
if !enabled || local_port == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
portmap::acquire_port_mapping(local_port, None).await.ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrap the gateway future to handle the Result.
|
|
||||||
async fn gateway_result_fut(
|
|
||||||
fut: impl std::future::Future<Output = Result<std::net::Ipv4Addr, portmap::PortMapError>>,
|
|
||||||
) -> Result<std::net::Ipv4Addr, portmap::PortMapError> {
|
|
||||||
fut.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test IPv6 connectivity by attempting to bind and send on an IPv6 socket.
|
|
||||||
async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
|
|
||||||
if !enabled {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve and connect to an IPv6 STUN server.
|
|
||||||
let result = tokio::time::timeout(timeout, async {
|
|
||||||
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
|
|
||||||
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record
|
|
||||||
// and we can send a packet, IPv6 is working.
|
|
||||||
let addr = stun::resolve_stun_server("stun.l.google.com:19302")
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
if addr.is_ipv6() {
|
|
||||||
sock.send_to(&[0u8; 1], addr).await.ok()?;
|
|
||||||
Some(true)
|
|
||||||
} else {
|
|
||||||
// Server resolved to IPv4 — try binding to [::] at least
|
|
||||||
Some(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Some(true)) => true,
|
|
||||||
_ => {
|
|
||||||
// Fallback: can we at least bind an IPv6 socket?
|
|
||||||
tokio::net::UdpSocket::bind("[::]:0").await.is_ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a netcheck report as a human-readable string.
|
|
||||||
pub fn format_report(report: &NetcheckReport) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
|
|
||||||
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
|
|
||||||
out.push_str(&format!("NAT Type: {:?}\n", report.nat_type));
|
|
||||||
out.push_str(&format!(
|
|
||||||
"Reflexive Addr: {}\n",
|
|
||||||
report.reflexive_addr.as_deref().unwrap_or("(unknown)")
|
|
||||||
));
|
|
||||||
out.push_str(&format!(
|
|
||||||
"IPv4: {}\n",
|
|
||||||
if report.ipv4_reachable { "yes" } else { "no" }
|
|
||||||
));
|
|
||||||
out.push_str(&format!(
|
|
||||||
"IPv6: {}\n",
|
|
||||||
if report.ipv6_reachable { "yes" } else { "no" }
|
|
||||||
));
|
|
||||||
out.push_str(&format!(
|
|
||||||
"Gateway: {}\n",
|
|
||||||
report.gateway.as_deref().unwrap_or("(unknown)")
|
|
||||||
));
|
|
||||||
|
|
||||||
if let Some(ref alloc) = report.port_allocation {
|
|
||||||
out.push_str(&format!("Port Alloc: {alloc}\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push_str(&format!("\n--- Port Mapping ---\n"));
|
|
||||||
out.push_str(&format!(
|
|
||||||
"NAT-PMP: {} PCP: {} UPnP: {}\n",
|
|
||||||
if report.nat_pmp_available {
|
|
||||||
"yes"
|
|
||||||
} else {
|
|
||||||
"no"
|
|
||||||
},
|
|
||||||
if report.pcp_available { "yes" } else { "no" },
|
|
||||||
if report.upnp_available { "yes" } else { "no" },
|
|
||||||
));
|
|
||||||
if let Some(proto) = &report.port_mapping {
|
|
||||||
out.push_str(&format!("Active mapping: {:?}\n", proto));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.stun_probes.is_empty() {
|
|
||||||
out.push_str(&format!("\n--- STUN Probes ---\n"));
|
|
||||||
for p in &report.stun_probes {
|
|
||||||
out.push_str(&format!(
|
|
||||||
" {} → {} ({}ms){}\n",
|
|
||||||
p.relay_name,
|
|
||||||
p.observed_addr.as_deref().unwrap_or("failed"),
|
|
||||||
p.latency_ms
|
|
||||||
.map(|ms| ms.to_string())
|
|
||||||
.unwrap_or_else(|| "-".into()),
|
|
||||||
p.error
|
|
||||||
.as_ref()
|
|
||||||
.map(|e| format!(" [{e}]"))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !report.relay_latencies.is_empty() {
|
|
||||||
out.push_str(&format!("\n--- Relay Latencies ---\n"));
|
|
||||||
for r in &report.relay_latencies {
|
|
||||||
out.push_str(&format!(
|
|
||||||
" {} ({}) → {}ms{}\n",
|
|
||||||
r.name,
|
|
||||||
r.addr,
|
|
||||||
r.rtt_ms
|
|
||||||
.map(|ms| ms.to_string())
|
|
||||||
.unwrap_or_else(|| "-".into()),
|
|
||||||
r.error
|
|
||||||
.as_ref()
|
|
||||||
.map(|e| format!(" [{e}]"))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(ref pref) = report.preferred_relay {
|
|
||||||
out.push_str(&format!(" Preferred: {pref}\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push_str(&format!("\nCompleted in {}ms\n", report.duration_ms));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_config_has_stun_servers() {
|
|
||||||
let config = NetcheckConfig::default();
|
|
||||||
assert!(!config.stun_config.servers.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_report_produces_output() {
|
|
||||||
let report = NetcheckReport {
|
|
||||||
nat_type: NatType::Cone,
|
|
||||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
||||||
ipv4_reachable: true,
|
|
||||||
ipv6_reachable: false,
|
|
||||||
hairpin_works: None,
|
|
||||||
port_mapping: None,
|
|
||||||
relay_latencies: vec![RelayLatency {
|
|
||||||
name: "relay-1".into(),
|
|
||||||
addr: "10.0.0.1:4433".into(),
|
|
||||||
rtt_ms: Some(25),
|
|
||||||
error: None,
|
|
||||||
}],
|
|
||||||
preferred_relay: Some("relay-1".into()),
|
|
||||||
stun_latency_ms: Some(15),
|
|
||||||
upnp_available: false,
|
|
||||||
pcp_available: false,
|
|
||||||
nat_pmp_available: false,
|
|
||||||
gateway: Some("192.168.1.1".into()),
|
|
||||||
duration_ms: 1500,
|
|
||||||
stun_probes: vec![],
|
|
||||||
port_allocation: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let text = format_report(&report);
|
|
||||||
assert!(text.contains("Cone"));
|
|
||||||
assert!(text.contains("203.0.113.5:4433"));
|
|
||||||
assert!(text.contains("relay-1"));
|
|
||||||
assert!(text.contains("1500ms"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn report_serializes_to_json() {
|
|
||||||
let report = NetcheckReport {
|
|
||||||
nat_type: NatType::Cone,
|
|
||||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
|
||||||
ipv4_reachable: true,
|
|
||||||
ipv6_reachable: false,
|
|
||||||
hairpin_works: None,
|
|
||||||
port_mapping: Some(PortMapProtocol::NatPmp),
|
|
||||||
relay_latencies: vec![],
|
|
||||||
preferred_relay: None,
|
|
||||||
stun_latency_ms: Some(25),
|
|
||||||
upnp_available: false,
|
|
||||||
pcp_available: false,
|
|
||||||
nat_pmp_available: true,
|
|
||||||
gateway: Some("192.168.1.1".into()),
|
|
||||||
duration_ms: 500,
|
|
||||||
stun_probes: vec![],
|
|
||||||
port_allocation: Some(stun::PortAllocation::Sequential { delta: 1 }),
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&report).unwrap();
|
|
||||||
assert!(json.contains("Cone"));
|
|
||||||
assert!(json.contains("203.0.113.5:4433"));
|
|
||||||
assert!(json.contains("NatPmp"));
|
|
||||||
|
|
||||||
// Roundtrip
|
|
||||||
let decoded: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(decoded["ipv4_reachable"], true);
|
|
||||||
assert_eq!(decoded["ipv6_reachable"], false);
|
|
||||||
assert_eq!(decoded["stun_latency_ms"], 25);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relay_latency_serializes() {
|
|
||||||
let lat = RelayLatency {
|
|
||||||
name: "eu-west".into(),
|
|
||||||
addr: "10.0.0.1:4433".into(),
|
|
||||||
rtt_ms: Some(42),
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&lat).unwrap();
|
|
||||||
assert!(json.contains("eu-west"));
|
|
||||||
assert!(json.contains("42"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_report_empty_relays() {
|
|
||||||
let report = NetcheckReport {
|
|
||||||
nat_type: NatType::Unknown,
|
|
||||||
reflexive_addr: None,
|
|
||||||
ipv4_reachable: false,
|
|
||||||
ipv6_reachable: false,
|
|
||||||
hairpin_works: None,
|
|
||||||
port_mapping: None,
|
|
||||||
relay_latencies: vec![],
|
|
||||||
preferred_relay: None,
|
|
||||||
stun_latency_ms: None,
|
|
||||||
upnp_available: false,
|
|
||||||
pcp_available: false,
|
|
||||||
nat_pmp_available: false,
|
|
||||||
gateway: None,
|
|
||||||
duration_ms: 100,
|
|
||||||
stun_probes: vec![],
|
|
||||||
port_allocation: None,
|
|
||||||
};
|
|
||||||
let text = format_report(&report);
|
|
||||||
assert!(text.contains("Unknown"));
|
|
||||||
assert!(text.contains("(unknown)")); // reflexive addr
|
|
||||||
assert!(text.contains("100ms"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_report_with_stun_probes() {
|
|
||||||
let report = NetcheckReport {
|
|
||||||
nat_type: NatType::SymmetricPort,
|
|
||||||
reflexive_addr: None,
|
|
||||||
ipv4_reachable: true,
|
|
||||||
ipv6_reachable: true,
|
|
||||||
hairpin_works: Some(false),
|
|
||||||
port_mapping: Some(PortMapProtocol::UPnP),
|
|
||||||
relay_latencies: vec![
|
|
||||||
RelayLatency {
|
|
||||||
name: "us-east".into(),
|
|
||||||
addr: "10.0.0.1:4433".into(),
|
|
||||||
rtt_ms: Some(15),
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
RelayLatency {
|
|
||||||
name: "eu-west".into(),
|
|
||||||
addr: "10.0.0.2:4433".into(),
|
|
||||||
rtt_ms: None,
|
|
||||||
error: Some("timeout".into()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferred_relay: Some("us-east".into()),
|
|
||||||
stun_latency_ms: Some(20),
|
|
||||||
upnp_available: true,
|
|
||||||
pcp_available: false,
|
|
||||||
nat_pmp_available: false,
|
|
||||||
gateway: Some("192.168.0.1".into()),
|
|
||||||
duration_ms: 3000,
|
|
||||||
stun_probes: vec![reflect::NatProbeResult {
|
|
||||||
relay_name: "stun:google".into(),
|
|
||||||
relay_addr: "74.125.250.129:19302".into(),
|
|
||||||
observed_addr: Some("203.0.113.5:12345".into()),
|
|
||||||
latency_ms: Some(20),
|
|
||||||
error: None,
|
|
||||||
}],
|
|
||||||
port_allocation: Some(stun::PortAllocation::Random),
|
|
||||||
};
|
|
||||||
let text = format_report(&report);
|
|
||||||
assert!(text.contains("SymmetricPort"));
|
|
||||||
assert!(text.contains("us-east"));
|
|
||||||
assert!(text.contains("eu-west"));
|
|
||||||
assert!(text.contains("Preferred: us-east"));
|
|
||||||
assert!(text.contains("UPnP: yes"));
|
|
||||||
assert!(text.contains("stun:google"));
|
|
||||||
assert!(text.contains("3000ms"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Integration test: run actual netcheck (requires network).
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn integration_netcheck() {
|
|
||||||
let config = NetcheckConfig::default();
|
|
||||||
let report = run_netcheck(&config).await;
|
|
||||||
println!("{}", format_report(&report));
|
|
||||||
assert!(report.duration_ms > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,704 +0,0 @@
|
|||||||
//! Multi-relay NAT reflection ("STUN for QUIC" — Phase 2).
|
|
||||||
//!
|
|
||||||
//! Phase 1 (`SignalMessage::Reflect` / `ReflectResponse`) lets a
|
|
||||||
//! client ask a single relay "what source address do you see for
|
|
||||||
//! me?". Phase 2 queries N relays in parallel and classifies the
|
|
||||||
//! results into a NAT type so the future P2P hole-punching path
|
|
||||||
//! can decide whether a direct QUIC handshake is viable:
|
|
||||||
//!
|
|
||||||
//! - All relays return the same `(ip, port)` → **Cone NAT**.
|
|
||||||
//! Endpoint-independent mapping, P2P hole-punching viable,
|
|
||||||
//! `consensus_addr` is the one address to advertise.
|
|
||||||
//! - Same ip, different ports → **Symmetric port-dependent NAT**.
|
|
||||||
//! The mapping changes per destination, so the advertised addr
|
|
||||||
//! wouldn't match what a peer actually sees; fall back to
|
|
||||||
//! relay-mediated path.
|
|
||||||
//! - Different ips → multi-homed / anycast / broken DNS, treat as
|
|
||||||
//! `Multiple` and do not attempt P2P.
|
|
||||||
//! - 0 or 1 successful probes → `Unknown`, not enough data.
|
|
||||||
//!
|
|
||||||
//! A probe is a throwaway QUIC signal connection: open endpoint,
|
|
||||||
//! connect, RegisterPresence (with a zero identity — the relay
|
|
||||||
//! accepts this exactly like the main signaling path does), send
|
|
||||||
//! Reflect, read ReflectResponse, close. Each probe gets its own
|
|
||||||
//! ephemeral quinn::Endpoint so the OS assigns a fresh source port
|
|
||||||
//! per relay — if we shared one endpoint across probes, a
|
|
||||||
//! symmetric NAT in front of the client would map every probe to
|
|
||||||
//! the same port and we couldn't detect it.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage, default_signal_version};
|
|
||||||
use wzp_transport::{QuinnTransport, client_config, create_endpoint};
|
|
||||||
|
|
||||||
/// Result of one probe against one relay. Always returned so the
|
|
||||||
/// UI can render per-relay status even when some fail.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct NatProbeResult {
|
|
||||||
pub relay_name: String,
|
|
||||||
pub relay_addr: String,
|
|
||||||
/// `Some` on successful probe, `None` on failure.
|
|
||||||
pub observed_addr: Option<String>,
|
|
||||||
/// End-to-end wall-clock from connect start to ReflectResponse
|
|
||||||
/// received, in milliseconds. `Some` only on success.
|
|
||||||
pub latency_ms: Option<u32>,
|
|
||||||
/// Human-readable error on failure.
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregated classification over N `NatProbeResult`s.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct NatDetection {
|
|
||||||
pub probes: Vec<NatProbeResult>,
|
|
||||||
pub nat_type: NatType,
|
|
||||||
/// When `nat_type == Cone`, the one address all probes agreed
|
|
||||||
/// on. `None` for every other case.
|
|
||||||
pub consensus_addr: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NAT classification. See module doc for semantics.
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
|
||||||
pub enum NatType {
|
|
||||||
Cone,
|
|
||||||
SymmetricPort,
|
|
||||||
Multiple,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe a single relay with a QUIC connection.
|
|
||||||
///
|
|
||||||
/// # Endpoint reuse (Phase 5 — Nebula-style architecture)
|
|
||||||
///
|
|
||||||
/// If `existing_endpoint` is `Some`, the probe uses that socket
|
|
||||||
/// instead of creating a fresh one. This is the desired mode in
|
|
||||||
/// production: a port-preserving NAT (MikroTik masquerade, most
|
|
||||||
/// consumer routers) gives a **stable** external port for the
|
|
||||||
/// one socket, so the reflex addr observed by ANY relay is the
|
|
||||||
/// SAME addr and matches what a peer would see on a direct dial.
|
|
||||||
/// Pass the signal endpoint here.
|
|
||||||
///
|
|
||||||
/// If `None`, creates a fresh one-shot endpoint. Kept for:
|
|
||||||
/// - tests that spin up isolated probes
|
|
||||||
/// - the "I'm not registered yet" case where there's no signal
|
|
||||||
/// endpoint to reuse
|
|
||||||
///
|
|
||||||
/// NOTE on NAT-type detection: the pre-Phase-5 behavior of
|
|
||||||
/// forcing a fresh endpoint per probe was wrong — it made every
|
|
||||||
/// port-preserving NAT look symmetric because the classifier saw
|
|
||||||
/// a different external port for each fresh source port. With
|
|
||||||
/// one shared socket, the classifier reflects the REAL NAT
|
|
||||||
/// behavior.
|
|
||||||
pub async fn probe_reflect_addr(
|
|
||||||
relay: SocketAddr,
|
|
||||||
timeout_ms: u64,
|
|
||||||
existing_endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
) -> Result<(SocketAddr, u32), String> {
|
|
||||||
// Install rustls provider idempotently — a second install on the
|
|
||||||
// same thread is a no-op.
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let endpoint = match existing_endpoint {
|
|
||||||
Some(ep) => ep,
|
|
||||||
None => {
|
|
||||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
create_endpoint(bind, None).map_err(|e| format!("endpoint: {e}"))?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
let probe = async {
|
|
||||||
// Open the signal connection.
|
|
||||||
let conn = wzp_transport::connect(&endpoint, relay, "_signal", client_config())
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("connect: {e}"))?;
|
|
||||||
let transport = QuinnTransport::new(conn);
|
|
||||||
|
|
||||||
// The relay signal handler waits for a RegisterPresence
|
|
||||||
// before entering its main dispatch loop (see
|
|
||||||
// wzp-relay/src/main.rs). So a transient probe has to
|
|
||||||
// register with a zero identity first — the relay accepts
|
|
||||||
// the empty-signature form exactly as the main signaling
|
|
||||||
// path does in desktop/src-tauri/src/lib.rs register_signal.
|
|
||||||
transport
|
|
||||||
.send_signal(&SignalMessage::RegisterPresence {
|
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub: [0u8; 32],
|
|
||||||
signature: vec![],
|
|
||||||
alias: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("send RegisterPresence: {e}"))?;
|
|
||||||
// Drain the RegisterPresenceAck so the response to our
|
|
||||||
// Reflect doesn't land on an unexpected stream order.
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {}
|
|
||||||
Ok(Some(other)) => {
|
|
||||||
return Err(format!(
|
|
||||||
"unexpected pre-reflect signal: {:?}",
|
|
||||||
std::mem::discriminant(&other)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(None) => return Err("connection closed before RegisterPresenceAck".into()),
|
|
||||||
Err(e) => return Err(format!("recv RegisterPresenceAck: {e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send Reflect and await response.
|
|
||||||
transport
|
|
||||||
.send_signal(&SignalMessage::Reflect)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("send Reflect: {e}"))?;
|
|
||||||
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::ReflectResponse { observed_addr, .. })) => {
|
|
||||||
let parsed: SocketAddr = observed_addr
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("parse observed_addr {observed_addr:?}: {e}"))?;
|
|
||||||
let latency_ms = start.elapsed().as_millis() as u32;
|
|
||||||
|
|
||||||
// Clean close so the relay's per-connection cleanup
|
|
||||||
// runs promptly and we don't leak file descriptors.
|
|
||||||
let _ = transport.close().await;
|
|
||||||
|
|
||||||
Ok((parsed, latency_ms))
|
|
||||||
}
|
|
||||||
Ok(Some(other)) => Err(format!(
|
|
||||||
"expected ReflectResponse, got {:?}",
|
|
||||||
std::mem::discriminant(&other)
|
|
||||||
)),
|
|
||||||
Ok(None) => Err("connection closed before ReflectResponse".into()),
|
|
||||||
Err(e) => Err(format!("recv ReflectResponse: {e}")),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = tokio::time::timeout(Duration::from_millis(timeout_ms), probe)
|
|
||||||
.await
|
|
||||||
.map_err(|_| format!("probe timeout ({timeout_ms}ms)"))??;
|
|
||||||
|
|
||||||
// `endpoint` is a quinn::Endpoint clone — an Arc under the
|
|
||||||
// hood. Letting it drop at end-of-scope is correct whether it
|
|
||||||
// was fresh (last ref → socket closes) or shared (ref count
|
|
||||||
// decrements, socket stays alive for the signal loop).
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect the client's NAT type by probing N relays in parallel and
|
|
||||||
/// classifying the returned addresses. Never errors — failing
|
|
||||||
/// probes surface via `NatProbeResult.error`; aggregate is always
|
|
||||||
/// returned.
|
|
||||||
///
|
|
||||||
/// # Endpoint reuse (Phase 5)
|
|
||||||
///
|
|
||||||
/// If `shared_endpoint` is `Some`, every probe reuses it. This is
|
|
||||||
/// the PRODUCTION behavior: all probes source from the same UDP
|
|
||||||
/// port, so port-preserving NATs map them to the same external
|
|
||||||
/// port, and the classifier reflects the real NAT type. Pass the
|
|
||||||
/// signal endpoint.
|
|
||||||
///
|
|
||||||
/// If `None`, each probe creates its own fresh endpoint — useful
|
|
||||||
/// in tests that don't have a signal endpoint, but produces
|
|
||||||
/// spurious `SymmetricPort` classifications against NATs that
|
|
||||||
/// would otherwise look cone-like.
|
|
||||||
pub async fn detect_nat_type(
|
|
||||||
relays: Vec<(String, SocketAddr)>,
|
|
||||||
timeout_ms: u64,
|
|
||||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
) -> NatDetection {
|
|
||||||
// Parallel probes via tokio::task::JoinSet so the wall-clock is
|
|
||||||
// bounded by the slowest probe, not the sum. JoinSet keeps the
|
|
||||||
// dep surface at just tokio — we already depend on it.
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
|
||||||
for (name, addr) in relays {
|
|
||||||
let ep = shared_endpoint.clone();
|
|
||||||
set.spawn(async move {
|
|
||||||
let result = probe_reflect_addr(addr, timeout_ms, ep).await;
|
|
||||||
(name, addr, result)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut probes = Vec::new();
|
|
||||||
while let Some(join_result) = set.join_next().await {
|
|
||||||
let (name, addr, result) = match join_result {
|
|
||||||
Ok(tuple) => tuple,
|
|
||||||
// Task panicked — surface as a synthetic failed probe so
|
|
||||||
// the aggregate still returns a reasonable shape. This
|
|
||||||
// shouldn't happen but we don't want one bad probe to
|
|
||||||
// poison the whole detection.
|
|
||||||
Err(join_err) => {
|
|
||||||
probes.push(NatProbeResult {
|
|
||||||
relay_name: "<panicked>".into(),
|
|
||||||
relay_addr: "unknown".into(),
|
|
||||||
observed_addr: None,
|
|
||||||
latency_ms: None,
|
|
||||||
error: Some(format!("probe task panicked: {join_err}")),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
probes.push(match result {
|
|
||||||
Ok((observed, latency_ms)) => NatProbeResult {
|
|
||||||
relay_name: name,
|
|
||||||
relay_addr: addr.to_string(),
|
|
||||||
observed_addr: Some(observed.to_string()),
|
|
||||||
latency_ms: Some(latency_ms),
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
Err(e) => NatProbeResult {
|
|
||||||
relay_name: name,
|
|
||||||
relay_addr: addr.to_string(),
|
|
||||||
observed_addr: None,
|
|
||||||
latency_ms: None,
|
|
||||||
error: Some(e),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let (nat_type, consensus_addr) = classify_nat(&probes);
|
|
||||||
NatDetection {
|
|
||||||
probes,
|
|
||||||
nat_type,
|
|
||||||
consensus_addr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate LAN-local host candidates this client is reachable
|
|
||||||
/// on, paired with the given port (typically the signal
|
|
||||||
/// endpoint's bound port so that incoming dials land on the same
|
|
||||||
/// socket the advertised reflex addr points to).
|
|
||||||
///
|
|
||||||
/// Gathers BOTH IPv4 and IPv6 candidates:
|
|
||||||
///
|
|
||||||
/// - **IPv4**: RFC1918 private ranges (10/8, 172.16/12, 192.168/16)
|
|
||||||
/// and CGNAT shared-transition (100.64/10). Public IPv4 is
|
|
||||||
/// skipped because the reflex-addr path already covers it.
|
|
||||||
/// Loopback and link-local (169.254/16) are skipped.
|
|
||||||
///
|
|
||||||
/// - **IPv6**: ALL global-unicast addresses (2000::/3 — the real
|
|
||||||
/// routable IPv6 space) AND unique-local (fc00::/7). These
|
|
||||||
/// are directly dialable from a peer on the same LAN, and on
|
|
||||||
/// true dual-stack LANs (which most consumer ISPs now provide,
|
|
||||||
/// including Starlink) IPv6 often gives a direct path even
|
|
||||||
/// when IPv4 can't hairpin. Loopback (::1), unspecified (::),
|
|
||||||
/// and link-local (fe80::/10) are skipped — link-local would
|
|
||||||
/// require a scope ID to be useful and is basically never
|
|
||||||
/// reachable across interface boundaries.
|
|
||||||
///
|
|
||||||
/// The port must come from the caller — typically
|
|
||||||
/// `signal_endpoint.local_addr()?.port()`, so that the peer's
|
|
||||||
/// dials to these addresses land on the same socket that's
|
|
||||||
/// already listening (Phase 5 shared-endpoint architecture).
|
|
||||||
///
|
|
||||||
/// Safe to call from any thread; no I/O, no async. The `if-addrs`
|
|
||||||
/// crate reads the kernel's interface table via a single
|
|
||||||
/// getifaddrs(3) syscall.
|
|
||||||
pub fn local_host_candidates(v4_port: u16, v6_port: Option<u16>) -> Vec<SocketAddr> {
|
|
||||||
let Ok(ifaces) = if_addrs::get_if_addrs() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for iface in ifaces {
|
|
||||||
if iface.is_loopback() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match iface.ip() {
|
|
||||||
std::net::IpAddr::V4(v4) => {
|
|
||||||
if v4.is_link_local() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Keep RFC1918 private ranges and CGNAT — those
|
|
||||||
// are the LAN-dialable addrs we actually want.
|
|
||||||
// Skip public v4 because the reflex addr already
|
|
||||||
// covers that path.
|
|
||||||
if v4.is_private() {
|
|
||||||
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
|
|
||||||
} else if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40 {
|
|
||||||
// 100.64/10 CGNAT — rare but valid if two
|
|
||||||
// phones are on the same CGNAT-hairpinned
|
|
||||||
// carrier LAN (some hotspot setups).
|
|
||||||
out.push(SocketAddr::new(std::net::IpAddr::V4(v4), v4_port));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(v6) => {
|
|
||||||
// Phase 7: IPv6 host candidates via dedicated
|
|
||||||
// IPv6 socket. When v6_port is None, no IPv6
|
|
||||||
// endpoint exists — skip silently.
|
|
||||||
let Some(port) = v6_port else { continue };
|
|
||||||
if v6.is_loopback() || v6.is_unspecified() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// fe80::/10 link-local — needs scope ID, not
|
|
||||||
// routable across interfaces.
|
|
||||||
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Accept global unicast (2000::/3) and
|
|
||||||
// unique-local (fc00::/7).
|
|
||||||
let first_seg = v6.segments()[0];
|
|
||||||
let is_global = (first_seg & 0xe000) == 0x2000;
|
|
||||||
let is_ula = (first_seg & 0xfe00) == 0xfc00;
|
|
||||||
if is_global || is_ula {
|
|
||||||
out.push(SocketAddr::new(std::net::IpAddr::V6(v6), port));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Role assignment for the Phase 3.5 dual-path QUIC race.
|
|
||||||
///
|
|
||||||
/// Both peers already know two strings at CallSetup time: their
|
|
||||||
/// own server-reflexive address (queried via Phase 1 Reflect) and
|
|
||||||
/// the peer's (carried in `CallSetup.peer_direct_addr`). To avoid
|
|
||||||
/// a negotiation round-trip, both sides compare the two strings
|
|
||||||
/// lexicographically and agree on a deterministic role:
|
|
||||||
///
|
|
||||||
/// - **Acceptor** — lexicographically smaller addr. Listens for
|
|
||||||
/// an incoming direct connection from the peer. Does NOT dial.
|
|
||||||
/// - **Dialer** — lexicographically larger addr. Dials the
|
|
||||||
/// peer's direct addr. Does NOT listen.
|
|
||||||
///
|
|
||||||
/// Both roles ALSO dial the relay in parallel as a fallback.
|
|
||||||
/// Whichever future (direct or relay) completes first is used as
|
|
||||||
/// the media transport. Because the role is deterministic and
|
|
||||||
/// symmetric, both peers end up holding the same underlying QUIC
|
|
||||||
/// session on the direct path — A's accepted conn and D's dialed
|
|
||||||
/// conn are literally the same connection.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Role {
|
|
||||||
/// This peer listens for the direct incoming connection.
|
|
||||||
Acceptor,
|
|
||||||
/// This peer dials the peer's direct address.
|
|
||||||
Dialer,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the deterministic role for this peer in the dual-path
|
|
||||||
/// race. Returns `None` when no direct attempt is possible —
|
|
||||||
/// either peer didn't advertise a reflex addr, or the two addrs
|
|
||||||
/// are identical (same host on loopback / mis-advertised).
|
|
||||||
///
|
|
||||||
/// The caller should treat `None` as "skip direct, relay-only".
|
|
||||||
pub fn determine_role(
|
|
||||||
own_reflex_addr: Option<&str>,
|
|
||||||
peer_reflex_addr: Option<&str>,
|
|
||||||
) -> Option<Role> {
|
|
||||||
let (own, peer) = match (own_reflex_addr, peer_reflex_addr) {
|
|
||||||
(Some(o), Some(p)) => (o, p),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
match own.cmp(peer) {
|
|
||||||
std::cmp::Ordering::Less => Some(Role::Acceptor),
|
|
||||||
std::cmp::Ordering::Greater => Some(Role::Dialer),
|
|
||||||
// Equal addrs should never happen in production (both
|
|
||||||
// peers behind the same NAT mapping + same port would be
|
|
||||||
// a degenerate case). Guard against it so we don't infinite-
|
|
||||||
// loop waiting for a connection to ourselves.
|
|
||||||
std::cmp::Ordering::Equal => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if the address is in an RFC1918 / link-local /
|
|
||||||
/// loopback range and therefore cannot possibly be a post-NAT
|
|
||||||
/// reflex address from the public internet's point of view.
|
|
||||||
///
|
|
||||||
/// A probe against a relay ON THE SAME LAN as the client will
|
|
||||||
/// naturally report the client's LAN IP back (because there's no
|
|
||||||
/// NAT between them) — that observation is real but says nothing
|
|
||||||
/// about the client's public-internet-facing NAT state. Mixing
|
|
||||||
/// LAN reflex addrs with public-internet reflex addrs in
|
|
||||||
/// `classify_nat` would always report `Multiple` (different IPs)
|
|
||||||
/// and falsely warn about symmetric NAT. Filter them out before
|
|
||||||
/// classifying.
|
|
||||||
fn is_private_or_loopback(addr: &SocketAddr) -> bool {
|
|
||||||
match addr.ip() {
|
|
||||||
std::net::IpAddr::V4(v4) => {
|
|
||||||
let o = v4.octets();
|
|
||||||
v4.is_loopback()
|
|
||||||
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|
|
||||||
|| v4.is_link_local() // 169.254/16
|
|
||||||
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40) // 100.64/10 CGNAT shared
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(v6) => {
|
|
||||||
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure-function NAT classifier — split out for unit testing
|
|
||||||
/// without touching the network.
|
|
||||||
///
|
|
||||||
/// Only considers probes whose reflex addr is a **public-internet**
|
|
||||||
/// address. LAN / private / loopback reflex addrs are dropped
|
|
||||||
/// because they reflect the same-network path rather than the
|
|
||||||
/// real NAT state. CGNAT (100.64/10) is also treated as private
|
|
||||||
/// because the post-CGNAT address would be what we actually want
|
|
||||||
/// to classify on — but CGNAT is unreachable from outside the
|
|
||||||
/// carrier, so a relay seeing the CGNAT addr is on the same
|
|
||||||
/// carrier network and again not useful for classification.
|
|
||||||
pub fn classify_nat(probes: &[NatProbeResult]) -> (NatType, Option<String>) {
|
|
||||||
// First: parse every successful probe's observed addr.
|
|
||||||
let parsed: Vec<SocketAddr> = probes
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| p.observed_addr.as_deref().and_then(|s| s.parse().ok()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Then: drop LAN / private / loopback reflex addrs. Those are
|
|
||||||
// legitimate observations by same-network relays, but they
|
|
||||||
// don't contribute to NAT-type classification because the
|
|
||||||
// client's real public-facing NAT mapping is not involved on
|
|
||||||
// that path. A relay on the same LAN always sees the client's
|
|
||||||
// LAN IP, regardless of whether the NAT beyond it is cone or
|
|
||||||
// symmetric.
|
|
||||||
let successes: Vec<SocketAddr> = parsed
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| !is_private_or_loopback(a))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if successes.len() < 2 {
|
|
||||||
return (NatType::Unknown, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let first = successes[0];
|
|
||||||
let same_ip = successes.iter().all(|a| a.ip() == first.ip());
|
|
||||||
if !same_ip {
|
|
||||||
return (NatType::Multiple, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let same_port = successes.iter().all(|a| a.port() == first.port());
|
|
||||||
if same_port {
|
|
||||||
(NatType::Cone, Some(first.to_string()))
|
|
||||||
} else {
|
|
||||||
(NatType::SymmetricPort, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced NAT detection that combines relay-based reflection with
|
|
||||||
/// public STUN server probes for more robust classification.
|
|
||||||
///
|
|
||||||
/// Runs both probe sets concurrently:
|
|
||||||
/// 1. Relay probes via `detect_nat_type` (existing behavior)
|
|
||||||
/// 2. Public STUN probes via `probe_stun_servers`
|
|
||||||
///
|
|
||||||
/// Merges all results and classifies. More probes = higher confidence
|
|
||||||
/// in the NAT type classification. Falls back gracefully: if STUN
|
|
||||||
/// servers are unreachable, relay probes still work (and vice versa).
|
|
||||||
pub async fn detect_nat_type_with_stun(
|
|
||||||
relays: Vec<(String, SocketAddr)>,
|
|
||||||
timeout_ms: u64,
|
|
||||||
shared_endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
stun_config: &crate::stun::StunConfig,
|
|
||||||
) -> NatDetection {
|
|
||||||
// Run relay probes and STUN probes concurrently.
|
|
||||||
let relay_fut = detect_nat_type(relays, timeout_ms, shared_endpoint);
|
|
||||||
let stun_fut = crate::stun::probe_stun_servers(stun_config);
|
|
||||||
|
|
||||||
let (relay_detection, stun_probes) = tokio::join!(relay_fut, stun_fut);
|
|
||||||
|
|
||||||
// Merge all probes and re-classify.
|
|
||||||
let mut all_probes = relay_detection.probes;
|
|
||||||
all_probes.extend(stun_probes);
|
|
||||||
|
|
||||||
let (nat_type, consensus_addr) = classify_nat(&all_probes);
|
|
||||||
NatDetection {
|
|
||||||
probes: all_probes,
|
|
||||||
nat_type,
|
|
||||||
consensus_addr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unit tests for the pure classifier ───────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn mk(addr: Option<&str>) -> NatProbeResult {
|
|
||||||
NatProbeResult {
|
|
||||||
relay_name: "test".into(),
|
|
||||||
relay_addr: "0.0.0.0:0".into(),
|
|
||||||
observed_addr: addr.map(|s| s.to_string()),
|
|
||||||
latency_ms: addr.map(|_| 10),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_empty_is_unknown() {
|
|
||||||
let (nt, addr) = classify_nat(&[]);
|
|
||||||
assert_eq!(nt, NatType::Unknown);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_single_success_is_unknown() {
|
|
||||||
let probes = vec![mk(Some("192.0.2.1:4433"))];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Unknown);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_two_identical_is_cone() {
|
|
||||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:4433"))];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Cone);
|
|
||||||
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_same_ip_different_ports_is_symmetric() {
|
|
||||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:51234"))];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::SymmetricPort);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_different_ips_is_multiple() {
|
|
||||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("198.51.100.9:4433"))];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Multiple);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_drops_private_ip_probes() {
|
|
||||||
// One LAN probe + one public probe should behave like a
|
|
||||||
// single public probe — i.e. Unknown (not enough data to
|
|
||||||
// classify). This is the common real-world case: the user
|
|
||||||
// has a LAN relay + an internet relay configured, the LAN
|
|
||||||
// relay sees the LAN IP, the internet relay sees the WAN
|
|
||||||
// IP, and the old classifier would flag "Multiple" and
|
|
||||||
// falsely warn about symmetric NAT.
|
|
||||||
let probes = vec![
|
|
||||||
mk(Some("192.168.1.100:4433")), // LAN — must be dropped
|
|
||||||
mk(Some("203.0.113.5:4433")), // public (TEST-NET-3)
|
|
||||||
];
|
|
||||||
let (nt, _) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_drops_loopback_probes() {
|
|
||||||
let probes = vec![
|
|
||||||
mk(Some("127.0.0.1:4433")), // loopback — must be dropped
|
|
||||||
mk(Some("203.0.113.5:4433")), // public
|
|
||||||
mk(Some("203.0.113.5:4433")), // public, same addr
|
|
||||||
];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
// Two public probes with identical addrs → Cone.
|
|
||||||
assert_eq!(nt, NatType::Cone);
|
|
||||||
assert_eq!(addr.as_deref(), Some("203.0.113.5:4433"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_drops_cgnat_probes() {
|
|
||||||
// 100.64.0.0/10 is the CGNAT shared-transition range.
|
|
||||||
// Filter treats it like RFC1918 — a relay that sees the
|
|
||||||
// client with a 100.64/10 addr is on the same CGNAT
|
|
||||||
// network and can't contribute to public NAT classification.
|
|
||||||
let probes = vec![
|
|
||||||
mk(Some("100.64.0.42:4433")), // CGNAT — dropped
|
|
||||||
mk(Some("203.0.113.5:4433")), // public
|
|
||||||
mk(Some("203.0.113.5:12345")), // public, different port
|
|
||||||
];
|
|
||||||
let (nt, _) = classify_nat(&probes);
|
|
||||||
// Two public probes same IP different port → SymmetricPort.
|
|
||||||
assert_eq!(nt, NatType::SymmetricPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_two_lan_probes_is_unknown_not_cone() {
|
|
||||||
// Even if both probes come back from LAN relays, we can't
|
|
||||||
// say anything useful about the public NAT state. Unknown,
|
|
||||||
// not Cone.
|
|
||||||
let probes = vec![
|
|
||||||
mk(Some("192.168.1.100:4433")),
|
|
||||||
mk(Some("192.168.1.100:4433")),
|
|
||||||
];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Unknown);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_mix_of_success_and_failure() {
|
|
||||||
let probes = vec![
|
|
||||||
mk(Some("192.0.2.1:4433")),
|
|
||||||
mk(None), // failed probe
|
|
||||||
mk(Some("192.0.2.1:4433")),
|
|
||||||
];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
// Two successes both agree → Cone, ignore the failure row.
|
|
||||||
assert_eq!(nt, NatType::Cone);
|
|
||||||
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_smaller_is_acceptor() {
|
|
||||||
// Lexicographic: "192.0.2.1:4433" < "198.51.100.9:4433"
|
|
||||||
assert_eq!(
|
|
||||||
determine_role(Some("192.0.2.1:4433"), Some("198.51.100.9:4433")),
|
|
||||||
Some(Role::Acceptor)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_larger_is_dialer() {
|
|
||||||
assert_eq!(
|
|
||||||
determine_role(Some("198.51.100.9:4433"), Some("192.0.2.1:4433")),
|
|
||||||
Some(Role::Dialer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_port_difference_matters() {
|
|
||||||
// Same ip, different ports — string compare still works
|
|
||||||
// because "4433" < "54321".
|
|
||||||
assert_eq!(
|
|
||||||
determine_role(Some("127.0.0.1:4433"), Some("127.0.0.1:54321")),
|
|
||||||
Some(Role::Acceptor)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
determine_role(Some("127.0.0.1:54321"), Some("127.0.0.1:4433")),
|
|
||||||
Some(Role::Dialer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_equal_addrs_is_none() {
|
|
||||||
assert_eq!(
|
|
||||||
determine_role(Some("192.0.2.1:4433"), Some("192.0.2.1:4433")),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_missing_side_is_none() {
|
|
||||||
assert_eq!(determine_role(None, Some("192.0.2.1:4433")), None);
|
|
||||||
assert_eq!(determine_role(Some("192.0.2.1:4433"), None), None);
|
|
||||||
assert_eq!(determine_role(None, None), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn determine_role_is_symmetric_across_peers() {
|
|
||||||
// Both peers compute roles independently; they must end
|
|
||||||
// up with opposite assignments (one Acceptor, one Dialer)
|
|
||||||
// so that each side ends up talking to the other.
|
|
||||||
let a = "192.0.2.1:4433";
|
|
||||||
let b = "198.51.100.9:4433";
|
|
||||||
let alice_role = determine_role(Some(a), Some(b));
|
|
||||||
let bob_role = determine_role(Some(b), Some(a));
|
|
||||||
assert_eq!(alice_role, Some(Role::Acceptor));
|
|
||||||
assert_eq!(bob_role, Some(Role::Dialer));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_one_success_one_failure_is_unknown() {
|
|
||||||
let probes = vec![mk(Some("192.0.2.1:4433")), mk(None)];
|
|
||||||
let (nt, addr) = classify_nat(&probes);
|
|
||||||
assert_eq!(nt, NatType::Unknown);
|
|
||||||
assert!(addr.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
//! Phase 8 (Tailscale-inspired): Relay map for automatic relay
|
|
||||||
//! selection based on latency.
|
|
||||||
//!
|
|
||||||
//! Maintains a sorted list of known relays with their measured
|
|
||||||
//! latencies. Used during call setup to pick the lowest-latency
|
|
||||||
//! relay, and by netcheck to report relay health.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
/// A known relay endpoint with measured latency.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct RelayEntry {
|
|
||||||
/// Human-readable name (e.g., "us-east", "eu-west").
|
|
||||||
pub name: String,
|
|
||||||
/// Relay address.
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
/// Geographic region (from RegisterPresenceAck).
|
|
||||||
pub region: Option<String>,
|
|
||||||
/// Last measured RTT (ms).
|
|
||||||
pub rtt_ms: Option<u32>,
|
|
||||||
/// When the RTT was last measured.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub last_probed: Option<Instant>,
|
|
||||||
/// Whether this relay is currently reachable.
|
|
||||||
pub reachable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sorted relay map. Entries are ordered by RTT (lowest first).
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct RelayMap {
|
|
||||||
entries: Vec<RelayEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RelayMap {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
entries: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add or update a relay entry.
|
|
||||||
pub fn upsert(&mut self, name: &str, addr: SocketAddr, region: Option<String>) {
|
|
||||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
|
||||||
entry.name = name.to_string();
|
|
||||||
if region.is_some() {
|
|
||||||
entry.region = region;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.entries.push(RelayEntry {
|
|
||||||
name: name.to_string(),
|
|
||||||
addr,
|
|
||||||
region,
|
|
||||||
rtt_ms: None,
|
|
||||||
last_probed: None,
|
|
||||||
reachable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update RTT measurement for a relay.
|
|
||||||
pub fn update_rtt(&mut self, addr: SocketAddr, rtt_ms: u32) {
|
|
||||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
|
||||||
entry.rtt_ms = Some(rtt_ms);
|
|
||||||
entry.last_probed = Some(Instant::now());
|
|
||||||
entry.reachable = true;
|
|
||||||
}
|
|
||||||
self.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark a relay as unreachable.
|
|
||||||
pub fn mark_unreachable(&mut self, addr: SocketAddr) {
|
|
||||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.addr == addr) {
|
|
||||||
entry.reachable = false;
|
|
||||||
entry.last_probed = Some(Instant::now());
|
|
||||||
}
|
|
||||||
self.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the preferred (lowest-latency, reachable) relay.
|
|
||||||
pub fn preferred(&self) -> Option<&RelayEntry> {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.find(|e| e.reachable && e.rtt_ms.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all entries, sorted by RTT.
|
|
||||||
pub fn entries(&self) -> &[RelayEntry] {
|
|
||||||
&self.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Populate from a `RegisterPresenceAck.available_relays` list.
|
|
||||||
/// Each entry is "name|addr" format.
|
|
||||||
pub fn populate_from_ack(&mut self, relays: &[String], relay_region: Option<&str>) {
|
|
||||||
for entry_str in relays {
|
|
||||||
if let Some((name, addr_str)) = entry_str.split_once('|') {
|
|
||||||
if let Ok(addr) = addr_str.parse::<SocketAddr>() {
|
|
||||||
self.upsert(name, addr, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If the ack included a region for the current relay, we
|
|
||||||
// could tag it — but we'd need to know which relay we're
|
|
||||||
// connected to. Left for the caller to handle.
|
|
||||||
let _ = relay_region;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if any entry has a stale probe (older than `max_age`).
|
|
||||||
pub fn needs_reprobe(&self, max_age: Duration) -> bool {
|
|
||||||
self.entries.iter().any(|e| match e.last_probed {
|
|
||||||
None => true,
|
|
||||||
Some(t) => t.elapsed() > max_age,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get entries that need reprobing.
|
|
||||||
pub fn stale_entries(&self, max_age: Duration) -> Vec<(String, SocketAddr)> {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.filter(|e| match e.last_probed {
|
|
||||||
None => true,
|
|
||||||
Some(t) => t.elapsed() > max_age,
|
|
||||||
})
|
|
||||||
.map(|e| (e.name.clone(), e.addr))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sort(&mut self) {
|
|
||||||
self.entries.sort_by_key(|e| {
|
|
||||||
if e.reachable {
|
|
||||||
e.rtt_ms.unwrap_or(u32::MAX)
|
|
||||||
} else {
|
|
||||||
u32::MAX
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preferred_returns_lowest_rtt() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
|
||||||
let a3: SocketAddr = "10.0.0.3:4433".parse().unwrap();
|
|
||||||
|
|
||||||
map.upsert("slow", a1, None);
|
|
||||||
map.upsert("fast", a2, None);
|
|
||||||
map.upsert("mid", a3, None);
|
|
||||||
|
|
||||||
map.update_rtt(a1, 200);
|
|
||||||
map.update_rtt(a2, 15);
|
|
||||||
map.update_rtt(a3, 80);
|
|
||||||
|
|
||||||
let pref = map.preferred().unwrap();
|
|
||||||
assert_eq!(pref.addr, a2);
|
|
||||||
assert_eq!(pref.rtt_ms, Some(15));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unreachable_not_preferred() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
|
||||||
|
|
||||||
map.upsert("fast-dead", a1, None);
|
|
||||||
map.upsert("slow-alive", a2, None);
|
|
||||||
|
|
||||||
map.update_rtt(a1, 5);
|
|
||||||
map.update_rtt(a2, 200);
|
|
||||||
map.mark_unreachable(a1);
|
|
||||||
|
|
||||||
let pref = map.preferred().unwrap();
|
|
||||||
assert_eq!(pref.addr, a2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn populate_from_ack() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
map.populate_from_ack(
|
|
||||||
&[
|
|
||||||
"us-east|203.0.113.5:4433".into(),
|
|
||||||
"eu-west|198.51.100.9:4433".into(),
|
|
||||||
],
|
|
||||||
Some("us-east"),
|
|
||||||
);
|
|
||||||
assert_eq!(map.entries().len(), 2);
|
|
||||||
assert_eq!(map.entries()[0].name, "us-east");
|
|
||||||
assert_eq!(map.entries()[1].name, "eu-west");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_updates_existing() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
map.upsert("old-name", addr, None);
|
|
||||||
map.upsert("new-name", addr, Some("us-west".into()));
|
|
||||||
assert_eq!(map.entries().len(), 1);
|
|
||||||
assert_eq!(map.entries()[0].name, "new-name");
|
|
||||||
assert_eq!(map.entries()[0].region, Some("us-west".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_preserves_region_when_none() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
map.upsert("relay", addr, Some("eu-west".into()));
|
|
||||||
map.upsert("relay", addr, None); // region is None
|
|
||||||
// Should keep the original region
|
|
||||||
assert_eq!(map.entries()[0].region, Some("eu-west".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preferred_returns_none_on_empty() {
|
|
||||||
let map = RelayMap::new();
|
|
||||||
assert!(map.preferred().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preferred_returns_none_when_all_unreachable() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
map.upsert("relay", addr, None);
|
|
||||||
// Not update_rtt'd, so reachable=false
|
|
||||||
assert!(map.preferred().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn needs_reprobe_empty_is_false() {
|
|
||||||
let map = RelayMap::new();
|
|
||||||
// No entries → nothing to reprobe
|
|
||||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn needs_reprobe_never_probed() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
map.upsert("relay", "10.0.0.1:4433".parse().unwrap(), None);
|
|
||||||
assert!(map.needs_reprobe(Duration::from_secs(60)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn needs_reprobe_fresh_is_false() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
map.upsert("relay", addr, None);
|
|
||||||
map.update_rtt(addr, 50);
|
|
||||||
// Just probed, so 60s max_age should not trigger
|
|
||||||
assert!(!map.needs_reprobe(Duration::from_secs(60)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_entries_returns_unprobed() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
|
||||||
map.upsert("probed", a1, None);
|
|
||||||
map.upsert("stale", a2, None);
|
|
||||||
map.update_rtt(a1, 50);
|
|
||||||
|
|
||||||
let stale = map.stale_entries(Duration::from_secs(60));
|
|
||||||
assert_eq!(stale.len(), 1);
|
|
||||||
assert_eq!(stale[0].1, a2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sort_stability_with_equal_rtt() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
|
||||||
map.upsert("first", a1, None);
|
|
||||||
map.upsert("second", a2, None);
|
|
||||||
map.update_rtt(a1, 50);
|
|
||||||
map.update_rtt(a2, 50);
|
|
||||||
|
|
||||||
// Both have same RTT — sort should be stable (insertion order)
|
|
||||||
assert_eq!(map.entries().len(), 2);
|
|
||||||
// Both are valid preferred relays
|
|
||||||
assert!(map.preferred().is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn populate_from_ack_skips_malformed() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
map.populate_from_ack(
|
|
||||||
&[
|
|
||||||
"good|10.0.0.1:4433".into(),
|
|
||||||
"no-pipe-separator".into(),
|
|
||||||
"bad-addr|not-a-socket-addr".into(),
|
|
||||||
"also-good|10.0.0.2:4433".into(),
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert_eq!(map.entries().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mark_unreachable_sorts_to_end() {
|
|
||||||
let mut map = RelayMap::new();
|
|
||||||
let a1: SocketAddr = "10.0.0.1:4433".parse().unwrap();
|
|
||||||
let a2: SocketAddr = "10.0.0.2:4433".parse().unwrap();
|
|
||||||
map.upsert("fast", a1, None);
|
|
||||||
map.upsert("slow", a2, None);
|
|
||||||
map.update_rtt(a1, 10);
|
|
||||||
map.update_rtt(a2, 200);
|
|
||||||
|
|
||||||
assert_eq!(map.preferred().unwrap().addr, a1);
|
|
||||||
|
|
||||||
map.mark_unreachable(a1);
|
|
||||||
assert_eq!(map.preferred().unwrap().addr, a2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relay_entry_serializes() {
|
|
||||||
let entry = RelayEntry {
|
|
||||||
name: "test".into(),
|
|
||||||
addr: "10.0.0.1:4433".parse().unwrap(),
|
|
||||||
region: Some("us-east".into()),
|
|
||||||
rtt_ms: Some(42),
|
|
||||||
last_probed: Some(Instant::now()),
|
|
||||||
reachable: true,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&entry).unwrap();
|
|
||||||
assert!(json.contains("test"));
|
|
||||||
assert!(json.contains("us-east"));
|
|
||||||
assert!(json.contains("42"));
|
|
||||||
// last_probed is #[serde(skip)]
|
|
||||||
assert!(!json.contains("last_probed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,8 @@ fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
|
|||||||
/// decoder, pushes frames through the pipeline, and collects statistics.
|
/// decoder, pushes frames through the pipeline, and collects statistics.
|
||||||
/// Combinations where `target_depth > max_depth` are skipped.
|
/// Combinations where `target_depth > max_depth` are skipped.
|
||||||
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
|
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
|
||||||
let frames_per_config = (config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
let frames_per_config =
|
||||||
|
(config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
//! Phase 3.5 integration tests for the dual-path QUIC race.
|
|
||||||
//!
|
|
||||||
//! The race takes a role (Acceptor or Dialer), a peer_direct_addr,
|
|
||||||
//! a relay_addr, and two SNI strings, then returns whichever QUIC
|
|
||||||
//! handshake completes first wrapped in a `QuinnTransport`. These
|
|
||||||
//! tests validate that:
|
|
||||||
//!
|
|
||||||
//! 1. On loopback with two real clients playing A + D roles, the
|
|
||||||
//! direct path wins (fewer hops than relay).
|
|
||||||
//! 2. When the direct peer is dead (nothing listening) but the
|
|
||||||
//! relay is up, the relay wins within the fallback window.
|
|
||||||
//! 3. When both paths are dead, the race errors cleanly rather
|
|
||||||
//! than hanging forever.
|
|
||||||
//!
|
|
||||||
//! The "relay" in these tests is a minimal mock that just accepts
|
|
||||||
//! an incoming QUIC connection and drops it — we don't need any
|
|
||||||
//! protocol handling, just a TCP-ish listen-and-accept.
|
|
||||||
|
|
||||||
use std::net::{Ipv4Addr, SocketAddr};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
|
|
||||||
use wzp_client::reflect::Role;
|
|
||||||
use wzp_transport::{create_endpoint, server_config};
|
|
||||||
|
|
||||||
/// Spin up a "relay-ish" mock server on loopback that accepts
|
|
||||||
/// incoming QUIC connections and does nothing with them. Used to
|
|
||||||
/// give the relay branch of the race a real target to dial.
|
|
||||||
/// Returns the bound address + a join handle (kept alive to keep
|
|
||||||
/// the endpoint up).
|
|
||||||
async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let (sc, _cert_der) = server_config();
|
|
||||||
let bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
|
||||||
let ep = create_endpoint(bind, Some(sc)).expect("relay endpoint");
|
|
||||||
let addr = ep.local_addr().expect("local_addr");
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
// Accept loop — hold the connection alive for a short
|
|
||||||
// while so the race result isn't killed by the peer
|
|
||||||
// closing before the winning transport is returned.
|
|
||||||
while let Some(incoming) = ep.accept().await {
|
|
||||||
if let Ok(_conn) = incoming.await {
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(addr, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 1: direct path wins when both sides are up
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Spawn a mock relay, then set up a two-client test where one
|
|
||||||
// client plays the Acceptor role and the other plays the Dialer
|
|
||||||
// role. The Dialer's `peer_direct_addr` is the Acceptor's listen
|
|
||||||
// address. Because the direct path is a single loopback hop and
|
|
||||||
// the relay dial also terminates on loopback, both complete
|
|
||||||
// essentially instantly — the `biased` tokio::select in race()
|
|
||||||
// should pick direct.
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn dual_path_direct_wins_on_loopback() {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let (relay_addr, _relay_handle) = spawn_mock_relay().await;
|
|
||||||
|
|
||||||
// Acceptor task: run race(Role::Acceptor, peer_addr_placeholder, ...).
|
|
||||||
// Since the acceptor doesn't dial, the peer_direct_addr arg is
|
|
||||||
// unused on the direct branch but we still pass a placeholder
|
|
||||||
// because the API takes one. Use a stub addr that would error
|
|
||||||
// if it were ever dialed — proving the Acceptor really doesn't
|
|
||||||
// reach it.
|
|
||||||
let unused_addr: SocketAddr = "127.0.0.1:2".parse().unwrap();
|
|
||||||
|
|
||||||
// We can't race both sides in the same task because each race
|
|
||||||
// call has its own direct endpoint that needs to talk to the
|
|
||||||
// OTHER side's endpoint. So spawn the Acceptor in a task and
|
|
||||||
// let it expose its listen addr via a oneshot back to the test,
|
|
||||||
// then run the Dialer in the test's main task.
|
|
||||||
//
|
|
||||||
// There's a chicken-and-egg issue: the Acceptor's listen addr
|
|
||||||
// is only known after race() creates its endpoint. To avoid
|
|
||||||
// reaching into race()'s internals, we instead play a slight
|
|
||||||
// trick: create the Acceptor's endpoint ourselves (outside
|
|
||||||
// race()) to learn its addr, spin up an accept loop on it
|
|
||||||
// ourselves, and pass THAT addr as the Dialer's peer addr.
|
|
||||||
// This tests the Dialer->Acceptor handshake end-to-end without
|
|
||||||
// running the full race() on both sides.
|
|
||||||
|
|
||||||
let (sc, _cert_der) = server_config();
|
|
||||||
let acceptor_bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
|
|
||||||
let acceptor_ep = create_endpoint(acceptor_bind, Some(sc)).expect("acceptor ep");
|
|
||||||
let acceptor_listen_addr = acceptor_ep.local_addr().expect("acceptor addr");
|
|
||||||
|
|
||||||
// Drop the external acceptor after the test finishes, not
|
|
||||||
// before — spawn a dedicated accept task.
|
|
||||||
let acceptor_accept_task = tokio::spawn(async move {
|
|
||||||
// Accept one connection and hold it for a while so the
|
|
||||||
// Dialer side can complete its QUIC handshake.
|
|
||||||
if let Some(incoming) = acceptor_ep.accept().await {
|
|
||||||
if let Ok(_conn) = incoming.await {
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now run the Dialer in the race — peer_direct_addr = acceptor's
|
|
||||||
// listen addr. The relay is the mock from above. Direct path
|
|
||||||
// should win.
|
|
||||||
let result = race(
|
|
||||||
Role::Dialer,
|
|
||||||
PeerCandidates {
|
|
||||||
reflexive: Some(acceptor_listen_addr),
|
|
||||||
local: Vec::new(),
|
|
||||||
mapped: None,
|
|
||||||
},
|
|
||||||
relay_addr,
|
|
||||||
"test-room".into(),
|
|
||||||
"call-test".into(),
|
|
||||||
None, // own_reflexive: not needed in tests
|
|
||||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
|
||||||
None, // Phase 7: no IPv6 endpoint in tests
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("race must succeed");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.direct_transport.is_some(),
|
|
||||||
"direct transport should be available"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.local_winner,
|
|
||||||
WinningPath::Direct,
|
|
||||||
"direct should win on loopback"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cancel the acceptor accept task so the test finishes.
|
|
||||||
acceptor_accept_task.abort();
|
|
||||||
// Suppress unused-var warning for the placeholder.
|
|
||||||
let _ = unused_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 2: relay wins when the direct peer is dead
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Dialer role, peer_direct_addr = a port nothing is listening on,
|
|
||||||
// relay is the working mock. Direct dial will sit waiting for a
|
|
||||||
// QUIC handshake that never comes; the 2s direct timeout kicks in
|
|
||||||
// and the relay path wins the fallback.
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn dual_path_relay_wins_when_direct_is_dead() {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let (relay_addr, _relay_handle) = spawn_mock_relay().await;
|
|
||||||
|
|
||||||
// A port that nothing is listening on — dead direct target.
|
|
||||||
// Port 1 on loopback is almost never bound and UDP packets to
|
|
||||||
// it will be dropped silently, so the QUIC handshake times out.
|
|
||||||
let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
|
||||||
|
|
||||||
let result = race(
|
|
||||||
Role::Dialer,
|
|
||||||
PeerCandidates {
|
|
||||||
reflexive: Some(dead_peer),
|
|
||||||
local: Vec::new(),
|
|
||||||
mapped: None,
|
|
||||||
},
|
|
||||||
relay_addr,
|
|
||||||
"test-room".into(),
|
|
||||||
"call-test".into(),
|
|
||||||
None, // own_reflexive: not needed in tests
|
|
||||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
|
||||||
None, // Phase 7: no IPv6 endpoint in tests
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("race must succeed via relay fallback");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.relay_transport.is_some(),
|
|
||||||
"relay transport should be available"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.local_winner,
|
|
||||||
WinningPath::Relay,
|
|
||||||
"relay should win when direct dial has nowhere to land"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 3: race errors cleanly when both paths are dead
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Dialer role, peer_direct_addr = dead, relay_addr = dead.
|
|
||||||
// Expected: race returns an Err within ~7s (2s direct timeout +
|
|
||||||
// 5s relay timeout fallback).
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn dual_path_errors_cleanly_when_both_paths_dead() {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
|
||||||
let dead_relay: SocketAddr = "127.0.0.1:2".parse().unwrap();
|
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let result = race(
|
|
||||||
Role::Dialer,
|
|
||||||
PeerCandidates {
|
|
||||||
reflexive: Some(dead_peer),
|
|
||||||
local: Vec::new(),
|
|
||||||
mapped: None,
|
|
||||||
},
|
|
||||||
dead_relay,
|
|
||||||
"test-room".into(),
|
|
||||||
"call-test".into(),
|
|
||||||
None, // own_reflexive: not needed in tests
|
|
||||||
None, // Phase 5: tests use fresh endpoints (no shared signal)
|
|
||||||
None, // Phase 7: no IPv6 endpoint in tests
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
|
|
||||||
assert!(result.is_err(), "both-dead must return Err");
|
|
||||||
// Upper bound: direct 2s timeout + relay 5s fallback + small
|
|
||||||
// slack for scheduling. If this blows, something is looping.
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_secs(10),
|
|
||||||
"race took too long to give up: {:?}",
|
|
||||||
elapsed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use wzp_proto::packet::MediaPacket;
|
use wzp_proto::packet::MediaPacket;
|
||||||
use wzp_proto::traits::{MediaTransport, PathQuality};
|
use wzp_proto::traits::{MediaTransport, PathQuality};
|
||||||
use wzp_proto::{SignalMessage, TransportError, default_signal_version};
|
use wzp_proto::{SignalMessage, TransportError};
|
||||||
|
|
||||||
/// A mock transport backed by two mpsc channels (one per direction).
|
/// A mock transport backed by two mpsc channels (one per direction).
|
||||||
///
|
///
|
||||||
@@ -83,69 +83,43 @@ async fn full_handshake_both_sides_derive_same_session() {
|
|||||||
|
|
||||||
// Run client and relay handshakes concurrently.
|
// Run client and relay handshakes concurrently.
|
||||||
let (client_result, relay_result) = tokio::join!(
|
let (client_result, relay_result) = tokio::join!(
|
||||||
wzp_client::handshake::perform_handshake(
|
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed),
|
||||||
client_transport_clone.as_ref(),
|
|
||||||
&client_seed,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
|
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
|
||||||
);
|
);
|
||||||
|
|
||||||
let client_hs = client_result.expect("client handshake should succeed");
|
let mut client_session = client_result.expect("client handshake should succeed");
|
||||||
let (mut relay_session, chosen_profile, _caller_fp, _caller_alias) =
|
let (mut relay_session, chosen_profile) =
|
||||||
relay_result.expect("relay handshake should succeed");
|
relay_result.expect("relay handshake should succeed");
|
||||||
|
|
||||||
// Verify a profile was chosen.
|
// Verify a profile was chosen.
|
||||||
assert_eq!(chosen_profile, wzp_proto::QualityProfile::GOOD);
|
assert_eq!(chosen_profile, wzp_proto::QualityProfile::GOOD);
|
||||||
|
|
||||||
// Verify both sides can communicate: client encrypts, relay decrypts.
|
// Verify both sides can communicate: client encrypts, relay decrypts.
|
||||||
// encrypt/decrypt derive nonces from MediaHeader.seq, so we need valid headers.
|
let header = b"test-header";
|
||||||
use wzp_proto::packet::MediaHeader;
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
let make_hdr = |seq: u32| {
|
|
||||||
let h = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq,
|
|
||||||
timestamp: seq.wrapping_mul(20),
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut b = Vec::new();
|
|
||||||
h.write_to(&mut b);
|
|
||||||
b
|
|
||||||
};
|
|
||||||
|
|
||||||
let header = make_hdr(0);
|
|
||||||
let plaintext = b"hello from client to relay";
|
let plaintext = b"hello from client to relay";
|
||||||
|
|
||||||
let mut client_session = client_hs.session;
|
|
||||||
let mut ciphertext = Vec::new();
|
let mut ciphertext = Vec::new();
|
||||||
client_session
|
client_session
|
||||||
.encrypt(&header, plaintext, &mut ciphertext)
|
.encrypt(header, plaintext, &mut ciphertext)
|
||||||
.expect("client encrypt should succeed");
|
.expect("client encrypt should succeed");
|
||||||
|
|
||||||
let mut decrypted = Vec::new();
|
let mut decrypted = Vec::new();
|
||||||
relay_session
|
relay_session
|
||||||
.decrypt(&header, &ciphertext, &mut decrypted)
|
.decrypt(header, &ciphertext, &mut decrypted)
|
||||||
.expect("relay decrypt should succeed");
|
.expect("relay decrypt should succeed");
|
||||||
|
|
||||||
assert_eq!(&decrypted[..], plaintext);
|
assert_eq!(&decrypted[..], plaintext);
|
||||||
|
|
||||||
// Verify reverse direction: relay encrypts, client decrypts.
|
// Verify reverse direction: relay encrypts, client decrypts.
|
||||||
let header2 = make_hdr(0); // relay's send_seq starts at 0
|
|
||||||
let plaintext2 = b"hello from relay to client";
|
let plaintext2 = b"hello from relay to client";
|
||||||
let mut ciphertext2 = Vec::new();
|
let mut ciphertext2 = Vec::new();
|
||||||
relay_session
|
relay_session
|
||||||
.encrypt(&header2, plaintext2, &mut ciphertext2)
|
.encrypt(header, plaintext2, &mut ciphertext2)
|
||||||
.expect("relay encrypt should succeed");
|
.expect("relay encrypt should succeed");
|
||||||
|
|
||||||
let mut decrypted2 = Vec::new();
|
let mut decrypted2 = Vec::new();
|
||||||
client_session
|
client_session
|
||||||
.decrypt(&header2, &ciphertext2, &mut decrypted2)
|
.decrypt(header, &ciphertext2, &mut decrypted2)
|
||||||
.expect("client decrypt should succeed");
|
.expect("client decrypt should succeed");
|
||||||
|
|
||||||
assert_eq!(&decrypted2[..], plaintext2);
|
assert_eq!(&decrypted2[..], plaintext2);
|
||||||
@@ -173,15 +147,10 @@ async fn handshake_rejects_tampered_signature() {
|
|||||||
let bad_signature = kx.sign(b"wrong-data-intentionally");
|
let bad_signature = kx.sign(b"wrong-data-intentionally");
|
||||||
|
|
||||||
let offer = SignalMessage::CallOffer {
|
let offer = SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub,
|
identity_pub,
|
||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature: bad_signature,
|
signature: bad_signature,
|
||||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
alias: None,
|
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs: vec![],
|
|
||||||
};
|
};
|
||||||
client_transport_clone
|
client_transport_clone
|
||||||
.send_signal(&offer)
|
.send_signal(&offer)
|
||||||
@@ -205,42 +174,3 @@ async fn handshake_rejects_tampered_signature() {
|
|||||||
Ok(_) => panic!("relay should reject tampered signature"),
|
Ok(_) => panic!("relay should reject tampered signature"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn client_receives_protocol_version_mismatch() {
|
|
||||||
let (client_transport, relay_transport) = MockTransport::pair();
|
|
||||||
|
|
||||||
let client_seed = [0xAA_u8; 32];
|
|
||||||
|
|
||||||
// Spawn a fake relay that sends ProtocolVersionMismatch.
|
|
||||||
let relay_clone = Arc::clone(&relay_transport);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// Wait for the client's CallOffer.
|
|
||||||
let offer = relay_clone.recv_signal().await.unwrap().unwrap();
|
|
||||||
assert!(matches!(offer, SignalMessage::CallOffer { .. }));
|
|
||||||
|
|
||||||
// Respond with ProtocolVersionMismatch.
|
|
||||||
let mismatch = SignalMessage::Hangup {
|
|
||||||
version: default_signal_version(),
|
|
||||||
reason: wzp_proto::HangupReason::ProtocolVersionMismatch {
|
|
||||||
server_supported: vec![3],
|
|
||||||
},
|
|
||||||
call_id: None,
|
|
||||||
};
|
|
||||||
relay_clone.send_signal(&mismatch).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let result =
|
|
||||||
wzp_client::handshake::perform_handshake(client_transport.as_ref(), &client_seed, None)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Err(wzp_client::handshake::HandshakeError::ProtocolVersionMismatch {
|
|
||||||
server_supported,
|
|
||||||
}) => {
|
|
||||||
assert_eq!(server_supported, vec![3]);
|
|
||||||
}
|
|
||||||
Err(other) => panic!("expected ProtocolVersionMismatch, got: {other:?}"),
|
|
||||||
Ok(_) => panic!("expected handshake to fail with ProtocolVersionMismatch"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -83,12 +83,8 @@ fn long_session_no_drift() {
|
|||||||
println!(
|
println!(
|
||||||
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
||||||
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
||||||
stats.underruns,
|
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
|
||||||
stats.overruns,
|
stats.packets_late, stats.packets_lost,
|
||||||
stats.current_depth,
|
|
||||||
stats.max_depth_seen,
|
|
||||||
stats.packets_late,
|
|
||||||
stats.packets_lost,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
|
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
|
||||||
@@ -127,7 +123,7 @@ fn long_session_with_simulated_loss() {
|
|||||||
|
|
||||||
for (j, pkt) in batch.into_iter().enumerate() {
|
for (j, pkt) in batch.into_iter().enumerate() {
|
||||||
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
|
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
|
||||||
if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
|
if !pkt.header.is_repair && i % 20 == 0 && j == 0 {
|
||||||
continue; // drop this packet
|
continue; // drop this packet
|
||||||
}
|
}
|
||||||
decoder.ingest(pkt);
|
decoder.ingest(pkt);
|
||||||
@@ -143,12 +139,8 @@ fn long_session_with_simulated_loss() {
|
|||||||
println!(
|
println!(
|
||||||
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
|
||||||
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
|
||||||
stats.underruns,
|
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen,
|
||||||
stats.overruns,
|
stats.packets_late, stats.packets_lost,
|
||||||
stats.current_depth,
|
|
||||||
stats.max_depth_seen,
|
|
||||||
stats.packets_late,
|
|
||||||
stats.packets_lost,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.
|
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.
|
||||||
@@ -158,65 +150,6 @@ fn long_session_with_simulated_loss() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `MediaHeader::timestamp` continues monotonically across
|
|
||||||
/// rekey boundaries. Rekey is a crypto-layer operation (key material
|
|
||||||
/// rotation) and must not reset or interfere with framing state.
|
|
||||||
///
|
|
||||||
/// We simulate a 3000-frame session with two conceptual rekeys at frames
|
|
||||||
/// 1000 and 2000. The encoder's timestamp counter must advance
|
|
||||||
/// monotonically throughout.
|
|
||||||
#[test]
|
|
||||||
fn rekey_timestamp_monotonic() {
|
|
||||||
let config = test_config();
|
|
||||||
let mut encoder = CallEncoder::new(&config);
|
|
||||||
|
|
||||||
let mut timestamps = Vec::new();
|
|
||||||
|
|
||||||
// Phase 1: before first rekey
|
|
||||||
for i in 0..1000 {
|
|
||||||
let pcm = sine_frame(i);
|
|
||||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
|
||||||
for pkt in packets {
|
|
||||||
timestamps.push(pkt.header.timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: between first and second rekey
|
|
||||||
for i in 1000..2000 {
|
|
||||||
let pcm = sine_frame(i);
|
|
||||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
|
||||||
for pkt in packets {
|
|
||||||
timestamps.push(pkt.header.timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: after second rekey
|
|
||||||
for i in 2000..3000 {
|
|
||||||
let pcm = sine_frame(i);
|
|
||||||
let packets = encoder.encode_frame(&pcm).expect("encode");
|
|
||||||
for pkt in packets {
|
|
||||||
timestamps.push(pkt.header.timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert strict monotonicity (non-decreasing) across all three phases.
|
|
||||||
for window in timestamps.windows(2) {
|
|
||||||
assert!(
|
|
||||||
window[1] >= window[0],
|
|
||||||
"timestamp not monotonic across rekey boundary: {} -> {}",
|
|
||||||
window[0],
|
|
||||||
window[1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity: we should have collected at least 3000 timestamps.
|
|
||||||
assert!(
|
|
||||||
timestamps.len() >= 3000,
|
|
||||||
"expected >= 3000 timestamps, got {}",
|
|
||||||
timestamps.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that the jitter buffer's decoded-frame count is consistent with its
|
/// Verify that the jitter buffer's decoded-frame count is consistent with its
|
||||||
/// own internal statistics over a long session.
|
/// own internal statistics over a long session.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,17 +10,8 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
|
|||||||
wzp-proto = { workspace = true }
|
wzp-proto = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
# Opus bindings — libopus 1.5.2.
|
# Opus bindings
|
||||||
# opusic-c for the encoder (set_dred_duration lives here in Phase 1).
|
audiopus = { workspace = true }
|
||||||
# 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
|
# Pure-Rust Codec2 implementation
|
||||||
codec2 = { workspace = true }
|
codec2 = { workspace = true }
|
||||||
|
|||||||
@@ -116,14 +116,6 @@ impl AudioEncoder for AdaptiveEncoder {
|
|||||||
fn set_dtx(&mut self, enabled: bool) {
|
fn set_dtx(&mut self, enabled: bool) {
|
||||||
self.opus.set_dtx(enabled);
|
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 ─────────────────────────────────────────────────────────
|
// ─── AdaptiveDecoder ─────────────────────────────────────────────────────────
|
||||||
@@ -207,27 +199,6 @@ impl AdaptiveDecoder {
|
|||||||
fn codec2_frame_samples(&self) -> usize {
|
fn codec2_frame_samples(&self) -> usize {
|
||||||
self.codec2.frame_samples()
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ impl EchoCanceller {
|
|||||||
/// Number of delayed samples available to release.
|
/// Number of delayed samples available to release.
|
||||||
fn delay_available(&self) -> usize {
|
fn delay_available(&self) -> usize {
|
||||||
let buffered = self.delay_write - self.delay_read;
|
let buffered = self.delay_write - self.delay_read;
|
||||||
buffered.saturating_sub(self.delay_samples)
|
if buffered > self.delay_samples {
|
||||||
|
buffered - self.delay_samples
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||||
@@ -157,8 +161,8 @@ impl EchoCanceller {
|
|||||||
let mut sum_near_sq: f64 = 0.0;
|
let mut sum_near_sq: f64 = 0.0;
|
||||||
let mut sum_err_sq: f64 = 0.0;
|
let mut sum_err_sq: f64 = 0.0;
|
||||||
|
|
||||||
for (i, sample) in nearend.iter_mut().enumerate() {
|
for i in 0..n {
|
||||||
let near_f = *sample as f32;
|
let near_f = nearend[i] as f32;
|
||||||
|
|
||||||
// Position of far-end "now" for this near-end sample.
|
// Position of far-end "now" for this near-end sample.
|
||||||
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||||
@@ -186,7 +190,7 @@ impl EchoCanceller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let out = error.clamp(-32768.0, 32767.0);
|
let out = error.clamp(-32768.0, 32767.0);
|
||||||
*sample = out as i16;
|
nearend[i] = out as i16;
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64).powi(2);
|
sum_near_sq += (near_f as f64).powi(2);
|
||||||
sum_err_sq += (out as f64).powi(2);
|
sum_err_sq += (out as f64).powi(2);
|
||||||
@@ -321,10 +325,7 @@ mod tests {
|
|||||||
// Feed 960 samples (= delay amount). No samples released yet.
|
// Feed 960 samples (= delay amount). No samples released yet.
|
||||||
aec.feed_farend(&vec![1i16; 960]);
|
aec.feed_farend(&vec![1i16; 960]);
|
||||||
// far_buf should still be all zeros (nothing released).
|
// far_buf should still be all zeros (nothing released).
|
||||||
assert!(
|
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
|
||||||
aec.far_buf.iter().all(|&s| s == 0.0),
|
|
||||||
"nothing should be released yet"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Feed 480 more. 480 should be released to far_buf.
|
// Feed 480 more. 480 should be released to far_buf.
|
||||||
aec.feed_farend(&vec![2i16; 480]);
|
aec.feed_farend(&vec![2i16; 480]);
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ impl AutoGainControl {
|
|||||||
/// Create a new AGC with sensible VoIP defaults.
|
/// Create a new AGC with sensible VoIP defaults.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
target_rms: 3000.0, // ~-20 dBFS for i16
|
||||||
current_gain: 1.0,
|
current_gain: 1.0,
|
||||||
min_gain: 0.5,
|
min_gain: 0.5,
|
||||||
max_gain: 32.0,
|
max_gain: 32.0,
|
||||||
attack_alpha: 0.3, // fast attack
|
attack_alpha: 0.3, // fast attack
|
||||||
release_alpha: 0.02, // slow release
|
release_alpha: 0.02, // slow release
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +211,9 @@ mod tests {
|
|||||||
fn agc_gain_db_at_unity() {
|
fn agc_gain_db_at_unity() {
|
||||||
let agc = AutoGainControl::new();
|
let agc = AutoGainControl::new();
|
||||||
let db = agc.current_gain_db();
|
let db = agc.current_gain_db();
|
||||||
assert!(db.abs() < 0.01, "expected ~0 dB at unity gain, got {db}");
|
assert!(
|
||||||
|
db.abs() < 0.01,
|
||||||
|
"expected ~0 dB at unity gain, got {db}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ impl Codec2Decoder {
|
|||||||
|
|
||||||
/// Number of compressed bytes per frame.
|
/// Number of compressed bytes per frame.
|
||||||
fn bytes_per_frame(&self) -> usize {
|
fn bytes_per_frame(&self) -> usize {
|
||||||
self.inner.bits_per_frame().div_ceil(8)
|
(self.inner.bits_per_frame() + 7) / 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ impl Codec2Encoder {
|
|||||||
|
|
||||||
/// Number of compressed bytes per frame.
|
/// Number of compressed bytes per frame.
|
||||||
fn bytes_per_frame(&self) -> usize {
|
fn bytes_per_frame(&self) -> usize {
|
||||||
self.inner.bits_per_frame().div_ceil(8)
|
(self.inner.bits_per_frame() + 7) / 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ impl NoiseSupressor {
|
|||||||
|
|
||||||
// f32 → i16 with clamping
|
// f32 → i16 with clamping
|
||||||
for (i, &val) in output.iter().enumerate() {
|
for (i, &val) in output.iter().enumerate() {
|
||||||
let clamped = val.clamp(-32768.0, 32767.0);
|
let clamped = val.max(-32768.0).min(32767.0);
|
||||||
pcm[offset + i] = clamped as i16;
|
pcm[offset + i] = clamped as i16;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,11 +99,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let original_len = pcm.len();
|
let original_len = pcm.len();
|
||||||
ns.process(&mut pcm);
|
ns.process(&mut pcm);
|
||||||
assert_eq!(
|
assert_eq!(pcm.len(), original_len, "output length must match input length");
|
||||||
pcm.len(),
|
|
||||||
original_len,
|
|
||||||
"output length must match input length"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,583 +0,0 @@
|
|||||||
//! Raw opusic-sys FFI wrappers for libopus 1.5.2 decoder + DRED reconstruction.
|
|
||||||
//!
|
|
||||||
//! # Why this module exists
|
|
||||||
//!
|
|
||||||
//! We cannot use `opusic_c::Decoder` because its inner `*mut OpusDecoder`
|
|
||||||
//! pointer is `pub(crate)` — not reachable from outside the opusic-c crate.
|
|
||||||
//! Phase 3 of the DRED integration needs to hand that same pointer to
|
|
||||||
//! `opus_decoder_dred_decode`, and running two parallel decoders (one from
|
|
||||||
//! opusic-c for normal audio, another from opusic-sys for DRED) would cause
|
|
||||||
//! the DRED-only decoder's internal state to drift out of sync with the
|
|
||||||
//! audio stream because it would not see normal decode calls.
|
|
||||||
//!
|
|
||||||
//! The fix is to own the raw decoder ourselves and use the same handle for
|
|
||||||
//! both normal decode AND DRED reconstruction. This module is the single
|
|
||||||
//! owner of `*mut OpusDecoder`, `*mut OpusDREDDecoder`, and `*mut OpusDRED`
|
|
||||||
//! in the WZP workspace.
|
|
||||||
//!
|
|
||||||
//! # Phase 3a scope
|
|
||||||
//!
|
|
||||||
//! Phase 0 added `DecoderHandle` (normal decode). Phase 3a adds:
|
|
||||||
//! - [`DredDecoderHandle`] — wraps `*mut OpusDREDDecoder` for parsing DRED
|
|
||||||
//! side-channel data out of arriving Opus packets.
|
|
||||||
//! - [`DredState`] — wraps `*mut OpusDRED` (a fixed 10,592-byte buffer
|
|
||||||
//! allocated by libopus) that holds parsed DRED state between the parse
|
|
||||||
//! and reconstruct steps.
|
|
||||||
//! - [`DredDecoderHandle::parse_into`] — wraps `opus_dred_parse`.
|
|
||||||
//! - [`DecoderHandle::reconstruct_from_dred`] — wraps `opus_decoder_dred_decode`.
|
|
||||||
//!
|
|
||||||
//! The pattern is: on every arriving Opus packet, the receiver calls
|
|
||||||
//! `parse_into` with a reusable `DredState`, then stores (seq, state_clone)
|
|
||||||
//! in a ring. On detected loss, the receiver computes the offset from the
|
|
||||||
//! freshest reachable DRED state and calls `reconstruct_from_dred` to
|
|
||||||
//! synthesize the missing audio.
|
|
||||||
|
|
||||||
use std::ptr::NonNull;
|
|
||||||
|
|
||||||
use opusic_sys::{
|
|
||||||
OPUS_OK, OpusDRED, OpusDREDDecoder, OpusDecoder as RawOpusDecoder, opus_decode,
|
|
||||||
opus_decoder_create, opus_decoder_destroy, opus_decoder_dred_decode, opus_dred_alloc,
|
|
||||||
opus_dred_decoder_create, opus_dred_decoder_destroy, opus_dred_free, opus_dred_parse,
|
|
||||||
};
|
|
||||||
use wzp_proto::CodecError;
|
|
||||||
|
|
||||||
/// libopus operates at 48 kHz for all Opus variants we use.
|
|
||||||
const SAMPLE_RATE_HZ: i32 = 48_000;
|
|
||||||
/// Mono.
|
|
||||||
const CHANNELS: i32 = 1;
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDecoder` allocated via `opus_decoder_create`.
|
|
||||||
///
|
|
||||||
/// Releases the decoder in `Drop`. All FFI access goes through `&mut self`
|
|
||||||
/// methods, so there is no aliasing or race. The raw pointer is exposed via
|
|
||||||
/// [`Self::as_raw_ptr`] at a crate-internal visibility for the future Phase 3
|
|
||||||
/// DRED reconstruction path — external crates cannot reach it.
|
|
||||||
pub struct DecoderHandle {
|
|
||||||
inner: NonNull<RawOpusDecoder>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DecoderHandle {
|
|
||||||
/// Allocate a new Opus decoder at 48 kHz mono.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_decoder_create writes to `error` and returns either a
|
|
||||||
// valid heap pointer or null. We check both before constructing the
|
|
||||||
// NonNull wrapper.
|
|
||||||
let ptr = unsafe { opus_decoder_create(SAMPLE_RATE_HZ, CHANNELS, &mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
// Even if ptr is non-null on error, libopus contracts guarantee
|
|
||||||
// it is unusable — do not attempt to free it.
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decoder_create failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr)
|
|
||||||
.ok_or_else(|| CodecError::DecodeFailed("opus_decoder_create returned null".into()))?;
|
|
||||||
Ok(Self { inner })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode an Opus packet into PCM samples.
|
|
||||||
///
|
|
||||||
/// `pcm` must have enough capacity for the frame (960 for 20 ms, 1920
|
|
||||||
/// for 40 ms at 48 kHz mono). Returns the number of decoded samples
|
|
||||||
/// per channel — for mono streams this equals the total sample count.
|
|
||||||
pub fn decode(&mut self, packet: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty packet".into()));
|
|
||||||
}
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
|
||||||
}
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDecoder owned by this struct.
|
|
||||||
// `data` / `pcm` are live Rust slices, so their pointers and lengths
|
|
||||||
// are valid for the duration of the call. libopus reads len bytes
|
|
||||||
// from data and writes up to frame_size samples (per channel) to pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
packet.as_ptr(),
|
|
||||||
packet.len() as i32,
|
|
||||||
pcm.as_mut_ptr(),
|
|
||||||
pcm.len() as i32,
|
|
||||||
/* decode_fec = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decode failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate packet-loss concealment audio for a missing frame.
|
|
||||||
///
|
|
||||||
/// Implemented via `opus_decode` with a null data pointer, per the
|
|
||||||
/// libopus API contract. `pcm` should be sized for the expected frame.
|
|
||||||
pub fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
|
||||||
}
|
|
||||||
// SAFETY: same invariants as decode(). libopus documents that passing
|
|
||||||
// a null data pointer with len=0 triggers PLC synthesis into pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
std::ptr::null(),
|
|
||||||
0,
|
|
||||||
pcm.as_mut_ptr(),
|
|
||||||
pcm.len() as i32,
|
|
||||||
/* decode_fec = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decode PLC failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconstruct audio from a `DredState` into the `output` buffer.
|
|
||||||
///
|
|
||||||
/// `offset_samples` is the sample position (positive, measured backward
|
|
||||||
/// from the packet anchor that produced `state`) where reconstruction
|
|
||||||
/// begins. `output.len()` must match the number of samples to synthesize.
|
|
||||||
///
|
|
||||||
/// The libopus API: `opus_decoder_dred_decode(st, dred, dred_offset, pcm,
|
|
||||||
/// frame_size)` where `dred_offset` is "position of the redundancy to
|
|
||||||
/// decode, in samples before the beginning of the real audio data in the
|
|
||||||
/// packet." Valid values: `0 < offset_samples < state.samples_available()`.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written (should equal
|
|
||||||
/// `output.len()` on success).
|
|
||||||
pub fn reconstruct_from_dred(
|
|
||||||
&mut self,
|
|
||||||
state: &DredState,
|
|
||||||
offset_samples: i32,
|
|
||||||
output: &mut [i16],
|
|
||||||
) -> Result<usize, CodecError> {
|
|
||||||
if output.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed(
|
|
||||||
"empty reconstruction output buffer".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if offset_samples <= 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"DRED offset must be positive (got {offset_samples})"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if offset_samples > state.samples_available() {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"DRED offset {offset_samples} exceeds available samples {}",
|
|
||||||
state.samples_available()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDecoder, state.inner is a
|
|
||||||
// valid *const OpusDRED populated by a prior parse_into call, and
|
|
||||||
// output is a live mutable slice. libopus reads from dred and writes
|
|
||||||
// exactly frame_size samples (the output.len()) to pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decoder_dred_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
state.inner.as_ptr(),
|
|
||||||
offset_samples,
|
|
||||||
output.as_mut_ptr(),
|
|
||||||
output.len() as i32,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decoder_dred_decode failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DecoderHandle {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_decoder_destroy(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: The underlying OpusDecoder is a plain heap allocation with no
|
|
||||||
// thread-local or lock-free state. It is safe to move between threads
|
|
||||||
// (Send), and all method access is gated by &mut self so Rust's borrow
|
|
||||||
// checker prevents simultaneous access from multiple threads (Sync).
|
|
||||||
unsafe impl Send for DecoderHandle {}
|
|
||||||
unsafe impl Sync for DecoderHandle {}
|
|
||||||
|
|
||||||
// ─── DRED decoder (parser) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDREDDecoder` allocated via
|
|
||||||
/// `opus_dred_decoder_create`.
|
|
||||||
///
|
|
||||||
/// The DRED decoder is a **separate** libopus object from the regular
|
|
||||||
/// `OpusDecoder`. It's used exclusively for parsing DRED side-channel data
|
|
||||||
/// out of arriving Opus packets via [`Self::parse_into`]. Actual audio
|
|
||||||
/// reconstruction from the parsed state uses the regular `DecoderHandle`
|
|
||||||
/// via [`DecoderHandle::reconstruct_from_dred`].
|
|
||||||
pub struct DredDecoderHandle {
|
|
||||||
inner: NonNull<OpusDREDDecoder>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DredDecoderHandle {
|
|
||||||
/// Allocate a new DRED decoder.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_dred_decoder_create writes to `error` and returns
|
|
||||||
// either a valid heap pointer or null. Both are checked.
|
|
||||||
let ptr = unsafe { opus_dred_decoder_create(&mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_decoder_create failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
|
||||||
CodecError::DecodeFailed("opus_dred_decoder_create returned null".into())
|
|
||||||
})?;
|
|
||||||
Ok(Self { inner })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse DRED side-channel data from an Opus packet into `state`.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples of audio history available for
|
|
||||||
/// reconstruction, or 0 if the packet carries no DRED data. Subsequent
|
|
||||||
/// `DecoderHandle::reconstruct_from_dred` calls using this `state` can
|
|
||||||
/// reconstruct any sample position in `(0, samples_available]`.
|
|
||||||
///
|
|
||||||
/// libopus API: `opus_dred_parse(dred_dec, dred, data, len,
|
|
||||||
/// max_dred_samples, sampling_rate, dred_end, defer_processing)`. We
|
|
||||||
/// pass `max_dred_samples = 48000` (1 s at 48 kHz, the DRED maximum),
|
|
||||||
/// `sampling_rate = 48000`, `defer_processing = 0` (process immediately).
|
|
||||||
/// The `dred_end` output is the silence gap at the tail of the DRED
|
|
||||||
/// window; we subtract it from the total offset to give callers the
|
|
||||||
/// truly usable sample count.
|
|
||||||
pub fn parse_into(&mut self, state: &mut DredState, packet: &[u8]) -> Result<i32, CodecError> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
state.samples_available = 0;
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
let mut dred_end: i32 = 0;
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDREDDecoder; state.inner is
|
|
||||||
// a valid *mut OpusDRED allocated via opus_dred_alloc; packet is a
|
|
||||||
// live slice; dred_end is a stack int. libopus reads packet bytes
|
|
||||||
// and writes parsed DRED state into *state.inner.
|
|
||||||
let ret = unsafe {
|
|
||||||
opus_dred_parse(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
state.inner.as_ptr(),
|
|
||||||
packet.as_ptr(),
|
|
||||||
packet.len() as i32,
|
|
||||||
/* max_dred_samples = */ 48_000, // 1s max per libopus 1.5
|
|
||||||
/* sampling_rate = */ 48_000,
|
|
||||||
&mut dred_end,
|
|
||||||
/* defer_processing = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if ret < 0 {
|
|
||||||
state.samples_available = 0;
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_parse failed: err={ret}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
// ret is the positive offset of the first decodable DRED sample,
|
|
||||||
// or 0 if no DRED is present. dred_end is the silence gap at the
|
|
||||||
// tail. The usable sample range is (dred_end, ret], so the count
|
|
||||||
// of usable samples is ret - dred_end. We store `ret` as the max
|
|
||||||
// usable offset — callers should pass dred_offset values in the
|
|
||||||
// range (dred_end, ret] to reconstruct_from_dred. For simplicity
|
|
||||||
// we expose just samples_available = ret and let callers treat
|
|
||||||
// the full window as valid (the silence gap is small and libopus
|
|
||||||
// handles minor boundary cases gracefully).
|
|
||||||
state.samples_available = ret;
|
|
||||||
Ok(ret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DredDecoderHandle {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_dred_decoder_destroy(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: same reasoning as DecoderHandle — heap allocation with no
|
|
||||||
// thread-local state, &mut self access discipline prevents races.
|
|
||||||
unsafe impl Send for DredDecoderHandle {}
|
|
||||||
unsafe impl Sync for DredDecoderHandle {}
|
|
||||||
|
|
||||||
// ─── DRED state buffer ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDRED` allocated via `opus_dred_alloc`.
|
|
||||||
///
|
|
||||||
/// Holds a fixed-size (10,592-byte per libopus 1.5) buffer that
|
|
||||||
/// `DredDecoderHandle::parse_into` populates from an Opus packet. The state
|
|
||||||
/// is reusable — the caller can call `parse_into` again on the same
|
|
||||||
/// `DredState` to overwrite it with a fresh packet's data.
|
|
||||||
///
|
|
||||||
/// `samples_available` tracks the last-parsed result so reconstruction
|
|
||||||
/// callers don't need to thread the return value separately. A fresh
|
|
||||||
/// state (before any `parse_into`) has `samples_available == 0`.
|
|
||||||
pub struct DredState {
|
|
||||||
inner: NonNull<OpusDRED>,
|
|
||||||
samples_available: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DredState {
|
|
||||||
/// Allocate a new DRED state buffer.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_dred_alloc writes to `error` and returns either a
|
|
||||||
// valid heap pointer or null.
|
|
||||||
let ptr = unsafe { opus_dred_alloc(&mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_alloc failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr)
|
|
||||||
.ok_or_else(|| CodecError::DecodeFailed("opus_dred_alloc returned null".into()))?;
|
|
||||||
Ok(Self {
|
|
||||||
inner,
|
|
||||||
samples_available: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many samples of audio history this state currently covers.
|
|
||||||
///
|
|
||||||
/// Returns 0 if the state is fresh or the last parse found no DRED
|
|
||||||
/// data. Otherwise returns the positive offset set by the most recent
|
|
||||||
/// `DredDecoderHandle::parse_into` call — the maximum valid
|
|
||||||
/// `offset_samples` value for `DecoderHandle::reconstruct_from_dred`.
|
|
||||||
pub fn samples_available(&self) -> i32 {
|
|
||||||
self.samples_available
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the state to "fresh" without freeing the underlying buffer.
|
|
||||||
/// The next `parse_into` will overwrite the contents.
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.samples_available = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DredState {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_dred_free(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: same reasoning as DecoderHandle.
|
|
||||||
unsafe impl Send for DredState {}
|
|
||||||
unsafe impl Sync for DredState {}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decoder_handle_creates_and_drops() {
|
|
||||||
let handle = DecoderHandle::new().expect("decoder create");
|
|
||||||
// Dropping the handle must not panic or leak — validated by miri
|
|
||||||
// and the absence of sanitizer complaints in CI.
|
|
||||||
drop(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_lost_produces_full_frame_of_silence_on_cold_start() {
|
|
||||||
let mut handle = DecoderHandle::new().unwrap();
|
|
||||||
// 20 ms @ 48 kHz mono.
|
|
||||||
let mut pcm = vec![0i16; 960];
|
|
||||||
let n = handle.decode_lost(&mut pcm).unwrap();
|
|
||||||
assert_eq!(n, 960);
|
|
||||||
// On a fresh decoder, PLC output is silence (no past audio to extend).
|
|
||||||
assert!(pcm.iter().all(|&s| s == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_empty_packet_errors() {
|
|
||||||
let mut handle = DecoderHandle::new().unwrap();
|
|
||||||
let mut pcm = vec![0i16; 960];
|
|
||||||
let err = handle.decode(&[], &mut pcm);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 3a — DRED decoder + state ────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_decoder_handle_creates_and_drops() {
|
|
||||||
let h = DredDecoderHandle::new().expect("dred decoder create");
|
|
||||||
drop(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_state_creates_and_drops() {
|
|
||||||
let s = DredState::new().expect("dred state alloc");
|
|
||||||
assert_eq!(s.samples_available(), 0);
|
|
||||||
drop(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_state_reset_zeroes_counter() {
|
|
||||||
let mut s = DredState::new().unwrap();
|
|
||||||
s.samples_available = 480; // pretend a parse populated it
|
|
||||||
assert_eq!(s.samples_available(), 480);
|
|
||||||
s.reset();
|
|
||||||
assert_eq!(s.samples_available(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3a end-to-end: encode a DRED-enabled stream, parse state out
|
|
||||||
/// of packets, and reconstruct audio at a past offset. Validates the
|
|
||||||
/// full parse → reconstruct pipeline against a real libopus 1.5.2
|
|
||||||
/// encoder so we catch FFI-layer bugs early.
|
|
||||||
#[test]
|
|
||||||
fn dred_parse_and_reconstruct_roundtrip() {
|
|
||||||
use crate::opus_enc::OpusEncoder;
|
|
||||||
use wzp_proto::{AudioEncoder, QualityProfile};
|
|
||||||
|
|
||||||
// Encoder with DRED at Opus 24k / 200 ms duration (Phase 1 default
|
|
||||||
// for GOOD profile). The loss floor is 5% per Phase 1.
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
|
|
||||||
// Decode-side handles.
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
|
||||||
let mut state = DredState::new().unwrap();
|
|
||||||
|
|
||||||
// Generate 60 frames (1.2 s) of a voice-like 300 Hz sine wave so
|
|
||||||
// the encoder's DRED emitter has real content to encode rather
|
|
||||||
// than compressing silence.
|
|
||||||
let frame_len = 960usize; // 20 ms @ 48 kHz
|
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..frame_len)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (offset + i) as f64 / 48_000.0;
|
|
||||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track the freshest packet that carried non-zero DRED state.
|
|
||||||
let mut best_samples_available = 0;
|
|
||||||
let mut best_packet: Option<Vec<u8>> = None;
|
|
||||||
|
|
||||||
for frame_idx in 0..60 {
|
|
||||||
let pcm = make_frame(frame_idx * frame_len);
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
|
||||||
encoded.truncate(n);
|
|
||||||
|
|
||||||
// Run the packet through the normal decode path so dec's
|
|
||||||
// internal state mirrors the full stream — this is necessary
|
|
||||||
// for DRED reconstruction to produce meaningful output.
|
|
||||||
let mut decoded = vec![0i16; frame_len];
|
|
||||||
dec.decode(&encoded, &mut decoded).unwrap();
|
|
||||||
|
|
||||||
// Parse DRED state out of the same packet. Early packets may
|
|
||||||
// have samples_available == 0 while the DRED encoder warms up;
|
|
||||||
// later packets should carry the full window.
|
|
||||||
match dred_dec.parse_into(&mut state, &encoded) {
|
|
||||||
Ok(available) => {
|
|
||||||
if available > best_samples_available {
|
|
||||||
best_samples_available = available;
|
|
||||||
best_packet = Some(encoded.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => panic!("parse_into errored unexpectedly: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// By the time we're 60 frames in, DRED should have emitted data.
|
|
||||||
assert!(
|
|
||||||
best_samples_available > 0,
|
|
||||||
"DRED emitted zero samples across 60 frames — the encoder isn't \
|
|
||||||
producing DRED bytes (check set_dred_duration and packet_loss floor)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the best packet into a fresh state and reconstruct some
|
|
||||||
// audio from somewhere inside its DRED window. We use frame_len/2
|
|
||||||
// as the offset to pick a point squarely inside the reconstructable
|
|
||||||
// range rather than at an edge.
|
|
||||||
let packet = best_packet.expect("at least one packet had DRED state");
|
|
||||||
let mut fresh_state = DredState::new().unwrap();
|
|
||||||
let available = dred_dec.parse_into(&mut fresh_state, &packet).unwrap();
|
|
||||||
assert!(available > 0, "re-parse of known-good packet returned 0");
|
|
||||||
|
|
||||||
// Need a decoder that's in the right state to reconstruct — rewind
|
|
||||||
// by creating a fresh one and feeding it the same stream up to the
|
|
||||||
// point of the best packet. Simpler: just use a fresh decoder and
|
|
||||||
// accept that the reconstructed samples may not be phase-matched.
|
|
||||||
// The test here only asserts *non-silent energy*, not signal fidelity.
|
|
||||||
let mut recon_dec = DecoderHandle::new().unwrap();
|
|
||||||
// Warm up the decoder with one frame so its internal state is valid.
|
|
||||||
let warmup_pcm = vec![0i16; frame_len];
|
|
||||||
let warmup_encoded = {
|
|
||||||
let mut warmup_enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
let mut buf = vec![0u8; 512];
|
|
||||||
let n = warmup_enc.encode(&warmup_pcm, &mut buf).unwrap();
|
|
||||||
buf.truncate(n);
|
|
||||||
buf
|
|
||||||
};
|
|
||||||
let mut throwaway = vec![0i16; frame_len];
|
|
||||||
let _ = recon_dec.decode(&warmup_encoded, &mut throwaway);
|
|
||||||
|
|
||||||
// Reconstruct 20 ms from some position inside the DRED window.
|
|
||||||
let offset = (available / 2).max(480).min(available);
|
|
||||||
let mut recon_pcm = vec![0i16; frame_len];
|
|
||||||
let n = recon_dec
|
|
||||||
.reconstruct_from_dred(&fresh_state, offset, &mut recon_pcm)
|
|
||||||
.expect("reconstruct_from_dred failed");
|
|
||||||
assert_eq!(n, frame_len);
|
|
||||||
|
|
||||||
// Energy check: reconstructed audio should not be all zeros. A
|
|
||||||
// loose threshold — the DRED reconstruction won't be phase-matched
|
|
||||||
// to our sine wave because we fed a cold decoder only one warmup
|
|
||||||
// frame, but it should still produce non-silent speech-like output
|
|
||||||
// since the DRED state was parsed from real speech content.
|
|
||||||
let energy: u64 = recon_pcm
|
|
||||||
.iter()
|
|
||||||
.map(|&s| (s as i32).unsigned_abs() as u64)
|
|
||||||
.sum();
|
|
||||||
assert!(
|
|
||||||
energy > 0,
|
|
||||||
"reconstructed audio has zero total energy — DRED reconstruction produced silence"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A second roundtrip variant: offset too large errors cleanly rather
|
|
||||||
/// than crashing the FFI.
|
|
||||||
#[test]
|
|
||||||
fn reconstruct_with_out_of_range_offset_errors() {
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let state = DredState::new().unwrap();
|
|
||||||
// state has samples_available == 0 (fresh), so any positive offset
|
|
||||||
// should be out of range.
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
let err = dec.reconstruct_from_dred(&state, 480, &mut out);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reconstruct_with_zero_offset_errors() {
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let state = DredState::new().unwrap();
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
let err = dec.reconstruct_from_dred(&state, 0, &mut out);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_parse_empty_packet_returns_zero() {
|
|
||||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
|
||||||
let mut state = DredState::new().unwrap();
|
|
||||||
let result = dred_dec.parse_into(&mut state, &[]).unwrap();
|
|
||||||
assert_eq!(result, 0);
|
|
||||||
assert_eq!(state.samples_available(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ pub mod agc;
|
|||||||
pub mod codec2_dec;
|
pub mod codec2_dec;
|
||||||
pub mod codec2_enc;
|
pub mod codec2_enc;
|
||||||
pub mod denoise;
|
pub mod denoise;
|
||||||
pub mod dred_ffi;
|
|
||||||
pub mod opus_dec;
|
pub mod opus_dec;
|
||||||
pub mod opus_enc;
|
pub mod opus_enc;
|
||||||
pub mod resample;
|
pub mod resample;
|
||||||
@@ -28,32 +27,15 @@ pub use denoise::NoiseSupressor;
|
|||||||
pub use silence::{ComfortNoise, SilenceDetector};
|
pub use silence::{ComfortNoise, SilenceDetector};
|
||||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
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.
|
/// Create an adaptive encoder starting at the given quality profile.
|
||||||
///
|
///
|
||||||
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
||||||
/// codec; resampling is handled internally when Codec2 is selected.
|
/// codec; resampling is handled internally when Codec2 is selected.
|
||||||
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
||||||
Box::new(AdaptiveEncoder::new(profile).expect("failed to create adaptive encoder"))
|
Box::new(
|
||||||
|
AdaptiveEncoder::new(profile)
|
||||||
|
.expect("failed to create adaptive encoder"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an adaptive decoder starting at the given quality profile.
|
/// Create an adaptive decoder starting at the given quality profile.
|
||||||
@@ -61,7 +43,10 @@ pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
|
|||||||
/// The returned decoder always produces 48 kHz mono PCM; upsampling from
|
/// The returned decoder always produces 48 kHz mono PCM; upsampling from
|
||||||
/// Codec2's native 8 kHz is handled internally.
|
/// Codec2's native 8 kHz is handled internally.
|
||||||
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
|
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
|
||||||
Box::new(AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder"))
|
Box::new(
|
||||||
|
AdaptiveDecoder::new(profile)
|
||||||
|
.expect("failed to create adaptive decoder"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -76,10 +61,6 @@ mod codec2_tests {
|
|||||||
fec_ratio: 0.5,
|
fec_ratio: 0.5,
|
||||||
frame_duration_ms: 20,
|
frame_duration_ms: 20,
|
||||||
frames_per_block: 5,
|
frames_per_block: 5,
|
||||||
priority_mode: wzp_proto::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,10 +189,7 @@ mod codec2_tests {
|
|||||||
|
|
||||||
let mut pcm_out_c2 = vec![0i16; 1920];
|
let mut pcm_out_c2 = vec![0i16; 1920];
|
||||||
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
|
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(samples_c2, 1920, "should get 1920 samples at 48kHz after upsample");
|
||||||
samples_c2, 1920,
|
|
||||||
"should get 1920 samples at 48kHz after upsample"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 3: Switch back to Opus.
|
// Step 3: Switch back to Opus.
|
||||||
enc.set_profile(QualityProfile::GOOD).unwrap();
|
enc.set_profile(QualityProfile::GOOD).unwrap();
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`.
|
//! Opus decoder wrapping the `audiopus` crate.
|
||||||
//!
|
|
||||||
//! 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 crate::dred_ffi::{DecoderHandle, DredState};
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, MutSignals, SampleRate};
|
||||||
|
use audiopus::packet::Packet;
|
||||||
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
/// Opus decoder implementing [`AudioDecoder`].
|
/// Opus decoder implementing `AudioDecoder`.
|
||||||
///
|
///
|
||||||
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via
|
/// Operates at 48 kHz mono output.
|
||||||
/// 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 {
|
pub struct OpusDecoder {
|
||||||
inner: DecoderHandle,
|
inner: Decoder,
|
||||||
codec_id: CodecId,
|
codec_id: CodecId,
|
||||||
frame_duration_ms: u8,
|
frame_duration_ms: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
|
||||||
|
unsafe impl Sync for OpusDecoder {}
|
||||||
|
|
||||||
impl OpusDecoder {
|
impl OpusDecoder {
|
||||||
/// Create a new Opus decoder for the given quality profile.
|
/// Create a new Opus decoder for the given quality profile.
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
let inner = DecoderHandle::new()?;
|
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner,
|
inner: decoder,
|
||||||
codec_id: profile.codec,
|
codec_id: profile.codec,
|
||||||
frame_duration_ms: profile.frame_duration_ms,
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
})
|
})
|
||||||
@@ -36,24 +34,6 @@ impl OpusDecoder {
|
|||||||
pub fn frame_samples(&self) -> usize {
|
pub fn frame_samples(&self) -> usize {
|
||||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
(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 {
|
impl AudioDecoder for OpusDecoder {
|
||||||
@@ -65,7 +45,15 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
pcm.len()
|
pcm.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.inner.decode(encoded, pcm)
|
let packet = Packet::try_from(encoded)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
|
||||||
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(Some(packet), signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
@@ -76,7 +64,13 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
pcm.len()
|
pcm.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.inner.decode_lost(pcm)
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(None, signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_id(&self) -> CodecId {
|
fn codec_id(&self) -> CodecId {
|
||||||
@@ -85,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
c if c.is_opus() => {
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,230 +1,58 @@
|
|||||||
//! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2).
|
//! Opus encoder wrapping the `audiopus` crate.
|
||||||
//!
|
|
||||||
//! 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 std::sync::OnceLock;
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||||
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
|
use tracing::debug;
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
/// Logged exactly once per process the first time an OpusEncoder is built.
|
|
||||||
/// Confirms that libopus 1.5.2 (the version with DRED) is actually linked
|
|
||||||
/// at runtime — invaluable when chasing "is the new codec loaded?"
|
|
||||||
/// regressions on Android, where the only debug surface is logcat.
|
|
||||||
static LIBOPUS_VERSION_LOGGED: OnceLock<()> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus
|
|
||||||
/// scales the DRED emission window with the assumed loss percentage:
|
|
||||||
/// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15%
|
|
||||||
/// gives 95 ms, and 25%+ saturates the configured max (~175 ms at 200 ms
|
|
||||||
/// duration). 15% is the minimum value that produces a DRED window larger
|
|
||||||
/// than a single 20 ms frame, making it the minimum floor that actually
|
|
||||||
/// gives DRED something useful to reconstruct. Real loss measurements from
|
|
||||||
/// the quality adapter override this upward.
|
|
||||||
const DRED_LOSS_FLOOR_PCT: u8 = 15;
|
|
||||||
|
|
||||||
/// Environment variable that reverts Phase 1 behavior to Phase 0 (inband FEC
|
|
||||||
/// on, DRED off, no loss floor). Read once per encoder construction.
|
|
||||||
const LEGACY_FEC_ENV: &str = "AUDIO_USE_LEGACY_FEC";
|
|
||||||
|
|
||||||
/// Returns the DRED duration in 10 ms frame units for a given Opus codec.
|
|
||||||
///
|
|
||||||
/// Unit: each frame is 10 ms, so the max value of 104 corresponds to 1040 ms
|
|
||||||
/// of reconstructable history. Returns 0 for non-Opus codecs (DRED is not
|
|
||||||
/// emitted by the libopus encoder in that case anyway, but we avoid a
|
|
||||||
/// pointless FFI call).
|
|
||||||
///
|
|
||||||
/// See the DRED duration policy in the module docs for per-tier rationale.
|
|
||||||
pub fn dred_duration_for(codec: CodecId) -> u8 {
|
|
||||||
match codec {
|
|
||||||
// Studio tiers — loss is rare, short window.
|
|
||||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
|
|
||||||
// Normal tiers — balanced baseline.
|
|
||||||
CodecId::Opus16k | CodecId::Opus24k => 20,
|
|
||||||
// Degraded tier — maximum burst resilience. 104 × 10 ms = 1040 ms,
|
|
||||||
// the highest value libopus 1.5 supports. Users on 6k are on a bad
|
|
||||||
// link by definition; the RDO-VAE naturally degrades quality at longer
|
|
||||||
// offsets, so the extra window costs only ~1-2 kbps additional overhead
|
|
||||||
// while buying substantially better burst resilience (up from 500 ms).
|
|
||||||
CodecId::Opus6k => 104,
|
|
||||||
// Non-Opus (Codec2 / CN / video): DRED is N/A.
|
|
||||||
CodecId::Codec2_1200
|
|
||||||
| CodecId::Codec2_3200
|
|
||||||
| CodecId::ComfortNoise
|
|
||||||
| CodecId::H264Baseline
|
|
||||||
| CodecId::H265Main
|
|
||||||
| CodecId::Av1Main => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the legacy-FEC escape hatch is active.
|
|
||||||
///
|
|
||||||
/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy
|
|
||||||
/// mode; unset or empty leaves DRED enabled.
|
|
||||||
fn read_legacy_fec_env() -> bool {
|
|
||||||
match std::env::var(LEGACY_FEC_ENV) {
|
|
||||||
Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opus encoder implementing `AudioEncoder`.
|
/// Opus encoder implementing `AudioEncoder`.
|
||||||
///
|
///
|
||||||
/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active
|
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||||
/// `QualityProfile`.
|
/// and 40 ms (1920 samples).
|
||||||
pub struct OpusEncoder {
|
pub struct OpusEncoder {
|
||||||
inner: Encoder,
|
inner: Encoder,
|
||||||
codec_id: CodecId,
|
codec_id: CodecId,
|
||||||
frame_duration_ms: u8,
|
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
|
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
||||||
// opusic-c Encoder wraps a non-null pointer that is !Sync by default,
|
// audiopus Encoder contains a raw pointer that is !Sync, but we never
|
||||||
// but we never share it across threads without exclusive access.
|
// share it across threads without exclusive access.
|
||||||
unsafe impl Sync for OpusEncoder {}
|
unsafe impl Sync for OpusEncoder {}
|
||||||
|
|
||||||
impl OpusEncoder {
|
impl OpusEncoder {
|
||||||
/// Create a new Opus encoder for the given quality profile.
|
/// Create a new Opus encoder for the given quality profile.
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
// opusic-c argument order: (Channels, SampleRate, Application)
|
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||||
// — different from audiopus's (SampleRate, Channels, Application).
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||||
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 {
|
let mut enc = Self {
|
||||||
inner: encoder,
|
inner: encoder,
|
||||||
codec_id: profile.codec,
|
codec_id: profile.codec,
|
||||||
frame_duration_ms: profile.frame_duration_ms,
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
legacy_fec_mode,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common setup — bitrate, DTX, signal hint, complexity. These are
|
|
||||||
// identical regardless of the protection mode below.
|
|
||||||
enc.apply_bitrate(profile.codec)?;
|
enc.apply_bitrate(profile.codec)?;
|
||||||
|
enc.set_inband_fec(true);
|
||||||
enc.set_dtx(true);
|
enc.set_dtx(true);
|
||||||
|
|
||||||
|
// Voice signal type hint for better compression
|
||||||
enc.inner
|
enc.inner
|
||||||
.set_signal(Signal::Voice)
|
.set_signal(Signal::Voice)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
|
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||||
enc.inner
|
enc.inner
|
||||||
.set_complexity(7)
|
.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)
|
Ok(enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure the protection mode for the active codec.
|
|
||||||
///
|
|
||||||
/// In DRED mode (default): disable inband FEC, set DRED duration for the
|
|
||||||
/// codec tier, clamp packet_loss to the 5% floor so DRED stays active.
|
|
||||||
///
|
|
||||||
/// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave
|
|
||||||
/// DRED and packet_loss at libopus defaults.
|
|
||||||
fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
|
||||||
if self.legacy_fec_mode {
|
|
||||||
self.inner
|
|
||||||
.set_inband_fec(InbandFec::Mode1)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?;
|
|
||||||
// Leave DRED at 0 and packet_loss at default — matches Phase 0.
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// DRED path: disable the overlapping inband FEC, enable DRED with
|
|
||||||
// per-profile duration, floor packet_loss so DRED emits.
|
|
||||||
self.inner
|
|
||||||
.set_inband_fec(InbandFec::Off)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?;
|
|
||||||
|
|
||||||
let dred_frames = dred_duration_for(codec);
|
|
||||||
self.inner
|
|
||||||
.set_dred_duration(dred_frames)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?;
|
|
||||||
|
|
||||||
self.inner
|
|
||||||
.set_packet_loss(DRED_LOSS_FLOOR_PCT)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
|
|
||||||
|
|
||||||
// Both of these are gated behind the GUI debug toggle so logcat
|
|
||||||
// stays clean in normal mode. Flip "DRED verbose logs" in the
|
|
||||||
// settings panel to see the per-encoder config + libopus version.
|
|
||||||
if crate::dred_verbose_logs() {
|
|
||||||
info!(
|
|
||||||
codec = ?codec,
|
|
||||||
dred_frames,
|
|
||||||
dred_ms = dred_frames as u32 * 10,
|
|
||||||
loss_floor_pct = DRED_LOSS_FLOOR_PCT,
|
|
||||||
"opus encoder: DRED enabled"
|
|
||||||
);
|
|
||||||
|
|
||||||
// One-shot logging of the linked libopus version so we can
|
|
||||||
// confirm at a glance that opusic-c (libopus 1.5.2) is loaded.
|
|
||||||
// Pre-Phase-0 audiopus shipped libopus 1.3 which has no DRED;
|
|
||||||
// if this log says "libopus 1.3" something is very wrong.
|
|
||||||
LIBOPUS_VERSION_LOGGED.get_or_init(|| {
|
|
||||||
info!(libopus_version = %opusic_c::version(), "linked libopus version");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||||
let bps = codec.bitrate_bps();
|
let bps = codec.bitrate_bps() as i32;
|
||||||
self.inner
|
self.inner
|
||||||
.set_bitrate(Bitrate::Value(bps))
|
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||||
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -243,36 +71,10 @@ impl OpusEncoder {
|
|||||||
|
|
||||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||||
///
|
///
|
||||||
/// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the
|
/// Higher values cause the encoder to use more redundancy to survive
|
||||||
/// encoder never drops DRED emission even on a perfect network. Real
|
/// packet loss, at the expense of slightly higher bitrate.
|
||||||
/// 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) {
|
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||||
let clamped = if self.legacy_fec_mode {
|
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||||
loss_pct.min(100)
|
|
||||||
} else {
|
|
||||||
loss_pct.clamp(DRED_LOSS_FLOOR_PCT, 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +87,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
pcm.len()
|
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
|
let n = self
|
||||||
.inner
|
.inner
|
||||||
.encode_to_slice(pcm_u16, out)
|
.encode(pcm, out)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +100,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
c if c.is_opus() => {
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
self.apply_bitrate(profile.codec)?;
|
self.apply_bitrate(profile.codec)?;
|
||||||
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
other => Err(CodecError::UnsupportedTransition {
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
@@ -325,202 +120,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_inband_fec(&mut self, enabled: bool) {
|
fn set_inband_fec(&mut self, enabled: bool) {
|
||||||
// In DRED mode, ignore external requests to re-enable inband FEC —
|
let _ = self.inner.set_inband_fec(enabled);
|
||||||
// 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) {
|
fn set_dtx(&mut self, enabled: bool) {
|
||||||
let _ = self.inner.set_dtx(enabled);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
|||||||
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
||||||
let beta_denom = bessel_i0(KAISER_BETA);
|
let beta_denom = bessel_i0(KAISER_BETA);
|
||||||
|
|
||||||
for (i, slot) in kernel.iter_mut().enumerate() {
|
for i in 0..FIR_TAPS {
|
||||||
// Sinc
|
// Sinc
|
||||||
let n = i as f64 - m / 2.0;
|
let n = i as f64 - m / 2.0;
|
||||||
let sinc = if n.abs() < 1e-12 {
|
let sinc = if n.abs() < 1e-12 {
|
||||||
@@ -61,7 +61,7 @@ fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
|||||||
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
||||||
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
|
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
|
||||||
|
|
||||||
*slot = sinc * kaiser;
|
kernel[i] = sinc * kaiser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalise to unity DC gain.
|
// Normalise to unity DC gain.
|
||||||
@@ -129,7 +129,8 @@ impl Downsampler48to8 {
|
|||||||
|
|
||||||
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
||||||
if work.len() >= hist_len {
|
if work.len() >= hist_len {
|
||||||
self.history.copy_from_slice(&work[work.len() - hist_len..]);
|
self.history
|
||||||
|
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||||
} else {
|
} else {
|
||||||
// Input was shorter than history — shift.
|
// Input was shorter than history — shift.
|
||||||
let shift = hist_len - work.len();
|
let shift = hist_len - work.len();
|
||||||
@@ -180,7 +181,9 @@ impl Upsampler8to48 {
|
|||||||
work.extend_from_slice(&self.history);
|
work.extend_from_slice(&self.history);
|
||||||
for &s in input {
|
for &s in input {
|
||||||
work.push(s as f64);
|
work.push(s as f64);
|
||||||
work.resize(work.len() + (RATIO - 1), 0.0f64);
|
for _ in 1..RATIO {
|
||||||
|
work.push(0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let out_len = stuffed_len;
|
let out_len = stuffed_len;
|
||||||
@@ -206,7 +209,8 @@ impl Upsampler8to48 {
|
|||||||
|
|
||||||
// Update history.
|
// Update history.
|
||||||
if work.len() >= hist_len {
|
if work.len() >= hist_len {
|
||||||
self.history.copy_from_slice(&work[work.len() - hist_len..]);
|
self.history
|
||||||
|
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||||
} else {
|
} else {
|
||||||
let shift = hist_len - work.len();
|
let shift = hist_len - work.len();
|
||||||
self.history.copy_within(shift.., 0);
|
self.history.copy_within(shift.., 0);
|
||||||
|
|||||||
@@ -151,10 +151,7 @@ mod tests {
|
|||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
det.is_silent(&silence);
|
det.is_silent(&silence);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(det.is_silent(&silence), "should be suppressing after hangover");
|
||||||
det.is_silent(&silence),
|
|
||||||
"should be suppressing after hangover"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Speech arrives — should immediately stop suppressing.
|
// Speech arrives — should immediately stop suppressing.
|
||||||
assert!(!det.is_silent(&speech));
|
assert!(!det.is_silent(&speech));
|
||||||
@@ -168,16 +165,10 @@ mod tests {
|
|||||||
cn.generate(&mut pcm);
|
cn.generate(&mut pcm);
|
||||||
|
|
||||||
// At least some samples should be non-zero.
|
// At least some samples should be non-zero.
|
||||||
assert!(
|
assert!(pcm.iter().any(|&s| s != 0), "CN output should not be all zeros");
|
||||||
pcm.iter().any(|&s| s != 0),
|
|
||||||
"CN output should not be all zeros"
|
|
||||||
);
|
|
||||||
|
|
||||||
// All samples should be within [-50, 50].
|
// All samples should be within [-50, 50].
|
||||||
assert!(
|
assert!(pcm.iter().all(|&s| s.abs() <= 50), "CN samples out of range");
|
||||||
pcm.iter().all(|&s| s.abs() <= 50),
|
|
||||||
"CN samples out of range"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -188,17 +179,11 @@ mod tests {
|
|||||||
// Constant value: RMS of [v, v, v, ...] = |v|.
|
// Constant value: RMS of [v, v, v, ...] = |v|.
|
||||||
let pcm = vec![100i16; 100];
|
let pcm = vec![100i16; 100];
|
||||||
let rms = SilenceDetector::rms(&pcm);
|
let rms = SilenceDetector::rms(&pcm);
|
||||||
assert!(
|
assert!((rms - 100.0).abs() < 0.01, "RMS of constant 100 should be 100, got {rms}");
|
||||||
(rms - 100.0).abs() < 0.01,
|
|
||||||
"RMS of constant 100 should be 100, got {rms}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355
|
// Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355
|
||||||
let rms2 = SilenceDetector::rms(&[3, 4]);
|
let rms2 = SilenceDetector::rms(&[3, 4]);
|
||||||
assert!(
|
assert!((rms2 - 3.5355).abs() < 0.01, "RMS of [3,4] should be ~3.5355, got {rms2}");
|
||||||
(rms2 - 3.5355).abs() < 0.01,
|
|
||||||
"RMS of [3,4] should be ~3.5355, got {rms2}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Empty buffer → 0.
|
// Empty buffer → 0.
|
||||||
assert_eq!(SilenceDetector::rms(&[]), 0.0);
|
assert_eq!(SilenceDetector::rms(&[]), 0.0);
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
//! Sliding window replay protection.
|
//! Sliding window replay protection.
|
||||||
//!
|
//!
|
||||||
//! Tracks seen sequence numbers using a bitmap. Window size is configurable
|
//! Tracks seen sequence numbers using a bitmap. Window size is 1024 packets.
|
||||||
//! at construction time. Sequence numbers that are too old (more than
|
//! Sequence numbers that are too old (more than WINDOW_SIZE behind the highest
|
||||||
//! `window_size` behind the highest seen) are rejected.
|
//! seen) are rejected.
|
||||||
|
|
||||||
use wzp_proto::CryptoError;
|
use wzp_proto::CryptoError;
|
||||||
|
|
||||||
|
/// Window size in packets.
|
||||||
|
const WINDOW_SIZE: u16 = 1024;
|
||||||
|
|
||||||
/// Sliding window anti-replay detector.
|
/// Sliding window anti-replay detector.
|
||||||
///
|
///
|
||||||
/// Uses a bitmap to track which sequence numbers have been seen within
|
/// Uses a bitmap to track which sequence numbers have been seen within
|
||||||
/// the current window. Handles `u32` wrapping correctly.
|
/// the current window. Handles u16 wrapping correctly.
|
||||||
pub struct AntiReplayWindow {
|
pub struct AntiReplayWindow {
|
||||||
/// Window size in packets.
|
|
||||||
window_size: u32,
|
|
||||||
/// Highest sequence number seen so far.
|
/// Highest sequence number seen so far.
|
||||||
highest: u32,
|
highest: u16,
|
||||||
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
||||||
bitmap: Vec<u64>,
|
bitmap: Vec<u64>,
|
||||||
/// Whether any packet has been received yet.
|
/// Whether any packet has been received yet.
|
||||||
@@ -22,26 +23,21 @@ pub struct AntiReplayWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AntiReplayWindow {
|
impl AntiReplayWindow {
|
||||||
/// Create a new anti-replay window with the default size of 1024 packets.
|
/// Number of u64 words needed for the bitmap.
|
||||||
pub fn new() -> Self {
|
const BITMAP_WORDS: usize = (WINDOW_SIZE as usize + 63) / 64;
|
||||||
Self::with_window(1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new anti-replay window with a custom size.
|
/// Create a new anti-replay window.
|
||||||
pub fn with_window(size: usize) -> Self {
|
pub fn new() -> Self {
|
||||||
let window_size = size as u32;
|
|
||||||
let bitmap_words = (size + 63) / 64;
|
|
||||||
Self {
|
Self {
|
||||||
window_size,
|
|
||||||
highest: 0,
|
highest: 0,
|
||||||
bitmap: vec![0u64; bitmap_words],
|
bitmap: vec![0u64; Self::BITMAP_WORDS],
|
||||||
initialized: false,
|
initialized: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a sequence number is valid (not a replay, not too old).
|
/// Check if a sequence number is valid (not a replay, not too old).
|
||||||
/// If valid, marks it as seen.
|
/// If valid, marks it as seen.
|
||||||
pub fn check_and_update(&mut self, seq: u32) -> Result<(), CryptoError> {
|
pub fn check_and_update(&mut self, seq: u16) -> Result<(), CryptoError> {
|
||||||
if !self.initialized {
|
if !self.initialized {
|
||||||
self.initialized = true;
|
self.initialized = true;
|
||||||
self.highest = seq;
|
self.highest = seq;
|
||||||
@@ -56,17 +52,17 @@ impl AntiReplayWindow {
|
|||||||
return Err(CryptoError::ReplayDetected { seq });
|
return Err(CryptoError::ReplayDetected { seq });
|
||||||
}
|
}
|
||||||
|
|
||||||
if diff < 0x8000_0000 {
|
if diff < 0x8000 {
|
||||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF_FFFF])
|
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF])
|
||||||
let shift = diff as usize;
|
let shift = diff as usize;
|
||||||
self.advance_window(shift);
|
self.advance_window(shift);
|
||||||
self.highest = seq;
|
self.highest = seq;
|
||||||
self.set_bit(0);
|
self.set_bit(0);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// seq is behind highest (wrapping-aware: diff in [0x8000_0000, 0xFFFF_FFFF])
|
// seq is behind highest (wrapping-aware: diff in [0x8000, 0xFFFF])
|
||||||
let behind = self.highest.wrapping_sub(seq) as usize;
|
let behind = self.highest.wrapping_sub(seq) as usize;
|
||||||
if behind >= self.window_size as usize {
|
if behind >= WINDOW_SIZE as usize {
|
||||||
return Err(CryptoError::ReplayDetected { seq });
|
return Err(CryptoError::ReplayDetected { seq });
|
||||||
}
|
}
|
||||||
if self.get_bit(behind) {
|
if self.get_bit(behind) {
|
||||||
@@ -79,8 +75,7 @@ impl AntiReplayWindow {
|
|||||||
|
|
||||||
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
||||||
fn advance_window(&mut self, shift: usize) {
|
fn advance_window(&mut self, shift: usize) {
|
||||||
let window_size = self.window_size as usize;
|
if shift >= WINDOW_SIZE as usize {
|
||||||
if shift >= window_size {
|
|
||||||
for word in &mut self.bitmap {
|
for word in &mut self.bitmap {
|
||||||
*word = 0;
|
*word = 0;
|
||||||
}
|
}
|
||||||
@@ -161,11 +156,7 @@ mod tests {
|
|||||||
fn sequential_accepted() {
|
fn sequential_accepted() {
|
||||||
let mut w = AntiReplayWindow::new();
|
let mut w = AntiReplayWindow::new();
|
||||||
for i in 0..200 {
|
for i in 0..200 {
|
||||||
assert!(
|
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i);
|
||||||
w.check_and_update(i).is_ok(),
|
|
||||||
"seq {} should be accepted",
|
|
||||||
i
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +183,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn wrapping_works() {
|
fn wrapping_works() {
|
||||||
let mut w = AntiReplayWindow::new();
|
let mut w = AntiReplayWindow::new();
|
||||||
assert!(w.check_and_update(0xFFFF_FFF0).is_ok());
|
assert!(w.check_and_update(65530).is_ok());
|
||||||
assert!(w.check_and_update(0xFFFF_FFFF).is_ok());
|
assert!(w.check_and_update(65535).is_ok());
|
||||||
assert!(w.check_and_update(0).is_ok()); // wrapped
|
assert!(w.check_and_update(0).is_ok()); // wrapped
|
||||||
assert!(w.check_and_update(1).is_ok());
|
assert!(w.check_and_update(1).is_ok());
|
||||||
assert!(w.check_and_update(0xFFFF_FFFF).is_err()); // duplicate
|
assert!(w.check_and_update(65535).is_err()); // duplicate
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -210,53 +201,4 @@ mod tests {
|
|||||||
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
||||||
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_window_size() {
|
|
||||||
let mut w = AntiReplayWindow::with_window(64);
|
|
||||||
for i in 0..64 {
|
|
||||||
assert!(w.check_and_update(i).is_ok());
|
|
||||||
}
|
|
||||||
// seq 0 is now exactly at the boundary (64 behind 64)
|
|
||||||
assert!(w.check_and_update(0).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn video_burst_200_with_one_reorder() {
|
|
||||||
let mut w = AntiReplayWindow::with_window(1024);
|
|
||||||
// Simulate a 200-packet burst
|
|
||||||
for i in 0..200 {
|
|
||||||
assert!(
|
|
||||||
w.check_and_update(i).is_ok(),
|
|
||||||
"seq {} should be accepted",
|
|
||||||
i
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// One packet reordered (arrives late)
|
|
||||||
assert!(w.check_and_update(50).is_err(), "seq 50 is a duplicate");
|
|
||||||
// But a packet just behind the window should still be ok
|
|
||||||
assert!(w.check_and_update(199).is_err(), "seq 199 is a duplicate");
|
|
||||||
// Continue the burst
|
|
||||||
for i in 200..400 {
|
|
||||||
assert!(
|
|
||||||
w.check_and_update(i).is_ok(),
|
|
||||||
"seq {} should be accepted",
|
|
||||||
i
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn u32_high_range_works() {
|
|
||||||
let mut w = AntiReplayWindow::with_window(64);
|
|
||||||
let base = 1000u32;
|
|
||||||
assert!(w.check_and_update(base).is_ok());
|
|
||||||
assert!(w.check_and_update(base + 1).is_ok());
|
|
||||||
// 65 behind highest (base+1) is outside the 64-packet window
|
|
||||||
assert!(w.check_and_update(base.wrapping_sub(64)).is_err());
|
|
||||||
// 63 behind is inside
|
|
||||||
assert!(w.check_and_update(base.wrapping_sub(62)).is_ok());
|
|
||||||
// base itself is now a duplicate
|
|
||||||
assert!(w.check_and_update(base).is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
|
|||||||
use hkdf::Hkdf;
|
use hkdf::Hkdf;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
|
||||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||||
|
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||||
|
|
||||||
use crate::session::ChaChaSession;
|
use crate::session::ChaChaSession;
|
||||||
|
|
||||||
@@ -18,14 +18,10 @@ use crate::session::ChaChaSession;
|
|||||||
pub struct WarzoneKeyExchange {
|
pub struct WarzoneKeyExchange {
|
||||||
/// Ed25519 signing key (identity).
|
/// Ed25519 signing key (identity).
|
||||||
signing_key: SigningKey,
|
signing_key: SigningKey,
|
||||||
/// X25519 static secret derived from identity seed. Reserved for future
|
/// X25519 static secret (derived from seed, used for identity encryption).
|
||||||
/// use in static-key federation authentication (not used in current
|
|
||||||
/// ephemeral-only handshake protocol).
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
x25519_static_secret: StaticSecret,
|
x25519_static_secret: StaticSecret,
|
||||||
/// X25519 static public key derived from identity seed. Reserved for
|
/// X25519 static public key.
|
||||||
/// future use in static-key federation authentication (not used in
|
|
||||||
/// current ephemeral-only handshake protocol).
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
x25519_static_public: X25519PublicKey,
|
x25519_static_public: X25519PublicKey,
|
||||||
/// Ephemeral X25519 secret for the current call (set by generate_ephemeral).
|
/// Ephemeral X25519 secret for the current call (set by generate_ephemeral).
|
||||||
@@ -95,11 +91,12 @@ impl KeyExchange for WarzoneKeyExchange {
|
|||||||
&self,
|
&self,
|
||||||
peer_ephemeral_pub: &[u8; 32],
|
peer_ephemeral_pub: &[u8; 32],
|
||||||
) -> Result<Box<dyn CryptoSession>, CryptoError> {
|
) -> Result<Box<dyn CryptoSession>, CryptoError> {
|
||||||
let secret = self.ephemeral_secret.as_ref().ok_or_else(|| {
|
let secret = self
|
||||||
CryptoError::Internal(
|
.ephemeral_secret
|
||||||
"no ephemeral key generated; call generate_ephemeral first".into(),
|
.as_ref()
|
||||||
)
|
.ok_or_else(|| {
|
||||||
})?;
|
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
|
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
|
||||||
// Use diffie_hellman with a clone of the StaticSecret
|
// Use diffie_hellman with a clone of the StaticSecret
|
||||||
@@ -113,18 +110,7 @@ impl KeyExchange for WarzoneKeyExchange {
|
|||||||
hk.expand(b"warzone-session-key", &mut session_key)
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
.expect("HKDF expand for session key should not fail");
|
.expect("HKDF expand for session key should not fail");
|
||||||
|
|
||||||
// Derive SAS (Short Authentication String) from shared secret only.
|
Ok(Box::new(ChaChaSession::new(session_key)))
|
||||||
// The shared secret is identical on both sides (X25519 DH property).
|
|
||||||
// A MITM would produce a different shared secret → different SAS.
|
|
||||||
// We use a dedicated HKDF label so SAS is independent of the session key.
|
|
||||||
let mut sas_key = [0u8; 4];
|
|
||||||
hk.expand(b"warzone-sas-code", &mut sas_key)
|
|
||||||
.expect("HKDF expand for SAS should not fail");
|
|
||||||
let sas_code = u32::from_be_bytes(sas_key) % 10000;
|
|
||||||
|
|
||||||
let mut session = ChaChaSession::new(session_key);
|
|
||||||
session.set_sas(sas_code);
|
|
||||||
Ok(Box::new(session))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,79 +195,20 @@ mod tests {
|
|||||||
let mut alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
let mut alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||||
let mut bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
let mut bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||||
|
|
||||||
// Verify they can communicate: Alice encrypts, Bob decrypts.
|
// Verify they can communicate: Alice encrypts, Bob decrypts
|
||||||
// Use a valid v2 MediaHeader — encrypt/decrypt now derive the nonce from
|
let header = b"call-header";
|
||||||
// header.seq and will reject raw byte slices shorter than WIRE_SIZE.
|
|
||||||
use wzp_proto::{CodecId, MediaHeader, MediaType};
|
|
||||||
let header = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq: 0,
|
|
||||||
timestamp: 0,
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
header.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let plaintext = b"hello from alice";
|
let plaintext = b"hello from alice";
|
||||||
|
|
||||||
let mut ciphertext = Vec::new();
|
let mut ciphertext = Vec::new();
|
||||||
alice_session
|
alice_session
|
||||||
.encrypt(&header_bytes, plaintext, &mut ciphertext)
|
.encrypt(header, plaintext, &mut ciphertext)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut decrypted = Vec::new();
|
let mut decrypted = Vec::new();
|
||||||
bob_session
|
bob_session
|
||||||
.decrypt(&header_bytes, &ciphertext, &mut decrypted)
|
.decrypt(header, &ciphertext, &mut decrypted)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(&decrypted, plaintext);
|
assert_eq!(&decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sas_codes_match_between_peers() {
|
|
||||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
|
||||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
|
||||||
|
|
||||||
let alice_eph_pub = alice.generate_ephemeral();
|
|
||||||
let bob_eph_pub = bob.generate_ephemeral();
|
|
||||||
|
|
||||||
let alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
|
||||||
let bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
|
||||||
|
|
||||||
let alice_sas = alice_session.sas_code();
|
|
||||||
let bob_sas = bob_session.sas_code();
|
|
||||||
|
|
||||||
assert!(alice_sas.is_some(), "Alice should have SAS");
|
|
||||||
assert!(bob_sas.is_some(), "Bob should have SAS");
|
|
||||||
assert_eq!(alice_sas, bob_sas, "SAS codes must match between peers");
|
|
||||||
assert!(alice_sas.unwrap() < 10000, "SAS should be 4 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sas_differs_for_different_peers() {
|
|
||||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
|
||||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
|
||||||
let mut eve = WarzoneKeyExchange::from_identity_seed(&[0xEE; 32]);
|
|
||||||
|
|
||||||
let alice_eph = alice.generate_ephemeral();
|
|
||||||
let bob_eph = bob.generate_ephemeral();
|
|
||||||
let eve_eph = eve.generate_ephemeral();
|
|
||||||
|
|
||||||
let alice_bob_session = alice.derive_session(&bob_eph).unwrap();
|
|
||||||
|
|
||||||
// Eve does separate handshake with Bob (MITM scenario)
|
|
||||||
let eve_bob_session = eve.derive_session(&bob_eph).unwrap();
|
|
||||||
|
|
||||||
// SAS codes should differ — Eve's session has different shared secret
|
|
||||||
assert_ne!(
|
|
||||||
alice_bob_session.sas_code(),
|
|
||||||
eve_bob_session.sas_code(),
|
|
||||||
"MITM session should produce different SAS"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ impl Seed {
|
|||||||
///
|
///
|
||||||
/// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed`
|
/// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed`
|
||||||
pub fn from_mnemonic(words: &str) -> Result<Self, String> {
|
pub fn from_mnemonic(words: &str) -> Result<Self, String> {
|
||||||
let mnemonic: bip39::Mnemonic = words
|
let mnemonic: bip39::Mnemonic = words.parse().map_err(|e| format!("invalid mnemonic: {e}"))?;
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("invalid mnemonic: {e}"))?;
|
|
||||||
let entropy = mnemonic.to_entropy();
|
let entropy = mnemonic.to_entropy();
|
||||||
if entropy.len() != 32 {
|
if entropy.len() != 32 {
|
||||||
return Err(format!("expected 32 bytes entropy, got {}", entropy.len()));
|
return Err(format!("expected 32 bytes entropy, got {}", entropy.len()));
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ pub mod session;
|
|||||||
|
|
||||||
pub use anti_replay::AntiReplayWindow;
|
pub use anti_replay::AntiReplayWindow;
|
||||||
pub use handshake::WarzoneKeyExchange;
|
pub use handshake::WarzoneKeyExchange;
|
||||||
pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed, hash_room_name};
|
pub use identity::{hash_room_name, Fingerprint, IdentityKeyPair, PublicIdentity, Seed};
|
||||||
pub use nonce::{Direction, build_nonce};
|
pub use nonce::{build_nonce, Direction};
|
||||||
pub use rekey::RekeyManager;
|
pub use rekey::RekeyManager;
|
||||||
pub use session::ChaChaSession;
|
pub use session::ChaChaSession;
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ impl RekeyManager {
|
|||||||
///
|
///
|
||||||
/// The old key is zeroized after the new key is derived.
|
/// The old key is zeroized after the new key is derived.
|
||||||
/// Returns the new 32-byte symmetric key.
|
/// Returns the new 32-byte symmetric key.
|
||||||
///
|
|
||||||
/// NOTE: Rekeying changes **only** the symmetric key material. Sequence
|
|
||||||
/// numbers and timestamps in the media framing layer (e.g. `MediaHeader`)
|
|
||||||
/// are untouched — they continue monotonically across the rekey boundary.
|
|
||||||
pub fn perform_rekey(
|
pub fn perform_rekey(
|
||||||
&mut self,
|
&mut self,
|
||||||
new_peer_pub: &[u8; 32],
|
new_peer_pub: &[u8; 32],
|
||||||
|
|||||||
@@ -3,15 +3,12 @@
|
|||||||
//! Implements the `CryptoSession` trait for per-call media encryption.
|
//! Implements the `CryptoSession` trait for per-call media encryption.
|
||||||
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use chacha20poly1305::aead::Aead;
|
use chacha20poly1305::aead::Aead;
|
||||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use wzp_proto::{CryptoError, CryptoSession, MediaHeader, MediaType};
|
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use wzp_proto::{CryptoError, CryptoSession};
|
||||||
|
|
||||||
use crate::anti_replay::AntiReplayWindow;
|
|
||||||
use crate::nonce::{self, Direction};
|
use crate::nonce::{self, Direction};
|
||||||
use crate::rekey::RekeyManager;
|
use crate::rekey::RekeyManager;
|
||||||
|
|
||||||
@@ -29,12 +26,6 @@ pub struct ChaChaSession {
|
|||||||
rekey_mgr: RekeyManager,
|
rekey_mgr: RekeyManager,
|
||||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||||
pending_rekey_secret: Option<StaticSecret>,
|
pending_rekey_secret: Option<StaticSecret>,
|
||||||
/// Short Authentication String (4-digit code for verbal verification).
|
|
||||||
sas_code: Option<u32>,
|
|
||||||
/// Per-stream anti-replay windows, keyed by (stream_id, media_type).
|
|
||||||
anti_replay: HashMap<(u8, MediaType), AntiReplayWindow>,
|
|
||||||
/// Last timestamp seen in encrypt() — used to assert monotonicity across rekeys.
|
|
||||||
last_encrypt_timestamp: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChaChaSession {
|
impl ChaChaSession {
|
||||||
@@ -55,17 +46,9 @@ impl ChaChaSession {
|
|||||||
recv_seq: 0,
|
recv_seq: 0,
|
||||||
rekey_mgr: RekeyManager::new(shared_secret),
|
rekey_mgr: RekeyManager::new(shared_secret),
|
||||||
pending_rekey_secret: None,
|
pending_rekey_secret: None,
|
||||||
sas_code: None,
|
|
||||||
anti_replay: HashMap::new(),
|
|
||||||
last_encrypt_timestamp: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the SAS code (called by key exchange after derivation).
|
|
||||||
pub fn set_sas(&mut self, code: u32) {
|
|
||||||
self.sas_code = Some(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install a new key (after rekeying).
|
/// Install a new key (after rekeying).
|
||||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
@@ -76,27 +59,6 @@ impl ChaChaSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a v2 `MediaHeader` from raw bytes.
|
|
||||||
/// Returns `None` if the buffer is too short or not a valid v2 header.
|
|
||||||
fn parse_header(header_bytes: &[u8]) -> Option<MediaHeader> {
|
|
||||||
if header_bytes.len() < MediaHeader::WIRE_SIZE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut cursor = std::io::Cursor::new(header_bytes);
|
|
||||||
MediaHeader::read_from(&mut cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the default anti-replay window size for a given media type.
|
|
||||||
fn default_window_for_media_type(media_type: MediaType) -> AntiReplayWindow {
|
|
||||||
let size = match media_type {
|
|
||||||
MediaType::Audio => 64,
|
|
||||||
MediaType::Video => 1024,
|
|
||||||
MediaType::Data => 256,
|
|
||||||
MediaType::Control => 32,
|
|
||||||
};
|
|
||||||
AntiReplayWindow::with_window(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CryptoSession for ChaChaSession {
|
impl CryptoSession for ChaChaSession {
|
||||||
fn encrypt(
|
fn encrypt(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -104,14 +66,10 @@ impl CryptoSession for ChaChaSession {
|
|||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
out: &mut Vec<u8>,
|
out: &mut Vec<u8>,
|
||||||
) -> Result<(), CryptoError> {
|
) -> Result<(), CryptoError> {
|
||||||
// Derive nonce from the wire-level seq in the header, not from an
|
let nonce_bytes = nonce::build_nonce(&self.session_id, self.send_seq, Direction::Send);
|
||||||
// internal counter. This ensures the receiver can reconstruct the
|
|
||||||
// same nonce using the header it receives, regardless of delivery order.
|
|
||||||
let header = parse_header(header_bytes)
|
|
||||||
.ok_or_else(|| CryptoError::Internal("header too short to derive nonce".into()))?;
|
|
||||||
let nonce_bytes = nonce::build_nonce(&self.session_id, header.seq, Direction::Send);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
// Encrypt with AAD
|
||||||
use chacha20poly1305::aead::Payload;
|
use chacha20poly1305::aead::Payload;
|
||||||
let payload = Payload {
|
let payload = Payload {
|
||||||
msg: plaintext,
|
msg: plaintext,
|
||||||
@@ -124,19 +82,7 @@ impl CryptoSession for ChaChaSession {
|
|||||||
.map_err(|_| CryptoError::Internal("encryption failed".into()))?;
|
.map_err(|_| CryptoError::Internal("encryption failed".into()))?;
|
||||||
|
|
||||||
out.extend_from_slice(&ciphertext);
|
out.extend_from_slice(&ciphertext);
|
||||||
self.send_seq = self.send_seq.wrapping_add(1); // packet counter for rekey trigger only
|
self.send_seq = self.send_seq.wrapping_add(1);
|
||||||
|
|
||||||
// M5: assert timestamp_ms is non-decreasing across calls (including post-rekey).
|
|
||||||
// Timestamps are u32 and wrap at 2^32 ms (~49 days); allow wrapping.
|
|
||||||
debug_assert!(
|
|
||||||
self.last_encrypt_timestamp
|
|
||||||
.map_or(true, |last| header.timestamp.wrapping_sub(last) < u32::MAX / 2),
|
|
||||||
"encrypt: timestamp must not decrease (last={:?}, now={})",
|
|
||||||
self.last_encrypt_timestamp,
|
|
||||||
header.timestamp,
|
|
||||||
);
|
|
||||||
self.last_encrypt_timestamp = Some(header.timestamp);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +92,9 @@ impl CryptoSession for ChaChaSession {
|
|||||||
ciphertext: &[u8],
|
ciphertext: &[u8],
|
||||||
out: &mut Vec<u8>,
|
out: &mut Vec<u8>,
|
||||||
) -> Result<(), CryptoError> {
|
) -> Result<(), CryptoError> {
|
||||||
// Parse header before decryption — needed for nonce derivation.
|
// Use Direction::Send to match the sender's nonce construction.
|
||||||
// Using header.seq (not recv_seq) means the nonce is always derived
|
// The recv_seq counter tracks which packet from the peer we're decrypting.
|
||||||
// from the same wire field as the sender, surviving out-of-order delivery.
|
let nonce_bytes = nonce::build_nonce(&self.session_id, self.recv_seq, Direction::Send);
|
||||||
// A recv_seq counter diverges from the sender's send_seq on any reorder,
|
|
||||||
// causing every subsequent decryption to fail for the rest of the session.
|
|
||||||
let header = parse_header(header_bytes)
|
|
||||||
.ok_or_else(|| CryptoError::Internal("header too short to derive nonce".into()))?;
|
|
||||||
let nonce_bytes = nonce::build_nonce(&self.session_id, header.seq, Direction::Send);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
use chacha20poly1305::aead::Payload;
|
use chacha20poly1305::aead::Payload;
|
||||||
@@ -167,21 +108,8 @@ impl CryptoSession for ChaChaSession {
|
|||||||
.decrypt(nonce, payload)
|
.decrypt(nonce, payload)
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
|
||||||
let plaintext_len = plaintext.len();
|
|
||||||
out.extend_from_slice(&plaintext);
|
out.extend_from_slice(&plaintext);
|
||||||
self.recv_seq = self.recv_seq.wrapping_add(1); // packet counter for rekey trigger only
|
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||||
|
|
||||||
// Anti-replay check: header already parsed above.
|
|
||||||
let window = self
|
|
||||||
.anti_replay
|
|
||||||
.entry((header.stream_id, header.media_type))
|
|
||||||
.or_insert_with(|| default_window_for_media_type(header.media_type));
|
|
||||||
if let Err(e) = window.check_and_update(header.seq) {
|
|
||||||
// Roll back the plaintext we just appended.
|
|
||||||
out.truncate(out.len() - plaintext_len);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,64 +127,38 @@ impl CryptoSession for ChaChaSession {
|
|||||||
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
|
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
|
||||||
|
|
||||||
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
|
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
|
||||||
let new_key = self
|
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
||||||
.rekey_mgr
|
|
||||||
.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
|
||||||
self.install_key(new_key);
|
self.install_key(new_key);
|
||||||
|
|
||||||
// Reset sequence counters after rekey for nonce uniqueness.
|
// Reset sequence counters after rekey for nonce uniqueness
|
||||||
// last_encrypt_timestamp is intentionally NOT reset — spec requires
|
|
||||||
// timestamp_ms to be monotonic across rekeys.
|
|
||||||
self.send_seq = 0;
|
self.send_seq = 0;
|
||||||
self.recv_seq = 0;
|
self.recv_seq = 0;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sas_code(&self) -> Option<u32> {
|
|
||||||
self.sas_code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
|
|
||||||
fn make_session_pair() -> (ChaChaSession, ChaChaSession) {
|
fn make_session_pair() -> (ChaChaSession, ChaChaSession) {
|
||||||
let key = [0x42u8; 32];
|
let key = [0x42u8; 32];
|
||||||
(ChaChaSession::new(key), ChaChaSession::new(key))
|
(ChaChaSession::new(key), ChaChaSession::new(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a minimal valid v2 MediaHeader serialised to bytes.
|
|
||||||
fn make_header_bytes(seq: u32) -> Vec<u8> {
|
|
||||||
let header = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq,
|
|
||||||
timestamp: seq.wrapping_mul(20),
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
header.write_to(&mut bytes);
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypt_decrypt_roundtrip() {
|
fn encrypt_decrypt_roundtrip() {
|
||||||
let (mut alice, mut bob) = make_session_pair();
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
let header = make_header_bytes(0);
|
let header = b"test-header";
|
||||||
let plaintext = b"hello warzone";
|
let plaintext = b"hello warzone";
|
||||||
|
|
||||||
let mut ciphertext = Vec::new();
|
let mut ciphertext = Vec::new();
|
||||||
alice.encrypt(&header, plaintext, &mut ciphertext).unwrap();
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
|
|
||||||
|
// Bob decrypts (his recv matches Alice's send)
|
||||||
let mut decrypted = Vec::new();
|
let mut decrypted = Vec::new();
|
||||||
bob.decrypt(&header, &ciphertext, &mut decrypted).unwrap();
|
bob.decrypt(header, &ciphertext, &mut decrypted).unwrap();
|
||||||
|
|
||||||
assert_eq!(&decrypted, plaintext);
|
assert_eq!(&decrypted, plaintext);
|
||||||
}
|
}
|
||||||
@@ -264,18 +166,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn decrypt_wrong_aad_fails() {
|
fn decrypt_wrong_aad_fails() {
|
||||||
let (mut alice, mut bob) = make_session_pair();
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
let correct_header = make_header_bytes(0);
|
let header = b"correct-header";
|
||||||
// Different seq → different nonce AND different AAD bytes: decryption must fail.
|
|
||||||
let wrong_header = make_header_bytes(1);
|
|
||||||
let plaintext = b"secret data";
|
let plaintext = b"secret data";
|
||||||
|
|
||||||
let mut ciphertext = Vec::new();
|
let mut ciphertext = Vec::new();
|
||||||
alice
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
.encrypt(&correct_header, plaintext, &mut ciphertext)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut decrypted = Vec::new();
|
let mut decrypted = Vec::new();
|
||||||
let result = bob.decrypt(&wrong_header, &ciphertext, &mut decrypted);
|
let result = bob.decrypt(b"wrong-header", &ciphertext, &mut decrypted);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,29 +182,29 @@ mod tests {
|
|||||||
let mut alice = ChaChaSession::new([0xAA; 32]);
|
let mut alice = ChaChaSession::new([0xAA; 32]);
|
||||||
let mut eve = ChaChaSession::new([0xBB; 32]);
|
let mut eve = ChaChaSession::new([0xBB; 32]);
|
||||||
|
|
||||||
let header = make_header_bytes(0);
|
let header = b"hdr";
|
||||||
let plaintext = b"secret";
|
let plaintext = b"secret";
|
||||||
|
|
||||||
let mut ciphertext = Vec::new();
|
let mut ciphertext = Vec::new();
|
||||||
alice.encrypt(&header, plaintext, &mut ciphertext).unwrap();
|
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||||
|
|
||||||
let mut decrypted = Vec::new();
|
let mut decrypted = Vec::new();
|
||||||
let result = eve.decrypt(&header, &ciphertext, &mut decrypted);
|
let result = eve.decrypt(header, &ciphertext, &mut decrypted);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_packets_roundtrip() {
|
fn multiple_packets_roundtrip() {
|
||||||
let (mut alice, mut bob) = make_session_pair();
|
let (mut alice, mut bob) = make_session_pair();
|
||||||
|
let header = b"hdr";
|
||||||
|
|
||||||
for i in 0..100u32 {
|
for i in 0..100 {
|
||||||
let header = make_header_bytes(i);
|
|
||||||
let msg = format!("message {}", i);
|
let msg = format!("message {}", i);
|
||||||
let mut ct = Vec::new();
|
let mut ct = Vec::new();
|
||||||
alice.encrypt(&header, msg.as_bytes(), &mut ct).unwrap();
|
alice.encrypt(header, msg.as_bytes(), &mut ct).unwrap();
|
||||||
|
|
||||||
let mut pt = Vec::new();
|
let mut pt = Vec::new();
|
||||||
bob.decrypt(&header, &ct, &mut pt).unwrap();
|
bob.decrypt(header, &ct, &mut pt).unwrap();
|
||||||
assert_eq!(pt, msg.as_bytes());
|
assert_eq!(pt, msg.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,140 +223,4 @@ mod tests {
|
|||||||
// Session is now rekeyed - counters reset
|
// Session is now rekeyed - counters reset
|
||||||
assert_eq!(alice.send_seq, 0);
|
assert_eq!(alice.send_seq, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_survives_out_of_order_delivery() {
|
|
||||||
// Regression test for nonce derivation using recv_seq instead of
|
|
||||||
// MediaHeader.seq. If nonces are tied to a local counter, any reorder
|
|
||||||
// causes the counter to diverge from the sender's seq and every
|
|
||||||
// subsequent packet fails decryption permanently.
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
|
|
||||||
let key = [0x55u8; 32];
|
|
||||||
let mut alice = ChaChaSession::new(key);
|
|
||||||
let mut bob = ChaChaSession::new(key);
|
|
||||||
|
|
||||||
let plaintext = b"audio payload";
|
|
||||||
|
|
||||||
// Encrypt 5 packets in order (seqs 10, 11, 12, 13, 14).
|
|
||||||
let seqs = [10u32, 11, 12, 13, 14];
|
|
||||||
let mut ciphertexts: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
|
||||||
for &seq in &seqs {
|
|
||||||
let header = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 0,
|
|
||||||
seq,
|
|
||||||
timestamp: seq * 20,
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
header.write_to(&mut header_bytes);
|
|
||||||
let mut ct = Vec::new();
|
|
||||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
|
||||||
ciphertexts.push((header_bytes, ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bob receives them out of order: 0, 2, 1, 4, 3
|
|
||||||
let delivery_order = [0usize, 2, 1, 4, 3];
|
|
||||||
for &idx in &delivery_order {
|
|
||||||
let (ref hdr, ref ct) = ciphertexts[idx];
|
|
||||||
let mut pt = Vec::new();
|
|
||||||
let result = bob.decrypt(hdr, ct, &mut pt);
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"out-of-order packet (original idx={idx}, seq={}) must decrypt successfully",
|
|
||||||
seqs[idx]
|
|
||||||
);
|
|
||||||
assert_eq!(&pt, plaintext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn per_stream_anti_replay_rejects_duplicate() {
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
|
|
||||||
let (mut alice, mut bob) = make_session_pair();
|
|
||||||
let header = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 0,
|
|
||||||
fec_ratio: 10,
|
|
||||||
seq: 42,
|
|
||||||
timestamp: 1000,
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
header.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let plaintext = b"audio frame";
|
|
||||||
|
|
||||||
// First packet decrypts successfully
|
|
||||||
let mut ct = Vec::new();
|
|
||||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
|
||||||
let mut pt = Vec::new();
|
|
||||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
|
||||||
assert_eq!(&pt, plaintext);
|
|
||||||
|
|
||||||
// Exact duplicate is rejected by anti-replay
|
|
||||||
let mut pt2 = Vec::new();
|
|
||||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt2);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"duplicate packet with same seq must be rejected"
|
|
||||||
);
|
|
||||||
assert!(pt2.is_empty(), "plaintext must be rolled back on replay");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn per_stream_anti_replay_video_burst_200_with_reorder() {
|
|
||||||
use wzp_proto::{CodecId, MediaType};
|
|
||||||
|
|
||||||
let (mut alice, mut bob) = make_session_pair();
|
|
||||||
let header = MediaHeader {
|
|
||||||
version: 2,
|
|
||||||
flags: 0,
|
|
||||||
media_type: MediaType::Video,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
stream_id: 1,
|
|
||||||
fec_ratio: 10,
|
|
||||||
seq: 0,
|
|
||||||
timestamp: 0,
|
|
||||||
fec_block: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let plaintext = b"video frame";
|
|
||||||
|
|
||||||
// Send 200 packets in order
|
|
||||||
for i in 0..200 {
|
|
||||||
let mut h = header;
|
|
||||||
h.seq = i;
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
h.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let mut ct = Vec::new();
|
|
||||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
|
||||||
|
|
||||||
let mut pt = Vec::new();
|
|
||||||
bob.decrypt(&header_bytes, &ct, &mut pt).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-send packet 50 — should be rejected as replay
|
|
||||||
let mut h = header;
|
|
||||||
h.seq = 50;
|
|
||||||
let mut header_bytes = Vec::new();
|
|
||||||
h.write_to(&mut header_bytes);
|
|
||||||
|
|
||||||
let mut ct = Vec::new();
|
|
||||||
alice.encrypt(&header_bytes, plaintext, &mut ct).unwrap();
|
|
||||||
|
|
||||||
let mut pt = Vec::new();
|
|
||||||
let result = bob.decrypt(&header_bytes, &ct, &mut pt);
|
|
||||||
assert!(result.is_err(), "reordered duplicate must be rejected");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! 3. Auth: WZP auth module request/response matches FC's /v1/auth/validate contract
|
//! 3. Auth: WZP auth module request/response matches FC's /v1/auth/validate contract
|
||||||
//! 4. Mnemonic: BIP39 interop between both implementations
|
//! 4. Mnemonic: BIP39 interop between both implementations
|
||||||
|
|
||||||
use wzp_proto::{KeyExchange, default_signal_version};
|
use wzp_proto::KeyExchange;
|
||||||
|
|
||||||
// ─── Identity Compatibility (WZP-FC-8) ──────────────────────────────────────
|
// ─── Identity Compatibility (WZP-FC-8) ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,10 +52,7 @@ fn wzp_identity_module_matches_featherchat() {
|
|||||||
assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes());
|
assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes());
|
||||||
assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes());
|
assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes());
|
||||||
assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0);
|
assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0);
|
||||||
assert_eq!(
|
assert_eq!(wzp_pub.fingerprint.to_string(), fc_pub.fingerprint.to_string());
|
||||||
wzp_pub.fingerprint.to_string(),
|
|
||||||
fc_pub.fingerprint.to_string()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -114,15 +111,10 @@ fn mnemonic_strings_identical() {
|
|||||||
fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
||||||
// WZP creates a CallOffer SignalMessage
|
// WZP creates a CallOffer SignalMessage
|
||||||
let offer = wzp_proto::SignalMessage::CallOffer {
|
let offer = wzp_proto::SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub: [1u8; 32],
|
identity_pub: [1u8; 32],
|
||||||
ephemeral_pub: [2u8; 32],
|
ephemeral_pub: [2u8; 32],
|
||||||
signature: vec![3u8; 64],
|
signature: vec![3u8; 64],
|
||||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
alias: None,
|
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encode as featherChat CallSignal payload
|
// Encode as featherChat CallSignal payload
|
||||||
@@ -155,25 +147,16 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
|||||||
// And deserializes back
|
// And deserializes back
|
||||||
let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap();
|
let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap();
|
||||||
if let warzone_protocol::message::WireMessage::CallSignal {
|
if let warzone_protocol::message::WireMessage::CallSignal {
|
||||||
id,
|
id, payload: p, signal_type, ..
|
||||||
payload: p,
|
|
||||||
signal_type,
|
|
||||||
..
|
|
||||||
} = decoded
|
} = decoded
|
||||||
{
|
{
|
||||||
assert_eq!(id, "call-123");
|
assert_eq!(id, "call-123");
|
||||||
assert!(matches!(
|
assert!(matches!(signal_type, warzone_protocol::message::CallSignalType::Offer));
|
||||||
signal_type,
|
|
||||||
warzone_protocol::message::CallSignalType::Offer
|
|
||||||
));
|
|
||||||
|
|
||||||
// Decode the WZP payload back
|
// Decode the WZP payload back
|
||||||
let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap();
|
let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap();
|
||||||
assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433");
|
assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433");
|
||||||
assert!(matches!(
|
assert!(matches!(wzp_payload.signal, wzp_proto::SignalMessage::CallOffer { .. }));
|
||||||
wzp_payload.signal,
|
|
||||||
wzp_proto::SignalMessage::CallOffer { .. }
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
panic!("expected CallSignal");
|
panic!("expected CallSignal");
|
||||||
}
|
}
|
||||||
@@ -182,12 +165,10 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn wzp_answer_round_trips_through_fc_callsignal() {
|
fn wzp_answer_round_trips_through_fc_callsignal() {
|
||||||
let answer = wzp_proto::SignalMessage::CallAnswer {
|
let answer = wzp_proto::SignalMessage::CallAnswer {
|
||||||
version: default_signal_version(),
|
|
||||||
identity_pub: [10u8; 32],
|
identity_pub: [10u8; 32],
|
||||||
ephemeral_pub: [20u8; 32],
|
ephemeral_pub: [20u8; 32],
|
||||||
signature: vec![30u8; 64],
|
signature: vec![30u8; 64],
|
||||||
chosen_profile: wzp_proto::QualityProfile::DEGRADED,
|
chosen_profile: wzp_proto::QualityProfile::DEGRADED,
|
||||||
video_codec: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = wzp_client::featherchat::encode_call_payload(&answer, None, None);
|
let payload = wzp_client::featherchat::encode_call_payload(&answer, None, None);
|
||||||
@@ -216,17 +197,12 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn wzp_hangup_round_trips_through_fc_callsignal() {
|
fn wzp_hangup_round_trips_through_fc_callsignal() {
|
||||||
let hangup = wzp_proto::SignalMessage::Hangup {
|
let hangup = wzp_proto::SignalMessage::Hangup {
|
||||||
version: default_signal_version(),
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
call_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
|
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
|
||||||
let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup);
|
let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup);
|
||||||
assert!(matches!(
|
assert!(matches!(signal_type, wzp_client::featherchat::CallSignalType::Hangup));
|
||||||
signal_type,
|
|
||||||
wzp_client::featherchat::CallSignalType::Hangup
|
|
||||||
));
|
|
||||||
|
|
||||||
let fc_msg = warzone_protocol::message::WireMessage::CallSignal {
|
let fc_msg = warzone_protocol::message::WireMessage::CallSignal {
|
||||||
id: "call-789".to_string(),
|
id: "call-789".to_string(),
|
||||||
@@ -241,10 +217,7 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
|
|||||||
|
|
||||||
if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded {
|
if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded {
|
||||||
let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap();
|
let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(wzp.signal, wzp_proto::SignalMessage::Hangup { .. }));
|
||||||
wzp.signal,
|
|
||||||
wzp_proto::SignalMessage::Hangup { .. }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +250,8 @@ fn auth_validate_response_matches_wzp_expectations() {
|
|||||||
"eth_address": null
|
"eth_address": null
|
||||||
});
|
});
|
||||||
|
|
||||||
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
|
let wzp_resp: wzp_relay::auth::ValidateResponse =
|
||||||
|
serde_json::from_value(fc_response).unwrap();
|
||||||
assert!(wzp_resp.valid);
|
assert!(wzp_resp.valid);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
wzp_resp.fingerprint.unwrap(),
|
wzp_resp.fingerprint.unwrap(),
|
||||||
@@ -289,7 +263,8 @@ fn auth_validate_response_matches_wzp_expectations() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn auth_invalid_response_matches() {
|
fn auth_invalid_response_matches() {
|
||||||
let fc_response = serde_json::json!({ "valid": false });
|
let fc_response = serde_json::json!({ "valid": false });
|
||||||
let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
|
let wzp_resp: wzp_relay::auth::ValidateResponse =
|
||||||
|
serde_json::from_value(fc_response).unwrap();
|
||||||
assert!(!wzp_resp.valid);
|
assert!(!wzp_resp.valid);
|
||||||
assert!(wzp_resp.fingerprint.is_none());
|
assert!(wzp_resp.fingerprint.is_none());
|
||||||
}
|
}
|
||||||
@@ -298,46 +273,33 @@ fn auth_invalid_response_matches() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_signal_types_map_correctly() {
|
fn all_signal_types_map_correctly() {
|
||||||
use wzp_client::featherchat::signal_to_call_type;
|
use wzp_client::featherchat::{signal_to_call_type, CallSignalType};
|
||||||
|
|
||||||
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
||||||
(
|
(
|
||||||
wzp_proto::SignalMessage::CallOffer {
|
wzp_proto::SignalMessage::CallOffer {
|
||||||
version: default_signal_version(),
|
identity_pub: [0; 32], ephemeral_pub: [0; 32],
|
||||||
identity_pub: [0; 32],
|
signature: vec![], supported_profiles: vec![],
|
||||||
ephemeral_pub: [0; 32],
|
|
||||||
signature: vec![],
|
|
||||||
supported_profiles: vec![],
|
|
||||||
alias: None,
|
|
||||||
protocol_version: 2,
|
|
||||||
supported_versions: vec![2],
|
|
||||||
video_codecs: vec![],
|
|
||||||
},
|
},
|
||||||
"Offer",
|
"Offer",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
wzp_proto::SignalMessage::CallAnswer {
|
wzp_proto::SignalMessage::CallAnswer {
|
||||||
version: default_signal_version(),
|
identity_pub: [0; 32], ephemeral_pub: [0; 32],
|
||||||
identity_pub: [0; 32],
|
|
||||||
ephemeral_pub: [0; 32],
|
|
||||||
signature: vec![],
|
signature: vec![],
|
||||||
chosen_profile: wzp_proto::QualityProfile::GOOD,
|
chosen_profile: wzp_proto::QualityProfile::GOOD,
|
||||||
video_codec: None,
|
|
||||||
},
|
},
|
||||||
"Answer",
|
"Answer",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
wzp_proto::SignalMessage::IceCandidate {
|
wzp_proto::SignalMessage::IceCandidate {
|
||||||
version: default_signal_version(),
|
|
||||||
candidate: "candidate:1".to_string(),
|
candidate: "candidate:1".to_string(),
|
||||||
},
|
},
|
||||||
"IceCandidate",
|
"IceCandidate",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
wzp_proto::SignalMessage::Hangup {
|
wzp_proto::SignalMessage::Hangup {
|
||||||
version: default_signal_version(),
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
call_id: None,
|
|
||||||
},
|
},
|
||||||
"Hangup",
|
"Hangup",
|
||||||
),
|
),
|
||||||
@@ -346,10 +308,7 @@ fn all_signal_types_map_correctly() {
|
|||||||
for (signal, expected_name) in cases {
|
for (signal, expected_name) in cases {
|
||||||
let ct = signal_to_call_type(&signal);
|
let ct = signal_to_call_type(&signal);
|
||||||
let name = format!("{ct:?}");
|
let name = format!("{ct:?}");
|
||||||
assert_eq!(
|
assert_eq!(name, expected_name, "signal type mapping for {expected_name}");
|
||||||
name, expected_name,
|
|
||||||
"signal type mapping for {expected_name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +422,8 @@ fn auth_response_with_eth_address() {
|
|||||||
"alias": "vitalik",
|
"alias": "vitalik",
|
||||||
"eth_address": "0x1234567890abcdef1234567890abcdef12345678"
|
"eth_address": "0x1234567890abcdef1234567890abcdef12345678"
|
||||||
});
|
});
|
||||||
let resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_eth).unwrap();
|
let resp: wzp_relay::auth::ValidateResponse =
|
||||||
|
serde_json::from_value(with_eth).unwrap();
|
||||||
assert!(resp.valid);
|
assert!(resp.valid);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp.fingerprint.unwrap(),
|
resp.fingerprint.unwrap(),
|
||||||
@@ -478,7 +438,8 @@ fn auth_response_with_eth_address() {
|
|||||||
"alias": "anon",
|
"alias": "anon",
|
||||||
"eth_address": null
|
"eth_address": null
|
||||||
});
|
});
|
||||||
let resp2: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_null_eth).unwrap();
|
let resp2: wzp_relay::auth::ValidateResponse =
|
||||||
|
serde_json::from_value(with_null_eth).unwrap();
|
||||||
assert!(resp2.valid);
|
assert!(resp2.valid);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp2.fingerprint.unwrap(),
|
resp2.fingerprint.unwrap(),
|
||||||
@@ -489,15 +450,15 @@ fn auth_response_with_eth_address() {
|
|||||||
let without_eth = serde_json::json!({
|
let without_eth = serde_json::json!({
|
||||||
"valid": false
|
"valid": false
|
||||||
});
|
});
|
||||||
let resp3: wzp_relay::auth::ValidateResponse = serde_json::from_value(without_eth).unwrap();
|
let resp3: wzp_relay::auth::ValidateResponse =
|
||||||
|
serde_json::from_value(without_eth).unwrap();
|
||||||
assert!(!resp3.valid);
|
assert!(!resp3.valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WZP-S-7: SignalMessage::AuthToken { version: default_signal_version(), token } exists and round-trips via serde.
|
/// WZP-S-7: SignalMessage::AuthToken { token } exists and round-trips via serde.
|
||||||
#[test]
|
#[test]
|
||||||
fn wzp_proto_has_auth_token_variant() {
|
fn wzp_proto_has_auth_token_variant() {
|
||||||
let msg = wzp_proto::SignalMessage::AuthToken {
|
let msg = wzp_proto::SignalMessage::AuthToken {
|
||||||
version: default_signal_version(),
|
|
||||||
token: "fc-bearer-token-xyz".to_string(),
|
token: "fc-bearer-token-xyz".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,7 +469,7 @@ fn wzp_proto_has_auth_token_variant() {
|
|||||||
|
|
||||||
// Deserialize back
|
// Deserialize back
|
||||||
let decoded: wzp_proto::SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: wzp_proto::SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
if let wzp_proto::SignalMessage::AuthToken { token, .. } = decoded {
|
if let wzp_proto::SignalMessage::AuthToken { token } = decoded {
|
||||||
assert_eq!(token, "fc-bearer-token-xyz");
|
assert_eq!(token, "fc-bearer-token-xyz");
|
||||||
} else {
|
} else {
|
||||||
panic!("expected AuthToken variant, got: {decoded:?}");
|
panic!("expected AuthToken variant, got: {decoded:?}");
|
||||||
@@ -531,11 +492,7 @@ fn all_fc_call_signal_types_representable() {
|
|||||||
(CallSignalType::Busy, "Busy"),
|
(CallSignalType::Busy, "Busy"),
|
||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(variants.len(), 7, "featherChat defines exactly 7 call signal types");
|
||||||
variants.len(),
|
|
||||||
7,
|
|
||||||
"featherChat defines exactly 7 call signal types"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (variant, expected_name) in &variants {
|
for (variant, expected_name) in &variants {
|
||||||
let name = format!("{variant:?}");
|
let name = format!("{variant:?}");
|
||||||
@@ -589,7 +546,10 @@ fn hash_room_name_used_as_sni_is_valid() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn wzp_proto_cargo_toml_is_standalone() {
|
fn wzp_proto_cargo_toml_is_standalone() {
|
||||||
// Try both paths (run from workspace root or from crate directory)
|
// Try both paths (run from workspace root or from crate directory)
|
||||||
let candidates = ["crates/wzp-proto/Cargo.toml", "../wzp-proto/Cargo.toml"];
|
let candidates = [
|
||||||
|
"crates/wzp-proto/Cargo.toml",
|
||||||
|
"../wzp-proto/Cargo.toml",
|
||||||
|
];
|
||||||
|
|
||||||
let contents = candidates
|
let contents = candidates
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -13,17 +13,11 @@ pub struct AdaptiveFec {
|
|||||||
pub repair_ratio: f32,
|
pub repair_ratio: f32,
|
||||||
/// Symbol size in bytes.
|
/// Symbol size in bytes.
|
||||||
pub symbol_size: u16,
|
pub symbol_size: u16,
|
||||||
/// Repair ratio to use when the block contains a keyframe.
|
|
||||||
/// Default 0.5 (50% overhead) — keyframes are critical and worth
|
|
||||||
/// the extra bandwidth.
|
|
||||||
pub keyframe_repair_ratio: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdaptiveFec {
|
impl AdaptiveFec {
|
||||||
/// Default symbol size for adaptive configuration.
|
/// Default symbol size for adaptive configuration.
|
||||||
const DEFAULT_SYMBOL_SIZE: u16 = 256;
|
const DEFAULT_SYMBOL_SIZE: u16 = 256;
|
||||||
/// Default keyframe repair ratio (PRD-video-v1 T4.5).
|
|
||||||
const DEFAULT_KEYFRAME_REPAIR_RATIO: f32 = 0.5;
|
|
||||||
|
|
||||||
/// Create an adaptive FEC configuration from a quality profile.
|
/// Create an adaptive FEC configuration from a quality profile.
|
||||||
///
|
///
|
||||||
@@ -36,15 +30,12 @@ impl AdaptiveFec {
|
|||||||
frames_per_block: profile.frames_per_block as usize,
|
frames_per_block: profile.frames_per_block as usize,
|
||||||
repair_ratio: profile.fec_ratio,
|
repair_ratio: profile.fec_ratio,
|
||||||
symbol_size: Self::DEFAULT_SYMBOL_SIZE,
|
symbol_size: Self::DEFAULT_SYMBOL_SIZE,
|
||||||
keyframe_repair_ratio: Self::DEFAULT_KEYFRAME_REPAIR_RATIO,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a configured FEC encoder from this adaptive configuration.
|
/// Build a configured FEC encoder from this adaptive configuration.
|
||||||
pub fn build_encoder(&self) -> RaptorQFecEncoder {
|
pub fn build_encoder(&self) -> RaptorQFecEncoder {
|
||||||
let mut enc = RaptorQFecEncoder::new(self.frames_per_block, self.symbol_size);
|
RaptorQFecEncoder::new(self.frames_per_block, self.symbol_size)
|
||||||
enc.set_keyframe_ratio(self.keyframe_repair_ratio);
|
|
||||||
enc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the repair ratio for use with `FecEncoder::generate_repair()`.
|
/// Get the repair ratio for use with `FecEncoder::generate_repair()`.
|
||||||
@@ -68,7 +59,6 @@ mod tests {
|
|||||||
let cfg = AdaptiveFec::from_profile(&QualityProfile::GOOD);
|
let cfg = AdaptiveFec::from_profile(&QualityProfile::GOOD);
|
||||||
assert_eq!(cfg.frames_per_block, 5);
|
assert_eq!(cfg.frames_per_block, 5);
|
||||||
assert!((cfg.repair_ratio - 0.2).abs() < f32::EPSILON);
|
assert!((cfg.repair_ratio - 0.2).abs() < f32::EPSILON);
|
||||||
assert!((cfg.keyframe_repair_ratio - 0.5).abs() < f32::EPSILON);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ pub enum DecoderBlockState {
|
|||||||
/// Manages encoder-side block tracking.
|
/// Manages encoder-side block tracking.
|
||||||
pub struct EncoderBlockManager {
|
pub struct EncoderBlockManager {
|
||||||
/// Current block ID being built.
|
/// Current block ID being built.
|
||||||
current_id: u16,
|
current_id: u8,
|
||||||
/// State of known blocks.
|
/// State of known blocks.
|
||||||
blocks: HashMap<u16, EncoderBlockState>,
|
blocks: HashMap<u8, EncoderBlockState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncoderBlockManager {
|
impl EncoderBlockManager {
|
||||||
@@ -45,7 +45,7 @@ impl EncoderBlockManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the next block ID (advances the current building block).
|
/// Get the next block ID (advances the current building block).
|
||||||
pub fn next_block_id(&mut self) -> u16 {
|
pub fn next_block_id(&mut self) -> u8 {
|
||||||
let old = self.current_id;
|
let old = self.current_id;
|
||||||
// Mark old block as pending.
|
// Mark old block as pending.
|
||||||
self.blocks.insert(old, EncoderBlockState::Pending);
|
self.blocks.insert(old, EncoderBlockState::Pending);
|
||||||
@@ -57,23 +57,23 @@ impl EncoderBlockManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Current block ID being built.
|
/// Current block ID being built.
|
||||||
pub fn current_id(&self) -> u16 {
|
pub fn current_id(&self) -> u8 {
|
||||||
self.current_id
|
self.current_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a block as fully sent.
|
/// Mark a block as fully sent.
|
||||||
pub fn mark_sent(&mut self, block_id: u16) {
|
pub fn mark_sent(&mut self, block_id: u8) {
|
||||||
self.blocks.insert(block_id, EncoderBlockState::Sent);
|
self.blocks.insert(block_id, EncoderBlockState::Sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a block as acknowledged by the peer.
|
/// Mark a block as acknowledged by the peer.
|
||||||
pub fn mark_acknowledged(&mut self, block_id: u16) {
|
pub fn mark_acknowledged(&mut self, block_id: u8) {
|
||||||
self.blocks
|
self.blocks
|
||||||
.insert(block_id, EncoderBlockState::Acknowledged);
|
.insert(block_id, EncoderBlockState::Acknowledged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the state of a block.
|
/// Get the state of a block.
|
||||||
pub fn state(&self, block_id: u16) -> Option<EncoderBlockState> {
|
pub fn state(&self, block_id: u8) -> Option<EncoderBlockState> {
|
||||||
self.blocks.get(&block_id).copied()
|
self.blocks.get(&block_id).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +93,9 @@ impl Default for EncoderBlockManager {
|
|||||||
/// Manages decoder-side block tracking.
|
/// Manages decoder-side block tracking.
|
||||||
pub struct DecoderBlockManager {
|
pub struct DecoderBlockManager {
|
||||||
/// State of known blocks.
|
/// State of known blocks.
|
||||||
blocks: HashMap<u16, DecoderBlockState>,
|
blocks: HashMap<u8, DecoderBlockState>,
|
||||||
/// Set of completed block IDs.
|
/// Set of completed block IDs.
|
||||||
completed: HashSet<u16>,
|
completed: HashSet<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecoderBlockManager {
|
impl DecoderBlockManager {
|
||||||
@@ -107,43 +107,43 @@ impl DecoderBlockManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register that we are receiving symbols for a block.
|
/// Register that we are receiving symbols for a block.
|
||||||
pub fn touch(&mut self, block_id: u16) {
|
pub fn touch(&mut self, block_id: u8) {
|
||||||
self.blocks
|
self.blocks
|
||||||
.entry(block_id)
|
.entry(block_id)
|
||||||
.or_insert(DecoderBlockState::Assembling);
|
.or_insert(DecoderBlockState::Assembling);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a block as successfully decoded.
|
/// Mark a block as successfully decoded.
|
||||||
pub fn mark_complete(&mut self, block_id: u16) {
|
pub fn mark_complete(&mut self, block_id: u8) {
|
||||||
self.blocks.insert(block_id, DecoderBlockState::Complete);
|
self.blocks.insert(block_id, DecoderBlockState::Complete);
|
||||||
self.completed.insert(block_id);
|
self.completed.insert(block_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a block as expired.
|
/// Mark a block as expired.
|
||||||
pub fn mark_expired(&mut self, block_id: u16) {
|
pub fn mark_expired(&mut self, block_id: u8) {
|
||||||
self.blocks.insert(block_id, DecoderBlockState::Expired);
|
self.blocks.insert(block_id, DecoderBlockState::Expired);
|
||||||
self.completed.remove(&block_id);
|
self.completed.remove(&block_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a block has been fully decoded.
|
/// Check if a block has been fully decoded.
|
||||||
pub fn is_block_complete(&self, block_id: u16) -> bool {
|
pub fn is_block_complete(&self, block_id: u8) -> bool {
|
||||||
self.completed.contains(&block_id)
|
self.completed.contains(&block_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the state of a block.
|
/// Get the state of a block.
|
||||||
pub fn state(&self, block_id: u16) -> Option<DecoderBlockState> {
|
pub fn state(&self, block_id: u8) -> Option<DecoderBlockState> {
|
||||||
self.blocks.get(&block_id).copied()
|
self.blocks.get(&block_id).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expire all blocks older than the given block_id (using wrapping distance).
|
/// Expire all blocks older than the given block_id (using wrapping distance).
|
||||||
pub fn expire_before(&mut self, block_id: u16) {
|
pub fn expire_before(&mut self, block_id: u8) {
|
||||||
let to_expire: Vec<u16> = self
|
let to_expire: Vec<u8> = self
|
||||||
.blocks
|
.blocks
|
||||||
.keys()
|
.keys()
|
||||||
.copied()
|
.copied()
|
||||||
.filter(|&id| {
|
.filter(|&id| {
|
||||||
let distance = block_id.wrapping_sub(id);
|
let distance = block_id.wrapping_sub(id);
|
||||||
distance > 0 && distance <= 32768
|
distance > 0 && distance <= 128
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn decoder_expire_before() {
|
fn decoder_expire_before() {
|
||||||
let mut mgr = DecoderBlockManager::new();
|
let mut mgr = DecoderBlockManager::new();
|
||||||
for i in 0..5u16 {
|
for i in 0..5u8 {
|
||||||
mgr.touch(i);
|
mgr.touch(i);
|
||||||
}
|
}
|
||||||
mgr.mark_complete(1);
|
mgr.mark_complete(1);
|
||||||
@@ -231,11 +231,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn next_block_id_wraps() {
|
fn next_block_id_wraps() {
|
||||||
let mut mgr = EncoderBlockManager::new();
|
let mut mgr = EncoderBlockManager::new();
|
||||||
// Start at 0, advance to u16::MAX then wrap
|
// Start at 0, advance to 255 then wrap
|
||||||
for _ in 0..65535 {
|
for _ in 0..255 {
|
||||||
mgr.next_block_id();
|
mgr.next_block_id();
|
||||||
}
|
}
|
||||||
assert_eq!(mgr.current_id(), u16::MAX);
|
assert_eq!(mgr.current_id(), 255);
|
||||||
let next = mgr.next_block_id();
|
let next = mgr.next_block_id();
|
||||||
assert_eq!(next, 0);
|
assert_eq!(next, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||||
use wzp_proto::FecDecoder;
|
|
||||||
use wzp_proto::error::FecError;
|
use wzp_proto::error::FecError;
|
||||||
|
use wzp_proto::FecDecoder;
|
||||||
|
|
||||||
/// Length prefix size (u16 little-endian), must match encoder.
|
/// Length prefix size (u16 little-endian), must match encoder.
|
||||||
const LEN_PREFIX: usize = 2;
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
/// Decoded blocks older than this are eligible for reuse by a new sender.
|
|
||||||
const BLOCK_STALE_SECS: u64 = 2;
|
|
||||||
|
|
||||||
/// State for one in-flight block being decoded.
|
/// State for one in-flight block being decoded.
|
||||||
struct BlockState {
|
struct BlockState {
|
||||||
/// Number of source symbols expected.
|
/// Number of source symbols expected.
|
||||||
@@ -25,14 +21,12 @@ struct BlockState {
|
|||||||
decoded: bool,
|
decoded: bool,
|
||||||
/// Cached decoded result.
|
/// Cached decoded result.
|
||||||
result: Option<Vec<Vec<u8>>>,
|
result: Option<Vec<Vec<u8>>>,
|
||||||
/// When this block was last decoded (for staleness check).
|
|
||||||
decoded_at: Option<Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||||
pub struct RaptorQFecDecoder {
|
pub struct RaptorQFecDecoder {
|
||||||
/// Per-block decoder state, keyed by block_id.
|
/// Per-block decoder state, keyed by block_id.
|
||||||
blocks: HashMap<u16, BlockState>,
|
blocks: HashMap<u8, BlockState>,
|
||||||
/// Symbol size (must match encoder).
|
/// Symbol size (must match encoder).
|
||||||
symbol_size: u16,
|
symbol_size: u16,
|
||||||
/// Number of source symbols per block (from encoder config).
|
/// Number of source symbols per block (from encoder config).
|
||||||
@@ -57,14 +51,13 @@ impl RaptorQFecDecoder {
|
|||||||
Self::new(frames_per_block, 256)
|
Self::new(frames_per_block, 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_or_create_block(&mut self, block_id: u16) -> &mut BlockState {
|
fn get_or_create_block(&mut self, block_id: u8) -> &mut BlockState {
|
||||||
self.blocks.entry(block_id).or_insert_with(|| BlockState {
|
self.blocks.entry(block_id).or_insert_with(|| BlockState {
|
||||||
num_source_symbols: Some(self.frames_per_block),
|
num_source_symbols: Some(self.frames_per_block),
|
||||||
packets: Vec::new(),
|
packets: Vec::new(),
|
||||||
symbol_size: self.symbol_size,
|
symbol_size: self.symbol_size,
|
||||||
decoded: false,
|
decoded: false,
|
||||||
result: None,
|
result: None,
|
||||||
decoded_at: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,8 +65,8 @@ impl RaptorQFecDecoder {
|
|||||||
impl FecDecoder for RaptorQFecDecoder {
|
impl FecDecoder for RaptorQFecDecoder {
|
||||||
fn add_symbol(
|
fn add_symbol(
|
||||||
&mut self,
|
&mut self,
|
||||||
block_id: u16,
|
block_id: u8,
|
||||||
symbol_index: u16,
|
symbol_index: u8,
|
||||||
_is_repair: bool,
|
_is_repair: bool,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
) -> Result<(), FecError> {
|
) -> Result<(), FecError> {
|
||||||
@@ -81,20 +74,8 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
let block = self.get_or_create_block(block_id);
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
if block.decoded {
|
if block.decoded {
|
||||||
// If the block was decoded recently, skip (normal duplicate).
|
// Already decoded, ignore additional symbols.
|
||||||
// If it's stale (>2s), a new sender is reusing this block_id — reset it.
|
return Ok(());
|
||||||
if let Some(at) = block.decoded_at {
|
|
||||||
if at.elapsed().as_secs() >= BLOCK_STALE_SECS {
|
|
||||||
block.decoded = false;
|
|
||||||
block.result = None;
|
|
||||||
block.decoded_at = None;
|
|
||||||
block.packets.clear();
|
|
||||||
} else {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||||
@@ -104,13 +85,13 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
padded[..len].copy_from_slice(&data[..len]);
|
padded[..len].copy_from_slice(&data[..len]);
|
||||||
|
|
||||||
let esi = symbol_index as u32;
|
let esi = symbol_index as u32;
|
||||||
let packet = EncodingPacket::new(PayloadId::new((block_id & 0xFF) as u8, esi), padded);
|
let packet = EncodingPacket::new(PayloadId::new(block_id, esi), padded);
|
||||||
block.packets.push(packet);
|
block.packets.push(packet);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_decode(&mut self, block_id: u16) -> Result<Option<Vec<Vec<u8>>>, FecError> {
|
fn try_decode(&mut self, block_id: u8) -> Result<Option<Vec<Vec<u8>>>, FecError> {
|
||||||
let frames_per_block = self.frames_per_block;
|
let frames_per_block = self.frames_per_block;
|
||||||
let block = match self.blocks.get_mut(&block_id) {
|
let block = match self.blocks.get_mut(&block_id) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
@@ -125,7 +106,7 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
let block_length = (num_source as u64) * (block.symbol_size as u64);
|
let block_length = (num_source as u64) * (block.symbol_size as u64);
|
||||||
|
|
||||||
let config = ObjectTransmissionInformation::with_defaults(block_length, block.symbol_size);
|
let config = ObjectTransmissionInformation::with_defaults(block_length, block.symbol_size);
|
||||||
let mut decoder = SourceBlockDecoder::new((block_id & 0xFF) as u8, &config, block_length);
|
let mut decoder = SourceBlockDecoder::new(block_id, &config, block_length);
|
||||||
|
|
||||||
let decoded = decoder.decode(block.packets.clone());
|
let decoded = decoder.decode(block.packets.clone());
|
||||||
|
|
||||||
@@ -140,7 +121,10 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
frames.push(Vec::new());
|
frames.push(Vec::new());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let payload_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
|
let payload_len = u16::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
]) as usize;
|
||||||
let payload_start = offset + LEN_PREFIX;
|
let payload_start = offset + LEN_PREFIX;
|
||||||
let payload_end = (payload_start + payload_len).min(data.len());
|
let payload_end = (payload_start + payload_len).min(data.len());
|
||||||
frames.push(data[payload_start..payload_end].to_vec());
|
frames.push(data[payload_start..payload_end].to_vec());
|
||||||
@@ -148,7 +132,6 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
|
|
||||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||||
block.decoded = true;
|
block.decoded = true;
|
||||||
block.decoded_at = Some(Instant::now());
|
|
||||||
block.result = Some(frames.clone());
|
block.result = Some(frames.clone());
|
||||||
Ok(Some(frames))
|
Ok(Some(frames))
|
||||||
}
|
}
|
||||||
@@ -156,15 +139,15 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expire_before(&mut self, block_id: u16) {
|
fn expire_before(&mut self, block_id: u8) {
|
||||||
// Remove blocks with IDs "older" than block_id.
|
// Remove blocks with IDs "older" than block_id.
|
||||||
// With wrapping u16 IDs, we consider a block old if its distance
|
// With wrapping u8 IDs, we consider a block old if its distance
|
||||||
// (in the forward direction) to block_id is > 32768.
|
// (in the forward direction) to block_id is > 128.
|
||||||
self.blocks.retain(|&id, _| {
|
self.blocks.retain(|&id, _| {
|
||||||
let distance = block_id.wrapping_sub(id);
|
let distance = block_id.wrapping_sub(id);
|
||||||
// If distance is 0 or > 32768, the block is current or "ahead" — keep it.
|
// If distance is 0 or > 128, the block is current or "ahead" — keep it.
|
||||||
// If distance is 1..=32768, the block is behind — remove it.
|
// If distance is 1..=128, the block is behind — remove it.
|
||||||
distance == 0 || distance > 32768
|
distance == 0 || distance > 128
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +178,9 @@ mod tests {
|
|||||||
|
|
||||||
// Feed all source symbols (using the length-prefixed padded data).
|
// Feed all source symbols (using the length-prefixed padded data).
|
||||||
for (i, pkt) in source_pkts.iter().enumerate() {
|
for (i, pkt) in source_pkts.iter().enumerate() {
|
||||||
decoder.add_symbol(0, i as u16, false, pkt.data()).unwrap();
|
decoder
|
||||||
|
.add_symbol(0, i as u8, false, pkt.data())
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = decoder.try_decode(0).unwrap();
|
let result = decoder.try_decode(0).unwrap();
|
||||||
@@ -228,11 +213,7 @@ mod tests {
|
|||||||
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
|
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
|
||||||
let mut dec = SourceBlockDecoder::new(0, &config, block_len);
|
let mut dec = SourceBlockDecoder::new(0, &config, block_len);
|
||||||
let decoded = dec.decode(all);
|
let decoded = dec.decode(all);
|
||||||
assert!(
|
assert!(decoded.is_some(), "Should recover with {:.0}% loss", drop_fraction * 100.0);
|
||||||
decoded.is_some(),
|
|
||||||
"Should recover with {:.0}% loss",
|
|
||||||
drop_fraction * 100.0
|
|
||||||
);
|
|
||||||
|
|
||||||
let data = decoded.unwrap();
|
let data = decoded.unwrap();
|
||||||
let ss = SYMBOL_SIZE as usize;
|
let ss = SYMBOL_SIZE as usize;
|
||||||
@@ -244,28 +225,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_with_30pct_loss() {
|
fn decode_with_30pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3); }
|
||||||
run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_with_50pct_loss() {
|
fn decode_with_50pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5); }
|
||||||
run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_with_70pct_source_loss_heavy_repair() {
|
fn decode_with_70pct_source_loss_heavy_repair() { run_loss_test(8, 2.0, 0.5); }
|
||||||
run_loss_test(8, 2.0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expire_removes_old_blocks() {
|
fn expire_removes_old_blocks() {
|
||||||
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
|
let mut decoder = RaptorQFecDecoder::new(FRAMES_PER_BLOCK, SYMBOL_SIZE);
|
||||||
|
|
||||||
// Add symbols to blocks 0, 1, 2
|
// Add symbols to blocks 0, 1, 2
|
||||||
for block_id in 0..3u16 {
|
for block_id in 0..3u8 {
|
||||||
decoder
|
decoder
|
||||||
.add_symbol(block_id, 0, false, &[block_id as u8; 50])
|
.add_symbol(block_id, 0, false, &[block_id; 50])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +268,10 @@ mod tests {
|
|||||||
// Interleave symbols from block 0 and block 1
|
// Interleave symbols from block 0 and block 1
|
||||||
for i in 0..FRAMES_PER_BLOCK {
|
for i in 0..FRAMES_PER_BLOCK {
|
||||||
decoder
|
decoder
|
||||||
.add_symbol(0, i as u16, false, pkts_a[i].data())
|
.add_symbol(0, i as u8, false, pkts_a[i].data())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
decoder
|
decoder
|
||||||
.add_symbol(1, i as u16, false, pkts_b[i].data())
|
.add_symbol(1, i as u8, false, pkts_b[i].data())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
|
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
|
||||||
|
|
||||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
|
||||||
use wzp_proto::FecEncoder;
|
|
||||||
use wzp_proto::error::FecError;
|
use wzp_proto::error::FecError;
|
||||||
|
use wzp_proto::FecEncoder;
|
||||||
|
|
||||||
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
|
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
|
||||||
/// but we pad to a uniform size within a block.
|
/// but we pad to a uniform size within a block.
|
||||||
@@ -15,19 +15,14 @@ const LEN_PREFIX: usize = 2;
|
|||||||
/// RaptorQ-based FEC encoder that groups audio frames into blocks
|
/// RaptorQ-based FEC encoder that groups audio frames into blocks
|
||||||
/// and generates fountain-code repair symbols.
|
/// and generates fountain-code repair symbols.
|
||||||
pub struct RaptorQFecEncoder {
|
pub struct RaptorQFecEncoder {
|
||||||
/// Current block ID (wraps at u16).
|
/// Current block ID (wraps at u8).
|
||||||
block_id: u16,
|
block_id: u8,
|
||||||
/// Maximum source symbols per block.
|
/// Maximum source symbols per block.
|
||||||
frames_per_block: usize,
|
frames_per_block: usize,
|
||||||
/// Accumulated source symbols for the current block.
|
/// Accumulated source symbols for the current block.
|
||||||
source_symbols: Vec<Vec<u8>>,
|
source_symbols: Vec<Vec<u8>>,
|
||||||
/// Symbol size used for encoding (all symbols padded to this size).
|
/// Symbol size used for encoding (all symbols padded to this size).
|
||||||
symbol_size: u16,
|
symbol_size: u16,
|
||||||
/// True if at least one source symbol in the current block is a keyframe.
|
|
||||||
has_keyframe: bool,
|
|
||||||
/// Repair ratio to use when the block contains a keyframe.
|
|
||||||
/// If zero, the nominal ratio passed to [`generate_repair`] is used.
|
|
||||||
keyframe_ratio: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RaptorQFecEncoder {
|
impl RaptorQFecEncoder {
|
||||||
@@ -41,26 +36,9 @@ impl RaptorQFecEncoder {
|
|||||||
frames_per_block,
|
frames_per_block,
|
||||||
source_symbols: Vec::with_capacity(frames_per_block),
|
source_symbols: Vec::with_capacity(frames_per_block),
|
||||||
symbol_size,
|
symbol_size,
|
||||||
has_keyframe: false,
|
|
||||||
keyframe_ratio: 0.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the repair ratio to use for blocks that contain at least one
|
|
||||||
/// keyframe source symbol.
|
|
||||||
///
|
|
||||||
/// When `keyframe_ratio > 0.0` and [`has_keyframe`](Self::has_keyframe)
|
|
||||||
/// is true, [`generate_repair`](FecEncoder::generate_repair) uses this
|
|
||||||
/// ratio instead of the nominal ratio passed by the caller.
|
|
||||||
pub fn set_keyframe_ratio(&mut self, ratio: f32) {
|
|
||||||
self.keyframe_ratio = ratio.max(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the current block contains a keyframe source symbol.
|
|
||||||
pub fn has_keyframe(&self) -> bool {
|
|
||||||
self.has_keyframe
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create with default symbol size (256 bytes).
|
/// Create with default symbol size (256 bytes).
|
||||||
pub fn with_defaults(frames_per_block: usize) -> Self {
|
pub fn with_defaults(frames_per_block: usize) -> Self {
|
||||||
Self::new(frames_per_block, DEFAULT_MAX_SYMBOL_SIZE)
|
Self::new(frames_per_block, DEFAULT_MAX_SYMBOL_SIZE)
|
||||||
@@ -76,7 +54,8 @@ impl RaptorQFecEncoder {
|
|||||||
let payload_len = sym.len().min(max_payload);
|
let payload_len = sym.len().min(max_payload);
|
||||||
let offset = i * ss;
|
let offset = i * ss;
|
||||||
// Write 2-byte little-endian length prefix.
|
// Write 2-byte little-endian length prefix.
|
||||||
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
// Write payload after prefix.
|
// Write payload after prefix.
|
||||||
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
.copy_from_slice(&sym[..payload_len]);
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
@@ -96,36 +75,17 @@ impl FecEncoder for RaptorQFecEncoder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_source_symbol_with_keyframe(
|
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u8, Vec<u8>)>, FecError> {
|
||||||
&mut self,
|
|
||||||
data: &[u8],
|
|
||||||
is_keyframe: bool,
|
|
||||||
) -> Result<(), FecError> {
|
|
||||||
self.add_source_symbol(data)?;
|
|
||||||
if is_keyframe {
|
|
||||||
self.has_keyframe = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_repair(&mut self, ratio: f32) -> Result<Vec<(u16, Vec<u8>)>, FecError> {
|
|
||||||
if self.source_symbols.is_empty() {
|
if self.source_symbols.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let effective_ratio = if self.has_keyframe && self.keyframe_ratio > 0.0 {
|
|
||||||
self.keyframe_ratio
|
|
||||||
} else {
|
|
||||||
ratio
|
|
||||||
};
|
|
||||||
|
|
||||||
let block_data = self.build_block_data();
|
let block_data = self.build_block_data();
|
||||||
let config =
|
let config = ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
|
||||||
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
|
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
|
||||||
let encoder = SourceBlockEncoder::new((self.block_id & 0xFF) as u8, &config, &block_data);
|
|
||||||
|
|
||||||
let num_source = self.source_symbols.len() as u32;
|
let num_source = self.source_symbols.len() as u32;
|
||||||
let num_repair = ((num_source as f32) * effective_ratio).ceil() as u32;
|
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
||||||
if num_repair == 0 {
|
if num_repair == 0 {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
@@ -133,11 +93,11 @@ impl FecEncoder for RaptorQFecEncoder {
|
|||||||
// Generate repair packets starting from offset 0 (ESIs begin at num_source).
|
// Generate repair packets starting from offset 0 (ESIs begin at num_source).
|
||||||
let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair);
|
let repair_packets: Vec<EncodingPacket> = encoder.repair_packets(0, num_repair);
|
||||||
|
|
||||||
let result: Vec<(u16, Vec<u8>)> = repair_packets
|
let result: Vec<(u8, Vec<u8>)> = repair_packets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, pkt): (usize, EncodingPacket)| {
|
.map(|(i, pkt): (usize, EncodingPacket)| {
|
||||||
let idx = (num_source as u16).wrapping_add(i as u16);
|
let idx = (num_source as u8).wrapping_add(i as u8);
|
||||||
(idx, pkt.data().to_vec())
|
(idx, pkt.data().to_vec())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -145,15 +105,14 @@ impl FecEncoder for RaptorQFecEncoder {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finalize_block(&mut self) -> Result<u16, FecError> {
|
fn finalize_block(&mut self) -> Result<u8, FecError> {
|
||||||
let completed = self.block_id;
|
let completed = self.block_id;
|
||||||
self.block_id = self.block_id.wrapping_add(1);
|
self.block_id = self.block_id.wrapping_add(1);
|
||||||
self.source_symbols.clear();
|
self.source_symbols.clear();
|
||||||
self.has_keyframe = false;
|
|
||||||
Ok(completed)
|
Ok(completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_block_id(&self) -> u16 {
|
fn current_block_id(&self) -> u8 {
|
||||||
self.block_id
|
self.block_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +130,8 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
|
|||||||
let max_payload = ss - LEN_PREFIX;
|
let max_payload = ss - LEN_PREFIX;
|
||||||
let payload_len = sym.len().min(max_payload);
|
let payload_len = sym.len().min(max_payload);
|
||||||
let offset = i * ss;
|
let offset = i * ss;
|
||||||
data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
|
data[offset..offset + LEN_PREFIX]
|
||||||
|
.copy_from_slice(&(payload_len as u16).to_le_bytes());
|
||||||
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
|
||||||
.copy_from_slice(&sym[..payload_len]);
|
.copy_from_slice(&sym[..payload_len]);
|
||||||
}
|
}
|
||||||
@@ -181,7 +141,7 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
|
|||||||
/// Helper: build source `EncodingPacket`s for a given block. Useful for
|
/// Helper: build source `EncodingPacket`s for a given block. Useful for
|
||||||
/// the decoder tests and interleaving.
|
/// the decoder tests and interleaving.
|
||||||
pub fn source_packets_for_block(
|
pub fn source_packets_for_block(
|
||||||
block_id: u16,
|
block_id: u8,
|
||||||
symbols: &[Vec<u8>],
|
symbols: &[Vec<u8>],
|
||||||
symbol_size: u16,
|
symbol_size: u16,
|
||||||
) -> Vec<EncodingPacket> {
|
) -> Vec<EncodingPacket> {
|
||||||
@@ -191,21 +151,21 @@ pub fn source_packets_for_block(
|
|||||||
.map(|i| {
|
.map(|i| {
|
||||||
let offset = i * ss;
|
let offset = i * ss;
|
||||||
let sym_data = data[offset..offset + ss].to_vec();
|
let sym_data = data[offset..offset + ss].to_vec();
|
||||||
EncodingPacket::new(PayloadId::new((block_id & 0xFF) as u8, i as u32), sym_data)
|
EncodingPacket::new(PayloadId::new(block_id, i as u32), sym_data)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: generate repair packets for the given source symbols.
|
/// Helper: generate repair packets for the given source symbols.
|
||||||
pub fn repair_packets_for_block(
|
pub fn repair_packets_for_block(
|
||||||
block_id: u16,
|
block_id: u8,
|
||||||
symbols: &[Vec<u8>],
|
symbols: &[Vec<u8>],
|
||||||
symbol_size: u16,
|
symbol_size: u16,
|
||||||
ratio: f32,
|
ratio: f32,
|
||||||
) -> Vec<EncodingPacket> {
|
) -> Vec<EncodingPacket> {
|
||||||
let data = build_prefixed_block_data(symbols, symbol_size);
|
let data = build_prefixed_block_data(symbols, symbol_size);
|
||||||
let config = ObjectTransmissionInformation::with_defaults(data.len() as u64, symbol_size);
|
let config = ObjectTransmissionInformation::with_defaults(data.len() as u64, symbol_size);
|
||||||
let encoder = SourceBlockEncoder::new((block_id & 0xFF) as u8, &config, &data);
|
let encoder = SourceBlockEncoder::new(block_id, &config, &data);
|
||||||
let num_source = symbols.len() as u32;
|
let num_source = symbols.len() as u32;
|
||||||
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
let num_repair = ((num_source as f32) * ratio).ceil() as u32;
|
||||||
encoder.repair_packets(0, num_repair)
|
encoder.repair_packets(0, num_repair)
|
||||||
@@ -241,70 +201,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn block_id_wraps_u16() {
|
fn block_id_wraps() {
|
||||||
let mut enc = RaptorQFecEncoder::with_defaults(1);
|
let mut enc = RaptorQFecEncoder::with_defaults(1);
|
||||||
// Advance 300 blocks and verify no panic + monotonic increment.
|
for expected in 0..=255u8 {
|
||||||
for expected in 0..300u16 {
|
|
||||||
assert_eq!(enc.current_block_id(), expected);
|
assert_eq!(enc.current_block_id(), expected);
|
||||||
enc.add_source_symbol(&[0u8; 10]).unwrap();
|
enc.add_source_symbol(&[expected; 10]).unwrap();
|
||||||
enc.finalize_block().unwrap();
|
enc.finalize_block().unwrap();
|
||||||
}
|
}
|
||||||
// Explicitly test wrap at u16 boundary.
|
// After 256 blocks, wraps back to 0
|
||||||
let mut enc2 = RaptorQFecEncoder::with_defaults(1);
|
assert_eq!(enc.current_block_id(), 0);
|
||||||
enc2.block_id = u16::MAX;
|
|
||||||
enc2.add_source_symbol(&[0u8; 10]).unwrap();
|
|
||||||
let id = enc2.finalize_block().unwrap();
|
|
||||||
assert_eq!(id, u16::MAX);
|
|
||||||
assert_eq!(enc2.current_block_id(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn keyframe_boost_uses_higher_ratio() {
|
|
||||||
// Non-keyframe block with nominal ratio 0.2 → ceil(5 * 0.2) = 1 repair.
|
|
||||||
let mut enc_normal = RaptorQFecEncoder::with_defaults(5);
|
|
||||||
enc_normal.set_keyframe_ratio(0.8);
|
|
||||||
for i in 0..5 {
|
|
||||||
enc_normal
|
|
||||||
.add_source_symbol_with_keyframe(&[i as u8; 100], false)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let normal_repair = enc_normal.generate_repair(0.2).unwrap();
|
|
||||||
assert_eq!(normal_repair.len(), 1);
|
|
||||||
|
|
||||||
// Keyframe block with same nominal ratio but boost to 0.8 → ceil(5 * 0.8) = 4 repairs.
|
|
||||||
let mut enc_key = RaptorQFecEncoder::with_defaults(5);
|
|
||||||
enc_key.set_keyframe_ratio(0.8);
|
|
||||||
for i in 0..5 {
|
|
||||||
enc_key
|
|
||||||
.add_source_symbol_with_keyframe(&[i as u8; 100], i == 2)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let keyframe_repair = enc_key.generate_repair(0.2).unwrap();
|
|
||||||
assert_eq!(keyframe_repair.len(), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_keyframe_block_uses_nominal_ratio() {
|
|
||||||
let mut enc = RaptorQFecEncoder::with_defaults(5);
|
|
||||||
enc.set_keyframe_ratio(0.8);
|
|
||||||
|
|
||||||
for i in 0..5 {
|
|
||||||
enc.add_source_symbol_with_keyframe(&[i as u8; 100], false)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let repair = enc.generate_repair(0.2).unwrap();
|
|
||||||
assert_eq!(repair.len(), 1); // ceil(5 * 0.2) = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finalize_clears_keyframe_flag() {
|
|
||||||
let mut enc = RaptorQFecEncoder::with_defaults(2);
|
|
||||||
enc.add_source_symbol_with_keyframe(&[0u8; 10], true)
|
|
||||||
.unwrap();
|
|
||||||
assert!(enc.has_keyframe());
|
|
||||||
|
|
||||||
enc.finalize_block().unwrap();
|
|
||||||
assert!(!enc.has_keyframe());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! rather than one block fatally.
|
//! rather than one block fatally.
|
||||||
|
|
||||||
/// A symbol ready for transmission: (block_id, symbol_index, is_repair, data).
|
/// A symbol ready for transmission: (block_id, symbol_index, is_repair, data).
|
||||||
pub type Symbol = (u16, u16, bool, Vec<u8>);
|
pub type Symbol = (u8, u8, bool, Vec<u8>);
|
||||||
|
|
||||||
/// Temporal interleaver that mixes symbols across multiple FEC blocks.
|
/// Temporal interleaver that mixes symbols across multiple FEC blocks.
|
||||||
pub struct Interleaver {
|
pub struct Interleaver {
|
||||||
@@ -64,13 +64,13 @@ mod tests {
|
|||||||
let interleaver = Interleaver::with_default_depth();
|
let interleaver = Interleaver::with_default_depth();
|
||||||
|
|
||||||
let block_a: Vec<Symbol> = (0..3)
|
let block_a: Vec<Symbol> = (0..3)
|
||||||
.map(|i| (0u16, i as u16, false, vec![0xA0 + i as u8]))
|
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
|
||||||
.collect();
|
.collect();
|
||||||
let block_b: Vec<Symbol> = (0..3)
|
let block_b: Vec<Symbol> = (0..3)
|
||||||
.map(|i| (1u16, i as u16, false, vec![0xB0 + i as u8]))
|
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
|
||||||
.collect();
|
.collect();
|
||||||
let block_c: Vec<Symbol> = (0..3)
|
let block_c: Vec<Symbol> = (0..3)
|
||||||
.map(|i| (2u16, i as u16, false, vec![0xC0 + i as u8]))
|
.map(|i| (2u8, i as u8, false, vec![0xC0 + i as u8]))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let result = interleaver.interleave(&[block_a, block_b, block_c]);
|
let result = interleaver.interleave(&[block_a, block_b, block_c]);
|
||||||
@@ -96,10 +96,10 @@ mod tests {
|
|||||||
let interleaver = Interleaver::new(2);
|
let interleaver = Interleaver::new(2);
|
||||||
|
|
||||||
let block_a: Vec<Symbol> = (0..3)
|
let block_a: Vec<Symbol> = (0..3)
|
||||||
.map(|i| (0u16, i as u16, false, vec![0xA0 + i as u8]))
|
.map(|i| (0u8, i as u8, false, vec![0xA0 + i as u8]))
|
||||||
.collect();
|
.collect();
|
||||||
let block_b: Vec<Symbol> = (0..1)
|
let block_b: Vec<Symbol> = (0..1)
|
||||||
.map(|i| (1u16, i as u16, false, vec![0xB0 + i as u8]))
|
.map(|i| (1u8, i as u8, false, vec![0xB0 + i as u8]))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let result = interleaver.interleave(&[block_a, block_b]);
|
let result = interleaver.interleave(&[block_a, block_b]);
|
||||||
@@ -128,7 +128,7 @@ mod tests {
|
|||||||
let blocks: Vec<Vec<Symbol>> = (0..3)
|
let blocks: Vec<Vec<Symbol>> = (0..3)
|
||||||
.map(|b| {
|
.map(|b| {
|
||||||
(0..6)
|
(0..6)
|
||||||
.map(|i| (b as u16, i as u16, false, vec![b as u8; 10]))
|
.map(|i| (b as u8, i as u8, false, vec![b as u8; 10]))
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -146,10 +146,7 @@ mod tests {
|
|||||||
|
|
||||||
// Each block should lose exactly 2 (6 losses / 3 blocks)
|
// Each block should lose exactly 2 (6 losses / 3 blocks)
|
||||||
for &loss in &losses_per_block {
|
for &loss in &losses_per_block {
|
||||||
assert_eq!(
|
assert_eq!(loss, 2, "Each block should lose at most 2 symbols from a burst of 6");
|
||||||
loss, 2,
|
|
||||||
"Each block should lose at most 2 symbols from a burst of 6"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ pub mod encoder;
|
|||||||
pub mod interleave;
|
pub mod interleave;
|
||||||
|
|
||||||
pub use adaptive::AdaptiveFec;
|
pub use adaptive::AdaptiveFec;
|
||||||
pub use block_manager::{
|
pub use block_manager::{DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState};
|
||||||
DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState,
|
|
||||||
};
|
|
||||||
pub use decoder::RaptorQFecDecoder;
|
pub use decoder::RaptorQFecDecoder;
|
||||||
pub use encoder::RaptorQFecEncoder;
|
pub use encoder::RaptorQFecEncoder;
|
||||||
pub use interleave::Interleaver;
|
pub use interleave::Interleaver;
|
||||||
@@ -26,7 +24,9 @@ pub use interleave::Interleaver;
|
|||||||
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
|
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
|
||||||
|
|
||||||
/// Create an encoder/decoder pair configured for the given quality profile.
|
/// Create an encoder/decoder pair configured for the given quality profile.
|
||||||
pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
|
pub fn create_fec_pair(
|
||||||
|
profile: &QualityProfile,
|
||||||
|
) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
|
||||||
let cfg = AdaptiveFec::from_profile(profile);
|
let cfg = AdaptiveFec::from_profile(profile);
|
||||||
let encoder = cfg.build_encoder();
|
let encoder = cfg.build_encoder();
|
||||||
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);
|
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-native"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
|
|
||||||
|
|
||||||
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
|
|
||||||
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
|
|
||||||
# standalone .so, which is the same path the legacy wzp-android crate uses
|
|
||||||
# successfully on the same phone / same NDK. Keeping the crate-type single
|
|
||||||
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
|
|
||||||
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
|
|
||||||
# archive pulled bionic's internal pthread_create into the final .so.
|
|
||||||
[lib]
|
|
||||||
name = "wzp_native"
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
# cc is SAFE to use here because this crate is a single-cdylib: no
|
|
||||||
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
|
|
||||||
# legacy wzp-android crate uses the same setup and works.
|
|
||||||
cc = "1"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
|
|
||||||
# audio pipeline via extern "C" into the bundled C++ and expose our own
|
|
||||||
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
|
|
||||||
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
|
|
||||||
# instead of calling back into wzp-desktop via callbacks.
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
|
|
||||||
//!
|
|
||||||
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
|
|
||||||
//! work). The crucial distinction: this crate is a single-cdylib (no
|
|
||||||
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
|
|
||||||
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
|
|
||||||
//! UND and resolve against libc.so at runtime, as they should.
|
|
||||||
//!
|
|
||||||
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
|
|
||||||
//! `cargo check --target <host>` still works for IDEs and CI.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
|
|
||||||
if target.contains("android") {
|
|
||||||
// getauxval_fix: override compiler-rt's broken static getauxval
|
|
||||||
// stub that SIGSEGVs in shared libraries.
|
|
||||||
cc::Build::new()
|
|
||||||
.file("cpp/getauxval_fix.c")
|
|
||||||
.compile("wzp_native_getauxval_fix");
|
|
||||||
|
|
||||||
let oboe_dir = fetch_oboe();
|
|
||||||
match oboe_dir {
|
|
||||||
Some(oboe_path) => {
|
|
||||||
println!(
|
|
||||||
"cargo:warning=wzp-native: building with Oboe from {:?}",
|
|
||||||
oboe_path
|
|
||||||
);
|
|
||||||
let mut build = cc::Build::new();
|
|
||||||
build
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
// Shared libc++ — matches legacy wzp-android setup.
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.include("cpp")
|
|
||||||
.include(oboe_path.join("include"))
|
|
||||||
.include(oboe_path.join("src"))
|
|
||||||
.define("WZP_HAS_OBOE", None)
|
|
||||||
.file("cpp/oboe_bridge.cpp");
|
|
||||||
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
|
|
||||||
build.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("cargo:warning=wzp-native: Oboe not found, building stub");
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oboe needs log + OpenSLES backends at runtime.
|
|
||||||
println!("cargo:rustc-link-lib=log");
|
|
||||||
println!("cargo:rustc-link-lib=OpenSLES");
|
|
||||||
|
|
||||||
// Re-run if any cpp file changes
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
|
||||||
} else {
|
|
||||||
// Non-Android hosts: compile the empty stub so lib.rs's extern
|
|
||||||
// declarations resolve when someone runs `cargo check` on macOS
|
|
||||||
// or Linux without an NDK.
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively add all `.cpp` files from a directory to a cc::Build.
|
|
||||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entry in std::fs::read_dir(dir).unwrap() {
|
|
||||||
let entry = entry.unwrap();
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
add_cpp_files_recursive(build, &path);
|
|
||||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
|
||||||
build.file(&path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
|
|
||||||
/// legacy wzp-android crate's build.rs.
|
|
||||||
fn fetch_oboe() -> Option<PathBuf> {
|
|
||||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
|
||||||
let oboe_dir = out_dir.join("oboe");
|
|
||||||
|
|
||||||
if oboe_dir
|
|
||||||
.join("include")
|
|
||||||
.join("oboe")
|
|
||||||
.join("Oboe.h")
|
|
||||||
.exists()
|
|
||||||
{
|
|
||||||
return Some(oboe_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = std::process::Command::new("git")
|
|
||||||
.args([
|
|
||||||
"clone",
|
|
||||||
"--depth=1",
|
|
||||||
"--branch=1.8.1",
|
|
||||||
"https://github.com/google/oboe.git",
|
|
||||||
oboe_dir.to_str().unwrap(),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
|
|
||||||
match status {
|
|
||||||
Ok(s)
|
|
||||||
if s.success()
|
|
||||||
&& oboe_dir
|
|
||||||
.join("include")
|
|
||||||
.join("oboe")
|
|
||||||
.join("Oboe.h")
|
|
||||||
.exists() =>
|
|
||||||
{
|
|
||||||
Some(oboe_dir)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Override the broken static getauxval from compiler-rt/CRT.
|
|
||||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
|
||||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
|
||||||
// This version calls the real bionic getauxval via dlsym.
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
|
||||||
|
|
||||||
unsigned long getauxval(unsigned long type) {
|
|
||||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
|
||||||
if (!real_getauxval) {
|
|
||||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
|
||||||
if (!real_getauxval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return real_getauxval(type);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
// Full Oboe implementation for Android
|
|
||||||
// This file is compiled only when targeting Android
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <oboe/Oboe.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <cstring>
|
|
||||||
#include <atomic>
|
|
||||||
#include <chrono>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
#define LOG_TAG "wzp-oboe"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ring buffer helpers (SPSC, lock-free)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
int32_t avail = w - r;
|
|
||||||
if (avail < 0) avail += capacity;
|
|
||||||
return avail;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
|
||||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
|
||||||
const int16_t* src, int32_t count) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
buf[w] = src[i];
|
|
||||||
w++;
|
|
||||||
if (w >= capacity) w = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
|
||||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
|
||||||
int16_t* dst, int32_t count) {
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
dst[i] = buf[r];
|
|
||||||
r++;
|
|
||||||
if (r >= capacity) r = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
|
||||||
// Value copy — the WzpOboeRings the Rust side passes us lives on the caller's
|
|
||||||
// stack frame and goes away as soon as wzp_oboe_start returns. The raw
|
|
||||||
// int16/atomic pointers INSIDE the struct point into the Rust-owned, leaked-
|
|
||||||
// for-the-lifetime-of-the-process AudioBackend singleton, so copying the
|
|
||||||
// struct by value is safe and keeps the inner pointers valid indefinitely.
|
|
||||||
// g_rings_valid guards the audio-callback-side read; clearing it in stop()
|
|
||||||
// signals "no backend" to the callbacks which then return silence + Stop.
|
|
||||||
static WzpOboeRings g_rings{};
|
|
||||||
static std::atomic<bool> g_rings_valid{false};
|
|
||||||
static std::atomic<bool> g_running{false};
|
|
||||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
|
||||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Capture callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_written = 0;
|
|
||||||
uint64_t ring_full_drops = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_write(g_rings.capture_write_idx,
|
|
||||||
g_rings.capture_read_idx,
|
|
||||||
g_rings.capture_capacity);
|
|
||||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
|
||||||
if (to_write > 0) {
|
|
||||||
ring_write(g_rings.capture_buf, g_rings.capture_capacity,
|
|
||||||
g_rings.capture_write_idx, g_rings.capture_read_idx,
|
|
||||||
src, to_write);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_written += to_write;
|
|
||||||
if (to_write < numFrames) {
|
|
||||||
ring_full_drops += (numFrames - to_write);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample-range probe on the FIRST callback to prove we get real audio
|
|
||||||
if (calls == 0 && numFrames > 0) {
|
|
||||||
int16_t lo = src[0], hi = src[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < numFrames; i++) {
|
|
||||||
if (src[i] < lo) lo = src[i];
|
|
||||||
if (src[i] > hi) hi = src[i];
|
|
||||||
sumsq += (int32_t)src[i] * (int32_t)src[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (int32_t) (numFrames > 0 ? (int32_t)__builtin_sqrt((double)sumsq / (double)numFrames) : 0);
|
|
||||||
LOGI("capture cb#0: numFrames=%d sample_range=[%d..%d] rms=%d to_write=%d",
|
|
||||||
numFrames, lo, hi, rms, to_write);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
LOGI("capture heartbeat: calls=%llu numFrames=%d ring_avail_write=%d to_write=%d full_drops=%llu total_written=%llu",
|
|
||||||
(unsigned long long)calls, numFrames, avail, to_write,
|
|
||||||
(unsigned long long)ring_full_drops, (unsigned long long)total_written);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Playout callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_played_real = 0;
|
|
||||||
uint64_t underrun_frames = 0;
|
|
||||||
uint64_t nonempty_calls = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_read(g_rings.playout_write_idx,
|
|
||||||
g_rings.playout_read_idx,
|
|
||||||
g_rings.playout_capacity);
|
|
||||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
|
||||||
|
|
||||||
if (to_read > 0) {
|
|
||||||
ring_read(g_rings.playout_buf, g_rings.playout_capacity,
|
|
||||||
g_rings.playout_write_idx, g_rings.playout_read_idx,
|
|
||||||
dst, to_read);
|
|
||||||
nonempty_calls++;
|
|
||||||
}
|
|
||||||
// Fill remainder with silence on underrun
|
|
||||||
if (to_read < numFrames) {
|
|
||||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
|
||||||
underrun_frames += (numFrames - to_read);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_played_real += to_read;
|
|
||||||
|
|
||||||
// First callback: log requested config + prove we're being called
|
|
||||||
if (calls == 0) {
|
|
||||||
LOGI("playout cb#0: numFrames=%d ring_avail_read=%d to_read=%d",
|
|
||||||
numFrames, avail, to_read);
|
|
||||||
}
|
|
||||||
// On the first callback that actually has data, log the sample range
|
|
||||||
// so we can tell if the samples coming out of the ring look like real
|
|
||||||
// audio vs constant-zeroes vs garbage.
|
|
||||||
if (to_read > 0 && nonempty_calls == 1) {
|
|
||||||
int16_t lo = dst[0], hi = dst[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < to_read; i++) {
|
|
||||||
if (dst[i] < lo) lo = dst[i];
|
|
||||||
if (dst[i] > hi) hi = dst[i];
|
|
||||||
sumsq += (int32_t)dst[i] * (int32_t)dst[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (to_read > 0) ? (int32_t)__builtin_sqrt((double)sumsq / (double)to_read) : 0;
|
|
||||||
LOGI("playout FIRST nonempty read: to_read=%d sample_range=[%d..%d] rms=%d",
|
|
||||||
to_read, lo, hi, rms);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
int state = (int)stream->getState();
|
|
||||||
auto xrunRes = stream->getXRunCount();
|
|
||||||
int xruns = xrunRes ? xrunRes.value() : -1;
|
|
||||||
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu state=%d xruns=%d",
|
|
||||||
(unsigned long long)calls, (unsigned long long)nonempty_calls,
|
|
||||||
numFrames, avail, to_read,
|
|
||||||
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
|
|
||||||
state, xruns);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static CaptureCallback g_capture_cb;
|
|
||||||
static PlayoutCallback g_playout_cb;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public C API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
if (g_running.load(std::memory_order_relaxed)) {
|
|
||||||
LOGW("wzp_oboe_start: already running");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep-copy the rings struct into static storage BEFORE we publish it to
|
|
||||||
// the audio callbacks — `rings` points at the caller's stack frame and
|
|
||||||
// goes away as soon as this function returns.
|
|
||||||
g_rings = *rings;
|
|
||||||
g_rings_valid.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Build capture stream
|
|
||||||
oboe::AudioStreamBuilder captureBuilder;
|
|
||||||
captureBuilder.setDirection(oboe::Direction::Input)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Shared)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
|
|
||||||
->setDataCallback(&g_capture_cb);
|
|
||||||
|
|
||||||
if (config->bt_active) {
|
|
||||||
// BT SCO mode: do NOT set sample rate or input preset.
|
|
||||||
// Requesting 48kHz against a BT SCO device fails with
|
|
||||||
// "getInputProfile could not find profile". Letting the system
|
|
||||||
// choose the native rate (8/16kHz) and relying on Oboe's
|
|
||||||
// resampler (SampleRateConversionQuality::Best) to bridge
|
|
||||||
// to our 48kHz ring buffer is the only path that works.
|
|
||||||
// InputPreset::VoiceCommunication can also prevent BT SCO
|
|
||||||
// routing on some devices — skip it for BT.
|
|
||||||
LOGI("capture: BT mode — no sample rate or input preset set");
|
|
||||||
} else {
|
|
||||||
captureBuilder.setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setInputPreset(oboe::InputPreset::VoiceCommunication);
|
|
||||||
}
|
|
||||||
|
|
||||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
LOGI("capture stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_capture_stream->getSampleRate(),
|
|
||||||
g_capture_stream->getChannelCount(),
|
|
||||||
(int)g_capture_stream->getFormat(),
|
|
||||||
g_capture_stream->getFramesPerBurst(),
|
|
||||||
g_capture_stream->getFramesPerDataCallback(),
|
|
||||||
g_capture_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_capture_stream->getSharingMode(),
|
|
||||||
(int)g_capture_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
// Build playout stream.
|
|
||||||
//
|
|
||||||
// Regression triangulation between builds:
|
|
||||||
// 96be740 (Usage::Media, default API): playout callback DID drain
|
|
||||||
// the ring at steady 50Hz (playout heartbeat: calls=1100,
|
|
||||||
// total_played_real=1055040). Audio not audible because OS routing
|
|
||||||
// sent it to a silent output.
|
|
||||||
//
|
|
||||||
// 8c36fb5 (Usage::VoiceCommunication + setAudioApi(AAudio) +
|
|
||||||
// ContentType::Speech): playout callback fired cb#0 once then
|
|
||||||
// stopped draining the ring entirely. written_samples stuck at
|
|
||||||
// ring capacity (7679) across all subsequent heartbeats, so Oboe
|
|
||||||
// accepted zero samples after startup. Still inaudible.
|
|
||||||
//
|
|
||||||
// Hypothesis: forcing setAudioApi(AAudio) + VoiceCommunication on
|
|
||||||
// Pixel 6 / Android 15 opens a stream that succeeds at cb#0 but
|
|
||||||
// then detaches from the real audio driver. Reverting to the
|
|
||||||
// config that at least drove callbacks correctly, plus the
|
|
||||||
// Kotlin-side MODE_IN_COMMUNICATION + setSpeakerphoneOn(true)
|
|
||||||
// handled in MainActivity.kt to route audio to the loud speaker.
|
|
||||||
// Usage::VoiceCommunication is the correct Oboe usage for a VoIP app
|
|
||||||
// — it respects Android's in-call audio routing and lets
|
|
||||||
// AudioManager.setSpeakerphoneOn/setBluetoothScoOn actually switch
|
|
||||||
// between earpiece, loudspeaker, and Bluetooth headset. Combined with
|
|
||||||
// MODE_IN_COMMUNICATION set from MainActivity.kt and
|
|
||||||
// speakerphoneOn=false by default, this produces handset/earpiece as
|
|
||||||
// the default output.
|
|
||||||
//
|
|
||||||
// IMPORTANT: do NOT add setAudioApi(AAudio) here. Build 8c36fb5 proved
|
|
||||||
// forcing AAudio with Usage::VoiceCommunication makes the playout
|
|
||||||
// callback stop draining the ring after cb#0, even though the stream
|
|
||||||
// opens successfully. Letting Oboe pick the API (which will be AAudio
|
|
||||||
// on API ≥ 27 but via a different codepath) kept callbacks firing in
|
|
||||||
// every other build.
|
|
||||||
oboe::AudioStreamBuilder playoutBuilder;
|
|
||||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Shared)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
|
|
||||||
->setDataCallback(&g_playout_cb);
|
|
||||||
|
|
||||||
if (config->bt_active) {
|
|
||||||
LOGI("playout: BT mode — no sample rate set, using Usage::Media");
|
|
||||||
// Usage::Media instead of VoiceCommunication for BT output
|
|
||||||
// to avoid conflicts with the communication device routing.
|
|
||||||
playoutBuilder.setUsage(oboe::Usage::Media);
|
|
||||||
} else {
|
|
||||||
playoutBuilder.setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setUsage(oboe::Usage::VoiceCommunication);
|
|
||||||
}
|
|
||||||
|
|
||||||
result = playoutBuilder.openStream(g_playout_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
LOGI("playout stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_playout_stream->getSampleRate(),
|
|
||||||
g_playout_stream->getChannelCount(),
|
|
||||||
(int)g_playout_stream->getFormat(),
|
|
||||||
g_playout_stream->getFramesPerBurst(),
|
|
||||||
g_playout_stream->getFramesPerDataCallback(),
|
|
||||||
g_playout_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_playout_stream->getSharingMode(),
|
|
||||||
(int)g_playout_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
g_running.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Start both streams
|
|
||||||
result = g_capture_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = g_playout_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log initial stream states right after requestStart() returns.
|
|
||||||
// On well-behaved HALs both will already be Started; on others
|
|
||||||
// (Nothing A059) they may still be in Starting state.
|
|
||||||
LOGI("requestStart returned: capture_state=%d playout_state=%d",
|
|
||||||
(int)g_capture_stream->getState(),
|
|
||||||
(int)g_playout_stream->getState());
|
|
||||||
|
|
||||||
// Poll until both streams report Started state, up to 2s timeout.
|
|
||||||
// Some Android HALs (Nothing A059) delay transitioning from Starting
|
|
||||||
// to Started; proceeding before the transition completes causes the
|
|
||||||
// first capture/playout callbacks to be dropped silently.
|
|
||||||
{
|
|
||||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
|
|
||||||
int poll_count = 0;
|
|
||||||
bool streams_started = false;
|
|
||||||
while (std::chrono::steady_clock::now() < deadline) {
|
|
||||||
auto cap_state = g_capture_stream->getState();
|
|
||||||
auto play_state = g_playout_stream->getState();
|
|
||||||
if (cap_state == oboe::StreamState::Started &&
|
|
||||||
play_state == oboe::StreamState::Started) {
|
|
||||||
LOGI("both streams Started after %d polls", poll_count);
|
|
||||||
streams_started = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
poll_count++;
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
||||||
}
|
|
||||||
// Log final state even on timeout (helps diagnose HAL quirks)
|
|
||||||
LOGI("stream states after poll: capture=%d playout=%d (polls=%d)",
|
|
||||||
(int)g_capture_stream->getState(),
|
|
||||||
(int)g_playout_stream->getState(),
|
|
||||||
poll_count);
|
|
||||||
if (!streams_started) {
|
|
||||||
LOGE("Timed out waiting for Oboe streams to reach Started state");
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_rings_valid.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_playout_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
|
||||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
|
|
||||||
// the streams, so any in-flight callback returns Stop instead of reading
|
|
||||||
// stale pointers.
|
|
||||||
g_rings_valid.store(false, std::memory_order_release);
|
|
||||||
|
|
||||||
if (g_capture_stream) {
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
}
|
|
||||||
if (g_playout_stream) {
|
|
||||||
g_playout_stream->requestStop();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
|
||||||
// Provide empty implementations just in case.
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config; (void)rings;
|
|
||||||
return -99;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {}
|
|
||||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
|
||||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
|
||||||
int wzp_oboe_is_running(void) { return 0; }
|
|
||||||
|
|
||||||
#endif // __ANDROID__
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#ifndef WZP_OBOE_BRIDGE_H
|
|
||||||
#define WZP_OBOE_BRIDGE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#include <atomic>
|
|
||||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
|
||||||
extern "C" {
|
|
||||||
#else
|
|
||||||
#include <stdatomic.h>
|
|
||||||
typedef atomic_int wzp_atomic_int;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t sample_rate;
|
|
||||||
int32_t frames_per_burst;
|
|
||||||
int32_t channel_count;
|
|
||||||
int32_t bt_active; /* nonzero = BT SCO mode: skip sample rate + input preset */
|
|
||||||
} WzpOboeConfig;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int16_t* capture_buf;
|
|
||||||
int32_t capture_capacity;
|
|
||||||
wzp_atomic_int* capture_write_idx;
|
|
||||||
wzp_atomic_int* capture_read_idx;
|
|
||||||
|
|
||||||
int16_t* playout_buf;
|
|
||||||
int32_t playout_capacity;
|
|
||||||
wzp_atomic_int* playout_write_idx;
|
|
||||||
wzp_atomic_int* playout_read_idx;
|
|
||||||
} WzpOboeRings;
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
|
||||||
void wzp_oboe_stop(void);
|
|
||||||
float wzp_oboe_capture_latency_ms(void);
|
|
||||||
float wzp_oboe_playout_latency_ms(void);
|
|
||||||
int wzp_oboe_is_running(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // WZP_OBOE_BRIDGE_H
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config;
|
|
||||||
(void)rings;
|
|
||||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
//! wzp-native — standalone Android cdylib for all the C++ audio code.
|
|
||||||
//!
|
|
||||||
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
|
|
||||||
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
|
|
||||||
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
|
|
||||||
//!
|
|
||||||
//! Phase 2: real Oboe audio backend.
|
|
||||||
//!
|
|
||||||
//! Architecture: Oboe runs capture + playout streams on its own high-
|
|
||||||
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
|
|
||||||
//! buffers (capture and playout) are shared between the C++ callbacks
|
|
||||||
//! and the Rust side via atomic indices — no locks on the hot path.
|
|
||||||
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
|
|
||||||
//! the playout ring with decoded PCM.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
|
|
||||||
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
|
|
||||||
|
|
||||||
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
|
|
||||||
/// work before any audio code runs.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_version() -> i32 {
|
|
||||||
42
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
|
|
||||||
/// returns bytes written excluding the NUL.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `out` must be a valid pointer to at least `cap` contiguous bytes of
|
|
||||||
/// writable memory. Passing a null pointer or zero capacity is safe
|
|
||||||
/// (returns 0), but a dangling non-null pointer is undefined behaviour.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
|
|
||||||
const MSG: &[u8] = b"hello from wzp-native\0";
|
|
||||||
if out.is_null() || cap == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let n = MSG.len().min(cap);
|
|
||||||
unsafe {
|
|
||||||
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
|
|
||||||
*out.add(n - 1) = 0;
|
|
||||||
}
|
|
||||||
n - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C++ Oboe bridge FFI ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WzpOboeConfig {
|
|
||||||
sample_rate: i32,
|
|
||||||
frames_per_burst: i32,
|
|
||||||
channel_count: i32,
|
|
||||||
/// When nonzero, capture stream skips setSampleRate and setInputPreset
|
|
||||||
/// so the system can route to BT SCO at its native rate (8/16kHz).
|
|
||||||
/// Oboe's SampleRateConversionQuality::Best resamples to 48kHz.
|
|
||||||
bt_active: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WzpOboeRings {
|
|
||||||
capture_buf: *mut i16,
|
|
||||||
capture_capacity: i32,
|
|
||||||
capture_write_idx: *mut AtomicI32,
|
|
||||||
capture_read_idx: *mut AtomicI32,
|
|
||||||
playout_buf: *mut i16,
|
|
||||||
playout_capacity: i32,
|
|
||||||
playout_write_idx: *mut AtomicI32,
|
|
||||||
playout_read_idx: *mut AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: atomics synchronise producer/consumer; raw pointers are owned
|
|
||||||
// by the AudioBackend singleton below whose lifetime covers all calls.
|
|
||||||
unsafe impl Send for WzpOboeRings {}
|
|
||||||
unsafe impl Sync for WzpOboeRings {}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
|
||||||
fn wzp_oboe_stop();
|
|
||||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_is_running() -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SPSC ring buffer (shared with C++ via AtomicI32) ────────────────────
|
|
||||||
|
|
||||||
/// 20 ms @ 48 kHz mono = 960 samples.
|
|
||||||
const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// ~160 ms headroom at 48 kHz.
|
|
||||||
const RING_CAPACITY: usize = 7680;
|
|
||||||
|
|
||||||
struct RingBuffer {
|
|
||||||
buf: Vec<i16>,
|
|
||||||
capacity: usize,
|
|
||||||
write_idx: AtomicI32,
|
|
||||||
read_idx: AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: SPSC with atomic read/write cursors; producer and consumer
|
|
||||||
// are always on different threads.
|
|
||||||
unsafe impl Send for RingBuffer {}
|
|
||||||
unsafe impl Sync for RingBuffer {}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; capacity],
|
|
||||||
capacity,
|
|
||||||
write_idx: AtomicI32::new(0),
|
|
||||||
read_idx: AtomicI32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn available_read(&self) -> usize {
|
|
||||||
let w = self.write_idx.load(Ordering::Acquire);
|
|
||||||
let r = self.read_idx.load(Ordering::Relaxed);
|
|
||||||
let avail = w - r;
|
|
||||||
if avail < 0 {
|
|
||||||
(avail + self.capacity as i32) as usize
|
|
||||||
} else {
|
|
||||||
avail as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn available_write(&self) -> usize {
|
|
||||||
self.capacity - 1 - self.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(&self, data: &[i16]) -> usize {
|
|
||||||
let count = data.len().min(self.available_write());
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
|
||||||
for sample in &data[..count] {
|
|
||||||
unsafe {
|
|
||||||
*buf_ptr.add(w) = *sample;
|
|
||||||
}
|
|
||||||
w += 1;
|
|
||||||
if w >= cap {
|
|
||||||
w = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.write_idx.store(w as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let count = out.len().min(self.available_read());
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr();
|
|
||||||
for slot in &mut out[..count] {
|
|
||||||
unsafe {
|
|
||||||
*slot = *buf_ptr.add(r);
|
|
||||||
}
|
|
||||||
r += 1;
|
|
||||||
if r >= cap {
|
|
||||||
r = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.read_idx.store(r as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn buf_ptr(&self) -> *mut i16 {
|
|
||||||
self.buf.as_ptr() as *mut i16
|
|
||||||
}
|
|
||||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AudioBackend singleton ──────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// There is one global AudioBackend instance because Oboe's C++ side
|
|
||||||
// holds its own singleton of the streams. The `Box::leak`'d statics own
|
|
||||||
// the ring buffers for the lifetime of the process — dropping them while
|
|
||||||
// Oboe is still running would cause use-after-free in the audio callback.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
struct AudioBackend {
|
|
||||||
capture: RingBuffer,
|
|
||||||
playout: RingBuffer,
|
|
||||||
started: std::sync::Mutex<bool>,
|
|
||||||
/// Per-write logging throttle counter for wzp_native_audio_write_playout.
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64,
|
|
||||||
/// Fix A (task #35): the playout ring's read_idx at the last
|
|
||||||
/// check. If audio_write_playout observes read_idx hasn't
|
|
||||||
/// advanced after N writes, the Oboe playout callback has
|
|
||||||
/// stopped firing → restart the streams.
|
|
||||||
playout_last_read_idx: std::sync::atomic::AtomicI32,
|
|
||||||
/// Number of writes since the last read_idx advance.
|
|
||||||
playout_stall_writes: std::sync::atomic::AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
|
|
||||||
|
|
||||||
fn backend() -> &'static AudioBackend {
|
|
||||||
BACKEND.get_or_init(|| {
|
|
||||||
Box::leak(Box::new(AudioBackend {
|
|
||||||
capture: RingBuffer::new(RING_CAPACITY),
|
|
||||||
playout: RingBuffer::new(RING_CAPACITY),
|
|
||||||
started: std::sync::Mutex::new(false),
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
|
|
||||||
playout_last_read_idx: std::sync::atomic::AtomicI32::new(0),
|
|
||||||
playout_stall_writes: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C FFI for wzp-desktop ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Start the Oboe audio streams. Returns 0 on success, non-zero on error.
|
|
||||||
/// Idempotent — calling while already running is a no-op that returns 0.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_start() -> i32 {
|
|
||||||
audio_start_inner(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start Oboe in Bluetooth SCO mode — skips sample rate and input preset
|
|
||||||
/// on capture so the system can route to the BT SCO device natively.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_start_bt() -> i32 {
|
|
||||||
audio_start_inner(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn audio_start_inner(bt: bool) -> i32 {
|
|
||||||
let b = backend();
|
|
||||||
let mut started = match b.started.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
|
||||||
if *started {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
bt_active: if bt { 1 } else { 0 },
|
|
||||||
};
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: b.capture.buf_ptr(),
|
|
||||||
capture_capacity: b.capture.capacity as i32,
|
|
||||||
capture_write_idx: b.capture.write_idx_ptr(),
|
|
||||||
capture_read_idx: b.capture.read_idx_ptr(),
|
|
||||||
playout_buf: b.playout.buf_ptr(),
|
|
||||||
playout_capacity: b.playout.capacity as i32,
|
|
||||||
playout_write_idx: b.playout.write_idx_ptr(),
|
|
||||||
playout_read_idx: b.playout.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret != 0 {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
*started = true;
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Oboe. Idempotent. Safe to call from any thread.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_stop() {
|
|
||||||
let b = backend();
|
|
||||||
if let Ok(mut started) = b.started.lock() {
|
|
||||||
if *started {
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
*started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of capture samples available to read without blocking.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_capture_available() -> usize {
|
|
||||||
backend().capture.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured PCM samples from the capture ring. Returns the number
|
|
||||||
/// of `i16` samples actually copied into `out` (may be less than
|
|
||||||
/// `out_len` if the ring is empty).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `out` must be a valid pointer to `out_len` contiguous `i16` values.
|
|
||||||
/// The caller must ensure no other thread writes to the same buffer
|
|
||||||
/// concurrently. Passing a null pointer or zero length is safe (returns 0).
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_read_capture(out: *mut i16, out_len: usize) -> usize {
|
|
||||||
if out.is_null() || out_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts_mut(out, out_len) };
|
|
||||||
backend().capture.read(slice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write PCM samples into the playout ring. Returns the number of
|
|
||||||
/// samples actually enqueued (may be less than `in_len` if the ring
|
|
||||||
/// is nearly full — in practice the caller should pace to 20 ms
|
|
||||||
/// frames and spin briefly if the ring is full).
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `input` must be a valid pointer to `in_len` contiguous `i16` values
|
|
||||||
/// that remain valid for the duration of the call. Passing a null pointer
|
|
||||||
/// or zero length is safe (returns 0). The caller must not free or mutate
|
|
||||||
/// the buffer while this function is executing.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_len: usize) -> usize {
|
|
||||||
if input.is_null() || in_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
|
|
||||||
let b = backend();
|
|
||||||
|
|
||||||
// Fix A (task #35): detect playout callback stall. If the
|
|
||||||
// playout ring's read_idx hasn't advanced in 50+ writes
|
|
||||||
// (~1 second at 50 writes/sec), the Oboe playout callback
|
|
||||||
// has stopped firing → restart the streams. This is the
|
|
||||||
// self-healing behavior that makes rejoin work: teardown +
|
|
||||||
// rebuild clears whatever HAL state locked up the callback.
|
|
||||||
let current_read_idx = b
|
|
||||||
.playout
|
|
||||||
.read_idx
|
|
||||||
.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let last_read_idx = b
|
|
||||||
.playout_last_read_idx
|
|
||||||
.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if current_read_idx == last_read_idx {
|
|
||||||
let stall = b
|
|
||||||
.playout_stall_writes
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if stall >= 50 {
|
|
||||||
// Callback hasn't drained anything in ~1 second.
|
|
||||||
// Force a stream restart.
|
|
||||||
unsafe {
|
|
||||||
android_log(
|
|
||||||
"playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
b.playout_stall_writes
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
// Release the started lock, stop, re-start.
|
|
||||||
// This is the same logic as the Rust-side
|
|
||||||
// audio_stop() + audio_start() but done inline
|
|
||||||
// because we can't call the extern "C" fns
|
|
||||||
// recursively. Just call the C++ side directly.
|
|
||||||
{
|
|
||||||
if let Ok(mut started) = b.started.lock() {
|
|
||||||
if *started {
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
*started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear the rings so the restart doesn't read stale data
|
|
||||||
b.playout
|
|
||||||
.write_idx
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
b.playout
|
|
||||||
.read_idx
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
b.capture
|
|
||||||
.write_idx
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
b.capture
|
|
||||||
.read_idx
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
// Re-start (stall detector — always non-BT mode)
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
bt_active: 0,
|
|
||||||
};
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: b.capture.buf_ptr(),
|
|
||||||
capture_capacity: b.capture.capacity as i32,
|
|
||||||
capture_write_idx: b.capture.write_idx_ptr(),
|
|
||||||
capture_read_idx: b.capture.read_idx_ptr(),
|
|
||||||
playout_buf: b.playout.buf_ptr(),
|
|
||||||
playout_capacity: b.playout.capacity as i32,
|
|
||||||
playout_write_idx: b.playout.write_idx_ptr(),
|
|
||||||
playout_read_idx: b.playout.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret == 0 {
|
|
||||||
if let Ok(mut started) = b.started.lock() {
|
|
||||||
*started = true;
|
|
||||||
}
|
|
||||||
unsafe {
|
|
||||||
android_log("playout restart OK — Oboe streams rebuilt");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unsafe {
|
|
||||||
android_log(&format!("playout restart FAILED: {ret}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.playout_last_read_idx
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
return 0; // caller will retry on next frame
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// read_idx advanced — callback is alive, reset counter
|
|
||||||
b.playout_stall_writes
|
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
b.playout_last_read_idx
|
|
||||||
.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let before_w = b
|
|
||||||
.playout
|
|
||||||
.write_idx
|
|
||||||
.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let before_r = b
|
|
||||||
.playout
|
|
||||||
.read_idx
|
|
||||||
.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let written = b.playout.write(slice);
|
|
||||||
// First few writes: log ring state + sample range so we can compare what
|
|
||||||
// engine.rs hands us to what the C++ playout callback reads.
|
|
||||||
let first_writes = b
|
|
||||||
.playout_write_log_count
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if first_writes < 3 || first_writes % 50 == 0 {
|
|
||||||
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
|
|
||||||
for &s in slice.iter() {
|
|
||||||
if s < lo {
|
|
||||||
lo = s;
|
|
||||||
}
|
|
||||||
if s > hi {
|
|
||||||
hi = s;
|
|
||||||
}
|
|
||||||
sumsq += (s as i64) * (s as i64);
|
|
||||||
}
|
|
||||||
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
|
|
||||||
let avail_w_after = b.playout.available_write();
|
|
||||||
let avail_r_after = b.playout.available_read();
|
|
||||||
let msg = format!(
|
|
||||||
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
|
|
||||||
slice.len(),
|
|
||||||
written
|
|
||||||
);
|
|
||||||
unsafe {
|
|
||||||
android_log(msg.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
written
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal android logcat shim so we can print from the cdylib without pulling
|
|
||||||
// in android_logger crate (which would add another dep that has to build with
|
|
||||||
// cargo-ndk). Uses libc's __android_log_print via extern linkage.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn __android_log_write(prio: i32, tag: *const u8, text: *const u8) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe fn android_log(msg: &str) {
|
|
||||||
// ANDROID_LOG_INFO = 4. Tag and text must be NUL-terminated.
|
|
||||||
let tag = b"wzp-native\0";
|
|
||||||
let mut buf = Vec::with_capacity(msg.len() + 1);
|
|
||||||
buf.extend_from_slice(msg.as_bytes());
|
|
||||||
buf.push(0);
|
|
||||||
unsafe {
|
|
||||||
__android_log_write(4, tag.as_ptr(), buf.as_ptr());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
unsafe fn android_log(_msg: &str) {}
|
|
||||||
|
|
||||||
/// Current capture latency reported by Oboe, in milliseconds. Returns
|
|
||||||
/// NaN / 0.0 if the stream isn't running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_capture_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_capture_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current playout latency reported by Oboe, in milliseconds.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_playout_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_playout_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-zero if both Oboe streams are currently running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_is_running() -> i32 {
|
|
||||||
unsafe { wzp_oboe_is_running() }
|
|
||||||
}
|
|
||||||
@@ -20,4 +20,3 @@ tracing = "0.1"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
bincode = "1"
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@
|
|||||||
//! Control (GCC).
|
//! Control (GCC).
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
use std::time::Instant;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use crate::QualityProfile;
|
|
||||||
use crate::packet::QualityReport;
|
use crate::packet::QualityReport;
|
||||||
|
use crate::QualityProfile;
|
||||||
|
|
||||||
/// Network congestion state derived from delay and loss signals.
|
/// Network congestion state derived from delay and loss signals.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -159,16 +158,6 @@ pub struct BandwidthEstimator {
|
|||||||
loss_detector: LossBasedDetector,
|
loss_detector: LossBasedDetector,
|
||||||
/// Last update timestamp.
|
/// Last update timestamp.
|
||||||
last_update: Option<Instant>,
|
last_update: Option<Instant>,
|
||||||
|
|
||||||
// ── Transport-feedback BWE (T2.2) ──
|
|
||||||
/// Congestion-window-derived bandwidth estimate in bits per second.
|
|
||||||
cwnd_bps: AtomicU64,
|
|
||||||
/// Peer REMB (Receiver Estimated Maximum Bitrate) in bits per second.
|
|
||||||
peer_remb_bps: AtomicU64,
|
|
||||||
/// EWMA-smoothed bandwidth estimate in bits per second.
|
|
||||||
smoothed_bps: AtomicU64,
|
|
||||||
/// Last time `smoothed_bps` was updated (UNIX epoch millis).
|
|
||||||
last_smoothed_ms: AtomicU64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multiplicative decrease factor applied on congestion (15% reduction).
|
/// Multiplicative decrease factor applied on congestion (15% reduction).
|
||||||
@@ -190,10 +179,6 @@ impl BandwidthEstimator {
|
|||||||
delay_detector: DelayBasedDetector::new(),
|
delay_detector: DelayBasedDetector::new(),
|
||||||
loss_detector: LossBasedDetector::new(),
|
loss_detector: LossBasedDetector::new(),
|
||||||
last_update: None,
|
last_update: None,
|
||||||
cwnd_bps: AtomicU64::new(0),
|
|
||||||
peer_remb_bps: AtomicU64::new(u64::MAX),
|
|
||||||
smoothed_bps: AtomicU64::new(0),
|
|
||||||
last_smoothed_ms: AtomicU64::new(0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,64 +250,6 @@ impl BandwidthEstimator {
|
|||||||
QualityProfile::CATASTROPHIC
|
QualityProfile::CATASTROPHIC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transport-feedback BWE (T2.2) ──
|
|
||||||
|
|
||||||
/// Update from QUIC path stats.
|
|
||||||
///
|
|
||||||
/// Computes `cwnd_bps = cwnd_bytes * 8 / rtt_s` and feeds it into the
|
|
||||||
/// smoothed estimate.
|
|
||||||
pub fn update_from_path(&self, cwnd_bytes: u64, _bytes_in_flight: u64, rtt_ms: u32) {
|
|
||||||
let rtt_s = rtt_ms.max(1) as f64 / 1000.0;
|
|
||||||
let cwnd_bps = ((cwnd_bytes * 8) as f64 / rtt_s) as u64;
|
|
||||||
self.cwnd_bps.store(cwnd_bps, Relaxed);
|
|
||||||
self.update_smoothed(cwnd_bps);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update from a peer's `TransportFeedback` REMB value.
|
|
||||||
pub fn update_from_peer(&self, fb_remb_bps: u32) {
|
|
||||||
let remb = fb_remb_bps as u64;
|
|
||||||
self.peer_remb_bps.store(remb, Relaxed);
|
|
||||||
self.update_smoothed(remb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Target sending bitrate in bits per second.
|
|
||||||
///
|
|
||||||
/// Returns 90% of the minimum between the congestion-window estimate
|
|
||||||
/// and the peer REMB estimate.
|
|
||||||
pub fn target_send_bps(&self) -> u64 {
|
|
||||||
let cwnd = self.cwnd_bps.load(Relaxed);
|
|
||||||
let remb = self.peer_remb_bps.load(Relaxed);
|
|
||||||
let m = cwnd.min(remb);
|
|
||||||
(m as f64 * 0.9) as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EWMA-smoothed bandwidth estimate in bits per second.
|
|
||||||
pub fn smoothed_bps(&self) -> u64 {
|
|
||||||
self.smoothed_bps.load(Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply EWMA smoothing with a 2-second half-life.
|
|
||||||
fn update_smoothed(&self, new_bps: u64) {
|
|
||||||
let now_ms = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64;
|
|
||||||
let last_ms = self.last_smoothed_ms.load(Relaxed);
|
|
||||||
let dt_ms = now_ms.saturating_sub(last_ms);
|
|
||||||
|
|
||||||
let current = self.smoothed_bps.load(Relaxed);
|
|
||||||
let updated = if current == 0 || dt_ms == 0 {
|
|
||||||
new_bps
|
|
||||||
} else {
|
|
||||||
let alpha = 1.0 - 0.5_f64.powf(dt_ms as f64 / 2000.0);
|
|
||||||
let s = current as f64 * (1.0 - alpha) + new_bps as f64 * alpha;
|
|
||||||
s as u64
|
|
||||||
};
|
|
||||||
|
|
||||||
self.smoothed_bps.store(updated, Relaxed);
|
|
||||||
self.last_smoothed_ms.store(now_ms, Relaxed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -469,7 +396,10 @@ mod tests {
|
|||||||
|
|
||||||
// Below 8 => CATASTROPHIC
|
// Below 8 => CATASTROPHIC
|
||||||
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
|
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
|
||||||
assert_eq!(bwe_cat.recommended_profile(), QualityProfile::CATASTROPHIC);
|
assert_eq!(
|
||||||
|
bwe_cat.recommended_profile(),
|
||||||
|
QualityProfile::CATASTROPHIC
|
||||||
|
);
|
||||||
|
|
||||||
// High bandwidth
|
// High bandwidth
|
||||||
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
|
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
|
||||||
@@ -483,7 +413,7 @@ mod tests {
|
|||||||
// Build a QualityReport with moderate loss and RTT.
|
// Build a QualityReport with moderate loss and RTT.
|
||||||
let report = QualityReport {
|
let report = QualityReport {
|
||||||
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
|
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
|
||||||
rtt_4ms: 25, // 100ms RTT
|
rtt_4ms: 25, // 100ms RTT
|
||||||
jitter_ms: 10,
|
jitter_ms: 10,
|
||||||
bitrate_cap_kbps: 200,
|
bitrate_cap_kbps: 200,
|
||||||
};
|
};
|
||||||
@@ -521,46 +451,4 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(det.is_congested());
|
assert!(det.is_congested());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_send_bps_uses_min_of_cwnd_and_remb() {
|
|
||||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
|
||||||
// cwnd_bps = 100_000, remb = 200_000 → min = 100_000 → 90%
|
|
||||||
bwe.update_from_path(1250, 0, 100); // 1250*8 / 0.1 = 100_000
|
|
||||||
bwe.update_from_peer(200_000);
|
|
||||||
assert_eq!(bwe.target_send_bps(), 90_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_send_bps_with_zero_cwnd_uses_remb() {
|
|
||||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
|
||||||
// Default cwnd is 0, remb is u64::MAX (default).
|
|
||||||
// 0.min(u64::MAX) = 0 → 90% = 0
|
|
||||||
assert_eq!(bwe.target_send_bps(), 0);
|
|
||||||
|
|
||||||
bwe.update_from_peer(100_000);
|
|
||||||
// cwnd still 0
|
|
||||||
assert_eq!(bwe.target_send_bps(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smoothed_bps_ewma_converges() {
|
|
||||||
let bwe = BandwidthEstimator::new(50.0, 2.0, 100.0);
|
|
||||||
bwe.update_from_path(1250, 0, 100); // 100_000 bps
|
|
||||||
let s1 = bwe.smoothed_bps();
|
|
||||||
assert_eq!(s1, 100_000);
|
|
||||||
|
|
||||||
// Immediately update with same value — dt ≈ 0, so should stay at 100_000
|
|
||||||
bwe.update_from_path(1250, 0, 100);
|
|
||||||
let s2 = bwe.smoothed_bps();
|
|
||||||
assert_eq!(s2, 100_000);
|
|
||||||
|
|
||||||
// Sleep a bit so dt is non-zero, then update with a much higher value.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
bwe.update_from_path(12500, 0, 100); // 1_000_000 bps
|
|
||||||
let s3 = bwe.smoothed_bps();
|
|
||||||
assert!(s3 > 100_000, "smoothed should increase toward 1M: {s3}");
|
|
||||||
// With 100ms dt, alpha ≈ 0.03, so smoothed should be ~100k * 0.97 + 1M * 0.03 ≈ 127k
|
|
||||||
assert!(s3 < 500_000, "smoothed should not jump too far: {s3}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
/// Identifies the audio codec and bitrate configuration.
|
/// Identifies the audio codec and bitrate configuration.
|
||||||
///
|
///
|
||||||
/// Encoded as 4 bits in the v1 media packet header, and as a full 8-bit
|
/// Encoded as 4 bits in the media packet header.
|
||||||
/// value in the v2 [`MediaHeaderV2`](crate::MediaHeaderV2).
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum CodecId {
|
pub enum CodecId {
|
||||||
@@ -19,22 +18,6 @@ pub enum CodecId {
|
|||||||
Codec2_1200 = 4,
|
Codec2_1200 = 4,
|
||||||
/// Comfort noise descriptor (silence suppression)
|
/// Comfort noise descriptor (silence suppression)
|
||||||
ComfortNoise = 5,
|
ComfortNoise = 5,
|
||||||
/// Opus at 32kbps (studio low)
|
|
||||||
Opus32k = 6,
|
|
||||||
/// Opus at 48kbps (studio)
|
|
||||||
Opus48k = 7,
|
|
||||||
/// Opus at 64kbps (studio high)
|
|
||||||
Opus64k = 8,
|
|
||||||
/// H.264 baseline profile (video).
|
|
||||||
H264Baseline = 9,
|
|
||||||
// Reserved for video codecs; implementations land in PRD-video-multicodec.
|
|
||||||
// 10 => H264 main
|
|
||||||
// 11 => H265 main
|
|
||||||
// 13 => VP9
|
|
||||||
/// AV1 main profile (video).
|
|
||||||
Av1Main = 12,
|
|
||||||
/// H.265 main profile (video).
|
|
||||||
H265Main = 11,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecId {
|
impl CodecId {
|
||||||
@@ -44,40 +27,30 @@ impl CodecId {
|
|||||||
Self::Opus24k => 24_000,
|
Self::Opus24k => 24_000,
|
||||||
Self::Opus16k => 16_000,
|
Self::Opus16k => 16_000,
|
||||||
Self::Opus6k => 6_000,
|
Self::Opus6k => 6_000,
|
||||||
Self::Opus32k => 32_000,
|
|
||||||
Self::Opus48k => 48_000,
|
|
||||||
Self::Opus64k => 64_000,
|
|
||||||
Self::Codec2_3200 => 3_200,
|
Self::Codec2_3200 => 3_200,
|
||||||
Self::Codec2_1200 => 1_200,
|
Self::Codec2_1200 => 1_200,
|
||||||
Self::ComfortNoise => 0,
|
Self::ComfortNoise => 0,
|
||||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 2_000_000,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred frame duration in milliseconds.
|
/// Preferred frame duration in milliseconds.
|
||||||
pub const fn frame_duration_ms(self) -> u8 {
|
pub const fn frame_duration_ms(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
Self::Opus24k => 20,
|
||||||
|
Self::Opus16k => 20,
|
||||||
Self::Opus6k => 40,
|
Self::Opus6k => 40,
|
||||||
Self::Codec2_3200 => 20,
|
Self::Codec2_3200 => 20,
|
||||||
Self::Codec2_1200 => 40,
|
Self::Codec2_1200 => 40,
|
||||||
Self::ComfortNoise => 20,
|
Self::ComfortNoise => 20,
|
||||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 33,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sample rate expected by this codec.
|
/// Sample rate expected by this codec.
|
||||||
pub const fn sample_rate_hz(self) -> u32 {
|
pub const fn sample_rate_hz(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k
|
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
||||||
| Self::Opus16k
|
|
||||||
| Self::Opus6k
|
|
||||||
| Self::Opus32k
|
|
||||||
| Self::Opus48k
|
|
||||||
| Self::Opus64k => 48_000,
|
|
||||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||||
Self::ComfortNoise => 48_000,
|
Self::ComfortNoise => 48_000,
|
||||||
Self::H264Baseline | Self::H265Main | Self::Av1Main => 48_000,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +63,6 @@ impl CodecId {
|
|||||||
3 => Some(Self::Codec2_3200),
|
3 => Some(Self::Codec2_3200),
|
||||||
4 => Some(Self::Codec2_1200),
|
4 => Some(Self::Codec2_1200),
|
||||||
5 => Some(Self::ComfortNoise),
|
5 => Some(Self::ComfortNoise),
|
||||||
6 => Some(Self::Opus32k),
|
|
||||||
7 => Some(Self::Opus48k),
|
|
||||||
8 => Some(Self::Opus64k),
|
|
||||||
9 => Some(Self::H264Baseline),
|
|
||||||
11 => Some(Self::H265Main),
|
|
||||||
12 => Some(Self::Av1Main),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,24 +71,6 @@ impl CodecId {
|
|||||||
pub const fn to_wire(self) -> u8 {
|
pub const fn to_wire(self) -> u8 {
|
||||||
self as u8
|
self as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this is a video codec variant.
|
|
||||||
pub const fn is_video(self) -> bool {
|
|
||||||
matches!(self, Self::H264Baseline | Self::H265Main | Self::Av1Main)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this is an Opus variant.
|
|
||||||
pub const fn is_opus(self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Opus6k
|
|
||||||
| Self::Opus16k
|
|
||||||
| Self::Opus24k
|
|
||||||
| Self::Opus32k
|
|
||||||
| Self::Opus48k
|
|
||||||
| Self::Opus64k
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the complete quality configuration for a call session.
|
/// Describes the complete quality configuration for a call session.
|
||||||
@@ -135,18 +84,6 @@ pub struct QualityProfile {
|
|||||||
pub frame_duration_ms: u8,
|
pub frame_duration_ms: u8,
|
||||||
/// Number of source frames per FEC block.
|
/// Number of source frames per FEC block.
|
||||||
pub frames_per_block: u8,
|
pub frames_per_block: u8,
|
||||||
/// Bandwidth-allocation priority between audio and video.
|
|
||||||
#[serde(default)]
|
|
||||||
pub priority_mode: crate::PriorityMode,
|
|
||||||
/// Target video bitrate in kbps (set by quality controller, not handshake).
|
|
||||||
#[serde(default)]
|
|
||||||
pub video_bitrate_kbps: Option<u32>,
|
|
||||||
/// Target video resolution as (width, height).
|
|
||||||
#[serde(default)]
|
|
||||||
pub video_resolution: Option<(u16, u16)>,
|
|
||||||
/// Target video frame rate.
|
|
||||||
#[serde(default)]
|
|
||||||
pub video_fps: Option<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QualityProfile {
|
impl QualityProfile {
|
||||||
@@ -156,10 +93,6 @@ impl QualityProfile {
|
|||||||
fec_ratio: 0.2,
|
fec_ratio: 0.2,
|
||||||
frame_duration_ms: 20,
|
frame_duration_ms: 20,
|
||||||
frames_per_block: 5,
|
frames_per_block: 5,
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Degraded conditions: Opus 6kbps, moderate FEC.
|
/// Degraded conditions: Opus 6kbps, moderate FEC.
|
||||||
@@ -168,10 +101,6 @@ impl QualityProfile {
|
|||||||
fec_ratio: 0.5,
|
fec_ratio: 0.5,
|
||||||
frame_duration_ms: 40,
|
frame_duration_ms: 40,
|
||||||
frames_per_block: 10,
|
frames_per_block: 10,
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Catastrophic conditions: Codec2 1.2kbps, heavy FEC.
|
/// Catastrophic conditions: Codec2 1.2kbps, heavy FEC.
|
||||||
@@ -180,46 +109,6 @@ impl QualityProfile {
|
|||||||
fec_ratio: 1.0,
|
fec_ratio: 1.0,
|
||||||
frame_duration_ms: 40,
|
frame_duration_ms: 40,
|
||||||
frames_per_block: 8,
|
frames_per_block: 8,
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Studio low: Opus 32kbps, minimal FEC.
|
|
||||||
pub const STUDIO_32K: Self = Self {
|
|
||||||
codec: CodecId::Opus32k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Studio: Opus 48kbps, minimal FEC.
|
|
||||||
pub const STUDIO_48K: Self = Self {
|
|
||||||
codec: CodecId::Opus48k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Studio high: Opus 64kbps, minimal FEC.
|
|
||||||
pub const STUDIO_64K: Self = Self {
|
|
||||||
codec: CodecId::Opus64k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
priority_mode: crate::PriorityMode::AudioFirst,
|
|
||||||
video_bitrate_kbps: None,
|
|
||||||
video_resolution: None,
|
|
||||||
video_fps: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||||
@@ -228,46 +117,3 @@ impl QualityProfile {
|
|||||||
base * (1.0 + self.fec_ratio)
|
base * (1.0 + self.fec_ratio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{CodecId, QualityProfile};
|
|
||||||
use crate::PriorityMode;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn codec_id_unknown_values_rejected() {
|
|
||||||
for v in [10u8, 13].iter().copied().chain(14u8..=255) {
|
|
||||||
assert!(CodecId::from_wire(v).is_none(), "v={v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn h265_main_roundtrips() {
|
|
||||||
assert_eq!(CodecId::H265Main.to_wire(), 11);
|
|
||||||
assert_eq!(CodecId::from_wire(11), Some(CodecId::H265Main));
|
|
||||||
assert!(CodecId::H265Main.is_video());
|
|
||||||
assert_eq!(CodecId::H265Main.bitrate_bps(), 2_000_000);
|
|
||||||
assert_eq!(CodecId::H265Main.frame_duration_ms(), 33);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn av1_main_roundtrips() {
|
|
||||||
assert_eq!(CodecId::Av1Main.to_wire(), 12);
|
|
||||||
assert_eq!(CodecId::from_wire(12), Some(CodecId::Av1Main));
|
|
||||||
assert!(CodecId::Av1Main.is_video());
|
|
||||||
assert_eq!(CodecId::Av1Main.bitrate_bps(), 2_000_000);
|
|
||||||
assert_eq!(CodecId::Av1Main.frame_duration_ms(), 33);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quality_profile_backward_compat_old_json() {
|
|
||||||
// Old JSON emitted before T5.1 has no priority_mode or video fields.
|
|
||||||
let old_json =
|
|
||||||
r#"{"codec":"Opus24k","fec_ratio":0.2,"frame_duration_ms":20,"frames_per_block":5}"#;
|
|
||||||
let parsed: QualityProfile = serde_json::from_str(old_json).unwrap();
|
|
||||||
assert_eq!(parsed.priority_mode, PriorityMode::AudioFirst);
|
|
||||||
assert_eq!(parsed.video_bitrate_kbps, None);
|
|
||||||
assert_eq!(parsed.video_resolution, None);
|
|
||||||
assert_eq!(parsed.video_fps, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
//! Continuous DRED tuning from real-time network metrics.
|
|
||||||
//!
|
|
||||||
//! Instead of locking DRED duration to 3 discrete quality tiers (100/200/500 ms),
|
|
||||||
//! `DredTuner` maps live path quality metrics to a continuous DRED duration and
|
|
||||||
//! expected-loss hint, updated every N packets. This makes DRED reactive within
|
|
||||||
//! ~200 ms instead of waiting for 3+ consecutive bad quality reports to trigger
|
|
||||||
//! a full tier transition.
|
|
||||||
//!
|
|
||||||
//! The tuner also implements pre-emptive jitter-spike detection ("sawtooth"
|
|
||||||
//! prediction): when jitter variance spikes >30% over a 200 ms window — typical
|
|
||||||
//! of Starlink satellite handovers — it temporarily boosts DRED to the maximum
|
|
||||||
//! allowed for the current codec before packets actually start dropping.
|
|
||||||
//!
|
|
||||||
//! See also: [`crate::quality`] for discrete tier classification that drives
|
|
||||||
//! codec switching. DredTuner operates within a tier, adjusting DRED
|
|
||||||
//! parameters continuously based on live network metrics.
|
|
||||||
|
|
||||||
use crate::CodecId;
|
|
||||||
|
|
||||||
/// Output of a single tuning cycle.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct DredTuning {
|
|
||||||
/// DRED duration in 10 ms frame units (0–104). Passed directly to
|
|
||||||
/// `OpusEncoder::set_dred_duration()`.
|
|
||||||
pub dred_frames: u8,
|
|
||||||
/// Expected packet loss percentage (0–100). Passed to
|
|
||||||
/// `OpusEncoder::set_expected_loss()`. Floored at 15% by the encoder
|
|
||||||
/// itself, but we pass the real value so the encoder can override upward.
|
|
||||||
pub expected_loss_pct: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimum DRED frames for any Opus codec (matches DRED_LOSS_FLOOR_PCT logic:
|
|
||||||
/// at 15% loss, libopus 1.5 emits ~95 ms of DRED, which needs at least 10
|
|
||||||
/// frames configured to be useful).
|
|
||||||
const MIN_DRED_FRAMES: u8 = 5;
|
|
||||||
|
|
||||||
/// Maximum DRED frames libopus supports (104 × 10 ms = 1040 ms).
|
|
||||||
const MAX_DRED_FRAMES: u8 = 104;
|
|
||||||
|
|
||||||
/// Jitter variance spike ratio that triggers pre-emptive DRED boost.
|
|
||||||
const JITTER_SPIKE_RATIO: f32 = 1.3;
|
|
||||||
|
|
||||||
/// How many tuning cycles a jitter-spike boost persists (at 25 packets/cycle
|
|
||||||
/// and 20 ms/packet, 10 cycles ≈ 5 seconds).
|
|
||||||
const SPIKE_BOOST_COOLDOWN_CYCLES: u32 = 10;
|
|
||||||
|
|
||||||
/// Maps codec tier to its baseline DRED frames (used when network is healthy).
|
|
||||||
fn baseline_dred_frames(codec: CodecId) -> u8 {
|
|
||||||
match codec {
|
|
||||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
|
|
||||||
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
|
|
||||||
CodecId::Opus6k => 50, // 500 ms
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps codec tier to its maximum allowed DRED frames under spike/bad conditions.
|
|
||||||
fn max_dred_frames_for(codec: CodecId) -> u8 {
|
|
||||||
match codec {
|
|
||||||
// Studio: cap at 300 ms (don't waste bitrate on good links)
|
|
||||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 30,
|
|
||||||
// Normal: cap at 500 ms
|
|
||||||
CodecId::Opus16k | CodecId::Opus24k => 50,
|
|
||||||
// Degraded: allow full 1040 ms
|
|
||||||
CodecId::Opus6k => MAX_DRED_FRAMES,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Continuous DRED tuner driven by network path metrics.
|
|
||||||
pub struct DredTuner {
|
|
||||||
/// Current codec (determines baseline and ceiling).
|
|
||||||
codec: CodecId,
|
|
||||||
/// Last computed tuning output.
|
|
||||||
last_tuning: DredTuning,
|
|
||||||
/// EWMA-smoothed jitter for spike detection (in ms).
|
|
||||||
jitter_ewma: f32,
|
|
||||||
/// Remaining cooldown cycles for a jitter-spike boost.
|
|
||||||
spike_cooldown: u32,
|
|
||||||
/// Whether the tuner has received at least one observation.
|
|
||||||
initialized: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DredTuner {
|
|
||||||
/// Create a new tuner for the given codec.
|
|
||||||
pub fn new(codec: CodecId) -> Self {
|
|
||||||
let baseline = baseline_dred_frames(codec);
|
|
||||||
Self {
|
|
||||||
codec,
|
|
||||||
last_tuning: DredTuning {
|
|
||||||
dred_frames: baseline,
|
|
||||||
expected_loss_pct: 15, // match DRED_LOSS_FLOOR_PCT
|
|
||||||
},
|
|
||||||
jitter_ewma: 0.0,
|
|
||||||
spike_cooldown: 0,
|
|
||||||
initialized: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the active codec (e.g. on tier transition). Resets spike state.
|
|
||||||
pub fn set_codec(&mut self, codec: CodecId) {
|
|
||||||
self.codec = codec;
|
|
||||||
self.spike_cooldown = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed network metrics and compute new DRED parameters.
|
|
||||||
///
|
|
||||||
/// Call this every tuning cycle (e.g. every 25 packets ≈ 500 ms at 20 ms
|
|
||||||
/// frame duration).
|
|
||||||
///
|
|
||||||
/// - `loss_pct`: observed packet loss (0.0–100.0)
|
|
||||||
/// - `rtt_ms`: smoothed round-trip time
|
|
||||||
/// - `jitter_ms`: current jitter estimate (RTT variance)
|
|
||||||
///
|
|
||||||
/// Returns `Some(tuning)` if the output changed, `None` if unchanged.
|
|
||||||
pub fn update(&mut self, loss_pct: f32, rtt_ms: u32, jitter_ms: u32) -> Option<DredTuning> {
|
|
||||||
if !self.codec.is_opus() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseline = baseline_dred_frames(self.codec);
|
|
||||||
let ceiling = max_dred_frames_for(self.codec);
|
|
||||||
|
|
||||||
// --- Jitter spike detection ---
|
|
||||||
let jitter_f = jitter_ms as f32;
|
|
||||||
if !self.initialized {
|
|
||||||
self.jitter_ewma = jitter_f;
|
|
||||||
self.initialized = true;
|
|
||||||
} else {
|
|
||||||
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
|
|
||||||
let alpha = if jitter_f > self.jitter_ewma {
|
|
||||||
0.3
|
|
||||||
} else {
|
|
||||||
0.05
|
|
||||||
};
|
|
||||||
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect spike: instantaneous jitter > EWMA × 1.3
|
|
||||||
if self.jitter_ewma > 1.0 && jitter_f > self.jitter_ewma * JITTER_SPIKE_RATIO {
|
|
||||||
self.spike_cooldown = SPIKE_BOOST_COOLDOWN_CYCLES;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrement cooldown
|
|
||||||
if self.spike_cooldown > 0 {
|
|
||||||
self.spike_cooldown -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Compute DRED frames ---
|
|
||||||
let dred_frames = if self.spike_cooldown > 0 {
|
|
||||||
// During spike boost: jump to ceiling
|
|
||||||
ceiling
|
|
||||||
} else {
|
|
||||||
// Continuous mapping: scale linearly between baseline and ceiling
|
|
||||||
// based on loss percentage.
|
|
||||||
// 0% loss → baseline
|
|
||||||
// 40% loss → ceiling
|
|
||||||
let loss_clamped = loss_pct.clamp(0.0, 40.0);
|
|
||||||
let t = loss_clamped / 40.0;
|
|
||||||
let raw = baseline as f32 + t * (ceiling - baseline) as f32;
|
|
||||||
(raw as u8).clamp(MIN_DRED_FRAMES, ceiling)
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Compute expected loss hint ---
|
|
||||||
// Pass the real loss so the encoder can clamp at its own floor (15%).
|
|
||||||
// For RTT-driven boost: high RTT suggests impending loss, so add a
|
|
||||||
// phantom loss contribution to keep DRED emitting generously.
|
|
||||||
let rtt_loss_phantom = if rtt_ms > 200 {
|
|
||||||
((rtt_ms - 200) as f32 / 40.0).min(15.0)
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let expected_loss = (loss_pct + rtt_loss_phantom).clamp(0.0, 100.0) as u8;
|
|
||||||
|
|
||||||
let tuning = DredTuning {
|
|
||||||
dred_frames,
|
|
||||||
expected_loss_pct: expected_loss,
|
|
||||||
};
|
|
||||||
|
|
||||||
if tuning != self.last_tuning {
|
|
||||||
self.last_tuning = tuning;
|
|
||||||
Some(tuning)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the last computed tuning without updating.
|
|
||||||
pub fn current(&self) -> DredTuning {
|
|
||||||
self.last_tuning
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a jitter-spike boost is currently active.
|
|
||||||
pub fn spike_boost_active(&self) -> bool {
|
|
||||||
self.spike_cooldown > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn baseline_for_opus24k() {
|
|
||||||
let tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
assert_eq!(tuner.current().dred_frames, 20); // 200 ms
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn baseline_for_opus6k() {
|
|
||||||
let tuner = DredTuner::new(CodecId::Opus6k);
|
|
||||||
assert_eq!(tuner.current().dred_frames, 50); // 500 ms
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn codec2_returns_none() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
|
|
||||||
assert!(tuner.update(10.0, 100, 20).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scales_with_loss() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// 0% loss → baseline (20 frames)
|
|
||||||
tuner.update(0.0, 50, 5);
|
|
||||||
assert_eq!(tuner.current().dred_frames, 20);
|
|
||||||
|
|
||||||
// 20% loss → midpoint between 20 and 50 = 35
|
|
||||||
tuner.update(20.0, 50, 5);
|
|
||||||
assert_eq!(tuner.current().dred_frames, 35);
|
|
||||||
|
|
||||||
// 40%+ loss → ceiling (50 frames)
|
|
||||||
tuner.update(40.0, 50, 5);
|
|
||||||
assert_eq!(tuner.current().dred_frames, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn jitter_spike_triggers_boost() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// Establish baseline jitter
|
|
||||||
for _ in 0..20 {
|
|
||||||
tuner.update(0.0, 50, 10);
|
|
||||||
}
|
|
||||||
assert!(!tuner.spike_boost_active());
|
|
||||||
|
|
||||||
// Spike: jitter jumps to 50 ms (5x the EWMA of ~10)
|
|
||||||
tuner.update(0.0, 50, 50);
|
|
||||||
assert!(tuner.spike_boost_active());
|
|
||||||
// Should be at ceiling (50 frames = 500 ms for Opus24k)
|
|
||||||
assert_eq!(tuner.current().dred_frames, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spike_cooldown_decays() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// Establish baseline then spike
|
|
||||||
for _ in 0..20 {
|
|
||||||
tuner.update(0.0, 50, 10);
|
|
||||||
}
|
|
||||||
tuner.update(0.0, 50, 50);
|
|
||||||
assert!(tuner.spike_boost_active());
|
|
||||||
|
|
||||||
// Run through cooldown
|
|
||||||
for _ in 0..SPIKE_BOOST_COOLDOWN_CYCLES {
|
|
||||||
tuner.update(0.0, 50, 10);
|
|
||||||
}
|
|
||||||
assert!(!tuner.spike_boost_active());
|
|
||||||
// Should return to baseline
|
|
||||||
assert_eq!(tuner.current().dred_frames, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rtt_phantom_loss() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// High RTT (400ms) with 0% real loss
|
|
||||||
tuner.update(0.0, 400, 10);
|
|
||||||
// Phantom loss = (400-200)/40 = 5
|
|
||||||
assert_eq!(tuner.current().expected_loss_pct, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_codec_resets_spike() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// Trigger spike
|
|
||||||
for _ in 0..20 {
|
|
||||||
tuner.update(0.0, 50, 10);
|
|
||||||
}
|
|
||||||
tuner.update(0.0, 50, 50);
|
|
||||||
assert!(tuner.spike_boost_active());
|
|
||||||
|
|
||||||
// Switch codec — spike should reset
|
|
||||||
tuner.set_codec(CodecId::Opus6k);
|
|
||||||
assert!(!tuner.spike_boost_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opus6k_reaches_max_1040ms() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus6k);
|
|
||||||
|
|
||||||
// High loss → should reach 104 frames (1040 ms)
|
|
||||||
tuner.update(40.0, 50, 5);
|
|
||||||
assert_eq!(tuner.current().dred_frames, MAX_DRED_FRAMES);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_when_unchanged() {
|
|
||||||
let mut tuner = DredTuner::new(CodecId::Opus24k);
|
|
||||||
|
|
||||||
// First update always returns Some (initial → computed)
|
|
||||||
let first = tuner.update(0.0, 50, 5);
|
|
||||||
// Same inputs → None
|
|
||||||
let second = tuner.update(0.0, 50, 5);
|
|
||||||
assert!(first.is_some() || second.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ pub enum CryptoError {
|
|||||||
#[error("rekey failed: {0}")]
|
#[error("rekey failed: {0}")]
|
||||||
RekeyFailed(String),
|
RekeyFailed(String),
|
||||||
#[error("anti-replay: duplicate or old packet (seq={seq})")]
|
#[error("anti-replay: duplicate or old packet (seq={seq})")]
|
||||||
ReplayDetected { seq: u32 },
|
ReplayDetected { seq: u16 },
|
||||||
#[error("internal crypto error: {0}")]
|
#[error("internal crypto error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@@ -53,15 +53,6 @@ pub enum TransportError {
|
|||||||
Timeout { ms: u64 },
|
Timeout { ms: u64 },
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
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}")]
|
#[error("internal transport error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ impl AdaptivePlayoutDelay {
|
|||||||
let jitter = (actual_delta - expected_delta).abs();
|
let jitter = (actual_delta - expected_delta).abs();
|
||||||
|
|
||||||
// Spike detection: check before EMA update
|
// Spike detection: check before EMA update
|
||||||
if self.jitter_ema > 0.0 && jitter > self.jitter_ema * self.spike_threshold_multiplier {
|
if self.jitter_ema > 0.0
|
||||||
|
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
||||||
|
{
|
||||||
self.spike_detected_at = Some(Instant::now());
|
self.spike_detected_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +107,10 @@ impl AdaptivePlayoutDelay {
|
|||||||
self.target_delay = self.max_delay;
|
self.target_delay = self.max_delay;
|
||||||
} else {
|
} else {
|
||||||
// Convert jitter estimate to target delay in packets
|
// Convert jitter estimate to target delay in packets
|
||||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
let raw_target =
|
||||||
self.target_delay = (raw_target as usize).clamp(self.min_delay, self.max_delay);
|
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
||||||
|
self.target_delay =
|
||||||
|
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +162,9 @@ impl AdaptivePlayoutDelay {
|
|||||||
/// Manages packet reordering, gap detection, and signals when PLC is needed.
|
/// Manages packet reordering, gap detection, and signals when PLC is needed.
|
||||||
pub struct JitterBuffer {
|
pub struct JitterBuffer {
|
||||||
/// Packets waiting to be consumed, ordered by sequence number.
|
/// Packets waiting to be consumed, ordered by sequence number.
|
||||||
buffer: BTreeMap<u32, MediaPacket>,
|
buffer: BTreeMap<u16, MediaPacket>,
|
||||||
/// Next sequence number expected for playout.
|
/// Next sequence number expected for playout.
|
||||||
next_playout_seq: u32,
|
next_playout_seq: u16,
|
||||||
/// Maximum buffer depth in number of packets.
|
/// Maximum buffer depth in number of packets.
|
||||||
max_depth: usize,
|
max_depth: usize,
|
||||||
/// Target buffer depth (adaptive, based on jitter).
|
/// Target buffer depth (adaptive, based on jitter).
|
||||||
@@ -200,7 +204,7 @@ pub enum PlayoutResult {
|
|||||||
/// A packet is available for playout.
|
/// A packet is available for playout.
|
||||||
Packet(MediaPacket),
|
Packet(MediaPacket),
|
||||||
/// The expected packet is missing — decoder should generate PLC.
|
/// The expected packet is missing — decoder should generate PLC.
|
||||||
Missing { seq: u32 },
|
Missing { seq: u16 },
|
||||||
/// Buffer is empty or not yet filled to target depth.
|
/// Buffer is empty or not yet filled to target depth.
|
||||||
NotReady,
|
NotReady,
|
||||||
}
|
}
|
||||||
@@ -269,30 +273,10 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out).
|
// Check if packet is too old (already played out)
|
||||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
|
||||||
// federation room — reset instead of dropping.
|
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
self.stats.packets_late += 1;
|
||||||
tracing::warn!(
|
return;
|
||||||
seq,
|
|
||||||
next = self.next_playout_seq,
|
|
||||||
backward_distance,
|
|
||||||
"jitter: backward seq detected"
|
|
||||||
);
|
|
||||||
if backward_distance > 100 {
|
|
||||||
tracing::info!(
|
|
||||||
seq,
|
|
||||||
next = self.next_playout_seq,
|
|
||||||
"jitter: RESET — new sender detected"
|
|
||||||
);
|
|
||||||
self.buffer.clear();
|
|
||||||
self.next_playout_seq = seq;
|
|
||||||
self.stats.packets_late = 0;
|
|
||||||
} else {
|
|
||||||
self.stats.packets_late += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
@@ -428,30 +412,10 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out).
|
// Check if packet is too old (already played out)
|
||||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
|
||||||
// federation room — reset instead of dropping.
|
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
self.stats.packets_late += 1;
|
||||||
tracing::warn!(
|
return;
|
||||||
seq,
|
|
||||||
next = self.next_playout_seq,
|
|
||||||
backward_distance,
|
|
||||||
"jitter: backward seq detected"
|
|
||||||
);
|
|
||||||
if backward_distance > 100 {
|
|
||||||
tracing::info!(
|
|
||||||
seq,
|
|
||||||
next = self.next_playout_seq,
|
|
||||||
"jitter: RESET — new sender detected"
|
|
||||||
);
|
|
||||||
self.buffer.clear();
|
|
||||||
self.next_playout_seq = seq;
|
|
||||||
self.stats.packets_late = 0;
|
|
||||||
} else {
|
|
||||||
self.stats.packets_late += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
@@ -503,7 +467,7 @@ impl JitterBuffer {
|
|||||||
|
|
||||||
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
|
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
|
||||||
/// Returns true if `a` comes before `b` in sequence space.
|
/// Returns true if `a` comes before `b` in sequence space.
|
||||||
fn seq_before(a: u32, b: u32) -> bool {
|
fn seq_before(a: u16, b: u16) -> bool {
|
||||||
let diff = b.wrapping_sub(a);
|
let diff = b.wrapping_sub(a);
|
||||||
diff > 0 && diff < 0x8000
|
diff > 0 && diff < 0x8000
|
||||||
}
|
}
|
||||||
@@ -511,23 +475,24 @@ fn seq_before(a: u32, b: u32) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::CodecId;
|
|
||||||
use crate::MediaType;
|
|
||||||
use crate::packet::{MediaHeader, MediaPacket};
|
use crate::packet::{MediaHeader, MediaPacket};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use crate::CodecId;
|
||||||
|
|
||||||
fn make_packet(seq: u32) -> MediaPacket {
|
fn make_packet(seq: u16) -> MediaPacket {
|
||||||
MediaPacket {
|
MediaPacket {
|
||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
version: 2,
|
version: 0,
|
||||||
flags: 0,
|
is_repair: false,
|
||||||
media_type: MediaType::Audio,
|
|
||||||
codec_id: CodecId::Opus24k,
|
codec_id: CodecId::Opus24k,
|
||||||
stream_id: 0,
|
has_quality_report: false,
|
||||||
fec_ratio: 0,
|
fec_ratio_encoded: 0,
|
||||||
seq,
|
seq,
|
||||||
timestamp: seq * 20,
|
timestamp: seq as u32 * 20,
|
||||||
fec_block: 0,
|
fec_block: 0,
|
||||||
|
fec_symbol: 0,
|
||||||
|
reserved: 0,
|
||||||
|
csrc_count: 0,
|
||||||
},
|
},
|
||||||
payload: Bytes::from(vec![0u8; 60]),
|
payload: Bytes::from(vec![0u8; 60]),
|
||||||
quality_report: None,
|
quality_report: None,
|
||||||
@@ -611,7 +576,7 @@ mod tests {
|
|||||||
fn seq_before_wrapping() {
|
fn seq_before_wrapping() {
|
||||||
assert!(seq_before(0, 1));
|
assert!(seq_before(0, 1));
|
||||||
assert!(seq_before(65534, 65535));
|
assert!(seq_before(65534, 65535));
|
||||||
assert!(seq_before(u32::MAX, 0)); // wrap
|
assert!(seq_before(65535, 0)); // wrap
|
||||||
assert!(!seq_before(1, 0));
|
assert!(!seq_before(1, 0));
|
||||||
assert!(!seq_before(5, 5)); // equal
|
assert!(!seq_before(5, 5)); // equal
|
||||||
}
|
}
|
||||||
@@ -813,7 +778,7 @@ mod tests {
|
|||||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||||
|
|
||||||
// Push packets with consistent timing
|
// Push packets with consistent timing
|
||||||
for i in 0u32..20 {
|
for i in 0u16..20 {
|
||||||
let pkt = make_packet(i);
|
let pkt = make_packet(i);
|
||||||
let arrival_ms = i as u64 * 20;
|
let arrival_ms = i as u64 * 20;
|
||||||
jb.push_with_arrival(pkt, arrival_ms);
|
jb.push_with_arrival(pkt, arrival_ms);
|
||||||
|
|||||||
@@ -14,28 +14,21 @@
|
|||||||
|
|
||||||
pub mod bandwidth;
|
pub mod bandwidth;
|
||||||
pub mod codec_id;
|
pub mod codec_id;
|
||||||
pub mod dred_tuner;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
pub mod media_type;
|
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
pub mod priority_mode;
|
|
||||||
pub mod quality;
|
pub mod quality;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
// Re-export key types at crate root for convenience.
|
// Re-export key types at crate root for convenience.
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
|
||||||
pub use codec_id::{CodecId, QualityProfile};
|
pub use codec_id::{CodecId, QualityProfile};
|
||||||
pub use dred_tuner::{DredTuner, DredTuning};
|
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use media_type::MediaType;
|
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV2,
|
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||||
MediaPacket, MiniFrameContext, MiniFrameContextV2, MiniHeader, MiniHeaderV2, PresenceUser,
|
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, default_signal_version,
|
|
||||||
};
|
};
|
||||||
pub use priority_mode::PriorityMode;
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||||
pub use session::{Session, SessionEvent, SessionState};
|
pub use session::{Session, SessionEvent, SessionState};
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Media stream type carried in a v2 [`MediaHeaderV2`](crate::MediaHeaderV2).
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum MediaType {
|
|
||||||
/// Encoded speech / music (Opus, Codec2, ComfortNoise).
|
|
||||||
Audio = 0,
|
|
||||||
/// Encoded video access unit (H.264, H.265, AV1; PRD-video-multicodec).
|
|
||||||
Video = 1,
|
|
||||||
/// Opaque payload not interpreted by the relay (reserved).
|
|
||||||
Data = 2,
|
|
||||||
/// In-band control message carried on the media plane (reserved).
|
|
||||||
Control = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaType {
|
|
||||||
/// Encode to the wire byte representation (`self as u8`).
|
|
||||||
pub const fn to_wire(self) -> u8 {
|
|
||||||
self as u8
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode from a wire byte. Returns `None` for values outside 0..=3.
|
|
||||||
pub const fn from_wire(v: u8) -> Option<Self> {
|
|
||||||
match v {
|
|
||||||
0 => Some(Self::Audio),
|
|
||||||
1 => Some(Self::Video),
|
|
||||||
2 => Some(Self::Data),
|
|
||||||
3 => Some(Self::Control),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn media_type_roundtrip() {
|
|
||||||
for mt in [
|
|
||||||
MediaType::Audio,
|
|
||||||
MediaType::Video,
|
|
||||||
MediaType::Data,
|
|
||||||
MediaType::Control,
|
|
||||||
] {
|
|
||||||
assert_eq!(MediaType::from_wire(mt.to_wire()), Some(mt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn media_type_unknown_rejected() {
|
|
||||||
for v in 4u8..=255 {
|
|
||||||
assert!(MediaType::from_wire(v).is_none(), "v={v}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
|||||||
//! Priority mode for bandwidth allocation between audio and video.
|
|
||||||
//!
|
|
||||||
//! See `docs/PRD/PRD-video-quality-priority.md` for the full design.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Bandwidth-allocation policy between audio and video.
|
|
||||||
///
|
|
||||||
/// Carried on [`QualityProfile`](crate::QualityProfile) and mutable at
|
|
||||||
/// runtime via [`SignalMessage::SetPriorityMode`](crate::SignalMessage).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
||||||
pub enum PriorityMode {
|
|
||||||
/// Audio gets its floor first; video gets the remainder.
|
|
||||||
/// Default for voice/video calls.
|
|
||||||
#[default]
|
|
||||||
AudioFirst,
|
|
||||||
/// Video gets its floor first; audio degrades to Opus 16k floor.
|
|
||||||
VideoFirst,
|
|
||||||
/// Audio clamped to 16 kbps (intelligible speech); video gets remainder.
|
|
||||||
/// Falls back to slide mode when bandwidth drops below SD floor.
|
|
||||||
ScreenShare,
|
|
||||||
/// Proportional split (~15 % audio, ~85 % video).
|
|
||||||
Balanced,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn priority_mode_default_is_audio_first() {
|
|
||||||
assert_eq!(PriorityMode::default(), PriorityMode::AudioFirst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,24 @@
|
|||||||
//! See also: [`crate::dred_tuner`] for continuous DRED tuning within a tier.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::BandwidthEstimator;
|
|
||||||
use crate::QualityProfile;
|
|
||||||
use crate::packet::QualityReport;
|
use crate::packet::QualityReport;
|
||||||
use crate::traits::QualityController;
|
use crate::traits::QualityController;
|
||||||
|
use crate::QualityProfile;
|
||||||
|
|
||||||
/// Network quality tier — drives codec and FEC selection.
|
/// Network quality tier — drives codec and FEC selection.
|
||||||
///
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
/// 5-tier range from studio quality down to catastrophic:
|
|
||||||
/// Studio64k > Studio48k > Studio32k > Good > Degraded > Catastrophic
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub enum Tier {
|
pub enum Tier {
|
||||||
/// loss >= 15% OR RTT >= 200ms — Codec2 1.2k
|
/// loss < 10%, RTT < 400ms
|
||||||
Catastrophic = 0,
|
Good,
|
||||||
/// loss < 15% AND RTT < 200ms — Opus 6k
|
/// loss 10-40% OR RTT 400-600ms
|
||||||
Degraded = 1,
|
Degraded,
|
||||||
/// loss < 5% AND RTT < 100ms — Opus 24k
|
/// loss > 40% OR RTT > 600ms
|
||||||
Good = 2,
|
Catastrophic,
|
||||||
/// loss < 2% AND RTT < 80ms — Opus 32k
|
|
||||||
Studio32k = 3,
|
|
||||||
/// loss < 1% AND RTT < 50ms — Opus 48k
|
|
||||||
Studio48k = 4,
|
|
||||||
/// loss < 1% AND RTT < 30ms — Opus 64k
|
|
||||||
Studio64k = 5,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tier {
|
impl Tier {
|
||||||
pub fn profile(self) -> QualityProfile {
|
pub fn profile(self) -> QualityProfile {
|
||||||
match self {
|
match self {
|
||||||
Self::Studio64k => QualityProfile::STUDIO_64K,
|
|
||||||
Self::Studio48k => QualityProfile::STUDIO_48K,
|
|
||||||
Self::Studio32k => QualityProfile::STUDIO_32K,
|
|
||||||
Self::Good => QualityProfile::GOOD,
|
Self::Good => QualityProfile::GOOD,
|
||||||
Self::Degraded => QualityProfile::DEGRADED,
|
Self::Degraded => QualityProfile::DEGRADED,
|
||||||
Self::Catastrophic => QualityProfile::CATASTROPHIC,
|
Self::Catastrophic => QualityProfile::CATASTROPHIC,
|
||||||
@@ -55,7 +39,7 @@ impl Tier {
|
|||||||
NetworkContext::CellularLte
|
NetworkContext::CellularLte
|
||||||
| NetworkContext::Cellular5g
|
| NetworkContext::Cellular5g
|
||||||
| NetworkContext::Cellular3g => {
|
| NetworkContext::Cellular3g => {
|
||||||
// Tighter thresholds for cellular — no studio tiers
|
// Tighter thresholds for cellular networks
|
||||||
if loss > 25.0 || rtt > 500 {
|
if loss > 25.0 || rtt > 500 {
|
||||||
Self::Catastrophic
|
Self::Catastrophic
|
||||||
} else if loss > 8.0 || rtt > 300 {
|
} else if loss > 8.0 || rtt > 300 {
|
||||||
@@ -65,18 +49,13 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
NetworkContext::WiFi | NetworkContext::Unknown => {
|
NetworkContext::WiFi | NetworkContext::Unknown => {
|
||||||
if loss >= 15.0 || rtt >= 200 {
|
// Original thresholds
|
||||||
|
if loss > 40.0 || rtt > 600 {
|
||||||
Self::Catastrophic
|
Self::Catastrophic
|
||||||
} else if loss >= 5.0 || rtt >= 100 {
|
} else if loss > 10.0 || rtt > 400 {
|
||||||
Self::Degraded
|
Self::Degraded
|
||||||
} else if loss >= 2.0 || rtt >= 80 {
|
|
||||||
Self::Good
|
|
||||||
} else if loss >= 1.0 || rtt >= 50 {
|
|
||||||
Self::Studio32k
|
|
||||||
} else if rtt >= 30 {
|
|
||||||
Self::Studio48k
|
|
||||||
} else {
|
} else {
|
||||||
Self::Studio64k
|
Self::Good
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,32 +64,29 @@ impl Tier {
|
|||||||
/// Return the next lower (worse) tier, or None if already at the worst.
|
/// Return the next lower (worse) tier, or None if already at the worst.
|
||||||
pub fn downgrade(self) -> Option<Tier> {
|
pub fn downgrade(self) -> Option<Tier> {
|
||||||
match self {
|
match self {
|
||||||
Self::Studio64k => Some(Self::Studio48k),
|
|
||||||
Self::Studio48k => Some(Self::Studio32k),
|
|
||||||
Self::Studio32k => Some(Self::Good),
|
|
||||||
Self::Good => Some(Self::Degraded),
|
Self::Good => Some(Self::Degraded),
|
||||||
Self::Degraded => Some(Self::Catastrophic),
|
Self::Degraded => Some(Self::Catastrophic),
|
||||||
Self::Catastrophic => None,
|
Self::Catastrophic => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this is a studio tier (above Good).
|
|
||||||
pub fn is_studio(self) -> bool {
|
|
||||||
matches!(self, Self::Studio64k | Self::Studio48k | Self::Studio32k)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the network transport type for context-aware quality decisions.
|
/// Describes the network transport type for context-aware quality decisions.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum NetworkContext {
|
pub enum NetworkContext {
|
||||||
WiFi,
|
WiFi,
|
||||||
CellularLte,
|
CellularLte,
|
||||||
Cellular5g,
|
Cellular5g,
|
||||||
Cellular3g,
|
Cellular3g,
|
||||||
#[default]
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for NetworkContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||||
///
|
///
|
||||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||||
@@ -132,50 +108,20 @@ pub struct AdaptiveQualityController {
|
|||||||
fec_boost_until: Option<Instant>,
|
fec_boost_until: Option<Instant>,
|
||||||
/// FEC boost amount to add during handoff recovery window.
|
/// FEC boost amount to add during handoff recovery window.
|
||||||
fec_boost_amount: f32,
|
fec_boost_amount: f32,
|
||||||
/// Probing state: when Some, we're actively testing a higher tier.
|
|
||||||
probe: Option<ProbeState>,
|
|
||||||
/// Time spent stable at the current tier (for probe trigger).
|
|
||||||
stable_since: Option<Instant>,
|
|
||||||
/// Optional bandwidth estimator for BWE-guarded upgrades.
|
|
||||||
bwe: Option<Arc<BandwidthEstimator>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Threshold for downgrading (fast reaction to degradation).
|
/// Threshold for downgrading (fast reaction to degradation).
|
||||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||||
/// Threshold for downgrading on cellular networks (even faster).
|
/// Threshold for downgrading on cellular networks (even faster).
|
||||||
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
||||||
/// Threshold for upgrading from Catastrophic/Degraded to Good.
|
/// Threshold for upgrading (slow, cautious improvement).
|
||||||
const UPGRADE_THRESHOLD: u32 = 5;
|
const UPGRADE_THRESHOLD: u32 = 10;
|
||||||
/// Threshold for upgrading into studio tiers (very conservative).
|
|
||||||
const STUDIO_UPGRADE_THRESHOLD: u32 = 10;
|
|
||||||
/// Maximum history window size.
|
/// Maximum history window size.
|
||||||
const HISTORY_SIZE: usize = 20;
|
const HISTORY_SIZE: usize = 20;
|
||||||
/// Default FEC boost amount during handoff recovery.
|
/// Default FEC boost amount during handoff recovery.
|
||||||
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
||||||
/// Duration of FEC boost after a network handoff.
|
/// Duration of FEC boost after a network handoff.
|
||||||
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
||||||
/// Minimum time stable at current tier before probing upward (30 seconds).
|
|
||||||
const PROBE_STABLE_SECS: u64 = 30;
|
|
||||||
/// Duration of a probe window (5 seconds — ~25 quality reports at 1/s).
|
|
||||||
const PROBE_DURATION_SECS: u64 = 5;
|
|
||||||
/// Maximum bad reports during probe before aborting (1 out of ~5 = 20%).
|
|
||||||
const PROBE_MAX_BAD: u32 = 1;
|
|
||||||
/// Cooldown after a failed probe before trying again (60 seconds).
|
|
||||||
const PROBE_COOLDOWN_SECS: u64 = 60;
|
|
||||||
|
|
||||||
/// Active bandwidth probe state.
|
|
||||||
struct ProbeState {
|
|
||||||
/// The tier we're probing (one step above current).
|
|
||||||
target_tier: Tier,
|
|
||||||
/// Profile to apply during probe.
|
|
||||||
target_profile: QualityProfile,
|
|
||||||
/// When the probe started.
|
|
||||||
started: Instant,
|
|
||||||
/// Reports observed during probe.
|
|
||||||
probe_reports: u32,
|
|
||||||
/// Bad reports during probe (loss/RTT exceeded target tier thresholds).
|
|
||||||
bad_reports: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdaptiveQualityController {
|
impl AdaptiveQualityController {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -189,9 +135,6 @@ impl AdaptiveQualityController {
|
|||||||
network_context: NetworkContext::default(),
|
network_context: NetworkContext::default(),
|
||||||
fec_boost_until: None,
|
fec_boost_until: None,
|
||||||
fec_boost_amount: DEFAULT_FEC_BOOST,
|
fec_boost_amount: DEFAULT_FEC_BOOST,
|
||||||
probe: None,
|
|
||||||
stable_since: None,
|
|
||||||
bwe: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +174,6 @@ impl AdaptiveQualityController {
|
|||||||
self.forced = false;
|
self.forced = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any active probe
|
|
||||||
self.probe = None;
|
|
||||||
self.stable_since = None;
|
|
||||||
|
|
||||||
// Activate FEC boost for any network change
|
// Activate FEC boost for any network change
|
||||||
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
||||||
}
|
}
|
||||||
@@ -255,19 +194,6 @@ impl AdaptiveQualityController {
|
|||||||
pub fn reset_counters(&mut self) {
|
pub fn reset_counters(&mut self) {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
self.consecutive_down = 0;
|
self.consecutive_down = 0;
|
||||||
self.probe = None;
|
|
||||||
self.stable_since = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a bandwidth estimator for BWE-guarded tier transitions.
|
|
||||||
pub fn set_bandwidth_estimator(&mut self, bwe: Arc<BandwidthEstimator>) {
|
|
||||||
self.bwe = Some(bwe);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the bitrate ceiling (in bps) for a given tier, including FEC overhead.
|
|
||||||
fn tier_ceiling_bps(tier: Tier) -> u64 {
|
|
||||||
let kbps = tier.profile().total_bitrate_kbps();
|
|
||||||
(kbps * 1000.0) as u64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the effective downgrade threshold based on network context.
|
/// Get the effective downgrade threshold based on network context.
|
||||||
@@ -287,13 +213,16 @@ impl AdaptiveQualityController {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_worse = observed_tier < self.current_tier;
|
let is_worse = match (self.current_tier, observed_tier) {
|
||||||
|
(Tier::Good, Tier::Degraded | Tier::Catastrophic) => true,
|
||||||
|
(Tier::Degraded, Tier::Catastrophic) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
if is_worse {
|
if is_worse {
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
self.consecutive_down += 1;
|
self.consecutive_down += 1;
|
||||||
if self.consecutive_down >= self.downgrade_threshold() {
|
if self.consecutive_down >= self.downgrade_threshold() {
|
||||||
// Jump directly to the observed tier (don't step one-at-a-time on downgrade)
|
|
||||||
self.current_tier = observed_tier;
|
self.current_tier = observed_tier;
|
||||||
self.current_profile = observed_tier.profile();
|
self.current_profile = observed_tier.profile();
|
||||||
self.consecutive_down = 0;
|
self.consecutive_down = 0;
|
||||||
@@ -303,123 +232,22 @@ impl AdaptiveQualityController {
|
|||||||
// Better conditions
|
// Better conditions
|
||||||
self.consecutive_down = 0;
|
self.consecutive_down = 0;
|
||||||
self.consecutive_up += 1;
|
self.consecutive_up += 1;
|
||||||
// Studio tiers require more consecutive good reports
|
if self.consecutive_up >= UPGRADE_THRESHOLD {
|
||||||
let threshold = if self.current_tier >= Tier::Good {
|
|
||||||
STUDIO_UPGRADE_THRESHOLD
|
|
||||||
} else {
|
|
||||||
UPGRADE_THRESHOLD
|
|
||||||
};
|
|
||||||
if self.consecutive_up >= threshold {
|
|
||||||
// Only upgrade one step at a time
|
// Only upgrade one step at a time
|
||||||
if let Some(next_tier) = self.upgrade_one_step() {
|
let next_tier = match self.current_tier {
|
||||||
// BWE guard: require 130% headroom over target tier bitrate
|
Tier::Catastrophic => Tier::Degraded,
|
||||||
if let Some(ref bwe) = self.bwe {
|
Tier::Degraded => Tier::Good,
|
||||||
let required = (Self::tier_ceiling_bps(next_tier) * 130) / 100;
|
Tier::Good => return None,
|
||||||
if bwe.target_send_bps() < required {
|
};
|
||||||
// Insufficient bandwidth — reset counter to prevent flapping
|
self.current_tier = next_tier;
|
||||||
self.consecutive_up = 0;
|
self.current_profile = next_tier.profile();
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.current_tier = next_tier;
|
|
||||||
self.current_profile = next_tier.profile();
|
|
||||||
self.consecutive_up = 0;
|
|
||||||
return Some(self.current_profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether to start, continue, or conclude a bandwidth probe.
|
|
||||||
///
|
|
||||||
/// Called from `observe()` when no hysteresis transition fired.
|
|
||||||
fn check_probe(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
|
||||||
// Don't probe if forced, or if already at highest tier, or on cellular
|
|
||||||
if self.forced || self.current_tier == Tier::Studio64k {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
self.network_context,
|
|
||||||
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
|
||||||
) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have an active probe, evaluate it
|
|
||||||
if let Some(ref mut probe) = self.probe {
|
|
||||||
probe.probe_reports += 1;
|
|
||||||
|
|
||||||
// Check if the observed tier meets the probe target
|
|
||||||
if observed_tier < probe.target_tier {
|
|
||||||
probe.bad_reports += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe failed: too many bad reports
|
|
||||||
if probe.bad_reports > PROBE_MAX_BAD {
|
|
||||||
let _failed_probe = self.probe.take();
|
|
||||||
// Reset stable_since to trigger cooldown
|
|
||||||
self.stable_since = Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
|
|
||||||
return None; // stay at current tier
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe succeeded: enough good reports within the window
|
|
||||||
if probe.started.elapsed() >= Duration::from_secs(PROBE_DURATION_SECS) {
|
|
||||||
let target = probe.target_tier;
|
|
||||||
let profile = probe.target_profile;
|
|
||||||
self.probe.take();
|
|
||||||
self.current_tier = target;
|
|
||||||
self.current_profile = profile;
|
|
||||||
self.consecutive_up = 0;
|
self.consecutive_up = 0;
|
||||||
self.stable_since = Some(Instant::now());
|
return Some(self.current_profile);
|
||||||
return Some(profile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return None; // probe still running
|
|
||||||
}
|
|
||||||
|
|
||||||
// No active probe — check if we should start one
|
|
||||||
if observed_tier >= self.current_tier {
|
|
||||||
// Track stability
|
|
||||||
if self.stable_since.is_none() {
|
|
||||||
self.stable_since = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(stable_since) = self.stable_since {
|
|
||||||
if stable_since.elapsed() >= Duration::from_secs(PROBE_STABLE_SECS) {
|
|
||||||
// Stable long enough — start probing
|
|
||||||
if let Some(next) = self.upgrade_one_step() {
|
|
||||||
self.probe = Some(ProbeState {
|
|
||||||
target_tier: next,
|
|
||||||
target_profile: next.profile(),
|
|
||||||
started: Instant::now(),
|
|
||||||
probe_reports: 0,
|
|
||||||
bad_reports: 0,
|
|
||||||
});
|
|
||||||
// Return the probe profile so the encoder switches
|
|
||||||
return Some(next.profile());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Conditions degraded — reset stability timer
|
|
||||||
self.stable_since = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upgrade_one_step(&self) -> Option<Tier> {
|
|
||||||
match self.current_tier {
|
|
||||||
Tier::Catastrophic => Some(Tier::Degraded),
|
|
||||||
Tier::Degraded => Some(Tier::Good),
|
|
||||||
Tier::Good => Some(Tier::Studio32k),
|
|
||||||
Tier::Studio32k => Some(Tier::Studio48k),
|
|
||||||
Tier::Studio48k => Some(Tier::Studio64k),
|
|
||||||
Tier::Studio64k => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AdaptiveQualityController {
|
impl Default for AdaptiveQualityController {
|
||||||
@@ -441,17 +269,7 @@ impl QualityController for AdaptiveQualityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let observed = Tier::classify_with_context(report, self.network_context);
|
let observed = Tier::classify_with_context(report, self.network_context);
|
||||||
|
self.try_transition(observed)
|
||||||
// First check for downgrades/upgrades via hysteresis
|
|
||||||
if let Some(profile) = self.try_transition(observed) {
|
|
||||||
// Cancel any active probe on tier change
|
|
||||||
self.probe.take();
|
|
||||||
self.stable_since = None;
|
|
||||||
return Some(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check probing
|
|
||||||
self.check_probe(observed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn force_profile(&mut self, profile: QualityProfile) {
|
fn force_profile(&mut self, profile: QualityProfile) {
|
||||||
@@ -513,33 +331,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||||
|
|
||||||
// 4 good reports — not enough (threshold is 5)
|
// 9 good reports — not enough
|
||||||
let good = make_report(0.5, 20); // studio-quality report
|
let good = make_report(2.0, 100);
|
||||||
for _ in 0..4 {
|
for _ in 0..9 {
|
||||||
assert!(ctrl.observe(&good).is_none());
|
assert!(ctrl.observe(&good).is_none());
|
||||||
}
|
}
|
||||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
||||||
|
|
||||||
// 5th good report triggers upgrade (one step: Catastrophic → Degraded)
|
// 10th good report triggers upgrade (one step: Catastrophic → Degraded)
|
||||||
let result = ctrl.observe(&good);
|
let result = ctrl.observe(&good);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||||
|
|
||||||
// Another 5 to go from Degraded → Good
|
// Need another 10 to go from Degraded → Good
|
||||||
for _ in 0..4 {
|
|
||||||
assert!(ctrl.observe(&good).is_none());
|
|
||||||
}
|
|
||||||
let result = ctrl.observe(&good);
|
|
||||||
assert!(result.is_some());
|
|
||||||
assert_eq!(ctrl.tier(), Tier::Good);
|
|
||||||
|
|
||||||
// Studio upgrades need 10 consecutive — Good → Studio32k
|
|
||||||
for _ in 0..9 {
|
for _ in 0..9 {
|
||||||
assert!(ctrl.observe(&good).is_none());
|
assert!(ctrl.observe(&good).is_none());
|
||||||
}
|
}
|
||||||
let result = ctrl.observe(&good);
|
let result = ctrl.observe(&good);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert_eq!(ctrl.tier(), Tier::Studio32k);
|
assert_eq!(ctrl.tier(), Tier::Good);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -554,78 +364,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bwe_guard_blocks_upgrade_when_bandwidth_insufficient() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
|
|
||||||
// Force to catastrophic
|
|
||||||
let bad = make_report(50.0, 300);
|
|
||||||
for _ in 0..3 {
|
|
||||||
ctrl.observe(&bad);
|
|
||||||
}
|
|
||||||
assert_eq!(ctrl.tier(), Tier::Catastrophic);
|
|
||||||
|
|
||||||
// Attach a BWE with very low headroom.
|
|
||||||
// Degraded tier needs 6kbps * 1.5 FEC = 9kbps → 130% = 11.7kbps.
|
|
||||||
// Set target_send_bps ≈ 9_000 (below 11_700 threshold).
|
|
||||||
let bwe = Arc::new(BandwidthEstimator::new(1000.0, 1.0, 100_000.0));
|
|
||||||
bwe.update_from_path(1_000_000, 0, 10); // high cwnd
|
|
||||||
bwe.update_from_peer(10_000); // low remb → target = 9_000
|
|
||||||
ctrl.set_bandwidth_estimator(bwe.clone());
|
|
||||||
|
|
||||||
let good = make_report(0.5, 20);
|
|
||||||
for _ in 0..5 {
|
|
||||||
assert!(
|
|
||||||
ctrl.observe(&good).is_none(),
|
|
||||||
"upgrade should be blocked by low BWE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
ctrl.tier(),
|
|
||||||
Tier::Catastrophic,
|
|
||||||
"should remain at Catastrophic"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Raise BWE well above the 130% threshold
|
|
||||||
bwe.update_from_peer(100_000); // target ≈ 90_000 bps
|
|
||||||
|
|
||||||
// Counter was reset, need another 5 good reports
|
|
||||||
for _ in 0..4 {
|
|
||||||
assert!(ctrl.observe(&good).is_none());
|
|
||||||
}
|
|
||||||
let result = ctrl.observe(&good);
|
|
||||||
assert!(
|
|
||||||
result.is_some(),
|
|
||||||
"upgrade should proceed with sufficient BWE"
|
|
||||||
);
|
|
||||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tier_classification() {
|
fn tier_classification() {
|
||||||
// Studio tiers
|
assert_eq!(Tier::classify(&make_report(5.0, 200)), Tier::Good);
|
||||||
assert_eq!(Tier::classify(&make_report(0.5, 20)), Tier::Studio64k);
|
assert_eq!(Tier::classify(&make_report(15.0, 200)), Tier::Degraded);
|
||||||
assert_eq!(Tier::classify(&make_report(0.5, 40)), Tier::Studio48k);
|
assert_eq!(Tier::classify(&make_report(5.0, 500)), Tier::Degraded);
|
||||||
assert_eq!(Tier::classify(&make_report(1.5, 60)), Tier::Studio32k);
|
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||||
// Good/Degraded/Catastrophic
|
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||||
assert_eq!(Tier::classify(&make_report(3.0, 90)), Tier::Good);
|
|
||||||
assert_eq!(Tier::classify(&make_report(6.0, 120)), Tier::Degraded);
|
|
||||||
assert_eq!(Tier::classify(&make_report(16.0, 120)), Tier::Catastrophic);
|
|
||||||
assert_eq!(Tier::classify(&make_report(5.0, 200)), Tier::Catastrophic);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn studio_tier_boundaries() {
|
|
||||||
// loss < 1% AND RTT < 30ms → Studio64k
|
|
||||||
assert_eq!(Tier::classify(&make_report(0.9, 28)), Tier::Studio64k);
|
|
||||||
// loss < 1% AND RTT 30-49ms → Studio48k
|
|
||||||
assert_eq!(Tier::classify(&make_report(0.9, 32)), Tier::Studio48k);
|
|
||||||
// loss < 2% AND RTT < 80ms → Studio32k (but loss >= 1%)
|
|
||||||
assert_eq!(Tier::classify(&make_report(1.5, 40)), Tier::Studio32k);
|
|
||||||
// loss >= 2% → Good (use 2.5 to survive u8 quantization)
|
|
||||||
assert_eq!(Tier::classify(&make_report(2.5, 40)), Tier::Good);
|
|
||||||
// RTT 80ms → Good
|
|
||||||
assert_eq!(Tier::classify(&make_report(0.5, 80)), Tier::Good);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -634,8 +379,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cellular_tighter_thresholds() {
|
fn cellular_tighter_thresholds() {
|
||||||
// 9% loss: Degraded on both WiFi (>=5%) and cellular (>=8%)
|
// 12% loss: Good on WiFi, Degraded on cellular
|
||||||
let report = make_report(9.0, 80);
|
let report = make_report(12.0, 200);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
Tier::Degraded
|
Tier::Degraded
|
||||||
@@ -645,22 +390,22 @@ mod tests {
|
|||||||
Tier::Degraded
|
Tier::Degraded
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6% loss, low RTT: Degraded on WiFi (>=5%), Good on cellular (<8%)
|
// 9% loss: Good on WiFi, Degraded on cellular
|
||||||
let report = make_report(6.0, 80);
|
let report = make_report(9.0, 200);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
Tier::Degraded
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Good
|
Tier::Good
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||||
|
Tier::Degraded
|
||||||
|
);
|
||||||
|
|
||||||
// 30% loss: Catastrophic on WiFi (>=15%), Catastrophic on cellular (>=25%)
|
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
||||||
let report = make_report(30.0, 80);
|
let report = make_report(30.0, 200);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
Tier::Catastrophic
|
Tier::Degraded
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
||||||
@@ -670,30 +415,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cellular_rtt_thresholds() {
|
fn cellular_rtt_thresholds() {
|
||||||
// RTT 150ms: Degraded on WiFi (>=100ms), Good on cellular (<300ms and loss<8%)
|
// RTT 350ms: Good on WiFi, Degraded on cellular
|
||||||
let report = make_report(2.0, 148);
|
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||||
|
Tier::Good
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||||
Tier::Degraded
|
Tier::Degraded
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Good
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cellular_no_studio_tiers() {
|
|
||||||
// Even with perfect network, cellular stays at Good (no studio)
|
|
||||||
let report = make_report(0.0, 10);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
|
||||||
Tier::Good
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
|
||||||
Tier::Studio64k
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -738,9 +469,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tier_downgrade() {
|
fn tier_downgrade() {
|
||||||
assert_eq!(Tier::Studio64k.downgrade(), Some(Tier::Studio48k));
|
|
||||||
assert_eq!(Tier::Studio48k.downgrade(), Some(Tier::Studio32k));
|
|
||||||
assert_eq!(Tier::Studio32k.downgrade(), Some(Tier::Good));
|
|
||||||
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
||||||
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
||||||
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
||||||
@@ -750,103 +478,4 @@ mod tests {
|
|||||||
fn network_context_default() {
|
fn network_context_default() {
|
||||||
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// Bandwidth probing tests
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn probe_triggers_after_stable_period() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
let excellent = make_report(0.3, 20); // would classify as Studio64k
|
|
||||||
|
|
||||||
// Starts at Good. Fast-forward stability by setting stable_since directly.
|
|
||||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
|
||||||
|
|
||||||
// One excellent report should trigger a probe (Good → Studio32k)
|
|
||||||
let result = ctrl.observe(&excellent);
|
|
||||||
assert!(result.is_some(), "should start probe after 30s stable");
|
|
||||||
assert!(ctrl.probe.is_some(), "probe should be active");
|
|
||||||
assert_eq!(ctrl.probe.as_ref().unwrap().target_tier, Tier::Studio32k);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn probe_succeeds_after_window() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
|
||||||
|
|
||||||
let excellent = make_report(0.3, 20);
|
|
||||||
|
|
||||||
// Trigger probe start
|
|
||||||
let result = ctrl.observe(&excellent);
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Simulate probe window elapsed by backdating started
|
|
||||||
ctrl.probe.as_mut().unwrap().started =
|
|
||||||
Instant::now() - Duration::from_secs(PROBE_DURATION_SECS);
|
|
||||||
|
|
||||||
// Next good report should finalize the probe
|
|
||||||
let result = ctrl.observe(&excellent);
|
|
||||||
assert!(result.is_some(), "probe should succeed");
|
|
||||||
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
|
||||||
assert!(ctrl.probe.is_none(), "probe should be cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn probe_fails_on_bad_reports() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
// Put controller at Studio32k, pretend we've been stable
|
|
||||||
ctrl.current_tier = Tier::Studio32k;
|
|
||||||
ctrl.current_profile = Tier::Studio32k.profile();
|
|
||||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(31));
|
|
||||||
|
|
||||||
// Start a probe to Studio48k
|
|
||||||
let excellent = make_report(0.3, 20);
|
|
||||||
let result = ctrl.observe(&excellent);
|
|
||||||
assert!(result.is_some()); // probe started
|
|
||||||
assert_eq!(ctrl.probe.as_ref().unwrap().target_tier, Tier::Studio48k);
|
|
||||||
|
|
||||||
// Feed bad reports (loss too high for Studio48k)
|
|
||||||
let degraded = make_report(3.0, 100);
|
|
||||||
ctrl.observe(°raded); // first bad
|
|
||||||
ctrl.observe(°raded); // second bad — exceeds PROBE_MAX_BAD (1)
|
|
||||||
|
|
||||||
// Probe should be cancelled
|
|
||||||
assert!(
|
|
||||||
ctrl.probe.is_none(),
|
|
||||||
"probe should be cancelled after bad reports"
|
|
||||||
);
|
|
||||||
// Should still be at Studio32k (not upgraded)
|
|
||||||
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_probe_on_cellular() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
|
||||||
ctrl.current_tier = Tier::Good;
|
|
||||||
ctrl.current_profile = Tier::Good.profile();
|
|
||||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(60));
|
|
||||||
|
|
||||||
let good = make_report(0.5, 40);
|
|
||||||
let result = ctrl.observe(&good);
|
|
||||||
// Should NOT probe on cellular
|
|
||||||
assert!(ctrl.probe.is_none(), "should not probe on cellular");
|
|
||||||
assert!(result.is_none() || ctrl.current_tier == Tier::Good);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_probe_at_highest_tier() {
|
|
||||||
let mut ctrl = AdaptiveQualityController::new();
|
|
||||||
ctrl.current_tier = Tier::Studio64k;
|
|
||||||
ctrl.current_profile = Tier::Studio64k.profile();
|
|
||||||
ctrl.stable_since = Some(Instant::now() - Duration::from_secs(60));
|
|
||||||
|
|
||||||
let excellent = make_report(0.1, 10);
|
|
||||||
let result = ctrl.observe(&excellent);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"should not probe when already at Studio64k"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user