291 lines
10 KiB
Rust
291 lines
10 KiB
Rust
|
|
//! Contract tests for microphone gain control and level taps.
|
||
|
|
//!
|
||
|
|
//! Scope: include `client/src/input/microphone.rs` and exercise live gain,
|
||
|
|
//! level telemetry, and shared-clock packet extraction helpers.
|
||
|
|
//! Targets: `client/src/input/microphone.rs`.
|
||
|
|
//! Why: microphone tuning is part of upstream transport quality, so gain and
|
||
|
|
//! tap behavior must remain deterministic without a live microphone.
|
||
|
|
|
||
|
|
#[allow(warnings)]
|
||
|
|
mod live_capture_clock {
|
||
|
|
include!("support/live_capture_clock_shim.rs");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[allow(warnings)]
|
||
|
|
mod microphone_include_contract {
|
||
|
|
include!(env!("LESAVKA_CLIENT_MICROPHONE_SRC"));
|
||
|
|
|
||
|
|
use serial_test::serial;
|
||
|
|
use std::fs;
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
use std::path::Path;
|
||
|
|
use temp_env::with_var;
|
||
|
|
use tempfile::tempdir;
|
||
|
|
|
||
|
|
fn write_executable(dir: &Path, name: &str, body: &str) {
|
||
|
|
let path = dir.join(name);
|
||
|
|
fs::write(&path, body).expect("write script");
|
||
|
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
||
|
|
perms.set_mode(0o755);
|
||
|
|
fs::set_permissions(path, perms).expect("chmod");
|
||
|
|
}
|
||
|
|
|
||
|
|
fn with_fake_command(name: &str, script_body: &str, f: impl FnOnce()) {
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
write_executable(dir.path(), name, script_body);
|
||
|
|
let prior = std::env::var("PATH").unwrap_or_default();
|
||
|
|
let merged = if prior.is_empty() {
|
||
|
|
dir.path().display().to_string()
|
||
|
|
} else {
|
||
|
|
format!("{}:{prior}", dir.path().display())
|
||
|
|
};
|
||
|
|
with_var("PATH", Some(merged), f);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn with_fake_pactl(script_body: &str, f: impl FnOnce()) {
|
||
|
|
with_fake_command("pactl", script_body, f);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn with_fake_pw_dump(script_body: &str, f: impl FnOnce()) {
|
||
|
|
with_fake_command("pw-dump", script_body, f);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mic_gain_control_reads_first_token_and_clamps() {
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
let path = dir.path().join("mic-gain.control");
|
||
|
|
fs::write(&path, "3.250 nonce\n").expect("write gain");
|
||
|
|
assert_eq!(read_mic_gain_control(&path), Some(3.25));
|
||
|
|
|
||
|
|
fs::write(&path, "20.0 nonce\n").expect("write clamped gain");
|
||
|
|
assert_eq!(read_mic_gain_control(&path), Some(4.0));
|
||
|
|
|
||
|
|
fs::write(&path, "bad nonce\n").expect("write invalid gain");
|
||
|
|
assert_eq!(read_mic_gain_control(&path), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
#[serial]
|
||
|
|
fn mic_level_tap_env_and_payload_helpers_are_stable() {
|
||
|
|
with_var("LESAVKA_UPLINK_MIC_LEVEL", None::<&str>, || {
|
||
|
|
assert!(mic_level_tap_path().is_none());
|
||
|
|
});
|
||
|
|
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
let path = dir.path().join("uplink-mic-level.value");
|
||
|
|
with_var(
|
||
|
|
"LESAVKA_UPLINK_MIC_LEVEL",
|
||
|
|
Some(path.to_string_lossy().to_string()),
|
||
|
|
|| {
|
||
|
|
assert_eq!(mic_level_tap_path().as_deref(), Some(path.as_path()));
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
assert_eq!(pcm_peak_fraction(&0_i16.to_le_bytes()), 0.0);
|
||
|
|
assert!(pcm_peak_fraction(&i16::MAX.to_le_bytes()) > 0.99);
|
||
|
|
|
||
|
|
write_mic_level_tap(&path, 0.375).expect("write level tap");
|
||
|
|
assert_eq!(
|
||
|
|
fs::read_to_string(&path).expect("read level tap").trim(),
|
||
|
|
"0.375000"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
#[serial]
|
||
|
|
fn mic_gain_control_returns_without_env() {
|
||
|
|
gst::init().ok();
|
||
|
|
let volume = gst::ElementFactory::make("volume")
|
||
|
|
.build()
|
||
|
|
.expect("volume element");
|
||
|
|
volume.set_property("volume", 1.75_f64);
|
||
|
|
|
||
|
|
with_var("LESAVKA_MIC_GAIN_CONTROL", None::<&str>, || {
|
||
|
|
maybe_spawn_mic_gain_control(volume.clone());
|
||
|
|
});
|
||
|
|
|
||
|
|
assert_eq!(volume.property::<f64>("volume"), 1.75);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
#[serial]
|
||
|
|
fn mic_gain_control_updates_volume_element_live() {
|
||
|
|
gst::init().ok();
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
let path = dir.path().join("mic-gain.control");
|
||
|
|
fs::write(&path, "2.500 nonce\n").expect("write gain");
|
||
|
|
let volume = gst::ElementFactory::make("volume")
|
||
|
|
.build()
|
||
|
|
.expect("volume element");
|
||
|
|
|
||
|
|
with_var(
|
||
|
|
"LESAVKA_MIC_GAIN_CONTROL",
|
||
|
|
Some(path.to_string_lossy().to_string()),
|
||
|
|
|| {
|
||
|
|
maybe_spawn_mic_gain_control(volume.clone());
|
||
|
|
for _ in 0..20 {
|
||
|
|
if (volume.property::<f64>("volume") - 2.5).abs() < 0.001 {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
(volume.property::<f64>("volume") - 2.5).abs() < 0.001,
|
||
|
|
"live mic gain control should update the GStreamer volume"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn pull_returns_none_for_empty_appsink() {
|
||
|
|
gst::init().ok();
|
||
|
|
let sink: gst_app::AppSink = gst::ElementFactory::make("appsink")
|
||
|
|
.build()
|
||
|
|
.expect("appsink")
|
||
|
|
.downcast::<gst_app::AppSink>()
|
||
|
|
.expect("appsink cast");
|
||
|
|
let running = std::sync::Arc::new(AtomicBool::new(true));
|
||
|
|
let cap = MicrophoneCapture {
|
||
|
|
pipeline: gst::Pipeline::new(),
|
||
|
|
sink,
|
||
|
|
level_tap_running: Some(std::sync::Arc::clone(&running)),
|
||
|
|
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
|
||
|
|
pending_packets: Default::default(),
|
||
|
|
};
|
||
|
|
assert!(
|
||
|
|
cap.pull().is_none(),
|
||
|
|
"empty appsink should produce no packet"
|
||
|
|
);
|
||
|
|
drop(cap);
|
||
|
|
assert!(!running.load(AtomicOrdering::Acquire));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn spawned_mic_level_tap_publishes_peak_from_appsink() {
|
||
|
|
gst::init().ok();
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
let path = dir.path().join("mic-level.value");
|
||
|
|
let pipeline: gst::Pipeline = gst::parse::launch(
|
||
|
|
"appsrc name=src is-live=true format=time caps=audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
|
||
|
|
appsink name=sink emit-signals=false sync=false max-buffers=4 drop=true",
|
||
|
|
)
|
||
|
|
.expect("pipeline")
|
||
|
|
.downcast()
|
||
|
|
.expect("pipeline cast");
|
||
|
|
let src: gst_app::AppSrc = pipeline
|
||
|
|
.by_name("src")
|
||
|
|
.expect("appsrc")
|
||
|
|
.downcast()
|
||
|
|
.expect("appsrc cast");
|
||
|
|
let sink: gst_app::AppSink = pipeline
|
||
|
|
.by_name("sink")
|
||
|
|
.expect("appsink")
|
||
|
|
.downcast()
|
||
|
|
.expect("appsink cast");
|
||
|
|
pipeline.set_state(gst::State::Playing).expect("playing");
|
||
|
|
|
||
|
|
let running = spawn_mic_level_tap(sink, path.clone());
|
||
|
|
src.push_buffer(gst::Buffer::from_slice(i16::MAX.to_le_bytes().repeat(4)))
|
||
|
|
.expect("push buffer");
|
||
|
|
|
||
|
|
for _ in 0..20 {
|
||
|
|
if let Ok(raw) = fs::read_to_string(&path) {
|
||
|
|
let level = raw.trim().parse::<f64>().expect("level");
|
||
|
|
assert!(level > 0.99);
|
||
|
|
running.store(false, AtomicOrdering::Release);
|
||
|
|
let _ = pipeline.set_state(gst::State::Null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||
|
|
}
|
||
|
|
|
||
|
|
running.store(false, AtomicOrdering::Release);
|
||
|
|
let _ = pipeline.set_state(gst::State::Null);
|
||
|
|
panic!("microphone level tap did not publish a value");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
#[cfg(coverage)]
|
||
|
|
#[serial]
|
||
|
|
fn microphone_capture_with_level_tap_uses_the_same_uplink_pipeline() {
|
||
|
|
gst::init().ok();
|
||
|
|
let dir = tempdir().expect("tempdir");
|
||
|
|
let level_path = dir.path().join("uplink-mic-level.value");
|
||
|
|
|
||
|
|
with_var("LESAVKA_MIC_SOURCE", None::<&str>, || {
|
||
|
|
with_var(
|
||
|
|
"LESAVKA_MIC_TEST_SOURCE_DESC",
|
||
|
|
Some("audiotestsrc is-live=true wave=sine freq=440".to_string()),
|
||
|
|
|| {
|
||
|
|
with_var(
|
||
|
|
"LESAVKA_UPLINK_MIC_LEVEL",
|
||
|
|
Some(level_path.to_string_lossy().to_string()),
|
||
|
|
|| {
|
||
|
|
let cap = MicrophoneCapture::new().expect("synthetic mic capture");
|
||
|
|
assert!(cap.level_tap_running.is_some());
|
||
|
|
},
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn pull_returns_packet_when_appsink_has_buffered_sample_with_shared_capture_clock_pts() {
|
||
|
|
gst::init().ok();
|
||
|
|
let pipeline = gst::Pipeline::new();
|
||
|
|
let src = gst::ElementFactory::make("appsrc")
|
||
|
|
.build()
|
||
|
|
.expect("appsrc")
|
||
|
|
.downcast::<gst_app::AppSrc>()
|
||
|
|
.expect("appsrc cast");
|
||
|
|
let sink = gst::ElementFactory::make("appsink")
|
||
|
|
.property("emit-signals", false)
|
||
|
|
.property("sync", false)
|
||
|
|
.build()
|
||
|
|
.expect("appsink")
|
||
|
|
.downcast::<gst_app::AppSink>()
|
||
|
|
.expect("appsink cast");
|
||
|
|
pipeline
|
||
|
|
.add_many([
|
||
|
|
src.upcast_ref::<gst::Element>(),
|
||
|
|
sink.upcast_ref::<gst::Element>(),
|
||
|
|
])
|
||
|
|
.expect("add appsrc/appsink");
|
||
|
|
src.link(&sink).expect("link appsrc->appsink");
|
||
|
|
pipeline.set_state(gst::State::Playing).ok();
|
||
|
|
|
||
|
|
let mut first = gst::Buffer::from_slice(vec![1_u8, 2, 3, 4]);
|
||
|
|
first
|
||
|
|
.get_mut()
|
||
|
|
.expect("buffer mut")
|
||
|
|
.set_pts(Some(gst::ClockTime::from_useconds(321)));
|
||
|
|
src.push_buffer(first).expect("push first sample");
|
||
|
|
|
||
|
|
let mut second = gst::Buffer::from_slice(vec![5_u8, 6, 7, 8]);
|
||
|
|
second
|
||
|
|
.get_mut()
|
||
|
|
.expect("buffer mut")
|
||
|
|
.set_pts(Some(gst::ClockTime::from_useconds(999_999)));
|
||
|
|
src.push_buffer(second).expect("push second sample");
|
||
|
|
|
||
|
|
let cap = MicrophoneCapture {
|
||
|
|
pipeline,
|
||
|
|
sink,
|
||
|
|
level_tap_running: None,
|
||
|
|
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(),
|
||
|
|
pending_packets: Default::default(),
|
||
|
|
};
|
||
|
|
let first_pkt = cap.pull().expect("first audio packet");
|
||
|
|
let second_pkt = cap.pull().expect("second audio packet");
|
||
|
|
assert_eq!(first_pkt.id, 0);
|
||
|
|
assert_eq!(first_pkt.data, vec![1, 2, 3, 4]);
|
||
|
|
assert_eq!(second_pkt.data, vec![5, 6, 7, 8]);
|
||
|
|
assert!(second_pkt.pts >= first_pkt.pts);
|
||
|
|
assert_ne!(first_pkt.pts, 321);
|
||
|
|
assert_ne!(second_pkt.pts, 999_999);
|
||
|
|
}
|
||
|
|
}
|