diff --git a/Cargo.lock b/Cargo.lock index 5fb23ac..d36c51d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index a7df3d9..540d1be 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.22" +version = "0.22.23" edition = "2024" [dependencies] diff --git a/client/src/input/audio_codec.rs b/client/src/input/audio_codec.rs index 74c324b..9abe3fd 100644 --- a/client/src/input/audio_codec.rs +++ b/client/src/input/audio_codec.rs @@ -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 { + 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> { + 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::() + .ok()?; + let appsink = pipeline + .by_name("sink")? + .downcast::() + .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) + } } diff --git a/client/src/input/camera/capture_pipeline.rs b/client/src/input/camera/capture_pipeline.rs index 69fde96..9b1ccdd 100644 --- a/client/src/input/camera/capture_pipeline.rs +++ b/client/src/input/camera/capture_pipeline.rs @@ -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); diff --git a/client/src/input/camera/encoder_selection.rs b/client/src/input/camera/encoder_selection.rs index 98669f5..1d10570 100644 --- a/client/src/input/camera/encoder_selection.rs +++ b/client/src/input/camera/encoder_selection.rs @@ -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(), } } diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index f585844..d9d95d0 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -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 { match raw.trim().to_ascii_lowercase().as_str() { "opus" | "compressed" => Some(Self::Opus), diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index df34273..889c4dd 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -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"); diff --git a/client/src/launcher/ui/eye_capture_bindings.rs b/client/src/launcher/ui/eye_capture_bindings.rs index f971bed..502c95d 100644 --- a/client/src/launcher/ui/eye_capture_bindings.rs +++ b/client/src/launcher/ui/eye_capture_bindings.rs @@ -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 { + 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.", diff --git a/client/src/launcher/ui_components/build_device_controls.rs b/client/src/launcher/ui_components/build_device_controls.rs index f6ecb69..f14d151 100644 --- a/client/src/launcher/ui_components/build_device_controls.rs +++ b/client/src/launcher/ui_components/build_device_controls.rs @@ -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); diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index 130f7ea..50de55e 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -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); diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index 514fb2a..388e591 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -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; } diff --git a/common/Cargo.toml b/common/Cargo.toml index d8d8e3e..8450673 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.22" +version = "0.22.23" edition = "2024" build = "build.rs" diff --git a/common/src/audio_transport.rs b/common/src/audio_transport.rs index 4044fa3..df8f994 100644 --- a/common/src/audio_transport.rs +++ b/common/src/audio_transport.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 { diff --git a/docs/operational-env.md b/docs/operational-env.md index b3c7391..2f48077 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -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 | diff --git a/scripts/manual/run_client_to_rct_transport_probe.sh b/scripts/manual/run_client_to_rct_transport_probe.sh index c6209fb..89fb5be 100755 --- a/scripts/manual/run_client_to_rct_transport_probe.sh +++ b/scripts/manual/run_client_to_rct_transport_probe.sh @@ -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} diff --git a/server/Cargo.toml b/server/Cargo.toml index 10d60a3..dd04156 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.22" +version = "0.22.23" edition = "2024" autobins = false diff --git a/server/src/audio/opus_decode.rs b/server/src/audio/opus_decode.rs index a945a0f..a81f801 100644 --- a/server/src/audio/opus_decode.rs +++ b/server/src/audio/opus_decode.rs @@ -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> { 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 { + 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 + } } diff --git a/tests/compatibility/client/audio/client_opus_transport_contract.rs b/tests/compatibility/client/audio/client_opus_transport_contract.rs index a3fb50d..675f421 100644 --- a/tests/compatibility/client/audio/client_opus_transport_contract.rs +++ b/tests/compatibility/client/audio/client_opus_transport_contract.rs @@ -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", "take_pending_packet(sample_pts_us)", diff --git a/tests/contract/client/input/camera/client_camera_include_contract.rs b/tests/contract/client/input/camera/client_camera_include_contract.rs index b7fe83e..652fd7a 100644 --- a/tests/contract/client/input/camera/client_camera_include_contract.rs +++ b/tests/contract/client/input/camera/client_camera_include_contract.rs @@ -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() { diff --git a/tests/contract/server/audio/server_opus_uac_contract.rs b/tests/contract/server/audio/server_opus_uac_contract.rs index ebd8b14..02a46a3 100644 --- a/tests/contract/server/audio/server_opus_uac_contract.rs +++ b/tests/contract/server/audio/server_opus_uac_contract.rs @@ -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", "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() }; diff --git a/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs b/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs index da33b5f..a407a5f 100644 --- a/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs +++ b/tests/manual/client/sync_probe/client_rct_transport_probe_contract.rs @@ -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}", diff --git a/tests/performance/client/uplink/opus_transport_budget_contract.rs b/tests/performance/client/uplink/opus_transport_budget_contract.rs index 2a77097..a23f588 100644 --- a/tests/performance/client/uplink/opus_transport_budget_contract.rs +++ b/tests/performance/client/uplink/opus_transport_budget_contract.rs @@ -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" ); } diff --git a/tests/ui/client/launcher/client_codec_transport_ui_contract.rs b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs index 672c15e..b251bfe 100644 --- a/tests/ui/client/launcher/client_codec_transport_ui_contract.rs +++ b/tests/ui/client/launcher/client_codec_transport_ui_contract.rs @@ -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);", diff --git a/tests/ui/client/launcher/client_launcher_layout_contract.rs b/tests/ui/client/launcher/client_launcher_layout_contract.rs index 22bb953..4a4e1e8 100644 --- a/tests/ui/client/launcher/client_launcher_layout_contract.rs +++ b/tests/ui/client/launcher/client_launcher_layout_contract.rs @@ -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!( diff --git a/tests/ui/client/launcher/client_launcher_runtime_contract.rs b/tests/ui/client/launcher/client_launcher_runtime_contract.rs index 52c9072..8a20162 100644 --- a/tests/ui/client/launcher/client_launcher_runtime_contract.rs +++ b/tests/ui/client/launcher/client_launcher_runtime_contract.rs @@ -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)")); diff --git a/tests/unit/common/audio/common_audio_transport_unit.rs b/tests/unit/common/audio/common_audio_transport_unit.rs index 523356f..78448d0 100644 --- a/tests/unit/common/audio/common_audio_transport_unit.rs +++ b/tests/unit/common/audio/common_audio_transport_unit.rs @@ -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);