media: restore pcm default and harden hevc path

This commit is contained in:
Brad Stein 2026-05-13 12:07:53 -03:00
parent af1ea6387a
commit 628b506b64
26 changed files with 247 additions and 64 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.22"
version = "0.22.23"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.22"
version = "0.22.23"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.22"
version = "0.22.23"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.22"
version = "0.22.23"
edition = "2024"
[dependencies]

View File

@ -25,8 +25,8 @@ const MAX_PENDING_OPUS_METADATA: usize = 16;
/// Resolve the requested upstream audio codec from runtime environment.
///
/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` or legacy `LESAVKA_AUDIO_CODEC`.
/// Output: Opus by default, PCM when explicitly requested. Why: Opus should be
/// the optimized path, but operators need an immediate known-good fallback.
/// Output: PCM by default, Opus when explicitly requested. Why: Opus is still
/// under active calibration; the launcher must boot into the known-good route.
#[must_use]
pub fn requested_upstream_audio_codec_from_env() -> UpstreamAudioCodec {
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())
.as_deref()
.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.
@ -61,7 +61,7 @@ impl OpusPacketEncoder {
appsrc name=src is-live=true block=false format=time \
caps=audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
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";
let pipeline: gst::Pipeline = gst::parse::launch(desc)?.downcast().expect("pipeline");
let appsrc = pipeline
@ -184,12 +184,12 @@ mod tests {
use lesavka_common::lesavka::AudioEncoding;
#[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_LEGACY_ENV, None::<&str>, || {
assert_eq!(
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)
}
}

View File

@ -340,7 +340,10 @@ impl CameraCapture {
.arg(capture_fps.to_string())
.arg("-video_size")
.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("-i").arg(dev_label);

View File

@ -132,13 +132,6 @@ impl CameraCapture {
true
}
#[cfg(not(coverage))]
fn gstreamer_hevc_hardware_encoder_available() -> bool {
["nvh265enc", "vah265enc", "vaapih265enc", "v4l2h265enc"]
.iter()
.any(|name| buildable_encoder(name))
}
#[cfg(not(coverage))]
fn ffmpeg_hevc_nvenc_available() -> bool {
Command::new("ffmpeg")
@ -160,10 +153,7 @@ impl CameraCapture {
Self::ffmpeg_hevc_nvenc_available()
}
Some("gstreamer" | "gst") => false,
_ => {
!Self::gstreamer_hevc_hardware_encoder_available()
&& Self::ffmpeg_hevc_nvenc_available()
}
_ => Self::ffmpeg_hevc_nvenc_available(),
}
}

View File

@ -94,8 +94,8 @@ impl WebcamTransport {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpstreamAudioTransport {
#[default]
Opus,
#[default]
Pcm,
}
@ -112,8 +112,8 @@ impl UpstreamAudioTransport {
/// Parse a GTK row id back into an upstream microphone transport.
///
/// Inputs: compact id or familiar operator alias. Output: a supported
/// transport. Why: Opus is now the optimized path, but PCM must remain
/// one click away as the known-good audio fallback.
/// transport. Why: PCM is the known-good route while Opus remains
/// explicitly selectable for calibration and bandwidth experiments.
pub fn from_id(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"opus" | "compressed" => Some(Self::Opus),

View File

@ -439,10 +439,10 @@ fn webcam_transport_combo_tracks_selected_upstream_codec() {
}
#[test]
fn audio_transport_model_defaults_to_compressed_with_raw_fallback() {
fn audio_transport_model_defaults_to_pcm_with_compressed_opt_in() {
assert_eq!(
UpstreamAudioTransport::default(),
UpstreamAudioTransport::Opus
UpstreamAudioTransport::Pcm
);
assert_eq!(UpstreamAudioTransport::Opus.as_id(), "opus");
assert_eq!(UpstreamAudioTransport::Opus.label(), "Opus");

View File

@ -39,6 +39,18 @@
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 {
if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
@ -78,6 +90,14 @@
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 {
let mut candidate = root.join(format!("{stem}.{ext}"));
if !candidate.exists() {
@ -280,7 +300,7 @@
pane.clip_button.connect_clicked(move |_| {
let root = {
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,
Err(err) => {
widgets
@ -321,6 +341,7 @@
let record_button = pane.record_button.clone();
record_button.connect_clicked(move |button| {
if save_state.borrow().timer.is_some() {
button.remove_css_class("recording-active");
let finalize_rx = {
let mut state = save_state.borrow_mut();
if let Some(timer) = state.timer.take() {
@ -352,6 +373,7 @@
{
Ok(Ok(output)) => {
button.set_sensitive(true);
button.remove_css_class("recording-active");
button.set_label("Record");
widgets.status_label.set_text(&format!(
"{} recording saved to {}.",
@ -362,6 +384,7 @@
}
Ok(Err(err)) => {
button.set_sensitive(true);
button.remove_css_class("recording-active");
button.set_label("Record");
widgets.status_label.set_text(&format!(
"{} recording stop failed: {err}",
@ -372,6 +395,7 @@
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
button.set_sensitive(true);
button.remove_css_class("recording-active");
button.set_label("Record");
widgets.status_label.set_text(&format!(
"{} recording stop failed: recording worker disconnected.",
@ -389,7 +413,7 @@
};
let root = {
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,
Err(err) => {
widgets
@ -460,6 +484,7 @@
);
save_state.borrow_mut().timer = Some(timer);
button.set_sensitive(true);
button.add_css_class("recording-active");
button.set_label("Stop");
widgets.status_label.set_text(&format!(
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",

View File

@ -224,7 +224,7 @@
let webcam_transport_combo = gtk::ComboBoxText::new();
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.set_active_id(Some(state.effective_webcam_transport().as_id()));
@ -236,14 +236,14 @@
let upstream_audio_transport_combo = gtk::ComboBoxText::new();
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.set_active_id(Some(state.upstream_audio_transport.as_id()));
upstream_audio_transport_combo.set_sensitive(true);
upstream_audio_transport_combo.set_size_request(88, -1);
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);

View File

@ -161,6 +161,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stabilize_button(&clip_button, 66);
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
let record_button = gtk::Button::with_label("Record");
record_button.add_css_class("media-toggle");
stabilize_button(&record_button, 78);
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
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);
breakout_row.set_hexpand(true);
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(&capture_actions, 1, 1, 1, 1);
controls_grid.attach(&action_button, 2, 1, 1, 1);
controls_grid.attach(&capture_actions, 1, 1, 2, 1);
footer_shell.append(&controls_grid);
root.append(&footer_shell);

View File

@ -179,6 +179,11 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
border-color: rgba(96, 214, 126, 0.46);
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 {
opacity: 0.7;
}

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.22"
version = "0.22.23"
edition = "2024"
build = "build.rs"

View File

@ -12,7 +12,7 @@ pub const PCM_FRAME_DURATION_US: u32 = 20_000;
pub const OPUS_SAMPLE_RATE: u32 = 48_000;
pub const OPUS_CHANNELS: u32 = 2;
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.
///
@ -95,9 +95,9 @@ impl AudioTransportProfile {
/// 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
/// keep latency bounded while still giving the codec enough lookahead.
/// keep latency bounded while preserving enough quality for live speech.
#[must_use]
pub const fn opus_voice() -> Self {
Self {

View File

@ -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_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_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_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 |

View File

@ -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_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_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_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.22"
version = "0.22.23"
edition = "2024"
autobins = false

View File

@ -35,7 +35,7 @@ impl OpusPacketDecoder {
let desc = "\
appsrc name=src is-live=true block=false format=time \
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 ! \
audio/x-raw,format=S16LE,layout=interleaved,channels=2,rate=48000 ! \
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 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>> {
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=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";
let pipeline: gst::Pipeline = gst::parse::launch(desc).ok()?.downcast().ok()?;
let appsrc = pipeline
@ -207,7 +213,7 @@ mod tests {
pipeline.set_state(gst::State::Playing).ok()?;
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() {
let pts = gst::ClockTime::from_useconds(index * 20_000);
meta.set_pts(Some(pts));
@ -226,4 +232,17 @@ mod tests {
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
}
}

View File

@ -5,8 +5,8 @@
// Targets: `client/src/input/microphone.rs`,
// `client/src/app/uplink_media/uplink_queue_metadata.rs`, and
// `scripts/install/client.sh`.
// Why: Opus is now the optimized microphone transport, while raw PCM remains
// one click away as the known-good fallback.
// Why: Opus remains an optional compressed microphone transport, while raw PCM
// is the default known-good fallback.
const MICROPHONE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
@ -53,9 +53,20 @@ fn client_microphone_path_preserves_pcm_fallback_and_opus_selection() {
);
}
assert!(
AUDIO_CODEC.contains("mark_packet_opus") && AUDIO_CODEC.contains("Opus by default"),
"client Opus encoder should stamp packets while documenting the default"
AUDIO_CODEC.contains("mark_packet_opus") && AUDIO_CODEC.contains("PCM by 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 [
"pending_packets: VecDeque<AudioPacket>",
"take_pending_packet(sample_pts_us)",

View File

@ -164,6 +164,9 @@ mod camera_include_contract {
for expected in [
"spawn_ffmpeg_raw_preview_tap",
"-filter_complex",
"CameraSourceProfile::Mjpeg | CameraSourceProfile::AutoDecode",
"-input_format",
"mjpeg",
"[vencsrc]format=nv12[vencout];[vprevsrc]format=rgba[vprevout]",
".arg(\"[vprevout]\")",
".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]
#[cfg(coverage)]
fn camera_bus_logger_coverage_stub_is_non_blocking() {

View File

@ -38,11 +38,15 @@ fn uac_sink_remains_raw_pcm_and_guards_compressed_packets() {
for expected in [
"OpusPacketDecoder",
"opusdec",
"plc=false",
"min-latency=0",
"audio/x-opus",
"audio/x-raw",
"pending_packets: VecDeque<AudioPacket>",
"take_pending_packet(sample_pts_us)",
"push_pending_packet(&mut self.pending_packets",
"audio-type=restricted-lowdelay",
"sine_pcm_packet",
] {
assert!(
OPUS_DECODE.contains(expected),
@ -58,7 +62,7 @@ fn opus_packets_are_detected_as_compressed_before_uac_handoff() {
sample_rate: 48_000,
channels: 2,
frame_duration_us: 20_000,
data: vec![0x55; 160],
data: vec![0x55; 240],
..AudioPacket::default()
};

View File

@ -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_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_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-codec",
"LESAVKA_CLIENT_RCT_START_DELAY_SECONDS=${LESAVKA_CLIENT_RCT_START_DELAY_SECONDS:-0}",

View File

@ -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.channels, opus.channels);
assert!(
opus.expected_payload_bytes() <= 200,
"64 kbps, 20 ms Opus packets should stay near 160 bytes"
opus.expected_payload_bytes() <= 260,
"96 kbps, 20 ms Opus packets should stay near 240 bytes"
);
assert!(
pcm.expected_payload_bytes() >= opus.expected_payload_bytes() * 20,
"Opus should remove at least 95% of raw PCM uplink byte pressure"
pcm.expected_payload_bytes() >= opus.expected_payload_bytes() * 12,
"Opus should remove over 90% of raw PCM uplink byte pressure"
);
}

View File

@ -52,7 +52,7 @@ fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
}
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.set_active_id(Some(state.effective_webcam_transport().as_id()));",
"webcam_transport_combo.set_sensitive(true);",

View File

@ -133,15 +133,16 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
< source_index("header_row.append(&capture_label);")
);
assert!(
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
< source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
source_index("controls_grid.attach(&capture_row, 1, 0, 1, 1);")
< source_index("controls_grid.attach(&action_button, 2, 0, 1, 1);")
);
assert!(
source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
< source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);")
source_index("controls_grid.attach(&breakout_row, 0, 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 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("capture_actions.append(&clip_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!(
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!(

View File

@ -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("clip saved to"));
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("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("recover_usb_soft(&server_addr)"));
assert!(UI_SRC.contains("recover_uac_soft(&server_addr)"));

View File

@ -25,7 +25,7 @@ fn opus_profile_is_low_bandwidth_without_changing_capture_clock() {
assert_eq!(pcm.frame_duration_us, 20_000);
assert_eq!(opus.frame_duration_us, 20_000);
assert_eq!(pcm.expected_payload_bytes(), 3_840);
assert_eq!(opus.expected_payload_bytes(), 160);
assert_eq!(opus.expected_payload_bytes(), 240);
}
#[test]
@ -50,7 +50,7 @@ fn packet_and_bundle_metadata_can_select_opus_without_payload_guessing() {
sample_rate: 48_000,
channels: 2,
frame_duration_us: 20_000,
data: vec![0xaa; 160],
data: vec![0xaa; 240],
..AudioPacket::default()
};
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);
let mut packet = AudioPacket {
data: vec![0xaa; 160],
data: vec![0xaa; 240],
..AudioPacket::default()
};
mark_packet_opus(&mut packet);