media: restore pcm default and harden hevc path
This commit is contained in:
parent
af1ea6387a
commit
628b506b64
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -25,8 +25,8 @@ const MAX_PENDING_OPUS_METADATA: usize = 16;
|
|||||||
/// Resolve the requested upstream audio codec from runtime environment.
|
/// Resolve the requested upstream audio codec from runtime environment.
|
||||||
///
|
///
|
||||||
/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` or legacy `LESAVKA_AUDIO_CODEC`.
|
/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` or legacy `LESAVKA_AUDIO_CODEC`.
|
||||||
/// Output: Opus by default, PCM when explicitly requested. Why: Opus should be
|
/// Output: PCM by default, Opus when explicitly requested. Why: Opus is still
|
||||||
/// the optimized path, but operators need an immediate known-good fallback.
|
/// under active calibration; the launcher must boot into the known-good route.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec {
|
pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec {
|
||||||
std::env::var(AUDIO_CODEC_ENV)
|
std::env::var(AUDIO_CODEC_ENV)
|
||||||
@ -34,7 +34,7 @@ pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec {
|
|||||||
.or_else(|| std::env::var(AUDIO_CODEC_LEGACY_ENV).ok())
|
.or_else(|| std::env::var(AUDIO_CODEC_LEGACY_ENV).ok())
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(parse_upstream_audio_codec)
|
.and_then(parse_upstream_audio_codec)
|
||||||
.unwrap_or(UpstreamAudioCodec::Opus)
|
.unwrap_or(UpstreamAudioCodec::PcmS16le)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Low-latency Opus packet encoder for already-framed 48 kHz stereo PCM.
|
/// Low-latency Opus packet encoder for already-framed 48 kHz stereo PCM.
|
||||||
@ -61,7 +61,7 @@ impl OpusPacketEncoder {
|
|||||||
appsrc name=src is-live=true block=false format=time \
|
appsrc name=src is-live=true block=false format=time \
|
||||||
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
||||||
queue max-size-buffers=4 max-size-time=80000000 leaky=downstream ! \
|
queue max-size-buffers=4 max-size-time=80000000 leaky=downstream ! \
|
||||||
opusenc audio-type=voice bitrate=64000 bitrate-type=constrained-vbr complexity=5 frame-size=20 ! \
|
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";
|
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
||||||
let pipeline: gst::Pipeline = gst::parse::launch(desc)?.downcast().expect("pipeline");
|
let pipeline: gst::Pipeline = gst::parse::launch(desc)?.downcast().expect("pipeline");
|
||||||
let appsrc = pipeline
|
let appsrc = pipeline
|
||||||
@ -184,12 +184,12 @@ mod tests {
|
|||||||
use lesavka_common::lesavka::AudioEncoding;
|
use lesavka_common::lesavka::AudioEncoding;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn requested_audio_codec_defaults_to_opus_and_parses_pcm_fallback() {
|
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_ENV, None::<&str>, || {
|
||||||
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || {
|
temp_env::with_var(AUDIO_CODEC_LEGACY_ENV, None::<&str>, || {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
requested_upstream_audio_codec_from_env(),
|
requested_upstream_audio_codec_from_env(),
|
||||||
UpstreamAudioCodec::Opus
|
UpstreamAudioCodec::PcmS16le
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -231,4 +231,89 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -340,7 +340,10 @@ impl CameraCapture {
|
|||||||
.arg(capture_fps.to_string())
|
.arg(capture_fps.to_string())
|
||||||
.arg("-video_size")
|
.arg("-video_size")
|
||||||
.arg(format!("{capture_width}x{capture_height}"));
|
.arg(format!("{capture_width}x{capture_height}"));
|
||||||
if source_profile == CameraSourceProfile::Mjpeg {
|
if matches!(
|
||||||
|
source_profile,
|
||||||
|
CameraSourceProfile::Mjpeg | CameraSourceProfile::AutoDecode
|
||||||
|
) {
|
||||||
command.arg("-input_format").arg("mjpeg");
|
command.arg("-input_format").arg("mjpeg");
|
||||||
}
|
}
|
||||||
command.arg("-i").arg(dev_label);
|
command.arg("-i").arg(dev_label);
|
||||||
|
|||||||
@ -132,13 +132,6 @@ impl CameraCapture {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
|
||||||
fn gstreamer_hevc_hardware_encoder_available() -> bool {
|
|
||||||
["nvh265enc", "vah265enc", "vaapih265enc", "v4l2h265enc"]
|
|
||||||
.iter()
|
|
||||||
.any(|name| buildable_encoder(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn ffmpeg_hevc_nvenc_available() -> bool {
|
fn ffmpeg_hevc_nvenc_available() -> bool {
|
||||||
Command::new("ffmpeg")
|
Command::new("ffmpeg")
|
||||||
@ -160,10 +153,7 @@ impl CameraCapture {
|
|||||||
Self::ffmpeg_hevc_nvenc_available()
|
Self::ffmpeg_hevc_nvenc_available()
|
||||||
}
|
}
|
||||||
Some("gstreamer" | "gst") => false,
|
Some("gstreamer" | "gst") => false,
|
||||||
_ => {
|
_ => Self::ffmpeg_hevc_nvenc_available(),
|
||||||
!Self::gstreamer_hevc_hardware_encoder_available()
|
|
||||||
&& Self::ffmpeg_hevc_nvenc_available()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,8 +94,8 @@ impl WebcamTransport {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum UpstreamAudioTransport {
|
pub enum UpstreamAudioTransport {
|
||||||
#[default]
|
|
||||||
Opus,
|
Opus,
|
||||||
|
#[default]
|
||||||
Pcm,
|
Pcm,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,8 +112,8 @@ impl UpstreamAudioTransport {
|
|||||||
/// Parse a GTK row id back into an upstream microphone transport.
|
/// Parse a GTK row id back into an upstream microphone transport.
|
||||||
///
|
///
|
||||||
/// Inputs: compact id or familiar operator alias. Output: a supported
|
/// Inputs: compact id or familiar operator alias. Output: a supported
|
||||||
/// transport. Why: Opus is now the optimized path, but PCM must remain
|
/// transport. Why: PCM is the known-good route while Opus remains
|
||||||
/// one click away as the known-good audio fallback.
|
/// explicitly selectable for calibration and bandwidth experiments.
|
||||||
pub fn from_id(raw: &str) -> Option<Self> {
|
pub fn from_id(raw: &str) -> Option<Self> {
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
"opus" | "compressed" => Some(Self::Opus),
|
"opus" | "compressed" => Some(Self::Opus),
|
||||||
|
|||||||
@ -439,10 +439,10 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_transport_model_defaults_to_compressed_with_raw_fallback() {
|
fn audio_transport_model_defaults_to_pcm_with_compressed_opt_in() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
UpstreamAudioTransport::default(),
|
UpstreamAudioTransport::default(),
|
||||||
UpstreamAudioTransport::Opus
|
UpstreamAudioTransport::Pcm
|
||||||
);
|
);
|
||||||
assert_eq!(UpstreamAudioTransport::Opus.as_id(), "opus");
|
assert_eq!(UpstreamAudioTransport::Opus.as_id(), "opus");
|
||||||
assert_eq!(UpstreamAudioTransport::Opus.label(), "Opus");
|
assert_eq!(UpstreamAudioTransport::Opus.label(), "Opus");
|
||||||
|
|||||||
@ -39,6 +39,18 @@
|
|||||||
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
|
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_day_slug() -> String {
|
||||||
|
if let Ok(now) = glib::DateTime::now_local()
|
||||||
|
&& let Ok(stamp) = now.format("%Y-%m-%d")
|
||||||
|
{
|
||||||
|
return stamp.to_string();
|
||||||
|
}
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{}", now.as_secs() / 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_home_token(raw: &str) -> PathBuf {
|
fn expand_home_token(raw: &str) -> PathBuf {
|
||||||
if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") {
|
if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") {
|
||||||
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
|
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
|
||||||
@ -78,6 +90,14 @@
|
|||||||
Ok(root)
|
Ok(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_eye_capture_dir(override_dir: Option<&Path>, kind: &str) -> Result<PathBuf, String> {
|
||||||
|
let root = ensure_eye_capture_root(override_dir)?;
|
||||||
|
let dir = root.join(capture_day_slug()).join(kind);
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|err| format!("could not create {}: {err}", dir.display()))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
fn unique_capture_path(root: &Path, stem: &str, ext: &str) -> PathBuf {
|
fn unique_capture_path(root: &Path, stem: &str, ext: &str) -> PathBuf {
|
||||||
let mut candidate = root.join(format!("{stem}.{ext}"));
|
let mut candidate = root.join(format!("{stem}.{ext}"));
|
||||||
if !candidate.exists() {
|
if !candidate.exists() {
|
||||||
@ -280,7 +300,7 @@
|
|||||||
pane.clip_button.connect_clicked(move |_| {
|
pane.clip_button.connect_clicked(move |_| {
|
||||||
let root = {
|
let root = {
|
||||||
let borrowed = save_state.borrow();
|
let borrowed = save_state.borrow();
|
||||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) {
|
match ensure_eye_capture_dir(borrowed.save_dir_override.as_deref(), "clips") {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
widgets
|
widgets
|
||||||
@ -321,6 +341,7 @@
|
|||||||
let record_button = pane.record_button.clone();
|
let record_button = pane.record_button.clone();
|
||||||
record_button.connect_clicked(move |button| {
|
record_button.connect_clicked(move |button| {
|
||||||
if save_state.borrow().timer.is_some() {
|
if save_state.borrow().timer.is_some() {
|
||||||
|
button.remove_css_class("recording-active");
|
||||||
let finalize_rx = {
|
let finalize_rx = {
|
||||||
let mut state = save_state.borrow_mut();
|
let mut state = save_state.borrow_mut();
|
||||||
if let Some(timer) = state.timer.take() {
|
if let Some(timer) = state.timer.take() {
|
||||||
@ -352,6 +373,7 @@
|
|||||||
{
|
{
|
||||||
Ok(Ok(output)) => {
|
Ok(Ok(output)) => {
|
||||||
button.set_sensitive(true);
|
button.set_sensitive(true);
|
||||||
|
button.remove_css_class("recording-active");
|
||||||
button.set_label("Record");
|
button.set_label("Record");
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"{} recording saved to {}.",
|
"{} recording saved to {}.",
|
||||||
@ -362,6 +384,7 @@
|
|||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
button.set_sensitive(true);
|
button.set_sensitive(true);
|
||||||
|
button.remove_css_class("recording-active");
|
||||||
button.set_label("Record");
|
button.set_label("Record");
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"{} recording stop failed: {err}",
|
"{} recording stop failed: {err}",
|
||||||
@ -372,6 +395,7 @@
|
|||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
button.set_sensitive(true);
|
button.set_sensitive(true);
|
||||||
|
button.remove_css_class("recording-active");
|
||||||
button.set_label("Record");
|
button.set_label("Record");
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"{} recording stop failed: recording worker disconnected.",
|
"{} recording stop failed: recording worker disconnected.",
|
||||||
@ -389,7 +413,7 @@
|
|||||||
};
|
};
|
||||||
let root = {
|
let root = {
|
||||||
let borrowed = save_state.borrow();
|
let borrowed = save_state.borrow();
|
||||||
match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) {
|
match ensure_eye_capture_dir(borrowed.save_dir_override.as_deref(), "recordings") {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
widgets
|
widgets
|
||||||
@ -460,6 +484,7 @@
|
|||||||
);
|
);
|
||||||
save_state.borrow_mut().timer = Some(timer);
|
save_state.borrow_mut().timer = Some(timer);
|
||||||
button.set_sensitive(true);
|
button.set_sensitive(true);
|
||||||
|
button.add_css_class("recording-active");
|
||||||
button.set_label("Stop");
|
button.set_label("Stop");
|
||||||
widgets.status_label.set_text(&format!(
|
widgets.status_label.set_text(&format!(
|
||||||
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
|
||||||
|
|||||||
@ -224,7 +224,7 @@
|
|||||||
|
|
||||||
let webcam_transport_combo = gtk::ComboBoxText::new();
|
let webcam_transport_combo = gtk::ComboBoxText::new();
|
||||||
webcam_transport_combo.add_css_class("compact-combo");
|
webcam_transport_combo.add_css_class("compact-combo");
|
||||||
for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg] {
|
for transport in [WebcamTransport::Mjpeg, WebcamTransport::Hevc] {
|
||||||
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
|
webcam_transport_combo.append(Some(transport.as_id()), transport.label());
|
||||||
}
|
}
|
||||||
webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id()));
|
webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id()));
|
||||||
@ -236,14 +236,14 @@
|
|||||||
|
|
||||||
let upstream_audio_transport_combo = gtk::ComboBoxText::new();
|
let upstream_audio_transport_combo = gtk::ComboBoxText::new();
|
||||||
upstream_audio_transport_combo.add_css_class("compact-combo");
|
upstream_audio_transport_combo.add_css_class("compact-combo");
|
||||||
for transport in [UpstreamAudioTransport::Opus, UpstreamAudioTransport::Pcm] {
|
for transport in [UpstreamAudioTransport::Pcm, UpstreamAudioTransport::Opus] {
|
||||||
upstream_audio_transport_combo.append(Some(transport.as_id()), transport.label());
|
upstream_audio_transport_combo.append(Some(transport.as_id()), transport.label());
|
||||||
}
|
}
|
||||||
upstream_audio_transport_combo.set_active_id(Some(state.upstream_audio_transport.as_id()));
|
upstream_audio_transport_combo.set_active_id(Some(state.upstream_audio_transport.as_id()));
|
||||||
upstream_audio_transport_combo.set_sensitive(true);
|
upstream_audio_transport_combo.set_sensitive(true);
|
||||||
upstream_audio_transport_combo.set_size_request(88, -1);
|
upstream_audio_transport_combo.set_size_request(88, -1);
|
||||||
upstream_audio_transport_combo.set_tooltip_text(Some(
|
upstream_audio_transport_combo.set_tooltip_text(Some(
|
||||||
"Upstream microphone transport for the live relay. Opus is compressed and low-bandwidth; PCM is the known-good fallback.",
|
"Upstream microphone transport for the live relay. PCM is the known-good default; Opus is compressed and experimental.",
|
||||||
));
|
));
|
||||||
|
|
||||||
upstream_transport_row.append(&webcam_transport_combo);
|
upstream_transport_row.append(&webcam_transport_combo);
|
||||||
|
|||||||
@ -161,6 +161,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
|||||||
stabilize_button(&clip_button, 66);
|
stabilize_button(&clip_button, 66);
|
||||||
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
|
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
|
||||||
let record_button = gtk::Button::with_label("Record");
|
let record_button = gtk::Button::with_label("Record");
|
||||||
|
record_button.add_css_class("media-toggle");
|
||||||
stabilize_button(&record_button, 78);
|
stabilize_button(&record_button, 78);
|
||||||
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
|
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
|
||||||
let save_button = gtk::Button::with_label("Save");
|
let save_button = gtk::Button::with_label("Save");
|
||||||
@ -191,10 +192,10 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
|
|||||||
capture_row.set_hexpand(true);
|
capture_row.set_hexpand(true);
|
||||||
breakout_row.set_hexpand(true);
|
breakout_row.set_hexpand(true);
|
||||||
controls_grid.attach(&feed_row, 0, 0, 1, 1);
|
controls_grid.attach(&feed_row, 0, 0, 1, 1);
|
||||||
controls_grid.attach(&capture_row, 1, 0, 2, 1);
|
controls_grid.attach(&capture_row, 1, 0, 1, 1);
|
||||||
|
controls_grid.attach(&action_button, 2, 0, 1, 1);
|
||||||
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
|
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
|
||||||
controls_grid.attach(&capture_actions, 1, 1, 1, 1);
|
controls_grid.attach(&capture_actions, 1, 1, 2, 1);
|
||||||
controls_grid.attach(&action_button, 2, 1, 1, 1);
|
|
||||||
footer_shell.append(&controls_grid);
|
footer_shell.append(&controls_grid);
|
||||||
root.append(&footer_shell);
|
root.append(&footer_shell);
|
||||||
|
|
||||||
|
|||||||
@ -179,6 +179,11 @@ pub fn install_css(window: >k::ApplicationWindow) {
|
|||||||
border-color: rgba(96, 214, 126, 0.46);
|
border-color: rgba(96, 214, 126, 0.46);
|
||||||
color: #dff7e4;
|
color: #dff7e4;
|
||||||
}
|
}
|
||||||
|
button.media-toggle.recording-active {
|
||||||
|
background: rgba(96, 214, 126, 0.20);
|
||||||
|
border-color: rgba(96, 214, 126, 0.46);
|
||||||
|
color: #dff7e4;
|
||||||
|
}
|
||||||
button.media-toggle:disabled {
|
button.media-toggle:disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ pub const PCM_FRAME_DURATION_US: u32 = 20_000;
|
|||||||
pub const OPUS_SAMPLE_RATE: u32 = 48_000;
|
pub const OPUS_SAMPLE_RATE: u32 = 48_000;
|
||||||
pub const OPUS_CHANNELS: u32 = 2;
|
pub const OPUS_CHANNELS: u32 = 2;
|
||||||
pub const OPUS_FRAME_DURATION_US: u32 = 20_000;
|
pub const OPUS_FRAME_DURATION_US: u32 = 20_000;
|
||||||
pub const OPUS_DEFAULT_BITRATE_BPS: u32 = 64_000;
|
pub const OPUS_DEFAULT_BITRATE_BPS: u32 = 96_000;
|
||||||
|
|
||||||
/// Operator-facing upstream audio transport choice.
|
/// Operator-facing upstream audio transport choice.
|
||||||
///
|
///
|
||||||
@ -95,9 +95,9 @@ impl AudioTransportProfile {
|
|||||||
|
|
||||||
/// Return the first Opus profile Lesavka should test for upstream audio.
|
/// Return the first Opus profile Lesavka should test for upstream audio.
|
||||||
///
|
///
|
||||||
/// Inputs: none. Outputs: a 48 kHz stereo, 20 ms, 64 kbps voice-oriented
|
/// Inputs: none. Outputs: a 48 kHz stereo, 20 ms, 96 kbps low-delay
|
||||||
/// profile. Why: Opus always runs internally at 48 kHz, and 20 ms frames
|
/// profile. Why: Opus always runs internally at 48 kHz, and 20 ms frames
|
||||||
/// keep latency bounded while still giving the codec enough lookahead.
|
/// keep latency bounded while preserving enough quality for live speech.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn opus_voice() -> Self {
|
pub const fn opus_voice() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@ -567,7 +567,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
|
|||||||
| `LESAVKA_UAC_APP_MAX_BYTES` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
|
| `LESAVKA_UAC_APP_MAX_BYTES` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
|
||||||
| `LESAVKA_UAC_APP_MAX_TIME_NS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
|
| `LESAVKA_UAC_APP_MAX_TIME_NS` | server UAC appsrc buffering override for lab tuning of microphone gadget output latency and stability |
|
||||||
| `LESAVKA_MIC_NOISE_SUPPRESSION` | client microphone capture toggle; when truthy, inserts WebRTC DSP noise suppression before upstream audio transport |
|
| `LESAVKA_MIC_NOISE_SUPPRESSION` | client microphone capture toggle; when truthy, inserts WebRTC DSP noise suppression before upstream audio transport |
|
||||||
| `LESAVKA_UPLINK_AUDIO_CODEC` | client/server upstream microphone transport hint (`opus` or `pcm`); launcher defaults to Opus while the server installer defaults to PCM until Opus calibration is intentionally selected |
|
| `LESAVKA_UPLINK_AUDIO_CODEC` | client/server upstream microphone transport hint (`opus` or `pcm`); launcher and installer default to PCM until Opus calibration is intentionally selected |
|
||||||
| `LESAVKA_UPLINK_CAMERA_CODEC` | server camera ingress codec hint; records whether upstream camera media arrives as `mjpeg`, `h264`, or `hevc` before UVC output |
|
| `LESAVKA_UPLINK_CAMERA_CODEC` | server camera ingress codec hint; records whether upstream camera media arrives as `mjpeg`, `h264`, or `hevc` before UVC output |
|
||||||
| `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server HEVC-ingress audio playout delay map by `WIDTHxHEIGHT@FPS`; overrides generic upstream audio offsets for HEVC |
|
| `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_MODE_OFFSETS_US` | server HEVC-ingress audio playout delay map by `WIDTHxHEIGHT@FPS`; overrides generic upstream audio offsets for HEVC |
|
||||||
| `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US` | server HEVC-ingress scalar audio playout delay in microseconds; used when no mode-specific value is present |
|
| `LESAVKA_UPSTREAM_HEVC_AUDIO_PLAYOUT_OFFSET_US` | server HEVC-ingress scalar audio playout delay in microseconds; used when no mode-specific value is present |
|
||||||
|
|||||||
@ -16,7 +16,7 @@ SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
|
|||||||
LESAVKA_CLIENT_RCT_MODE=${LESAVKA_CLIENT_RCT_MODE:-auto}
|
LESAVKA_CLIENT_RCT_MODE=${LESAVKA_CLIENT_RCT_MODE:-auto}
|
||||||
LESAVKA_CLIENT_RCT_UPSTREAM_MODE=${LESAVKA_CLIENT_RCT_UPSTREAM_MODE:-${LESAVKA_CLIENT_RCT_MODE}}
|
LESAVKA_CLIENT_RCT_UPSTREAM_MODE=${LESAVKA_CLIENT_RCT_UPSTREAM_MODE:-${LESAVKA_CLIENT_RCT_MODE}}
|
||||||
LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC:-auto}
|
LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC:-auto}
|
||||||
LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-opus}}
|
LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}
|
||||||
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
||||||
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
||||||
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.22"
|
version = "0.22.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ impl OpusPacketDecoder {
|
|||||||
let desc = "\
|
let desc = "\
|
||||||
appsrc name=src is-live=true block=false format=time \
|
appsrc name=src is-live=true block=false format=time \
|
||||||
caps=audio/x-opus,channel-mapping-family=0 ! \
|
caps=audio/x-opus,channel-mapping-family=0 ! \
|
||||||
opusdec plc=true use-inband-fec=false ! \
|
opusdec plc=false use-inband-fec=false min-latency=0 tolerance=0 ! \
|
||||||
audioconvert ! audioresample ! \
|
audioconvert ! audioresample ! \
|
||||||
audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
||||||
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
appsink name=sink emit-signals=false sync=false max-buffers=8 drop=true";
|
||||||
@ -187,13 +187,19 @@ mod tests {
|
|||||||
decoded.data.len() >= 1_000,
|
decoded.data.len() >= 1_000,
|
||||||
"decoded PCM should be far larger than one compressed Opus frame"
|
"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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_silent_opus_payload() -> Option<Vec<u8>> {
|
fn encode_silent_opus_payload() -> Option<Vec<u8>> {
|
||||||
let desc = "\
|
let desc = "\
|
||||||
appsrc name=src is-live=true block=false format=time \
|
appsrc name=src is-live=true block=false format=time \
|
||||||
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
|
||||||
opusenc audio-type=voice bitrate=64000 bitrate-type=constrained-vbr complexity=5 frame-size=20 ! \
|
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";
|
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 pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
|
||||||
let appsrc = pipeline
|
let appsrc = pipeline
|
||||||
@ -207,7 +213,7 @@ mod tests {
|
|||||||
pipeline.set_state(gst::State::Playing).ok()?;
|
pipeline.set_state(gst::State::Playing).ok()?;
|
||||||
|
|
||||||
for index in 0..4u64 {
|
for index in 0..4u64 {
|
||||||
let mut buffer = gst::Buffer::from_slice(vec![0; 3_840]);
|
let mut buffer = gst::Buffer::from_slice(sine_pcm_packet(index, 3_840));
|
||||||
if let Some(meta) = buffer.get_mut() {
|
if let Some(meta) = buffer.get_mut() {
|
||||||
let pts = gst::ClockTime::from_useconds(index * 20_000);
|
let pts = gst::ClockTime::from_useconds(index * 20_000);
|
||||||
meta.set_pts(Some(pts));
|
meta.set_pts(Some(pts));
|
||||||
@ -226,4 +232,17 @@ mod tests {
|
|||||||
let _ = pipeline.set_state(gst::State::Null);
|
let _ = pipeline.set_state(gst::State::Null);
|
||||||
None
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
// Targets: `client/src/input/microphone.rs`,
|
// Targets: `client/src/input/microphone.rs`,
|
||||||
// `client/src/app/uplink_media/uplink_queue_metadata.rs`, and
|
// `client/src/app/uplink_media/uplink_queue_metadata.rs`, and
|
||||||
// `scripts/install/client.sh`.
|
// `scripts/install/client.sh`.
|
||||||
// Why: Opus is now the optimized microphone transport, while raw PCM remains
|
// Why: Opus remains an optional compressed microphone transport, while raw PCM
|
||||||
// one click away as the known-good fallback.
|
// is the default known-good fallback.
|
||||||
|
|
||||||
const MICROPHONE: &str = include_str!(concat!(
|
const MICROPHONE: &str = include_str!(concat!(
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
@ -53,9 +53,20 @@ fn client_microphone_path_preserves_pcm_fallback_and_opus_selection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
AUDIO_CODEC.contains("mark_packet_opus") && AUDIO_CODEC.contains("Opus by default"),
|
AUDIO_CODEC.contains("mark_packet_opus") && AUDIO_CODEC.contains("PCM by default"),
|
||||||
"client Opus encoder should stamp packets while documenting the default"
|
"client Opus encoder should stamp packets while documenting the safe default"
|
||||||
);
|
);
|
||||||
|
for expected in [
|
||||||
|
"audio-type=restricted-lowdelay",
|
||||||
|
"bitrate=96000",
|
||||||
|
"plc=false",
|
||||||
|
"decode_opus_payload",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
AUDIO_CODEC.contains(expected),
|
||||||
|
"client Opus path should keep low-delay/non-silent evidence marker {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
for expected in [
|
for expected in [
|
||||||
"pending_packets: VecDeque<AudioPacket>",
|
"pending_packets: VecDeque<AudioPacket>",
|
||||||
"take_pending_packet(sample_pts_us)",
|
"take_pending_packet(sample_pts_us)",
|
||||||
|
|||||||
@ -164,6 +164,9 @@ mod camera_include_contract {
|
|||||||
for expected in [
|
for expected in [
|
||||||
"spawn_ffmpeg_raw_preview_tap",
|
"spawn_ffmpeg_raw_preview_tap",
|
||||||
"-filter_complex",
|
"-filter_complex",
|
||||||
|
"CameraSourceProfile::Mjpeg | CameraSourceProfile::AutoDecode",
|
||||||
|
"-input_format",
|
||||||
|
"mjpeg",
|
||||||
"[vencsrc]format=nv12[vencout];[vprevsrc]format=rgba[vprevout]",
|
"[vencsrc]format=nv12[vencout];[vprevsrc]format=rgba[vprevout]",
|
||||||
".arg(\"[vprevout]\")",
|
".arg(\"[vprevout]\")",
|
||||||
".arg(\"rawvideo\")",
|
".arg(\"rawvideo\")",
|
||||||
@ -182,6 +185,29 @@ mod camera_include_contract {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hevc_prefers_proven_ffmpeg_nvenc_unless_gstreamer_is_explicit() {
|
||||||
|
let encoder_source = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/client/src/input/camera/encoder_selection.rs"
|
||||||
|
));
|
||||||
|
|
||||||
|
for expected in [
|
||||||
|
"Some(\"ffmpeg_hevc_nvenc\" | \"hevc_nvenc\" | \"nvenc\")",
|
||||||
|
"Some(\"gstreamer\" | \"gst\") => false",
|
||||||
|
"_ => Self::ffmpeg_hevc_nvenc_available()",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
encoder_source.contains(expected),
|
||||||
|
"HEVC selection should keep FFmpeg/NVENC crash-avoidance marker {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!encoder_source.contains("!Self::gstreamer_hevc_hardware_encoder_available()"),
|
||||||
|
"a present GStreamer HEVC encoder must not suppress the proven FFmpeg/NVENC path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn camera_bus_logger_coverage_stub_is_non_blocking() {
|
fn camera_bus_logger_coverage_stub_is_non_blocking() {
|
||||||
|
|||||||
@ -38,11 +38,15 @@ fn uac_sink_remains_raw_pcm_and_guards_compressed_packets() {
|
|||||||
for expected in [
|
for expected in [
|
||||||
"OpusPacketDecoder",
|
"OpusPacketDecoder",
|
||||||
"opusdec",
|
"opusdec",
|
||||||
|
"plc=false",
|
||||||
|
"min-latency=0",
|
||||||
"audio/x-opus",
|
"audio/x-opus",
|
||||||
"audio/x-raw",
|
"audio/x-raw",
|
||||||
"pending_packets: VecDeque<AudioPacket>",
|
"pending_packets: VecDeque<AudioPacket>",
|
||||||
"take_pending_packet(sample_pts_us)",
|
"take_pending_packet(sample_pts_us)",
|
||||||
"push_pending_packet(&mut self.pending_packets",
|
"push_pending_packet(&mut self.pending_packets",
|
||||||
|
"audio-type=restricted-lowdelay",
|
||||||
|
"sine_pcm_packet",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
OPUS_DECODE.contains(expected),
|
OPUS_DECODE.contains(expected),
|
||||||
@ -58,7 +62,7 @@ fn opus_packets_are_detected_as_compressed_before_uac_handoff() {
|
|||||||
sample_rate: 48_000,
|
sample_rate: 48_000,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
frame_duration_us: 20_000,
|
frame_duration_us: 20_000,
|
||||||
data: vec![0x55; 160],
|
data: vec![0x55; 240],
|
||||||
..AudioPacket::default()
|
..AudioPacket::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,7 @@ fn client_rct_probe_is_non_mutating_and_passwordless_by_default() {
|
|||||||
"LESAVKA_CLIENT_RCT_MODE=${LESAVKA_CLIENT_RCT_MODE:-auto}",
|
"LESAVKA_CLIENT_RCT_MODE=${LESAVKA_CLIENT_RCT_MODE:-auto}",
|
||||||
"LESAVKA_CLIENT_RCT_UPSTREAM_MODE=${LESAVKA_CLIENT_RCT_UPSTREAM_MODE:-${LESAVKA_CLIENT_RCT_MODE}}",
|
"LESAVKA_CLIENT_RCT_UPSTREAM_MODE=${LESAVKA_CLIENT_RCT_UPSTREAM_MODE:-${LESAVKA_CLIENT_RCT_MODE}}",
|
||||||
"LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC:-auto}",
|
"LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_CAMERA_CODEC:-auto}",
|
||||||
"LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-opus}}",
|
"LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC=${LESAVKA_CLIENT_RCT_UPSTREAM_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}",
|
||||||
"--camera-mode",
|
"--camera-mode",
|
||||||
"--camera-codec",
|
"--camera-codec",
|
||||||
"LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0}",
|
"LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0}",
|
||||||
|
|||||||
@ -17,12 +17,12 @@ fn opus_transport_budget_is_small_enough_to_be_worth_testing() {
|
|||||||
assert_eq!(pcm.sample_rate, opus.sample_rate);
|
assert_eq!(pcm.sample_rate, opus.sample_rate);
|
||||||
assert_eq!(pcm.channels, opus.channels);
|
assert_eq!(pcm.channels, opus.channels);
|
||||||
assert!(
|
assert!(
|
||||||
opus.expected_payload_bytes() <= 200,
|
opus.expected_payload_bytes() <= 260,
|
||||||
"64 kbps, 20 ms Opus packets should stay near 160 bytes"
|
"96 kbps, 20 ms Opus packets should stay near 240 bytes"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
pcm.expected_payload_bytes() >= opus.expected_payload_bytes() * 20,
|
pcm.expected_payload_bytes() >= opus.expected_payload_bytes() * 12,
|
||||||
"Opus should remove at least 95% of raw PCM uplink byte pressure"
|
"Opus should remove over 90% of raw PCM uplink byte pressure"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@ fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for marker in [
|
for marker in [
|
||||||
"for transport in [WebcamTransport::Hevc, WebcamTransport::Mjpeg]",
|
"for transport in [WebcamTransport::Mjpeg, WebcamTransport::Hevc]",
|
||||||
"webcam_transport_combo.append(Some(transport.as_id()), transport.label());",
|
"webcam_transport_combo.append(Some(transport.as_id()), transport.label());",
|
||||||
"webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id()));",
|
"webcam_transport_combo.set_active_id(Some(state.effective_webcam_transport().as_id()));",
|
||||||
"webcam_transport_combo.set_sensitive(true);",
|
"webcam_transport_combo.set_sensitive(true);",
|
||||||
|
|||||||
@ -133,15 +133,16 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
|
|||||||
< source_index("header_row.append(&capture_label);")
|
< source_index("header_row.append(&capture_label);")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
|
source_index("controls_grid.attach(&capture_row, 1, 0, 1, 1);")
|
||||||
< source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
|
< source_index("controls_grid.attach(&action_button, 2, 0, 1, 1);")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
|
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
|
||||||
< source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);")
|
< source_index("controls_grid.attach(&capture_actions, 1, 1, 2, 1);")
|
||||||
);
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("let clip_button = gtk::Button::with_label(\"Clip\");"));
|
assert!(UI_LAYOUT_SRC.contains("let clip_button = gtk::Button::with_label(\"Clip\");"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let record_button = gtk::Button::with_label(\"Record\");"));
|
assert!(UI_LAYOUT_SRC.contains("let record_button = gtk::Button::with_label(\"Record\");"));
|
||||||
|
assert!(UI_LAYOUT_SRC.contains("record_button.add_css_class(\"media-toggle\");"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"Save\");"));
|
assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"Save\");"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&clip_button);"));
|
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&clip_button);"));
|
||||||
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&record_button);"));
|
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&record_button);"));
|
||||||
@ -491,7 +492,7 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
|
|||||||
));
|
));
|
||||||
assert!(
|
assert!(
|
||||||
UI_LAYOUT_SRC
|
UI_LAYOUT_SRC
|
||||||
.contains("Opus is compressed and low-bandwidth; PCM is the known-good fallback.")
|
.contains("PCM is the known-good default; Opus is compressed and experimental.")
|
||||||
);
|
);
|
||||||
assert!(UI_LAYOUT_SRC.contains("upstream_transport_row.append(&webcam_transport_combo);"));
|
assert!(UI_LAYOUT_SRC.contains("upstream_transport_row.append(&webcam_transport_combo);"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@ -313,8 +313,21 @@ fn launcher_utility_buttons_still_bind_to_live_actions() {
|
|||||||
assert!(UI_SRC.contains("pane.clip_button.connect_clicked"));
|
assert!(UI_SRC.contains("pane.clip_button.connect_clicked"));
|
||||||
assert!(UI_SRC.contains("clip saved to"));
|
assert!(UI_SRC.contains("clip saved to"));
|
||||||
assert!(UI_SRC.contains("record_button.connect_clicked"));
|
assert!(UI_SRC.contains("record_button.connect_clicked"));
|
||||||
|
assert!(UI_SRC.contains("button.add_css_class(\"recording-active\");"));
|
||||||
|
assert!(UI_SRC.contains("button.remove_css_class(\"recording-active\");"));
|
||||||
assert!(UI_SRC.contains("recording saved to"));
|
assert!(UI_SRC.contains("recording saved to"));
|
||||||
assert!(UI_SRC.contains("press Stop to finish."));
|
assert!(UI_SRC.contains("press Stop to finish."));
|
||||||
|
assert!(UI_SRC.contains("fn default_eye_capture_root() -> PathBuf"));
|
||||||
|
assert!(UI_SRC.contains(".join(\"Pictures\").join(\"lesavka\")"));
|
||||||
|
assert!(UI_SRC.contains("fn capture_day_slug() -> String"));
|
||||||
|
assert!(
|
||||||
|
UI_SRC.contains("ensure_eye_capture_dir(borrowed.save_dir_override.as_deref(), \"clips\")")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
UI_SRC.contains(
|
||||||
|
"ensure_eye_capture_dir(borrowed.save_dir_override.as_deref(), \"recordings\")"
|
||||||
|
)
|
||||||
|
);
|
||||||
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
|
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
|
||||||
assert!(UI_SRC.contains("recover_usb_soft(&server_addr)"));
|
assert!(UI_SRC.contains("recover_usb_soft(&server_addr)"));
|
||||||
assert!(UI_SRC.contains("recover_uac_soft(&server_addr)"));
|
assert!(UI_SRC.contains("recover_uac_soft(&server_addr)"));
|
||||||
|
|||||||
@ -25,7 +25,7 @@ fn opus_profile_is_low_bandwidth_without_changing_capture_clock() {
|
|||||||
assert_eq!(pcm.frame_duration_us, 20_000);
|
assert_eq!(pcm.frame_duration_us, 20_000);
|
||||||
assert_eq!(opus.frame_duration_us, 20_000);
|
assert_eq!(opus.frame_duration_us, 20_000);
|
||||||
assert_eq!(pcm.expected_payload_bytes(), 3_840);
|
assert_eq!(pcm.expected_payload_bytes(), 3_840);
|
||||||
assert_eq!(opus.expected_payload_bytes(), 160);
|
assert_eq!(opus.expected_payload_bytes(), 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -50,7 +50,7 @@ fn packet_and_bundle_metadata_can_select_opus_without_payload_guessing() {
|
|||||||
sample_rate: 48_000,
|
sample_rate: 48_000,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
frame_duration_us: 20_000,
|
frame_duration_us: 20_000,
|
||||||
data: vec![0xaa; 160],
|
data: vec![0xaa; 240],
|
||||||
..AudioPacket::default()
|
..AudioPacket::default()
|
||||||
};
|
};
|
||||||
let bundle = UpstreamMediaBundle {
|
let bundle = UpstreamMediaBundle {
|
||||||
@ -107,7 +107,7 @@ fn upstream_audio_codec_parser_keeps_opus_and_pcm_names_stable() {
|
|||||||
assert_eq!(parse_upstream_audio_codec("aac"), None);
|
assert_eq!(parse_upstream_audio_codec("aac"), None);
|
||||||
|
|
||||||
let mut packet = AudioPacket {
|
let mut packet = AudioPacket {
|
||||||
data: vec![0xaa; 160],
|
data: vec![0xaa; 240],
|
||||||
..AudioPacket::default()
|
..AudioPacket::default()
|
||||||
};
|
};
|
||||||
mark_packet_opus(&mut packet);
|
mark_packet_opus(&mut packet);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user