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:
Siavash Sameni
2026-03-28 14:24:53 +04:00
parent 34cd1017c1
commit 0dc381e948
11 changed files with 1547 additions and 11 deletions

View File

@@ -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