feat: IAX2-inspired protocol improvements — trunking, mini-frames, silence suppression, call control (P2-T6/T7/T8/T9)

WZP-P2-T6: Trunking
- TrunkFrame/TrunkEntry: pack N session packets into one datagram
- Wire format: [count:u16][session_id:2][len:u16][payload]...
- TrunkBatcher: batches by count (10) or bytes (1200), flushes on limit
- 5 tests: encode/decode roundtrip, empty frame, batcher fill/flush, byte limit

WZP-P2-T7: Mini-frames
- MiniHeader: 4-byte delta header (timestamp_delta + payload_len)
- FRAME_TYPE_FULL (0x00) / FRAME_TYPE_MINI (0x01) discriminator
- MiniFrameContext: expands mini-headers to full by tracking baseline
- Saves 8 bytes per packet (5 vs 13 bytes with type prefix)
- 5 tests: encode/decode, wire size, context expand, no baseline, size comparison

WZP-P2-T8: Silence suppression
- SilenceDetector: RMS-based detection with hangover (5 frames = 100ms)
- ComfortNoise: low-level random noise generator
- CodecId::ComfortNoise variant for CN packets
- CallEncoder: suppresses silent frames, sends 1-byte CN every 200ms
- CallDecoder: generates comfort noise on CN packets
- ~50% bandwidth savings in typical conversations
- 6 tests: silence/speech detection, hangover, CN generation, RMS math, suppression

WZP-P2-T9: Call control signals
- SignalMessage: Hold, Unhold, Mute, Unmute, Transfer, TransferAck
- CallSignalType mapping in featherchat.rs for all new variants
- 4 serde roundtrip tests + signal type mapping tests

255 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:13:05 +04:00
parent a64b79d953
commit 34cd1017c1
14 changed files with 1077 additions and 2 deletions

View File

@@ -15,8 +15,10 @@ pub mod pipeline;
pub mod probe;
pub mod room;
pub mod session_mgr;
pub mod trunk;
pub use config::RelayConfig;
pub use handshake::accept_handshake;
pub use pipeline::{PipelineConfig, PipelineStats, RelayPipeline};
pub use session_mgr::{RelaySession, SessionId, SessionInfo, SessionManager, SessionState};
pub use trunk::TrunkBatcher;

View File

@@ -0,0 +1,152 @@
//! Trunk batching — accumulates media packets from multiple sessions into
//! [`TrunkFrame`]s that fit inside a single QUIC datagram.
use std::time::Duration;
use bytes::Bytes;
use wzp_proto::packet::{TrunkEntry, TrunkFrame};
/// Batches individual session packets into [`TrunkFrame`]s.
///
/// A trunk frame is flushed when any of the following thresholds are hit:
/// - `max_entries` — maximum number of packets per trunk.
/// - `max_bytes` — maximum total wire size (should fit one UDP datagram).
///
/// The caller is responsible for timer-based flushing using [`flush_interval`]
/// and calling [`flush`] when the interval expires.
pub struct TrunkBatcher {
pending: TrunkFrame,
/// Current accumulated wire size of the pending frame.
pending_bytes: usize,
/// Maximum packets per trunk (default 10).
pub max_entries: usize,
/// Maximum total wire bytes per trunk (default 1200, fits in one UDP datagram).
pub max_bytes: usize,
/// Maximum wait before flushing (default 5 ms). Used by the caller for timer scheduling.
pub flush_interval: Duration,
}
impl TrunkBatcher {
/// Header size: the 2-byte count prefix present in every TrunkFrame.
const FRAME_HEADER: usize = 2;
pub fn new() -> Self {
Self {
pending: TrunkFrame::new(),
pending_bytes: Self::FRAME_HEADER,
max_entries: 10,
max_bytes: 1200,
flush_interval: Duration::from_millis(5),
}
}
/// Push a session packet. Returns `Some(frame)` if the batch is now full
/// and was flushed, `None` if more room remains.
pub fn push(&mut self, session_id: [u8; 2], payload: Bytes) -> Option<TrunkFrame> {
let entry_wire = TrunkEntry::OVERHEAD + payload.len();
// If adding this entry would exceed limits, flush first.
if self.should_flush_with(entry_wire) && !self.pending.is_empty() {
let frame = self.take_pending();
// Then start a new batch with this entry.
self.pending.push(session_id, payload);
self.pending_bytes += entry_wire;
return Some(frame);
}
self.pending.push(session_id, payload);
self.pending_bytes += entry_wire;
if self.should_flush() {
Some(self.take_pending())
} else {
None
}
}
/// Flush the current pending frame if non-empty.
pub fn flush(&mut self) -> Option<TrunkFrame> {
if self.pending.is_empty() {
None
} else {
Some(self.take_pending())
}
}
/// Returns `true` if the pending batch has reached `max_entries` or `max_bytes`.
pub fn should_flush(&self) -> bool {
self.pending.len() >= self.max_entries || self.pending_bytes >= self.max_bytes
}
// --- private helpers ---
/// Would adding `extra_bytes` exceed a threshold?
fn should_flush_with(&self, extra_bytes: usize) -> bool {
self.pending.len() + 1 > self.max_entries
|| self.pending_bytes + extra_bytes > self.max_bytes
}
/// Take the pending frame out, resetting state.
fn take_pending(&mut self) -> TrunkFrame {
let frame = std::mem::replace(&mut self.pending, TrunkFrame::new());
self.pending_bytes = Self::FRAME_HEADER;
frame
}
}
impl Default for TrunkBatcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trunk_batcher_fills_and_flushes() {
let mut batcher = TrunkBatcher::new();
batcher.max_entries = 3;
batcher.max_bytes = 4096; // large enough to not interfere
// First two pushes should not flush.
assert!(batcher.push([0, 1], Bytes::from_static(b"aaa")).is_none());
assert!(batcher.push([0, 2], Bytes::from_static(b"bbb")).is_none());
// Third push should trigger flush (max_entries = 3).
let frame = batcher
.push([0, 3], Bytes::from_static(b"ccc"))
.expect("should flush at max_entries");
assert_eq!(frame.len(), 3);
assert_eq!(frame.packets[0].session_id, [0, 1]);
assert_eq!(frame.packets[2].payload, Bytes::from_static(b"ccc"));
// Batcher is now empty.
assert!(batcher.flush().is_none());
}
#[test]
fn trunk_batcher_respects_max_bytes() {
let mut batcher = TrunkBatcher::new();
batcher.max_entries = 100; // won't be the trigger
// Frame header (2) + one entry overhead (4) + 50 payload = 56
// Two entries: 2 + 2*(4+50) = 110
// Three entries: 2 + 3*54 = 164
batcher.max_bytes = 120; // allow at most 2 entries of 50-byte payload
let big = Bytes::from(vec![0xAA; 50]);
assert!(batcher.push([0, 1], big.clone()).is_none()); // 56 bytes
// Second push: 56 + 54 = 110 < 120, fits
assert!(batcher.push([0, 2], big.clone()).is_none());
// Third push would be 164 > 120, so existing batch flushes first
let frame = batcher
.push([0, 3], big.clone())
.expect("should flush on max_bytes");
assert_eq!(frame.len(), 2);
// The third entry is now pending
let remaining = batcher.flush().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining.packets[0].session_id, [0, 3]);
}
}