Files
wz-phone/crates/wzp-proto/src/packet.rs
Siavash Sameni 34cd1017c1 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>
2026-03-28 14:13:05 +04:00

842 lines
26 KiB
Rust

use bytes::{Buf, BufMut, Bytes, BytesMut};
use serde::{Deserialize, Serialize};
use crate::CodecId;
/// 12-byte media packet header for the lossy link.
///
/// Wire layout:
/// ```text
/// Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
/// Byte 1: [FecRatioLo:6][unused:2]
/// Byte 2-3: Sequence number (big-endian u16)
/// Byte 4-7: Timestamp in ms since session start (big-endian u32)
/// Byte 8: FEC block ID
/// Byte 9: FEC symbol index within block
/// Byte 10: Reserved / flags
/// Byte 11: CSRC count
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MediaHeader {
/// Protocol version (0 = v1).
pub version: u8,
/// true = FEC repair packet, false = source media.
pub is_repair: bool,
/// Codec identifier.
pub codec_id: CodecId,
/// Whether a QualityReport trailer is appended.
pub has_quality_report: bool,
/// FEC ratio as 7-bit value (0-127 maps to 0.0-1.0).
pub fec_ratio_encoded: u8,
/// Wrapping packet sequence number.
pub seq: u16,
/// Milliseconds since session start.
pub timestamp: u32,
/// FEC source block ID (wrapping).
pub fec_block: u8,
/// Symbol index within the FEC block.
pub fec_symbol: u8,
/// Reserved flags byte.
pub reserved: u8,
/// Number of contributing sources (for future mixing).
pub csrc_count: u8,
}
impl MediaHeader {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 12;
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
pub fn encode_fec_ratio(ratio: f32) -> u8 {
// Map 0.0-2.0 to 0-127, clamping at 127
let scaled = (ratio * 63.5).round() as u8;
scaled.min(127)
}
/// Decode the 7-bit FEC ratio value back to a float.
pub fn decode_fec_ratio(encoded: u8) -> f32 {
(encoded & 0x7F) as f32 / 63.5
}
/// Serialize to a 12-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
// Byte 0: V(1) | T(1) | CodecID(4) | Q(1) | FecRatioHi(1)
let byte0 = ((self.version & 0x01) << 7)
| ((self.is_repair as u8) << 6)
| ((self.codec_id.to_wire() & 0x0F) << 2)
| ((self.has_quality_report as u8) << 1)
| ((self.fec_ratio_encoded >> 6) & 0x01);
buf.put_u8(byte0);
// Byte 1: FecRatioLo(6) | unused(2)
let byte1 = (self.fec_ratio_encoded & 0x3F) << 2;
buf.put_u8(byte1);
// Bytes 2-3: sequence number
buf.put_u16(self.seq);
// Bytes 4-7: timestamp
buf.put_u32(self.timestamp);
// Byte 8: FEC block
buf.put_u8(self.fec_block);
// Byte 9: FEC symbol
buf.put_u8(self.fec_symbol);
// Byte 10: reserved
buf.put_u8(self.reserved);
// Byte 11: CSRC count
buf.put_u8(self.csrc_count);
}
/// Deserialize from a buffer. Returns None if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
let byte0 = buf.get_u8();
let byte1 = buf.get_u8();
let version = (byte0 >> 7) & 0x01;
let is_repair = ((byte0 >> 6) & 0x01) != 0;
let codec_wire = (byte0 >> 2) & 0x0F;
let has_quality_report = ((byte0 >> 1) & 0x01) != 0;
let fec_ratio_hi = byte0 & 0x01;
let fec_ratio_lo = (byte1 >> 2) & 0x3F;
let fec_ratio_encoded = (fec_ratio_hi << 6) | fec_ratio_lo;
let codec_id = CodecId::from_wire(codec_wire)?;
let seq = buf.get_u16();
let timestamp = buf.get_u32();
let fec_block = buf.get_u8();
let fec_symbol = buf.get_u8();
let reserved = buf.get_u8();
let csrc_count = buf.get_u8();
Some(Self {
version,
is_repair,
codec_id,
has_quality_report,
fec_ratio_encoded,
seq,
timestamp,
fec_block,
fec_symbol,
reserved,
csrc_count,
})
}
/// Serialize header to a new Bytes value.
pub fn to_bytes(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
self.write_to(&mut buf);
buf.freeze()
}
}
/// Quality report appended to a media packet when Q flag is set (4 bytes).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualityReport {
/// Observed loss percentage (0-255 maps to 0-100%).
pub loss_pct: u8,
/// RTT estimate in 4ms units (0-255 = 0-1020ms).
pub rtt_4ms: u8,
/// Jitter in milliseconds.
pub jitter_ms: u8,
/// Maximum receive bitrate in kbps.
pub bitrate_cap_kbps: u8,
}
impl QualityReport {
pub const WIRE_SIZE: usize = 4;
pub fn loss_percent(&self) -> f32 {
self.loss_pct as f32 / 255.0 * 100.0
}
pub fn rtt_ms(&self) -> u16 {
self.rtt_4ms as u16 * 4
}
pub fn write_to(&self, buf: &mut impl BufMut) {
buf.put_u8(self.loss_pct);
buf.put_u8(self.rtt_4ms);
buf.put_u8(self.jitter_ms);
buf.put_u8(self.bitrate_cap_kbps);
}
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
Some(Self {
loss_pct: buf.get_u8(),
rtt_4ms: buf.get_u8(),
jitter_ms: buf.get_u8(),
bitrate_cap_kbps: buf.get_u8(),
})
}
}
/// A complete media packet (header + payload + optional quality report).
#[derive(Clone, Debug)]
pub struct MediaPacket {
pub header: MediaHeader,
pub payload: Bytes,
pub quality_report: Option<QualityReport>,
}
impl MediaPacket {
/// Serialize the entire packet to bytes.
pub fn to_bytes(&self) -> Bytes {
let qr_size = if self.quality_report.is_some() {
QualityReport::WIRE_SIZE
} else {
0
};
let total = MediaHeader::WIRE_SIZE + self.payload.len() + qr_size;
let mut buf = BytesMut::with_capacity(total);
self.header.write_to(&mut buf);
buf.put(self.payload.clone());
if let Some(ref qr) = self.quality_report {
qr.write_to(&mut buf);
}
buf.freeze()
}
/// Deserialize from bytes. `payload_len` must be known from context
/// (e.g., total packet size minus header minus optional QR).
pub fn from_bytes(data: Bytes) -> Option<Self> {
let mut cursor = &data[..];
let header = MediaHeader::read_from(&mut cursor)?;
let remaining = data.len() - MediaHeader::WIRE_SIZE;
let (payload_len, quality_report) = if header.has_quality_report {
if remaining < QualityReport::WIRE_SIZE {
return None;
}
let pl = remaining - QualityReport::WIRE_SIZE;
let qr_start = MediaHeader::WIRE_SIZE + pl;
let mut qr_cursor = &data[qr_start..];
let qr = QualityReport::read_from(&mut qr_cursor)?;
(pl, Some(qr))
} else {
(remaining, None)
};
let payload = data.slice(MediaHeader::WIRE_SIZE..MediaHeader::WIRE_SIZE + payload_len);
Some(Self {
header,
payload,
quality_report,
})
}
}
// ---------------------------------------------------------------------------
// Trunking — multiplex multiple session packets into one QUIC datagram
// ---------------------------------------------------------------------------
/// A single entry inside a [`TrunkFrame`].
#[derive(Clone, Debug)]
pub struct TrunkEntry {
/// 2-byte session identifier (up to 65 536 sessions).
pub session_id: [u8; 2],
/// Encoded MediaPacket payload (already compressed).
pub payload: Bytes,
}
impl TrunkEntry {
/// Per-entry wire overhead: 2 (session_id) + 2 (len).
pub const OVERHEAD: usize = 4;
}
/// A trunked frame carrying multiple session packets in one datagram.
///
/// Wire format:
/// ```text
/// [count:u16] [entry1] [entry2] ...
/// ```
/// Each entry:
/// ```text
/// [session_id:2] [len:u16] [payload:len]
/// ```
#[derive(Clone, Debug)]
pub struct TrunkFrame {
pub packets: Vec<TrunkEntry>,
}
impl TrunkFrame {
/// Create an empty trunk frame.
pub fn new() -> Self {
Self {
packets: Vec::new(),
}
}
/// Append a session packet to the frame.
pub fn push(&mut self, session_id: [u8; 2], payload: Bytes) {
self.packets.push(TrunkEntry {
session_id,
payload,
});
}
/// Number of entries in the frame.
pub fn len(&self) -> usize {
self.packets.len()
}
/// Whether the frame is empty.
pub fn is_empty(&self) -> bool {
self.packets.is_empty()
}
/// Total wire size of the encoded frame.
pub fn wire_size(&self) -> usize {
// 2 bytes for count + each entry
2 + self
.packets
.iter()
.map(|e| TrunkEntry::OVERHEAD + e.payload.len())
.sum::<usize>()
}
/// Encode to wire bytes.
pub fn encode(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(self.wire_size());
buf.put_u16(self.packets.len() as u16);
for entry in &self.packets {
buf.put_slice(&entry.session_id);
buf.put_u16(entry.payload.len() as u16);
buf.put(entry.payload.clone());
}
buf.freeze()
}
/// Decode from wire bytes. Returns `None` on malformed input.
pub fn decode(buf: &[u8]) -> Option<Self> {
if buf.len() < 2 {
return None;
}
let mut cursor = &buf[..];
let count = cursor.get_u16() as usize;
let mut packets = Vec::with_capacity(count);
for _ in 0..count {
if cursor.remaining() < TrunkEntry::OVERHEAD {
return None;
}
let mut session_id = [0u8; 2];
session_id[0] = cursor.get_u8();
session_id[1] = cursor.get_u8();
let len = cursor.get_u16() as usize;
if cursor.remaining() < len {
return None;
}
let payload = Bytes::copy_from_slice(&cursor[..len]);
cursor.advance(len);
packets.push(TrunkEntry {
session_id,
payload,
});
}
Some(Self { packets })
}
}
// ---------------------------------------------------------------------------
// Mini-frames — compact header for steady-state media packets
// ---------------------------------------------------------------------------
/// Frame type tag: full MediaHeader follows.
pub const FRAME_TYPE_FULL: u8 = 0x00;
/// Frame type tag: MiniHeader follows (requires prior baseline).
pub const FRAME_TYPE_MINI: u8 = 0x01;
/// Compact 4-byte header used after a full MediaHeader baseline has been
/// established. Only the timestamp delta and payload length are transmitted;
/// all other fields are inherited from the last full header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MiniHeader {
/// Milliseconds elapsed since the last header's timestamp.
pub timestamp_delta_ms: u16,
/// Length of the payload that follows this header.
pub payload_len: u16,
}
impl MiniHeader {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 4;
/// Serialize to a 4-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
buf.put_u16(self.timestamp_delta_ms);
buf.put_u16(self.payload_len);
}
/// Deserialize from a buffer. Returns `None` if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
Some(Self {
timestamp_delta_ms: buf.get_u16(),
payload_len: buf.get_u16(),
})
}
}
/// Stateful context that expands [`MiniHeader`]s back into full
/// [`MediaHeader`]s by tracking the last baseline header.
#[derive(Clone, Debug, Default)]
pub struct MiniFrameContext {
last_header: Option<MediaHeader>,
}
impl MiniFrameContext {
/// Record a full header as the new baseline for subsequent mini-frames.
pub fn update(&mut self, header: &MediaHeader) {
self.last_header = Some(*header);
}
/// Expand a mini-header into a full [`MediaHeader`] using the stored
/// baseline. Returns `None` if no baseline has been set yet.
pub fn expand(&mut self, mini: &MiniHeader) -> Option<MediaHeader> {
let base = self.last_header.as_ref()?;
let mut expanded = *base;
expanded.seq = base.seq.wrapping_add(1);
expanded.timestamp = base.timestamp.wrapping_add(mini.timestamp_delta_ms as u32);
self.last_header = Some(expanded);
Some(expanded)
}
}
/// Signaling messages sent over the reliable QUIC stream.
///
/// Compatible with Warzone messenger's identity model:
/// - Identity keys are Ed25519 (signing) + X25519 (encryption) derived from a 32-byte seed via HKDF
/// - Fingerprint = SHA-256(Ed25519 public key)[:16]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum SignalMessage {
/// Call initiation (analogous to Warzone's WireMessage::CallOffer).
CallOffer {
/// Caller's Ed25519 identity public key (32 bytes).
identity_pub: [u8; 32],
/// Ephemeral X25519 public key for this call.
ephemeral_pub: [u8; 32],
/// Ed25519 signature over (ephemeral_pub || callee_fingerprint).
signature: Vec<u8>,
/// Supported quality profiles.
supported_profiles: Vec<crate::QualityProfile>,
},
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
CallAnswer {
/// Callee's Ed25519 identity public key (32 bytes).
identity_pub: [u8; 32],
/// Callee's ephemeral X25519 public key.
ephemeral_pub: [u8; 32],
/// Ed25519 signature over (ephemeral_pub || caller_fingerprint).
signature: Vec<u8>,
/// Chosen quality profile.
chosen_profile: crate::QualityProfile,
},
/// ICE candidate for NAT traversal.
IceCandidate {
candidate: String,
},
/// Periodic rekeying (forward secrecy).
Rekey {
/// New ephemeral X25519 public key.
new_ephemeral_pub: [u8; 32],
/// Ed25519 signature over (new_ephemeral_pub || session_id).
signature: Vec<u8>,
},
/// Quality/profile change request.
QualityUpdate {
report: QualityReport,
recommended_profile: crate::QualityProfile,
},
/// Connection keepalive / RTT measurement.
Ping { timestamp_ms: u64 },
Pong { timestamp_ms: u64 },
/// End the call.
Hangup { reason: HangupReason },
/// featherChat bearer token for relay authentication.
/// Sent as the first signal message when --auth-url is configured.
AuthToken { token: String },
/// Put the call on hold (stop sending media, keep session alive).
Hold,
/// Resume a held call.
Unhold,
/// Mute request from the remote side (server-initiated mute, like IAX2 QUELCH).
Mute,
/// Unmute request from the remote side (like IAX2 UNQUELCH).
Unmute,
/// Transfer the call to another peer.
Transfer {
target_fingerprint: String,
/// Optional relay address for the transfer target.
relay_addr: Option<String>,
},
/// Acknowledge a transfer request.
TransferAck,
}
/// Reasons for ending a call.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum HangupReason {
Normal,
Busy,
Declined,
Timeout,
Error,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_roundtrip() {
let header = MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: true,
fec_ratio_encoded: 42,
seq: 12345,
timestamp: 987654,
fec_block: 7,
fec_symbol: 3,
reserved: 0,
csrc_count: 0,
};
let bytes = header.to_bytes();
assert_eq!(bytes.len(), MediaHeader::WIRE_SIZE);
let mut cursor = &bytes[..];
let decoded = MediaHeader::read_from(&mut cursor).unwrap();
assert_eq!(header, decoded);
}
#[test]
fn header_repair_flag() {
let header = MediaHeader {
version: 0,
is_repair: true,
codec_id: CodecId::Codec2_1200,
has_quality_report: false,
fec_ratio_encoded: 127,
seq: 65535,
timestamp: u32::MAX,
fec_block: 255,
fec_symbol: 255,
reserved: 0xFF,
csrc_count: 0,
};
let bytes = header.to_bytes();
let mut cursor = &bytes[..];
let decoded = MediaHeader::read_from(&mut cursor).unwrap();
assert_eq!(header, decoded);
}
#[test]
fn quality_report_roundtrip() {
let qr = QualityReport {
loss_pct: 128,
rtt_4ms: 100,
jitter_ms: 50,
bitrate_cap_kbps: 200,
};
let mut buf = BytesMut::new();
qr.write_to(&mut buf);
assert_eq!(buf.len(), QualityReport::WIRE_SIZE);
let mut cursor = &buf[..];
let decoded = QualityReport::read_from(&mut cursor).unwrap();
assert_eq!(qr, decoded);
}
#[test]
fn media_packet_roundtrip() {
let packet = MediaPacket {
header: MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus6k,
has_quality_report: true,
fec_ratio_encoded: 32,
seq: 100,
timestamp: 2000,
fec_block: 1,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
},
payload: Bytes::from_static(b"test audio data here"),
quality_report: Some(QualityReport {
loss_pct: 25,
rtt_4ms: 75,
jitter_ms: 10,
bitrate_cap_kbps: 100,
}),
};
let bytes = packet.to_bytes();
let decoded = MediaPacket::from_bytes(bytes).unwrap();
assert_eq!(packet.header, decoded.header);
assert_eq!(packet.payload, decoded.payload);
assert_eq!(packet.quality_report, decoded.quality_report);
}
#[test]
fn hold_unhold_serialize() {
let hold = SignalMessage::Hold;
let json = serde_json::to_string(&hold).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Hold));
let unhold = SignalMessage::Unhold;
let json = serde_json::to_string(&unhold).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Unhold));
}
#[test]
fn mute_unmute_serialize() {
let mute = SignalMessage::Mute;
let json = serde_json::to_string(&mute).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Mute));
let unmute = SignalMessage::Unmute;
let json = serde_json::to_string(&unmute).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Unmute));
}
#[test]
fn transfer_serialize() {
let transfer = SignalMessage::Transfer {
target_fingerprint: "abc123".to_string(),
relay_addr: Some("relay.example.com:4433".to_string()),
};
let json = serde_json::to_string(&transfer).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::Transfer {
target_fingerprint,
relay_addr,
} => {
assert_eq!(target_fingerprint, "abc123");
assert_eq!(relay_addr.unwrap(), "relay.example.com:4433");
}
_ => panic!("expected Transfer variant"),
}
// Also test with relay_addr = None
let transfer_no_relay = SignalMessage::Transfer {
target_fingerprint: "def456".to_string(),
relay_addr: None,
};
let json = serde_json::to_string(&transfer_no_relay).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::Transfer {
target_fingerprint,
relay_addr,
} => {
assert_eq!(target_fingerprint, "def456");
assert!(relay_addr.is_none());
}
_ => panic!("expected Transfer variant"),
}
}
#[test]
fn transfer_ack_serialize() {
let ack = SignalMessage::TransferAck;
let json = serde_json::to_string(&ack).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::TransferAck));
}
#[test]
fn fec_ratio_encode_decode() {
let ratio = 0.5;
let encoded = MediaHeader::encode_fec_ratio(ratio);
let decoded = MediaHeader::decode_fec_ratio(encoded);
assert!((decoded - ratio).abs() < 0.02);
let ratio_max = 2.0;
let encoded_max = MediaHeader::encode_fec_ratio(ratio_max);
assert_eq!(encoded_max, 127);
}
// ---------------------------------------------------------------
// TrunkFrame tests
// ---------------------------------------------------------------
#[test]
fn trunk_frame_encode_decode() {
let mut frame = TrunkFrame::new();
frame.push([0, 1], Bytes::from_static(b"hello"));
frame.push([0, 2], Bytes::from_static(b"world!"));
frame.push([1, 0], Bytes::from_static(b"x"));
assert_eq!(frame.len(), 3);
let encoded = frame.encode();
let decoded = TrunkFrame::decode(&encoded).expect("decode failed");
assert_eq!(decoded.len(), 3);
assert_eq!(decoded.packets[0].session_id, [0, 1]);
assert_eq!(decoded.packets[0].payload, Bytes::from_static(b"hello"));
assert_eq!(decoded.packets[1].session_id, [0, 2]);
assert_eq!(decoded.packets[1].payload, Bytes::from_static(b"world!"));
assert_eq!(decoded.packets[2].session_id, [1, 0]);
assert_eq!(decoded.packets[2].payload, Bytes::from_static(b"x"));
}
#[test]
fn trunk_frame_empty() {
let frame = TrunkFrame::new();
assert!(frame.is_empty());
assert_eq!(frame.len(), 0);
let encoded = frame.encode();
// Just the 2-byte count header with value 0.
assert_eq!(encoded.len(), 2);
assert_eq!(&encoded[..], &[0, 0]);
let decoded = TrunkFrame::decode(&encoded).unwrap();
assert!(decoded.is_empty());
}
#[test]
fn trunk_entry_wire_size() {
// Each entry overhead must be exactly 4 bytes (2 session_id + 2 len).
assert_eq!(TrunkEntry::OVERHEAD, 4);
// Verify empirically: one entry with a 10-byte payload should produce
// 2 (count) + 4 (overhead) + 10 (payload) = 16 bytes total.
let mut frame = TrunkFrame::new();
frame.push([0xAB, 0xCD], Bytes::from(vec![0u8; 10]));
let encoded = frame.encode();
assert_eq!(encoded.len(), 2 + 4 + 10);
}
// ---------------------------------------------------------------
// MiniHeader / MiniFrameContext tests
// ---------------------------------------------------------------
#[test]
fn mini_header_encode_decode() {
let mini = MiniHeader {
timestamp_delta_ms: 20,
payload_len: 160,
};
let mut buf = BytesMut::new();
mini.write_to(&mut buf);
let mut cursor = &buf[..];
let decoded = MiniHeader::read_from(&mut cursor).unwrap();
assert_eq!(mini, decoded);
}
#[test]
fn mini_header_wire_size() {
let mini = MiniHeader {
timestamp_delta_ms: 0xFFFF,
payload_len: 0xFFFF,
};
let mut buf = BytesMut::new();
mini.write_to(&mut buf);
assert_eq!(buf.len(), 4);
assert_eq!(MiniHeader::WIRE_SIZE, 4);
}
#[test]
fn mini_frame_context_expand() {
let baseline = MediaHeader {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 10,
seq: 100,
timestamp: 1000,
fec_block: 5,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
};
let mut ctx = MiniFrameContext::default();
ctx.update(&baseline);
// First expansion
let mini1 = MiniHeader {
timestamp_delta_ms: 20,
payload_len: 80,
};
let h1 = ctx.expand(&mini1).unwrap();
assert_eq!(h1.seq, 101);
assert_eq!(h1.timestamp, 1020);
assert_eq!(h1.codec_id, CodecId::Opus24k);
assert_eq!(h1.fec_block, 5);
// Second expansion — builds on expanded h1
let mini2 = MiniHeader {
timestamp_delta_ms: 20,
payload_len: 80,
};
let h2 = ctx.expand(&mini2).unwrap();
assert_eq!(h2.seq, 102);
assert_eq!(h2.timestamp, 1040);
}
#[test]
fn mini_frame_context_no_baseline() {
let mut ctx = MiniFrameContext::default();
let mini = MiniHeader {
timestamp_delta_ms: 20,
payload_len: 80,
};
assert!(ctx.expand(&mini).is_none());
}
#[test]
fn full_vs_mini_size_comparison() {
// Full frame on wire: 1 byte type tag + 12 byte MediaHeader = 13
let full_size = 1 + MediaHeader::WIRE_SIZE;
assert_eq!(full_size, 13);
// Mini frame on wire: 1 byte type tag + 4 byte MiniHeader = 5
let mini_size = 1 + MiniHeader::WIRE_SIZE;
assert_eq!(mini_size, 5);
// Verify the constants match expectations
assert_eq!(FRAME_TYPE_FULL, 0x00);
assert_eq!(FRAME_TYPE_MINI, 0x01);
}
}