feat: Phase 3 — crypto handshake, codec2, benchmarks, audio I/O, relay forwarding

E2E crypto handshake:
- Client/relay handshake via SignalMessage (CallOffer/CallAnswer)
- X25519 ephemeral key exchange with Ed25519 identity signatures
- Integration tests proving bidirectional encrypt/decrypt

Codec2 integration:
- Pure Rust codec2 crate (v0.3) — no C bindings needed
- MODE_3200 (160 samples/20ms, 8 bytes) and MODE_1200 (320 samples/40ms, 6 bytes)
- 11 new tests including encode/decode roundtrip and adaptive switching

Relay forwarding:
- Bidirectional client → remote forwarding with pipeline processing
- CLI args: --listen, --remote
- Periodic stats logging, clean shutdown via tokio::select!

Benchmark tool (wzp-bench):
- Codec roundtrip, FEC recovery, crypto throughput, full pipeline benchmarks
- Sine wave PCM generator for realistic testing

Audio I/O (cpal):
- AudioCapture (microphone) and AudioPlayback (speakers) at 48kHz mono
- CLI --live mode: mic → encode → send / recv → decode → speakers

120 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 13:43:22 +04:00
parent 43d7f70fe9
commit 79f9ff1596
18 changed files with 2451 additions and 75 deletions

View File

@@ -1,26 +1,38 @@
//! Codec2 decoder — stub implementation.
//! Codec2 decoder — real implementation via the pure-Rust `codec2` crate.
//!
//! Codec2 operates at 8 kHz mono. Resampling back to 48 kHz is handled
//! externally (see `resample.rs` and `AdaptiveCodec`).
//!
//! This is a stub that returns an error on decode. When `codec2-sys`
//! is linked, replace the body of `decode()` with actual FFI calls.
use codec2::{Codec2 as C2, Codec2Mode};
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
/// Stub Codec2 decoder implementing `AudioDecoder`.
/// Maps our `CodecId` to the `codec2` crate's `Codec2Mode`.
fn mode_for(codec: CodecId) -> Result<Codec2Mode, CodecError> {
match codec {
CodecId::Codec2_3200 => Ok(Codec2Mode::MODE_3200),
CodecId::Codec2_1200 => Ok(Codec2Mode::MODE_1200),
other => Err(CodecError::DecodeFailed(format!(
"not a Codec2 variant: {other:?}"
))),
}
}
/// Codec2 decoder implementing `AudioDecoder`.
///
/// Currently returns `CodecError::DecodeFailed` for decode operations.
/// PLC fills output with silence (zeros).
/// Wraps the pure-Rust `codec2` crate. Output is 8 kHz mono i16 PCM;
/// the `AdaptiveDecoder` handles 8 kHz -> 48 kHz upsampling.
pub struct Codec2Decoder {
inner: C2,
codec_id: CodecId,
frame_duration_ms: u8,
}
impl Codec2Decoder {
/// Create a new stub Codec2 decoder.
/// Create a new Codec2 decoder for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
let mode = mode_for(profile.codec)?;
Ok(Self {
inner: C2::new(mode),
codec_id: profile.codec,
frame_duration_ms: profile.frame_duration_ms,
})
@@ -28,21 +40,41 @@ impl Codec2Decoder {
/// Expected number of 8 kHz PCM output samples per frame.
pub fn frame_samples(&self) -> usize {
(8_000 * self.frame_duration_ms as usize) / 1000
self.inner.samples_per_frame()
}
/// Number of compressed bytes per frame.
fn bytes_per_frame(&self) -> usize {
(self.inner.bits_per_frame() + 7) / 8
}
}
impl AudioDecoder for Codec2Decoder {
fn decode(&mut self, _encoded: &[u8], _pcm: &mut [i16]) -> Result<usize, CodecError> {
Err(CodecError::DecodeFailed(
"codec2-sys not yet linked".to_string(),
))
fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
let spf = self.inner.samples_per_frame();
let bpf = self.bytes_per_frame();
if encoded.len() < bpf {
return Err(CodecError::DecodeFailed(format!(
"need {bpf} encoded bytes, got {}",
encoded.len()
)));
}
if pcm.len() < spf {
return Err(CodecError::DecodeFailed(format!(
"output buffer too small: need {spf} samples, got {}",
pcm.len()
)));
}
self.inner.decode(&mut pcm[..spf], &encoded[..bpf]);
Ok(spf)
}
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
let samples = self.frame_samples();
// Codec2 has no built-in PLC. Fill with silence.
let samples = self.inner.samples_per_frame();
let n = samples.min(pcm.len());
// Fill with silence as basic PLC
pcm[..n].fill(0);
Ok(n)
}
@@ -54,6 +86,11 @@ impl AudioDecoder for Codec2Decoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec {
CodecId::Codec2_3200 | CodecId::Codec2_1200 => {
// Recreate the inner decoder if the mode changed.
if profile.codec != self.codec_id {
let mode = mode_for(profile.codec)?;
self.inner = C2::new(mode);
}
self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms;
Ok(())

View File

@@ -1,26 +1,38 @@
//! Codec2 encoder — stub implementation.
//! Codec2 encoder — real implementation via the pure-Rust `codec2` crate.
//!
//! Codec2 operates at 8 kHz mono. Resampling from 48 kHz is handled
//! externally (see `resample.rs` and `AdaptiveCodec`).
//!
//! This is a stub that returns an error on encode. When `codec2-sys`
//! is linked, replace the body of `encode()` with actual FFI calls.
use codec2::{Codec2 as C2, Codec2Mode};
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
/// Stub Codec2 encoder implementing `AudioEncoder`.
/// Maps our `CodecId` to the `codec2` crate's `Codec2Mode`.
fn mode_for(codec: CodecId) -> Result<Codec2Mode, CodecError> {
match codec {
CodecId::Codec2_3200 => Ok(Codec2Mode::MODE_3200),
CodecId::Codec2_1200 => Ok(Codec2Mode::MODE_1200),
other => Err(CodecError::EncodeFailed(format!(
"not a Codec2 variant: {other:?}"
))),
}
}
/// Codec2 encoder implementing `AudioEncoder`.
///
/// Currently returns `CodecError::EncodeFailed` for all encode operations.
/// The structure is ready for drop-in replacement once `codec2-sys` is available.
/// Wraps the pure-Rust `codec2` crate. Input is 8 kHz mono i16 PCM;
/// the `AdaptiveEncoder` handles 48 kHz -> 8 kHz resampling.
pub struct Codec2Encoder {
inner: C2,
codec_id: CodecId,
frame_duration_ms: u8,
}
impl Codec2Encoder {
/// Create a new stub Codec2 encoder.
/// Create a new Codec2 encoder for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
let mode = mode_for(profile.codec)?;
Ok(Self {
inner: C2::new(mode),
codec_id: profile.codec,
frame_duration_ms: profile.frame_duration_ms,
})
@@ -28,15 +40,35 @@ impl Codec2Encoder {
/// Expected number of 8 kHz PCM samples per frame.
pub fn frame_samples(&self) -> usize {
(8_000 * self.frame_duration_ms as usize) / 1000
self.inner.samples_per_frame()
}
/// Number of compressed bytes per frame.
fn bytes_per_frame(&self) -> usize {
(self.inner.bits_per_frame() + 7) / 8
}
}
impl AudioEncoder for Codec2Encoder {
fn encode(&mut self, _pcm: &[i16], _out: &mut [u8]) -> Result<usize, CodecError> {
Err(CodecError::EncodeFailed(
"codec2-sys not yet linked".to_string(),
))
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
let spf = self.inner.samples_per_frame();
let bpf = self.bytes_per_frame();
if pcm.len() < spf {
return Err(CodecError::EncodeFailed(format!(
"need {spf} samples, got {}",
pcm.len()
)));
}
if out.len() < bpf {
return Err(CodecError::EncodeFailed(format!(
"output buffer too small: need {bpf} bytes, got {}",
out.len()
)));
}
self.inner.encode(&mut out[..bpf], &pcm[..spf]);
Ok(bpf)
}
fn codec_id(&self) -> CodecId {
@@ -46,6 +78,11 @@ impl AudioEncoder for Codec2Encoder {
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
match profile.codec {
CodecId::Codec2_3200 | CodecId::Codec2_1200 => {
// Recreate the inner encoder if the mode changed.
if profile.codec != self.codec_id {
let mode = mode_for(profile.codec)?;
self.inner = C2::new(mode);
}
self.codec_id = profile.codec;
self.frame_duration_ms = profile.frame_duration_ms;
Ok(())

View File

@@ -2,7 +2,7 @@
//!
//! Provides audio encoding/decoding with adaptive codec switching:
//! - Opus (24kbps / 16kbps / 6kbps) for normal to degraded conditions
//! - Codec2 (3200bps / 1200bps) via C bindings for catastrophic conditions
//! - Codec2 (3200bps / 1200bps) via the pure-Rust `codec2` crate for catastrophic conditions
//!
//! ## Usage
//!
@@ -40,3 +40,184 @@ pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
.expect("failed to create adaptive decoder"),
)
}
#[cfg(test)]
mod codec2_tests {
use super::*;
use crate::codec2_dec::Codec2Decoder;
use crate::codec2_enc::Codec2Encoder;
fn c2_3200_profile() -> QualityProfile {
QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
}
}
fn c2_1200_profile() -> QualityProfile {
QualityProfile::CATASTROPHIC
}
// ── Frame size tests ────────────────────────────────────────────────
#[test]
fn codec2_3200_frame_sizes() {
let enc = Codec2Encoder::new(c2_3200_profile()).unwrap();
// 3200bps: 160 samples/frame @ 8kHz (20ms), 8 bytes output
assert_eq!(enc.frame_samples(), 160);
}
#[test]
fn codec2_1200_frame_sizes() {
let enc = Codec2Encoder::new(c2_1200_profile()).unwrap();
// 1200bps: 320 samples/frame @ 8kHz (40ms), 6 bytes output
assert_eq!(enc.frame_samples(), 320);
}
// ── Encode/Decode roundtrip tests ───────────────────────────────────
#[test]
fn codec2_3200_encode_decode_roundtrip() {
let mut enc = Codec2Encoder::new(c2_3200_profile()).unwrap();
let mut dec = Codec2Decoder::new(c2_3200_profile()).unwrap();
// 160 samples of silence at 8kHz
let pcm_in = vec![0i16; 160];
let mut encoded = vec![0u8; 16];
let enc_bytes = enc.encode(&pcm_in, &mut encoded).unwrap();
assert_eq!(enc_bytes, 8, "3200bps should produce 8 bytes per frame");
let mut pcm_out = vec![0i16; 160];
let dec_samples = dec.decode(&encoded[..enc_bytes], &mut pcm_out).unwrap();
assert_eq!(dec_samples, 160, "3200bps should decode to 160 samples");
}
#[test]
fn codec2_1200_encode_decode_roundtrip() {
let mut enc = Codec2Encoder::new(c2_1200_profile()).unwrap();
let mut dec = Codec2Decoder::new(c2_1200_profile()).unwrap();
// 320 samples of silence at 8kHz
let pcm_in = vec![0i16; 320];
let mut encoded = vec![0u8; 16];
let enc_bytes = enc.encode(&pcm_in, &mut encoded).unwrap();
assert_eq!(enc_bytes, 6, "1200bps should produce 6 bytes per frame");
let mut pcm_out = vec![0i16; 320];
let dec_samples = dec.decode(&encoded[..enc_bytes], &mut pcm_out).unwrap();
assert_eq!(dec_samples, 320, "1200bps should decode to 320 samples");
}
#[test]
fn codec2_3200_encode_produces_bytes() {
let mut enc = Codec2Encoder::new(c2_3200_profile()).unwrap();
// Feed a non-silent signal to ensure encoding produces non-trivial output.
let pcm_in: Vec<i16> = (0..160).map(|i| (i * 100) as i16).collect();
let mut encoded = vec![0u8; 16];
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
assert_eq!(n, 8);
// At least some non-zero bytes in the output.
assert!(encoded[..n].iter().any(|&b| b != 0));
}
#[test]
fn codec2_1200_encode_produces_bytes() {
let mut enc = Codec2Encoder::new(c2_1200_profile()).unwrap();
let pcm_in: Vec<i16> = (0..320).map(|i| (i * 50) as i16).collect();
let mut encoded = vec![0u8; 16];
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
assert_eq!(n, 6);
assert!(encoded[..n].iter().any(|&b| b != 0));
}
// ── Error handling tests ────────────────────────────────────────────
#[test]
fn codec2_encode_rejects_short_input() {
let mut enc = Codec2Encoder::new(c2_3200_profile()).unwrap();
let pcm_in = vec![0i16; 10]; // too few samples
let mut out = vec![0u8; 16];
assert!(enc.encode(&pcm_in, &mut out).is_err());
}
#[test]
fn codec2_decode_rejects_short_input() {
let mut dec = Codec2Decoder::new(c2_3200_profile()).unwrap();
let encoded = vec![0u8; 2]; // too few bytes
let mut pcm = vec![0i16; 160];
assert!(dec.decode(&encoded, &mut pcm).is_err());
}
// ── Adaptive switching: Opus → Codec2 → Opus roundtrip ─────────────
#[test]
fn adaptive_opus_to_codec2_to_opus_roundtrip() {
let mut enc = AdaptiveEncoder::new(QualityProfile::GOOD).unwrap();
let mut dec = AdaptiveDecoder::new(QualityProfile::GOOD).unwrap();
// Step 1: Encode/decode with Opus (20ms @ 48kHz = 960 samples).
let pcm_48k = vec![0i16; 960];
let mut encoded = vec![0u8; 512];
let n = enc.encode(&pcm_48k, &mut encoded).unwrap();
assert!(n > 0);
let mut pcm_out = vec![0i16; 960];
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
assert_eq!(samples, 960);
// Step 2: Switch to Codec2 1200.
enc.set_profile(QualityProfile::CATASTROPHIC).unwrap();
dec.set_profile(QualityProfile::CATASTROPHIC).unwrap();
assert_eq!(enc.codec_id(), CodecId::Codec2_1200);
// Codec2 1200 @ 40ms needs 1920 samples at 48kHz (resampled internally to 320 @ 8kHz).
let pcm_48k_c2 = vec![0i16; 1920];
let mut encoded_c2 = vec![0u8; 16];
let n_c2 = enc.encode(&pcm_48k_c2, &mut encoded_c2).unwrap();
assert_eq!(n_c2, 6, "Codec2 1200 should produce 6 bytes");
let mut pcm_out_c2 = vec![0i16; 1920];
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
assert_eq!(samples_c2, 1920, "should get 1920 samples at 48kHz after upsample");
// Step 3: Switch back to Opus.
enc.set_profile(QualityProfile::GOOD).unwrap();
dec.set_profile(QualityProfile::GOOD).unwrap();
assert_eq!(enc.codec_id(), CodecId::Opus24k);
let n_opus = enc.encode(&pcm_48k, &mut encoded).unwrap();
assert!(n_opus > 0);
let samples_opus = dec.decode(&encoded[..n_opus], &mut pcm_out).unwrap();
assert_eq!(samples_opus, 960);
}
// ── PLC (decode_lost) test ──────────────────────────────────────────
#[test]
fn codec2_decode_lost_produces_silence() {
let mut dec = Codec2Decoder::new(c2_3200_profile()).unwrap();
let mut pcm = vec![1i16; 160];
let n = dec.decode_lost(&mut pcm).unwrap();
assert_eq!(n, 160);
assert!(pcm.iter().all(|&s| s == 0));
}
// ── Mode switching within Codec2 ────────────────────────────────────
#[test]
fn codec2_encoder_switches_3200_to_1200() {
let mut enc = Codec2Encoder::new(c2_3200_profile()).unwrap();
assert_eq!(enc.frame_samples(), 160);
enc.set_profile(c2_1200_profile()).unwrap();
assert_eq!(enc.frame_samples(), 320);
let pcm_in = vec![0i16; 320];
let mut out = vec![0u8; 16];
let n = enc.encode(&pcm_in, &mut out).unwrap();
assert_eq!(n, 6);
}
}