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]]
|
||||
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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.22"
|
||||
version = "0.22.23"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -179,6 +179,11 @@ pub fn install_css(window: >k::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;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.22"
|
||||
version = "0.22.23"
|
||||
edition = "2024"
|
||||
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_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 {
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.22"
|
||||
version = "0.22.23"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()
|
||||
};
|
||||
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);",
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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)"));
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user