test(lesavka): keep coverage helpers out of source gates
This commit is contained in:
parent
84f84b4891
commit
3c7ccfdbcc
@ -179,206 +179,5 @@ impl Drop for OpusPacketEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "audio_codec/tests/mod.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use lesavka_common::lesavka::AudioEncoding;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn requested_audio_codec_defaults_to_pcm_and_parses_opus() {
|
|
||||||
temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || {
|
|
||||||
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || {
|
|
||||||
assert_eq!(
|
|
||||||
requested_upstream_audio_codec_from_env(),
|
|
||||||
UpstreamAudioCodec::PcmS16le
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || {
|
|
||||||
assert_eq!(
|
|
||||||
requested_upstream_audio_codec_from_env(),
|
|
||||||
UpstreamAudioCodec::PcmS16le
|
|
||||||
);
|
|
||||||
});
|
|
||||||
temp_env::with_var(AUDIO_CODEC_ENV, Some("opus"), || {
|
|
||||||
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("pcm"), || {
|
|
||||||
assert_eq!(
|
|
||||||
requested_upstream_audio_codec_from_env(),
|
|
||||||
UpstreamAudioCodec::Opus
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || {
|
|
||||||
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("compressed"), || {
|
|
||||||
assert_eq!(
|
|
||||||
requested_upstream_audio_codec_from_env(),
|
|
||||||
UpstreamAudioCodec::Opus
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
temp_env::with_var(AUDIO_CODEC_ENV, Some("aac"), || {
|
|
||||||
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("opus"), || {
|
|
||||||
assert_eq!(
|
|
||||||
requested_upstream_audio_codec_from_env(),
|
|
||||||
UpstreamAudioCodec::PcmS16le
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opus_encoder_preserves_packet_timing_when_plugin_is_available() {
|
|
||||||
let _ = gst::init();
|
|
||||||
if gst::ElementFactory::find("opusenc").is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut packet = AudioPacket {
|
|
||||||
pts: 123_000,
|
|
||||||
data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize],
|
|
||||||
frame_duration_us: 20_000,
|
|
||||||
..AudioPacket::default()
|
|
||||||
};
|
|
||||||
let raw_len = packet.data.len();
|
|
||||||
audio_transport::mark_packet_pcm_s16le(&mut packet);
|
|
||||||
|
|
||||||
let mut encoder = OpusPacketEncoder::new().expect("opus encoder");
|
|
||||||
let encoded = encoder
|
|
||||||
.encode_packet(packet.clone())
|
|
||||||
.expect("encode")
|
|
||||||
.unwrap_or_else(|| packet.clone());
|
|
||||||
assert_eq!(encoded.pts, 123_000);
|
|
||||||
assert_eq!(encoded.sample_rate, 48_000);
|
|
||||||
assert_eq!(encoded.channels, 2);
|
|
||||||
if encoded.encoding == AudioEncoding::Opus as i32 {
|
|
||||||
assert!(
|
|
||||||
encoded.data.len() < raw_len,
|
|
||||||
"Opus packet should be smaller than raw PCM"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opus_encoder_decodes_non_silent_voice_like_pcm_when_plugin_is_available() {
|
|
||||||
let _ = gst::init();
|
|
||||||
if gst::ElementFactory::find("opusenc").is_none()
|
|
||||||
|| gst::ElementFactory::find("opusdec").is_none()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut packet = AudioPacket {
|
|
||||||
pts: 456_000,
|
|
||||||
data: sine_pcm_packet(
|
|
||||||
440.0,
|
|
||||||
AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize,
|
|
||||||
),
|
|
||||||
frame_duration_us: 20_000,
|
|
||||||
..AudioPacket::default()
|
|
||||||
};
|
|
||||||
audio_transport::mark_packet_pcm_s16le(&mut packet);
|
|
||||||
|
|
||||||
let mut encoder = OpusPacketEncoder::new().expect("opus encoder");
|
|
||||||
let mut encoded = None;
|
|
||||||
for _ in 0..4 {
|
|
||||||
encoded = encoder.encode_packet(packet.clone()).expect("encode");
|
|
||||||
if encoded.is_some() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(encoded) = encoded else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let decoded = decode_opus_payload(&encoded.data).expect("decode opus payload");
|
|
||||||
let peak = decoded
|
|
||||||
.chunks_exact(2)
|
|
||||||
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs())
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0);
|
|
||||||
assert!(
|
|
||||||
peak > 1_000,
|
|
||||||
"decoded Opus should contain a real waveform, not silence/garbage"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packet_metadata_keeps_capture_timing_without_pcm_payload() {
|
|
||||||
let packet = AudioPacket {
|
|
||||||
pts: 987_000,
|
|
||||||
encoding: AudioEncoding::PcmS16le as i32,
|
|
||||||
sample_rate: 48_000,
|
|
||||||
channels: 2,
|
|
||||||
frame_duration_us: 20_000,
|
|
||||||
data: vec![0x11; 3_840],
|
|
||||||
..AudioPacket::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata = packet_metadata(&packet);
|
|
||||||
|
|
||||||
assert_eq!(metadata.pts, 987_000);
|
|
||||||
assert_eq!(metadata.encoding, AudioEncoding::PcmS16le as i32);
|
|
||||||
assert_eq!(metadata.sample_rate, 48_000);
|
|
||||||
assert_eq!(metadata.channels, 2);
|
|
||||||
assert_eq!(metadata.frame_duration_us, 20_000);
|
|
||||||
assert!(metadata.data.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_packet_metadata_drops_oldest_when_encoder_lags() {
|
|
||||||
let mut pending = VecDeque::new();
|
|
||||||
|
|
||||||
for pts in 100..120 {
|
|
||||||
push_pending_packet(
|
|
||||||
&mut pending,
|
|
||||||
AudioPacket {
|
|
||||||
pts,
|
|
||||||
..AudioPacket::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA);
|
|
||||||
assert_eq!(pending.front().expect("oldest retained").pts, 104);
|
|
||||||
assert_eq!(pending.back().expect("newest retained").pts, 119);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sine_pcm_packet(freq_hz: f32, len: usize) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(len);
|
|
||||||
let frames = len / 4;
|
|
||||||
for frame in 0..frames {
|
|
||||||
let phase = (frame as f32 * freq_hz * std::f32::consts::TAU) / 48_000.0;
|
|
||||||
let sample = (phase.sin() * 12_000.0) as i16;
|
|
||||||
out.extend_from_slice(&sample.to_le_bytes());
|
|
||||||
out.extend_from_slice(&sample.to_le_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_opus_payload(payload: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
let desc = "\
|
|
||||||
appsrc name=src is-live=true block=false format=time \
|
|
||||||
caps=audio/x-opus,channel-mapping-family=0 ! \
|
|
||||||
opusdec plc=false use-inband-fec=false min-latency=0 tolerance=0 ! \
|
|
||||||
audioconvert ! audioresample ! \
|
|
||||||
audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
|
||||||
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
|
||||||
let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
|
|
||||||
let appsrc = pipeline
|
|
||||||
.by_name("src")?
|
|
||||||
.downcast::<gst_app::AppSrc>()
|
|
||||||
.ok()?;
|
|
||||||
let appsink = pipeline
|
|
||||||
.by_name("sink")?
|
|
||||||
.downcast::<gst_app::AppSink>()
|
|
||||||
.ok()?;
|
|
||||||
pipeline.set_state(gst::State::Playing).ok()?;
|
|
||||||
let mut buffer = gst::Buffer::from_slice(payload.to_vec());
|
|
||||||
if let Some(meta) = buffer.get_mut() {
|
|
||||||
meta.set_pts(Some(gst::ClockTime::from_useconds(456_000)));
|
|
||||||
meta.set_duration(Some(gst::ClockTime::from_useconds(20_000)));
|
|
||||||
}
|
|
||||||
appsrc.push_buffer(buffer).ok()?;
|
|
||||||
let sample = appsink.try_pull_sample(gst::ClockTime::from_mseconds(100))?;
|
|
||||||
let decoded = sample.buffer()?.map_readable().ok()?.to_vec();
|
|
||||||
let _ = pipeline.set_state(gst::State::Null);
|
|
||||||
Some(decoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
201
client/src/input/audio_codec/tests/mod.rs
Normal file
201
client/src/input/audio_codec/tests/mod.rs
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
use super::*;
|
||||||
|
use lesavka_common::lesavka::AudioEncoding;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requested_audio_codec_defaults_to_pcm_and_parses_opus() {
|
||||||
|
temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || {
|
||||||
|
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || {
|
||||||
|
assert_eq!(
|
||||||
|
requested_upstream_audio_codec_from_env(),
|
||||||
|
UpstreamAudioCodec::PcmS16le
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
temp_env::with_var(AUDIO_CODEC_ENV, Some("pcm"), || {
|
||||||
|
assert_eq!(
|
||||||
|
requested_upstream_audio_codec_from_env(),
|
||||||
|
UpstreamAudioCodec::PcmS16le
|
||||||
|
);
|
||||||
|
});
|
||||||
|
temp_env::with_var(AUDIO_CODEC_ENV, Some("opus"), || {
|
||||||
|
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("pcm"), || {
|
||||||
|
assert_eq!(
|
||||||
|
requested_upstream_audio_codec_from_env(),
|
||||||
|
UpstreamAudioCodec::Opus
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
temp_env::with_var(AUDIO_CODEC_ENV, None::<&str>, || {
|
||||||
|
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("compressed"), || {
|
||||||
|
assert_eq!(
|
||||||
|
requested_upstream_audio_codec_from_env(),
|
||||||
|
UpstreamAudioCodec::Opus
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
temp_env::with_var(AUDIO_CODEC_ENV, Some("aac"), || {
|
||||||
|
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, Some("opus"), || {
|
||||||
|
assert_eq!(
|
||||||
|
requested_upstream_audio_codec_from_env(),
|
||||||
|
UpstreamAudioCodec::PcmS16le
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opus_encoder_preserves_packet_timing_when_plugin_is_available() {
|
||||||
|
let _ = gst::init();
|
||||||
|
if gst::ElementFactory::find("opusenc").is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut packet = AudioPacket {
|
||||||
|
pts: 123_000,
|
||||||
|
data: vec![0; AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize],
|
||||||
|
frame_duration_us: 20_000,
|
||||||
|
..AudioPacket::default()
|
||||||
|
};
|
||||||
|
let raw_len = packet.data.len();
|
||||||
|
audio_transport::mark_packet_pcm_s16le(&mut packet);
|
||||||
|
|
||||||
|
let mut encoder = OpusPacketEncoder::new().expect("opus encoder");
|
||||||
|
let encoded = encoder
|
||||||
|
.encode_packet(packet.clone())
|
||||||
|
.expect("encode")
|
||||||
|
.unwrap_or_else(|| packet.clone());
|
||||||
|
assert_eq!(encoded.pts, 123_000);
|
||||||
|
assert_eq!(encoded.sample_rate, 48_000);
|
||||||
|
assert_eq!(encoded.channels, 2);
|
||||||
|
if encoded.encoding == AudioEncoding::Opus as i32 {
|
||||||
|
assert!(
|
||||||
|
encoded.data.len() < raw_len,
|
||||||
|
"Opus packet should be smaller than raw PCM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opus_encoder_decodes_non_silent_voice_like_pcm_when_plugin_is_available() {
|
||||||
|
let _ = gst::init();
|
||||||
|
if gst::ElementFactory::find("opusenc").is_none()
|
||||||
|
|| gst::ElementFactory::find("opusdec").is_none()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut packet = AudioPacket {
|
||||||
|
pts: 456_000,
|
||||||
|
data: sine_pcm_packet(
|
||||||
|
440.0,
|
||||||
|
AudioTransportProfile::pcm_s16le().expected_payload_bytes() as usize,
|
||||||
|
),
|
||||||
|
frame_duration_us: 20_000,
|
||||||
|
..AudioPacket::default()
|
||||||
|
};
|
||||||
|
audio_transport::mark_packet_pcm_s16le(&mut packet);
|
||||||
|
|
||||||
|
let mut encoder = OpusPacketEncoder::new().expect("opus encoder");
|
||||||
|
let mut encoded = None;
|
||||||
|
for _ in 0..4 {
|
||||||
|
encoded = encoder.encode_packet(packet.clone()).expect("encode");
|
||||||
|
if encoded.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(encoded) = encoded else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded = decode_opus_payload(&encoded.data).expect("decode opus payload");
|
||||||
|
let peak = decoded
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(
|
||||||
|
peak > 1_000,
|
||||||
|
"decoded Opus should contain a real waveform, not silence/garbage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packet_metadata_keeps_capture_timing_without_pcm_payload() {
|
||||||
|
let packet = AudioPacket {
|
||||||
|
pts: 987_000,
|
||||||
|
encoding: AudioEncoding::PcmS16le as i32,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
frame_duration_us: 20_000,
|
||||||
|
data: vec![0x11; 3_840],
|
||||||
|
..AudioPacket::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = packet_metadata(&packet);
|
||||||
|
|
||||||
|
assert_eq!(metadata.pts, 987_000);
|
||||||
|
assert_eq!(metadata.encoding, AudioEncoding::PcmS16le as i32);
|
||||||
|
assert_eq!(metadata.sample_rate, 48_000);
|
||||||
|
assert_eq!(metadata.channels, 2);
|
||||||
|
assert_eq!(metadata.frame_duration_us, 20_000);
|
||||||
|
assert!(metadata.data.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_packet_metadata_drops_oldest_when_encoder_lags() {
|
||||||
|
let mut pending = VecDeque::new();
|
||||||
|
|
||||||
|
for pts in 100..120 {
|
||||||
|
push_pending_packet(
|
||||||
|
&mut pending,
|
||||||
|
AudioPacket {
|
||||||
|
pts,
|
||||||
|
..AudioPacket::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA);
|
||||||
|
assert_eq!(pending.front().expect("oldest retained").pts, 104);
|
||||||
|
assert_eq!(pending.back().expect("newest retained").pts, 119);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sine_pcm_packet(freq_hz: f32, len: usize) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(len);
|
||||||
|
let frames = len / 4;
|
||||||
|
for frame in 0..frames {
|
||||||
|
let phase = (frame as f32 * freq_hz * std::f32::consts::TAU) / 48_000.0;
|
||||||
|
let sample = (phase.sin() * 12_000.0) as i16;
|
||||||
|
out.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
out.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_opus_payload(payload: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let desc = "\
|
||||||
|
appsrc name=src is-live=true block=false format=time \
|
||||||
|
caps=audio/x-opus,channel-mapping-family=0 ! \
|
||||||
|
opusdec plc=false use-inband-fec=false min-latency=0 tolerance=0 ! \
|
||||||
|
audioconvert ! audioresample ! \
|
||||||
|
audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
||||||
|
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
||||||
|
let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
|
||||||
|
let appsrc = pipeline
|
||||||
|
.by_name("src")?
|
||||||
|
.downcast::<gst_app::AppSrc>()
|
||||||
|
.ok()?;
|
||||||
|
let appsink = pipeline
|
||||||
|
.by_name("sink")?
|
||||||
|
.downcast::<gst_app::AppSink>()
|
||||||
|
.ok()?;
|
||||||
|
pipeline.set_state(gst::State::Playing).ok()?;
|
||||||
|
let mut buffer = gst::Buffer::from_slice(payload.to_vec());
|
||||||
|
if let Some(meta) = buffer.get_mut() {
|
||||||
|
meta.set_pts(Some(gst::ClockTime::from_useconds(456_000)));
|
||||||
|
meta.set_duration(Some(gst::ClockTime::from_useconds(20_000)));
|
||||||
|
}
|
||||||
|
appsrc.push_buffer(buffer).ok()?;
|
||||||
|
let sample = appsink.try_pull_sample(gst::ClockTime::from_mseconds(100))?;
|
||||||
|
let decoded = sample.buffer()?.map_readable().ok()?.to_vec();
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
Some(decoded)
|
||||||
|
}
|
||||||
@ -68,224 +68,5 @@ include!("camera/preview_tap.rs");
|
|||||||
include!("camera/bus_and_encoder.rs");
|
include!("camera/bus_and_encoder.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "camera/tests/mod.rs"]
|
||||||
use super::{
|
mod tests;
|
||||||
CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile,
|
|
||||||
resolved_output_profile,
|
|
||||||
};
|
|
||||||
use serial_test::serial;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// Keeps the selected local webcam mode independent from the UVC gadget mode.
|
|
||||||
fn local_capture_profile_keeps_launcher_quality_env_by_default() {
|
|
||||||
let cfg = CameraConfig {
|
|
||||||
codec: CameraCodec::Mjpeg,
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
fps: 20,
|
|
||||||
};
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_WIDTH", Some("1280")),
|
|
||||||
("LESAVKA_CAM_HEIGHT", Some("720")),
|
|
||||||
("LESAVKA_CAM_FPS", Some("30")),
|
|
||||||
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
|
||||||
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
|
|
||||||
],
|
|
||||||
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// UVC output must match the gadget profile that browsers negotiate.
|
|
||||||
fn negotiated_output_profile_matches_server_uvc_contract_by_default() {
|
|
||||||
let cfg = CameraConfig {
|
|
||||||
codec: CameraCodec::Mjpeg,
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
fps: 20,
|
|
||||||
};
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_WIDTH", Some("1280")),
|
|
||||||
("LESAVKA_CAM_HEIGHT", Some("720")),
|
|
||||||
("LESAVKA_CAM_FPS", Some("30")),
|
|
||||||
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
|
||||||
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let capture_profile = resolved_capture_profile(Some(cfg));
|
|
||||||
assert_eq!(capture_profile, (1280, 720, 30));
|
|
||||||
assert_eq!(
|
|
||||||
resolved_output_profile(Some(cfg), capture_profile),
|
|
||||||
(640, 480, 20)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// Keeps UI-profile emission explicit until the server can reconfigure UVC.
|
|
||||||
fn explicit_ui_profile_emission_keeps_lab_mode_available() {
|
|
||||||
let cfg = CameraConfig {
|
|
||||||
codec: CameraCodec::Mjpeg,
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
fps: 20,
|
|
||||||
};
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_WIDTH", Some("1280")),
|
|
||||||
("LESAVKA_CAM_HEIGHT", Some("720")),
|
|
||||||
("LESAVKA_CAM_FPS", Some("30")),
|
|
||||||
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
|
||||||
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let capture_profile = resolved_capture_profile(Some(cfg));
|
|
||||||
assert_eq!(capture_profile, (1280, 720, 30));
|
|
||||||
assert_eq!(
|
|
||||||
resolved_output_profile(Some(cfg), capture_profile),
|
|
||||||
(1280, 720, 30)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// The safety lock wins if both experimental flags are set.
|
|
||||||
fn explicit_server_profile_lock_wins_over_ui_emission() {
|
|
||||||
let cfg = CameraConfig {
|
|
||||||
codec: CameraCodec::Mjpeg,
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
fps: 20,
|
|
||||||
};
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_WIDTH", Some("1280")),
|
|
||||||
("LESAVKA_CAM_HEIGHT", Some("720")),
|
|
||||||
("LESAVKA_CAM_FPS", Some("30")),
|
|
||||||
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")),
|
|
||||||
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let capture_profile = resolved_capture_profile(Some(cfg));
|
|
||||||
assert_eq!(capture_profile, (1280, 720, 30));
|
|
||||||
assert_eq!(
|
|
||||||
resolved_output_profile(Some(cfg), capture_profile),
|
|
||||||
(640, 480, 20)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// HEVC lab fallback options must stay shaped for live transport.
|
|
||||||
fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() {
|
|
||||||
temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || {
|
|
||||||
let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30);
|
|
||||||
|
|
||||||
assert!(options.starts_with("x265enc "));
|
|
||||||
assert!(options.contains("tune=zerolatency"));
|
|
||||||
assert!(options.contains("speed-preset=ultrafast"));
|
|
||||||
assert!(options.contains("bitrate=2400"));
|
|
||||||
assert!(options.contains("key-int-max=30"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// Vulkan H.264 hardware encode should stay live-call shaped when available.
|
|
||||||
fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() {
|
|
||||||
temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || {
|
|
||||||
let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
options,
|
|
||||||
"vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// HEVC should recover quickly after freshness drops without changing H.264 knobs.
|
|
||||||
fn hevc_keyframe_interval_defaults_short_and_honors_overrides() {
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>),
|
|
||||||
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
assert_eq!(hevc_keyframe_interval(30), 1);
|
|
||||||
assert_eq!(hevc_keyframe_interval(2), 1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")),
|
|
||||||
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
assert_eq!(hevc_keyframe_interval(30), 2);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[test]
|
|
||||||
/// Coverage builds use a deterministic HEVC encoder choice.
|
|
||||||
fn coverage_hevc_encoder_choice_is_stable() {
|
|
||||||
assert_eq!(
|
|
||||||
CameraCapture::choose_hevc_encoder().unwrap(),
|
|
||||||
("x265enc", Some("key-int-max"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
/// Coverage builds can exercise H.264 encoder branches without host GPU assumptions.
|
|
||||||
fn coverage_h264_encoder_choice_honors_stable_test_overrides() {
|
|
||||||
let cases = [
|
|
||||||
("nvh264enc", ("nvh264enc", None)),
|
|
||||||
("vulkanh264enc", ("vulkanh264enc", Some("idr-period"))),
|
|
||||||
("vaapih264enc", ("vaapih264enc", Some("keyframe-period"))),
|
|
||||||
("v4l2h264enc", ("v4l2h264enc", Some("idrcount"))),
|
|
||||||
("unknown", ("x264enc", Some("key-int-max"))),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (override_value, expected) in cases {
|
|
||||||
temp_env::with_var("LESAVKA_CAM_TEST_ENCODER", Some(override_value), || {
|
|
||||||
assert_eq!(CameraCapture::choose_encoder().unwrap(), expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[test]
|
|
||||||
/// Coverage mode keeps software video fallback enabled for deterministic tests.
|
|
||||||
fn coverage_software_video_fallback_is_enabled() {
|
|
||||||
assert!(CameraCapture::software_video_fallback_allowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
#[test]
|
|
||||||
/// Coverage mode replaces the FFmpeg preview reader with an inert cancellation flag.
|
|
||||||
fn coverage_ffmpeg_preview_tap_stub_starts_stopped() {
|
|
||||||
let running = super::spawn_ffmpeg_raw_preview_tap(
|
|
||||||
std::io::empty(),
|
|
||||||
std::path::PathBuf::from("/tmp/lesavka-preview-tap.coverage"),
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(!running.load(std::sync::atomic::Ordering::Acquire));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
219
client/src/input/camera/tests/mod.rs
Normal file
219
client/src/input/camera/tests/mod.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
use super::{
|
||||||
|
CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile,
|
||||||
|
resolved_output_profile,
|
||||||
|
};
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// Keeps the selected local webcam mode independent from the UVC gadget mode.
|
||||||
|
fn local_capture_profile_keeps_launcher_quality_env_by_default() {
|
||||||
|
let cfg = CameraConfig {
|
||||||
|
codec: CameraCodec::Mjpeg,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
fps: 20,
|
||||||
|
};
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_WIDTH", Some("1280")),
|
||||||
|
("LESAVKA_CAM_HEIGHT", Some("720")),
|
||||||
|
("LESAVKA_CAM_FPS", Some("30")),
|
||||||
|
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
||||||
|
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
|
||||||
|
],
|
||||||
|
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// UVC output must match the gadget profile that browsers negotiate.
|
||||||
|
fn negotiated_output_profile_matches_server_uvc_contract_by_default() {
|
||||||
|
let cfg = CameraConfig {
|
||||||
|
codec: CameraCodec::Mjpeg,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
fps: 20,
|
||||||
|
};
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_WIDTH", Some("1280")),
|
||||||
|
("LESAVKA_CAM_HEIGHT", Some("720")),
|
||||||
|
("LESAVKA_CAM_FPS", Some("30")),
|
||||||
|
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
||||||
|
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let capture_profile = resolved_capture_profile(Some(cfg));
|
||||||
|
assert_eq!(capture_profile, (1280, 720, 30));
|
||||||
|
assert_eq!(
|
||||||
|
resolved_output_profile(Some(cfg), capture_profile),
|
||||||
|
(640, 480, 20)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// Keeps UI-profile emission explicit until the server can reconfigure UVC.
|
||||||
|
fn explicit_ui_profile_emission_keeps_lab_mode_available() {
|
||||||
|
let cfg = CameraConfig {
|
||||||
|
codec: CameraCodec::Mjpeg,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
fps: 20,
|
||||||
|
};
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_WIDTH", Some("1280")),
|
||||||
|
("LESAVKA_CAM_HEIGHT", Some("720")),
|
||||||
|
("LESAVKA_CAM_FPS", Some("30")),
|
||||||
|
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
|
||||||
|
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let capture_profile = resolved_capture_profile(Some(cfg));
|
||||||
|
assert_eq!(capture_profile, (1280, 720, 30));
|
||||||
|
assert_eq!(
|
||||||
|
resolved_output_profile(Some(cfg), capture_profile),
|
||||||
|
(1280, 720, 30)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// The safety lock wins if both experimental flags are set.
|
||||||
|
fn explicit_server_profile_lock_wins_over_ui_emission() {
|
||||||
|
let cfg = CameraConfig {
|
||||||
|
codec: CameraCodec::Mjpeg,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
fps: 20,
|
||||||
|
};
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_WIDTH", Some("1280")),
|
||||||
|
("LESAVKA_CAM_HEIGHT", Some("720")),
|
||||||
|
("LESAVKA_CAM_FPS", Some("30")),
|
||||||
|
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")),
|
||||||
|
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
let capture_profile = resolved_capture_profile(Some(cfg));
|
||||||
|
assert_eq!(capture_profile, (1280, 720, 30));
|
||||||
|
assert_eq!(
|
||||||
|
resolved_output_profile(Some(cfg), capture_profile),
|
||||||
|
(640, 480, 20)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// HEVC lab fallback options must stay shaped for live transport.
|
||||||
|
fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() {
|
||||||
|
temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || {
|
||||||
|
let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30);
|
||||||
|
|
||||||
|
assert!(options.starts_with("x265enc "));
|
||||||
|
assert!(options.contains("tune=zerolatency"));
|
||||||
|
assert!(options.contains("speed-preset=ultrafast"));
|
||||||
|
assert!(options.contains("bitrate=2400"));
|
||||||
|
assert!(options.contains("key-int-max=30"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// Vulkan H.264 hardware encode should stay live-call shaped when available.
|
||||||
|
fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() {
|
||||||
|
temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || {
|
||||||
|
let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
options,
|
||||||
|
"vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// HEVC should recover quickly after freshness drops without changing H.264 knobs.
|
||||||
|
fn hevc_keyframe_interval_defaults_short_and_honors_overrides() {
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>),
|
||||||
|
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(hevc_keyframe_interval(30), 1);
|
||||||
|
assert_eq!(hevc_keyframe_interval(2), 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
temp_env::with_vars(
|
||||||
|
[
|
||||||
|
("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")),
|
||||||
|
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
assert_eq!(hevc_keyframe_interval(30), 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[test]
|
||||||
|
/// Coverage builds use a deterministic HEVC encoder choice.
|
||||||
|
fn coverage_hevc_encoder_choice_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
CameraCapture::choose_hevc_encoder().unwrap(),
|
||||||
|
("x265enc", Some("key-int-max"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
/// Coverage builds can exercise H.264 encoder branches without host GPU assumptions.
|
||||||
|
fn coverage_h264_encoder_choice_honors_stable_test_overrides() {
|
||||||
|
let cases = [
|
||||||
|
("nvh264enc", ("nvh264enc", None)),
|
||||||
|
("vulkanh264enc", ("vulkanh264enc", Some("idr-period"))),
|
||||||
|
("vaapih264enc", ("vaapih264enc", Some("keyframe-period"))),
|
||||||
|
("v4l2h264enc", ("v4l2h264enc", Some("idrcount"))),
|
||||||
|
("unknown", ("x264enc", Some("key-int-max"))),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (override_value, expected) in cases {
|
||||||
|
temp_env::with_var("LESAVKA_CAM_TEST_ENCODER", Some(override_value), || {
|
||||||
|
assert_eq!(CameraCapture::choose_encoder().unwrap(), expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[test]
|
||||||
|
/// Coverage mode keeps software video fallback enabled for deterministic tests.
|
||||||
|
fn coverage_software_video_fallback_is_enabled() {
|
||||||
|
assert!(CameraCapture::software_video_fallback_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
#[test]
|
||||||
|
/// Coverage mode replaces the FFmpeg preview reader with an inert cancellation flag.
|
||||||
|
fn coverage_ffmpeg_preview_tap_stub_starts_stopped() {
|
||||||
|
let running = super::spawn_ffmpeg_raw_preview_tap(
|
||||||
|
std::io::empty(),
|
||||||
|
std::path::PathBuf::from("/tmp/lesavka-preview-tap.coverage"),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!running.load(std::sync::atomic::Ordering::Acquire));
|
||||||
|
}
|
||||||
@ -442,5 +442,5 @@ fn control_request_nonce() -> u128 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "live_media_control/tests.rs"]
|
#[path = "live_media_control/tests/mod.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@ -147,143 +147,5 @@ impl Drop for OpusPacketDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "opus_decode/tests/mod.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use lesavka_common::lesavka::AudioEncoding;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() {
|
|
||||||
let _ = gst::init();
|
|
||||||
if gst::ElementFactory::find("opusenc").is_none()
|
|
||||||
|| gst::ElementFactory::find("opusdec").is_none()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(opus_payload) = encode_silent_opus_payload() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let packet = AudioPacket {
|
|
||||||
pts: 42_000,
|
|
||||||
encoding: AudioEncoding::Opus as i32,
|
|
||||||
sample_rate: 48_000,
|
|
||||||
channels: 2,
|
|
||||||
frame_duration_us: 20_000,
|
|
||||||
data: opus_payload,
|
|
||||||
..AudioPacket::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut decoder = OpusPacketDecoder::new().expect("opus decoder");
|
|
||||||
let decoded = decoder
|
|
||||||
.decode_packet(&packet)
|
|
||||||
.expect("decode")
|
|
||||||
.expect("decoded pcm");
|
|
||||||
|
|
||||||
assert_eq!(decoded.pts, 42_000);
|
|
||||||
assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32);
|
|
||||||
assert_eq!(decoded.sample_rate, 48_000);
|
|
||||||
assert_eq!(decoded.channels, 2);
|
|
||||||
assert!(
|
|
||||||
decoded.data.len() >= 1_000,
|
|
||||||
"decoded PCM should be far larger than one compressed Opus frame"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
decoded.data.chunks_exact(2).any(|sample| {
|
|
||||||
i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs() > 250
|
|
||||||
}),
|
|
||||||
"decoded Opus payload should preserve non-silent waveform energy"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn packet_metadata_keeps_timing_and_clears_payload() {
|
|
||||||
let packet = AudioPacket {
|
|
||||||
pts: 123_000,
|
|
||||||
encoding: AudioEncoding::Opus as i32,
|
|
||||||
sample_rate: 48_000,
|
|
||||||
channels: 2,
|
|
||||||
frame_duration_us: 20_000,
|
|
||||||
data: vec![1, 2, 3, 4],
|
|
||||||
..AudioPacket::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata = packet_metadata(&packet);
|
|
||||||
|
|
||||||
assert_eq!(metadata.pts, 123_000);
|
|
||||||
assert_eq!(metadata.encoding, AudioEncoding::Opus as i32);
|
|
||||||
assert_eq!(metadata.sample_rate, 48_000);
|
|
||||||
assert_eq!(metadata.channels, 2);
|
|
||||||
assert_eq!(metadata.frame_duration_us, 20_000);
|
|
||||||
assert!(metadata.data.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_packet_metadata_is_bounded_to_recent_frames() {
|
|
||||||
let mut pending = VecDeque::new();
|
|
||||||
|
|
||||||
for pts in 0..20 {
|
|
||||||
push_pending_packet(
|
|
||||||
&mut pending,
|
|
||||||
AudioPacket {
|
|
||||||
pts,
|
|
||||||
..AudioPacket::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA);
|
|
||||||
assert_eq!(pending.front().expect("oldest retained").pts, 4);
|
|
||||||
assert_eq!(pending.back().expect("newest retained").pts, 19);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_silent_opus_payload() -> Option<Vec<u8>> {
|
|
||||||
let desc = "\
|
|
||||||
appsrc name=src is-live=true block=false format=time \
|
|
||||||
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
|
||||||
opusenc audio-type=restricted-lowdelay bitrate=96000 bitrate-type=cbr complexity=7 frame-size=20 perfect-timestamp=true hard-resync=true ! \
|
|
||||||
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
|
||||||
let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
|
|
||||||
let appsrc = pipeline
|
|
||||||
.by_name("src")?
|
|
||||||
.downcast::<gst_app::AppSrc>()
|
|
||||||
.ok()?;
|
|
||||||
let appsink = pipeline
|
|
||||||
.by_name("sink")?
|
|
||||||
.downcast::<gst_app::AppSink>()
|
|
||||||
.ok()?;
|
|
||||||
pipeline.set_state(gst::State::Playing).ok()?;
|
|
||||||
|
|
||||||
for index in 0..4u64 {
|
|
||||||
let mut buffer = gst::Buffer::from_slice(sine_pcm_packet(index, 3_840));
|
|
||||||
if let Some(meta) = buffer.get_mut() {
|
|
||||||
let pts = gst::ClockTime::from_useconds(index * 20_000);
|
|
||||||
meta.set_pts(Some(pts));
|
|
||||||
meta.set_dts(Some(pts));
|
|
||||||
meta.set_duration(Some(gst::ClockTime::from_useconds(20_000)));
|
|
||||||
}
|
|
||||||
appsrc.push_buffer(buffer).ok()?;
|
|
||||||
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) {
|
|
||||||
let payload = sample.buffer()?.map_readable().ok()?.to_vec();
|
|
||||||
let _ = pipeline.set_state(gst::State::Null);
|
|
||||||
if !payload.is_empty() {
|
|
||||||
return Some(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = pipeline.set_state(gst::State::Null);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sine_pcm_packet(packet_index: u64, len: usize) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(len);
|
|
||||||
let frames = len / 4;
|
|
||||||
for frame in 0..frames {
|
|
||||||
let absolute = packet_index as usize * frames + frame;
|
|
||||||
let phase = (absolute as f32 * 440.0 * std::f32::consts::TAU) / 48_000.0;
|
|
||||||
let sample = (phase.sin() * 12_000.0) as i16;
|
|
||||||
out.extend_from_slice(&sample.to_le_bytes());
|
|
||||||
out.extend_from_slice(&sample.to_le_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
138
server/src/audio/opus_decode/tests/mod.rs
Normal file
138
server/src/audio/opus_decode/tests/mod.rs
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
use super::*;
|
||||||
|
use lesavka_common::lesavka::AudioEncoding;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opus_decoder_roundtrips_to_pcm_when_plugins_are_available() {
|
||||||
|
let _ = gst::init();
|
||||||
|
if gst::ElementFactory::find("opusenc").is_none()
|
||||||
|
|| gst::ElementFactory::find("opusdec").is_none()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(opus_payload) = encode_silent_opus_payload() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let packet = AudioPacket {
|
||||||
|
pts: 42_000,
|
||||||
|
encoding: AudioEncoding::Opus as i32,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
frame_duration_us: 20_000,
|
||||||
|
data: opus_payload,
|
||||||
|
..AudioPacket::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decoder = OpusPacketDecoder::new().expect("opus decoder");
|
||||||
|
let decoded = decoder
|
||||||
|
.decode_packet(&packet)
|
||||||
|
.expect("decode")
|
||||||
|
.expect("decoded pcm");
|
||||||
|
|
||||||
|
assert_eq!(decoded.pts, 42_000);
|
||||||
|
assert_eq!(decoded.encoding, AudioEncoding::PcmS16le as i32);
|
||||||
|
assert_eq!(decoded.sample_rate, 48_000);
|
||||||
|
assert_eq!(decoded.channels, 2);
|
||||||
|
assert!(
|
||||||
|
decoded.data.len() >= 1_000,
|
||||||
|
"decoded PCM should be far larger than one compressed Opus frame"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
decoded.data.chunks_exact(2).any(|sample| {
|
||||||
|
i16::from_le_bytes([sample[0], sample[1]]).unsigned_abs() > 250
|
||||||
|
}),
|
||||||
|
"decoded Opus payload should preserve non-silent waveform energy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packet_metadata_keeps_timing_and_clears_payload() {
|
||||||
|
let packet = AudioPacket {
|
||||||
|
pts: 123_000,
|
||||||
|
encoding: AudioEncoding::Opus as i32,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
frame_duration_us: 20_000,
|
||||||
|
data: vec![1, 2, 3, 4],
|
||||||
|
..AudioPacket::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = packet_metadata(&packet);
|
||||||
|
|
||||||
|
assert_eq!(metadata.pts, 123_000);
|
||||||
|
assert_eq!(metadata.encoding, AudioEncoding::Opus as i32);
|
||||||
|
assert_eq!(metadata.sample_rate, 48_000);
|
||||||
|
assert_eq!(metadata.channels, 2);
|
||||||
|
assert_eq!(metadata.frame_duration_us, 20_000);
|
||||||
|
assert!(metadata.data.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_packet_metadata_is_bounded_to_recent_frames() {
|
||||||
|
let mut pending = VecDeque::new();
|
||||||
|
|
||||||
|
for pts in 0..20 {
|
||||||
|
push_pending_packet(
|
||||||
|
&mut pending,
|
||||||
|
AudioPacket {
|
||||||
|
pts,
|
||||||
|
..AudioPacket::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(pending.len(), MAX_PENDING_OPUS_METADATA);
|
||||||
|
assert_eq!(pending.front().expect("oldest retained").pts, 4);
|
||||||
|
assert_eq!(pending.back().expect("newest retained").pts, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_silent_opus_payload() -> Option<Vec<u8>> {
|
||||||
|
let desc = "\
|
||||||
|
appsrc name=src is-live=true block=false format=time \
|
||||||
|
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
||||||
|
opusenc audio-type=restricted-lowdelay bitrate=96000 bitrate-type=cbr complexity=7 frame-size=20 perfect-timestamp=true hard-resync=true ! \
|
||||||
|
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
||||||
|
let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
|
||||||
|
let appsrc = pipeline
|
||||||
|
.by_name("src")?
|
||||||
|
.downcast::<gst_app::AppSrc>()
|
||||||
|
.ok()?;
|
||||||
|
let appsink = pipeline
|
||||||
|
.by_name("sink")?
|
||||||
|
.downcast::<gst_app::AppSink>()
|
||||||
|
.ok()?;
|
||||||
|
pipeline.set_state(gst::State::Playing).ok()?;
|
||||||
|
|
||||||
|
for index in 0..4u64 {
|
||||||
|
let mut buffer = gst::Buffer::from_slice(sine_pcm_packet(index, 3_840));
|
||||||
|
if let Some(meta) = buffer.get_mut() {
|
||||||
|
let pts = gst::ClockTime::from_useconds(index * 20_000);
|
||||||
|
meta.set_pts(Some(pts));
|
||||||
|
meta.set_dts(Some(pts));
|
||||||
|
meta.set_duration(Some(gst::ClockTime::from_useconds(20_000)));
|
||||||
|
}
|
||||||
|
appsrc.push_buffer(buffer).ok()?;
|
||||||
|
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(50)) {
|
||||||
|
let payload = sample.buffer()?.map_readable().ok()?.to_vec();
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
if !payload.is_empty() {
|
||||||
|
return Some(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sine_pcm_packet(packet_index: u64, len: usize) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(len);
|
||||||
|
let frames = len / 4;
|
||||||
|
for frame in 0..frames {
|
||||||
|
let absolute = packet_index as usize * frames + frame;
|
||||||
|
let phase = (absolute as f32 * 440.0 * std::f32::consts::TAU) / 48_000.0;
|
||||||
|
let sample = (phase.sin() * 12_000.0) as i16;
|
||||||
|
out.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
out.extend_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@ -444,5 +444,5 @@ async fn main() -> Result<()> {
|
|||||||
include!("lesavka_synthetic_uplink/support.rs");
|
include!("lesavka_synthetic_uplink/support.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "lesavka_synthetic_uplink/tests.rs"]
|
#[path = "lesavka_synthetic_uplink/tests/mod.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@ -338,5 +338,5 @@ pub fn reserve_local_pts(counter: &AtomicU64, preferred_pts_us: u64, frame_step_
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "video_support/tests.rs"]
|
#[path = "video_support/tests/mod.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user