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)]
|
||||
mod tests {
|
||||
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)
|
||||
}
|
||||
}
|
||||
#[path = "audio_codec/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
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");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
#[path = "camera/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
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)]
|
||||
#[path = "live_media_control/tests.rs"]
|
||||
#[path = "live_media_control/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
@ -147,143 +147,5 @@ impl Drop for OpusPacketDecoder {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
#[path = "opus_decode/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
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");
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lesavka_synthetic_uplink/tests.rs"]
|
||||
#[path = "lesavka_synthetic_uplink/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
@ -338,5 +338,5 @@ pub fn reserve_local_pts(counter: &AtomicU64, preferred_pts_us: u64, frame_step_
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "video_support/tests.rs"]
|
||||
#[path = "video_support/tests/mod.rs"]
|
||||
mod tests;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user