feat: protocol improvements — live trunking, mini-frames, noise suppression, adaptive jitter
T6 wiring: Trunking in relay hot path - TrunkedForwarder wraps transport with TrunkBatcher - run_participant uses 5ms flush timer when trunking enabled - send_trunk/recv_trunk on QuinnTransport - --trunking flag on relay config - 2 new tests: forwarder batches, auto-flush on full T7 wiring: Mini-frames in encoder/decoder - MediaPacket::encode_compact/decode_compact with MiniFrameContext - CallEncoder sends mini-headers for consecutive frames (full every 50th) - CallDecoder auto-detects full vs mini on receive - mini_frames_enabled in CallConfig (default true) - 3 new tests: encode/decode sequence, periodic full, disabled mode Noise suppression (nnnoiseless/RNNoise) - NoiseSupressor in wzp-codec: pure Rust ML-based noise removal - Processes 960-sample frames as two 480-sample halves - Integrated in CallEncoder before silence detection - noise_suppression in CallConfig (default true) - 4 new tests: creation, processing, SNR improvement, passthrough T1-S4: Adaptive playout delay - AdaptivePlayoutDelay: EMA-based jitter tracking (NetEq-inspired) - Computes target_delay from observed inter-arrival jitter - JitterBuffer::new_adaptive() uses adaptive delay - adaptive_jitter in CallConfig (default true) - 5 new tests: stable, jitter increase, recovery, clamping, estimate 272 tests passing across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
365
Cargo.lock
generated
365
Cargo.lock
generated
@@ -58,6 +58,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "anymap3"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.0"
|
||||
@@ -67,6 +73,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "array-init"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -90,6 +102,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audiopus"
|
||||
version = "0.3.0-rc.0"
|
||||
@@ -462,6 +485,31 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"bitflags 1.3.2",
|
||||
"clap_lex",
|
||||
"indexmap 1.9.3",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -628,12 +676,125 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a"
|
||||
dependencies = [
|
||||
"dasp_envelope",
|
||||
"dasp_frame",
|
||||
"dasp_interpolate",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
"dasp_signal",
|
||||
"dasp_slice",
|
||||
"dasp_window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_envelope"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_frame"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_interpolate"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_peak"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_ring_buffer"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_rms"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_signal"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7"
|
||||
dependencies = [
|
||||
"dasp_envelope",
|
||||
"dasp_frame",
|
||||
"dasp_interpolate",
|
||||
"dasp_peak",
|
||||
"dasp_ring_buffer",
|
||||
"dasp_rms",
|
||||
"dasp_sample",
|
||||
"dasp_window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_slice"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1"
|
||||
dependencies = [
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_window"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
@@ -688,6 +849,19 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "easyfft"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "767e39eef2ad8a3b6f1d733be3ec70364d21d437d06d4f18ea76ce08df20b75f"
|
||||
dependencies = [
|
||||
"array-init",
|
||||
"generic_singleton",
|
||||
"num-complex",
|
||||
"realfft",
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -971,6 +1145,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic_singleton"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d5de0fc83987dac514f3b910c5d08392b220efe8cf72086c660029a197bf73"
|
||||
dependencies = [
|
||||
"anymap3",
|
||||
"lazy_static",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1040,13 +1225,19 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1068,6 +1259,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1101,6 +1301,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1364,6 +1570,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1668,6 +1884,22 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nnnoiseless"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"dasp",
|
||||
"dasp_interpolate",
|
||||
"dasp_ring_buffer",
|
||||
"easyfft",
|
||||
"hound",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1687,6 +1919,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -1704,6 +1946,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1814,6 +2065,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1926,6 +2183,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -2121,6 +2387,15 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "realfft"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2238,6 +2513,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -2603,6 +2892,18 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -2674,6 +2975,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -2885,7 +3201,7 @@ version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -2899,7 +3215,7 @@ version = "0.25.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.1.0+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.0",
|
||||
@@ -3034,6 +3350,16 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -3304,7 +3630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -3317,7 +3643,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"semver",
|
||||
]
|
||||
|
||||
@@ -3350,6 +3676,22 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -3359,6 +3701,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
@@ -3726,7 +4074,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
@@ -3757,7 +4105,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.0",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -3776,7 +4124,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -3820,6 +4168,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"codec2",
|
||||
"nnnoiseless",
|
||||
"rand 0.8.5",
|
||||
"tracing",
|
||||
"wzp-proto",
|
||||
|
||||
@@ -7,10 +7,10 @@ use std::time::{Duration, Instant};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use wzp_codec::{ComfortNoise, SilenceDetector};
|
||||
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{
|
||||
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
|
||||
@@ -36,6 +36,17 @@ pub struct CallConfig {
|
||||
pub silence_hangover_frames: u32,
|
||||
/// Comfort noise amplitude (default: 50).
|
||||
pub comfort_noise_level: i16,
|
||||
/// Enable ML-based noise suppression via RNNoise (default: true).
|
||||
pub noise_suppression: bool,
|
||||
/// Enable mini-frame header compression (default: true).
|
||||
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||
pub mini_frames_enabled: bool,
|
||||
/// Enable adaptive jitter buffer (default: true).
|
||||
///
|
||||
/// When true, the jitter buffer target depth is automatically adjusted
|
||||
/// based on observed inter-arrival jitter (NetEq-inspired algorithm).
|
||||
pub adaptive_jitter: bool,
|
||||
}
|
||||
|
||||
impl Default for CallConfig {
|
||||
@@ -49,6 +60,9 @@ impl Default for CallConfig {
|
||||
silence_threshold_rms: 100.0,
|
||||
silence_hangover_frames: 5,
|
||||
comfort_noise_level: 50,
|
||||
noise_suppression: true,
|
||||
mini_frames_enabled: true,
|
||||
adaptive_jitter: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +219,14 @@ pub struct CallEncoder {
|
||||
cn_counter: u32,
|
||||
/// Comfort noise amplitude level (stored for CN packet payload).
|
||||
cn_level: i16,
|
||||
/// ML-based noise suppressor (RNNoise).
|
||||
denoiser: NoiseSupressor,
|
||||
/// Mini-frame compression context (tracks last full header).
|
||||
mini_context: MiniFrameContext,
|
||||
/// Whether mini-frame header compression is enabled.
|
||||
mini_frames_enabled: bool,
|
||||
/// Frames encoded since the last full header was emitted.
|
||||
frames_since_full: u32,
|
||||
}
|
||||
|
||||
impl CallEncoder {
|
||||
@@ -226,6 +248,27 @@ impl CallEncoder {
|
||||
frames_suppressed: 0,
|
||||
cn_counter: 0,
|
||||
cn_level: config.comfort_noise_level,
|
||||
denoiser: {
|
||||
let mut d = NoiseSupressor::new();
|
||||
d.set_enabled(config.noise_suppression);
|
||||
d
|
||||
},
|
||||
mini_context: MiniFrameContext::default(),
|
||||
mini_frames_enabled: config.mini_frames_enabled,
|
||||
frames_since_full: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a `MediaPacket` for transmission, applying mini-frame
|
||||
/// compression when enabled.
|
||||
///
|
||||
/// Returns compact wire bytes: either `[FRAME_TYPE_FULL][MediaHeader][payload]`
|
||||
/// or `[FRAME_TYPE_MINI][MiniHeader][payload]`.
|
||||
pub fn serialize_compact(&mut self, packet: &MediaPacket) -> Bytes {
|
||||
if self.mini_frames_enabled {
|
||||
packet.encode_compact(&mut self.mini_context, &mut self.frames_since_full)
|
||||
} else {
|
||||
packet.to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +277,16 @@ impl CallEncoder {
|
||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||
/// Output: one or more MediaPackets to send.
|
||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||
// Noise suppression: denoise the PCM before silence detection and encoding.
|
||||
let pcm = if self.denoiser.is_enabled() {
|
||||
let mut buf = pcm.to_vec();
|
||||
self.denoiser.process(&mut buf);
|
||||
buf
|
||||
} else {
|
||||
pcm.to_vec()
|
||||
};
|
||||
let pcm = &pcm[..];
|
||||
|
||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||
self.frames_suppressed += 1;
|
||||
@@ -368,21 +421,38 @@ pub struct CallDecoder {
|
||||
comfort_noise: ComfortNoise,
|
||||
/// Whether the last decoded frame was comfort noise.
|
||||
last_was_cn: bool,
|
||||
/// Mini-frame decompression context (tracks last full header baseline).
|
||||
mini_context: MiniFrameContext,
|
||||
}
|
||||
|
||||
impl CallDecoder {
|
||||
pub fn new(config: &CallConfig) -> Self {
|
||||
let jitter = if config.adaptive_jitter {
|
||||
JitterBuffer::new_adaptive(config.jitter_min, config.jitter_max)
|
||||
} else {
|
||||
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
|
||||
};
|
||||
Self {
|
||||
audio_dec: wzp_codec::create_decoder(config.profile),
|
||||
fec_dec: wzp_fec::create_decoder(&config.profile),
|
||||
jitter: JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min),
|
||||
jitter,
|
||||
quality: AdaptiveQualityController::new(),
|
||||
profile: config.profile,
|
||||
comfort_noise: ComfortNoise::new(50),
|
||||
last_was_cn: false,
|
||||
mini_context: MiniFrameContext::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a compact wire-format buffer into a `MediaPacket`,
|
||||
/// auto-detecting full vs mini headers.
|
||||
///
|
||||
/// Returns `None` on malformed data or if a mini-frame arrives before
|
||||
/// any full header baseline has been established.
|
||||
pub fn deserialize_compact(&mut self, buf: &[u8]) -> Option<MediaPacket> {
|
||||
MediaPacket::decode_compact(buf, &mut self.mini_context)
|
||||
}
|
||||
|
||||
/// Feed a received media packet into the decode pipeline.
|
||||
pub fn ingest(&mut self, packet: MediaPacket) {
|
||||
// Feed to FEC decoder
|
||||
|
||||
@@ -19,4 +19,7 @@ codec2 = { workspace = true }
|
||||
# RNG for comfort noise generation
|
||||
rand = { workspace = true }
|
||||
|
||||
# ML-based noise suppression (pure-Rust port of RNNoise)
|
||||
nnnoiseless = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
183
crates/wzp-codec/src/denoise.rs
Normal file
183
crates/wzp-codec/src/denoise.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! ML-based noise suppression using nnnoiseless (pure-Rust RNNoise port).
|
||||
//!
|
||||
//! RNNoise operates on 480-sample frames at 48 kHz (10 ms). Our codec pipeline
|
||||
//! uses 960-sample frames (20 ms), so each call processes two halves.
|
||||
|
||||
use nnnoiseless::DenoiseState;
|
||||
|
||||
/// Wraps [`DenoiseState`] to provide noise suppression on 960-sample (20 ms) PCM
|
||||
/// frames at 48 kHz.
|
||||
pub struct NoiseSupressor {
|
||||
state: Box<DenoiseState<'static>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl NoiseSupressor {
|
||||
/// Create a new noise suppressor (enabled by default).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DenoiseState::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a 960-sample frame of 48 kHz mono PCM **in place**.
|
||||
///
|
||||
/// nnnoiseless expects f32 samples in the range roughly [-32768, 32767].
|
||||
/// We convert i16 → f32, process two 480-sample halves, then convert back.
|
||||
pub fn process(&mut self, pcm: &mut [i16]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(
|
||||
pcm.len() >= 960,
|
||||
"NoiseSupressor::process expects at least 960 samples, got {}",
|
||||
pcm.len()
|
||||
);
|
||||
|
||||
// Process in two 480-sample halves.
|
||||
for half in 0..2 {
|
||||
let offset = half * 480;
|
||||
let end = offset + 480;
|
||||
if end > pcm.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
// i16 → f32
|
||||
let mut float_buf = [0.0f32; 480];
|
||||
for (i, &sample) in pcm[offset..end].iter().enumerate() {
|
||||
float_buf[i] = sample as f32;
|
||||
}
|
||||
|
||||
// nnnoiseless processes in-place, returns VAD probability (unused here).
|
||||
let mut output = [0.0f32; 480];
|
||||
let _vad = self.state.process_frame(&mut output, &float_buf);
|
||||
|
||||
// f32 → i16 with clamping
|
||||
for (i, &val) in output.iter().enumerate() {
|
||||
let clamped = val.max(-32768.0).min(32767.0);
|
||||
pcm[offset + i] = clamped as i16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable noise suppression.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns `true` if noise suppression is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoiseSupressor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn denoiser_creates() {
|
||||
let ns = NoiseSupressor::new();
|
||||
assert!(ns.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denoiser_processes_frame() {
|
||||
let mut ns = NoiseSupressor::new();
|
||||
let mut pcm = vec![0i16; 960];
|
||||
// Fill with a simple pattern so we have something to process.
|
||||
for (i, s) in pcm.iter_mut().enumerate() {
|
||||
*s = ((i % 100) as i16).wrapping_mul(100);
|
||||
}
|
||||
let original_len = pcm.len();
|
||||
ns.process(&mut pcm);
|
||||
assert_eq!(pcm.len(), original_len, "output length must match input length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denoiser_reduces_noise() {
|
||||
let mut ns = NoiseSupressor::new();
|
||||
|
||||
// Generate a 440 Hz sine tone + white noise at 48 kHz.
|
||||
// We need multiple frames for the RNN to converge.
|
||||
let sample_rate = 48000.0f64;
|
||||
let freq = 440.0f64;
|
||||
let amplitude = 10000.0f64;
|
||||
let noise_amplitude = 3000.0f64;
|
||||
|
||||
// Use a simple PRNG for reproducibility.
|
||||
let mut rng_state: u32 = 12345;
|
||||
let mut next_noise = || -> f64 {
|
||||
// xorshift32
|
||||
rng_state ^= rng_state << 13;
|
||||
rng_state ^= rng_state >> 17;
|
||||
rng_state ^= rng_state << 5;
|
||||
// Map to [-1, 1]
|
||||
(rng_state as f64 / u32::MAX as f64) * 2.0 - 1.0
|
||||
};
|
||||
|
||||
// Feed several frames to let the RNN warm up, then measure the last one.
|
||||
let num_warmup_frames = 20;
|
||||
let mut last_input = vec![0i16; 960];
|
||||
let mut last_output = vec![0i16; 960];
|
||||
|
||||
for frame_idx in 0..=num_warmup_frames {
|
||||
let mut pcm = vec![0i16; 960];
|
||||
for (i, s) in pcm.iter_mut().enumerate() {
|
||||
let t = (frame_idx * 960 + i) as f64 / sample_rate;
|
||||
let sine = amplitude * (2.0 * std::f64::consts::PI * freq * t).sin();
|
||||
let noise = noise_amplitude * next_noise();
|
||||
*s = (sine + noise).max(-32768.0).min(32767.0) as i16;
|
||||
}
|
||||
|
||||
if frame_idx == num_warmup_frames {
|
||||
last_input = pcm.clone();
|
||||
}
|
||||
|
||||
ns.process(&mut pcm);
|
||||
|
||||
if frame_idx == num_warmup_frames {
|
||||
last_output = pcm;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute RMS of input and output.
|
||||
let rms = |buf: &[i16]| -> f64 {
|
||||
let sum: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||
(sum / buf.len() as f64).sqrt()
|
||||
};
|
||||
|
||||
let input_rms = rms(&last_input);
|
||||
let output_rms = rms(&last_output);
|
||||
|
||||
// The denoiser should not amplify the signal beyond input.
|
||||
// More importantly, the output should have measurably lower noise.
|
||||
// We verify the output RMS is less than the input RMS (noise was reduced).
|
||||
assert!(
|
||||
output_rms < input_rms,
|
||||
"expected output RMS ({output_rms:.1}) < input RMS ({input_rms:.1}); \
|
||||
denoiser should reduce noise"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denoiser_passthrough_when_disabled() {
|
||||
let mut ns = NoiseSupressor::new();
|
||||
ns.set_enabled(false);
|
||||
assert!(!ns.is_enabled());
|
||||
|
||||
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
|
||||
let mut pcm = original.clone();
|
||||
ns.process(&mut pcm);
|
||||
|
||||
assert_eq!(pcm, original, "disabled denoiser must not alter input");
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@
|
||||
pub mod adaptive;
|
||||
pub mod codec2_dec;
|
||||
pub mod codec2_enc;
|
||||
pub mod denoise;
|
||||
pub mod opus_dec;
|
||||
pub mod opus_enc;
|
||||
pub mod resample;
|
||||
pub mod silence;
|
||||
|
||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||
pub use denoise::NoiseSupressor;
|
||||
pub use silence::{ComfortNoise, SilenceDetector};
|
||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||
|
||||
|
||||
@@ -2,6 +2,97 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::packet::MediaPacket;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adaptive playout delay (NetEq-inspired)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adaptive playout delay estimator based on observed inter-arrival jitter.
|
||||
///
|
||||
/// Inspired by WebRTC NetEq and IAX2 adaptive jitter buffering. Tracks an
|
||||
/// exponential moving average (EMA) of inter-packet arrival jitter and
|
||||
/// converts it to a target buffer depth in packets.
|
||||
pub struct AdaptivePlayoutDelay {
|
||||
/// Current target delay in packets (equivalent to target_depth).
|
||||
target_delay: usize,
|
||||
/// Minimum allowed delay.
|
||||
min_delay: usize,
|
||||
/// Maximum allowed delay.
|
||||
max_delay: usize,
|
||||
/// Exponential moving average of inter-packet arrival jitter (ms).
|
||||
jitter_ema: f64,
|
||||
/// EMA smoothing factor (0.0-1.0, lower = smoother).
|
||||
alpha: f64,
|
||||
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
||||
last_arrival_ms: Option<u64>,
|
||||
/// Last packet expected timestamp.
|
||||
last_expected_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
||||
const FRAME_DURATION_MS: f64 = 20.0;
|
||||
/// Safety margin added to jitter-derived target (in packets).
|
||||
const SAFETY_MARGIN_PACKETS: f64 = 2.0;
|
||||
/// Default EMA smoothing factor.
|
||||
const DEFAULT_ALPHA: f64 = 0.05;
|
||||
|
||||
impl AdaptivePlayoutDelay {
|
||||
/// Create a new adaptive playout delay estimator.
|
||||
///
|
||||
/// - `min_delay`: minimum target delay in packets
|
||||
/// - `max_delay`: maximum target delay in packets
|
||||
pub fn new(min_delay: usize, max_delay: usize) -> Self {
|
||||
Self {
|
||||
target_delay: min_delay,
|
||||
min_delay,
|
||||
max_delay,
|
||||
jitter_ema: 0.0,
|
||||
alpha: DEFAULT_ALPHA,
|
||||
last_arrival_ms: None,
|
||||
last_expected_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update with a new packet arrival. Returns the new target delay.
|
||||
///
|
||||
/// - `arrival_ms`: when the packet actually arrived (wall clock)
|
||||
/// - `expected_ms`: when it should have arrived (based on sequence * 20ms)
|
||||
pub fn update(&mut self, arrival_ms: u64, expected_ms: u64) -> usize {
|
||||
if let (Some(last_arrival), Some(last_expected)) =
|
||||
(self.last_arrival_ms, self.last_expected_ms)
|
||||
{
|
||||
let actual_delta = arrival_ms as f64 - last_arrival as f64;
|
||||
let expected_delta = expected_ms as f64 - last_expected as f64;
|
||||
let jitter = (actual_delta - expected_delta).abs();
|
||||
|
||||
// Update EMA
|
||||
self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
|
||||
|
||||
// Convert jitter estimate to target delay in packets
|
||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
|
||||
self.target_delay =
|
||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
}
|
||||
|
||||
self.last_arrival_ms = Some(arrival_ms);
|
||||
self.last_expected_ms = Some(expected_ms);
|
||||
self.target_delay
|
||||
}
|
||||
|
||||
/// Get current target delay in packets.
|
||||
pub fn target_delay(&self) -> usize {
|
||||
self.target_delay
|
||||
}
|
||||
|
||||
/// Get current jitter estimate in ms.
|
||||
pub fn jitter_estimate_ms(&self) -> f64 {
|
||||
self.jitter_ema
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jitter buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adaptive jitter buffer that reorders packets by sequence number.
|
||||
///
|
||||
/// Designed for the lossy relay link with up to 5 seconds of buffering depth.
|
||||
@@ -21,6 +112,8 @@ pub struct JitterBuffer {
|
||||
initialized: bool,
|
||||
/// Statistics.
|
||||
stats: JitterStats,
|
||||
/// Optional adaptive playout delay estimator.
|
||||
adaptive: Option<AdaptivePlayoutDelay>,
|
||||
}
|
||||
|
||||
/// Jitter buffer statistics.
|
||||
@@ -68,6 +161,27 @@ impl JitterBuffer {
|
||||
min_depth,
|
||||
initialized: false,
|
||||
stats: JitterStats::default(),
|
||||
adaptive: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a jitter buffer with adaptive playout delay.
|
||||
///
|
||||
/// The target depth will be automatically adjusted based on observed
|
||||
/// inter-arrival jitter (NetEq-inspired algorithm).
|
||||
///
|
||||
/// - `min_delay`: minimum target delay in packets
|
||||
/// - `max_delay`: maximum target delay in packets (also used as max_depth)
|
||||
pub fn new_adaptive(min_delay: usize, max_delay: usize) -> Self {
|
||||
Self {
|
||||
buffer: BTreeMap::new(),
|
||||
next_playout_seq: 0,
|
||||
max_depth: max_delay,
|
||||
target_depth: min_delay,
|
||||
min_depth: min_delay,
|
||||
initialized: false,
|
||||
stats: JitterStats::default(),
|
||||
adaptive: Some(AdaptivePlayoutDelay::new(min_delay, max_delay)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +221,28 @@ impl JitterBuffer {
|
||||
self.next_playout_seq = seq;
|
||||
}
|
||||
|
||||
// Update adaptive playout delay if enabled.
|
||||
// Use the packet's timestamp as expected_ms and compute a simple wall-clock
|
||||
// proxy from the header timestamp (arrival_ms is approximated as timestamp
|
||||
// + observed jitter, but since we don't have real wall-clock here we use
|
||||
// the receive order with the header timestamp as the expected baseline).
|
||||
if let Some(ref mut adaptive) = self.adaptive {
|
||||
// expected_ms derived from sequence-implied timing: seq * frame_duration
|
||||
let expected_ms = packet.header.timestamp as u64;
|
||||
// For arrival_ms, use the actual receive timestamp. In the absence of
|
||||
// a wall-clock parameter, we use std::time for a monotonic approximation.
|
||||
// However, to keep the API simple, we compute arrival from the packet
|
||||
// stats: the Nth received packet "arrives" at N * frame_duration as a
|
||||
// baseline, and real network jitter shows in the deviation.
|
||||
// NOTE: In production, the caller should pass real wall-clock time.
|
||||
// For now, we use the header timestamp as-is (callers with adaptive
|
||||
// mode should feed arrival time via push_with_arrival).
|
||||
let arrival_ms = expected_ms; // no-op for basic push; use push_with_arrival
|
||||
adaptive.update(arrival_ms, expected_ms);
|
||||
self.target_depth = adaptive.target_delay();
|
||||
self.min_depth = self.min_depth.min(self.target_depth);
|
||||
}
|
||||
|
||||
self.buffer.insert(seq, packet);
|
||||
|
||||
// Evict oldest if over max depth
|
||||
@@ -193,6 +329,68 @@ impl JitterBuffer {
|
||||
};
|
||||
}
|
||||
|
||||
/// Push a received packet with an explicit wall-clock arrival time.
|
||||
///
|
||||
/// This is the preferred entry point when adaptive playout delay is enabled,
|
||||
/// since the estimator needs real arrival timestamps.
|
||||
pub fn push_with_arrival(&mut self, packet: MediaPacket, arrival_ms: u64) {
|
||||
let expected_ms = packet.header.timestamp as u64;
|
||||
let seq = packet.header.seq;
|
||||
self.stats.packets_received += 1;
|
||||
|
||||
if !self.initialized {
|
||||
self.next_playout_seq = seq;
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if self.buffer.contains_key(&seq) {
|
||||
self.stats.packets_duplicate += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if packet is too old (already played out)
|
||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||
self.stats.packets_late += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||
self.next_playout_seq = seq;
|
||||
}
|
||||
|
||||
// Update adaptive playout delay if enabled.
|
||||
if let Some(ref mut adaptive) = self.adaptive {
|
||||
adaptive.update(arrival_ms, expected_ms);
|
||||
self.target_depth = adaptive.target_delay();
|
||||
}
|
||||
|
||||
self.buffer.insert(seq, packet);
|
||||
|
||||
// Evict oldest if over max depth
|
||||
while self.buffer.len() > self.max_depth {
|
||||
if let Some((&oldest_seq, _)) = self.buffer.first_key_value() {
|
||||
self.buffer.remove(&oldest_seq);
|
||||
self.stats.overruns += 1;
|
||||
if seq_before(self.next_playout_seq, oldest_seq.wrapping_add(1)) {
|
||||
self.next_playout_seq = oldest_seq.wrapping_add(1);
|
||||
self.stats.packets_lost += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stats.current_depth = self.buffer.len();
|
||||
if self.stats.current_depth > self.stats.max_depth_seen {
|
||||
self.stats.max_depth_seen = self.stats.current_depth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the adaptive playout delay estimator, if enabled.
|
||||
pub fn adaptive_delay(&self) -> Option<&AdaptivePlayoutDelay> {
|
||||
self.adaptive.as_ref()
|
||||
}
|
||||
|
||||
/// Adjust target depth based on observed jitter.
|
||||
pub fn set_target_depth(&mut self, depth: usize) {
|
||||
self.target_depth = depth.min(self.max_depth);
|
||||
@@ -334,4 +532,192 @@ mod tests {
|
||||
other => panic!("expected packet 0, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// AdaptivePlayoutDelay tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn adaptive_delay_stable() {
|
||||
// Feed packets with consistent 20ms spacing — target should stay at minimum.
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
|
||||
for i in 0u64..200 {
|
||||
let arrival_ms = i * 20;
|
||||
let expected_ms = i * 20;
|
||||
apd.update(arrival_ms, expected_ms);
|
||||
}
|
||||
|
||||
// With zero jitter, target should be min_delay (ceil(0/20) + 2 = 2,
|
||||
// clamped to min_delay=3).
|
||||
assert_eq!(apd.target_delay(), 3);
|
||||
assert!(
|
||||
apd.jitter_estimate_ms() < 1.0,
|
||||
"jitter estimate should be near zero, got {}",
|
||||
apd.jitter_estimate_ms()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_delay_increases_on_jitter() {
|
||||
// Feed packets with variable spacing (±10ms jitter).
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
|
||||
// Alternate: arrive 10ms early / 10ms late
|
||||
for i in 0u64..200 {
|
||||
let expected_ms = i * 20;
|
||||
let jitter_offset: i64 = if i % 2 == 0 { 10 } else { -10 };
|
||||
let arrival_ms = (expected_ms as i64 + jitter_offset).max(0) as u64;
|
||||
apd.update(arrival_ms, expected_ms);
|
||||
}
|
||||
|
||||
// Inter-arrival jitter should be ~20ms (swing of 10 to -10 = delta 20).
|
||||
// target = ceil(~20/20) + 2 = 3, but EMA converges near 20 so target >= 3.
|
||||
assert!(
|
||||
apd.target_delay() >= 3,
|
||||
"target should increase with jitter, got {}",
|
||||
apd.target_delay()
|
||||
);
|
||||
assert!(
|
||||
apd.jitter_estimate_ms() > 5.0,
|
||||
"jitter estimate should be significant, got {}",
|
||||
apd.jitter_estimate_ms()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_delay_decreases_on_recovery() {
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
|
||||
// Phase 1: high jitter (±30ms)
|
||||
for i in 0u64..200 {
|
||||
let expected_ms = i * 20;
|
||||
let offset: i64 = if i % 2 == 0 { 30 } else { -30 };
|
||||
let arrival_ms = (expected_ms as i64 + offset).max(0) as u64;
|
||||
apd.update(arrival_ms, expected_ms);
|
||||
}
|
||||
let high_target = apd.target_delay();
|
||||
let high_jitter = apd.jitter_estimate_ms();
|
||||
|
||||
// Phase 2: stable (no jitter) — target should decrease via EMA decay
|
||||
for i in 200u64..600 {
|
||||
let t = i * 20;
|
||||
apd.update(t, t);
|
||||
}
|
||||
let low_target = apd.target_delay();
|
||||
let low_jitter = apd.jitter_estimate_ms();
|
||||
|
||||
assert!(
|
||||
low_target <= high_target,
|
||||
"target should decrease after recovery: {} -> {}",
|
||||
high_target,
|
||||
low_target
|
||||
);
|
||||
assert!(
|
||||
low_jitter < high_jitter,
|
||||
"jitter estimate should decrease: {} -> {}",
|
||||
high_jitter,
|
||||
low_jitter
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_delay_clamped() {
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 10);
|
||||
|
||||
// Extreme jitter: packets arrive with huge variance
|
||||
for i in 0u64..500 {
|
||||
let expected_ms = i * 20;
|
||||
let offset: i64 = if i % 2 == 0 { 500 } else { -500 };
|
||||
let arrival_ms = (expected_ms as i64 + offset).max(0) as u64;
|
||||
apd.update(arrival_ms, expected_ms);
|
||||
}
|
||||
|
||||
assert!(
|
||||
apd.target_delay() <= 10,
|
||||
"target should not exceed max_delay=10, got {}",
|
||||
apd.target_delay()
|
||||
);
|
||||
assert!(
|
||||
apd.target_delay() >= 3,
|
||||
"target should not go below min_delay=3, got {}",
|
||||
apd.target_delay()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_jitter_estimate() {
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
|
||||
// Initial jitter estimate should be zero
|
||||
assert_eq!(apd.jitter_estimate_ms(), 0.0);
|
||||
|
||||
// After one packet, still zero (no delta yet)
|
||||
apd.update(0, 0);
|
||||
assert_eq!(apd.jitter_estimate_ms(), 0.0);
|
||||
|
||||
// Second packet with 5ms jitter
|
||||
apd.update(25, 20); // arrived 5ms late
|
||||
assert!(
|
||||
apd.jitter_estimate_ms() > 0.0,
|
||||
"jitter estimate should be positive after jittery packet"
|
||||
);
|
||||
assert!(
|
||||
apd.jitter_estimate_ms() <= 5.0,
|
||||
"first jitter sample of 5ms with alpha=0.05 should not exceed 5ms, got {}",
|
||||
apd.jitter_estimate_ms()
|
||||
);
|
||||
|
||||
// Feed many packets with ~15ms jitter — EMA should converge
|
||||
for i in 2u64..500 {
|
||||
let expected_ms = i * 20;
|
||||
let arrival_ms = expected_ms + 15; // consistently 15ms late
|
||||
apd.update(arrival_ms, expected_ms);
|
||||
}
|
||||
// Steady-state: inter-arrival jitter = |35 - 20| = 0 actually,
|
||||
// because if every packet is 15ms late, delta_actual = 35-35 = 20,
|
||||
// same as expected. So jitter should converge toward 0.
|
||||
// Let's use variable jitter instead for a better test.
|
||||
let mut apd2 = AdaptivePlayoutDelay::new(3, 50);
|
||||
for i in 0u64..500 {
|
||||
let expected_ms = i * 20;
|
||||
// Alternate 0ms and 15ms late
|
||||
let extra = if i % 2 == 0 { 0 } else { 15 };
|
||||
let arrival_ms = expected_ms + extra;
|
||||
apd2.update(arrival_ms, expected_ms);
|
||||
}
|
||||
let est = apd2.jitter_estimate_ms();
|
||||
assert!(
|
||||
est > 5.0 && est < 20.0,
|
||||
"jitter estimate should converge near 15ms with alternating 0/15ms offsets, got {}",
|
||||
est
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// JitterBuffer with adaptive mode tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn jitter_buffer_adaptive_constructor() {
|
||||
let jb = JitterBuffer::new_adaptive(5, 250);
|
||||
assert!(jb.adaptive_delay().is_some());
|
||||
assert_eq!(jb.adaptive_delay().unwrap().target_delay(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_buffer_adaptive_push_with_arrival() {
|
||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||
|
||||
// Push packets with consistent timing
|
||||
for i in 0u16..20 {
|
||||
let pkt = make_packet(i);
|
||||
let arrival_ms = i as u64 * 20;
|
||||
jb.push_with_arrival(pkt, arrival_ms);
|
||||
}
|
||||
|
||||
// With zero jitter, target should stay at min
|
||||
let ad = jb.adaptive_delay().unwrap();
|
||||
assert_eq!(ad.target_delay(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,9 @@ pub struct MediaPacket {
|
||||
pub quality_report: Option<QualityReport>,
|
||||
}
|
||||
|
||||
/// Maximum number of mini-frames between full headers (1 second at 50 fps).
|
||||
pub const MINI_FRAME_FULL_INTERVAL: u32 = 50;
|
||||
|
||||
impl MediaPacket {
|
||||
/// Serialize the entire packet to bytes.
|
||||
pub fn to_bytes(&self) -> Bytes {
|
||||
@@ -239,6 +242,98 @@ impl MediaPacket {
|
||||
quality_report,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize with mini-frame compression.
|
||||
///
|
||||
/// Uses the `MiniFrameContext` to decide whether to emit a compact 4-byte
|
||||
/// mini-header or a full 12-byte header. A full header is forced on the
|
||||
/// first frame and every `MINI_FRAME_FULL_INTERVAL` frames thereafter.
|
||||
pub fn encode_compact(
|
||||
&self,
|
||||
ctx: &mut MiniFrameContext,
|
||||
frames_since_full: &mut u32,
|
||||
) -> Bytes {
|
||||
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
|
||||
// --- mini frame ---
|
||||
let ts_delta = self
|
||||
.header
|
||||
.timestamp
|
||||
.wrapping_sub(ctx.last_header.unwrap().timestamp)
|
||||
as u16;
|
||||
let mini = MiniHeader {
|
||||
timestamp_delta_ms: ts_delta,
|
||||
payload_len: self.payload.len() as u16,
|
||||
};
|
||||
let total = 1 + MiniHeader::WIRE_SIZE + self.payload.len();
|
||||
let mut buf = BytesMut::with_capacity(total);
|
||||
buf.put_u8(FRAME_TYPE_MINI);
|
||||
mini.write_to(&mut buf);
|
||||
buf.put(self.payload.clone());
|
||||
// Advance the context so the next mini-frame delta is relative
|
||||
// to this frame, mirroring what expand() does on the decoder side.
|
||||
ctx.update(&self.header);
|
||||
*frames_since_full += 1;
|
||||
buf.freeze()
|
||||
} else {
|
||||
// --- full frame ---
|
||||
let qr_size = if self.quality_report.is_some() {
|
||||
QualityReport::WIRE_SIZE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total = 1 + MediaHeader::WIRE_SIZE + self.payload.len() + qr_size;
|
||||
let mut buf = BytesMut::with_capacity(total);
|
||||
buf.put_u8(FRAME_TYPE_FULL);
|
||||
self.header.write_to(&mut buf);
|
||||
buf.put(self.payload.clone());
|
||||
if let Some(ref qr) = self.quality_report {
|
||||
qr.write_to(&mut buf);
|
||||
}
|
||||
ctx.update(&self.header);
|
||||
*frames_since_full = 1; // next frame will be the 1st after full
|
||||
buf.freeze()
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode from compact wire format (auto-detects full vs mini).
|
||||
///
|
||||
/// Returns `None` on malformed input or if a mini-frame arrives before any
|
||||
/// full header baseline has been established.
|
||||
pub fn decode_compact(buf: &[u8], ctx: &mut MiniFrameContext) -> Option<Self> {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let frame_type = buf[0];
|
||||
let rest = &buf[1..];
|
||||
|
||||
match frame_type {
|
||||
FRAME_TYPE_FULL => {
|
||||
let pkt = Self::from_bytes(Bytes::copy_from_slice(rest))?;
|
||||
ctx.update(&pkt.header);
|
||||
Some(pkt)
|
||||
}
|
||||
FRAME_TYPE_MINI => {
|
||||
if rest.len() < MiniHeader::WIRE_SIZE {
|
||||
return None;
|
||||
}
|
||||
let mut cursor = rest;
|
||||
let mini = MiniHeader::read_from(&mut cursor)?;
|
||||
let payload_start = 1 + MiniHeader::WIRE_SIZE;
|
||||
let payload_end = payload_start + mini.payload_len as usize;
|
||||
if buf.len() < payload_end {
|
||||
return None;
|
||||
}
|
||||
let payload = Bytes::copy_from_slice(&buf[payload_start..payload_end]);
|
||||
let header = ctx.expand(&mini)?;
|
||||
Some(Self {
|
||||
header,
|
||||
payload,
|
||||
quality_report: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -838,4 +933,101 @@ mod tests {
|
||||
assert_eq!(FRAME_TYPE_FULL, 0x00);
|
||||
assert_eq!(FRAME_TYPE_MINI, 0x01);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// encode_compact / decode_compact tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn make_media_packet(seq: u16, ts: u32, payload: &[u8]) -> MediaPacket {
|
||||
MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: CodecId::Opus24k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 10,
|
||||
seq,
|
||||
timestamp: ts,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(payload.to_vec()),
|
||||
quality_report: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_frame_encode_decode_sequence() {
|
||||
let mut enc_ctx = MiniFrameContext::default();
|
||||
let mut dec_ctx = MiniFrameContext::default();
|
||||
let mut frames_since_full: u32 = 0;
|
||||
|
||||
let packets: Vec<MediaPacket> = (0..5)
|
||||
.map(|i| make_media_packet(i, i as u32 * 20, b"audio"))
|
||||
.collect();
|
||||
|
||||
for (i, pkt) in packets.iter().enumerate() {
|
||||
let wire = pkt.encode_compact(&mut enc_ctx, &mut frames_since_full);
|
||||
|
||||
if i == 0 {
|
||||
// First frame must be full
|
||||
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame 0 should be FULL");
|
||||
} else {
|
||||
// Subsequent frames should be mini
|
||||
assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI");
|
||||
// Mini wire: 1 (tag) + 4 (mini header) + payload
|
||||
assert_eq!(wire.len(), 1 + MiniHeader::WIRE_SIZE + pkt.payload.len());
|
||||
}
|
||||
|
||||
let decoded = MediaPacket::decode_compact(&wire, &mut dec_ctx)
|
||||
.unwrap_or_else(|| panic!("decode failed at frame {i}"));
|
||||
assert_eq!(decoded.header.seq, pkt.header.seq);
|
||||
assert_eq!(decoded.header.timestamp, pkt.header.timestamp);
|
||||
assert_eq!(decoded.payload, pkt.payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_frame_periodic_full() {
|
||||
let mut ctx = MiniFrameContext::default();
|
||||
let mut frames_since_full: u32 = 0;
|
||||
|
||||
// Encode MINI_FRAME_FULL_INTERVAL + 1 frames. Frame 0 and frame 50
|
||||
// should be FULL, everything in between should be MINI.
|
||||
for i in 0..=MINI_FRAME_FULL_INTERVAL {
|
||||
let pkt = make_media_packet(i as u16, i * 20, b"data");
|
||||
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
||||
|
||||
if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
|
||||
assert_eq!(
|
||||
wire[0], FRAME_TYPE_FULL,
|
||||
"frame {i} should be FULL"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
wire[0], FRAME_TYPE_MINI,
|
||||
"frame {i} should be MINI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mini_frame_disabled() {
|
||||
// Simulate disabled mini-frames by always keeping frames_since_full at 0
|
||||
// (which is what the encoder does when the feature is off).
|
||||
let mut ctx = MiniFrameContext::default();
|
||||
|
||||
for i in 0..10u16 {
|
||||
let pkt = make_media_packet(i, i as u32 * 20, b"payload");
|
||||
// When mini-frames are disabled, the encoder always passes
|
||||
// frames_since_full = 0 equivalent by never using encode_compact.
|
||||
// We test the raw path: frames_since_full forced to 0 every time.
|
||||
let mut frames_since_full: u32 = 0;
|
||||
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
||||
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL when disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ pub struct RelayConfig {
|
||||
/// Discovery is manual via multiple --probe flags; this flag signals intent.
|
||||
#[serde(default)]
|
||||
pub probe_mesh: bool,
|
||||
/// Enable trunk batching for outgoing media in room mode.
|
||||
/// When true, packets destined for the same receiver are accumulated into
|
||||
/// [`TrunkFrame`]s and flushed every 5 ms (or when the batcher is full),
|
||||
/// reducing per-packet QUIC datagram overhead.
|
||||
#[serde(default)]
|
||||
pub trunking_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for RelayConfig {
|
||||
@@ -48,6 +54,7 @@ impl Default for RelayConfig {
|
||||
metrics_port: None,
|
||||
probe_targets: Vec::new(),
|
||||
probe_mesh: false,
|
||||
trunking_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ fn parse_args() -> RelayConfig {
|
||||
"--probe-mesh" => {
|
||||
config.probe_mesh = true;
|
||||
}
|
||||
"--trunking" => {
|
||||
config.trunking_enabled = true;
|
||||
}
|
||||
"--mesh-status" => {
|
||||
// Print mesh table from a fresh registry and exit.
|
||||
// In practice this is useful after the relay has been running;
|
||||
@@ -84,6 +87,7 @@ fn parse_args() -> RelayConfig {
|
||||
eprintln!(" --probe <addr> Peer relay to probe for health monitoring (repeatable).");
|
||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||
eprintln!();
|
||||
eprintln!("Room mode (default):");
|
||||
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
||||
@@ -239,6 +243,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let auth_url = config.auth_url.clone();
|
||||
let relay_seed_bytes = relay_seed.0;
|
||||
let metrics = metrics.clone();
|
||||
let trunking_enabled = config.trunking_enabled;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = connection.remote_address();
|
||||
@@ -423,6 +428,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
transport.clone(),
|
||||
metrics.clone(),
|
||||
&session_id_str,
|
||||
trunking_enabled,
|
||||
).await;
|
||||
|
||||
// Participant disconnected — clean up per-session metrics
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::MediaTransport;
|
||||
|
||||
use crate::metrics::RelayMetrics;
|
||||
use crate::trunk::TrunkBatcher;
|
||||
|
||||
/// Unique participant ID within a room.
|
||||
pub type ParticipantId = u64;
|
||||
@@ -171,8 +175,70 @@ impl RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TrunkedForwarder — wraps a transport and batches outgoing media into trunk
|
||||
// frames so multiple packets ride a single QUIC datagram.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wraps a [`QuinnTransport`] with a [`TrunkBatcher`] so that small media
|
||||
/// packets are accumulated and sent together in a single QUIC datagram.
|
||||
pub struct TrunkedForwarder {
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
batcher: TrunkBatcher,
|
||||
session_id: [u8; 2],
|
||||
}
|
||||
|
||||
impl TrunkedForwarder {
|
||||
/// Create a new trunked forwarder.
|
||||
///
|
||||
/// `session_id` tags every entry pushed into the batcher so the receiver
|
||||
/// can demultiplex packets by session.
|
||||
pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
batcher: TrunkBatcher::new(),
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a media packet into the batcher. If the batcher is full it will
|
||||
/// flush automatically and the resulting trunk frame is sent immediately.
|
||||
pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> {
|
||||
let payload: Bytes = pkt.to_bytes();
|
||||
if let Some(frame) = self.batcher.push(self.session_id, payload) {
|
||||
self.send_frame(&frame)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush any pending packets — called on the 5 ms timer tick.
|
||||
pub async fn flush(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(frame) = self.batcher.flush() {
|
||||
self.send_frame(&frame)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the flush interval configured on the inner batcher.
|
||||
pub fn flush_interval(&self) -> Duration {
|
||||
self.batcher.flush_interval
|
||||
}
|
||||
|
||||
fn send_frame(&self, frame: &TrunkFrame) -> anyhow::Result<()> {
|
||||
self.transport.send_trunk(frame).map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_participant — the hot-path forwarding loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the receive loop for one participant in a room.
|
||||
/// Forwards all received packets to every other participant.
|
||||
///
|
||||
/// When `trunking_enabled` is true, outgoing packets are accumulated per-peer
|
||||
/// into [`TrunkedForwarder`]s and flushed every 5 ms or when the batcher is
|
||||
/// full, reducing QUIC datagram overhead.
|
||||
pub async fn run_participant(
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
room_name: String,
|
||||
@@ -180,6 +246,29 @@ pub async fn run_participant(
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
trunking_enabled: bool,
|
||||
) {
|
||||
if trunking_enabled {
|
||||
run_participant_trunked(
|
||||
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
run_participant_plain(
|
||||
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain (non-trunked) forwarding loop — original behaviour.
|
||||
async fn run_participant_plain(
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
room_name: String,
|
||||
participant_id: ParticipantId,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
) {
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
@@ -242,6 +331,120 @@ pub async fn run_participant(
|
||||
mgr.leave(&room_name, participant_id);
|
||||
}
|
||||
|
||||
/// Trunked forwarding loop — batches outgoing packets per peer.
|
||||
async fn run_participant_trunked(
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
room_name: String,
|
||||
participant_id: ParticipantId,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
metrics: Arc<RelayMetrics>,
|
||||
session_id: &str,
|
||||
) {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
|
||||
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
|
||||
// transport (stable for the Arc's lifetime). We use the remote address
|
||||
// string as the key since it is unique per connection.
|
||||
let mut forwarders: HashMap<std::net::SocketAddr, TrunkedForwarder> = HashMap::new();
|
||||
|
||||
// Derive a 2-byte session tag from the session_id hex string.
|
||||
let sid_bytes: [u8; 2] = parse_session_id_bytes(session_id);
|
||||
|
||||
let mut flush_interval = tokio::time::interval(Duration::from_millis(5));
|
||||
// Don't let missed ticks pile up — skip them and move on.
|
||||
flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
result = transport.recv_media() => {
|
||||
let pkt = match result {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
let peer_addr = other.connection().remote_address();
|
||||
let fwd = forwarders
|
||||
.entry(peer_addr)
|
||||
.or_insert_with(|| TrunkedForwarder::new(other.clone(), sid_bytes));
|
||||
if let Err(e) = fwd.send(&pkt).await {
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
if packets_forwarded % 500 == 0 {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
};
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
"participant stats (trunked)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_ = flush_interval.tick() => {
|
||||
for fwd in forwarders.values_mut() {
|
||||
if let Err(e) = fwd.flush().await {
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush — send any remaining buffered packets.
|
||||
for fwd in forwarders.values_mut() {
|
||||
let _ = fwd.flush().await;
|
||||
}
|
||||
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
mgr.leave(&room_name, participant_id);
|
||||
}
|
||||
|
||||
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.
|
||||
fn parse_session_id_bytes(session_id: &str) -> [u8; 2] {
|
||||
let bytes: Vec<u8> = (0..session_id.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(session_id.get(i..i + 2)?, 16).ok())
|
||||
.collect();
|
||||
let mut out = [0u8; 2];
|
||||
for (i, b) in bytes.iter().take(2).enumerate() {
|
||||
out[i] = *b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -277,4 +480,97 @@ mod tests {
|
||||
assert!(mgr.is_authorized("room1", Some("bob")));
|
||||
assert!(!mgr.is_authorized("room1", Some("eve")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_id_bytes_works() {
|
||||
assert_eq!(parse_session_id_bytes("abcd"), [0xab, 0xcd]);
|
||||
assert_eq!(parse_session_id_bytes("ff00"), [0xff, 0x00]);
|
||||
assert_eq!(parse_session_id_bytes(""), [0x00, 0x00]);
|
||||
// Longer hex strings: only first 2 bytes taken
|
||||
assert_eq!(parse_session_id_bytes("aabbccdd"), [0xaa, 0xbb]);
|
||||
}
|
||||
|
||||
/// Helper: create a minimal MediaPacket with the given payload bytes.
|
||||
fn make_test_packet(payload: &[u8]) -> wzp_proto::MediaPacket {
|
||||
wzp_proto::MediaPacket {
|
||||
header: wzp_proto::packet::MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: wzp_proto::CodecId::Opus16k,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: 0,
|
||||
seq: 1,
|
||||
timestamp: 100,
|
||||
fec_block: 0,
|
||||
fec_symbol: 0,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(payload.to_vec()),
|
||||
quality_report: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push 3 packets into a batcher (simulating TrunkedForwarder.send),
|
||||
/// then flush and verify all 3 appear in a single TrunkFrame.
|
||||
#[test]
|
||||
fn trunked_forwarder_batches() {
|
||||
let session_id: [u8; 2] = [0x00, 0x01];
|
||||
let mut batcher = TrunkBatcher::new();
|
||||
// Ensure max_entries is high enough that 3 packets don't auto-flush.
|
||||
batcher.max_entries = 10;
|
||||
batcher.max_bytes = 4096;
|
||||
|
||||
let pkts = [
|
||||
make_test_packet(b"aaa"),
|
||||
make_test_packet(b"bbb"),
|
||||
make_test_packet(b"ccc"),
|
||||
];
|
||||
|
||||
for pkt in &pkts {
|
||||
let payload = pkt.to_bytes();
|
||||
let flushed = batcher.push(session_id, payload);
|
||||
// Should NOT auto-flush — we are below max_entries.
|
||||
assert!(flushed.is_none(), "unexpected auto-flush");
|
||||
}
|
||||
|
||||
// Explicit flush (simulates the 5 ms timer tick).
|
||||
let frame = batcher.flush().expect("expected a frame with 3 entries");
|
||||
assert_eq!(frame.len(), 3);
|
||||
for entry in &frame.packets {
|
||||
assert_eq!(entry.session_id, session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push exactly max_entries packets and verify the batcher auto-flushes
|
||||
/// on the last push (simulating TrunkedForwarder.send triggering a send).
|
||||
#[test]
|
||||
fn trunked_forwarder_auto_flushes() {
|
||||
let session_id: [u8; 2] = [0x00, 0x02];
|
||||
let mut batcher = TrunkBatcher::new();
|
||||
batcher.max_entries = 5;
|
||||
batcher.max_bytes = 8192;
|
||||
|
||||
let pkt = make_test_packet(b"hello");
|
||||
let mut auto_flushed: Option<wzp_proto::packet::TrunkFrame> = None;
|
||||
|
||||
for i in 0..5 {
|
||||
let payload = pkt.to_bytes();
|
||||
if let Some(frame) = batcher.push(session_id, payload) {
|
||||
assert!(auto_flushed.is_none(), "should auto-flush exactly once");
|
||||
auto_flushed = Some(frame);
|
||||
// The auto-flush should happen on the 5th push (max_entries = 5).
|
||||
assert_eq!(i, 4, "expected auto-flush on the last push");
|
||||
}
|
||||
}
|
||||
|
||||
let frame = auto_flushed.expect("batcher should have auto-flushed at max_entries");
|
||||
assert_eq!(frame.len(), 5);
|
||||
for entry in &frame.packets {
|
||||
assert_eq!(entry.session_id, session_id);
|
||||
}
|
||||
|
||||
// Batcher should now be empty — nothing to flush.
|
||||
assert!(batcher.flush().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::{MediaPacket, MediaTransport, PathQuality, SignalMessage, TransportError};
|
||||
|
||||
use crate::datagram;
|
||||
@@ -36,6 +37,47 @@ impl QuinnTransport {
|
||||
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||
datagram::max_datagram_payload(&self.connection)
|
||||
}
|
||||
|
||||
/// Send an encoded [`TrunkFrame`] as a single QUIC datagram.
|
||||
pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> {
|
||||
let data = frame.encode();
|
||||
|
||||
if let Some(max_size) = self.connection.max_datagram_size() {
|
||||
if data.len() > max_size {
|
||||
return Err(TransportError::DatagramTooLarge {
|
||||
size: data.len(),
|
||||
max: max_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.connection.send_datagram(data).map_err(|e| {
|
||||
TransportError::Internal(format!("send trunk datagram error: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive a single QUIC datagram and decode it as a [`TrunkFrame`].
|
||||
///
|
||||
/// Returns `Ok(None)` on connection close, `Ok(Some(frame))` on success,
|
||||
/// or an error on malformed data / transport failure.
|
||||
pub async fn recv_trunk(&self) -> Result<Option<TrunkFrame>, TransportError> {
|
||||
let data = match self.connection.read_datagram().await {
|
||||
Ok(data) => data,
|
||||
Err(quinn::ConnectionError::ApplicationClosed(_)) => return Ok(None),
|
||||
Err(quinn::ConnectionError::LocallyClosed) => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(TransportError::Internal(format!(
|
||||
"recv trunk datagram error: {e}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
TrunkFrame::decode(&data)
|
||||
.map(Some)
|
||||
.ok_or_else(|| TransportError::Internal("malformed trunk frame".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user