Fixes the two P2 efficacy findings from the Codex audit of the W12 profiles feature. FEC was enabled on the encoder but never used: the jitter buffer's loss path did pure PLC, so the redundancy was wasted bitrate. Now the gap path reconstructs the lost frame from the next buffered packet via Opus in-band FEC (new `AudioDecoder::decode_fec`, libopus decode with fec=true into a one-frame buffer), keeping that packet for its own normal decode and falling back to PLC if FEC decode fails. This is the documented libopus FEC pattern; receiver-side only, no wire change. DTX was enabled on BadNetwork but provided no benefit — the capture noise gate already suppresses silence transmission, and the broadcast DTX silence packets only created seq gaps that grew the jitter cushion. All profiles now set dtx=false (plumbing kept for a future revisit). Adds a jitter-buffer test proving FEC reconstruction beats pure PLC (RMS error < 0.75x) and that the FEC source packet stays buffered. 500 lib tests, clippy + fmt clean, release build clean. Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
12 KiB
Rust
325 lines
12 KiB
Rust
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
|
|
use crate::config::AudioProfile;
|
|
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
|
|
|
|
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
|
|
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
|
|
/// unit-testable function (W12).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OpusParams {
|
|
/// Target bitrate in bits/sec.
|
|
pub bitrate: i32,
|
|
/// Enable in-band forward error correction (loss redundancy in the bitstream).
|
|
pub inband_fec: bool,
|
|
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
|
|
pub packet_loss_perc: i32,
|
|
/// Discontinuous transmission: stop sending during silence to save bandwidth.
|
|
pub dtx: bool,
|
|
}
|
|
|
|
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
|
|
///
|
|
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
|
|
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
|
|
/// leaves headroom for the redundancy on a congested link.
|
|
pub fn opus_params(profile: AudioProfile) -> OpusParams {
|
|
match profile {
|
|
AudioProfile::LowLatency => OpusParams {
|
|
bitrate: 24_000,
|
|
inband_fec: false,
|
|
packet_loss_perc: 0,
|
|
dtx: false,
|
|
},
|
|
AudioProfile::Balanced => OpusParams {
|
|
bitrate: 32_000,
|
|
inband_fec: true,
|
|
packet_loss_perc: 10,
|
|
dtx: false,
|
|
},
|
|
AudioProfile::BadNetwork => OpusParams {
|
|
bitrate: 20_000,
|
|
inband_fec: true,
|
|
packet_loss_perc: 25,
|
|
dtx: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub struct OpusEncoder {
|
|
encoder: Encoder,
|
|
}
|
|
|
|
impl OpusEncoder {
|
|
/// Creates a new Opus encoder.
|
|
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
|
|
pub fn new(
|
|
sample_rate: u32,
|
|
channels: Channels,
|
|
application: Application,
|
|
) -> Result<Self, CodecError> {
|
|
let encoder = Encoder::new(sample_rate, channels, application)
|
|
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
|
Ok(Self { encoder })
|
|
}
|
|
|
|
/// Apply concrete codec parameters to the live encoder. Safe to call between
|
|
/// frames, so the user can switch profile mid-call.
|
|
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
|
|
self.encoder
|
|
.set_bitrate(Bitrate::Bits(params.bitrate))
|
|
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
|
|
self.encoder
|
|
.set_inband_fec(params.inband_fec)
|
|
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
|
|
self.encoder
|
|
.set_packet_loss_perc(params.packet_loss_perc)
|
|
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
|
|
self.encoder
|
|
.set_dtx(params.dtx)
|
|
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
|
|
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
|
|
self.apply_params(&opus_params(profile))
|
|
}
|
|
}
|
|
|
|
impl AudioEncoder for OpusEncoder {
|
|
fn encode(&mut self, pcm: &[i16]) -> Result<Vec<u8>, CodecError> {
|
|
// We allocate a buffer for the compressed output.
|
|
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
|
let mut compressed = vec![0u8; 4000];
|
|
let len = self
|
|
.encoder
|
|
.encode(pcm, &mut compressed)
|
|
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
|
|
|
|
compressed.truncate(len);
|
|
Ok(compressed)
|
|
}
|
|
}
|
|
|
|
pub struct OpusDecoder {
|
|
decoder: Decoder,
|
|
channels: Channels,
|
|
/// Samples-per-channel of the frames we transmit (20ms @ 48kHz = 960).
|
|
/// Used to size the Packet Loss Concealment output, since libopus conceals
|
|
/// `frame_size` samples when given no input — passing the full max buffer
|
|
/// would synthesize a 120ms burst instead of a single 20ms frame.
|
|
frame_samples: usize,
|
|
}
|
|
|
|
impl OpusDecoder {
|
|
/// Creates a new Opus decoder.
|
|
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
|
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
|
|
pub fn new(
|
|
sample_rate: u32,
|
|
channels: Channels,
|
|
frame_samples: usize,
|
|
) -> Result<Self, CodecError> {
|
|
let decoder = Decoder::new(sample_rate, channels)
|
|
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
|
|
Ok(Self {
|
|
decoder,
|
|
channels,
|
|
frame_samples,
|
|
})
|
|
}
|
|
|
|
fn channels_count(&self) -> usize {
|
|
match self.channels {
|
|
Channels::Mono => 1,
|
|
Channels::Stereo => 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AudioDecoder for OpusDecoder {
|
|
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError> {
|
|
let channels_count = self.channels_count();
|
|
|
|
let (mut pcm, input): (Vec<i16>, &[u8]) = match compressed {
|
|
Some(data) if !data.is_empty() => {
|
|
// Normal decode. Size the buffer to the maximum Opus frame (120ms =
|
|
// 5760 samples/channel); libopus decodes the packet's true duration.
|
|
(vec![0i16; 5760 * channels_count], data)
|
|
}
|
|
_ => {
|
|
// Packet Loss Concealment: an empty input makes libopus synthesize
|
|
// exactly `frame_samples` of concealment, so size the buffer to match.
|
|
(vec![0i16; self.frame_samples * channels_count], &[])
|
|
}
|
|
};
|
|
|
|
let decoded_per_channel = self
|
|
.decoder
|
|
.decode(input, &mut pcm, false)
|
|
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
|
|
|
pcm.truncate(decoded_per_channel * channels_count);
|
|
Ok(pcm)
|
|
}
|
|
|
|
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError> {
|
|
let channels_count = self.channels_count();
|
|
let mut pcm = vec![0i16; self.frame_samples * channels_count];
|
|
|
|
let decoded_per_channel = self
|
|
.decoder
|
|
.decode(next_payload, &mut pcm, true)
|
|
.map_err(|e| CodecError::Decode(format!("Opus FEC decoding failed: {}", e)))?;
|
|
|
|
pcm.truncate(decoded_per_channel * channels_count);
|
|
Ok(pcm)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_opus_params_mapping() {
|
|
let low = opus_params(AudioProfile::LowLatency);
|
|
let bal = opus_params(AudioProfile::Balanced);
|
|
let bad = opus_params(AudioProfile::BadNetwork);
|
|
|
|
// LowLatency has no loss redundancy; the other two do.
|
|
assert!(!low.inband_fec);
|
|
assert_eq!(low.packet_loss_perc, 0);
|
|
assert!(bal.inband_fec);
|
|
assert!(bad.inband_fec);
|
|
|
|
// Capture-side gating suppresses silence; no profile adds Opus DTX.
|
|
assert!(!low.dtx && !bal.dtx && !bad.dtx);
|
|
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
|
|
|
|
// BadNetwork trims base bitrate to make room for FEC redundancy.
|
|
assert!(bad.bitrate < bal.bitrate);
|
|
|
|
// All bitrates are sane positive voice rates.
|
|
for p in [low, bal, bad] {
|
|
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
|
|
assert!((0..=100).contains(&p.packet_loss_perc));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_apply_profile_sets_bitrate() {
|
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
|
// Every profile applies cleanly to a real encoder...
|
|
for profile in AudioProfile::ALL {
|
|
encoder.apply_profile(profile).unwrap();
|
|
}
|
|
// ...and the last-applied bitrate is reflected by the encoder.
|
|
encoder.apply_profile(AudioProfile::Balanced).unwrap();
|
|
let want = opus_params(AudioProfile::Balanced).bitrate;
|
|
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
|
|
}
|
|
|
|
#[test]
|
|
fn test_round_trip() {
|
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
|
let mut decoder = OpusDecoder::new(48000, Channels::Mono, 960).unwrap();
|
|
|
|
// 1. Round-trip shape: generate a 960-sample mono PCM frame (440Hz sine wave)
|
|
let mut pcm = vec![0i16; 960];
|
|
for (i, sample) in pcm.iter_mut().enumerate() {
|
|
let t = (i as f32) / 48000.0;
|
|
let val = (t * 440.0 * 2.0 * std::f32::consts::PI).sin();
|
|
*sample = (val * 10000.0) as i16;
|
|
}
|
|
|
|
// encode it
|
|
let compressed = encoder.encode(&pcm).unwrap();
|
|
assert!(
|
|
!compressed.is_empty(),
|
|
"Compressed buffer should not be empty"
|
|
);
|
|
assert!(
|
|
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
|
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
|
compressed.len(),
|
|
pcm.len() * std::mem::size_of::<i16>()
|
|
);
|
|
|
|
// decode it
|
|
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
|
assert_eq!(
|
|
decoded.len(),
|
|
960,
|
|
"Decoded sample count should be exactly 960"
|
|
);
|
|
|
|
// 2. Round-trip carries signal energy (not silence)
|
|
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
|
let rms = (sum_sq / decoded.len() as f64).sqrt();
|
|
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
|
|
assert!(
|
|
rms > 100.0,
|
|
"Decoded signal should carry energy (RMS was {})",
|
|
rms
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_plc_sizing() {
|
|
let mut decoder = OpusDecoder::new(48000, Channels::Mono, 960).unwrap();
|
|
|
|
// decode(None) returns exactly frame_samples (960) samples
|
|
let plc_none = decoder.decode(None).unwrap();
|
|
assert_eq!(
|
|
plc_none.len(),
|
|
960,
|
|
"decode(None) should yield exactly 960 samples"
|
|
);
|
|
|
|
// decode(Some(&[])) (empty slice) does the same
|
|
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
|
assert_eq!(
|
|
plc_empty.len(),
|
|
960,
|
|
"decode(Some(&[])) should yield exactly 960 samples"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decoded_duration_follows_packet() {
|
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
|
let mut decoder = OpusDecoder::new(48000, Channels::Mono, 960).unwrap();
|
|
|
|
let pcm = vec![0i16; 960];
|
|
let compressed = encoder.encode(&pcm).unwrap();
|
|
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
|
assert_eq!(
|
|
decoded.len(),
|
|
960,
|
|
"Decoded sample count should match packet duration"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stereo_plc_sizing() {
|
|
let mut decoder = OpusDecoder::new(48000, Channels::Stereo, 960).unwrap();
|
|
|
|
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
|
let plc_none = decoder.decode(None).unwrap();
|
|
assert_eq!(
|
|
plc_none.len(),
|
|
960 * 2,
|
|
"Stereo decode(None) should yield exactly 1920 samples"
|
|
);
|
|
|
|
// decode(Some(&[])) (empty slice) does the same
|
|
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
|
assert_eq!(
|
|
plc_empty.len(),
|
|
960 * 2,
|
|
"Stereo decode(Some(&[])) should yield exactly 1920 samples"
|
|
);
|
|
}
|
|
}
|